Skip to main content

max / goingson

8.8 KB · 181 lines History Blame Raw
1 /**
2 * GoingsOn - Contacts Render Module
3 * Contact card rendering, detail modal rendering
4 */
5
6 (function() {
7 'use strict';
8 const esc = GoingsOn.utils.escapeHtml;
9 const escAttr = GoingsOn.utils.escapeAttrValue;
10 const escArg = GoingsOn.utils.escapeHandlerArg;
11 const escAttrVal = GoingsOn.utils.escapeAttrValue;
12 const safeUrl = GoingsOn.utils.safeUrl;
13
14 // ============ Helpers ============
15
16 /**
17 * Extract up to 2 initials from a display name.
18 * @param {string} name - Full display name
19 * @returns {string} Uppercase initials (e.g. "JD")
20 */
21 function getInitials(name) {
22 return name.split(/\s+/).filter(Boolean).map(w => w[0]).slice(0, 2).join('').toUpperCase();
23 }
24
25 // ============ Card Rendering ============
26
27 /**
28 * Render a contact card for the contacts grid.
29 * @param {Object} c - Contact object
30 * @returns {string} HTML string for the contact card
31 */
32 function renderCard(c) {
33 const initials = c.initials || getInitials(c.displayName);
34 const primaryEmail = c.primaryEmail || c.emails?.find(e => e.isPrimary)?.address || c.emails?.[0]?.address || '';
35 const nickname = c.nickname ? `<span class="contact-nickname">"${esc(c.nickname)}"</span>` : '';
36 const company = c.company ? `<span class="contact-company">${esc(c.company)}</span>` : '';
37 const emailLine = primaryEmail ? `<span class="contact-email">${esc(primaryEmail)}</span>` : '';
38 const tagPills = (c.tags || []).map(t =>
39 `<span class="tag">${esc(t)}</span>`
40 ).join('');
41
42 return `
43 <div class="card contact-card" data-act="contacts.open" data-a1="${escAttr(c.id)}"
44 tabindex="0" role="button" aria-label="Open contact ${esc(c.displayName)}">
45 <div class="card-header contact-card-header">
46 <input type="checkbox" class="bulk-checkbox contact-select-cb" data-id="${escAttr(c.id)}"
47 data-act="contacts.toggleSelection" data-a1="${escAttr(c.id)}" data-a2="@event"
48 aria-label="Select ${esc(c.displayName)}">
49 <div class="avatar">${esc(initials)}</div>
50 <div class="contact-card-body">
51 <h3 class="card-title">${esc(c.displayName)}</h3>
52 ${nickname}
53 ${company}
54 </div>
55 <button class="btn btn-sm btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(c.id)}" title="Edit">...</button>
56 </div>
57 ${emailLine ? `<p class="card-description">${emailLine}</p>` : ''}
58 ${tagPills ? `<div class="card-meta">${tagPills}</div>` : ''}
59 </div>
60 `;
61 }
62
63 // ============ Detail Modal Rendering ============
64
65 /**
66 * Show the full contact detail modal with all sub-collections.
67 * @param {Object} contact - Full contact object with emails, phones, socialHandles, customFields
68 */
69 function showDetailModal(contact) {
70 const initials = contact.initials || getInitials(contact.displayName);
71
72 // Build info section
73 let info = '';
74 if (contact.nickname) info += `<div class="detail-row"><strong>Nickname:</strong> ${esc(contact.nickname)}</div>`;
75 if (contact.company) info += `<div class="detail-row"><strong>Company:</strong> ${esc(contact.company)}</div>`;
76 if (contact.title) info += `<div class="detail-row"><strong>Title:</strong> ${esc(contact.title)}</div>`;
77 if (contact.birthday) info += `<div class="detail-row"><strong>Birthday:</strong> ${esc(contact.birthday)}</div>`;
78 if (contact.timezone) info += `<div class="detail-row"><strong>Timezone:</strong> ${esc(contact.timezone)}</div>`;
79
80 // Tags
81 const tagPills = (contact.tags || []).map(t =>
82 `<span class="tag">${esc(t)}</span>`
83 ).join(' ');
84
85 // Emails
86 const emailRows = (contact.emails || []).map(e => `
87 <div class="sub-item">
88 <span>${esc(e.address)} ${e.label ? `<small>(${esc(e.label)})</small>` : ''} ${e.isPrimary ? '<strong>Primary</strong>' : ''}</span>
89 <button class="btn btn-sm btn-danger" data-act="contacts.removeEmail" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(e.id)}" title="Remove">x</button>
90 </div>
91 `).join('');
92
93 // Phones
94 const phoneRows = (contact.phones || []).map(p => `
95 <div class="sub-item">
96 <span>${esc(p.number)} ${p.label ? `<small>(${esc(p.label)})</small>` : ''} ${p.isPrimary ? '<strong>Primary</strong>' : ''}</span>
97 <button class="btn btn-sm btn-danger" data-act="contacts.removePhone" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(p.id)}" title="Remove">x</button>
98 </div>
99 `).join('');
100
101 // Social handles
102 const socialRows = (contact.socialHandles || []).map(s => `
103 <div class="sub-item">
104 <span><strong>${esc(s.platform)}:</strong> ${s.url ? `<a href="${escAttrVal(safeUrl(s.url))}" target="_blank">${esc(s.handle)}</a>` : esc(s.handle)}</span>
105 <button class="btn btn-sm btn-danger" data-act="contacts.removeSocialHandle" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(s.id)}" title="Remove">x</button>
106 </div>
107 `).join('');
108
109 // Custom fields
110 const customFieldRows = (contact.customFields || []).map(f => `
111 <div class="sub-item">
112 <span><strong>${esc(f.label)}:</strong> ${f.url ? `<a href="${escAttrVal(safeUrl(f.url))}" target="_blank">${esc(f.value)}</a>` : esc(f.value)}</span>
113 <button class="btn btn-sm btn-danger" data-act="contacts.removeCustomField" data-a1="${escAttr(contact.id)}" data-a2="${escAttr(f.id)}" title="Remove">x</button>
114 </div>
115 `).join('');
116
117 const content = `
118 <div class="contact-detail">
119 <div class="contact-detail-header">
120 <div class="avatar avatar--lg">${esc(initials)}</div>
121 <div>
122 <h3 class="contact-detail-name">${esc(contact.displayName)}</h3>
123 ${contact.company ? `<div class="text-secondary">${esc(contact.company)}${contact.title ? ` - ${esc(contact.title)}` : ''}</div>` : ''}
124 </div>
125 </div>
126
127 ${info ? `<div class="card card--muted contact-info-section">${info}</div>` : ''}
128 ${tagPills ? `<div class="contact-detail-tags">${tagPills}</div>` : ''}
129 ${contact.notes ? `<div class="contact-notes"><strong>Notes:</strong><p>${esc(contact.notes)}</p></div>` : ''}
130
131 <div class="sub-collection">
132 <div class="sub-collection-header">
133 <h4>Email Addresses</h4>
134 <button class="btn btn-sm btn-primary" data-act="contacts.openAddEmail" data-a1="${escAttr(contact.id)}">+ Add</button>
135 </div>
136 ${emailRows || '<div class="sub-empty">No email addresses</div>'}
137 </div>
138
139 <div class="sub-collection">
140 <div class="sub-collection-header">
141 <h4>Phone Numbers</h4>
142 <button class="btn btn-sm btn-primary" data-act="contacts.openAddPhone" data-a1="${escAttr(contact.id)}">+ Add</button>
143 </div>
144 ${phoneRows || '<div class="sub-empty">No phone numbers</div>'}
145 </div>
146
147 <div class="sub-collection">
148 <div class="sub-collection-header">
149 <h4>Social Handles</h4>
150 <button class="btn btn-sm btn-primary" data-act="contacts.openAddSocial" data-a1="${escAttr(contact.id)}">+ Add</button>
151 </div>
152 ${socialRows || '<div class="sub-empty">No social handles</div>'}
153 </div>
154
155 <div class="sub-collection">
156 <div class="sub-collection-header">
157 <h4>Custom Fields</h4>
158 <button class="btn btn-sm btn-primary" data-act="contacts.openAddCustomField" data-a1="${escAttr(contact.id)}">+ Add</button>
159 </div>
160 ${customFieldRows || '<div class="sub-empty">No custom fields</div>'}
161 </div>
162
163 <div class="form-actions form-actions--spaced">
164 <button class="btn btn-secondary" data-act="contacts.openEdit" data-a1="${escAttr(contact.id)}">Edit Contact</button>
165 </div>
166 </div>
167 `;
168
169 GoingsOn.ui.openModal(contact.displayName, content);
170 }
171
172 // ============ Populate Namespace ============
173
174 GoingsOn.contactsRender = {
175 getInitials,
176 renderCard,
177 showDetailModal,
178 };
179
180 })();
181