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); });