Mail mark-all-read + iframe fixes; Contacts detail/selection/groups + resizable panels

Mail:
- New "More" (⋯) menu in the list sorting bar with "Mark all as read": POST
  /threads/mark-read marks every unread thread in the current view (unified /
  folder:<special> / account:<uuid>) as \Seen, pushes + publishes updates.
- Message iframe: no longer shows a spurious vertical scrollbar (display:block,
  getBoundingClientRect+ceil sizing, overflow-y:hidden on the doc) and has
  rounded corners.
- Deep links from Contacts: /mail?compose=1&to= opens a maximized prefilled
  composer; /mail?q= prefills the search box.

Contacts:
- Detail pane shows all populated fields (work subtitle, phones, address, urls,
  nickname, birthday). Company name is used as the display name when there's no
  personal name (existing rows too). Editable "Add notes".
- Selection: checkbox reveals on avatar hover only; Groups/More bulk menus in
  select mode; "N contacts selected" panel; filled yellow VIP star in list +
  detail; pagination bottom bar; Lato pinned on form controls.
- Groups: "+" on the GROUPS header and "Create new" in the group menus (bulk
  add/remove via POST /groups/{id}/add|remove, ownership-guarded).
- Rounded Mail-style list rows; removed the ⋯ button from the search bar;
  Compose→Mail, Find-all-conversations→Mail search; Message button removed;
  delete confirmation; resizable sidebar + list/detail split.

