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:
2026-08-02 11:57:39 +02:00
co-authored by Claude Opus 4.8
parent a985b112b1
commit a393939628
6 changed files with 581 additions and 33 deletions
+191
View File
@@ -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

@@ -1424,6 +1424,17 @@ export default function mount(el, ctx) {
await loadCalendars();
await loadEvents();
render();
// Deep link (Contacts "Invite"): /p/calendar?guest=<email> opens a new-event
// form with that guest already added, then strips the param so refresh is clean.
try {
const guest = new URLSearchParams(window.location.search).get("guest");
if (guest) {
window.history.replaceState({}, "", window.location.pathname);
openForm(null);
const addChip = expandGuests();
if (addChip) addChip(guest, "", false);
}
} catch { /* ignore */ }
})();
return () => {
@@ -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);