M5 Contacts plugin (builtin) + collapsed icon-rail for Calendar
Contacts (SPEC Appendix B, phase B1) — new builtin plugin `webmail_plugin_contacts`:
- Tables plg_contacts_{books,contacts,groups,group_members} (RLS-isolated,
migration contacts0001): vCard-style structured contact fields, address books,
groups with membership.
- API /api/plugins/contacts/: list (filter all|vip|recent|nogroup, group, q),
get/create/update/delete, star toggle, groups CRUD, and the recipients.suggest
capability endpoint (enriches the composer). Display name derived server-side
from name parts so search + suggestions work regardless of client.
- Frontend ui/page.js: three-pane app — shared .wm-side-* sidebar template with a
"Contacts" header, alphabetically grouped list, detail pane (avatar, VIP star,
Call/Message/Compose/Invite, Contact details, live Mail search, Events stub) and
a full edit form (name, org, birthday, repeatable emails/phones, address, notes).
Collapsed sidebar: both Contacts and Calendar now show a 60px icon-only rail (like
Mail) with expand toggle, primary button, quick nav and Settings/Sign out — instead
of hiding the panel (Contacts) / a top-bar button (Calendar).
Deferred: Gravatar photos, vCard import/export + merge (B2), CardDAV sync (B3),
"Add sender to contacts".
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -201,6 +201,16 @@ const CSS = `
|
|||||||
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
|
.cal-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 4px;border-radius:4px}
|
||||||
.cal-secrow:hover .cal-secadd{opacity:1}
|
.cal-secrow:hover .cal-secadd{opacity:1}
|
||||||
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
.cal-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
/* collapsed icon-only rail (matches Mail's 60px rail) */
|
||||||
|
.cal-rail{width:60px;align-items:center}
|
||||||
|
.cal-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
|
||||||
|
.cal-railprimary{padding:2px 0 8px}
|
||||||
|
.cal-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
|
||||||
|
.cal-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
|
||||||
|
.cal-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
|
||||||
|
.cal-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.cal-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}
|
||||||
|
.cal-railnew:hover{opacity:.9}
|
||||||
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
.cal-resizer{flex:0 0 auto;width:5px;margin:0 -2px;cursor:col-resize;position:relative;z-index:10}
|
||||||
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
|
.cal-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
|
||||||
.cal-resizer:hover::after{background:#3b82f6}
|
.cal-resizer:hover::after{background:#3b82f6}
|
||||||
@@ -813,7 +823,7 @@ export default function mount(el, ctx) {
|
|||||||
// ---- render ----------------------------------------------------------------
|
// ---- render ----------------------------------------------------------------
|
||||||
function render() {
|
function render() {
|
||||||
el.innerHTML = `<div class="cal-app">
|
el.innerHTML = `<div class="cal-app">
|
||||||
${state.leftCollapsed ? "" : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
${state.leftCollapsed ? railPanel() : `<div class="wm-side cal-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div><div class="cal-resizer" data-resizer></div>`}
|
||||||
<div class="cal-maincol">
|
<div class="cal-maincol">
|
||||||
${topbar()}
|
${topbar()}
|
||||||
<div class="cal-mainarea">
|
<div class="cal-mainarea">
|
||||||
@@ -858,7 +868,6 @@ export default function mount(el, ctx) {
|
|||||||
|
|
||||||
function topbar() {
|
function topbar() {
|
||||||
return `<div class="cal-topbar">
|
return `<div class="cal-topbar">
|
||||||
${state.leftCollapsed ? `<button class="cal-iconbtn" data-act="left-toggle" title="Show sidebar">${SHOW_ICON}</button>` : ""}
|
|
||||||
<div class="cal-searchbox">
|
<div class="cal-searchbox">
|
||||||
<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>
|
<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>
|
||||||
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
|
<input class="cal-search" placeholder="Search calendar" value="${esc(state.search ? state.search.q : "")}"/>
|
||||||
@@ -867,6 +876,26 @@ export default function mount(el, ctx) {
|
|||||||
</div>`;
|
</div>`;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
|
||||||
|
// toggle, so it can always be re-opened.
|
||||||
|
function railPanel() {
|
||||||
|
const TODAY_IC = `<svg width="18" height="18" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/><circle cx="12" cy="15.5" r="2.3" fill="currentColor" stroke="none"/></svg>`;
|
||||||
|
return `<div class="wm-side cal-rail" style="flex:0 0 auto">
|
||||||
|
<div class="cal-railtop">
|
||||||
|
<button class="cal-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
|
||||||
|
<button class="cal-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||||||
|
</div>
|
||||||
|
<div class="cal-railprimary"><button class="cal-railnew" data-act="new" title="New event">${COMPOSE_ICON}</button></div>
|
||||||
|
<div class="cal-railnav">
|
||||||
|
<button class="cal-railbtn" data-act="today" title="Today">${TODAY_IC}</button>
|
||||||
|
</div>
|
||||||
|
<div class="cal-railfoot">
|
||||||
|
<button class="cal-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
|
||||||
|
<button class="cal-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
|
// Left panel = the shared sidebar-shell template (.wm-side-*, see plugin-ui.css):
|
||||||
// switcher + collapse/theme, primary button, an app-specific scrollable body
|
// switcher + collapse/theme, primary button, an app-specific scrollable body
|
||||||
// (mini-month + calendars here), and the shared foot. Identical to Mail.
|
// (mini-month + calendars here), and the shared foot. Identical to Mail.
|
||||||
|
|||||||
@@ -0,0 +1,20 @@
|
|||||||
|
[build-system]
|
||||||
|
requires = ["setuptools>=68"]
|
||||||
|
build-backend = "setuptools.build_meta"
|
||||||
|
|
||||||
|
[project]
|
||||||
|
name = "webmail-plugin-contacts"
|
||||||
|
version = "0.1.0"
|
||||||
|
description = "Contacts — the second heavy plugin (SPEC Appendix B). Local contact books, groups, recipients.suggest; vCard + CardDAV later."
|
||||||
|
requires-python = ">=3.11"
|
||||||
|
# B1 (local contacts) needs no external deps; vobject is added at B2 (vCard I/O).
|
||||||
|
dependencies = []
|
||||||
|
|
||||||
|
[project.entry-points."webmail.plugins"]
|
||||||
|
contacts = "webmail_plugin_contacts:plugin"
|
||||||
|
|
||||||
|
[tool.setuptools]
|
||||||
|
packages = ["webmail_plugin_contacts"]
|
||||||
|
|
||||||
|
[tool.setuptools.package-data]
|
||||||
|
webmail_plugin_contacts = ["ui/*.js", "migrations/versions/*.py"]
|
||||||
@@ -0,0 +1,37 @@
|
|||||||
|
"""``contacts`` — the second heavy plugin (SPEC Appendix B).
|
||||||
|
|
||||||
|
A **core, non-removable** plugin (``builtin=True``) that ships a `/contacts` page
|
||||||
|
beside Mail and Calendar. Its own ``plg_contacts_*`` tables (RLS-isolated) hold
|
||||||
|
address books, contacts (structured vCard-style fields), and groups. Core owns the
|
||||||
|
*implicit* recipient history (``known_recipients``); this plugin owns the *explicit*,
|
||||||
|
user-curated contacts and enriches the composer via the ``recipients.suggest``
|
||||||
|
capability. Uses the SDK only — zero core diffs. Phase B1 (local contacts) is the
|
||||||
|
current scope; vCard import/export (B2) and CardDAV sync (B3) build on the same model.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pathlib import Path
|
||||||
|
|
||||||
|
from app.plugins.sdk import Page, PageNav, Plugin, UiManifest
|
||||||
|
from webmail_plugin_contacts.router import router
|
||||||
|
|
||||||
|
_HERE = Path(__file__).resolve().parent
|
||||||
|
|
||||||
|
plugin = Plugin(
|
||||||
|
name="contacts",
|
||||||
|
version="0.1.0",
|
||||||
|
builtin=True,
|
||||||
|
router=router,
|
||||||
|
ui_manifest=UiManifest(
|
||||||
|
pages=[
|
||||||
|
Page(
|
||||||
|
route="/contacts",
|
||||||
|
nav=PageNav(icon="contacts", label="Contacts", order=20),
|
||||||
|
bundle="/api/plugins/contacts/ui/page.js",
|
||||||
|
)
|
||||||
|
],
|
||||||
|
capabilities={"recipients.suggest": "/api/plugins/contacts/suggest"},
|
||||||
|
),
|
||||||
|
migrations=str(_HERE / "migrations" / "versions"),
|
||||||
|
)
|
||||||
+108
@@ -0,0 +1,108 @@
|
|||||||
|
"""contacts plugin: books + contacts + groups tables with RLS (SPEC §B.3)
|
||||||
|
|
||||||
|
Revision ID: contacts0001
|
||||||
|
Revises:
|
||||||
|
Create Date: 2026-08-02
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from collections.abc import Sequence
|
||||||
|
|
||||||
|
from alembic import op
|
||||||
|
|
||||||
|
from app.plugins.rls import enable_rls
|
||||||
|
|
||||||
|
revision: str = "contacts0001"
|
||||||
|
down_revision: str | None = None
|
||||||
|
branch_labels: str | Sequence[str] | None = None
|
||||||
|
depends_on: str | Sequence[str] | None = None
|
||||||
|
|
||||||
|
|
||||||
|
def upgrade() -> None:
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE plg_contacts_books (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
kind text NOT NULL DEFAULT 'local', -- local|carddav
|
||||||
|
source_config jsonb NOT NULL DEFAULT '{}',
|
||||||
|
secret_ct bytea, -- CardDAV creds (vault, §10.2)
|
||||||
|
sync_token text,
|
||||||
|
read_only boolean NOT NULL DEFAULT false,
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE plg_contacts_contacts (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
book_id uuid NOT NULL
|
||||||
|
REFERENCES plg_contacts_books(id) ON DELETE CASCADE,
|
||||||
|
vcard_uid text NOT NULL,
|
||||||
|
etag text,
|
||||||
|
name_display text NOT NULL DEFAULT '', -- FN
|
||||||
|
name_parts jsonb NOT NULL DEFAULT '{}', -- {prefix,given,middle,family,suffix}
|
||||||
|
nickname text NOT NULL DEFAULT '',
|
||||||
|
emails jsonb NOT NULL DEFAULT '[]', -- [{value,type,pref}]
|
||||||
|
default_email text NOT NULL DEFAULT '',
|
||||||
|
phones jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
|
||||||
|
addresses jsonb NOT NULL DEFAULT '[]', -- [{type,country,street,postal_code,city}]
|
||||||
|
urls jsonb NOT NULL DEFAULT '[]', -- [{value,type}]
|
||||||
|
org text NOT NULL DEFAULT '',
|
||||||
|
department text NOT NULL DEFAULT '',
|
||||||
|
title text NOT NULL DEFAULT '',
|
||||||
|
birthday date,
|
||||||
|
notes text NOT NULL DEFAULT '',
|
||||||
|
starred boolean NOT NULL DEFAULT false, -- VIP / favourite
|
||||||
|
photo_sha256 text,
|
||||||
|
raw_vcard text, -- round-trip fidelity (B2/B3)
|
||||||
|
updated_at timestamptz NOT NULL DEFAULT now(),
|
||||||
|
UNIQUE (book_id, vcard_uid)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_plg_contacts_contacts_user_name "
|
||||||
|
"ON plg_contacts_contacts (user_id, name_display)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"CREATE INDEX ix_plg_contacts_contacts_emails "
|
||||||
|
"ON plg_contacts_contacts USING gin (emails jsonb_path_ops)"
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE plg_contacts_groups (
|
||||||
|
id uuid PRIMARY KEY DEFAULT gen_random_uuid(),
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
name text NOT NULL,
|
||||||
|
UNIQUE (user_id, name)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
op.execute(
|
||||||
|
"""
|
||||||
|
CREATE TABLE plg_contacts_group_members (
|
||||||
|
user_id uuid NOT NULL,
|
||||||
|
group_id uuid NOT NULL
|
||||||
|
REFERENCES plg_contacts_groups(id) ON DELETE CASCADE,
|
||||||
|
contact_id uuid NOT NULL
|
||||||
|
REFERENCES plg_contacts_contacts(id) ON DELETE CASCADE,
|
||||||
|
PRIMARY KEY (group_id, contact_id)
|
||||||
|
)
|
||||||
|
"""
|
||||||
|
)
|
||||||
|
enable_rls("plg_contacts_books")
|
||||||
|
enable_rls("plg_contacts_contacts")
|
||||||
|
enable_rls("plg_contacts_groups")
|
||||||
|
enable_rls("plg_contacts_group_members")
|
||||||
|
|
||||||
|
|
||||||
|
def downgrade() -> None:
|
||||||
|
op.execute("DROP TABLE IF EXISTS plg_contacts_group_members")
|
||||||
|
op.execute("DROP TABLE IF EXISTS plg_contacts_groups")
|
||||||
|
op.execute("DROP TABLE IF EXISTS plg_contacts_contacts")
|
||||||
|
op.execute("DROP TABLE IF EXISTS plg_contacts_books")
|
||||||
@@ -0,0 +1,458 @@
|
|||||||
|
"""Contacts plugin API (SPEC §B.5). Local address book: contact CRUD with
|
||||||
|
structured fields, groups, alphabetically grouped listing + search, and the
|
||||||
|
``recipients.suggest`` capability endpoint used by the composer.
|
||||||
|
|
||||||
|
Uses raw SQL through the RLS-scoped ``sdk.session`` (plugin tables aren't in the
|
||||||
|
core ORM). jsonb columns are written as ``json.dumps`` strings and read back as
|
||||||
|
already-parsed Python objects — same discipline as the calendar plugin.
|
||||||
|
"""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
import json
|
||||||
|
import logging
|
||||||
|
import uuid
|
||||||
|
from datetime import date
|
||||||
|
from pathlib import Path
|
||||||
|
from typing import Any
|
||||||
|
|
||||||
|
from fastapi import APIRouter, Depends, HTTPException, Query
|
||||||
|
from fastapi.responses import FileResponse
|
||||||
|
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
|
||||||
|
|
||||||
|
_log = logging.getLogger("plg.contacts")
|
||||||
|
_SDK = sdk("contacts")
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
_UI = Path(__file__).resolve().parent / "ui"
|
||||||
|
|
||||||
|
# Columns selected for both the list and detail responses.
|
||||||
|
_COLS = (
|
||||||
|
"id, book_id, name_display, name_parts, nickname, emails, default_email, phones, "
|
||||||
|
"urls, addresses, org, department, title, birthday, notes, starred, updated_at"
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def _json(value: object) -> Any:
|
||||||
|
"""Read a jsonb column defensively (SQLAlchemy usually returns parsed objects,
|
||||||
|
but tolerate a raw JSON string too)."""
|
||||||
|
if isinstance(value, str):
|
||||||
|
try:
|
||||||
|
return json.loads(value)
|
||||||
|
except ValueError:
|
||||||
|
return []
|
||||||
|
return value if value is not None else []
|
||||||
|
|
||||||
|
|
||||||
|
def _display_name(row: dict[str, object]) -> str:
|
||||||
|
if row.get("name_display"):
|
||||||
|
return str(row["name_display"])
|
||||||
|
parts = _json(row.get("name_parts")) or {}
|
||||||
|
if isinstance(parts, dict):
|
||||||
|
joined = " ".join(
|
||||||
|
str(parts.get(k, "")).strip()
|
||||||
|
for k in ("prefix", "given", "middle", "family", "suffix")
|
||||||
|
if str(parts.get(k, "")).strip()
|
||||||
|
)
|
||||||
|
if joined:
|
||||||
|
return joined
|
||||||
|
emails = _json(row.get("emails"))
|
||||||
|
if emails and isinstance(emails, list):
|
||||||
|
return str(emails[0].get("value", "")) if isinstance(emails[0], dict) else ""
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _primary_email(row: dict[str, object]) -> str:
|
||||||
|
if row.get("default_email"):
|
||||||
|
return str(row["default_email"])
|
||||||
|
emails = _json(row.get("emails"))
|
||||||
|
if isinstance(emails, list) and emails:
|
||||||
|
pref = next((e for e in emails if isinstance(e, dict) and e.get("pref")), None)
|
||||||
|
chosen = pref or emails[0]
|
||||||
|
if isinstance(chosen, dict):
|
||||||
|
return str(chosen.get("value", ""))
|
||||||
|
return ""
|
||||||
|
|
||||||
|
|
||||||
|
def _detail(row: dict[str, object], groups: list[dict[str, str]]) -> dict[str, object]:
|
||||||
|
bday = row.get("birthday")
|
||||||
|
return {
|
||||||
|
"id": str(row["id"]),
|
||||||
|
"display_name": _display_name(row),
|
||||||
|
"name_parts": _json(row.get("name_parts")) or {},
|
||||||
|
"nickname": row.get("nickname") or "",
|
||||||
|
"org": row.get("org") or "",
|
||||||
|
"department": row.get("department") or "",
|
||||||
|
"title": row.get("title") or "",
|
||||||
|
"birthday": bday.isoformat() if isinstance(bday, date) else None,
|
||||||
|
"emails": _json(row.get("emails")),
|
||||||
|
"default_email": _primary_email(row),
|
||||||
|
"phones": _json(row.get("phones")),
|
||||||
|
"urls": _json(row.get("urls")),
|
||||||
|
"addresses": _json(row.get("addresses")),
|
||||||
|
"notes": row.get("notes") or "",
|
||||||
|
"starred": bool(row.get("starred")),
|
||||||
|
"groups": groups,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _ensure_book(s: AsyncSession, user_id: uuid.UUID) -> str:
|
||||||
|
"""Return the user's default local book id, creating it (and an 'Autosaved'
|
||||||
|
group) on first use so the page always has somewhere to put new contacts."""
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id FROM plg_contacts_books "
|
||||||
|
"WHERE user_id = :u ORDER BY name LIMIT 1"
|
||||||
|
),
|
||||||
|
{"u": str(user_id)},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is not None:
|
||||||
|
book_id = str(row[0])
|
||||||
|
else:
|
||||||
|
book_id = str(
|
||||||
|
(
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_books (user_id, name) "
|
||||||
|
"VALUES (:u, 'Contacts') RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": str(user_id)},
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
)
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, 'Autosaved') "
|
||||||
|
"ON CONFLICT (user_id, name) DO NOTHING"
|
||||||
|
),
|
||||||
|
{"u": str(user_id)},
|
||||||
|
)
|
||||||
|
return book_id
|
||||||
|
|
||||||
|
|
||||||
|
async def _group_map(s: AsyncSession) -> dict[str, list[dict[str, str]]]:
|
||||||
|
"""contact_id -> [{id, name}] for every membership (RLS-scoped)."""
|
||||||
|
rows = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT m.contact_id, g.id AS gid, g.name FROM plg_contacts_group_members m "
|
||||||
|
"JOIN plg_contacts_groups g ON g.id = m.group_id"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
out: dict[str, list[dict[str, str]]] = {}
|
||||||
|
for r in rows:
|
||||||
|
out.setdefault(str(r["contact_id"]), []).append(
|
||||||
|
{"id": str(r["gid"]), "name": str(r["name"])}
|
||||||
|
)
|
||||||
|
return out
|
||||||
|
|
||||||
|
|
||||||
|
# --- bundle ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/ui/{filename}")
|
||||||
|
async def bundle(filename: str) -> FileResponse:
|
||||||
|
if filename != "page.js":
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
return FileResponse(
|
||||||
|
_UI / filename,
|
||||||
|
media_type="text/javascript",
|
||||||
|
headers={"Cache-Control": "no-cache"},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
# --- groups ------------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/groups")
|
||||||
|
async def list_groups(user_id: CurrentUserId) -> list[dict[str, object]]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
await _ensure_book(s, user_id)
|
||||||
|
rows = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT g.id, g.name, count(m.contact_id) AS n "
|
||||||
|
"FROM plg_contacts_groups g "
|
||||||
|
"LEFT JOIN plg_contacts_group_members m ON m.group_id = g.id "
|
||||||
|
"GROUP BY g.id, g.name ORDER BY lower(g.name)"
|
||||||
|
)
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
return [{"id": str(r["id"]), "name": str(r["name"]), "count": int(r["n"])} for r in rows]
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/groups", dependencies=[Depends(require_csrf)])
|
||||||
|
async def create_group(body: GroupIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||||
|
name = body.name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="empty_name")
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
gid = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_groups (user_id, name) VALUES (:u, :n) "
|
||||||
|
"ON CONFLICT (user_id, name) DO UPDATE SET name = EXCLUDED.name "
|
||||||
|
"RETURNING id"
|
||||||
|
),
|
||||||
|
{"u": str(user_id), "n": name},
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
return {"id": str(gid), "name": name}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/groups/{group_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
async def delete_group(group_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
await s.execute(
|
||||||
|
text("DELETE FROM plg_contacts_groups WHERE id = :id"), {"id": str(group_id)}
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
# --- contacts ----------------------------------------------------------------
|
||||||
|
|
||||||
|
@router.get("/contacts")
|
||||||
|
async def list_contacts(
|
||||||
|
user_id: CurrentUserId,
|
||||||
|
filter: str = Query("all"), # all | vip | recent | nogroup
|
||||||
|
group: str = Query(""),
|
||||||
|
q: str = Query("", max_length=200),
|
||||||
|
) -> dict[str, object]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
await _ensure_book(s, user_id)
|
||||||
|
where = ["c.user_id = :u"]
|
||||||
|
params: dict[str, object] = {"u": str(user_id)}
|
||||||
|
if filter == "vip":
|
||||||
|
where.append("c.starred")
|
||||||
|
elif filter == "nogroup":
|
||||||
|
where.append(
|
||||||
|
"NOT EXISTS (SELECT 1 FROM plg_contacts_group_members m "
|
||||||
|
"WHERE m.contact_id = c.id)"
|
||||||
|
)
|
||||||
|
if group:
|
||||||
|
where.append(
|
||||||
|
"EXISTS (SELECT 1 FROM plg_contacts_group_members m "
|
||||||
|
"WHERE m.contact_id = c.id AND m.group_id = :g)"
|
||||||
|
)
|
||||||
|
params["g"] = group
|
||||||
|
if q.strip():
|
||||||
|
where.append("(c.name_display ILIKE :q OR c.emails::text ILIKE :q)")
|
||||||
|
params["q"] = f"%{q.strip()}%"
|
||||||
|
order = (
|
||||||
|
"c.updated_at DESC"
|
||||||
|
if filter == "recent"
|
||||||
|
else "lower(c.name_display), c.updated_at DESC"
|
||||||
|
)
|
||||||
|
limit = " LIMIT 50" if filter == "recent" else ""
|
||||||
|
rows = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
f"SELECT {_COLS} FROM plg_contacts_contacts c "
|
||||||
|
f"WHERE {' AND '.join(where)} ORDER BY {order}{limit}"
|
||||||
|
),
|
||||||
|
params,
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
groups = await _group_map(s)
|
||||||
|
contacts = []
|
||||||
|
for r in rows:
|
||||||
|
d = dict(r)
|
||||||
|
contacts.append(
|
||||||
|
{
|
||||||
|
"id": str(r["id"]),
|
||||||
|
"display_name": _display_name(d),
|
||||||
|
"primary_email": _primary_email(d),
|
||||||
|
"org": r["org"] or "",
|
||||||
|
"title": r["title"] or "",
|
||||||
|
"starred": bool(r["starred"]),
|
||||||
|
"groups": groups.get(str(r["id"]), []),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return {"contacts": contacts}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/contacts/{contact_id}")
|
||||||
|
async def get_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text(f"SELECT {_COLS} FROM plg_contacts_contacts c WHERE c.id = :id"),
|
||||||
|
{"id": str(contact_id)},
|
||||||
|
)
|
||||||
|
).mappings().first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
groups = (await _group_map(s)).get(str(contact_id), [])
|
||||||
|
return _detail(dict(row), groups)
|
||||||
|
|
||||||
|
|
||||||
|
def _derive_name(body: ContactIn) -> str:
|
||||||
|
"""FN: the client's display_name, else the assembled name parts, else first email."""
|
||||||
|
name = body.display_name.strip()
|
||||||
|
if not name:
|
||||||
|
np = body.name_parts
|
||||||
|
name = " ".join(
|
||||||
|
x.strip()
|
||||||
|
for x in (np.prefix, np.given, np.middle, np.family, np.suffix)
|
||||||
|
if x.strip()
|
||||||
|
).strip()
|
||||||
|
if not name and body.emails:
|
||||||
|
name = body.emails[0].value.strip()
|
||||||
|
return name
|
||||||
|
|
||||||
|
|
||||||
|
def _contact_params(body: ContactIn) -> dict[str, object]:
|
||||||
|
bday: date | None = None
|
||||||
|
if body.birthday:
|
||||||
|
try:
|
||||||
|
bday = date.fromisoformat(body.birthday)
|
||||||
|
except ValueError:
|
||||||
|
bday = None
|
||||||
|
return {
|
||||||
|
"name": _derive_name(body),
|
||||||
|
"parts": json.dumps(body.name_parts.model_dump()),
|
||||||
|
"nick": body.nickname.strip(),
|
||||||
|
"emails": json.dumps([e.model_dump() for e in body.emails]),
|
||||||
|
"defemail": body.default_email.strip(),
|
||||||
|
"phones": json.dumps([p.model_dump() for p in body.phones]),
|
||||||
|
"urls": json.dumps([u.model_dump() for u in body.urls]),
|
||||||
|
"addrs": json.dumps([a.model_dump() for a in body.addresses]),
|
||||||
|
"org": body.org.strip(),
|
||||||
|
"dept": body.department.strip(),
|
||||||
|
"title": body.title.strip(),
|
||||||
|
"bday": bday,
|
||||||
|
"notes": body.notes,
|
||||||
|
"starred": body.starred,
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
async def _set_groups(
|
||||||
|
s: AsyncSession, user_id: uuid.UUID, contact_id: str, group_ids: list[str]
|
||||||
|
) -> None:
|
||||||
|
await s.execute(
|
||||||
|
text("DELETE FROM plg_contacts_group_members WHERE contact_id = :c"),
|
||||||
|
{"c": contact_id},
|
||||||
|
)
|
||||||
|
for gid in group_ids:
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_group_members (user_id, group_id, contact_id) "
|
||||||
|
"VALUES (:u, :g, :c) ON CONFLICT DO NOTHING"
|
||||||
|
),
|
||||||
|
{"u": str(user_id), "g": gid, "c": contact_id},
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contacts", dependencies=[Depends(require_csrf)])
|
||||||
|
async def create_contact(body: ContactIn, user_id: CurrentUserId) -> dict[str, str]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
book_id = await _ensure_book(s, user_id)
|
||||||
|
p = _contact_params(body)
|
||||||
|
p.update({"u": str(user_id), "book": book_id, "uid": str(uuid.uuid4())})
|
||||||
|
cid = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"INSERT INTO plg_contacts_contacts "
|
||||||
|
"(user_id, book_id, vcard_uid, name_display, name_parts, nickname, emails, "
|
||||||
|
" default_email, phones, urls, addresses, org, department, title, birthday, "
|
||||||
|
" notes, starred) "
|
||||||
|
"VALUES (:u, :book, :uid, :name, :parts, :nick, :emails, :defemail, :phones, "
|
||||||
|
" :urls, :addrs, :org, :dept, :title, :bday, :notes, :starred) RETURNING id"
|
||||||
|
),
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
).scalar_one()
|
||||||
|
await _set_groups(s, user_id, str(cid), body.group_ids)
|
||||||
|
return {"id": str(cid)}
|
||||||
|
|
||||||
|
|
||||||
|
@router.put("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
async def update_contact(
|
||||||
|
contact_id: uuid.UUID, body: ContactIn, user_id: CurrentUserId
|
||||||
|
) -> dict[str, str]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
p = _contact_params(body)
|
||||||
|
p["id"] = str(contact_id)
|
||||||
|
result = await s.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE plg_contacts_contacts SET "
|
||||||
|
"name_display = :name, name_parts = :parts, nickname = :nick, emails = :emails, "
|
||||||
|
"default_email = :defemail, phones = :phones, urls = :urls, addresses = :addrs, "
|
||||||
|
"org = :org, department = :dept, title = :title, birthday = :bday, notes = :notes, "
|
||||||
|
"starred = :starred, updated_at = now() WHERE id = :id"
|
||||||
|
),
|
||||||
|
p,
|
||||||
|
)
|
||||||
|
if result.rowcount == 0:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
await _set_groups(s, user_id, str(contact_id), body.group_ids)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/contacts/{contact_id}", dependencies=[Depends(require_csrf)])
|
||||||
|
async def delete_contact(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, str]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
await s.execute(
|
||||||
|
text("DELETE FROM plg_contacts_contacts WHERE id = :id"), {"id": str(contact_id)}
|
||||||
|
)
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/contacts/{contact_id}/star", dependencies=[Depends(require_csrf)])
|
||||||
|
async def toggle_star(contact_id: uuid.UUID, user_id: CurrentUserId) -> dict[str, object]:
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
row = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"UPDATE plg_contacts_contacts SET starred = NOT starred WHERE id = :id "
|
||||||
|
"RETURNING starred"
|
||||||
|
),
|
||||||
|
{"id": str(contact_id)},
|
||||||
|
)
|
||||||
|
).first()
|
||||||
|
if row is None:
|
||||||
|
raise HTTPException(status_code=404, detail="not_found")
|
||||||
|
return {"starred": bool(row[0])}
|
||||||
|
|
||||||
|
|
||||||
|
# --- recipients.suggest capability (SPEC §B.5 / §11.3) -----------------------
|
||||||
|
|
||||||
|
@router.get("/suggest")
|
||||||
|
async def suggest(
|
||||||
|
user_id: CurrentUserId, q: str = Query("", max_length=200)
|
||||||
|
) -> list[dict[str, object]]:
|
||||||
|
term = q.strip()
|
||||||
|
if not term:
|
||||||
|
return []
|
||||||
|
like = f"%{term}%"
|
||||||
|
async with _SDK.session(user_id) as s:
|
||||||
|
rows = (
|
||||||
|
await s.execute(
|
||||||
|
text(
|
||||||
|
"SELECT id, name_display, emails, default_email FROM plg_contacts_contacts "
|
||||||
|
"WHERE name_display ILIKE :q OR emails::text ILIKE :q "
|
||||||
|
"ORDER BY lower(name_display) LIMIT 10"
|
||||||
|
),
|
||||||
|
{"q": like},
|
||||||
|
)
|
||||||
|
).mappings().all()
|
||||||
|
out: list[dict[str, object]] = []
|
||||||
|
for r in rows:
|
||||||
|
email = _primary_email(dict(r))
|
||||||
|
if not email:
|
||||||
|
continue
|
||||||
|
out.append(
|
||||||
|
{
|
||||||
|
"name": _display_name(dict(r)),
|
||||||
|
"email": email,
|
||||||
|
"source": "contact",
|
||||||
|
"contact_id": str(r["id"]),
|
||||||
|
}
|
||||||
|
)
|
||||||
|
return out
|
||||||
@@ -0,0 +1,59 @@
|
|||||||
|
"""Request models for the contacts API (SPEC §B.5)."""
|
||||||
|
|
||||||
|
from __future__ import annotations
|
||||||
|
|
||||||
|
from pydantic import BaseModel
|
||||||
|
|
||||||
|
|
||||||
|
class NameParts(BaseModel):
|
||||||
|
prefix: str = "" # "Title" in the edit form (Dr, Mr, …)
|
||||||
|
given: str = ""
|
||||||
|
middle: str = ""
|
||||||
|
family: str = ""
|
||||||
|
suffix: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class EmailEntry(BaseModel):
|
||||||
|
value: str
|
||||||
|
type: str = "personal" # personal | work | other
|
||||||
|
pref: bool = False
|
||||||
|
|
||||||
|
|
||||||
|
class PhoneEntry(BaseModel):
|
||||||
|
value: str
|
||||||
|
type: str = "mobile" # mobile | home | work | other
|
||||||
|
|
||||||
|
|
||||||
|
class UrlEntry(BaseModel):
|
||||||
|
value: str
|
||||||
|
type: str = "website"
|
||||||
|
|
||||||
|
|
||||||
|
class AddressEntry(BaseModel):
|
||||||
|
type: str = "home" # home | work | other
|
||||||
|
country: str = ""
|
||||||
|
street: str = ""
|
||||||
|
postal_code: str = ""
|
||||||
|
city: str = ""
|
||||||
|
|
||||||
|
|
||||||
|
class ContactIn(BaseModel):
|
||||||
|
display_name: str = "" # FN; derived from name parts / first email when blank
|
||||||
|
name_parts: NameParts = NameParts()
|
||||||
|
nickname: str = ""
|
||||||
|
org: str = "" # Company
|
||||||
|
department: str = ""
|
||||||
|
title: str = "" # Job title
|
||||||
|
birthday: str | None = None # "YYYY-MM-DD" (full date) or None
|
||||||
|
emails: list[EmailEntry] = []
|
||||||
|
default_email: str = ""
|
||||||
|
phones: list[PhoneEntry] = []
|
||||||
|
urls: list[UrlEntry] = []
|
||||||
|
addresses: list[AddressEntry] = []
|
||||||
|
notes: str = ""
|
||||||
|
starred: bool = False
|
||||||
|
group_ids: list[str] = []
|
||||||
|
|
||||||
|
|
||||||
|
class GroupIn(BaseModel):
|
||||||
|
name: str
|
||||||
@@ -0,0 +1,591 @@
|
|||||||
|
// contacts · app.page bundle (SPEC §B.6). A full-bleed three-pane contacts app:
|
||||||
|
// shared sidebar template (.wm-side-*, identical to Mail/Calendar) with a
|
||||||
|
// "Contacts" header, an alphabetically grouped contact list, and a detail pane
|
||||||
|
// with an edit-in-place form. Framework-agnostic vanilla DOM; theme-aware via
|
||||||
|
// rgb(var(--app-*)); data comes from the plugin API.
|
||||||
|
|
||||||
|
const esc = (s) => String(s == null ? "" : s).replace(/[&<>"]/g, (c) => ({ "&": "&", "<": "<", ">": ">", '"': """ })[c]);
|
||||||
|
const MONTHS = ["January","February","March","April","May","June","July","August","September","October","November","December"];
|
||||||
|
const EMAIL_TYPES = ["personal", "work", "other"];
|
||||||
|
const PHONE_TYPES = ["mobile", "home", "work", "other"];
|
||||||
|
const ADDR_TYPES = ["home", "work", "other"];
|
||||||
|
const cap = (s) => (s ? s[0].toUpperCase() + s.slice(1) : s);
|
||||||
|
|
||||||
|
// Icons copied from Mail/Calendar (frontend/icons/*.svg) so the apps match exactly.
|
||||||
|
const HIDE_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m4 4l16 16m-3.5-3.244C15.147 17.485 13.618 18 12 18c-3.53 0-6.634-2.452-8.413-4.221c-.47-.467-.705-.7-.854-1.159c-.107-.327-.107-.913 0-1.24c.15-.459.385-.693.855-1.16c.897-.892 2.13-1.956 3.584-2.793M19.5 14.634c.333-.293.638-.582.912-.854l.003-.003c.468-.466.703-.7.852-1.156c.107-.327.107-.914 0-1.241c-.15-.458-.384-.692-.854-1.159C18.633 8.452 15.531 6 12 6q-.507 0-1 .064m2.323 7.436a2 2 0 0 1-2.762-2.889"/></svg>`;
|
||||||
|
const SHOW_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="M3.587 13.779c1.78 1.769 4.883 4.22 8.413 4.22s6.634-2.451 8.413-4.22c.47-.467.705-.7.854-1.159c.107-.327.107-.913 0-1.24c-.15-.458-.385-.692-.854-1.159C18.633 8.452 15.531 6 12 6c-3.53 0-6.634 2.452-8.413 4.221c-.47.467-.705.7-.854 1.159c-.107.327-.107.913 0 1.24c.15.458.384.692.854 1.159"/><path d="M10 12a2 2 0 1 0 4 0a2 2 0 0 0-4 0"/></svg>`;
|
||||||
|
const SETTINGS_ICON = `<svg width="22" height="22" viewBox="0 0 24 24" fill="currentColor"><path d="M19 7.5h-7.628a2.251 2.251 0 0 0-4.244 0H5V9h2.128a2.25 2.25 0 0 0 4.244 0H19zm0 7.5h-2.128a2.251 2.251 0 0 0-4.244 0H5v1.5h7.628a2.251 2.251 0 0 0 4.244 0H19z"/></svg>`;
|
||||||
|
const LOGOUT_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-linecap="round" stroke-linejoin="round" stroke-width="2"><path d="m16 17l5-5l-5-5m5 5H9m0 9H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4"/></svg>`;
|
||||||
|
const PLUS_ICON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="2" stroke-linecap="round"><path d="M12 5v14M5 12h14"/></svg>`;
|
||||||
|
const IC_BOOK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="4" y="3" width="14" height="18" rx="2"/><path d="M4 8h-.5M4 12h-.5M4 16h-.5"/><circle cx="11" cy="10" r="2"/><path d="M8 16c.5-1.5 1.7-2 3-2s2.5.5 3 2"/></svg>`;
|
||||||
|
const IC_STAR = `<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="M12 3l2.6 5.3 5.9.9-4.3 4.1 1 5.8L12 16.9 6.8 19.6l1-5.8L3.5 9.7l5.9-.9z"/></svg>`;
|
||||||
|
const IC_CLOCK = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="12" r="9"/><path d="M12 7v5l3 2"/></svg>`;
|
||||||
|
const IC_GROUP = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="9" cy="8" r="3"/><path d="M3 20c0-3 3-5 6-5s6 2 6 5"/><path d="M16 6a3 3 0 0 1 0 5m1 8c0-2-1-3.5-2.5-4.3"/></svg>`;
|
||||||
|
const IC_PERSON = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><circle cx="12" cy="8" r="4"/><path d="M4 21c0-4 4-6 8-6s8 2 8 6"/></svg>`;
|
||||||
|
const IC_MAIL = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="5" width="18" height="14" rx="2"/><path d="m3 7 9 6 9-6"/></svg>`;
|
||||||
|
const IC_PHONE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M4 4h4l2 5-3 2a12 12 0 0 0 6 6l2-3 5 2v4a2 2 0 0 1-2 2A16 16 0 0 1 2 6a2 2 0 0 1 2-2"/></svg>`;
|
||||||
|
const IC_CHAT = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M21 15a2 2 0 0 1-2 2H8l-4 4V5a2 2 0 0 1 2-2h13a2 2 0 0 1 2 2z"/></svg>`;
|
||||||
|
const IC_COMPOSE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M12 20h9"/><path d="M16.5 3.5a2.1 2.1 0 0 1 3 3L7 19l-4 1 1-4z"/></svg>`;
|
||||||
|
const IC_CAL = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><rect x="3" y="4" width="18" height="18" rx="2"/><path d="M16 2v4M8 2v4M3 10h18"/></svg>`;
|
||||||
|
const IC_PENCIL = IC_COMPOSE;
|
||||||
|
const IC_SORT = `<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="M7 4v16m0 0-3-3m3 3 3-3M17 20V4m0 0-3 3m3-3 3 3"/></svg>`;
|
||||||
|
const IC_MORE = `<svg width="18" height="18" viewBox="0 0 24 24" fill="currentColor"><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>`;
|
||||||
|
const CHEV = `<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="M4 6l4 4 4-4"/></svg>`;
|
||||||
|
const themeGlyph = () => (document.documentElement.classList.contains("dark") ? "☀" : "☾");
|
||||||
|
|
||||||
|
const AVATAR_COLORS = ["#ef4444","#f97316","#eab308","#22c55e","#14b8a6","#3b82f6","#8b5cf6","#ec4899","#6b7280"];
|
||||||
|
function avatarColor(key) { let h = 0; for (const c of String(key || "?")) h = (h * 31 + c.charCodeAt(0)) >>> 0; return AVATAR_COLORS[h % AVATAR_COLORS.length]; }
|
||||||
|
function initials(name) {
|
||||||
|
const parts = String(name || "").trim().split(/\s+/).filter(Boolean);
|
||||||
|
if (!parts.length) return "?";
|
||||||
|
return ((parts[0][0] || "") + (parts.length > 1 ? parts[parts.length - 1][0] : "")).toUpperCase();
|
||||||
|
}
|
||||||
|
function avatarHtml(name, email, size) {
|
||||||
|
const s = size || 40;
|
||||||
|
return `<span class="ct-av" style="width:${s}px;height:${s}px;background:${avatarColor(email || name)};font-size:${Math.round(s * 0.38)}px">${esc(initials(name || email))}</span>`;
|
||||||
|
}
|
||||||
|
function shortDate(iso) {
|
||||||
|
const d = new Date(iso);
|
||||||
|
if (isNaN(d)) return "";
|
||||||
|
const now = new Date();
|
||||||
|
const opts = d.getFullYear() === now.getFullYear() ? { month: "short", day: "numeric" } : { year: "numeric", month: "short", day: "numeric" };
|
||||||
|
return d.toLocaleDateString("en-GB", opts);
|
||||||
|
}
|
||||||
|
|
||||||
|
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 .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) */
|
||||||
|
.ct-rail{width:60px;align-items:center}
|
||||||
|
.ct-railtop{display:flex;flex-direction:column;align-items:center;gap:4px;padding:12px 0 6px}
|
||||||
|
.ct-railprimary{padding:2px 0 8px}
|
||||||
|
.ct-railnav{flex:1;min-height:0;overflow:auto;display:flex;flex-direction:column;align-items:center;gap:4px;padding:2px 0}
|
||||||
|
.ct-railfoot{width:100%;display:flex;flex-direction:column;align-items:center;gap:4px;padding:8px 0;border-top:1px solid rgb(var(--app-border))}
|
||||||
|
.ct-railbtn{display:flex;align-items:center;justify-content:center;width:38px;height:38px;border-radius:9px;color:rgb(var(--app-muted));cursor:pointer;background:none;border:0}
|
||||||
|
.ct-railbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.ct-railbtn.active{background:rgb(var(--app-active));color:rgb(var(--app-fg))}
|
||||||
|
.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-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
|
||||||
|
.ct-bar{display:flex;align-items:center;gap:6px;height:49px;padding:0 14px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
|
||||||
|
.ct-bar .sp{flex:1}
|
||||||
|
.ct-iconbtn{display:inline-flex;align-items:center;justify-content:center;width:32px;height:32px;border-radius:50%;color:rgb(var(--app-muted));cursor:pointer}
|
||||||
|
.ct-iconbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
|
||||||
|
.ct-barbtn{display:inline-flex;align-items:center;gap:6px;padding:5px 10px;border-radius:6px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px}
|
||||||
|
.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-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-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
|
||||||
|
.ct-detail{flex:1;min-height:0;overflow:auto;padding:20px}
|
||||||
|
.ct-card{max-width:760px;margin:0 auto;border:1px solid rgb(var(--app-border));border-radius:12px;padding:22px 26px}
|
||||||
|
.ct-cardhead{display:flex;gap:20px;align-items:flex-start;position:relative}
|
||||||
|
.ct-headinfo{flex:1;min-width:0}
|
||||||
|
.ct-name{font-size:22px;font-weight:700;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-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-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
|
||||||
|
.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}
|
||||||
|
.ct-mailrow{display:flex;gap:12px;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
|
||||||
|
.ct-mailmain{min-width:0;flex:1}
|
||||||
|
.ct-mailsub{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ct-mailsnip{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
|
||||||
|
.ct-maildate{color:rgb(var(--app-muted));font-size:12px;flex:0 0 auto}
|
||||||
|
.ct-findbtn{margin-top:14px;display:inline-flex;align-items:center;gap:8px;padding:7px 12px;border-radius:7px;background:rgb(var(--app-hover));cursor:pointer;font-size:13px}
|
||||||
|
/* --- edit form --- */
|
||||||
|
.ct-form{max-width:640px;margin:0 auto;padding:6px 0 40px}
|
||||||
|
.ct-formav{text-align:center;margin-bottom:6px}
|
||||||
|
.ct-formav .ct-av{margin:0 auto}
|
||||||
|
.ct-formav .cap{color:rgb(var(--app-muted));font-size:12px;margin-top:6px}
|
||||||
|
.ct-frow{display:flex;gap:10px;margin-bottom:12px}
|
||||||
|
.ct-fsec{font-weight:700;margin:22px 0 10px}
|
||||||
|
.ct-flabel{width:90px;flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px;padding-top:9px}
|
||||||
|
.ct-in,.ct-sel{width:100%;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:6px;padding:8px 10px;color:rgb(var(--app-fg));font-size:14px;outline:none}
|
||||||
|
.ct-in:focus,.ct-sel:focus{border-color:rgb(var(--app-accent))}
|
||||||
|
.ct-multi{display:flex;gap:8px;align-items:center;margin-bottom:8px}
|
||||||
|
.ct-multi .ct-sel{width:130px;flex:0 0 auto}
|
||||||
|
.ct-rm{color:rgb(var(--app-muted));cursor:pointer;padding:4px;flex:0 0 auto}
|
||||||
|
.ct-rm:hover{color:rgb(var(--app-fg))}
|
||||||
|
.ct-addline{color:rgb(var(--app-accent));cursor:pointer;font-size:13px;display:inline-flex;align-items:center;gap:6px;margin-top:2px}
|
||||||
|
.ct-menu{position:absolute;z-index:20;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:8px;box-shadow:0 8px 24px rgb(0 0 0 / .3);padding:6px;min-width:180px}
|
||||||
|
.ct-menu button,.ct-menu label{display:flex;align-items:center;gap:8px;width:100%;text-align:left;padding:7px 9px;border-radius:6px;cursor:pointer;font-size:13px;color:rgb(var(--app-fg))}
|
||||||
|
.ct-menu button:hover,.ct-menu label:hover{background:rgb(var(--app-hover))}
|
||||||
|
.ct-primary{background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer}
|
||||||
|
.ct-primary:hover{opacity:.9}
|
||||||
|
.ct-ghostbtn{padding:6px 14px;border-radius:6px;cursor:pointer;font-size:13px}
|
||||||
|
.ct-ghostbtn:hover{background:rgb(var(--app-hover))}
|
||||||
|
`;
|
||||||
|
|
||||||
|
export default function mount(el, ctx) {
|
||||||
|
if (!document.getElementById("ct-styles")) {
|
||||||
|
const style = document.createElement("style");
|
||||||
|
style.id = "ct-styles";
|
||||||
|
style.textContent = CSS;
|
||||||
|
document.head.appendChild(style);
|
||||||
|
}
|
||||||
|
|
||||||
|
const state = {
|
||||||
|
filter: "all", group: "", groupLabel: "All contacts",
|
||||||
|
q: "",
|
||||||
|
contacts: [], groups: [],
|
||||||
|
selectedId: null, detail: null,
|
||||||
|
mode: "view", // view | edit | new
|
||||||
|
mail: null, // { loading, threads }
|
||||||
|
sortAsc: true,
|
||||||
|
menu: null, // "groups" | "more" | null
|
||||||
|
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
|
||||||
|
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
|
||||||
|
menuOpen: false,
|
||||||
|
me: "", quota: "",
|
||||||
|
apps: [{ label: "Mail", to: "/mail" }, { label: "Contacts", to: null }],
|
||||||
|
};
|
||||||
|
|
||||||
|
const api = ctx.api;
|
||||||
|
|
||||||
|
// ---- data loading --------------------------------------------------------
|
||||||
|
async function loadGroups() {
|
||||||
|
try { state.groups = await api.pluginFetch("/groups"); } catch { state.groups = []; }
|
||||||
|
}
|
||||||
|
async function loadContacts() {
|
||||||
|
const qs = `filter=${encodeURIComponent(state.filter)}&group=${encodeURIComponent(state.group)}&q=${encodeURIComponent(state.q)}`;
|
||||||
|
const res = await api.pluginFetch(`/contacts?${qs}`);
|
||||||
|
state.contacts = res.contacts || [];
|
||||||
|
if (!state.contacts.find((c) => c.id === state.selectedId)) {
|
||||||
|
state.selectedId = state.contacts[0] ? state.contacts[0].id : null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
async function loadDetail(id) {
|
||||||
|
if (!id) { state.detail = null; state.mail = null; return; }
|
||||||
|
state.detail = await api.pluginFetch(`/contacts/${id}`);
|
||||||
|
loadMail(state.detail.default_email);
|
||||||
|
}
|
||||||
|
async function loadMail(email) {
|
||||||
|
state.mail = { loading: true, threads: [] };
|
||||||
|
if (!email) { state.mail = { loading: false, threads: [] }; render(); return; }
|
||||||
|
try {
|
||||||
|
const res = await api.fetch(`/search?q=${encodeURIComponent('"' + email + '"')}&limit=5`);
|
||||||
|
state.mail = { loading: false, threads: (res.threads || []).map((h) => h.thread) };
|
||||||
|
} catch { state.mail = { loading: false, threads: [] }; }
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function selectContact(id) {
|
||||||
|
state.selectedId = id;
|
||||||
|
state.mode = "view";
|
||||||
|
state.menu = null;
|
||||||
|
await loadDetail(id);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function reloadList(keepSelection) {
|
||||||
|
if (!keepSelection) state.selectedId = state.selectedId;
|
||||||
|
await loadContacts();
|
||||||
|
await loadDetail(state.selectedId);
|
||||||
|
render();
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- sidebar (shared .wm-side-* template; identical to Mail/Calendar) -----
|
||||||
|
function navItem(icon, label, active, act) {
|
||||||
|
return `<button class="wm-side-item${active ? " active" : ""}" data-act="${act}"><span class="ct-dico">${icon}</span>${esc(label)}</button>`;
|
||||||
|
}
|
||||||
|
function leftPanel() {
|
||||||
|
const f = state.filter;
|
||||||
|
const grp = (g) => `<button class="wm-side-item${state.group === g.id ? " active" : ""}" data-group="${g.id}" data-groupname="${esc(g.name)}"><span class="ct-dico">${IC_GROUP}</span>${esc(g.name)}</button>`;
|
||||||
|
return `
|
||||||
|
<div class="wm-side-head">
|
||||||
|
<div class="wm-side-switch">
|
||||||
|
<button data-act="menu">Contacts ${CHEV}</button>
|
||||||
|
${state.menuOpen ? `<div class="wm-side-menu">${state.apps.map((a, i) => `<button data-app="${i}">${esc(a.label)}</button>`).join("")}</div>` : ""}
|
||||||
|
</div>
|
||||||
|
<div class="wm-side-headicons">
|
||||||
|
<button class="wm-side-iconbtn" data-act="left-toggle" title="Collapse sidebar">${HIDE_ICON}</button>
|
||||||
|
<button class="wm-side-iconbtn wm-side-themebtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wm-side-primary">
|
||||||
|
<button data-act="new">${PLUS_ICON}New contact</button>
|
||||||
|
</div>
|
||||||
|
<div class="wm-side-body">
|
||||||
|
<div class="wm-side-nav">
|
||||||
|
${navItem(IC_BOOK, "All contacts", !state.group && f === "all", "filter-all")}
|
||||||
|
${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-nav">
|
||||||
|
${state.groups.map(grp).join("")}
|
||||||
|
${navItem(IC_PERSON, "No group", f === "nogroup", "filter-nogroup")}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="wm-side-foot">
|
||||||
|
<button data-act="settings">${SETTINGS_ICON}Settings</button>
|
||||||
|
<button data-act="signout">${LOGOUT_ICON}Sign out</button>
|
||||||
|
${state.quota ? `<div class="wm-side-quota">${esc(state.quota)}</div>` : ""}
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Collapsed sidebar: a narrow icon-only rail (like Mail) that keeps the expand
|
||||||
|
// toggle, so it can always be re-opened.
|
||||||
|
function railPanel() {
|
||||||
|
const nav = (icon, act, on, title) => `<button class="ct-railbtn${on ? " active" : ""}" data-act="${act}" title="${title}">${icon}</button>`;
|
||||||
|
return `<div class="wm-side ct-rail" style="flex:0 0 auto">
|
||||||
|
<div class="ct-railtop">
|
||||||
|
<button class="ct-railbtn" data-act="left-toggle" title="Expand sidebar">${SHOW_ICON}</button>
|
||||||
|
<button class="ct-railbtn" data-act="theme" title="Toggle theme">${themeGlyph()}</button>
|
||||||
|
</div>
|
||||||
|
<div class="ct-railprimary"><button class="ct-railnew" data-act="new" title="New contact">${PLUS_ICON}</button></div>
|
||||||
|
<div class="ct-railnav">
|
||||||
|
${nav(IC_BOOK, "filter-all", !state.group && state.filter === "all", "All contacts")}
|
||||||
|
${nav(IC_STAR, "filter-vip", !state.group && state.filter === "vip", "VIPs")}
|
||||||
|
${nav(IC_CLOCK, "filter-recent", !state.group && state.filter === "recent", "Recent")}
|
||||||
|
</div>
|
||||||
|
<div class="ct-railfoot">
|
||||||
|
<button class="ct-railbtn" data-act="settings" title="Settings">${SETTINGS_ICON}</button>
|
||||||
|
<button class="ct-railbtn" data-act="signout" title="Sign out">${LOGOUT_ICON}</button>
|
||||||
|
</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- middle: contact list ------------------------------------------------
|
||||||
|
function listPanel() {
|
||||||
|
const rows = [...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;
|
||||||
|
});
|
||||||
|
let html = "";
|
||||||
|
let letter = null;
|
||||||
|
for (const c of rows) {
|
||||||
|
const name = c.display_name || c.primary_email || "(no name)";
|
||||||
|
const ch = (name[0] || "#").toUpperCase();
|
||||||
|
const key = /[A-Z]/.test(ch) ? ch : "#";
|
||||||
|
if (key !== letter) { letter = key; html += `<div class="ct-alpha">${esc(letter)}</div>`; }
|
||||||
|
html += `<div class="ct-row${c.id === state.selectedId ? " sel" : ""}" data-contact="${c.id}">
|
||||||
|
${avatarHtml(c.display_name, c.primary_email, 38)}
|
||||||
|
<div class="ct-rowmain">
|
||||||
|
<div class="ct-rowname">${esc(name)}</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>`;
|
||||||
|
return `
|
||||||
|
<div class="ct-bar">
|
||||||
|
<input type="checkbox" class="ct-cbx" disabled title="Select all"/>
|
||||||
|
<span class="sp"></span>
|
||||||
|
<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>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- 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>`;
|
||||||
|
}
|
||||||
|
const disabled = state.detail ? "" : "opacity:.4;pointer-events:none";
|
||||||
|
return `<div class="ct-bar" style="${disabled}">
|
||||||
|
<button class="ct-barbtn" data-act="edit">${IC_PENCIL} Edit</button>
|
||||||
|
<div style="position:relative"><button class="ct-barbtn" data-act="menu-groups">${IC_GROUP} Groups ${CHEV}</button>${state.menu === "groups" ? groupsMenu() : ""}</div>
|
||||||
|
<div style="position:relative"><button class="ct-barbtn" data-act="menu-more">More ${CHEV}</button>${state.menu === "more" ? moreMenu() : ""}</div>
|
||||||
|
</div>`;
|
||||||
|
}
|
||||||
|
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>`;
|
||||||
|
}
|
||||||
|
function moreMenu() {
|
||||||
|
return `<div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function detailPanel() {
|
||||||
|
if (state.mode === "edit" || state.mode === "new") return editForm();
|
||||||
|
const c = state.detail;
|
||||||
|
if (!c) return `<div class="ct-detail"><div class="ct-empty">Select a contact.</div></div>`;
|
||||||
|
const groups = (c.groups || []).map((g) => esc(g.name)).join(", ");
|
||||||
|
const emailRows = (c.emails && c.emails.length ? c.emails : (c.default_email ? [{ value: c.default_email, type: "personal" }] : []));
|
||||||
|
const emailsHtml = emailRows.map((e) => `<div class="ct-drow"><span class="ct-dico">${IC_MAIL}</span><div><div class="ct-dlabel">${esc(cap(e.type || "email"))}</div><div class="ct-dval"><a href="mailto:${esc(e.value)}">${esc(e.value)}</a></div></div></div>`).join("");
|
||||||
|
return `<div class="ct-detail"><div class="ct-card">
|
||||||
|
<div class="ct-cardhead">
|
||||||
|
${avatarHtml(c.display_name, c.default_email, 84)}
|
||||||
|
<div class="ct-headinfo">
|
||||||
|
<div class="ct-name">${esc(c.display_name || c.default_email || "(no name)")}</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>
|
||||||
|
</div>
|
||||||
|
<button class="ct-star${c.starred ? " on" : ""}" data-act="star" title="VIP">${IC_STAR}</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-sec">
|
||||||
|
<div class="ct-sectitle">Contact details</div>
|
||||||
|
${groups ? `<div class="ct-drow"><span class="ct-dico">${IC_GROUP}</span><div><div class="ct-dlabel">Groups</div><div class="ct-dval">${groups}</div></div></div>` : ""}
|
||||||
|
${emailsHtml}
|
||||||
|
<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>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-sec">
|
||||||
|
<div class="ct-sectitle">Mail</div>
|
||||||
|
${mailSection(c)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-sec">
|
||||||
|
<div class="ct-sectitle">Events</div>
|
||||||
|
<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>
|
||||||
|
</div>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function mailSection(c) {
|
||||||
|
if (!state.mail || state.mail.loading) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">Loading…</div>`;
|
||||||
|
if (!state.mail.threads.length) return `<div class="ct-dlabel" style="padding:12px 2px;border-top:1px solid rgb(var(--app-border))">No matches found</div>`;
|
||||||
|
const rows = state.mail.threads.map((t) => {
|
||||||
|
const who = (t.participants && t.participants[0]) ? (t.participants[0].name || t.participants[0].email) : (c.display_name || "");
|
||||||
|
return `<div class="ct-mailrow">${avatarHtml(who, (t.participants && t.participants[0] && t.participants[0].email) || c.default_email, 32)}
|
||||||
|
<div class="ct-mailmain"><div class="ct-mailsub">${esc(t.subject || "(no subject)")}</div><div class="ct-mailsnip">${esc(t.snippet || "")}</div></div>
|
||||||
|
<div class="ct-maildate">${esc(shortDate(t.last_message_at))}</div></div>`;
|
||||||
|
}).join("");
|
||||||
|
return rows + `<button class="ct-findbtn" data-act="find-mail">${IC_MAIL} Find all conversations with ${esc((c.display_name || "").split(" ")[0] || "this contact")}</button>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- edit form -----------------------------------------------------------
|
||||||
|
function typeSelect(kind, current) {
|
||||||
|
const opts = kind === "email" ? EMAIL_TYPES : kind === "phone" ? PHONE_TYPES : ADDR_TYPES;
|
||||||
|
return `<select class="ct-sel" data-type>${opts.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
|
||||||
|
}
|
||||||
|
function emailRow(e) {
|
||||||
|
return `<div class="ct-multi" data-emailrow>${typeSelect("email", (e && e.type) || "personal")}<input class="ct-in" data-val placeholder="email@example.com" value="${esc(e ? e.value : "")}"/><span class="ct-rm" data-act="rm-email">✕</span></div>`;
|
||||||
|
}
|
||||||
|
function phoneRow(p) {
|
||||||
|
return `<div class="ct-multi" data-phonerow>${typeSelect("phone", (p && p.type) || "mobile")}<input class="ct-in" data-val placeholder="0123 456 789" value="${esc(p ? p.value : "")}"/><span class="ct-rm" data-act="rm-phone">✕</span></div>`;
|
||||||
|
}
|
||||||
|
function editForm() {
|
||||||
|
const c = state.detail && state.mode === "edit" ? state.detail : {};
|
||||||
|
const np = c.name_parts || {};
|
||||||
|
const emails = (c.emails && c.emails.length ? c.emails : [null]);
|
||||||
|
const phones = (c.phones && c.phones.length ? c.phones : [null]);
|
||||||
|
const url0 = (c.urls && c.urls[0]) || {};
|
||||||
|
const ad0 = (c.addresses && c.addresses[0]) || {};
|
||||||
|
const bday = c.birthday ? new Date(c.birthday) : null;
|
||||||
|
const monthSel = `<select class="ct-sel" data-f=bmonth><option value="">Month</option>${MONTHS.map((m, i) => `<option value="${i + 1}"${bday && bday.getMonth() === i ? " selected" : ""}>${m}</option>`).join("")}</select>`;
|
||||||
|
return `<div class="ct-detail"><div class="ct-form">
|
||||||
|
<div class="ct-formav">${avatarHtml(c.display_name, c.default_email, 88)}<div class="cap">Photo from initials</div></div>
|
||||||
|
<div class="ct-frow">
|
||||||
|
<input class="ct-in" data-f=prefix placeholder="Title" style="flex:0 0 110px" value="${esc(np.prefix || "")}"/>
|
||||||
|
<input class="ct-in" data-f=given placeholder="First name" value="${esc(np.given || "")}"/>
|
||||||
|
<input class="ct-in" data-f=family placeholder="Last name" value="${esc(np.family || "")}"/>
|
||||||
|
</div>
|
||||||
|
<div class="ct-frow">
|
||||||
|
<input class="ct-in" data-f=title placeholder="Job title" value="${esc(c.title || "")}"/>
|
||||||
|
<input class="ct-in" data-f=department placeholder="Department" value="${esc(c.department || "")}"/>
|
||||||
|
<input class="ct-in" data-f=org placeholder="Company" value="${esc(c.org || "")}"/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Personal</div>
|
||||||
|
<div class="ct-frow"><div class="ct-flabel">Nickname</div><input class="ct-in" data-f=nickname value="${esc(c.nickname || "")}"/></div>
|
||||||
|
<div class="ct-frow"><div class="ct-flabel">Birthday</div>${monthSel}
|
||||||
|
<input class="ct-in" data-f=bday placeholder="Day" style="flex:0 0 90px" value="${bday ? bday.getDate() : ""}"/>
|
||||||
|
<input class="ct-in" data-f=byear placeholder="Year" style="flex:0 0 110px" value="${bday ? bday.getFullYear() : ""}"/></div>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Email</div>
|
||||||
|
<div data-emails>${emails.map(emailRow).join("")}</div>
|
||||||
|
<span class="ct-addline" data-act="add-email">${PLUS_ICON} Add email</span>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Phone</div>
|
||||||
|
<div data-phones>${phones.map(phoneRow).join("")}</div>
|
||||||
|
<span class="ct-addline" data-act="add-phone">${PLUS_ICON} Add phone</span>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Online</div>
|
||||||
|
<div class="ct-multi"><select class="ct-sel" data-f=urltype><option>Website</option></select><input class="ct-in" data-f=url placeholder="www.example.com" value="${esc(url0.value || "")}"/></div>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Address</div>
|
||||||
|
<div class="ct-frow">${typeSelectAddr(ad0.type || "home")}<input class="ct-in" data-f=country placeholder="Country" value="${esc(ad0.country || "")}"/></div>
|
||||||
|
<div class="ct-frow"><input class="ct-in" data-f=street placeholder="Street" value="${esc(ad0.street || "")}"/></div>
|
||||||
|
<div class="ct-frow"><input class="ct-in" data-f=postal placeholder="Postal code" style="flex:0 0 160px" value="${esc(ad0.postal_code || "")}"/><input class="ct-in" data-f=city placeholder="City" value="${esc(ad0.city || "")}"/></div>
|
||||||
|
|
||||||
|
<div class="ct-fsec">Notes</div>
|
||||||
|
<textarea class="ct-in" data-f=notes rows="3">${esc(c.notes || "")}</textarea>
|
||||||
|
</div></div>`;
|
||||||
|
}
|
||||||
|
function typeSelectAddr(current) {
|
||||||
|
return `<select class="ct-sel" data-f=addrtype style="flex:0 0 130px">${ADDR_TYPES.map((o) => `<option value="${o}"${o === current ? " selected" : ""}>${esc(cap(o))}</option>`).join("")}</select>`;
|
||||||
|
}
|
||||||
|
|
||||||
|
function collectForm() {
|
||||||
|
const v = (sel) => { const n = el.querySelector(sel); return n ? n.value.trim() : ""; };
|
||||||
|
const emails = [...el.querySelectorAll("[data-emailrow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((e) => e.value);
|
||||||
|
const phones = [...el.querySelectorAll("[data-phonerow]")].map((r) => ({ value: r.querySelector("[data-val]").value.trim(), type: r.querySelector("[data-type]").value })).filter((p) => p.value);
|
||||||
|
const given = v("[data-f=given]"), family = v("[data-f=family]"), prefix = v("[data-f=prefix]");
|
||||||
|
const display = [prefix, given, family].filter(Boolean).join(" ") || (emails[0] ? emails[0].value : "");
|
||||||
|
const month = v("[data-f=bmonth]"), day = v("[data-f=bday]"), year = v("[data-f=byear]");
|
||||||
|
let birthday = null;
|
||||||
|
if (month && day && year) birthday = `${year.padStart(4, "0")}-${String(month).padStart(2, "0")}-${String(day).padStart(2, "0")}`;
|
||||||
|
const url = v("[data-f=url]");
|
||||||
|
const addr = { type: v("[data-f=addrtype]") || "home", country: v("[data-f=country]"), street: v("[data-f=street]"), postal_code: v("[data-f=postal]"), city: v("[data-f=city]") };
|
||||||
|
const hasAddr = addr.country || addr.street || addr.postal_code || addr.city;
|
||||||
|
return {
|
||||||
|
display_name: display,
|
||||||
|
name_parts: { prefix, given, family, middle: "", suffix: "" },
|
||||||
|
nickname: v("[data-f=nickname]"),
|
||||||
|
title: v("[data-f=title]"), department: v("[data-f=department]"), org: v("[data-f=org]"),
|
||||||
|
birthday,
|
||||||
|
emails, default_email: emails[0] ? emails[0].value : "",
|
||||||
|
phones,
|
||||||
|
urls: url ? [{ value: url, type: "website" }] : [],
|
||||||
|
addresses: hasAddr ? [addr] : [],
|
||||||
|
notes: v("[data-f=notes]"),
|
||||||
|
starred: state.detail ? !!state.detail.starred : false,
|
||||||
|
group_ids: state.detail ? (state.detail.groups || []).map((g) => g.id) : [],
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function saveForm() {
|
||||||
|
const body = collectForm();
|
||||||
|
if (state.mode === "new") {
|
||||||
|
const res = await api.pluginFetch("/contacts", { method: "POST", body: JSON.stringify(body) });
|
||||||
|
state.selectedId = res.id;
|
||||||
|
} else {
|
||||||
|
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
}
|
||||||
|
state.mode = "view";
|
||||||
|
await loadGroups();
|
||||||
|
await reloadList(true);
|
||||||
|
}
|
||||||
|
|
||||||
|
// ---- render + wire -------------------------------------------------------
|
||||||
|
function render() {
|
||||||
|
el.innerHTML = `<div class="ct-app">
|
||||||
|
${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>
|
||||||
|
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
|
||||||
|
</div>`;
|
||||||
|
wire();
|
||||||
|
}
|
||||||
|
|
||||||
|
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; 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(); });
|
||||||
|
act("settings", () => ctx.navigate("/settings?from=%2Fp%2Fcontacts"));
|
||||||
|
act("signout", async () => { await api.fetch("/auth/logout", { method: "POST" }); ctx.navigate("/login"); });
|
||||||
|
|
||||||
|
act("filter-all", () => setFilter("all"));
|
||||||
|
act("filter-vip", () => setFilter("vip"));
|
||||||
|
act("filter-recent", () => setFilter("recent"));
|
||||||
|
act("filter-nogroup", () => setFilter("nogroup"));
|
||||||
|
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; reloadList(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
|
||||||
|
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
|
||||||
|
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; render(); });
|
||||||
|
|
||||||
|
// detail actions
|
||||||
|
act("edit", () => { state.mode = "edit"; state.menu = null; render(); });
|
||||||
|
act("cancel", () => { state.mode = "view"; render(); });
|
||||||
|
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("invite", () => ctx.navigate("/p/calendar"));
|
||||||
|
act("find-mail", () => ctx.navigate(`/mail/search/${encodeURIComponent('"' + state.detail.default_email + '"')}`));
|
||||||
|
|
||||||
|
// menus
|
||||||
|
act("menu-groups", (e) => { e.stopPropagation(); state.menu = state.menu === "groups" ? null : "groups"; render(); });
|
||||||
|
act("menu-more", (e) => { e.stopPropagation(); state.menu = state.menu === "more" ? null : "more"; render(); });
|
||||||
|
on("[data-stop]", "click", (e) => e.stopPropagation());
|
||||||
|
on("[data-toggle-group]", "change", async (e) => {
|
||||||
|
const gid = e.currentTarget.getAttribute("data-toggle-group");
|
||||||
|
const cur = new Set((state.detail.groups || []).map((g) => g.id));
|
||||||
|
if (e.currentTarget.checked) cur.add(gid); else cur.delete(gid);
|
||||||
|
const body = detailToBody(state.detail, [...cur]);
|
||||||
|
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
|
||||||
|
await loadGroups(); await loadDetail(state.selectedId); render();
|
||||||
|
});
|
||||||
|
act("delete", async () => {
|
||||||
|
state.menu = null;
|
||||||
|
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
|
||||||
|
state.selectedId = null;
|
||||||
|
await reloadList(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
// 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); });
|
||||||
|
on("[data-act=rm-email]", "click", (e) => e.currentTarget.closest("[data-emailrow]").remove());
|
||||||
|
on("[data-act=rm-phone]", "click", (e) => e.currentTarget.closest("[data-phonerow]").remove());
|
||||||
|
|
||||||
|
// close popover menus on outside click
|
||||||
|
if (state.menu || state.menuOpen) {
|
||||||
|
setTimeout(() => {
|
||||||
|
const close = () => { state.menu = null; state.menuOpen = false; document.removeEventListener("click", close); render(); };
|
||||||
|
document.addEventListener("click", close);
|
||||||
|
}, 0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Map a loaded detail contact back into a ContactIn body (for group toggles).
|
||||||
|
function detailToBody(c, groupIds) {
|
||||||
|
return {
|
||||||
|
display_name: c.display_name, name_parts: c.name_parts || {}, nickname: c.nickname || "",
|
||||||
|
title: c.title || "", department: c.department || "", org: c.org || "",
|
||||||
|
birthday: c.birthday || null, emails: c.emails || [], default_email: c.default_email || "",
|
||||||
|
phones: c.phones || [], urls: c.urls || [], addresses: c.addresses || [],
|
||||||
|
notes: c.notes || "", starred: !!c.starred, group_ids: groupIds,
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
|
async function loadChrome() {
|
||||||
|
try {
|
||||||
|
const manifest = await api.fetch("/plugins/ui-manifest");
|
||||||
|
const pages = (manifest.plugins || []).flatMap((p) => p.pages.map((pg) => ({ label: pg.nav.label, to: pg.route === "/contacts" ? null : "/p" + pg.route })));
|
||||||
|
state.apps = [{ label: "Mail", to: "/mail" }, ...pages];
|
||||||
|
} catch { /* keep defaults */ }
|
||||||
|
try { const accounts = await api.fetch("/accounts"); if (accounts && accounts[0]) state.me = accounts[0].email; } catch { /* ignore */ }
|
||||||
|
try {
|
||||||
|
const q = await api.fetch("/settings/quota");
|
||||||
|
if (q && q.quota_bytes) {
|
||||||
|
const pct = (q.used_bytes / q.quota_bytes) * 100;
|
||||||
|
const gb = q.quota_bytes / 1024 ** 3;
|
||||||
|
state.quota = `${pct >= 10 ? Math.round(pct) : Math.round(pct * 10) / 10}% of ${Number.isInteger(gb) ? gb : gb.toFixed(1)} GB`;
|
||||||
|
}
|
||||||
|
} catch { /* ignore */ }
|
||||||
|
}
|
||||||
|
|
||||||
|
(async () => {
|
||||||
|
await loadChrome();
|
||||||
|
await loadGroups();
|
||||||
|
await loadContacts();
|
||||||
|
await loadDetail(state.selectedId);
|
||||||
|
render();
|
||||||
|
})();
|
||||||
|
|
||||||
|
return () => { el.innerHTML = ""; };
|
||||||
|
}
|
||||||
Binary file not shown.
|
After Width: | Height: | Size: 137 KiB |
Reference in New Issue
Block a user