Calendar: the right panel (agenda / search) is now resizable.

Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
2026-08-02 21:34:22 +02:00
co-authored by Claude Opus 4.8
parent a393939628
commit 421b44a8ab
10 changed files with 455 additions and 34 deletions
@@ -22,7 +22,7 @@ from sqlalchemy import text
from sqlalchemy.ext.asyncio import AsyncSession
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
from webmail_plugin_contacts.schemas import ContactIn, GroupIn
from webmail_plugin_contacts.schemas import ContactIn, GroupIn, MembersIn
_log = logging.getLogger("plg.contacts")
_SDK = sdk("contacts")
@@ -225,6 +225,53 @@ async def delete_group(group_id: uuid.UUID, user_id: CurrentUserId) -> dict[str,
return {"status": "ok"}
async def _require_own_group(s: AsyncSession, group_id: uuid.UUID) -> None:
"""404 unless the group belongs to the (RLS-scoped) caller."""
exists = (
await s.execute(
text("SELECT 1 FROM plg_contacts_groups WHERE id = :g"), {"g": str(group_id)}
)
).first()
if exists is None:
raise HTTPException(status_code=404, detail="group_not_found")
@router.post("/groups/{group_id}/add", dependencies=[Depends(require_csrf)])
async def group_add(
group_id: uuid.UUID, body: MembersIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await _require_own_group(s, group_id)
for cid in body.ids:
await s.execute(
text(
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
"SELECT :u, :g, :c WHERE EXISTS "
"(SELECT 1 FROM plg_contacts_contacts WHERE id = :c) "
"ON CONFLICT DO NOTHING"
),
{"u": str(user_id), "g": str(group_id), "c": cid},
)
return {"status": "ok"}
@router.post("/groups/{group_id}/remove", dependencies=[Depends(require_csrf)])
async def group_remove(
group_id: uuid.UUID, body: MembersIn, user_id: CurrentUserId
) -> dict[str, str]:
async with _SDK.session(user_id) as s:
await _require_own_group(s, group_id)
for cid in body.ids:
await s.execute(
text(
"DELETE FROM plg_contacts_group_members "
"WHERE group_id = :g AND contact_id = :c"
),
{"g": str(group_id), "c": cid},
)
return {"status": "ok"}
# --- contacts ----------------------------------------------------------------
@router.get("/contacts")
@@ -57,3 +57,7 @@ class ContactIn(BaseModel):
class GroupIn(BaseModel):
name: str
class MembersIn(BaseModel):
ids: list[str] = [] # contact ids to add to / remove from a group
@@ -32,6 +32,7 @@ const IC_MAPPIN = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" s
const IC_LINK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg>`;
const IC_SEARCH = `<svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg>`;
const CHECK_SVG = `<svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-7"/></svg>`;
const PAGE_SIZE = 50; // contacts per page (matches Mail)
// A Mail-style square selection checkbox (unchecked = outline, checked = filled).
const checkbox = (on) => `<span class="ct-check${on ? " on" : ""}">${on ? CHECK_SVG : ""}</span>`;
const IC_PENCIL = IC_COMPOSE;
@@ -65,6 +66,8 @@ function shortDate(iso) {
const CSS = `
.ct-app{display:flex;height:100vh;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
/* Form controls don't inherit font-family by default — pin them to Lato too. */
.ct-app input,.ct-app select,.ct-app textarea,.ct-app button{font-family:inherit}
.ct-app .wm-side-item.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
.ct-app .wm-side-item .ct-dico{margin-top:0}
/* collapsed icon-only rail (matches Mail's 60px rail) */
@@ -79,9 +82,12 @@ const CSS = `
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.ct-railnew:hover{opacity:.9}
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
.ct-listcol{flex:0 0 46%;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-listcol{flex:0 0 auto;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
.ct-bar{display:flex;align-items:center;gap:8px;height:49px;padding:0 14px 0 18px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
.ct-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
.ct-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.ct-resizer:hover::after{background:#3b82f6}
.ct-bar{display:flex;align-items:center;gap:8px;height:49px;padding:0 14px 0 20px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
.ct-bar .sp{flex:1}
/* Mail-style selection checkbox (bar "select all" + per-row on avatar-hover) */
.ct-check{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;border:2px solid rgb(var(--app-faint));color:#fff;box-sizing:border-box;transition:background .1s,border-color .1s}
@@ -93,8 +99,8 @@ const CSS = `
.ct-avshow{position:absolute;inset:0}
.ct-avcheck{position:absolute;inset:0;display:none;align-items:center;justify-content:center;border:0;background:none;cursor:pointer;padding:0}
.ct-avcheck:hover .ct-check{border-color:rgb(var(--app-muted))}
.ct-app:not(.selmode) .ct-row:hover .ct-avshow{visibility:hidden}
.ct-app:not(.selmode) .ct-row:hover .ct-avcheck{display:flex}
.ct-app:not(.selmode) .ct-avwrap:hover .ct-avshow{visibility:hidden}
.ct-app:not(.selmode) .ct-avwrap:hover .ct-avcheck{display:flex}
.ct-app.selmode .ct-avshow{visibility:hidden}
.ct-app.selmode .ct-avcheck{display:flex}
.ct-searchbox{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
@@ -108,13 +114,20 @@ const CSS = `
.ct-barbtn:hover{background:rgb(var(--app-hover))}
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
.ct-list{flex:1;min-height:0;overflow:auto}
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 18px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 18px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5)}
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 20px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 12px;margin:2px 8px;border-radius:8px;cursor:pointer}
.ct-row:hover{background:rgb(var(--app-hover))}
.ct-row.sel{background:rgb(var(--app-active))}
.ct-rowmain{min-width:0;flex:1}
.ct-rowname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-rowname{display:flex;align-items:center;gap:5px;font-weight:600}
.ct-nametext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ct-vipstar{flex:0 0 auto;display:inline-flex}
.ct-vipstar svg{width:13px;height:13px;fill:#eab308;stroke:#eab308}
.ct-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-pager{display:flex;align-items:center;justify-content:flex-end;gap:12px;height:44px;padding:0 14px;border-top:1px solid rgb(var(--app-border));flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px}
.ct-pagerbtn{display:inline-flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:5px;border-radius:5px}
.ct-pagerbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-pagerbtn:disabled{opacity:.4;pointer-events:none}
.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
.ct-emptyill{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:rgb(var(--app-faint))}
.ct-emptyill img{width:224px;max-width:55%}
@@ -127,12 +140,20 @@ const CSS = `
.ct-sub{display:flex;align-items:center;gap:6px;color:rgb(var(--app-muted));font-size:14px;margin:-6px 0 12px}
.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
.ct-star.on{color:#eab308}
.ct-star.on svg{fill:#eab308}
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
.ct-act{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid rgb(var(--app-border));border-radius:7px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px;background:rgb(var(--app-surface))}
.ct-act:hover{background:rgb(var(--app-hover))}
.ct-sec{margin-top:26px}
.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
.ct-secrow{display:flex;align-items:center;justify-content:space-between}
.ct-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 6px;border-radius:4px}
.ct-secrow:hover .ct-secadd{opacity:1}
.ct-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-drow.ct-clickable{cursor:pointer}
.ct-drow.ct-clickable:hover{background:rgb(var(--app-hover) / .5)}
.ct-menu .ct-menusep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
.ct-dico{color:rgb(var(--app-muted));margin-top:2px}
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
.ct-dval{margin-top:1px}
@@ -189,10 +210,14 @@ export default function mount(el, ctx) {
mode: "view", // view | edit | new
mail: null, // { loading, threads }
sortAsc: true,
page: 0,
checked: new Set(), // multi-selected contact ids (Mail-style)
confirmDelete: false,
editingNotes: false,
newGroup: null, // null | { add: [contactId,...] } — create-group modal
menu: null, // "groups" | "more" | null
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
listW: Number(localStorage.getItem("ct.listW")) || 480,
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
menuOpen: false,
me: "", quota: "",
@@ -209,6 +234,24 @@ export default function mount(el, ctx) {
render();
}
// Create the group named in the modal; optionally add the contacts it was opened for.
async function createGroup() {
if (!state.newGroup) return;
const inp = el.querySelector("[data-ng-name]");
const name = inp ? inp.value.trim() : "";
if (!name) return;
const res = await api.pluginFetch("/groups", { method: "POST", body: JSON.stringify({ name }) });
const add = state.newGroup.add || [];
if (add.length) {
await api.pluginFetch(`/groups/${res.id}/add`, { method: "POST", body: JSON.stringify({ ids: add }) });
}
state.newGroup = null;
await loadGroups();
await loadContacts();
if (state.selectedId) await loadDetail(state.selectedId);
render();
}
// ---- data loading --------------------------------------------------------
async function loadGroups() {
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
@@ -240,6 +283,7 @@ export default function mount(el, ctx) {
state.selectedId = id;
state.mode = "view";
state.menu = null;
state.editingNotes = false;
state.checked.clear(); // opening a contact exits multi-select mode
await loadDetail(id);
render();
@@ -279,7 +323,7 @@ export default function mount(el, ctx) {
${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
</div>
<div class="wm-side-section">Groups</div>
<div class="wm-side-section ct-secrow"><span>Groups</span><button class="ct-secadd" data-act="new-group" title="Create group">+</button></div>
<div class="wm-side-nav">
${state.groups.map(grp).join("")}
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
@@ -316,10 +360,15 @@ export default function mount(el, ctx) {
// ---- middle: contact list ------------------------------------------------
function listPanel() {
const rows = [...state.contacts].sort((a, b) => {
const all = [...state.contacts].sort((a, b) => {
const r = (a.display_name || a.primary_email || "").localeCompare(b.display_name || b.primary_email || "", undefined, { sensitivity: "base" });
return state.sortAsc ? r : -r;
});
const total = all.length;
const maxPage = Math.max(0, Math.ceil(total / PAGE_SIZE) - 1);
if (state.page > maxPage) state.page = maxPage;
const start = state.page * PAGE_SIZE;
const rows = all.slice(start, start + PAGE_SIZE);
let html = "";
let letter = null;
for (const c of rows) {
@@ -334,12 +383,13 @@ export default function mount(el, ctx) {
<button class="ct-avcheck" data-check="${c.id}" aria-label="Select contact">${checkbox(ck)}</button>
</div>
<div class="ct-rowmain">
<div class="ct-rowname">${esc(name)}</div>
<div class="ct-rowname"><span class="ct-nametext">${esc(name)}</span>${c.starred ? `<span class="ct-vipstar" title="VIP">${IC_STAR}</span>` : ""}</div>
<div class="ct-rowmail">${esc(c.primary_email || "")}</div>
</div></div>`;
}
if (!rows.length) html = `<div class="ct-empty">No contacts${state.q ? " match your search" : " yet"}.</div>`;
const allChecked = rows.length > 0 && rows.every((c) => state.checked.has(c.id));
const arrow = (d) => `<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d="${d === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4"}"/></svg>`;
return `
<div class="ct-bar">
<div class="ct-selall"><button class="ct-selallbtn" data-act="select-all" aria-label="Select all">${checkbox(allChecked)}</button></div>
@@ -349,16 +399,26 @@ export default function mount(el, ctx) {
${state.q ? `<button class="ct-searchclear" data-act="search-clear" title="Clear">✕</button>` : ""}
</div>
<button class="ct-iconbtn" data-act="sort" title="Sort">${IC_SORT}</button>
<button class="ct-iconbtn" data-act="more-list" title="More">${IC_MORE}</button>
</div>
<div class="ct-list">${html}</div>`;
<div class="ct-list">${html}</div>
<div class="ct-pager">
<span>${total ? start + 1 : 0}–${Math.min(start + PAGE_SIZE, total)} of ${total}</span>
<div style="display:flex;gap:2px">
<button class="ct-pagerbtn" data-act="page-prev" title="Previous" ${state.page === 0 ? "disabled" : ""}>${arrow("left")}</button>
<button class="ct-pagerbtn" data-act="page-next" title="Next" ${start + PAGE_SIZE >= total ? "disabled" : ""}>${arrow("right")}</button>
</div>
</div>`;
}
function confirmModal() {
const n = state.checked.size;
const bulk = n > 0;
const name = state.detail ? (state.detail.display_name || state.detail.default_email || "this contact") : "this contact";
const title = bulk ? `Delete ${n} contact${n === 1 ? "" : "s"}` : "Delete contact";
const msg = bulk ? `Delete ${n} selected contact${n === 1 ? "" : "s"}? This can't be undone.` : `Delete “${esc(name)}”? This can't be undone.`;
return `<div class="ct-modalbg" data-act="confirm-cancel"><div class="ct-modal" data-stop>
<h3>Delete contact</h3>
<p>Delete “${esc(name)}”? This can't be undone.</p>
<h3>${title}</h3>
<p>${msg}</p>
<div class="row">
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
<button class="ct-danger" data-act="confirm-delete">Delete</button>
@@ -366,13 +426,28 @@ export default function mount(el, ctx) {
</div></div>`;
}
function newGroupModal() {
return `<div class="ct-modalbg" data-act="ng-cancel"><div class="ct-modal" data-stop>
<h3>Create group</h3>
<input class="ct-in" data-ng-name placeholder="Group name" style="margin-top:12px"/>
<div class="row">
<button class="ct-ghostbtn" data-act="ng-cancel">Cancel</button>
<button class="ct-primary" data-act="ng-create">Create</button>
</div>
</div></div>`;
}
// ---- right: detail / edit ------------------------------------------------
function detailBar() {
if (state.mode === "edit" || state.mode === "new") {
return `<div class="ct-bar"><button class="ct-primary" data-act="save">Save</button><button class="ct-ghostbtn" data-act="cancel">Cancel</button></div>`;
}
if (state.checked.size > 0) {
return `<div class="ct-bar"><button class="ct-barbtn" data-act="clear-sel">Clear selection</button></div>`;
return `<div class="ct-bar">
<button class="ct-barbtn" data-act="clear-sel">Clear selection</button>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-groups">${IC_GROUP} Groups ${CHEV}</button>${state.menu === "groups" ? bulkGroupsMenu() : ""}</div>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-more">More ${CHEV}</button>${state.menu === "more" ? bulkMoreMenu() : ""}</div>
</div>`;
}
const disabled = state.detail ? "" : "opacity:.4;pointer-events:none";
return `<div class="ct-bar" style="${disabled}">
@@ -383,11 +458,27 @@ export default function mount(el, ctx) {
}
function groupsMenu() {
const mine = new Set((state.detail.groups || []).map((g) => g.id));
return `<div class="ct-menu" data-stop>${state.groups.map((g) => `<label><input type="checkbox" data-toggle-group="${g.id}" ${mine.has(g.id) ? "checked" : ""}/> ${esc(g.name)}</label>`).join("") || `<div class="ct-dlabel" style="padding:6px 9px">No groups yet</div>`}</div>`;
const items = state.groups.map((g) => `<label><input type="checkbox" data-toggle-group="${g.id}" ${mine.has(g.id) ? "checked" : ""}/> ${esc(g.name)}</label>`).join("");
return `<div class="ct-menu" data-stop>${items}${items ? `<div class="ct-menusep"></div>` : ""}<button data-act="new-group-add">${PLUS_ICON} Create new</button></div>`;
}
function moreMenu() {
return `<div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div>`;
}
// Bulk group menu: a box is ticked when EVERY selected contact is in that group;
// toggling adds/removes the whole selection.
function bulkGroupsMenu() {
const sel = [...state.checked];
const byId = new Map(state.contacts.map((c) => [c.id, c]));
const inGroup = (id, gid) => ((byId.get(id) || {}).groups || []).some((x) => x.id === gid);
const items = state.groups.map((g) => {
const allIn = sel.length > 0 && sel.every((id) => inGroup(id, g.id));
return `<label><input type="checkbox" data-bulk-group="${g.id}" ${allIn ? "checked" : ""}/> ${esc(g.name)}</label>`;
}).join("");
return `<div class="ct-menu" data-stop>${items}${items ? `<div class="ct-menusep"></div>` : ""}<button data-act="new-group-bulk">${PLUS_ICON} Create new</button></div>`;
}
function bulkMoreMenu() {
return `<div class="ct-menu" data-stop><button data-act="bulk-delete">Delete contacts</button></div>`;
}
function detailPanel() {
if (state.mode === "edit" || state.mode === "new") return editForm();
@@ -421,7 +512,17 @@ export default function mount(el, ctx) {
(c.urls || []).forEach((u) => { if (u.value) details += drow(IC_LINK, cap(u.type || "website"), `<a href="${esc(withHttp(u.value))}" target="_blank" rel="noopener noreferrer">${esc(u.value)}</a>`); });
if (c.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname));
if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday)));
details += `<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>`;
if (state.editingNotes) {
details += `<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div style="flex:1">
<div class="ct-dlabel">Notes</div>
<textarea class="ct-in" data-notes rows="3" style="margin-top:4px">${esc(c.notes || "")}</textarea>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="ct-primary" data-act="save-notes">Save</button>
<button class="ct-ghostbtn" data-act="cancel-notes">Cancel</button>
</div></div></div>`;
} else {
details += `<div class="ct-drow ct-clickable" data-act="edit-notes"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>`;
}
return `<div class="ct-detail"><div class="ct-card">
<div class="ct-cardhead">
@@ -431,7 +532,6 @@ export default function mount(el, ctx) {
${work ? `<div class="ct-sub">${IC_WORK} ${esc(work)}</div>` : ""}
<div class="ct-actions">
<button class="ct-act" data-act="call">${IC_PHONE} Call</button>
<button class="ct-act" data-act="message">${IC_CHAT} Message</button>
<button class="ct-act" data-act="compose">${IC_COMPOSE} Compose</button>
<button class="ct-act" data-act="invite">${IC_CAL} Invite</button>
</div>
@@ -582,10 +682,12 @@ export default function mount(el, ctx) {
const keepFocus = prev && document.activeElement === prev;
const caret = keepFocus ? prev.selectionStart : null;
el.innerHTML = `<div class="ct-app${state.checked.size ? " selmode" : ""}">
${state.leftCollapsed ? railPanel() : `<div class="wm-side ct-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div>`}
<div class="ct-listcol">${listPanel()}</div>
${state.leftCollapsed ? railPanel() : `<div class="wm-side ct-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="ct-resizer" data-lresizer></div>`}
<div class="ct-listcol" style="width:${state.listW}px;flex:0 0 auto">${listPanel()}</div>
<div class="ct-resizer" data-mresizer></div>
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
${state.confirmDelete ? confirmModal() : ""}
${state.newGroup ? newGroupModal() : ""}
</div>`;
wire();
if (keepFocus) {
@@ -597,12 +699,42 @@ export default function mount(el, ctx) {
function wire() {
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
const act = (name, fn) => on(`[data-act="${name}"]`, "click", fn);
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); reloadList(false); };
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false); };
on("[data-app]", "click", (e) => { const a = state.apps[Number(e.currentTarget.getAttribute("data-app"))]; state.menuOpen = false; if (a.to) ctx.navigate(a.to); else render(); });
act("menu", () => { state.menuOpen = !state.menuOpen; render(); });
act("left-toggle", () => { state.leftCollapsed = !state.leftCollapsed; try { localStorage.setItem("ct.leftCollapsed", state.leftCollapsed ? "1" : "0"); } catch { /* ignore */ } render(); });
act("theme", () => { const dark = document.documentElement.classList.toggle("dark"); try { localStorage.setItem("theme", dark ? "dark" : "light"); } catch { /* ignore */ } render(); });
// Draggable column dividers (sidebar + list/detail split), persisted like Mail.
const drag = (handle, targetSel, key, min, max, sign) => {
const h = el.querySelector(handle);
if (!h) return;
h.addEventListener("mousedown", (e) => {
e.preventDefault();
const target = el.querySelector(targetSel);
const startX = e.clientX;
const startW = target.offsetWidth;
const onMove = (ev) => {
const w = Math.max(min, Math.min(max, startW + sign * (ev.clientX - startX)));
target.style.width = `${w}px`;
state[key] = w;
};
const onUp = () => {
document.removeEventListener("mousemove", onMove);
document.removeEventListener("mouseup", onUp);
document.body.style.cursor = "";
document.body.style.userSelect = "";
try { localStorage.setItem(`ct.${key}`, String(state[key])); } catch { /* ignore */ }
};
document.addEventListener("mousemove", onMove);
document.addEventListener("mouseup", onUp);
document.body.style.cursor = "col-resize";
document.body.style.userSelect = "none";
});
};
drag("[data-lresizer]", ".ct-left", "leftW", 190, 440, 1);
drag("[data-mresizer]", ".ct-listcol", "listW", 320, 900, 1);
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcontacts"));
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
@@ -613,8 +745,10 @@ export default function mount(el, ctx) {
on("[data-group]", "click", (e) => {
state.group = e.currentTarget.getAttribute("data-group");
state.groupLabel = e.currentTarget.getAttribute("data-groupname");
state.filter = "group"; state.menuOpen = false; state.checked.clear(); reloadList(false);
state.filter = "group"; state.menuOpen = false; state.checked.clear(); state.page = 0; reloadList(false);
});
act("page-prev", () => { if (state.page > 0) { state.page--; render(); } });
act("page-next", () => { state.page++; render(); });
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
// Selection checkboxes (revealed on avatar-hover / in select mode) — Mail-style.
@@ -633,11 +767,12 @@ export default function mount(el, ctx) {
act("clear-sel", () => { state.checked.clear(); render(); });
on(".ct-search", "input", (e) => {
state.q = e.currentTarget.value;
state.page = 0;
if (searchTimer) clearTimeout(searchTimer);
searchTimer = setTimeout(runSearch, 250);
});
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; runSearch(); } });
act("search-clear", () => { state.q = ""; runSearch(); });
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; state.page = 0; runSearch(); } });
act("search-clear", () => { state.q = ""; state.page = 0; runSearch(); });
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; render(); });
@@ -647,13 +782,18 @@ export default function mount(el, ctx) {
act("save", () => saveForm());
act("star", async () => { const r = await api.pluginFetch(`/contacts/${state.selectedId}/star`, { method: "POST" }); state.detail.starred = r.starred; render(); });
act("call", () => { const p = state.detail.phones && state.detail.phones[0]; if (p) window.open(`tel:${p.value}`); });
act("message", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
act("compose", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
act("compose", () => {
const email = state.detail && state.detail.default_email;
ctx.navigate(email ? `/mail?compose=1&to=${encodeURIComponent(email)}` : "/mail");
});
act("invite", () => {
const email = state.detail && state.detail.default_email;
ctx.navigate(email ? `/p/calendar?guest=${encodeURIComponent(email)}` : "/p/calendar");
});
act("find-mail", () => ctx.navigate(`/mail/search/${encodeURIComponent('"' + state.detail.default_email + '"')}`));
act("find-mail", () => {
const email = state.detail && state.detail.default_email;
ctx.navigate(email ? `/mail?q=${encodeURIComponent(email)}` : "/mail");
});
// menus
act("menu-groups", (e) => { e.stopPropagation(); state.menu = state.menu === "groups" ? null : "groups"; render(); });
@@ -667,15 +807,57 @@ export default function mount(el, ctx) {
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
await loadGroups(); await loadDetail(state.selectedId); render();
});
// Bulk group add/remove for the whole selection.
on("[data-bulk-group]", "change", async (e) => {
const gid = e.currentTarget.getAttribute("data-bulk-group");
const path = e.currentTarget.checked ? "add" : "remove";
await api.pluginFetch(`/groups/${gid}/${path}`, { method: "POST", body: JSON.stringify({ ids: [...state.checked] }) });
await loadGroups(); await loadContacts(); render();
});
act("delete", () => { state.menu = null; state.confirmDelete = true; render(); });
act("bulk-delete", () => { state.menu = null; state.confirmDelete = true; render(); });
act("confirm-cancel", () => { state.confirmDelete = false; render(); });
act("confirm-delete", async () => {
state.confirmDelete = false;
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
if (state.checked.size > 0) {
for (const id of state.checked) await api.pluginFetch(`/contacts/${id}`, { method: "DELETE" });
state.checked.clear();
} else {
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
}
state.selectedId = null;
await reloadList(false);
});
// notes inline editor
act("edit-notes", () => {
state.editingNotes = true; render();
const ta = el.querySelector("[data-notes]");
if (ta) { ta.focus(); ta.setSelectionRange(ta.value.length, ta.value.length); }
});
act("cancel-notes", () => { state.editingNotes = false; render(); });
act("save-notes", async () => {
const ta = el.querySelector("[data-notes]");
const body = detailToBody(state.detail, (state.detail.groups || []).map((g) => g.id));
body.notes = ta ? ta.value : "";
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
state.editingNotes = false;
await loadDetail(state.selectedId);
render();
});
// create-group modal (left-panel +, and "Create new" in the group menus)
const openNewGroup = (add) => {
state.newGroup = { add }; state.menu = null; render();
const inp = el.querySelector("[data-ng-name]"); if (inp) inp.focus();
};
act("new-group", () => openNewGroup([]));
act("new-group-add", () => openNewGroup(state.selectedId ? [state.selectedId] : []));
act("new-group-bulk", () => openNewGroup([...state.checked]));
act("ng-cancel", () => { state.newGroup = null; render(); });
act("ng-create", () => createGroup());
on("[data-ng-name]", "keydown", (e) => { if (e.key === "Enter") { e.preventDefault(); createGroup(); } });
// edit form multi-rows
act("add-email", () => { const box = el.querySelector("[data-emails]"); const w = document.createElement("div"); w.innerHTML = emailRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-email]").addEventListener("click", () => row.remove()); box.appendChild(row); });
act("add-phone", () => { const box = el.querySelector("[data-phones]"); const w = document.createElement("div"); w.innerHTML = phoneRow(null); const row = w.firstElementChild; row.querySelector("[data-act=rm-phone]").addEventListener("click", () => row.remove()); box.appendChild(row); });