From 421b44a8abb8d42ce1ee9942d040bc2e867cff8d Mon Sep 17 00:00:00 2001 From: Lukas Remis Date: Sun, 2 Aug 2026 21:34:22 +0200 Subject: [PATCH] Mail mark-all-read + iframe fixes; Contacts detail/selection/groups + resizable panels MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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: / account:) 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 Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA --- backend/app/api/actions.py | 59 ++++- backend/app/api/schemas.py | 4 + .../src/components/thread/MessageBody.tsx | 29 ++- frontend/src/i18n/en.ts | 2 + frontend/src/lib/api.ts | 5 + frontend/src/routes/MailLayout.tsx | 69 ++++- .../webmail_plugin_calendar/ui/page.js | 30 ++- .../webmail_plugin_contacts/router.py | 49 +++- .../webmail_plugin_contacts/schemas.py | 4 + .../webmail_plugin_contacts/ui/page.js | 238 +++++++++++++++--- 10 files changed, 455 insertions(+), 34 deletions(-) diff --git a/backend/app/api/actions.py b/backend/app/api/actions.py index 8853bbe..b71d29a 100644 --- a/backend/app/api/actions.py +++ b/backend/app/api/actions.py @@ -10,7 +10,7 @@ from sqlalchemy import select from sqlalchemy.ext.asyncio import AsyncSession from app.api.errors import raise_api_error -from app.api.schemas import FlagChangeRequest, MoveRequest +from app.api.schemas import FlagChangeRequest, MarkReadRequest, MoveRequest from app.db import get_session from app.events import get_event_bus from app.identity.deps import CurrentUser, require_csrf @@ -86,6 +86,63 @@ async def thread_flags( return {"thread_id": str(thread_id), "changed": len(changed)} +@router.post("/threads/mark-read") +async def mark_all_read( + body: MarkReadRequest, user: CurrentUser, db: DbSession +) -> dict[str, object]: + """Mark every unread thread in the given view as read (adds \\Seen). Scopes the + same way as the thread list: unified, folder:, or account:.""" + view = body.view + conditions = [Thread.user_id == user.id, Thread.unread_count > 0] + if view.startswith("account:"): + try: + account_id = uuid.UUID(view.split(":", 1)[1]) + except ValueError: + raise_api_error(400, "bad_view", "invalid account id in view") + in_account_inbox = ( + select(Message.thread_id) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where( + Message.user_id == user.id, + Message.account_id == account_id, + Mailbox.special_use == "inbox", + ) + ) + conditions.append(Thread.id.in_(in_account_inbox)) + elif view.startswith("folder:"): + special = view.split(":", 1)[1] + in_folder = ( + select(Message.thread_id) + .join(Mailbox, Mailbox.id == Message.mailbox_id) + .where(Message.user_id == user.id, Mailbox.special_use == special) + ) + conditions.append(Thread.id.in_(in_folder)) + elif view != "unified": + raise_api_error(400, "bad_view", "unsupported view") + + thread_ids = list(await db.scalars(select(Thread.id).where(*conditions))) + if not thread_ids: + return {"changed": 0} + messages = list( + await db.scalars( + select(Message).where( + Message.thread_id.in_(thread_ids), Message.user_id == user.id + ) + ) + ) + add, remove = sanitize_flags(["\\Seen"]), sanitize_flags([]) + changed = [m for m in messages if _would_change(m, add, remove)] + await apply_flags_to_messages(db, messages, add, remove) + await db.commit() + await _push_and_publish(user.id, changed, add, remove) + bus = get_event_bus() + for tid in thread_ids: + await bus.publish( + "thread.updated", {"user_id": str(user.id), "thread_id": str(tid)} + ) + return {"changed": len(changed)} + + async def _move_thread( user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str ) -> None: diff --git a/backend/app/api/schemas.py b/backend/app/api/schemas.py index 5ea461f..f365dd4 100644 --- a/backend/app/api/schemas.py +++ b/backend/app/api/schemas.py @@ -76,6 +76,10 @@ class FlagChangeRequest(BaseModel): remove: list[str] = [] +class MarkReadRequest(BaseModel): + view: str = "unified" # unified | folder: | account: + + class MoveRequest(BaseModel): mailbox_id: uuid.UUID diff --git a/frontend/src/components/thread/MessageBody.tsx b/frontend/src/components/thread/MessageBody.tsx index ed4d082..8aaad7a 100644 --- a/frontend/src/components/thread/MessageBody.tsx +++ b/frontend/src/components/thread/MessageBody.tsx @@ -49,7 +49,24 @@ export function MessageBody({ // Collapse first so scrollHeight reflects the content, not the current frame // height — otherwise the frame can grow but never shrink (e.g. hiding a quote). frame.style.height = "0px"; - const h = doc.documentElement.scrollHeight; + const de = doc.documentElement; + const body = doc.body; + // Float-accurate content bottom (getBoundingClientRect keeps sub-pixels that + // scrollHeight would floor), combined with the integer scroll/offset heights. + const rectBottom = body ? body.getBoundingClientRect().bottom : 0; + let h = Math.ceil( + Math.max( + rectBottom, + de.scrollHeight, + de.offsetHeight, + body?.scrollHeight ?? 0, + body?.offsetHeight ?? 0, + ), + ); + // If the email overflows horizontally, its bottom scrollbar steals vertical + // space — leave room for it so the content doesn't get a vertical scrollbar too. + if (de.scrollWidth > de.clientWidth) h += 18; + h += 2; // final guard against any residual rounding frame.style.height = `${h}px`; setHeight(h); }, []); @@ -103,8 +120,16 @@ export function MessageBody({ const onLoad = () => { const doc = frameRef.current?.contentDocument; if (!doc) return; + // The frame is auto-sized to its content, so it must never scroll itself — the + // thread panel is the scroll container. Hiding the document's own vertical + // overflow removes any phantom scrollbar left by sub-pixel/line-box rounding + // (horizontal stays scrollable for genuinely wide emails). + doc.documentElement.style.overflowY = "hidden"; injectQuoteToggle(doc); resize(); + // Re-measure after the first paint settles (fonts/layout can shift the height + // by a hair, which is enough to leave a spurious scrollbar). + requestAnimationFrame(() => requestAnimationFrame(resize)); doc.addEventListener("click", handleFrameClick); // Re-measure as images finish loading (they change the content height). doc.querySelectorAll("img").forEach((img) => { @@ -154,7 +179,7 @@ export function MessageBody({ srcDoc={data} onLoad={onLoad} style={{ height }} - className="w-full border-0 bg-white" + className="block w-full rounded-lg border-0 bg-white" /> )} {message.has_attachments && } diff --git a/frontend/src/i18n/en.ts b/frontend/src/i18n/en.ts index f05e8d0..fd6603a 100644 --- a/frontend/src/i18n/en.ts +++ b/frontend/src/i18n/en.ts @@ -97,6 +97,8 @@ export const en = { "mail.alsoIn": "Also delivered to", "mail.emptyTrash": "Empty trash", "mail.emptySpam": "Empty spam", + "mail.more": "More", + "mail.markAllRead": "Mark all as read", "mail.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.", "mail.loadFailed": "Couldn't load this message.", "mail.imagesBlocked": "Images from this sender are blocked.", diff --git a/frontend/src/lib/api.ts b/frontend/src/lib/api.ts index 3a51e9a..0af0fd7 100644 --- a/frontend/src/lib/api.ts +++ b/frontend/src/lib/api.ts @@ -251,6 +251,11 @@ export const api = { method: "POST", body: JSON.stringify({ add, remove }), }), + markAllRead: (view: string) => + apiFetch<{ changed: number }>("/threads/mark-read", { + method: "POST", + body: JSON.stringify({ view }), + }), moveMessage: (id: string, mailboxId: string) => apiFetch<{ id: string; mailbox_id: string }>(`/messages/${id}/move`, { method: "POST", diff --git a/frontend/src/routes/MailLayout.tsx b/frontend/src/routes/MailLayout.tsx index 424cfba..6845e26 100644 --- a/frontend/src/routes/MailLayout.tsx +++ b/frontend/src/routes/MailLayout.tsx @@ -1,6 +1,6 @@ import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query"; import { useEffect, useRef, useState } from "react"; -import { Link, useMatch, useNavigate } from "react-router-dom"; +import { Link, useMatch, useNavigate, useSearchParams } from "react-router-dom"; import { AppSwitcher } from "../components/AppSwitcher"; import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer"; @@ -400,6 +400,15 @@ export function MailLayout() { }, 1500); }; + const [moreOpen, setMoreOpen] = useState(false); + const markAllRead = useMutation({ + mutationFn: () => api.markAllRead(view), + onSuccess: () => { + queryClient.invalidateQueries({ queryKey: ["threads"] }); + queryClient.invalidateQueries({ queryKey: ["folder-counts"] }); + }, + }); + const openRelative = (delta: number) => { const list = threadsRef.current; if (list.length === 0) return; @@ -423,6 +432,33 @@ export function MailLayout() { const accounts = accountsQuery.data ?? []; + // Deep link (e.g. Contacts "Compose"): /mail?compose=1&to= opens a new, + // maximized composer prefilled with the recipient, then strips the params. + const [searchParams, setSearchParams] = useSearchParams(); + useEffect(() => { + if (searchParams.get("compose") !== "1") return; + const first = accountsQuery.data?.[0]; + if (!first) return; // wait until accounts have loaded + openComposer({ accountId: first.id, kind: "new", to: searchParams.get("to") ?? "" }, true); + const next = new URLSearchParams(searchParams); + next.delete("compose"); + next.delete("to"); + setSearchParams(next, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams, accountsQuery.data]); + + // Deep link (e.g. Contacts "Find all conversations"): /mail?q= prefills the + // search box (which drives the search), then strips the param. + useEffect(() => { + const q = searchParams.get("q"); + if (q == null) return; + setTerm(q); + const next = new URLSearchParams(searchParams); + next.delete("q"); + setSearchParams(next, { replace: true }); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + return (
@@ -700,6 +736,37 @@ export function MailLayout() { > +
+ + {moreOpen && ( + <> +
setMoreOpen(false)} /> +
+ +
+ + )} +
)} diff --git a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js index 6c196c0..089b131 100644 --- a/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js +++ b/plugins/webmail_plugin_calendar/webmail_plugin_calendar/ui/page.js @@ -388,6 +388,7 @@ export default function mount(el, ctx) { rightOpen: false, menuOpen: false, leftW: Number(localStorage.getItem("cal.leftW")) || 240, + rightW: Number(localStorage.getItem("cal.rightW")) || 320, leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1", composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId } eventPopover: null, // null | { ev, rect } @@ -828,7 +829,10 @@ export default function mount(el, ctx) { ${topbar()}
${mainHead()}
${viewBody()}
- ${state.search ? `
${searchPanelHtml()}
` : (state.rightOpen ? `
${agenda()}
` : "")} + ${(() => { + const inner = state.search ? searchPanelHtml() : (state.rightOpen ? agenda() : null); + return inner === null ? "" : `
${inner}
`; + })()}
${state.composing ? formHtml() : ""} @@ -1274,6 +1278,30 @@ export default function mount(el, ctx) { document.body.style.cursor = "col-resize"; document.body.style.userSelect = "none"; }); + // Right panel (agenda / search results) — drag its left edge to resize. + const rrz = el.querySelector("[data-rresizer]"); + if (rrz) rrz.addEventListener("mousedown", (e) => { + e.preventDefault(); + const right = el.querySelector(".cal-right"); + const startX = e.clientX; + const startW = right.offsetWidth; + const onMove = (ev) => { + const w = Math.max(240, Math.min(640, startW - (ev.clientX - startX))); + right.style.width = `${w}px`; + state.rightW = w; + }; + const onUp = () => { + document.removeEventListener("mousemove", onMove); + document.removeEventListener("mouseup", onUp); + document.body.style.cursor = ""; + document.body.style.userSelect = ""; + try { localStorage.setItem("cal.rightW", String(state.rightW)); } catch { /* ignore */ } + }; + document.addEventListener("mousemove", onMove); + document.addEventListener("mouseup", onUp); + document.body.style.cursor = "col-resize"; + document.body.style.userSelect = "none"; + }); on("[data-view]", "click", (e) => { state.view = e.currentTarget.getAttribute("data-view"); reload(); }); on("[data-mini]", "click", (e) => { state.anchor = startOfDay(new Date(e.currentTarget.getAttribute("data-mini"))); if (state.view === "month") state.view = "day"; reload(); }); diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py index 909a829..adf7fa6 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/router.py @@ -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") diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py index 609150a..735d616 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/schemas.py @@ -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 diff --git a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js index 9a38e00..d74a689 100644 --- a/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js +++ b/plugins/webmail_plugin_contacts/webmail_plugin_contacts/ui/page.js @@ -32,6 +32,7 @@ const IC_MAPPIN = ``; const IC_SEARCH = ``; const CHECK_SVG = ``; +const PAGE_SIZE = 50; // contacts per page (matches Mail) // A Mail-style square selection checkbox (unchecked = outline, checked = filled). const checkbox = (on) => `${on ? CHECK_SVG : ""}`; 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")} -
Groups
+
Groups
${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) {
-
${esc(name)}
+
${esc(name)}${c.starred ? `${IC_STAR}` : ""}
${esc(c.primary_email || "")}
`; } if (!rows.length) html = `
No contacts${state.q ? " match your search" : " yet"}.
`; const allChecked = rows.length > 0 && rows.every((c) => state.checked.has(c.id)); + const arrow = (d) => ``; return `
@@ -349,16 +399,26 @@ export default function mount(el, ctx) { ${state.q ? `` : ""}
- -
${html}
`; +
${html}
+
+ ${total ? start + 1 : 0}–${Math.min(start + PAGE_SIZE, total)} of ${total} +
+ + +
+
`; } 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 `
-

Delete contact

-

Delete “${esc(name)}”? This can't be undone.

+

${title}

+

${msg}

@@ -366,13 +426,28 @@ export default function mount(el, ctx) {
`; } + function newGroupModal() { + return `
+

Create group

+ +
+ + +
+
`; + } + // ---- right: detail / edit ------------------------------------------------ function detailBar() { if (state.mode === "edit" || state.mode === "new") { return `
`; } if (state.checked.size > 0) { - return `
`; + return `
+ +
${state.menu === "groups" ? bulkGroupsMenu() : ""}
+
${state.menu === "more" ? bulkMoreMenu() : ""}
+
`; } const disabled = state.detail ? "" : "opacity:.4;pointer-events:none"; return `
@@ -383,11 +458,27 @@ export default function mount(el, ctx) { } function groupsMenu() { const mine = new Set((state.detail.groups || []).map((g) => g.id)); - return `
${state.groups.map((g) => ``).join("") || `
No groups yet
`}
`; + const items = state.groups.map((g) => ``).join(""); + return `
${items}${items ? `
` : ""}
`; } function moreMenu() { return `
`; } + // 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 ``; + }).join(""); + return `
${items}${items ? `
` : ""}
`; + } + function bulkMoreMenu() { + return `
`; + } 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"), `${esc(u.value)}`); }); if (c.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname)); if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday))); - details += `
${IC_PENCIL}
${c.notes ? `
Notes
${esc(c.notes)}
` : `Add notes`}
`; + if (state.editingNotes) { + details += `
${IC_PENCIL}
+
Notes
+ +
+ + +
`; + } else { + details += `
${IC_PENCIL}
${c.notes ? `
Notes
${esc(c.notes)}
` : `Add notes`}
`; + } return `
@@ -431,7 +532,6 @@ export default function mount(el, ctx) { ${work ? `
${IC_WORK} ${esc(work)}
` : ""}
-
@@ -582,10 +682,12 @@ export default function mount(el, ctx) { const keepFocus = prev && document.activeElement === prev; const caret = keepFocus ? prev.selectionStart : null; el.innerHTML = `
- ${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`} -
${listPanel()}
+ ${state.leftCollapsed ? railPanel() : `
${leftPanel()}
`} +
${listPanel()}
+
${detailBar()}${detailPanel()}
${state.confirmDelete ? confirmModal() : ""} + ${state.newGroup ? newGroupModal() : ""}
`; 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); });