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