@@ -32,6 +32,7 @@ const IC_MAPPIN = `<svg width="16" height="16" viewBox="0 0 24 24" fill="none" s
const IC _LINK = ` <svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.7" stroke-linecap="round" stroke-linejoin="round"><path d="M10 13a5 5 0 0 0 7 0l3-3a5 5 0 0 0-7-7l-1 1"/><path d="M14 11a5 5 0 0 0-7 0l-3 3a5 5 0 0 0 7 7l1-1"/></svg> ` ;
const IC _SEARCH = ` <svg class="ic" width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" stroke-width="1.8" stroke-linecap="round"><circle cx="11" cy="11" r="7"/><path d="M21 21l-4.3-4.3"/></svg> ` ;
const CHECK _SVG = ` <svg width="11" height="11" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="2.6" stroke-linecap="round" stroke-linejoin="round"><path d="M3.5 8.5l3 3 6-7"/></svg> ` ;
const PAGE _SIZE = 50 ; // contacts per page (matches Mail)
// A Mail-style square selection checkbox (unchecked = outline, checked = filled).
const checkbox = ( on ) => ` <span class="ct-check ${ on ? " on" : "" } "> ${ on ? CHECK _SVG : "" } </span> ` ;
const IC _PENCIL = IC _COMPOSE ;
@@ -65,6 +66,8 @@ function shortDate(iso) {
const CSS = `
.ct-app{display:flex;height:100vh;background:rgb(var(--app-bg));color:rgb(var(--app-fg));font-family:"Lato",system-ui,-apple-system,"Segoe UI",Roboto,sans-serif;font-size:14px}
/* Form controls don't inherit font-family by default — pin them to Lato too. */
.ct-app input,.ct-app select,.ct-app textarea,.ct-app button{font-family:inherit}
.ct-app .wm-side-item.active{background:rgb(var(--app-active));color:rgb(var(--app-fg));font-weight:600}
.ct-app .wm-side-item .ct-dico{margin-top:0}
/* collapsed icon-only rail (matches Mail's 60px rail) */
@@ -79,9 +82,12 @@ const CSS = `
.ct-railnew{display:flex;align-items:center;justify-content:center;width:40px;height:40px;border-radius:11px;background:rgb(var(--app-accent));color:rgb(var(--app-accent-fg));cursor:pointer;border:0}
.ct-railnew:hover{opacity:.9}
.ct-av{display:inline-flex;align-items:center;justify-content:center;border-radius:50%;color:#fff;font-weight:600;flex:0 0 auto;overflow:hidden}
.ct-listcol{flex:0 0 46% ;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-listcol{flex:0 0 auto ;min-width:0;display:flex;flex-direction:column;border-right:1px solid rgb(var(--app-border))}
.ct-detailcol{flex:1;min-width:0;display:flex;flex-direction:column}
.ct-bar{display:flex;align-items:center;gap:8px;height:49px;padd ing :0 14px 0 18px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto }
.ct-resizer{flex:0 0 auto;width:5px;marg in:0 -2px;cursor:col-resize;position:relative;z-index:10 }
.ct-resizer::after{content:"";position:absolute;top:0;bottom:0;left:50%;width:1px;transform:translateX(-50%);background:transparent}
.ct-resizer:hover::after{background:#3b82f6}
.ct-bar{display:flex;align-items:center;gap:8px;height:49px;padding:0 14px 0 20px;border-bottom:1px solid rgb(var(--app-border));flex:0 0 auto}
.ct-bar .sp{flex:1}
/* Mail-style selection checkbox (bar "select all" + per-row on avatar-hover) */
.ct-check{display:flex;align-items:center;justify-content:center;width:18px;height:18px;border-radius:5px;border:2px solid rgb(var(--app-faint));color:#fff;box-sizing:border-box;transition:background .1s,border-color .1s}
@@ -93,8 +99,8 @@ const CSS = `
.ct-avshow{position:absolute;inset:0}
.ct-avcheck{position:absolute;inset:0;display:none;align-items:center;justify-content:center;border:0;background:none;cursor:pointer;padding:0}
.ct-avcheck:hover .ct-check{border-color:rgb(var(--app-muted))}
.ct-app:not(.selmode) .ct-row :hover .ct-avshow{visibility:hidden}
.ct-app:not(.selmode) .ct-row :hover .ct-avcheck{display:flex}
.ct-app:not(.selmode) .ct-avwrap :hover .ct-avshow{visibility:hidden}
.ct-app:not(.selmode) .ct-avwrap :hover .ct-avcheck{display:flex}
.ct-app.selmode .ct-avshow{visibility:hidden}
.ct-app.selmode .ct-avcheck{display:flex}
.ct-searchbox{flex:1;min-width:0;display:flex;align-items:center;gap:8px;padding:0 10px;border-radius:8px;border:1px solid rgb(var(--app-border));background:rgb(var(--app-surface))}
@@ -108,13 +114,20 @@ const CSS = `
.ct-barbtn:hover{background:rgb(var(--app-hover))}
.ct-cbx{width:16px;height:16px;accent-color:rgb(var(--app-accent))}
.ct-list{flex:1;min-height:0;overflow:auto}
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 18 px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 18 px;cursor:pointer;border-bottom:1px solid rgb(var(--app-border) / .5) }
.ct-alpha{position:sticky;top:0;background:rgb(var(--app-bg));color:rgb(var(--app-muted));font-size:12px;font-weight:700;padding:6px 20 px 2px;z-index:1}
.ct-row{display:flex;align-items:center;gap:12px;padding:9px 12 px;margin:2px 8px;border-radius:8px;cursor:pointer }
.ct-row:hover{background:rgb(var(--app-hover))}
.ct-row.sel{background:rgb(var(--app-active))}
.ct-rowmain{min-width:0;flex:1}
.ct-rowname{font-weight:600;white-space:nowrap;overflow:hidden;text-overflow:ellipsis }
.ct-rowname{display:flex;align-items:center;gap:5px;font-weight:600 }
.ct-nametext{min-width:0;overflow:hidden;text-overflow:ellipsis;white-space:nowrap}
.ct-vipstar{flex:0 0 auto;display:inline-flex}
.ct-vipstar svg{width:13px;height:13px;fill:#eab308;stroke:#eab308}
.ct-rowmail{color:rgb(var(--app-muted));font-size:13px;white-space:nowrap;overflow:hidden;text-overflow:ellipsis}
.ct-pager{display:flex;align-items:center;justify-content:flex-end;gap:12px;height:44px;padding:0 14px;border-top:1px solid rgb(var(--app-border));flex:0 0 auto;color:rgb(var(--app-muted));font-size:13px}
.ct-pagerbtn{display:inline-flex;align-items:center;justify-content:center;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;padding:5px;border-radius:5px}
.ct-pagerbtn:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-pagerbtn:disabled{opacity:.4;pointer-events:none}
.ct-empty{padding:60px 20px;text-align:center;color:rgb(var(--app-muted))}
.ct-emptyill{height:100%;display:flex;flex-direction:column;align-items:center;justify-content:center;gap:24px;color:rgb(var(--app-faint))}
.ct-emptyill img{width:224px;max-width:55%}
@@ -127,12 +140,20 @@ const CSS = `
.ct-sub{display:flex;align-items:center;gap:6px;color:rgb(var(--app-muted));font-size:14px;margin:-6px 0 12px}
.ct-star{position:absolute;top:0;right:0;color:rgb(var(--app-muted));cursor:pointer}
.ct-star.on{color:#eab308}
.ct-star.on svg{fill:#eab308}
.ct-actions{display:flex;gap:8px;flex-wrap:wrap}
.ct-act{display:inline-flex;align-items:center;gap:7px;padding:7px 12px;border:1px solid rgb(var(--app-border));border-radius:7px;color:rgb(var(--app-fg));cursor:pointer;font-size:13px;background:rgb(var(--app-surface))}
.ct-act:hover{background:rgb(var(--app-hover))}
.ct-sec{margin-top:26px}
.ct-sectitle{font-size:15px;font-weight:700;margin-bottom:6px}
.ct-secrow{display:flex;align-items:center;justify-content:space-between}
.ct-secadd{opacity:0;border:0;background:none;color:rgb(var(--app-muted));cursor:pointer;font-size:15px;line-height:1;padding:2px 6px;border-radius:4px}
.ct-secrow:hover .ct-secadd{opacity:1}
.ct-secadd:hover{background:rgb(var(--app-hover));color:rgb(var(--app-fg))}
.ct-drow{display:flex;gap:14px;align-items:flex-start;padding:12px 2px;border-top:1px solid rgb(var(--app-border))}
.ct-drow.ct-clickable{cursor:pointer}
.ct-drow.ct-clickable:hover{background:rgb(var(--app-hover) / .5)}
.ct-menu .ct-menusep{border-top:1px solid rgb(var(--app-border));margin:4px 0}
.ct-dico{color:rgb(var(--app-muted));margin-top:2px}
.ct-dlabel{color:rgb(var(--app-muted));font-size:12px}
.ct-dval{margin-top:1px}
@@ -189,10 +210,14 @@ export default function mount(el, ctx) {
mode : "view" , // view | edit | new
mail : null , // { loading, threads }
sortAsc : true ,
page : 0 ,
checked : new Set ( ) , // multi-selected contact ids (Mail-style)
confirmDelete : false ,
editingNotes : false ,
newGroup : null , // null | { add: [contactId,...] } — create-group modal
menu : null , // "groups" | "more" | null
leftW : Number ( localStorage . getItem ( "ct.leftW" ) ) || 240 ,
listW : Number ( localStorage . getItem ( "ct.listW" ) ) || 480 ,
leftCollapsed : localStorage . getItem ( "ct.leftCollapsed" ) === "1" ,
menuOpen : false ,
me : "" , quota : "" ,
@@ -209,6 +234,24 @@ export default function mount(el, ctx) {
render ( ) ;
}
// Create the group named in the modal; optionally add the contacts it was opened for.
async function createGroup ( ) {
if ( ! state . newGroup ) return ;
const inp = el . querySelector ( "[data-ng-name]" ) ;
const name = inp ? inp . value . trim ( ) : "" ;
if ( ! name ) return ;
const res = await api . pluginFetch ( "/groups" , { method : "POST" , body : JSON . stringify ( { name } ) } ) ;
const add = state . newGroup . add || [ ] ;
if ( add . length ) {
await api . pluginFetch ( ` /groups/ ${ res . id } /add ` , { method : "POST" , body : JSON . stringify ( { ids : add } ) } ) ;
}
state . newGroup = null ;
await loadGroups ( ) ;
await loadContacts ( ) ;
if ( state . selectedId ) await loadDetail ( state . selectedId ) ;
render ( ) ;
}
// ---- data loading --------------------------------------------------------
async function loadGroups ( ) {
try { state . groups = await api . pluginFetch ( "/groups" ) ; } catch { state . groups = [ ] ; }
@@ -240,6 +283,7 @@ export default function mount(el, ctx) {
state . selectedId = id ;
state . mode = "view" ;
state . menu = null ;
state . editingNotes = false ;
state . checked . clear ( ) ; // opening a contact exits multi-select mode
await loadDetail ( id ) ;
render ( ) ;
@@ -279,7 +323,7 @@ export default function mount(el, ctx) {
${ navItem ( IC _STAR , "VIPs" , ! state . group && f === "vip" , "filter-vip" ) }
${ navItem ( IC _CLOCK , "Recent" , ! state . group && f === "recent" , "filter-recent" ) }
</div>
<div class="wm-side-section">Groups </div>
<div class="wm-side-section ct-secrow"><span>Groups</span><button class="ct-secadd" data-act="new-group" title="Create group">+ </button> </div>
<div class="wm-side-nav">
${ state . groups . map ( grp ) . join ( "" ) }
${ navItem ( IC _PERSON , "No group" , f === "nogroup" , "filter-nogroup" ) }
@@ -316,10 +360,15 @@ export default function mount(el, ctx) {
// ---- middle: contact list ------------------------------------------------
function listPanel ( ) {
const rows = [ ... state . contacts ] . sort ( ( a , b ) => {
const all = [ ... state . contacts ] . sort ( ( a , b ) => {
const r = ( a . display _name || a . primary _email || "" ) . localeCompare ( b . display _name || b . primary _email || "" , undefined , { sensitivity : "base" } ) ;
return state . sortAsc ? r : - r ;
} ) ;
const total = all . length ;
const maxPage = Math . max ( 0 , Math . ceil ( total / PAGE _SIZE ) - 1 ) ;
if ( state . page > maxPage ) state . page = maxPage ;
const start = state . page * PAGE _SIZE ;
const rows = all . slice ( start , start + PAGE _SIZE ) ;
let html = "" ;
let letter = null ;
for ( const c of rows ) {
@@ -334,12 +383,13 @@ export default function mount(el, ctx) {
<button class="ct-avcheck" data-check=" ${ c . id } " aria-label="Select contact"> ${ checkbox ( ck ) } </button>
</div>
<div class="ct-rowmain">
<div class="ct-rowname"> ${ esc ( name ) } </div>
<div class="ct-rowname"><span class="ct-nametext"> ${ esc ( name ) } </span> ${ c . starred ? ` <span class="ct-vipstar" title="VIP"> ${ IC _STAR } </span> ` : "" } </div>
<div class="ct-rowmail"> ${ esc ( c . primary _email || "" ) } </div>
</div></div> ` ;
}
if ( ! rows . length ) html = ` <div class="ct-empty">No contacts ${ state . q ? " match your search" : " yet" } .</div> ` ;
const allChecked = rows . length > 0 && rows . every ( ( c ) => state . checked . has ( c . id ) ) ;
const arrow = ( d ) => ` <svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" stroke-width="1.6" stroke-linecap="round" stroke-linejoin="round"><path d=" ${ d === "left" ? "M10 4l-4 4 4 4" : "M6 4l4 4-4 4" } "/></svg> ` ;
return `
<div class="ct-bar">
<div class="ct-selall"><button class="ct-selallbtn" data-act="select-all" aria-label="Select all"> ${ checkbox ( allChecked ) } </button></div>
@@ -349,16 +399,26 @@ export default function mount(el, ctx) {
${ state . q ? ` <button class="ct-searchclear" data-act="search-clear" title="Clear">✕</button> ` : "" }
</div>
<button class="ct-iconbtn" data-act="sort" title="Sort"> ${ IC _SORT } </button>
<button class="ct-iconbtn" data-act="more-list" title="More"> ${ IC _MORE } </button>
</div>
<div class="ct-list"> ${ html } </div> ` ;
<div class="ct-list"> ${ html } </div>
<div class="ct-pager">
<span> ${ total ? start + 1 : 0 } – ${ Math . min ( start + PAGE _SIZE , total ) } of ${ total } </span>
<div style="display:flex;gap:2px">
<button class="ct-pagerbtn" data-act="page-prev" title="Previous" ${ state . page === 0 ? "disabled" : "" } > ${ arrow ( "left" ) } </button>
<button class="ct-pagerbtn" data-act="page-next" title="Next" ${ start + PAGE _SIZE >= total ? "disabled" : "" } > ${ arrow ( "right" ) } </button>
</div>
</div> ` ;
}
function confirmModal ( ) {
const n = state . checked . size ;
const bulk = n > 0 ;
const name = state . detail ? ( state . detail . display _name || state . detail . default _email || "this contact" ) : "this contact" ;
const title = bulk ? ` Delete ${ n } contact ${ n === 1 ? "" : "s" } ` : "Delete contact" ;
const msg = bulk ? ` Delete ${ n } selected contact ${ n === 1 ? "" : "s" } ? This can't be undone. ` : ` Delete “ ${ esc ( name ) } ”? This can't be undone. ` ;
return ` <div class="ct-modalbg" data-act="confirm-cancel"><div class="ct-modal" data-stop>
<h3>Delete contact </h3>
<p>Delete “ ${ esc ( name ) } ”? This can't be undone. </p>
<h3> ${ title } </h3>
<p> ${ msg } </p>
<div class="row">
<button class="ct-ghostbtn" data-act="confirm-cancel">Cancel</button>
<button class="ct-danger" data-act="confirm-delete">Delete</button>
@@ -366,13 +426,28 @@ export default function mount(el, ctx) {
</div></div> ` ;
}
function newGroupModal ( ) {
return ` <div class="ct-modalbg" data-act="ng-cancel"><div class="ct-modal" data-stop>
<h3>Create group</h3>
<input class="ct-in" data-ng-name placeholder="Group name" style="margin-top:12px"/>
<div class="row">
<button class="ct-ghostbtn" data-act="ng-cancel">Cancel</button>
<button class="ct-primary" data-act="ng-create">Create</button>
</div>
</div></div> ` ;
}
// ---- right: detail / edit ------------------------------------------------
function detailBar ( ) {
if ( state . mode === "edit" || state . mode === "new" ) {
return ` <div class="ct-bar"><button class="ct-primary" data-act="save">Save</button><button class="ct-ghostbtn" data-act="cancel">Cancel</button></div> ` ;
}
if ( state . checked . size > 0 ) {
return ` <div class="ct-bar"><button class="ct-barbtn" data-act="clear-sel">Clear selection</button></div> ` ;
return ` <div class="ct-bar">
<button class="ct-barbtn" data-act="clear-sel">Clear selection</button>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-groups"> ${ IC _GROUP } Groups ${ CHEV } </button> ${ state . menu === "groups" ? bulkGroupsMenu ( ) : "" } </div>
<div style="position:relative"><button class="ct-barbtn" data-act="menu-more">More ${ CHEV } </button> ${ state . menu === "more" ? bulkMoreMenu ( ) : "" } </div>
</div> ` ;
}
const disabled = state . detail ? "" : "opacity:.4;pointer-events:none" ;
return ` <div class="ct-bar" style=" ${ disabled } ">
@@ -383,11 +458,27 @@ export default function mount(el, ctx) {
}
function groupsMenu ( ) {
const mine = new Set ( ( state . detail . groups || [ ] ) . map ( ( g ) => g . id ) ) ;
return ` <div class="ct-menu" data-stop> ${ state . groups . map ( ( g ) => ` <label><input type="checkbox" data-toggle-group=" ${ g . id } " ${ mine . has ( g . id ) ? "checked" : "" } /> ${ esc ( g . name ) } </label> ` ) . join ( "" ) || ` <div class="ct-dlabel" style="padding:6px 9px">No groups yet</div> ` } </div> ` ;
const items = state . groups . map ( ( g ) => ` <label><input type="checkbox" data-toggle-group=" ${ g . id } " ${ mine . has ( g . id ) ? "checked" : "" } /> ${ esc ( g . name ) } </label> ` ) . join ( "" ) ;
return ` <div class="ct-menu" data-stop> ${ items } ${ items ? ` <div class="ct-menusep"></div> ` : "" } <button data-act="new-group-add"> ${ PLUS _ICON } Create new</button></div> ` ;
}
function moreMenu ( ) {
return ` <div class="ct-menu" data-stop><button data-act="delete">Delete contact</button></div> ` ;
}
// Bulk group menu: a box is ticked when EVERY selected contact is in that group;
// toggling adds/removes the whole selection.
function bulkGroupsMenu ( ) {
const sel = [ ... state . checked ] ;
const byId = new Map ( state . contacts . map ( ( c ) => [ c . id , c ] ) ) ;
const inGroup = ( id , gid ) => ( ( byId . get ( id ) || { } ) . groups || [ ] ) . some ( ( x ) => x . id === gid ) ;
const items = state . groups . map ( ( g ) => {
const allIn = sel . length > 0 && sel . every ( ( id ) => inGroup ( id , g . id ) ) ;
return ` <label><input type="checkbox" data-bulk-group=" ${ g . id } " ${ allIn ? "checked" : "" } /> ${ esc ( g . name ) } </label> ` ;
} ) . join ( "" ) ;
return ` <div class="ct-menu" data-stop> ${ items } ${ items ? ` <div class="ct-menusep"></div> ` : "" } <button data-act="new-group-bulk"> ${ PLUS _ICON } Create new</button></div> ` ;
}
function bulkMoreMenu ( ) {
return ` <div class="ct-menu" data-stop><button data-act="bulk-delete">Delete contacts</button></div> ` ;
}
function detailPanel ( ) {
if ( state . mode === "edit" || state . mode === "new" ) return editForm ( ) ;
@@ -421,7 +512,17 @@ export default function mount(el, ctx) {
( c . urls || [ ] ) . forEach ( ( u ) => { if ( u . value ) details += drow ( IC _LINK , cap ( u . type || "website" ) , ` <a href=" ${ esc ( withHttp ( u . value ) ) } " target="_blank" rel="noopener noreferrer"> ${ esc ( u . value ) } </a> ` ) ; } ) ;
if ( c . nickname ) details += drow ( IC _PERSON , "Nickname" , esc ( c . nickname ) ) ;
if ( c . birthday ) details += drow ( IC _CAL , "Birthday" , esc ( fmtBirthday ( c . birthday ) ) ) ;
details += ` <div class="ct-drow"><span class="ct-dico"> ${ IC _PENCIL } </span><div> ${ c . notes ? ` <div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap"> ${ esc ( c . notes ) } </div> ` : ` <span class="ct-dlabel">Add notes</span> ` } </div></div> ` ;
if ( state . editingNotes ) {
details += ` <div class="ct-drow"><span class="ct-dico"> ${ IC _PENCIL } </span><div style="flex:1">
<div class="ct-dlabel">Notes</div>
<textarea class="ct-in" data-notes rows="3" style="margin-top:4px"> ${ esc ( c . notes || "" ) } </textarea>
<div style="margin-top:8px;display:flex;gap:8px">
<button class="ct-primary" data-act="save-notes">Save</button>
<button class="ct-ghostbtn" data-act="cancel-notes">Cancel</button>
</div></div></div> ` ;
} else {
details += ` <div class="ct-drow ct-clickable" data-act="edit-notes"><span class="ct-dico"> ${ IC _PENCIL } </span><div> ${ c . notes ? ` <div class="ct-dlabel">Notes</div><div class="ct-dval" style="white-space:pre-wrap"> ${ esc ( c . notes ) } </div> ` : ` <span class="ct-dlabel">Add notes</span> ` } </div></div> ` ;
}
return ` <div class="ct-detail"><div class="ct-card">
<div class="ct-cardhead">
@@ -431,7 +532,6 @@ export default function mount(el, ctx) {
${ work ? ` <div class="ct-sub"> ${ IC _WORK } ${ esc ( work ) } </div> ` : "" }
<div class="ct-actions">
<button class="ct-act" data-act="call"> ${ IC _PHONE } Call</button>
<button class="ct-act" data-act="message"> ${ IC _CHAT } Message</button>
<button class="ct-act" data-act="compose"> ${ IC _COMPOSE } Compose</button>
<button class="ct-act" data-act="invite"> ${ IC _CAL } Invite</button>
</div>
@@ -582,10 +682,12 @@ export default function mount(el, ctx) {
const keepFocus = prev && document . activeElement === prev ;
const caret = keepFocus ? prev . selectionStart : null ;
el . innerHTML = ` <div class="ct-app ${ state . checked . size ? " selmode" : "" } ">
${ state . leftCollapsed ? railPanel ( ) : ` <div class="wm-side ct-left" style="width: ${ state . leftW } px;flex:0 0 auto"> ${ leftPanel ( ) } </div> ` }
<div class="ct-listcol"> ${ listPanel ( ) } </div>
${ state . leftCollapsed ? railPanel ( ) : ` <div class="wm-side ct-left" style="width: ${ state . leftW } px;flex:0 0 auto"> ${ leftPanel ( ) } </div><div class="ct-resizer" data-lresizer></div> ` }
<div class="ct-listcol" style="width: ${ state . listW } px;flex:0 0 auto" >${ listPanel ( ) } </div>
<div class="ct-resizer" data-mresizer></div>
<div class="ct-detailcol"> ${ detailBar ( ) } ${ detailPanel ( ) } </div>
${ state . confirmDelete ? confirmModal ( ) : "" }
${ state . newGroup ? newGroupModal ( ) : "" }
</div> ` ;
wire ( ) ;
if ( keepFocus ) {
@@ -597,12 +699,42 @@ export default function mount(el, ctx) {
function wire ( ) {
const on = ( sel , ev , fn ) => el . querySelectorAll ( sel ) . forEach ( ( n ) => n . addEventListener ( ev , fn ) ) ;
const act = ( name , fn ) => on ( ` [data-act=" ${ name } "] ` , "click" , fn ) ;
const setFilter = ( f ) => { state . filter = f ; state . group = "" ; state . menuOpen = false ; state . checked . clear ( ) ; reloadList ( false ) ; } ;
const setFilter = ( f ) => { state . filter = f ; state . group = "" ; state . menuOpen = false ; state . checked . clear ( ) ; state . page = 0 ; reloadList ( false ) ; } ;
on ( "[data-app]" , "click" , ( e ) => { const a = state . apps [ Number ( e . currentTarget . getAttribute ( "data-app" ) ) ] ; state . menuOpen = false ; if ( a . to ) ctx . navigate ( a . to ) ; else render ( ) ; } ) ;
act ( "menu" , ( ) => { state . menuOpen = ! state . menuOpen ; render ( ) ; } ) ;
act ( "left-toggle" , ( ) => { state . leftCollapsed = ! state . leftCollapsed ; try { localStorage . setItem ( "ct.leftCollapsed" , state . leftCollapsed ? "1" : "0" ) ; } catch { /* ignore */ } render ( ) ; } ) ;
act ( "theme" , ( ) => { const dark = document . documentElement . classList . toggle ( "dark" ) ; try { localStorage . setItem ( "theme" , dark ? "dark" : "light" ) ; } catch { /* ignore */ } render ( ) ; } ) ;
// Draggable column dividers (sidebar + list/detail split), persisted like Mail.
const drag = ( handle , targetSel , key , min , max , sign ) => {
const h = el . querySelector ( handle ) ;
if ( ! h ) return ;
h . addEventListener ( "mousedown" , ( e ) => {
e . preventDefault ( ) ;
const target = el . querySelector ( targetSel ) ;
const startX = e . clientX ;
const startW = target . offsetWidth ;
const onMove = ( ev ) => {
const w = Math . max ( min , Math . min ( max , startW + sign * ( ev . clientX - startX ) ) ) ;
target . style . width = ` ${ w } px ` ;
state [ key ] = w ;
} ;
const onUp = ( ) => {
document . removeEventListener ( "mousemove" , onMove ) ;
document . removeEventListener ( "mouseup" , onUp ) ;
document . body . style . cursor = "" ;
document . body . style . userSelect = "" ;
try { localStorage . setItem ( ` ct. ${ key } ` , String ( state [ key ] ) ) ; } catch { /* ignore */ }
} ;
document . addEventListener ( "mousemove" , onMove ) ;
document . addEventListener ( "mouseup" , onUp ) ;
document . body . style . cursor = "col-resize" ;
document . body . style . userSelect = "none" ;
} ) ;
} ;
drag ( "[data-lresizer]" , ".ct-left" , "leftW" , 190 , 440 , 1 ) ;
drag ( "[data-mresizer]" , ".ct-listcol" , "listW" , 320 , 900 , 1 ) ;
act ( "settings" , ( ) => ctx . navigate ( "/settings?from=%2Fp%2Fcontacts" ) ) ;
act ( "signout" , async ( ) => { await api . fetch ( "/auth/logout" , { method : "POST" } ) ; ctx . navigate ( "/login" ) ; } ) ;
@@ -613,8 +745,10 @@ export default function mount(el, ctx) {
on ( "[data-group]" , "click" , ( e ) => {
state . group = e . currentTarget . getAttribute ( "data-group" ) ;
state . groupLabel = e . currentTarget . getAttribute ( "data-groupname" ) ;
state . filter = "group" ; state . menuOpen = false ; state . checked . clear ( ) ; reloadList ( false ) ;
state . filter = "group" ; state . menuOpen = false ; state . checked . clear ( ) ; state . page = 0 ; reloadList ( false ) ;
} ) ;
act ( "page-prev" , ( ) => { if ( state . page > 0 ) { state . page -- ; render ( ) ; } } ) ;
act ( "page-next" , ( ) => { state . page ++ ; render ( ) ; } ) ;
on ( "[data-contact]" , "click" , ( e ) => selectContact ( e . currentTarget . getAttribute ( "data-contact" ) ) ) ;
// Selection checkboxes (revealed on avatar-hover / in select mode) — Mail-style.
@@ -633,11 +767,12 @@ export default function mount(el, ctx) {
act ( "clear-sel" , ( ) => { state . checked . clear ( ) ; render ( ) ; } ) ;
on ( ".ct-search" , "input" , ( e ) => {
state . q = e . currentTarget . value ;
state . page = 0 ;
if ( searchTimer ) clearTimeout ( searchTimer ) ;
searchTimer = setTimeout ( runSearch , 250 ) ;
} ) ;
on ( ".ct-search" , "keydown" , ( e ) => { if ( e . key === "Escape" ) { state . q = "" ; runSearch ( ) ; } } ) ;
act ( "search-clear" , ( ) => { state . q = "" ; runSearch ( ) ; } ) ;
on ( ".ct-search" , "keydown" , ( e ) => { if ( e . key === "Escape" ) { state . q = "" ; state . page = 0 ; runSearch ( ) ; } } ) ;
act ( "search-clear" , ( ) => { state . q = "" ; state . page = 0 ; runSearch ( ) ; } ) ;
act ( "sort" , ( ) => { state . sortAsc = ! state . sortAsc ; render ( ) ; } ) ;
act ( "new" , ( ) => { state . mode = "new" ; state . selectedId = null ; state . detail = null ; render ( ) ; } ) ;
@@ -647,13 +782,18 @@ export default function mount(el, ctx) {
act ( "save" , ( ) => saveForm ( ) ) ;
act ( "star" , async ( ) => { const r = await api . pluginFetch ( ` /contacts/ ${ state . selectedId } /star ` , { method : "POST" } ) ; state . detail . starred = r . starred ; render ( ) ; } ) ;
act ( "call" , ( ) => { const p = state . detail . phones && state . detail . phones [ 0 ] ; if ( p ) window . open ( ` tel: ${ p . value } ` ) ; } ) ;
act ( "messag e" , ( ) => { 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 ( "compos e" , ( ) => {
const email = state . detail && state . detail . default _email ;
ctx . navigate ( email ? ` /mail?compose=1&to= ${ encodeURIComponent ( email ) } ` : "/mail" ) ;
} ) ;
act ( "invite" , ( ) => {
const email = state . detail && state . detail . default _email ;
ctx . navigate ( email ? ` /p/calendar?guest= ${ encodeURIComponent ( email ) } ` : "/p/calendar" ) ;
} ) ;
act ( "find-mail" , ( ) => ctx . navigate ( ` /mail/search/ ${ encodeURIComponent ( '"' + state . detail . default _email + '"' ) } ` ) ) ;
act ( "find-mail" , ( ) => {
const email = state . detail && state . detail . default _email ;
ctx . navigate ( email ? ` /mail?q= ${ encodeURIComponent ( email ) } ` : "/mail" ) ;
} ) ;
// menus
act ( "menu-groups" , ( e ) => { e . stopPropagation ( ) ; state . menu = state . menu === "groups" ? null : "groups" ; render ( ) ; } ) ;
@@ -667,15 +807,57 @@ export default function mount(el, ctx) {
await api . pluginFetch ( ` /contacts/ ${ state . selectedId } ` , { method : "PUT" , body : JSON . stringify ( body ) } ) ;
await loadGroups ( ) ; await loadDetail ( state . selectedId ) ; render ( ) ;
} ) ;
// Bulk group add/remove for the whole selection.
on ( "[data-bulk-group]" , "change" , async ( e ) => {
const gid = e . currentTarget . getAttribute ( "data-bulk-group" ) ;
const path = e . currentTarget . checked ? "add" : "remove" ;
await api . pluginFetch ( ` /groups/ ${ gid } / ${ path } ` , { method : "POST" , body : JSON . stringify ( { ids : [ ... state . checked ] } ) } ) ;
await loadGroups ( ) ; await loadContacts ( ) ; render ( ) ;
} ) ;
act ( "delete" , ( ) => { state . menu = null ; state . confirmDelete = true ; render ( ) ; } ) ;
act ( "bulk-delete" , ( ) => { state . menu = null ; state . confirmDelete = true ; render ( ) ; } ) ;
act ( "confirm-cancel" , ( ) => { state . confirmDelete = false ; render ( ) ; } ) ;
act ( "confirm-delete" , async ( ) => {
state . confirmDelete = false ;
await api . pluginFetch ( ` /contacts/ ${ state . selectedId } ` , { method : "DELETE" } ) ;
if ( state . checked . size > 0 ) {
for ( const id of state . checked ) await api . pluginFetch ( ` /contacts/ ${ id } ` , { method : "DELETE" } ) ;
state . checked . clear ( ) ;
} else {
await api . pluginFetch ( ` /contacts/ ${ state . selectedId } ` , { method : "DELETE" } ) ;
}
state . selectedId = null ;
await reloadList ( false ) ;
} ) ;
// notes inline editor
act ( "edit-notes" , ( ) => {
state . editingNotes = true ; render ( ) ;
const ta = el . querySelector ( "[data-notes]" ) ;
if ( ta ) { ta . focus ( ) ; ta . setSelectionRange ( ta . value . length , ta . value . length ) ; }
} ) ;
act ( "cancel-notes" , ( ) => { state . editingNotes = false ; render ( ) ; } ) ;
act ( "save-notes" , async ( ) => {
const ta = el . querySelector ( "[data-notes]" ) ;
const body = detailToBody ( state . detail , ( state . detail . groups || [ ] ) . map ( ( g ) => g . id ) ) ;
body . notes = ta ? ta . value : "" ;
await api . pluginFetch ( ` /contacts/ ${ state . selectedId } ` , { method : "PUT" , body : JSON . stringify ( body ) } ) ;
state . editingNotes = false ;
await loadDetail ( state . selectedId ) ;
render ( ) ;
} ) ;
// create-group modal (left-panel +, and "Create new" in the group menus)
const openNewGroup = ( add ) => {
state . newGroup = { add } ; state . menu = null ; render ( ) ;
const inp = el . querySelector ( "[data-ng-name]" ) ; if ( inp ) inp . focus ( ) ;
} ;
act ( "new-group" , ( ) => openNewGroup ( [ ] ) ) ;
act ( "new-group-add" , ( ) => openNewGroup ( state . selectedId ? [ state . selectedId ] : [ ] ) ) ;
act ( "new-group-bulk" , ( ) => openNewGroup ( [ ... state . checked ] ) ) ;
act ( "ng-cancel" , ( ) => { state . newGroup = null ; render ( ) ; } ) ;
act ( "ng-create" , ( ) => createGroup ( ) ) ;
on ( "[data-ng-name]" , "keydown" , ( e ) => { if ( e . key === "Enter" ) { e . preventDefault ( ) ; createGroup ( ) ; } } ) ;
// edit form multi-rows
act ( "add-email" , ( ) => { const box = el . querySelector ( "[data-emails]" ) ; const w = document . createElement ( "div" ) ; w . innerHTML = emailRow ( null ) ; const row = w . firstElementChild ; row . querySelector ( "[data-act=rm-email]" ) . addEventListener ( "click" , ( ) => row . remove ( ) ) ; box . appendChild ( row ) ; } ) ;
act ( "add-phone" , ( ) => { const box = el . querySelector ( "[data-phones]" ) ; const w = document . createElement ( "div" ) ; w . innerHTML = phoneRow ( null ) ; const row = w . firstElementChild ; row . querySelector ( "[data-act=rm-phone]" ) . addEventListener ( "click" , ( ) => row . remove ( ) ) ; box . appendChild ( row ) ; } ) ;