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
+58 -1
View File
@@ -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:<special>, or account:<uuid>."""
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:
+4
View File
@@ -76,6 +76,10 @@ class FlagChangeRequest(BaseModel):
remove: list[str] = []
class MarkReadRequest(BaseModel):
view: str = "unified" # unified | folder:<special> | account:<uuid>
class MoveRequest(BaseModel):
mailbox_id: uuid.UUID
+27 -2
View File
@@ -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 && <Attachments messageId={message.id} />}
+2
View File
@@ -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.",
+5
View File
@@ -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",
+68 -1
View File
@@ -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=<email> 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=<text> 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 (
<div className="flex h-screen bg-app-bg text-app-fg">
<BackfillBanner />
@@ -700,6 +736,37 @@ export function MailLayout() {
>
<SortIcon />
</button>
<div className="relative">
<button
className="rounded p-1.5 text-app-muted hover:bg-app-hover hover:text-app-fg"
title={t("mail.more")}
aria-label={t("mail.more")}
onClick={() => setMoreOpen((o) => !o)}
>
<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor" aria-hidden>
<circle cx="5" cy="12" r="1.6" />
<circle cx="12" cy="12" r="1.6" />
<circle cx="19" cy="12" r="1.6" />
</svg>
</button>
{moreOpen && (
<>
<div className="fixed inset-0 z-10" onClick={() => setMoreOpen(false)} />
<div className="absolute right-0 z-20 mt-1 min-w-[180px] rounded-lg border border-app-border bg-app-surface py-1 shadow-lg">
<button
className="flex w-full items-center px-3 py-2 text-left text-[13px] text-app-fg hover:bg-app-hover disabled:opacity-50"
disabled={markAllRead.isPending}
onClick={() => {
setMoreOpen(false);
markAllRead.mutate();
}}
>
{t("mail.markAllRead")}
</button>
</div>
</>
)}
</div>
</div>
</div>
)}
@@ -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()}
<div class="cal-mainarea">
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
${state.search ? `<div class="cal-right">${searchPanelHtml()}</div>` : (state.rightOpen ? `<div class="cal-right">${agenda()}</div>` : "")}
${(() => {
const inner = state.search ? searchPanelHtml() : (state.rightOpen ? agenda() : null);
return inner === null ? "" : `<div class="cal-resizer" data-rresizer></div><div class="cal-right" style="width:${state.rightW}px">${inner}</div>`;
})()}
</div>
</div>
${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(); });
@@ -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); });