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:
@@ -10,7 +10,7 @@ from sqlalchemy import select
|
|||||||
from sqlalchemy.ext.asyncio import AsyncSession
|
from sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.api.errors import raise_api_error
|
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.db import get_session
|
||||||
from app.events import get_event_bus
|
from app.events import get_event_bus
|
||||||
from app.identity.deps import CurrentUser, require_csrf
|
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)}
|
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(
|
async def _move_thread(
|
||||||
user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str
|
user_id: uuid.UUID, thread_id: uuid.UUID, db: AsyncSession, special: str
|
||||||
) -> None:
|
) -> None:
|
||||||
|
|||||||
@@ -76,6 +76,10 @@ class FlagChangeRequest(BaseModel):
|
|||||||
remove: list[str] = []
|
remove: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class MarkReadRequest(BaseModel):
|
||||||
|
view: str = "unified" # unified | folder:<special> | account:<uuid>
|
||||||
|
|
||||||
|
|
||||||
class MoveRequest(BaseModel):
|
class MoveRequest(BaseModel):
|
||||||
mailbox_id: uuid.UUID
|
mailbox_id: uuid.UUID
|
||||||
|
|
||||||
|
|||||||
@@ -49,7 +49,24 @@ export function MessageBody({
|
|||||||
// Collapse first so scrollHeight reflects the content, not the current frame
|
// 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).
|
// height — otherwise the frame can grow but never shrink (e.g. hiding a quote).
|
||||||
frame.style.height = "0px";
|
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`;
|
frame.style.height = `${h}px`;
|
||||||
setHeight(h);
|
setHeight(h);
|
||||||
}, []);
|
}, []);
|
||||||
@@ -103,8 +120,16 @@ export function MessageBody({
|
|||||||
const onLoad = () => {
|
const onLoad = () => {
|
||||||
const doc = frameRef.current?.contentDocument;
|
const doc = frameRef.current?.contentDocument;
|
||||||
if (!doc) return;
|
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);
|
injectQuoteToggle(doc);
|
||||||
resize();
|
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);
|
doc.addEventListener("click", handleFrameClick);
|
||||||
// Re-measure as images finish loading (they change the content height).
|
// Re-measure as images finish loading (they change the content height).
|
||||||
doc.querySelectorAll("img").forEach((img) => {
|
doc.querySelectorAll("img").forEach((img) => {
|
||||||
@@ -154,7 +179,7 @@ export function MessageBody({
|
|||||||
srcDoc={data}
|
srcDoc={data}
|
||||||
onLoad={onLoad}
|
onLoad={onLoad}
|
||||||
style={{ height }}
|
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} />}
|
{message.has_attachments && <Attachments messageId={message.id} />}
|
||||||
|
|||||||
@@ -97,6 +97,8 @@ export const en = {
|
|||||||
"mail.alsoIn": "Also delivered to",
|
"mail.alsoIn": "Also delivered to",
|
||||||
"mail.emptyTrash": "Empty trash",
|
"mail.emptyTrash": "Empty trash",
|
||||||
"mail.emptySpam": "Empty spam",
|
"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.emptyConfirm": "Permanently delete everything in this folder? This can't be undone.",
|
||||||
"mail.loadFailed": "Couldn't load this message.",
|
"mail.loadFailed": "Couldn't load this message.",
|
||||||
"mail.imagesBlocked": "Images from this sender are blocked.",
|
"mail.imagesBlocked": "Images from this sender are blocked.",
|
||||||
|
|||||||
@@ -251,6 +251,11 @@ export const api = {
|
|||||||
method: "POST",
|
method: "POST",
|
||||||
body: JSON.stringify({ add, remove }),
|
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) =>
|
moveMessage: (id: string, mailboxId: string) =>
|
||||||
apiFetch<{ id: string; mailbox_id: string }>(`/messages/${id}/move`, {
|
apiFetch<{ id: string; mailbox_id: string }>(`/messages/${id}/move`, {
|
||||||
method: "POST",
|
method: "POST",
|
||||||
|
|||||||
@@ -1,6 +1,6 @@
|
|||||||
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
import { keepPreviousData, useMutation, useQuery, useQueryClient } from "@tanstack/react-query";
|
||||||
import { useEffect, useRef, useState } from "react";
|
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 { AppSwitcher } from "../components/AppSwitcher";
|
||||||
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
import { Composer, type ComposerInit, draftToInit } from "../components/compose/Composer";
|
||||||
@@ -400,6 +400,15 @@ export function MailLayout() {
|
|||||||
}, 1500);
|
}, 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 openRelative = (delta: number) => {
|
||||||
const list = threadsRef.current;
|
const list = threadsRef.current;
|
||||||
if (list.length === 0) return;
|
if (list.length === 0) return;
|
||||||
@@ -423,6 +432,33 @@ export function MailLayout() {
|
|||||||
|
|
||||||
const accounts = accountsQuery.data ?? [];
|
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 (
|
return (
|
||||||
<div className="flex h-screen bg-app-bg text-app-fg">
|
<div className="flex h-screen bg-app-bg text-app-fg">
|
||||||
<BackfillBanner />
|
<BackfillBanner />
|
||||||
@@ -700,6 +736,37 @@ export function MailLayout() {
|
|||||||
>
|
>
|
||||||
<SortIcon />
|
<SortIcon />
|
||||||
</button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|||||||
@@ -388,6 +388,7 @@ export default function mount(el, ctx) {
|
|||||||
rightOpen: false,
|
rightOpen: false,
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
leftW: Number(localStorage.getItem("cal.leftW")) || 240,
|
leftW: Number(localStorage.getItem("cal.leftW")) || 240,
|
||||||
|
rightW: Number(localStorage.getItem("cal.rightW")) || 320,
|
||||||
leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1",
|
leftCollapsed: localStorage.getItem("cal.leftCollapsed") === "1",
|
||||||
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
composing: null, // null | { date:"YYYY-MM-DD", time:"HH:MM", editId }
|
||||||
eventPopover: null, // null | { ev, rect }
|
eventPopover: null, // null | { ev, rect }
|
||||||
@@ -828,7 +829,10 @@ export default function mount(el, ctx) {
|
|||||||
${topbar()}
|
${topbar()}
|
||||||
<div class="cal-mainarea">
|
<div class="cal-mainarea">
|
||||||
<div class="cal-main">${mainHead()}<div class="cal-view">${viewBody()}</div></div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
${state.composing ? formHtml() : ""}
|
${state.composing ? formHtml() : ""}
|
||||||
@@ -1274,6 +1278,30 @@ export default function mount(el, ctx) {
|
|||||||
document.body.style.cursor = "col-resize";
|
document.body.style.cursor = "col-resize";
|
||||||
document.body.style.userSelect = "none";
|
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-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(); });
|
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 sqlalchemy.ext.asyncio import AsyncSession
|
||||||
|
|
||||||
from app.plugins.sdk import CurrentUserId, require_csrf, sdk
|
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")
|
_log = logging.getLogger("plg.contacts")
|
||||||
_SDK = sdk("contacts")
|
_SDK = sdk("contacts")
|
||||||
@@ -225,6 +225,53 @@ async def delete_group(group_id: uuid.UUID, user_id: CurrentUserId) -> dict[str,
|
|||||||
return {"status": "ok"}
|
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 ----------------------------------------------------------------
|
# --- contacts ----------------------------------------------------------------
|
||||||
|
|
||||||
@router.get("/contacts")
|
@router.get("/contacts")
|
||||||
|
|||||||
@@ -57,3 +57,7 @@ class ContactIn(BaseModel):
|
|||||||
|
|
||||||
class GroupIn(BaseModel):
|
class GroupIn(BaseModel):
|
||||||
name: str
|
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_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 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 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).
|
// A Mail-style square selection checkbox (unchecked = outline, checked = filled).
|
||||||
const checkbox = (on) => `<span class="ct-check${on ? " on" : ""}">${on ? CHECK_SVG : ""}</span>`;
|
const checkbox = (on) => `<span class="ct-check${on ? " on" : ""}">${on ? CHECK_SVG : ""}</span>`;
|
||||||
const IC_PENCIL = IC_COMPOSE;
|
const IC_PENCIL = IC_COMPOSE;
|
||||||
@@ -65,6 +66,8 @@ function shortDate(iso) {
|
|||||||
|
|
||||||
const CSS = `
|
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}
|
.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.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
|
||||||
.ct-app .wm-side-item .ct-dico{margin-top:0}
|
.ct-app .wm-side-item .ct-dico{margin-top:0}
|
||||||
/* collapsed icon-only rail (matches Mail's 60px rail) */
|
/* 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{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-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-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-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}
|
.ct-bar .sp{flex:1}
|
||||||
/* Mail-style selection checkbox (bar "select all" + per-row on avatar-hover) */
|
/* 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}
|
.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-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{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-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-avwrap:hover .ct-avshow{visibility:hidden}
|
||||||
.ct-app:not(.selmode) .ct-row:hover .ct-avcheck{display:flex}
|
.ct-app:not(.selmode) .ct-avwrap:hover .ct-avcheck{display:flex}
|
||||||
.ct-app.selmode .ct-avshow{visibility:hidden}
|
.ct-app.selmode .ct-avshow{visibility:hidden}
|
||||||
.ct-app.selmode .ct-avcheck{display:flex}
|
.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))}
|
.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-barbtn:hover{background:rgb(var(--app-hover))}
|
||||||
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
|
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
|
||||||
.ct-list{flex:1;min-height:0;overflow:auto}
|
.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-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 18px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5)}
|
.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:hover{background:rgb(var(--app-hover))}
|
||||||
.ct-row.sel{background:rgb(var(--app-active))}
|
.ct-row.sel{background:rgb(var(--app-active))}
|
||||||
.ct-rowmain{min-width:0;flex:1}
|
.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-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-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{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%}
|
.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-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{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
|
||||||
.ct-star.on{color:#eab308}
|
.ct-star.on{color:#eab308}
|
||||||
|
.ct-star.on svg{fill:#eab308}
|
||||||
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
|
.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{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-act:hover{background:rgb(var(--app-hover))}
|
||||||
.ct-sec{margin-top:26px}
|
.ct-sec{margin-top:26px}
|
||||||
.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
|
.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{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-dico{color:rgb(var(--app-muted));margin-top:2px}
|
||||||
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
|
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
|
||||||
.ct-dval{margin-top:1px}
|
.ct-dval{margin-top:1px}
|
||||||
@@ -189,10 +210,14 @@ export default function mount(el, ctx) {
|
|||||||
mode: "view", // view | edit | new
|
mode: "view", // view | edit | new
|
||||||
mail: null, // { loading, threads }
|
mail: null, // { loading, threads }
|
||||||
sortAsc: true,
|
sortAsc: true,
|
||||||
|
page: 0,
|
||||||
checked: new Set(), // multi-selected contact ids (Mail-style)
|
checked: new Set(), // multi-selected contact ids (Mail-style)
|
||||||
confirmDelete: false,
|
confirmDelete: false,
|
||||||
|
editingNotes: false,
|
||||||
|
newGroup: null, // null | { add: [contactId,...] } — create-group modal
|
||||||
menu: null, // "groups" | "more" | null
|
menu: null, // "groups" | "more" | null
|
||||||
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
|
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
|
||||||
|
listW: Number(localStorage.getItem("ct.listW")) || 480,
|
||||||
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
|
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
|
||||||
menuOpen: false,
|
menuOpen: false,
|
||||||
me: "", quota: "",
|
me: "", quota: "",
|
||||||
@@ -209,6 +234,24 @@ export default function mount(el, ctx) {
|
|||||||
render();
|
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 --------------------------------------------------------
|
// ---- data loading --------------------------------------------------------
|
||||||
async function loadGroups() {
|
async function loadGroups() {
|
||||||
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
|
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
|
||||||
@@ -240,6 +283,7 @@ export default function mount(el, ctx) {
|
|||||||
state.selectedId = id;
|
state.selectedId = id;
|
||||||
state.mode = "view";
|
state.mode = "view";
|
||||||
state.menu = null;
|
state.menu = null;
|
||||||
|
state.editingNotes = false;
|
||||||
state.checked.clear(); // opening a contact exits multi-select mode
|
state.checked.clear(); // opening a contact exits multi-select mode
|
||||||
await loadDetail(id);
|
await loadDetail(id);
|
||||||
render();
|
render();
|
||||||
@@ -279,7 +323,7 @@ export default function mount(el, ctx) {
|
|||||||
${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
|
${navItem(IC_STAR, "VIPs", !state.group && f === "vip", "filter-vip")}
|
||||||
${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
|
${navItem(IC_CLOCK, "Recent", !state.group && f === "recent", "filter-recent")}
|
||||||
</div>
|
</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">
|
<div class="wm-side-nav">
|
||||||
${state.groups.map(grp).join("")}
|
${state.groups.map(grp).join("")}
|
||||||
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
|
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
|
||||||
@@ -316,10 +360,15 @@ export default function mount(el, ctx) {
|
|||||||
|
|
||||||
// ---- middle: contact list ------------------------------------------------
|
// ---- middle: contact list ------------------------------------------------
|
||||||
function listPanel() {
|
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" });
|
const r = (a.display_name || a.primary_email || "").localeCompare(b.display_name || b.primary_email || "", undefined, { sensitivity: "base" });
|
||||||
return state.sortAsc ? r : -r;
|
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 html = "";
|
||||||
let letter = null;
|
let letter = null;
|
||||||
for (const c of rows) {
|
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>
|
<button class="ct-avcheck" data-check="${c.id}" aria-label="Select contact">${checkbox(ck)}</button>
|
||||||
</div>
|
</div>
|
||||||
<div class="ct-rowmain">
|
<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 class="ct-rowmail">${esc(c.primary_email || "")}</div>
|
||||||
</div></div>`;
|
</div></div>`;
|
||||||
}
|
}
|
||||||
if (!rows.length) html = `<div class="ct-empty">No contacts${state.q ? " match your search" : " yet"}.</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 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 `
|
return `
|
||||||
<div class="ct-bar">
|
<div class="ct-bar">
|
||||||
<div class="ct-selall"><button class="ct-selallbtn" data-act="select-all" aria-label="Select all">${checkbox(allChecked)}</button></div>
|
<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>` : ""}
|
${state.q ? `<button class="ct-searchclear" data-act="search-clear" title="Clear">✕</button>` : ""}
|
||||||
</div>
|
</div>
|
||||||
<button class="ct-iconbtn" data-act="sort" title="Sort">${IC_SORT}</button>
|
<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>
|
||||||
<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() {
|
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 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>
|
return `<div class="ct-modalbg" data-act="confirm-cancel"><div class="ct-modal" data-stop>
|
||||||
<h3>Delete contact</h3>
|
<h3>${title}</h3>
|
||||||
<p>Delete “${esc(name)}”? This can't be undone.</p>
|
<p>${msg}</p>
|
||||||
<div class="row">
|
<div class="row">
|
||||||
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
|
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
|
||||||
<button class="ct-danger" data-act="confirm-delete">Delete</button>
|
<button class="ct-danger" data-act="confirm-delete">Delete</button>
|
||||||
@@ -366,13 +426,28 @@ export default function mount(el, ctx) {
|
|||||||
</div></div>`;
|
</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 ------------------------------------------------
|
// ---- right: detail / edit ------------------------------------------------
|
||||||
function detailBar() {
|
function detailBar() {
|
||||||
if (state.mode === "edit" || state.mode === "new") {
|
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>`;
|
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) {
|
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";
|
const disabled = state.detail ? "" : "opacity:.4;pointer-events:none";
|
||||||
return `<div class="ct-bar" style="${disabled}">
|
return `<div class="ct-bar" style="${disabled}">
|
||||||
@@ -383,11 +458,27 @@ export default function mount(el, ctx) {
|
|||||||
}
|
}
|
||||||
function groupsMenu() {
|
function groupsMenu() {
|
||||||
const mine = new Set((state.detail.groups || []).map((g) => g.id));
|
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() {
|
function moreMenu() {
|
||||||
return `<div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div>`;
|
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() {
|
function detailPanel() {
|
||||||
if (state.mode === "edit" || state.mode === "new") return editForm();
|
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>`); });
|
(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.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname));
|
||||||
if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday)));
|
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">
|
return `<div class="ct-detail"><div class="ct-card">
|
||||||
<div class="ct-cardhead">
|
<div class="ct-cardhead">
|
||||||
@@ -431,7 +532,6 @@ export default function mount(el, ctx) {
|
|||||||
${work ? `<div class="ct-sub">${IC_WORK} ${esc(work)}</div>` : ""}
|
${work ? `<div class="ct-sub">${IC_WORK} ${esc(work)}</div>` : ""}
|
||||||
<div class="ct-actions">
|
<div class="ct-actions">
|
||||||
<button class="ct-act" data-act="call">${IC_PHONE} Call</button>
|
<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="compose">${IC_COMPOSE} Compose</button>
|
||||||
<button class="ct-act" data-act="invite">${IC_CAL} Invite</button>
|
<button class="ct-act" data-act="invite">${IC_CAL} Invite</button>
|
||||||
</div>
|
</div>
|
||||||
@@ -582,10 +682,12 @@ export default function mount(el, ctx) {
|
|||||||
const keepFocus = prev && document.activeElement === prev;
|
const keepFocus = prev && document.activeElement === prev;
|
||||||
const caret = keepFocus ? prev.selectionStart : null;
|
const caret = keepFocus ? prev.selectionStart : null;
|
||||||
el.innerHTML = `<div class="ct-app${state.checked.size ? " selmode" : ""}">
|
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>`}
|
${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">${listPanel()}</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>
|
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
|
||||||
${state.confirmDelete ? confirmModal() : ""}
|
${state.confirmDelete ? confirmModal() : ""}
|
||||||
|
${state.newGroup ? newGroupModal() : ""}
|
||||||
</div>`;
|
</div>`;
|
||||||
wire();
|
wire();
|
||||||
if (keepFocus) {
|
if (keepFocus) {
|
||||||
@@ -597,12 +699,42 @@ export default function mount(el, ctx) {
|
|||||||
function wire() {
|
function wire() {
|
||||||
const on = (sel, ev, fn) => el.querySelectorAll(sel).forEach((n) => n.addEventListener(ev, fn));
|
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 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(); });
|
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("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("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(); });
|
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("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcontacts"));
|
||||||
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
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) => {
|
on("[data-group]", "click", (e) => {
|
||||||
state.group = e.currentTarget.getAttribute("data-group");
|
state.group = e.currentTarget.getAttribute("data-group");
|
||||||
state.groupLabel = e.currentTarget.getAttribute("data-groupname");
|
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")));
|
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
|
||||||
// Selection checkboxes (revealed on avatar-hover / in select mode) — Mail-style.
|
// 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(); });
|
act("clear-sel", () => { state.checked.clear(); render(); });
|
||||||
on(".ct-search", "input", (e) => {
|
on(".ct-search", "input", (e) => {
|
||||||
state.q = e.currentTarget.value;
|
state.q = e.currentTarget.value;
|
||||||
|
state.page = 0;
|
||||||
if (searchTimer) clearTimeout(searchTimer);
|
if (searchTimer) clearTimeout(searchTimer);
|
||||||
searchTimer = setTimeout(runSearch, 250);
|
searchTimer = setTimeout(runSearch, 250);
|
||||||
});
|
});
|
||||||
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; runSearch(); } });
|
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; state.page = 0; runSearch(); } });
|
||||||
act("search-clear", () => { state.q = ""; runSearch(); });
|
act("search-clear", () => { state.q = ""; state.page = 0; runSearch(); });
|
||||||
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
|
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
|
||||||
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; 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("save", () => saveForm());
|
||||||
act("star", async () => { const r = await api.pluginFetch(`/contacts/${state.selectedId}/star`, { method: "POST" }); state.detail.starred = r.starred; render(); });
|
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("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", () => {
|
||||||
act("compose", () => { if (state.detail.default_email) window.location.href = `mailto:${state.detail.default_email}`; });
|
const email = state.detail && state.detail.default_email;
|
||||||
|
ctx.navigate(email ? `/mail?compose=1&to=${encodeURIComponent(email)}` : "/mail");
|
||||||
|
});
|
||||||
act("invite", () => {
|
act("invite", () => {
|
||||||
const email = state.detail && state.detail.default_email;
|
const email = state.detail && state.detail.default_email;
|
||||||
ctx.navigate(email ? `/p/calendar?guest=${encodeURIComponent(email)}` : "/p/calendar");
|
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
|
// menus
|
||||||
act("menu-groups", (e) => { e.stopPropagation(); state.menu = state.menu === "groups" ? null : "groups"; render(); });
|
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 api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
await loadGroups(); await loadDetail(state.selectedId); render();
|
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("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-cancel", () => { state.confirmDelete = false; render(); });
|
||||||
act("confirm-delete", async () => {
|
act("confirm-delete", async () => {
|
||||||
state.confirmDelete = false;
|
state.confirmDelete = false;
|
||||||
|
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" });
|
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
|
||||||
|
}
|
||||||
state.selectedId = null;
|
state.selectedId = null;
|
||||||
await reloadList(false);
|
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
|
// 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-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); });
|
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); });
|
||||||
|
|||||||
Reference in New Issue
Block a user