Contacts: full detail pane, search, selection UX, empty illustration; Calendar invite deep-link
Contacts plugin: - Detail pane now renders ALL populated fields (job/company subtitle, every email, phones, addresses, websites, nickname, birthday, notes) — not just the email. - Company fallback for the display name: personal name → company → email, applied at read time so existing contacts whose name_display holds only the email now show the company without a re-save; name_display no longer stores emails going forward. - Search box in the list top bar (like Calendar): debounced, matches name/email/company, keeps focus + caret across re-renders. - Mail-style selection: hovering a row's avatar reveals a square checkbox; once anything is checked every row shows one (select mode), the bar "select all" matches and aligns with the row checkboxes, and the detail pane shows "N contact(s) selected". - Delete contact now confirms via a modal. - Empty reading pane shows contacts-illustration.svg (served from the plugin's ui/ dir), mirroring Mail's email-illustration empty state. Calendar: - Invite from a contact opens /p/calendar?guest=<email>, which pops a new-event form with that guest already added, then strips the param. Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01U72FZiF1W1pb8XxyWBTREA
This commit is contained in:
@@ -17,4 +17,4 @@ contacts = "webmail_plugin_contacts:plugin"
|
||||
packages = ["webmail_plugin_contacts"]
|
||||
|
||||
[tool.setuptools.package-data]
|
||||
webmail_plugin_contacts = ["ui/*.js", "migrations/versions/*.py"]
|
||||
webmail_plugin_contacts = ["ui/*.js", "ui/*.svg", "migrations/versions/*.py"]
|
||||
|
||||
@@ -50,8 +50,7 @@ def _json(value: object) -> Any:
|
||||
|
||||
|
||||
def _display_name(row: dict[str, object]) -> str:
|
||||
if row.get("name_display"):
|
||||
return str(row["name_display"])
|
||||
# 1) a real personal name (N parts) always wins.
|
||||
parts = _json(row.get("name_parts")) or {}
|
||||
if isinstance(parts, dict):
|
||||
joined = " ".join(
|
||||
@@ -61,10 +60,14 @@ def _display_name(row: dict[str, object]) -> str:
|
||||
)
|
||||
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 ""
|
||||
org = str(row.get("org") or "").strip()
|
||||
nd = str(row.get("name_display") or "").strip()
|
||||
primary = _primary_email(row)
|
||||
# 2) a stored FN that is a real name (not just the legacy email fallback) wins.
|
||||
if nd and nd != primary:
|
||||
return nd
|
||||
# 3) otherwise the company name, 4) otherwise the email.
|
||||
return org or primary
|
||||
|
||||
|
||||
def _primary_email(row: dict[str, object]) -> str:
|
||||
@@ -157,13 +160,20 @@ async def _group_map(s: AsyncSession) -> dict[str, list[dict[str, str]]]:
|
||||
|
||||
# --- bundle ------------------------------------------------------------------
|
||||
|
||||
_UI_FILES = {
|
||||
"page.js": "text/javascript",
|
||||
"contacts-illustration.svg": "image/svg+xml", # empty reading-pane illustration
|
||||
}
|
||||
|
||||
|
||||
@router.get("/ui/{filename}")
|
||||
async def bundle(filename: str) -> FileResponse:
|
||||
if filename != "page.js":
|
||||
media_type = _UI_FILES.get(filename)
|
||||
if media_type is None:
|
||||
raise HTTPException(status_code=404, detail="not_found")
|
||||
return FileResponse(
|
||||
_UI / filename,
|
||||
media_type="text/javascript",
|
||||
media_type=media_type,
|
||||
headers={"Cache-Control": "no-cache"},
|
||||
)
|
||||
|
||||
@@ -242,7 +252,9 @@ async def list_contacts(
|
||||
)
|
||||
params["g"] = group
|
||||
if q.strip():
|
||||
where.append("(c.name_display ILIKE :q OR c.emails::text ILIKE :q)")
|
||||
where.append(
|
||||
"(c.name_display ILIKE :q OR c.emails::text ILIKE :q OR c.org ILIKE :q)"
|
||||
)
|
||||
params["q"] = f"%{q.strip()}%"
|
||||
order = (
|
||||
"c.updated_at DESC"
|
||||
@@ -302,8 +314,10 @@ def _derive_name(body: ContactIn) -> str:
|
||||
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()
|
||||
if not name and body.org.strip():
|
||||
name = body.org.strip()
|
||||
# Deliberately NOT falling back to the email — name_display holds only a real
|
||||
# name or company; the email fallback for display happens at read time.
|
||||
return name
|
||||
|
||||
|
||||
@@ -435,8 +449,9 @@ async def suggest(
|
||||
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 "
|
||||
"SELECT id, name_display, name_parts, org, emails, default_email "
|
||||
"FROM plg_contacts_contacts "
|
||||
"WHERE name_display ILIKE :q OR emails::text ILIKE :q OR org ILIKE :q "
|
||||
"ORDER BY lower(name_display) LIMIT 10"
|
||||
),
|
||||
{"q": like},
|
||||
|
||||
@@ -0,0 +1,191 @@
|
||||
<?xml version="1.0" encoding="UTF-8" standalone="no"?>
|
||||
<svg
|
||||
version="1.1"
|
||||
style="display:block"
|
||||
viewBox="0 0 1691.2177 1722.6987"
|
||||
width="1035.5405"
|
||||
height="1054.8165"
|
||||
preserveAspectRatio="none"
|
||||
id="svg37"
|
||||
sodipodi:docname="contacts_illustration.svg"
|
||||
inkscape:version="1.4.4 (dcaf3e7d9e, 2026-05-05)"
|
||||
xmlns:inkscape="http://www.inkscape.org/namespaces/inkscape"
|
||||
xmlns:sodipodi="http://sodipodi.sourceforge.net/DTD/sodipodi-0.dtd"
|
||||
xmlns="http://www.w3.org/2000/svg"
|
||||
xmlns:svg="http://www.w3.org/2000/svg">
|
||||
<defs
|
||||
id="defs37" />
|
||||
<sodipodi:namedview
|
||||
id="namedview37"
|
||||
pagecolor="#ffffff"
|
||||
bordercolor="#000000"
|
||||
borderopacity="0.25"
|
||||
inkscape:showpageshadow="2"
|
||||
inkscape:pageopacity="0.0"
|
||||
inkscape:pagecheckerboard="0"
|
||||
inkscape:deskcolor="#d1d1d1"
|
||||
inkscape:zoom="0.4669059"
|
||||
inkscape:cx="312.69684"
|
||||
inkscape:cy="530.0854"
|
||||
inkscape:window-width="2560"
|
||||
inkscape:window-height="1376"
|
||||
inkscape:window-x="2560"
|
||||
inkscape:window-y="27"
|
||||
inkscape:window-maximized="1"
|
||||
inkscape:current-layer="svg37" />
|
||||
<circle
|
||||
style="fill:#e0e5f6;fill-opacity:1;stroke-width:1.49905;stroke-linecap:round"
|
||||
id="path38"
|
||||
cx="788.94696"
|
||||
cy="845.50885"
|
||||
r="640.60126" />
|
||||
<path
|
||||
id="path38-5"
|
||||
style="display:block;fill:#c5d2f0;fill-opacity:1;stroke-width:1.49905;stroke-linecap:round"
|
||||
d="m 1029.3575,397.80655 c -17.5,0.358 -61.19917,8.35281 -79.86917,11.30781 -54.15,8.542 -108.2499,17.43424 -162.2999,26.67624 l -307.31418,52.30303 c -46.623,8.064 -96.80138,15.58329 -142.88038,24.84529 -31.177,9.456 -54.38342,25.50095 -70.05742,54.88995 -22.375,41.954 -11.49019,77.09725 -3.31419,120.60925 -20.197,3.138 -59.64116,3.78284 -53.26316,34.29984 9.059,26.94 40.14264,14.68016 61.67464,10.38916 1.493,8.765 2.9198,18.26366 4.6858,26.91866 6.431,34.718 12.40678,74.7727 19.69378,108.58378 -22.585,3.861 -56.09065,3.2713 -53.17065,34.1563 5.799,17.603 18.34659,19.4495 34.81659,16.0095 8.424,-1.759 18.35817,-2.9235 26.50717,-5.3365 1.444,8.856 3.04226,17.6817 4.79426,26.4817 l 20.19777,110.21054 c -16.745,2.82 -61.01106,5.6707 -53.04306,33.5407 6.661,22.45 23.02862,19.1679 41.99362,15.5279 6.481,-1.24 13.0971,-2.4494 19.5311,-3.9394 l 25.62041,137.1515 c -19.929,3.4 -58.77485,4.4381 -54.23285,33.2281 0.86734,3.121 1.92229,5.8038 3.17065,8.0861 2.26586,2.5424 4.55195,5.0666 6.85806,7.5725 5.2845,3.3447 12.22768,3.7781 20.9984,2.0319 13.82685,-1.4557 49.64672,-9.8921 50.94878,-1.7465 119.43229,115.3467 231.37272,206.4037 397.41166,206.4036 185.1372,-5e-4 361.22067,-80.0977 482.87717,-219.6523 -0.1669,-19.246 -4.6761,-38.4482 -8.2839,-57.3843 l -6.1499,-33.3812 14.3477,-2.2999 c 15.78,-2.57 24.3312,-3.2787 37.7512,-12.0287 19.17,-15.16 24.4692,-33.4906 19.8692,-57.1706 -4.72,-24.37 -9.5189,-48.739 -13.7289,-73.209 -1.62,-9.36 -3.2007,-20.121 -7.0207,-28.791 -4.01,-9.22004 -10.4909,-17.15874 -18.7209,-22.91874 -18.42,-12.64 -36.2487,-7.671 -56.3987,-4.051 l -12.7401,2.8517 c -0.75,-5.95 -1.18,-9.8525 -2.74,-15.7225 l 14.3286,-2.8581 c 18.03,-2.71 35.9104,-5.0714 48.3604,-21.6714 24.03,-32.053 -1.9407,-82.03238 -4.6507,-118.20738 -0.5,-6.711 -4.6297,-24.54392 -6.9697,-29.89792 -3.97,-8.947 -10.2692,-16.66469 -18.2392,-22.34769 -17.59,-12.724 -35.4091,-8.12161 -55.0591,-4.93461 -4.49,0.922 -9.9008,2.19209 -14.3508,2.90909 l -3.0112,-15.18979 13.9107,-2.8453 c 7.26,-1.787 15.0119,-1.96295 22.4019,-4.45295 54.56,-18.368 32.3494,-72.10147 25.5694,-112.66347 -4.58,-27.352 -6.3695,-59.04127 -30.5295,-75.85327 -17.52,-12.198 -36.0104,-7.60354 -55.2504,-3.95854 l -13.7193,2.57098 -12.0702,-64.7209 c -6.2,-31.756 -17.3197,-58.06909 -45.3397,-76.65709 -15.87,-10.76 -34.7311,-16.24107 -53.9011,-15.66507 z"
|
||||
sodipodi:nodetypes="cccccccccccccccccccccccccsccccccccccccccccccccccccccccc" />
|
||||
<path
|
||||
fill="#20c0af"
|
||||
d="m 354.51623,451.64126 c 46.079,-9.262 96.258,-16.781 142.881,-24.845 l 307.313,-52.304 c 54.05,-9.242 108.15,-18.134 162.3,-26.676 18.67,-2.955 62.36997,-10.951 79.86997,-11.309 19.17,-0.576 38.03,4.906 53.9,15.666 28.02,18.588 39.14,44.901 45.34,76.657 l 12.07,64.721 13.72,-2.571 c 19.24,-3.645 37.73,-8.239 55.25,3.959 24.16,16.812 25.95,48.501 30.53,75.853 6.78,40.562 28.99,94.297 -25.57,112.665 -7.39,2.49 -15.14,2.664 -22.4,4.451 l -13.91,2.845 3.01,15.191 c 4.45,-0.717 9.86,-1.988 14.35,-2.91 19.65,-3.187 37.47,-7.788 55.06,4.936 7.97,5.683 14.27,13.4 18.24,22.347 2.34,5.354 6.47,23.187 6.97,29.898 2.71,36.175 28.68,86.155 4.65,118.208 -12.45,16.6 -30.33,18.96 -48.36,21.67 l -14.33,2.86 c 1.56,5.87 1.99,9.77 2.74,15.72 l 12.74,-2.85 c 20.15,-3.62 37.98,-8.59 56.4,4.05 8.23,5.76 14.71,13.7 18.72,22.92 3.82,8.67 5.4,19.43 7.02,28.79 4.21,24.47 9.01,48.84004 13.73,73.21004 4.6,23.68 -0.7,42.01 -19.87,57.17 -13.42,8.75 -21.97,9.46 -37.75,12.03 l -14.35,2.3 6.15,33.38 c 3.7,19.42 8.35,39.12 8.29,58.86 -0.02,7.18 -4.2,23.06 -3.72,28.24 4.56,49.32 27.2,93.58 -4.25,139.3 -5.44,7.9 -8.86,11.5 -16.07,18.1 0.37,9.89 -7.83,21.47 -13.88,29.14 -10.96,14.06 -26,24.39 -43.05,29.59 -14.8,4.63 -33.42,8.29 -48.76,11.24 -89.39,17.24 -180.74997,30.61 -270.42997,46.14 l -262.504,45.93 -81.627,14.33 c -38.393,6.74 -77.129,18.84 -109.743,-9.69 -21.493,-18.79 -23.91,-46.58 -28.769,-72.74 l -13.011,-70.02 c -8.016,-40.7 -15.738,-81.46 -23.167,-122.27 -4.628,-25.03 -9.59,-48.38 -12.263,-73.77 -16.324,3.25 -26.375,-0.95 -31.027,-17.69 -4.542,-28.79 34.305,-29.83 54.234,-33.23 l -25.619,-137.15 c -6.434,1.49 -13.052,2.7 -19.533,3.94 -18.965,3.64 -35.332,6.92 -41.993,-15.53 -7.968,-27.87004 36.299,-30.72004 53.044,-33.54004 l -20.198,-110.21 c -1.752,-8.8 -3.35,-17.625 -4.794,-26.481 -8.149,2.413 -18.083,3.575 -26.507,5.334 -16.47,3.44 -29.017,1.594 -34.816,-16.009 -2.92,-30.885 30.585,-30.294 53.17,-34.155 -7.287,-33.811 -13.265,-73.865 -19.696,-108.583 -1.766,-8.655 -3.192,-18.155 -4.685,-26.92 -21.532,4.291 -52.615,16.55 -61.674,-10.39 -6.378,-30.517 33.065,-31.16 53.262,-34.298 -8.176,-43.512 -19.059,-78.655 3.316,-120.609 15.674,-29.389 38.879,-45.435 70.056,-54.891 z"
|
||||
id="path2" />
|
||||
<path
|
||||
fill="#e0e8f6"
|
||||
d="m 357.97623,1222.0533 c 10.622,-1.71 21.2,-3.68 31.725,-5.91 1.763,9.74 3.45,19.75 5.367,29.44 12.373,76.82 30.322,153.94 44.077,230.61 4.289,23.91 19.051,47.45 42.731,56.15 25.347,12.15 67.987,1 95.148,-3.76 l 64.729,-11.22 226.367,-39.45 c 71.92,-12.41 143.34997,-24.62 215.64997,-34.67 46.35,-6.44 94.55,-20.57 141.09,-27.99 13.13,-2.1 30.01,-12.89 40.32,-21.08 0.37,9.89 -7.83,21.47 -13.88,29.14 -10.96,14.06 -26,24.39 -43.05,29.59 -14.8,4.63 -33.42,8.29 -48.76,11.24 -89.39,17.24 -180.74997,30.61 -270.42997,46.14 l -262.504,45.93 -81.627,14.33 c -38.393,6.74 -77.129,18.84 -109.743,-9.69 -21.493,-18.79 -23.91,-46.58 -28.769,-72.74 l -13.011,-70.02 c -8.016,-40.7 -15.738,-81.46 -23.167,-122.27 -4.628,-25.03 -9.59,-48.38 -12.263,-73.77 z"
|
||||
id="path3" />
|
||||
<path
|
||||
fill="#a978f0"
|
||||
d="m 1250.8802,915.82326 c 20.15,-3.62 37.98,-8.59 56.4,4.05 8.23,5.76 14.71,13.7 18.72,22.92 3.82,8.67 5.4,19.43 7.02,28.79 4.21,24.47 9.01,48.84004 13.73,73.21004 4.6,23.68 -0.7,42.01 -19.87,57.17 -13.42,8.75 -21.97,9.46 -37.75,12.03 l -14.35,2.3 c -2.9,-10.14 -7.44,-39.8 -9.55,-51.35 l -17.81,-96.47004 c -2.89,-15.59 -5.8,-34.75 -9.28,-49.8 z"
|
||||
id="path4" />
|
||||
<path
|
||||
fill="#895fe1"
|
||||
d="m 1238.1402,918.67326 12.74,-2.85 c 1.71,0.79 2.53,0.55 2.84,2.45 5.12,30.76 10.57,61.68 16.72,92.29004 6.87,34.19 11.62,69.38 18.69,103.43 l -14.35,2.3 c -2.9,-10.14 -7.44,-39.8 -9.55,-51.35 l -17.81,-96.47004 c -2.89,-15.59 -5.8,-34.75 -9.28,-49.8 z"
|
||||
id="path5" />
|
||||
<path
|
||||
fill="#4d8ff7"
|
||||
d="m 1171.9102,490.98026 c 19.24,-3.645 37.73,-8.239 55.25,3.959 24.16,16.812 25.95,48.501 30.53,75.853 6.78,40.562 28.99,94.297 -25.57,112.665 -7.39,2.49 -15.14,2.664 -22.4,4.451 l -13.91,2.845 c -14.2,-62.479 -25.06,-133.623 -37.62,-197.202 z"
|
||||
id="path6" />
|
||||
<path
|
||||
fill="#357be8"
|
||||
d="m 1158.1902,493.55126 13.72,-2.571 c 0.5,0.537 0.99,1.073 1.49,1.609 10.95,65.237 26.09,129.99 36.32,195.319 l -13.91,2.845 c -14.2,-62.479 -25.06,-133.623 -37.62,-197.202 z"
|
||||
id="path7" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 1213.1702,703.03426 c 19.65,-3.187 37.47,-7.788 55.06,4.936 7.97,5.683 14.27,13.4 18.24,22.347 2.34,5.354 6.47,23.187 6.97,29.898 2.71,36.175 28.68,86.155 4.65,118.208 -12.45,16.6 -30.33,18.96 -48.36,21.67 l -14.33,2.86 c -11.11,-65.462 -24.94,-131.43 -36.58,-197.009 4.45,-0.717 9.86,-1.988 14.35,-2.91 z"
|
||||
id="path8" />
|
||||
<path
|
||||
fill="#e4900f"
|
||||
d="m 1198.8202,705.94426 c 4.45,-0.717 9.86,-1.988 14.35,-2.91 l 36.56,197.059 -14.33,2.86 c -11.11,-65.462 -24.94,-131.43 -36.58,-197.009 z"
|
||||
id="path9" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 381.18323,1171.1333 c 25.058,-3.3 62.667,-20.16 74.224,7.82 0.486,8.25 0.778,10.14 -2.383,18 -12.984,14.86 -44.232,16.04 -63.323,19.19 -10.525,2.23 -21.103,4.2 -31.725,5.91 -16.324,3.25 -26.375,-0.95 -31.027,-17.69 -4.542,-28.79 34.305,-29.83 54.234,-33.23 z"
|
||||
id="path10" />
|
||||
<path
|
||||
fill="#e4900f"
|
||||
d="m 455.40723,1178.9533 c 0.486,8.25 0.778,10.14 -2.383,18 -12.984,14.86 -44.232,16.04 -63.323,19.19 -10.525,2.23 -21.103,4.2 -31.725,5.91 -16.324,3.25 -26.375,-0.95 -31.027,-17.69 4.449,-0.12 8.879,4.33 13.169,5.62 8.473,2.54 98.527,-13.16 106.86,-18.19 4.481,-2.7 7.156,-7.96 8.429,-12.84 z"
|
||||
id="path11" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 347.08223,988.85326 c 23.603,-3.19 68.61,-21.37 74.871,10.37 1.117,32.66004 -41.439,29.32004 -66.389,34.76004 -6.434,1.49 -13.052,2.7 -19.533,3.94 -18.965,3.64 -35.332,6.92 -41.993,-15.53 -7.968,-27.87004 36.299,-30.72004 53.044,-33.54004 z"
|
||||
id="path12" />
|
||||
<path
|
||||
fill="#e4900f"
|
||||
d="m 294.03823,1022.3933 c 3.591,-1.23 11.348,6.45 15.284,6.03 33.512,-3.59 68.539,-10.47 101.516,-17.76 3.236,-0.72 9.086,-8.78 11.115,-11.44004 1.117,32.66004 -41.439,29.32004 -66.389,34.76004 -6.434,1.49 -13.052,2.7 -19.533,3.94 -18.965,3.64 -35.332,6.92 -41.993,-15.53 z"
|
||||
id="path13" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 313.93723,807.33226 c 24.632,-3.599 67.489,-21.677 75.053,10.1 0.469,6.784 -0.362,9.895 -2.88,16.24 -6.292,6.369 -8.821,8.21 -17.822,10.251 -15.196,3.445 -30.997,5.009 -46.198,8.239 -8.149,2.413 -18.083,3.575 -26.507,5.334 -16.47,3.44 -29.017,1.594 -34.816,-16.009 -2.92,-30.885 30.585,-30.294 53.17,-34.155 z"
|
||||
id="path14" />
|
||||
<path
|
||||
fill="#e4900f"
|
||||
d="m 260.76723,841.48726 c 0.048,-0.076 1.155,-2.068 1.835,-1.648 3.75,2.032 13.515,7.254 17.467,6.732 28.497,-3.763 56.872,-9.533 85.219,-14.319 13.963,-2.358 15.966,-3.54 23.702,-14.82 0.469,6.784 -0.362,9.895 -2.88,16.24 -6.292,6.369 -8.821,8.21 -17.822,10.251 -15.196,3.445 -30.997,5.009 -46.198,8.239 -8.149,2.413 -18.083,3.575 -26.507,5.334 -16.47,3.44 -29.017,1.594 -34.816,-16.009 z"
|
||||
id="path15" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 281.14423,627.14126 c 23.96,-4.47 62.144,-19.766 74.277,6.064 0.309,7.517 0.558,10.807 -1.3,18.306 -2,2.272 -5.874,6.799 -8.442,7.989 -16.12,7.476 -38.924,7.976 -56.123,12.329 -21.532,4.291 -52.615,16.55 -61.674,-10.39 -6.378,-30.517 33.065,-31.16 53.262,-34.298 z"
|
||||
id="path16" />
|
||||
<path
|
||||
fill="#e4900f"
|
||||
d="m 355.42123,633.20526 c 0.309,7.517 0.558,10.807 -1.3,18.306 -2,2.272 -5.874,6.799 -8.442,7.989 -16.12,7.476 -38.924,7.976 -56.123,12.329 -21.532,4.291 -52.615,16.55 -61.674,-10.39 0.836,-1.092 0.257,-0.615 1.938,-1.145 2.653,1.678 5.848,4.184 8.756,5.302 7.201,2.768 98.899,-13.36 107.203,-17.857 5.686,-3.079 8.01,-8.615 9.642,-14.534 z"
|
||||
id="path17" />
|
||||
<path
|
||||
fill="#0aa59a"
|
||||
d="m 453.02423,1196.9533 c -4.059,31.82 -42.336,20.26 -56.056,46.69 -0.435,0.84 -1.172,1.32 -1.9,1.94 -1.917,-9.69 -3.604,-19.7 -5.367,-29.44 19.091,-3.15 50.339,-4.33 63.323,-19.19 z"
|
||||
id="path18" />
|
||||
<path
|
||||
fill="#0aa59a"
|
||||
d="m 289.55623,671.82926 c 17.199,-4.353 40.003,-4.853 56.123,-12.329 2.568,-1.19 6.442,-5.717 8.442,-7.989 -5.802,24.939 -23.092,22.108 -42.186,31.378 -6.734,3.27 -11.468,11.944 -17.694,15.86 -1.766,-8.655 -3.192,-18.155 -4.685,-26.92 z"
|
||||
id="path19" />
|
||||
<path
|
||||
fill="#0aa59a"
|
||||
d="m 322.09023,852.16226 c 15.201,-3.23 31.002,-4.794 46.198,-8.239 9.001,-2.041 11.53,-3.882 17.822,-10.251 -4.492,22.123 -25.384,21.667 -41.932,30.521 -6.589,3.52 -11.408,10.89 -17.294,14.45 -1.752,-8.8 -3.35,-17.625 -4.794,-26.481 z"
|
||||
id="path20" />
|
||||
<path
|
||||
fill="#fefeff"
|
||||
d="m 776.93023,587.39726 c 151.23,-10.612 282.46997,103.29 293.23997,254.507 10.77,151.219 -102.98997,282.57904 -254.18997,293.50904 -151.436,10.96 -283.038,-103.02 -293.829,-254.46004 -10.791,-151.447 103.322,-282.927 254.779,-293.556 z"
|
||||
id="path21" />
|
||||
<path
|
||||
fill="#20c0af"
|
||||
d="m 831.42023,895.94326 c 72.6,-6.06 130.57,34.9 148.96,105.27004 -1.86,3.66 -3.22,5.57 -5.78,8.71 -38.58,46.41 -93.72,75.99 -153.73,82.46 -49.744,4.49 -97.889,-4.91 -141.445,-30.75 -4.997,-2.96 -7.746,-6.91 -8.397,-12.81 -3.611,-32.7 5.771,-66.43004 25.439,-92.78004 35.892,-47.94 80.54,-51.84 134.953,-60.1 z"
|
||||
id="path22" />
|
||||
<path
|
||||
fill="#20c0af"
|
||||
d="m 773.11223,696.78626 c 51.878,-4.826 97.818,33.391 102.508,85.282 4.7,51.89 -33.64,97.735 -85.54,102.295 -51.715,4.55 -97.357,-33.619 -102.035,-85.325 -4.678,-51.706 33.373,-97.444 85.067,-102.252 z"
|
||||
id="path23" />
|
||||
<path
|
||||
fill="#fefeff"
|
||||
d="m 481.35023,1470.6033 c 7.857,-35.54 49.975,-35.83 79.902,-41.2 l 78.482,-13.89 383.98597,-68.88 127.41,-23.09 c 45.1,-8.28 80.58,-8.39 113.18,-44.72 l 1.28,-1.44 1.39,0.58 0.83,4.29 c 3.26,16.24 4.92,27.07 2.76,42.82 -2.94,21.42 -25.81,49.7 -47.5,54.69 -59.82,13.75 -129.17,24.97 -189.34,35.68 l -271.91597,48.57 -159.973,28.43 c -24.823,4.37 -49.45,9.53 -74.633,12.84 -17.449,2.3 -41.978,-7.07 -45.302,-26.71 -0.409,-2.41 -0.454,-5.49 -0.556,-7.97 z"
|
||||
id="path24" />
|
||||
<path
|
||||
fill="#e0e8f6"
|
||||
d="m 481.35023,1470.6033 c 7.857,-35.54 49.975,-35.83 79.902,-41.2 l 78.482,-13.89 383.98597,-68.88 127.41,-23.09 c 45.1,-8.28 80.58,-8.39 113.18,-44.72 l 1.28,-1.44 1.39,0.58 0.83,4.29 c -24.3,50.6 -46.42,55.36 -99.27,64.8 l -57.04,10.15 -216.47997,38.77 -297.551,53.32 -80.17,14.61 c -8.569,1.57 -28.187,5.78 -35.949,6.7 z"
|
||||
id="path25" />
|
||||
<path
|
||||
fill="#a978f0"
|
||||
d="m 214.95223,114.20826 c 79.774,-6.325 149.547,53.276 155.768,133.059 6.22,79.783 -53.472,149.478 -133.263,155.594 -79.643,6.104 -149.180997,-53.453 -155.389997,-133.089 -6.209,-79.635 53.257997,-149.25 132.884997,-155.564 z"
|
||||
id="path26" />
|
||||
<path
|
||||
fill="#fefeff"
|
||||
d="m 175.87723,179.55126 c 3.36,-0.054 7.145,-0.188 9.826,2.275 8.927,9.151 19.713,23.523 22.637,35.891 2.776,11.739 -18.273,19.61 -14.692,29.308 6.392,16.865 22.722,33.094 37.064,43.762 23.156,17.226 22.121,-29.4 49.31,-4.183 14.282,13.246 34.271,17.206 14.254,37.894 -12.722,12.57 -20.971,19.59 -40.344,13.959 -35.792,-10.775 -68.523,-40.627 -87.294,-72.523 -19.27,-32.746 -31.389,-64.452 9.239,-86.383 z"
|
||||
id="path27" />
|
||||
<path
|
||||
fill="#4d8ff7"
|
||||
d="m 1448.1602,334.54526 c 62.46,-6.429 118.34,38.896 124.93,101.333 6.6,62.437 -38.58,118.436 -101,125.196 -62.65,6.786 -118.91,-38.597 -125.53,-101.269 -6.62,-62.672 38.91,-118.806 101.6,-125.26 z"
|
||||
id="path28" />
|
||||
<path
|
||||
fill="#fefeff"
|
||||
d="m 1404.3502,396.67826 c 29.63,-0.359 63.32,5.583 93.28,5.9 8.8,0.093 22.57,1.043 29.51,6.565 4.14,10.039 -1.41,39.473 -1.59,51.079 -0.14,9.173 -0.32,20.995 -2.01,29.953 -0.98,5.237 -4.25,7.362 -8.59,9.803 -36.01,-0.575 -78.92,-5.72 -115.14,-7.793 -17.94,-1.026 -2.97,-57.62 -5.54,-70.277 -1.87,-9.186 0.54,-20.167 10.08,-25.23 z"
|
||||
id="path29" />
|
||||
<path
|
||||
fill="#4d8ff7"
|
||||
d="m 1407.4902,411.89126 c 7.55,3.315 39.53,31.504 51.61,39.01 11.04,-4.784 50.07,-33.09 58.33,-29.63 1.61,3.759 0.65,5.93 -0.83,10.221 -9.92,5.386 -49.52,32.269 -59.09,30.906 -6.21,-0.883 -46.61,-32.392 -52.33,-40.265 -1.35,-1.862 1.04,-7.432 2.31,-10.242 z"
|
||||
id="path30" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 1463.0802,1093.5333 c 58.8,-6.88 112.06,35.19 118.97,93.99 6.9,58.8 -35.14,112.07 -93.94,119 -58.83,6.94 -112.14,-35.14 -119.05,-93.97 -6.92,-58.83 35.19,-112.13 94.02,-119.02 z"
|
||||
id="path31" />
|
||||
<path
|
||||
fill="#fefeff"
|
||||
d="m 1468.4302,1136.8733 c 3.27,-0.23 8.41,-0.29 11.63,0.12 12.92,1.72 24.66,8.39 32.76,18.6 8.9,11.36 11.87,23.55 10.35,37.78 -1.8,16.98 -30.19,64.23 -42.82,73.69 -2.29,0 -5.04,-0.55 -6.69,-2.07 -16.15,-14.88 -44.83,-48.79 -48.55,-69.53 -2.38,-13.37 0.77,-27.13 8.72,-38.13 8.83,-12.25 20.14,-17.99 34.6,-20.46 z"
|
||||
id="path32" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 1470.6202,1164.1833 c 12.36,-1.89 23.93,6.57 25.87,18.93 1.94,12.36 -6.47,23.96 -18.82,25.96 -12.43,2.01 -24.12,-6.47 -26.07,-18.9 -1.96,-12.44 6.57,-24.09 19.02,-25.99 z"
|
||||
id="path33" />
|
||||
<path
|
||||
fill="#20c0af"
|
||||
d="m 101.02423,1000.2033 c 17.023,-2.62004 32.97,8.98 35.734,25.98 2.765,17 -8.686,33.05 -25.659,35.97 -17.191997,2.95 -33.493997,-8.68 -36.293997,-25.9 -2.8,-17.21 8.977,-33.41 26.218997,-36.05 z"
|
||||
id="path34" />
|
||||
<path
|
||||
fill="#a978f0"
|
||||
d="m 1066.2302,1576.9733 c 17,-3.1 33.27,8.22 36.29,25.23 3.02,17.02 -8.38,33.24 -25.41,36.17 -16.91,2.91 -33,-8.39 -35.99,-25.29 -3,-16.89 8.22,-33.04 25.11,-36.11 z"
|
||||
id="path35" />
|
||||
<path
|
||||
fill="#fba825"
|
||||
d="m 1184.1402,165.31526 c 16.19,-1.674 30.72,9.957 32.63,26.117 1.91,16.159 -9.51,30.857 -25.64,33.002 -10.64,1.415 -21.23,-3.012 -27.69,-11.587 -6.47,-8.574 -7.81,-19.965 -3.52,-29.81 4.28,-9.845 13.54,-16.617 24.22,-17.722 z"
|
||||
id="path36" />
|
||||
<path
|
||||
fill="#e0e8f6"
|
||||
d="m 1289.1302,1113.9933 c 15.78,-2.57 24.33,-3.28 37.75,-12.03 -3.09,7.75 -0.44,13.81 -2.79,21.2 -4.18,13.17 -15.27,19.28 -28.02,22 -4.59,0.98 -11.34,1.86 -15.14,4.51 l -6.15,-33.38 z"
|
||||
id="path37" />
|
||||
</svg>
|
||||
|
After Width: | Height: | Size: 17 KiB |
@@ -27,6 +27,13 @@ const IC_PHONE = `<svg width="14" height="14" viewBox="0 0 24 24" fill="none" st
|
||||
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_WORK = `<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="7" width="18" height="13" rx="2"/><path d="M8 7V5a2 2 0 0 1 2-2h4a2 2 0 0 1 2 2v2M3 12h18"/></svg>`;
|
||||
const IC_MAPPIN = `<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="M20 10c0 6-8 12-8 12s-8-6-8-12a8 8 0 0 1 16 0z"/><circle cx="12" cy="10" r="3"/></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 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>`;
|
||||
// A Mail-style square selection checkbox (unchecked = outline, checked = filled).
|
||||
const checkbox = (on) => `<span class="ct-check${on ? " on" : ""}">${on ? CHECK_SVG : ""}</span>`;
|
||||
const IC_PENCIL = IC_COMPOSE;
|
||||
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>`;
|
||||
@@ -44,6 +51,10 @@ 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 fmtBirthday(s) {
|
||||
const m = /^(\d{4})-(\d{2})-(\d{2})/.exec(String(s));
|
||||
return m ? `${MONTHS[+m[2] - 1]} ${+m[3]}, ${m[1]}` : String(s);
|
||||
}
|
||||
function shortDate(iso) {
|
||||
const d = new Date(iso);
|
||||
if (isNaN(d)) return "";
|
||||
@@ -70,9 +81,28 @@ const CSS = `
|
||||
.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{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-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}
|
||||
/* 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.on{background:rgb(var(--app-accent));border-color:rgb(var(--app-accent))}
|
||||
.ct-selall{width:38px;flex:0 0 auto;display:flex;align-items:center;justify-content:center}
|
||||
.ct-selallbtn{border:0;background:none;cursor:pointer;padding:0;display:flex}
|
||||
.ct-selallbtn:hover .ct-check{border-color:rgb(var(--app-muted))}
|
||||
.ct-avwrap{position:relative;width:38px;height:38px;flex:0 0 auto}
|
||||
.ct-avshow{position:absolute;inset:0}
|
||||
.ct-avcheck{position:absolute;inset:0;display:none;align-items:center;justify-content:center;border:0;background:none;cursor:pointer;padding:0}
|
||||
.ct-avcheck:hover .ct-check{border-color:rgb(var(--app-muted))}
|
||||
.ct-app:not(.selmode) .ct-row:hover .ct-avshow{visibility:hidden}
|
||||
.ct-app:not(.selmode) .ct-row:hover .ct-avcheck{display:flex}
|
||||
.ct-app.selmode .ct-avshow{visibility:hidden}
|
||||
.ct-app.selmode .ct-avcheck{display:flex}
|
||||
.ct-searchbox{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
|
||||
.ct-searchbox .ic{color:rgb(var(--app-faint));flex:0 0 auto}
|
||||
.ct-search{flex:1;min-width:0;padding:6px 0;border:0;background:none;color:rgb(var(--app-fg));outline:none;font:inherit}
|
||||
.ct-searchclear{border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;flex:0 0 auto;font-size:14px;line-height:1}
|
||||
.ct-searchclear:hover{color:rgb(var(--app-fg))}
|
||||
.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;flex:0 0 auto}
|
||||
.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))}
|
||||
@@ -86,11 +116,15 @@ const CSS = `
|
||||
.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-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 span{font-size:28px}
|
||||
.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-name{font-size:22px;font-weight:700;margin:6px 0 10px}
|
||||
.ct-sub{display:flex;align-items:center;gap:6px;color:rgb(var(--app-muted));font-size:14px;margin:-6px 0 12px}
|
||||
.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
|
||||
.ct-star.on{color:#eab308}
|
||||
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
|
||||
@@ -126,6 +160,13 @@ const CSS = `
|
||||
.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-modalbg{position:fixed;inset:0;z-index:50;display:flex;align-items:center;justify-content:center;background:rgb(0 0 0 / .5);padding:16px}
|
||||
.ct-modal{width:100%;max-width:360px;background:rgb(var(--app-surface));border:1px solid rgb(var(--app-border));border-radius:12px;padding:20px;box-shadow:0 12px 40px rgb(0 0 0 / .35)}
|
||||
.ct-modal h3{font-size:16px;font-weight:600}
|
||||
.ct-modal p{margin-top:8px;font-size:14px;color:rgb(var(--app-muted))}
|
||||
.ct-modal .row{margin-top:20px;display:flex;justify-content:flex-end;gap:8px}
|
||||
.ct-danger{background:#ef4444;color:#fff;padding:6px 16px;border-radius:6px;font-weight:600;font-size:13px;cursor:pointer;border:0}
|
||||
.ct-danger:hover{opacity:.9}
|
||||
.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}
|
||||
@@ -148,6 +189,8 @@ export default function mount(el, ctx) {
|
||||
mode: "view", // view | edit | new
|
||||
mail: null, // { loading, threads }
|
||||
sortAsc: true,
|
||||
checked: new Set(), // multi-selected contact ids (Mail-style)
|
||||
confirmDelete: false,
|
||||
menu: null, // "groups" | "more" | null
|
||||
leftW: Number(localStorage.getItem("ct.leftW")) || 240,
|
||||
leftCollapsed: localStorage.getItem("ct.leftCollapsed") === "1",
|
||||
@@ -157,6 +200,14 @@ export default function mount(el, ctx) {
|
||||
};
|
||||
|
||||
const api = ctx.api;
|
||||
let searchTimer = null;
|
||||
|
||||
// Reload the list for the current search term, then re-render (keeps search focus).
|
||||
async function runSearch() {
|
||||
await loadContacts();
|
||||
await loadDetail(state.selectedId);
|
||||
render();
|
||||
}
|
||||
|
||||
// ---- data loading --------------------------------------------------------
|
||||
async function loadGroups() {
|
||||
@@ -189,6 +240,7 @@ export default function mount(el, ctx) {
|
||||
state.selectedId = id;
|
||||
state.mode = "view";
|
||||
state.menu = null;
|
||||
state.checked.clear(); // opening a contact exits multi-select mode
|
||||
await loadDetail(id);
|
||||
render();
|
||||
}
|
||||
@@ -275,29 +327,53 @@ export default function mount(el, ctx) {
|
||||
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>`; }
|
||||
const ck = state.checked.has(c.id);
|
||||
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-avwrap">
|
||||
<div class="ct-avshow">${avatarHtml(c.display_name, c.primary_email, 38)}</div>
|
||||
<button class="ct-avcheck" data-check="${c.id}" aria-label="Select contact">${checkbox(ck)}</button>
|
||||
</div>
|
||||
<div class="ct-rowmain">
|
||||
<div class="ct-rowname">${esc(name)}</div>
|
||||
<div class="ct-rowmail">${esc(c.primary_email || "")}</div>
|
||||
</div></div>`;
|
||||
}
|
||||
if (!rows.length) html = `<div class="ct-empty">No contacts${state.q ? " match your search" : " yet"}.</div>`;
|
||||
const allChecked = rows.length > 0 && rows.every((c) => state.checked.has(c.id));
|
||||
return `
|
||||
<div class="ct-bar">
|
||||
<input type="checkbox" class="ct-cbx" disabled title="Select all"/>
|
||||
<span class="sp"></span>
|
||||
<div class="ct-selall"><button class="ct-selallbtn" data-act="select-all" aria-label="Select all">${checkbox(allChecked)}</button></div>
|
||||
<div class="ct-searchbox">
|
||||
${IC_SEARCH}
|
||||
<input class="ct-search" placeholder="Search contacts" value="${esc(state.q)}"/>
|
||||
${state.q ? `<button class="ct-searchclear" data-act="search-clear" title="Clear">✕</button>` : ""}
|
||||
</div>
|
||||
<button class="ct-iconbtn" data-act="sort" title="Sort">${IC_SORT}</button>
|
||||
<button class="ct-iconbtn" data-act="more-list" title="More">${IC_MORE}</button>
|
||||
</div>
|
||||
<div class="ct-list">${html}</div>`;
|
||||
}
|
||||
|
||||
function confirmModal() {
|
||||
const name = state.detail ? (state.detail.display_name || state.detail.default_email || "this contact") : "this contact";
|
||||
return `<div class="ct-modalbg" data-act="confirm-cancel"><div class="ct-modal" data-stop>
|
||||
<h3>Delete contact</h3>
|
||||
<p>Delete “${esc(name)}”? This can't be undone.</p>
|
||||
<div class="row">
|
||||
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
|
||||
<button class="ct-danger" data-act="confirm-delete">Delete</button>
|
||||
</div>
|
||||
</div></div>`;
|
||||
}
|
||||
|
||||
// ---- right: detail / edit ------------------------------------------------
|
||||
function detailBar() {
|
||||
if (state.mode === "edit" || state.mode === "new") {
|
||||
return `<div class="ct-bar"><button class="ct-primary" data-act="save">Save</button><button class="ct-ghostbtn" data-act="cancel">Cancel</button></div>`;
|
||||
}
|
||||
if (state.checked.size > 0) {
|
||||
return `<div class="ct-bar"><button class="ct-barbtn" data-act="clear-sel">Clear selection</button></div>`;
|
||||
}
|
||||
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>
|
||||
@@ -315,16 +391,44 @@ export default function mount(el, ctx) {
|
||||
|
||||
function detailPanel() {
|
||||
if (state.mode === "edit" || state.mode === "new") return editForm();
|
||||
// Multi-select: show the selection count (like Mail) instead of a single contact.
|
||||
if (state.checked.size > 0) {
|
||||
const n = state.checked.size;
|
||||
return `<div class="ct-detail"><div class="ct-emptyill">
|
||||
<img src="/api/plugins/contacts/ui/contacts-illustration.svg" alt="" aria-hidden="true"/>
|
||||
<span>${n} contact${n === 1 ? "" : "s"} selected</span>
|
||||
</div></div>`;
|
||||
}
|
||||
const c = state.detail;
|
||||
if (!c) return `<div class="ct-detail"><div class="ct-empty">Select a contact.</div></div>`;
|
||||
if (!c) return `<div class="ct-detail"><div class="ct-emptyill">
|
||||
<img src="/api/plugins/contacts/ui/contacts-illustration.svg" alt="" aria-hidden="true"/>
|
||||
<span>Select a contact</span>
|
||||
</div></div>`;
|
||||
const drow = (icon, label, val) => `<div class="ct-drow"><span class="ct-dico">${icon}</span><div><div class="ct-dlabel">${esc(label)}</div><div class="ct-dval">${val}</div></div></div>`;
|
||||
const withHttp = (u) => (/^https?:\/\//i.test(u) ? u : "https://" + u);
|
||||
const groups = (c.groups || []).map((g) => esc(g.name)).join(", ");
|
||||
const work = [c.title, c.department, c.org].filter(Boolean).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("");
|
||||
|
||||
let details = "";
|
||||
if (groups) details += drow(IC_GROUP, "Groups", groups);
|
||||
emailRows.forEach((e) => { if (e.value) details += drow(IC_MAIL, cap(e.type || "email"), `<a href="mailto:${esc(e.value)}">${esc(e.value)}</a>`); });
|
||||
(c.phones || []).forEach((p) => { if (p.value) details += drow(IC_PHONE, cap(p.type || "phone"), `<a href="tel:${esc(p.value)}">${esc(p.value)}</a>`); });
|
||||
(c.addresses || []).forEach((a) => {
|
||||
const line = [a.street, [a.postal_code, a.city].filter(Boolean).join(" "), a.country].filter(Boolean).join(", ");
|
||||
if (line) details += drow(IC_MAPPIN, cap(a.type || "address"), esc(line));
|
||||
});
|
||||
(c.urls || []).forEach((u) => { if (u.value) details += drow(IC_LINK, cap(u.type || "website"), `<a href="${esc(withHttp(u.value))}" target="_blank" rel="noopener noreferrer">${esc(u.value)}</a>`); });
|
||||
if (c.nickname) details += drow(IC_PERSON, "Nickname", esc(c.nickname));
|
||||
if (c.birthday) details += drow(IC_CAL, "Birthday", esc(fmtBirthday(c.birthday)));
|
||||
details += `<div class="ct-drow"><span class="ct-dico">${IC_PENCIL}</span><div>${c.notes ? `<div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap">${esc(c.notes)}</div>` : `<span class="ct-dlabel">Add notes</span>`}</div></div>`;
|
||||
|
||||
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>
|
||||
${work ? `<div class="ct-sub">${IC_WORK} ${esc(work)}</div>` : ""}
|
||||
<div class="ct-actions">
|
||||
<button class="ct-act" data-act="call">${IC_PHONE} Call</button>
|
||||
<button class="ct-act" data-act="message">${IC_CHAT} Message</button>
|
||||
@@ -337,9 +441,7 @@ export default function mount(el, ctx) {
|
||||
|
||||
<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>
|
||||
${details}
|
||||
</div>
|
||||
|
||||
<div class="ct-sec">
|
||||
@@ -434,7 +536,9 @@ export default function mount(el, ctx) {
|
||||
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 org = v("[data-f=org]");
|
||||
// FN = personal name, else company. Never the email (display falls back to it).
|
||||
const display = [prefix, given, family].filter(Boolean).join(" ") || org || "";
|
||||
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")}`;
|
||||
@@ -445,7 +549,7 @@ export default function mount(el, ctx) {
|
||||
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]"),
|
||||
title: v("[data-f=title]"), department: v("[data-f=department]"), org,
|
||||
birthday,
|
||||
emails, default_email: emails[0] ? emails[0].value : "",
|
||||
phones,
|
||||
@@ -472,18 +576,28 @@ export default function mount(el, ctx) {
|
||||
|
||||
// ---- render + wire -------------------------------------------------------
|
||||
function render() {
|
||||
el.innerHTML = `<div class="ct-app">
|
||||
// If the search box currently has focus, keep it (and the caret) across the
|
||||
// re-render — covers the debounced reload and the async mail-load re-render.
|
||||
const prev = el.querySelector(".ct-search");
|
||||
const keepFocus = prev && document.activeElement === prev;
|
||||
const caret = keepFocus ? prev.selectionStart : null;
|
||||
el.innerHTML = `<div class="ct-app${state.checked.size ? " selmode" : ""}">
|
||||
${state.leftCollapsed ? railPanel() : `<div class="wm-side ct-left" style="width:${state.leftW}px;flex:0 0 auto">${leftPanel()}</div>`}
|
||||
<div class="ct-listcol">${listPanel()}</div>
|
||||
<div class="ct-detailcol">${detailBar()}${detailPanel()}</div>
|
||||
${state.confirmDelete ? confirmModal() : ""}
|
||||
</div>`;
|
||||
wire();
|
||||
if (keepFocus) {
|
||||
const si = el.querySelector(".ct-search");
|
||||
if (si) { si.focus(); const p = caret != null ? caret : si.value.length; si.setSelectionRange(p, p); }
|
||||
}
|
||||
}
|
||||
|
||||
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); };
|
||||
const setFilter = (f) => { state.filter = f; state.group = ""; state.menuOpen = false; state.checked.clear(); 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(); });
|
||||
@@ -499,10 +613,31 @@ export default function mount(el, ctx) {
|
||||
on("[data-group]", "click", (e) => {
|
||||
state.group = e.currentTarget.getAttribute("data-group");
|
||||
state.groupLabel = e.currentTarget.getAttribute("data-groupname");
|
||||
state.filter = "group"; state.menuOpen = false; reloadList(false);
|
||||
state.filter = "group"; state.menuOpen = false; state.checked.clear(); reloadList(false);
|
||||
});
|
||||
|
||||
on("[data-contact]", "click", (e) => selectContact(e.currentTarget.getAttribute("data-contact")));
|
||||
// Selection checkboxes (revealed on avatar-hover / in select mode) — Mail-style.
|
||||
on("[data-check]", "click", (e) => {
|
||||
e.stopPropagation();
|
||||
const id = e.currentTarget.getAttribute("data-check");
|
||||
if (state.checked.has(id)) state.checked.delete(id); else state.checked.add(id);
|
||||
render();
|
||||
});
|
||||
act("select-all", () => {
|
||||
const ids = state.contacts.map((c) => c.id);
|
||||
const all = ids.length > 0 && ids.every((id) => state.checked.has(id));
|
||||
if (all) state.checked.clear(); else ids.forEach((id) => state.checked.add(id));
|
||||
render();
|
||||
});
|
||||
act("clear-sel", () => { state.checked.clear(); render(); });
|
||||
on(".ct-search", "input", (e) => {
|
||||
state.q = e.currentTarget.value;
|
||||
if (searchTimer) clearTimeout(searchTimer);
|
||||
searchTimer = setTimeout(runSearch, 250);
|
||||
});
|
||||
on(".ct-search", "keydown", (e) => { if (e.key === "Escape") { state.q = ""; runSearch(); } });
|
||||
act("search-clear", () => { state.q = ""; runSearch(); });
|
||||
act("sort", () => { state.sortAsc = !state.sortAsc; render(); });
|
||||
act("new", () => { state.mode = "new"; state.selectedId = null; state.detail = null; render(); });
|
||||
|
||||
@@ -514,7 +649,10 @@ export default function mount(el, ctx) {
|
||||
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("invite", () => {
|
||||
const email = state.detail && state.detail.default_email;
|
||||
ctx.navigate(email ? `/p/calendar?guest=${encodeURIComponent(email)}` : "/p/calendar");
|
||||
});
|
||||
act("find-mail", () => ctx.navigate(`/mail/search/${encodeURIComponent('"' + state.detail.default_email + '"')}`));
|
||||
|
||||
// menus
|
||||
@@ -529,8 +667,10 @@ export default function mount(el, ctx) {
|
||||
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "PUT", body: JSON.stringify(body) });
|
||||
await loadGroups(); await loadDetail(state.selectedId); render();
|
||||
});
|
||||
act("delete", async () => {
|
||||
state.menu = null;
|
||||
act("delete", () => { state.menu = null; state.confirmDelete = true; render(); });
|
||||
act("confirm-cancel", () => { state.confirmDelete = false; render(); });
|
||||
act("confirm-delete", async () => {
|
||||
state.confirmDelete = false;
|
||||
await api.pluginFetch(`/contacts/${state.selectedId}`, { method: "DELETE" });
|
||||
state.selectedId = null;
|
||||
await reloadList(false);
|
||||
|
||||
Reference in New Issue
Block a user