Add contacts features: tags, history, CSV import/export, merge, last-contacted; fix CalendarPage unused var; v1.1.23

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
nmemmert
2026-07-28 17:00:21 -04:00
parent 5e43c41b2b
commit e2c560a1ab
6 changed files with 902 additions and 282 deletions
+1 -1
View File
@@ -1,7 +1,7 @@
{
"name": "siteforge",
"private": true,
"version": "1.1.22",
"version": "1.1.23",
"type": "module",
"scripts": {
"dev": "vite",
+5 -1
View File
@@ -1113,7 +1113,11 @@ function sanitizeLoadedContactSubmissions(value) {
snoozedUntil: typeof entry.snoozedUntil === 'string' && !isNaN(Date.parse(entry.snoozedUntil)) ? entry.snoozedUntil : null,
threadId: typeof entry.threadId === 'string' && entry.threadId.trim() ? entry.threadId.trim() : randomUUID(),
emailStatus: normalizeContactEmailStatus(entry.emailStatus, entry.subscribe === true),
source: entry.source === 'inbound-email' || entry.source === 'download' ? entry.source : 'contact-form',
source: ['inbound-email', 'download', 'manual'].includes(entry.source) ? entry.source : 'contact-form',
notes: typeof entry.notes === 'string' ? entry.notes.trim().slice(0, 2000) : '',
tags: Array.isArray(entry.tags)
? [...new Set(entry.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
: [],
htmlBody: typeof entry.htmlBody === 'string' && entry.htmlBody.trim() ? entry.htmlBody : null,
inboundTo: typeof entry.inboundTo === 'string' ? entry.inboundTo : '',
messageId: typeof entry.messageId === 'string' ? entry.messageId : '',
+62
View File
@@ -442,6 +442,9 @@ export function register(app) {
if (typeof req.body?.starred === 'boolean') patch.starred = req.body.starred
if (typeof req.body?.name === 'string') patch.name = req.body.name.trim().slice(0, 200)
if (typeof req.body?.notes === 'string') patch.notes = req.body.notes.trim().slice(0, 2000)
if (Array.isArray(req.body?.tags)) {
patch.tags = [...new Set(req.body.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)))].slice(0, 20)
}
if ('snoozedUntil' in (req.body ?? {})) {
const v = req.body.snoozedUntil
patch.snoozedUntil = v === null ? null : (typeof v === 'string' && !isNaN(Date.parse(v)) ? v : undefined)
@@ -507,6 +510,65 @@ export function register(app) {
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/merge', requireAdminAuth, (req, res) => {
const keepEmail = typeof req.body?.keepEmail === 'string' ? req.body.keepEmail.trim().toLowerCase() : ''
const mergeEmail = typeof req.body?.mergeEmail === 'string' ? req.body.mergeEmail.trim().toLowerCase() : ''
if (!keepEmail || !mergeEmail || keepEmail === mergeEmail) {
res.status(400).json({ message: 'keepEmail and mergeEmail must be different non-empty addresses.' }); return
}
let affected = 0
state.contactSubmissions = state.contactSubmissions.map(s => {
if ((s.email ?? '').trim().toLowerCase() !== mergeEmail) return s
affected++
return { ...s, email: keepEmail }
})
queueContactSubmissionsWrite()
res.json({ ok: true, affected })
})
app.post('/api/admin-contacts/import', requireAdminAuth, (req, res) => {
const rows = req.body?.rows
if (!Array.isArray(rows) || rows.length === 0) {
res.status(400).json({ message: 'rows must be a non-empty array.' }); return
}
const EMAIL_RE = /^[a-zA-Z0-9._%+\-]{1,64}@[a-zA-Z0-9.\-]{1,253}\.[a-zA-Z]{2,}$/
let created = 0
let skipped = 0
const toAdd = []
for (const row of rows.slice(0, 1000)) {
const name = typeof row.name === 'string' ? row.name.trim().slice(0, 200) : ''
const email = typeof row.email === 'string' ? row.email.trim().toLowerCase().slice(0, 320) : ''
const notes = typeof row.notes === 'string' ? row.notes.trim().slice(0, 2000) : ''
const tags = Array.isArray(row.tags)
? row.tags.filter(t => typeof t === 'string' && t.trim()).map(t => t.trim().slice(0, 50)).slice(0, 20)
: (typeof row.tags === 'string' ? row.tags.split(';').map(t => t.trim()).filter(Boolean).slice(0, 20) : [])
if (!name && !email) { skipped++; continue }
if (email && !EMAIL_RE.test(email)) { skipped++; continue }
toAdd.push({ name, email, notes, tags })
}
for (const row of toAdd) {
const submission = {
id: randomUUID(),
submittedAt: new Date().toISOString(),
name: row.name,
email: row.email,
message: '',
messageType: 'general',
subscribe: false,
archived: false,
source: 'manual',
notes: row.notes,
tags: row.tags,
emailStatus: normalizeContactEmailStatus(null, false),
}
state.contactSubmissions.unshift(submission)
created++
}
state.contactSubmissions = state.contactSubmissions.slice(0, MAX_CONTACT_SUBMISSIONS)
if (created > 0) queueContactSubmissionsWrite()
res.json({ ok: true, created, skipped })
})
app.get('/api/admin-contact-submissions/:id/attachments/:attachmentId', requireAdminAuth, (req, res) => {
const { id, attachmentId } = req.params
const submission = state.contactSubmissions.find(s => s.id === id)
+254
View File
@@ -10461,6 +10461,260 @@
margin-top: 0.2rem;
}
.ct-card-meta-row {
margin-top: 0.2rem;
}
.ct-card-date {
color: #6b6560;
font-size: 0.78rem;
}
.ct-last-contacted {
color: #8a8070;
}
.ct-card-preview {
color: #9a9088;
font-size: 0.82rem;
margin-top: 0.25rem;
line-height: 1.4;
white-space: pre-wrap;
word-break: break-word;
}
/* Tags */
.ct-tags-row {
display: flex;
flex-wrap: wrap;
gap: 0.35rem;
margin-top: 0.3rem;
}
.ct-tag {
align-items: center;
border-radius: 12px;
color: rgba(255,255,255,0.85);
display: inline-flex;
font-size: 0.72rem;
font-weight: 500;
gap: 0.25rem;
padding: 0.18rem 0.55rem;
}
.ct-tag-remove {
background: none;
border: none;
color: inherit;
cursor: pointer;
font-size: 1rem;
line-height: 1;
opacity: 0.7;
padding: 0;
margin-left: 0.1rem;
}
.ct-tag-remove:hover { opacity: 1; }
.ct-tag-editor {
align-items: center;
background: #1a1a1a;
border: 1px solid #2a2a2a;
border-radius: 6px;
display: flex;
flex-wrap: wrap;
gap: 0.3rem;
min-height: 34px;
padding: 0.3rem 0.5rem;
}
.ct-tag-editor:focus-within { border-color: #c8860a; }
.ct-tag-input {
background: none;
border: none;
color: #e8e2d5;
flex: 1;
font-size: 0.82rem;
min-width: 80px;
outline: none;
padding: 0;
}
.ct-tag-input::placeholder { color: #554f45; }
.ct-tag-filter {
appearance: none;
background: #1a1a1a url("data:image/svg+xml,%3Csvg xmlns='http://www.w3.org/2000/svg' width='10' height='6'%3E%3Cpath d='M0 0l5 6 5-6z' fill='%23888'/%3E%3C/svg%3E") no-repeat right 0.6rem center;
border: 1px solid #2a2a2a;
border-radius: 6px;
color: #c0b8a8;
font-size: 0.82rem;
padding: 0.42rem 2rem 0.42rem 0.75rem;
cursor: pointer;
}
.ct-tag-filter:focus { outline: none; border-color: #c8860a; }
/* Merge picker */
.ct-merge-picker {
background: #141414;
border: 1px solid #2a2a2a;
border-radius: 8px;
margin-top: 0.5rem;
padding: 0.75rem;
}
.ct-merge-label {
color: #a09880;
font-size: 0.82rem;
margin: 0 0 0.5rem;
}
.ct-merge-search {
margin-bottom: 0.5rem;
width: 100%;
}
.ct-merge-list {
display: flex;
flex-direction: column;
gap: 0.3rem;
max-height: 240px;
overflow-y: auto;
}
.ct-merge-option {
align-items: center;
background: #1a1a1a;
border: 1px solid #252525;
border-radius: 6px;
color: #d4c8a8;
cursor: pointer;
display: flex;
gap: 0.75rem;
padding: 0.5rem 0.75rem;
text-align: left;
transition: background 0.12s;
}
.ct-merge-option:hover:not(:disabled) { background: #222; border-color: #363636; }
.ct-merge-option:disabled { opacity: 0.5; cursor: not-allowed; }
.ct-merge-name { font-weight: 500; font-size: 0.86rem; }
.ct-merge-email { color: #8a8070; font-size: 0.78rem; flex: 1; }
.ct-merge-count { color: #6b6560; font-size: 0.75rem; }
/* Conversation history */
.ct-history-panel {
background: #111;
border-top: 1px solid #1e1e1e;
display: flex;
flex-direction: column;
gap: 0.5rem;
grid-column: 1 / -1;
padding: 0.75rem 1rem;
width: 100%;
}
.ct-history-empty {
color: #554f45;
font-size: 0.82rem;
font-style: italic;
}
.ct-history-item {
border-radius: 8px;
padding: 0.5rem 0.75rem;
}
.ct-history-item--in {
background: #1a1a1a;
border-left: 3px solid #3a5a4a;
}
.ct-history-item--out {
background: #191419;
border-left: 3px solid #5a3a5a;
}
.ct-history-meta {
align-items: baseline;
display: flex;
gap: 0.5rem;
margin-bottom: 0.2rem;
}
.ct-history-who {
color: #c0b8a8;
font-size: 0.78rem;
font-weight: 500;
}
.ct-history-date {
color: #6b6560;
font-size: 0.72rem;
margin-left: auto;
}
.ct-history-subject {
color: #a09880;
font-size: 0.78rem;
font-style: italic;
margin-bottom: 0.15rem;
}
.ct-history-body {
color: #9a9088;
font-size: 0.8rem;
line-height: 1.45;
margin: 0;
white-space: pre-wrap;
word-break: break-word;
}
/* Import banner */
.ct-import-banner {
align-items: flex-start;
background: #13181a;
border-bottom: 1px solid #2a3a2a;
display: flex;
flex-wrap: wrap;
gap: 0.75rem;
padding: 0.75rem 1.25rem;
}
.ct-import-info {
color: #d4c8a8;
flex: 1;
font-size: 0.84rem;
}
.ct-import-cols {
color: #8a8070;
}
.ct-import-actions {
display: flex;
gap: 0.5rem;
}
.ct-import-msg {
color: #4ade80;
font-size: 0.82rem;
margin: 0;
width: 100%;
}
/* Flash message */
.ct-flash {
background: #1a2a1a;
border-bottom: 1px solid #2a3a2a;
color: #4ade80;
font-size: 0.84rem;
padding: 0.5rem 1.25rem;
}
/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
.cal-app {
+1 -2
View File
@@ -657,8 +657,7 @@ function CalendarClient() {
// ── Render chip ──
function renderEpChip(ep: PodcastChecklistEpisode, dk: string) {
const isScheduled = ep.datePublished === dk
function renderEpChip(ep: PodcastChecklistEpisode, _dk: string) {
return (
<button
key={ep.id}
+522 -221
View File
@@ -1,4 +1,4 @@
import React, { useCallback, useEffect, useMemo, useState } from 'react'
import React, { useCallback, useEffect, useMemo, useRef, useState } from 'react'
import { Link } from 'react-router-dom'
// ── Types ────────────────────────────────────────────────────────────────────
@@ -12,9 +12,23 @@ interface ContactSubmission {
messageType: string
subscribe: boolean
archived?: boolean
starred?: boolean
source?: string
inboundTo?: string
notes?: string
tags?: string[]
}
interface ReplyHistoryItem {
id: string
submissionId: string
toEmail: string
toName: string
fromEmail: string
subject: string
preview: string
sentAt: string
scheduledAt?: string | null
}
interface Contact {
@@ -22,18 +36,108 @@ interface Contact {
email: string
name: string
source: string | undefined
inboundTo: string | undefined
subscribe: boolean
archived: boolean
submittedAt: string
firstContactAt: string // oldest submission date
latestAt: string // newest submission date
message: string
notes: string
tags: string[]
lastContactedAt: string | null
submissionCount: number
mainId: string
allIds: string[]
allSubmissions: ContactSubmission[]
}
// ── Auth Shell ───────────────────────────────────────────────────────────────
type ConversationItem =
| { kind: 'inbound'; date: string; name: string; message: string; source?: string; id: string }
| { kind: 'outbound'; date: string; subject: string; preview: string; toEmail: string }
// ── Helpers ───────────────────────────────────────────────────────────────────
const TAG_PALETTE = [
'#1a3d2b', '#2b1a3d', '#3d1a1a', '#1a2b3d', '#3d2b1a',
'#1a3d3d', '#3d1a3d', '#2b3d1a', '#1a1a3d', '#3d3d1a',
]
function tagBg(tag: string): string {
let h = 0
for (const c of tag) h = (h * 31 + c.charCodeAt(0)) & 0x7fffffff
return TAG_PALETTE[h % TAG_PALETTE.length]
}
function fmtDate(iso: string | null | undefined) {
if (!iso) return '—'
try { return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' }) }
catch { return '—' }
}
function fmtShort(iso: string | null | undefined) {
if (!iso) return ''
try {
const d = new Date(iso)
const now = new Date()
if (d.toDateString() === now.toDateString()) return d.toLocaleTimeString([], { hour: '2-digit', minute: '2-digit' })
if (d.getFullYear() === now.getFullYear()) return d.toLocaleDateString([], { month: 'short', day: 'numeric' })
return d.toLocaleDateString([], { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return '' }
}
// ── CSV helpers ───────────────────────────────────────────────────────────────
function csvField(v: string): string {
const s = String(v ?? '')
return /[",\n\r]/.test(s) ? '"' + s.replace(/"/g, '""') + '"' : s
}
function exportContactsCSV(contacts: Contact[]) {
const headers = ['name', 'email', 'notes', 'tags', 'source', 'subscribed', 'first_contact', 'last_contact', 'message_count']
const rows = contacts.map(c => [
c.name, c.email, c.notes,
c.tags.join(';'),
c.source ?? '',
c.subscribe ? 'yes' : 'no',
c.firstContactAt.slice(0, 10),
c.latestAt.slice(0, 10),
String(c.submissionCount),
])
const csv = [headers, ...rows].map(r => r.map(csvField).join(',')).join('\r\n')
const blob = new Blob([csv], { type: 'text/csv;charset=utf-8;' })
const url = URL.createObjectURL(blob)
const a = document.createElement('a')
a.href = url; a.download = `contacts-${new Date().toISOString().slice(0, 10)}.csv`; a.click()
setTimeout(() => URL.revokeObjectURL(url), 5000)
}
function parseCSVRow(line: string): string[] {
const result: string[] = []
let cur = '', inQ = false
for (let i = 0; i < line.length; i++) {
const ch = line[i]
if (ch === '"') {
if (inQ && line[i + 1] === '"') { cur += '"'; i++ }
else inQ = !inQ
} else if (ch === ',' && !inQ) { result.push(cur); cur = '' }
else cur += ch
}
result.push(cur)
return result
}
function parseCSV(text: string): Record<string, string>[] {
const lines = text.split(/\r?\n/).filter(l => l.trim())
if (lines.length < 2) return []
const headers = parseCSVRow(lines[0]).map(h => h.toLowerCase().trim().replace(/\s+/g, '_'))
return lines.slice(1)
.map(line => {
const vals = parseCSVRow(line)
return Object.fromEntries(headers.map((h, i) => [h, (vals[i] ?? '').trim()]))
})
.filter(r => Object.values(r).some(v => v))
}
// ── Auth Shell ────────────────────────────────────────────────────────────────
export default function ContactsShell() {
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
@@ -41,46 +145,31 @@ export default function ContactsShell() {
const [totp, setTotp] = useState('')
const [authError, setAuthError] = useState('')
const [authBusy, setAuthBusy] = useState(false)
const [pendingToken, setPendingToken] = useState('')
useEffect(() => {
fetch('/api/admin-auth/status', { credentials: 'include' })
.then(r => r.json())
.then((data: { authenticated?: boolean }) => {
setAuthState(data.authenticated ? 'ok' : 'needs-password')
})
.then((d: { authenticated?: boolean }) => setAuthState(d.authenticated ? 'ok' : 'needs-password'))
.catch(() => setAuthState('needs-password'))
}, [])
async function handleLogin(e: React.FormEvent) {
e.preventDefault()
setAuthBusy(true)
setAuthError('')
e.preventDefault(); setAuthBusy(true); setAuthError('')
try {
const res = await fetch('/api/admin-auth/login', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ password }),
credentials: 'include',
})
const data = await res.json() as { ok?: boolean; requiresTOTP?: boolean; message?: string }
const res = await fetch('/api/admin-auth/login', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ password }), credentials: 'include' })
const data = await res.json() as { ok?: boolean; totpRequired?: boolean; pendingToken?: string; message?: string }
if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
if (data.requiresTOTP) { setAuthState('needs-totp'); setAuthBusy(false); return }
if (data.totpRequired && data.pendingToken) { setPendingToken(data.pendingToken); setAuthState('needs-totp'); setAuthBusy(false); return }
setAuthState('ok')
} catch { setAuthError('Login failed.') }
setAuthBusy(false)
}
async function handleTotp(e: React.FormEvent) {
e.preventDefault()
setAuthBusy(true)
setAuthError('')
e.preventDefault(); setAuthBusy(true); setAuthError('')
try {
const res = await fetch('/api/admin-auth/totp', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ token: totp }),
credentials: 'include',
})
const res = await fetch('/api/admin-auth/totp-verify', { method: 'POST', headers: { 'Content-Type': 'application/json' }, body: JSON.stringify({ pendingToken, code: totp }), credentials: 'include' })
const data = await res.json() as { ok?: boolean; message?: string }
if (!res.ok) { setAuthError(data.message ?? 'Invalid code.'); setAuthBusy(false); return }
setAuthState('ok')
@@ -88,80 +177,91 @@ export default function ContactsShell() {
setAuthBusy(false)
}
if (authState === 'checking') {
return <div className="em-auth-loading">Loading</div>
}
if (authState === 'checking') return <div className="em-auth-loading">Loading</div>
if (authState === 'needs-password') {
return (
if (authState === 'needs-password') return (
<div className="em-auth-wrap">
<form className="em-auth-form" onSubmit={handleLogin}>
<h1 className="em-auth-title">Contacts</h1>
<label className="em-auth-label">Admin password
<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus />
</label>
<label className="em-auth-label">Admin password<input type="password" className="em-auth-input" value={password} onChange={e => setPassword(e.target.value)} autoFocus /></label>
{authError && <p className="em-auth-error">{authError}</p>}
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
{authBusy ? 'Signing in…' : 'Sign in'}
</button>
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Signing in…' : 'Sign in'}</button>
</form>
</div>
)
}
if (authState === 'needs-totp') {
return (
if (authState === 'needs-totp') return (
<div className="em-auth-wrap">
<form className="em-auth-form" onSubmit={handleTotp}>
<h1 className="em-auth-title">Two-factor code</h1>
<label className="em-auth-label">Authenticator code
<input
type="text"
className="em-auth-input"
inputMode="numeric"
pattern="[0-9]*"
maxLength={6}
value={totp}
onChange={e => setTotp(e.target.value)}
autoFocus
/>
</label>
<label className="em-auth-label">Authenticator code<input type="text" className="em-auth-input" inputMode="numeric" pattern="[0-9]*" maxLength={6} value={totp} onChange={e => setTotp(e.target.value)} autoFocus /></label>
{authError && <p className="em-auth-error">{authError}</p>}
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
{authBusy ? 'Verifying…' : 'Verify'}
</button>
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>{authBusy ? 'Verifying…' : 'Verify'}</button>
</form>
</div>
)
}
return <ContactsClient />
}
// ── Contacts Client ──────────────────────────────────────────────────────────
// ── Contacts Client ──────────────────────────────────────────────────────────
function ContactsClient() {
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
const [replyHistory, setReplyHistory] = useState<ReplyHistoryItem[]>([])
const [loading, setLoading] = useState(true)
const [search, setSearch] = useState('')
const [tagFilter, setTagFilter] = useState('')
const [showArchived, setShowArchived] = useState(false)
const [editingId, setEditingId] = useState<string | null>(null)
// Edit state
const [editingKey, setEditingKey] = useState<string | null>(null)
const [editName, setEditName] = useState('')
const [editNotes, setEditNotes] = useState('')
const [editTags, setEditTags] = useState<string[]>([])
const [editTagInput, setEditTagInput] = useState('')
const [editSaving, setEditSaving] = useState(false)
// Merge state
const [mergePickerKey, setMergePickerKey] = useState<string | null>(null)
const [mergeSearch, setMergeSearch] = useState('')
const [mergeBusy, setMergeBusy] = useState(false)
const [mergeMsg, setMergeMsg] = useState('')
// History state
const [expandedHistoryKey, setExpandedHistoryKey] = useState<string | null>(null)
// Add contact
const [addOpen, setAddOpen] = useState(false)
const [addName, setAddName] = useState('')
const [addEmail, setAddEmail] = useState('')
const [addNotes, setAddNotes] = useState('')
const [addTags, setAddTags] = useState('')
const [addBusy, setAddBusy] = useState(false)
const [addError, setAddError] = useState('')
// CSV import
const [importBusy, setImportBusy] = useState(false)
const [importMsg, setImportMsg] = useState('')
const [importPreview, setImportPreview] = useState<{ rows: Record<string, string>[]; filename: string } | null>(null)
const importFileRef = useRef<HTMLInputElement>(null)
// Flash
const [flashMsg, setFlashMsg] = useState('')
const reload = useCallback(async () => {
try {
const res = await fetch('/api/admin-contact-submissions', { credentials: 'include' })
if (res.ok) {
const data = await res.json() as { submissions: ContactSubmission[] }
setSubmissions(data.submissions ?? [])
const [subRes, histRes] = await Promise.all([
fetch('/api/admin-contact-submissions', { credentials: 'include' }),
fetch('/api/admin-contact-reply-history', { credentials: 'include' }),
])
if (subRes.ok) {
const d = await subRes.json() as { submissions: ContactSubmission[] }
setSubmissions(d.submissions ?? [])
}
if (histRes.ok) {
const d = await histRes.json() as { items: ReplyHistoryItem[] }
setReplyHistory(d.items ?? [])
}
} catch { /* silent */ }
setLoading(false)
@@ -169,7 +269,10 @@ function ContactsClient() {
useEffect(() => { reload() }, [reload])
// ── Derived contacts ──
const contacts: Contact[] = useMemo(() => {
// Group by email (lowercased), falling back to id
const grouped = new Map<string, ContactSubmission[]>()
for (const s of submissions) {
const key = s.email?.trim().toLowerCase() || s.id
@@ -177,98 +280,248 @@ function ContactsClient() {
arr.push(s)
grouped.set(key, arr)
}
// Build last-contacted index from reply history
const lastContactedMap = new Map<string, string>()
for (const item of replyHistory) {
const email = item.toEmail?.trim().toLowerCase()
if (!email) continue
const existing = lastContactedMap.get(email)
if (!existing || item.sentAt > existing) lastContactedMap.set(email, item.sentAt)
}
return Array.from(grouped.values())
.map(entries => {
const sorted = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
const latest = sorted[0]
const oldest = sorted[sorted.length - 1]
const emailKey = latest.email?.trim().toLowerCase() || latest.id
// Tags: prefer entry that has tags, falling back to mainId submission
const withTags = sorted.find(s => s.tags && s.tags.length > 0)
return {
key: latest.email?.trim().toLowerCase() || latest.id,
email: latest.email,
name: latest.name,
key: emailKey,
email: latest.email ?? '',
name: latest.name ?? '',
source: latest.source,
inboundTo: latest.inboundTo,
subscribe: sorted.some(s => s.subscribe),
archived: sorted.every(s => s.archived === true),
submittedAt: latest.submittedAt,
firstContactAt: oldest.submittedAt,
latestAt: latest.submittedAt,
message: latest.message || sorted.find(s => s.message)?.message || '',
notes: latest.notes ?? '',
notes: sorted.find(s => s.notes)?.notes ?? '',
tags: withTags?.tags ?? latest.tags ?? [],
lastContactedAt: lastContactedMap.get(emailKey) ?? null,
submissionCount: sorted.length,
mainId: latest.id,
allIds: sorted.map(s => s.id),
allSubmissions: sorted,
}
})
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
}, [submissions])
.sort((a, b) => new Date(b.latestAt).getTime() - new Date(a.latestAt).getTime())
}, [submissions, replyHistory])
const filtered = contacts.filter(c => {
// ── All tags (for filter dropdown + autocomplete) ──
const allTags = useMemo(() => {
const set = new Set<string>()
for (const c of contacts) for (const t of c.tags) set.add(t)
return [...set].sort()
}, [contacts])
// ── Filtered list ──
const filtered = useMemo(() => {
return contacts.filter(c => {
if (!showArchived && c.archived) return false
if (tagFilter && !c.tags.includes(tagFilter)) return false
if (!search.trim()) return true
const q = search.toLowerCase()
return (
c.name.toLowerCase().includes(q) ||
c.email.toLowerCase().includes(q) ||
c.message.toLowerCase().includes(q) ||
c.notes.toLowerCase().includes(q)
c.notes.toLowerCase().includes(q) ||
c.tags.some(t => t.toLowerCase().includes(q))
)
})
}, [contacts, showArchived, tagFilter, search])
// ── Edit helpers ──
function startEdit(c: Contact) {
setEditingId(c.mainId)
setEditingKey(c.key)
setEditName(c.name)
setEditNotes(c.notes)
setEditTags([...c.tags])
setEditTagInput('')
setMergePickerKey(null)
setMergeMsg('')
}
async function saveEdit() {
if (!editingId) return
function cancelEdit() {
setEditingKey(null)
setMergePickerKey(null)
setMergeMsg('')
}
async function saveEdit(c: Contact) {
setEditSaving(true)
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, {
try {
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(c.mainId)}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: editName, notes: editNotes }),
body: JSON.stringify({ name: editName, notes: editNotes, tags: editTags }),
})
setSubmissions(prev => prev.map(s =>
s.id === c.mainId
? { ...s, name: editName, notes: editNotes, tags: editTags }
: (s.email?.trim().toLowerCase() === c.key ? { ...s, name: editName } : s)
))
setEditingKey(null)
} catch { /* silent */ }
setEditSaving(false)
setEditingId(null)
await reload()
}
function addEditTag(tag: string) {
const t = tag.trim().slice(0, 50)
if (!t || editTags.includes(t)) return
setEditTags(prev => [...prev, t])
setEditTagInput('')
}
function removeEditTag(tag: string) {
setEditTags(prev => prev.filter(t => t !== tag))
}
// ── Delete ──
async function deleteContact(c: Contact) {
const label = c.name || c.email || 'this contact'
const plural = c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'
if (!confirm(`Delete ${plural} from ${label}?`)) return
await Promise.all(
c.allIds.map(id =>
fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, {
method: 'DELETE',
credentials: 'include',
})
fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE', credentials: 'include' })
)
)
await reload()
}
async function handleAdd(e: React.FormEvent) {
e.preventDefault()
setAddBusy(true)
setAddError('')
// ── Merge ──
async function doMerge(keepContact: Contact, mergeContact: Contact) {
if (!confirm(`Merge "${mergeContact.name || mergeContact.email}" into "${keepContact.name || keepContact.email}"? All messages from ${mergeContact.email} will be reassigned to ${keepContact.email}.`)) return
setMergeBusy(true)
try {
const res = await fetch('/api/admin-contacts/merge', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ keepEmail: keepContact.email, mergeEmail: mergeContact.email }),
})
if (res.ok) {
setMergeMsg('')
setMergePickerKey(null)
setEditingKey(null)
flash(`Merged ${mergeContact.email} into ${keepContact.email}.`)
await reload()
} else {
const d = await res.json() as { message?: string }
setMergeMsg(d.message ?? 'Merge failed.')
}
} catch { setMergeMsg('Network error.') }
setMergeBusy(false)
}
// ── Add contact ──
async function handleAdd(e: React.FormEvent) {
e.preventDefault(); setAddBusy(true); setAddError('')
try {
const tags = addTags.split(',').map(t => t.trim()).filter(Boolean)
const res = await fetch('/api/admin-contact-submissions/add', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes }),
body: JSON.stringify({ name: addName, email: addEmail, notes: addNotes, tags }),
})
const data = await res.json() as { ok?: boolean; message?: string }
if (!res.ok) { setAddError(data.message ?? 'Failed to add contact.'); setAddBusy(false); return }
setAddOpen(false)
setAddName('')
setAddEmail('')
setAddNotes('')
const d = await res.json() as { ok?: boolean; message?: string }
if (!res.ok) { setAddError(d.message ?? 'Failed to add.'); setAddBusy(false); return }
setAddOpen(false); setAddName(''); setAddEmail(''); setAddNotes(''); setAddTags('')
await reload()
} catch { setAddError('Network error.') }
setAddBusy(false)
}
// ── CSV import ──
function handleFileChange(e: React.ChangeEvent<HTMLInputElement>) {
const file = e.target.files?.[0]
if (!file) return
const reader = new FileReader()
reader.onload = evt => {
const text = evt.target?.result as string
const rows = parseCSV(text)
setImportPreview({ rows, filename: file.name })
}
reader.readAsText(file)
if (importFileRef.current) importFileRef.current.value = ''
}
async function doImport() {
if (!importPreview) return
setImportBusy(true); setImportMsg('')
try {
const res = await fetch('/api/admin-contacts/import', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
credentials: 'include',
body: JSON.stringify({ rows: importPreview.rows }),
})
const d = await res.json() as { ok?: boolean; created?: number; skipped?: number; message?: string }
if (!res.ok) { setImportMsg(d.message ?? 'Import failed.'); setImportBusy(false); return }
setImportMsg(`Imported ${d.created} contact${d.created !== 1 ? 's' : ''}${d.skipped ? `, skipped ${d.skipped}` : ''}.`)
setImportPreview(null)
await reload()
} catch { setImportMsg('Network error.') }
setImportBusy(false)
}
// ── Flash ──
function flash(msg: string) {
setFlashMsg(msg)
setTimeout(() => setFlashMsg(''), 3000)
}
// ── Conversation history builder ──
function buildHistory(c: Contact): ConversationItem[] {
const inbound: ConversationItem[] = c.allSubmissions.map(s => ({
kind: 'inbound',
date: s.submittedAt,
name: s.name,
message: s.source === 'inbound-email'
? s.message.replace(/^Subject:\s*.+\n+/m, '').trim().slice(0, 400)
: s.message.slice(0, 400),
source: s.source,
id: s.id,
}))
const outbound: ConversationItem[] = replyHistory
.filter(r => r.toEmail?.trim().toLowerCase() === c.key)
.map(r => ({
kind: 'outbound',
date: r.scheduledAt ?? r.sentAt,
subject: r.subject,
preview: r.preview,
toEmail: r.toEmail,
}))
return [...inbound, ...outbound].sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime())
}
// ── Source badge ──
function sourceBadge(source: string | undefined) {
if (source === 'manual') return <span className="ct-badge ct-badge--manual">manual</span>
if (source === 'inbound-email') return <span className="ct-badge ct-badge--email">email</span>
@@ -276,212 +529,260 @@ function ContactsClient() {
return <span className="ct-badge ct-badge--form">form</span>
}
function fmtDate(iso: string) {
try {
return new Date(iso).toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: 'numeric' })
} catch { return '—' }
}
// ── Render ──
return (
<div className="ct-app">
{/* Header */}
<header className="ct-header">
<div className="ct-header-brand">
<svg width="16" height="16" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" aria-hidden="true">
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" />
<circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" />
<path d="M16 3.13a4 4 0 0 1 0 7.75" />
<path d="M17 21v-2a4 4 0 0 0-4-4H5a4 4 0 0 0-4 4v2" /><circle cx="9" cy="7" r="4" />
<path d="M23 21v-2a4 4 0 0 0-3-3.87" /><path d="M16 3.13a4 4 0 0 1 0 7.75" />
</svg>
Contacts
<span className="ct-header-count">{contacts.length}</span>
</div>
<div className="ct-header-actions">
<button
type="button"
className="em-btn em-btn--secondary em-btn--sm"
onClick={() => { setAddOpen(o => !o); setAddError('') }}
>
<button type="button" className="em-btn em-btn--secondary em-btn--sm" onClick={() => { setAddOpen(o => !o); setAddError('') }}>
{addOpen ? 'Cancel' : '+ Add Contact'}
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => exportContactsCSV(filtered)} title="Export visible contacts to CSV">
Export CSV
</button>
<label className="em-btn em-btn--ghost em-btn--sm" style={{ cursor: 'pointer' }}>
Import CSV
<input ref={importFileRef} type="file" accept=".csv,text/csv" style={{ display: 'none' }} onChange={handleFileChange} />
</label>
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm"> Email</Link>
<Link to="/calendar" className="em-btn em-btn--ghost em-btn--sm">Calendar</Link>
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm"> Admin</Link>
</div>
</header>
{/* Import preview */}
{importPreview && (
<div className="ct-import-banner">
<div className="ct-import-info">
<strong>{importPreview.filename}</strong> {importPreview.rows.length} row{importPreview.rows.length !== 1 ? 's' : ''} found
{importPreview.rows.length > 0 && (
<span className="ct-import-cols"> · columns: {Object.keys(importPreview.rows[0]).join(', ')}</span>
)}
</div>
<div className="ct-import-actions">
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={doImport} disabled={importBusy || importPreview.rows.length === 0}>
{importBusy ? 'Importing…' : `Import ${importPreview.rows.length} contacts`}
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setImportPreview(null); setImportMsg('') }}>Cancel</button>
</div>
{importMsg && <p className="ct-import-msg">{importMsg}</p>}
</div>
)}
{importMsg && !importPreview && <div className="ct-flash">{importMsg}</div>}
{flashMsg && <div className="ct-flash">{flashMsg}</div>}
{/* Add contact form */}
{addOpen && (
<div className="ct-add-banner">
<form className="ct-add-form" onSubmit={handleAdd}>
<h3 className="ct-add-title">Add Contact</h3>
<div className="ct-add-row">
<label className="ct-add-label">
Name
<input
className="ct-input"
type="text"
placeholder="Full name"
value={addName}
onChange={e => setAddName(e.target.value)}
autoFocus
/>
</label>
<label className="ct-add-label">
Email
<input
className="ct-input"
type="email"
placeholder="email@example.com"
value={addEmail}
onChange={e => setAddEmail(e.target.value)}
/>
</label>
<label className="ct-add-label">Name<input className="ct-input" type="text" placeholder="Full name" value={addName} onChange={e => setAddName(e.target.value)} autoFocus /></label>
<label className="ct-add-label">Email<input className="ct-input" type="email" placeholder="email@example.com" value={addEmail} onChange={e => setAddEmail(e.target.value)} /></label>
<label className="ct-add-label">Tags (comma-separated)<input className="ct-input" type="text" placeholder="listener, partner" value={addTags} onChange={e => setAddTags(e.target.value)} /></label>
</div>
<label className="ct-add-label ct-add-label--full">
Notes
<textarea
className="ct-input ct-notes-input"
placeholder="Notes (optional)"
value={addNotes}
onChange={e => setAddNotes(e.target.value)}
rows={2}
/>
</label>
<label className="ct-add-label ct-add-label--full">Notes<textarea className="ct-input ct-notes-input" placeholder="Notes (optional)" value={addNotes} onChange={e => setAddNotes(e.target.value)} rows={2} /></label>
{addError && <p className="ct-error">{addError}</p>}
<div className="ct-add-actions">
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>
{addBusy ? 'Adding…' : 'Add Contact'}
</button>
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={addBusy}>{addBusy ? 'Adding…' : 'Add Contact'}</button>
</div>
</form>
</div>
)}
{/* Toolbar */}
<div className="ct-toolbar">
<input
type="search"
className="ct-search"
placeholder="Search name, email, message, or notes…"
value={search}
onChange={e => setSearch(e.target.value)}
/>
<input type="search" className="ct-search" placeholder="Search name, email, notes, tags…" value={search} onChange={e => setSearch(e.target.value)} />
<select className="ct-tag-filter" value={tagFilter} onChange={e => setTagFilter(e.target.value)}>
<option value="">All tags</option>
{allTags.map(t => <option key={t} value={t}>{t}</option>)}
</select>
<label className="ct-archived-toggle">
<input
type="checkbox"
checked={showArchived}
onChange={e => setShowArchived(e.target.checked)}
/>
<input type="checkbox" checked={showArchived} onChange={e => setShowArchived(e.target.checked)} />
Show archived
</label>
<span className="ct-count-label">
{filtered.length} contact{filtered.length !== 1 ? 's' : ''}
</span>
<span className="ct-count-label">{filtered.length} contact{filtered.length !== 1 ? 's' : ''}</span>
</div>
{/* Contact list */}
<div className="ct-list">
{loading && <p className="ct-empty">Loading contacts</p>}
{!loading && filtered.length === 0 && (
<p className="ct-empty">
{search ? 'No contacts match your search.' : 'No contacts yet. Add one above.'}
</p>
<p className="ct-empty">{search || tagFilter ? 'No contacts match.' : 'No contacts yet.'}</p>
)}
{filtered.map(c => (
{filtered.map(c => {
const isEditing = editingKey === c.key
const historyOpen = expandedHistoryKey === c.key
const history = historyOpen ? buildHistory(c) : []
const isMergeTarget = mergePickerKey === c.key
return (
<div key={c.mainId} className={`ct-card${c.archived ? ' ct-card--archived' : ''}`}>
<div className="ct-avatar" aria-hidden="true">
{(c.name || c.email || '?').charAt(0).toUpperCase()}
</div>
<div className="ct-card-body">
{editingId === c.mainId ? (
{isEditing ? (
/* ── Edit mode ── */
<div className="ct-edit-form">
<div className="ct-edit-row">
<label className="ct-edit-label">
Name
<input
className="ct-input"
type="text"
value={editName}
onChange={e => setEditName(e.target.value)}
autoFocus
/>
</label>
<label className="ct-edit-label">Name<input className="ct-input" type="text" value={editName} onChange={e => setEditName(e.target.value)} autoFocus /></label>
</div>
<label className="ct-edit-label">
Notes
<textarea
className="ct-input ct-notes-input"
rows={3}
value={editNotes}
onChange={e => setEditNotes(e.target.value)}
placeholder="Add a note about this contact…"
Tags
<div className="ct-tag-editor">
{editTags.map(t => (
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>
{t}
<button type="button" className="ct-tag-remove" onClick={() => removeEditTag(t)} aria-label={`Remove ${t}`}>×</button>
</span>
))}
<input
className="ct-tag-input"
type="text"
placeholder="Add tag…"
value={editTagInput}
list="ct-tag-suggestions"
onChange={e => setEditTagInput(e.target.value)}
onKeyDown={e => {
if (e.key === 'Enter' || e.key === ',') { e.preventDefault(); addEditTag(editTagInput) }
else if (e.key === 'Backspace' && !editTagInput && editTags.length) removeEditTag(editTags[editTags.length - 1])
}}
/>
<datalist id="ct-tag-suggestions">
{allTags.filter(t => !editTags.includes(t)).map(t => <option key={t} value={t} />)}
</datalist>
</div>
</label>
<label className="ct-edit-label">Notes<textarea className="ct-input ct-notes-input" rows={3} value={editNotes} onChange={e => setEditNotes(e.target.value)} placeholder="Add a note…" /></label>
{/* Merge picker */}
{isMergeTarget && (
<div className="ct-merge-picker">
<p className="ct-merge-label">Merge another contact into <strong>{c.name || c.email}</strong>:</p>
<input className="ct-input ct-merge-search" type="search" placeholder="Search contacts to merge…" value={mergeSearch} autoFocus onChange={e => setMergeSearch(e.target.value)} />
<div className="ct-merge-list">
{contacts
.filter(other =>
other.key !== c.key &&
(!mergeSearch || other.name.toLowerCase().includes(mergeSearch.toLowerCase()) || other.email.toLowerCase().includes(mergeSearch.toLowerCase()))
)
.slice(0, 20)
.map(other => (
<button key={other.key} type="button" className="ct-merge-option" onClick={() => doMerge(c, other)} disabled={mergeBusy}>
<span className="ct-merge-name">{other.name || <em>No name</em>}</span>
<span className="ct-merge-email">{other.email}</span>
<span className="ct-merge-count">{other.submissionCount} msg{other.submissionCount !== 1 ? 's' : ''}</span>
</button>
))
}
</div>
{mergeMsg && <p className="ct-error">{mergeMsg}</p>}
</div>
)}
<div className="ct-edit-actions">
<button
type="button"
className="em-btn em-btn--primary em-btn--sm"
onClick={saveEdit}
disabled={editSaving}
>
{editSaving ? 'Saving…' : 'Save'}
</button>
<button
type="button"
className="em-btn em-btn--ghost em-btn--sm"
onClick={() => setEditingId(null)}
>
Cancel
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={() => saveEdit(c)} disabled={editSaving}>{editSaving ? 'Saving…' : 'Save'}</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setMergePickerKey(prev => prev === c.key ? null : c.key); setMergeSearch(''); setMergeMsg('') }}>
{isMergeTarget ? 'Cancel merge' : 'Merge with…'}
</button>
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={cancelEdit}>Cancel</button>
</div>
</div>
) : (
/* ── Display mode ── */
<>
<div className="ct-card-top">
<span className="ct-card-name">{c.name || <em>No name</em>}</span>
{c.email && (
<a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>
)}
{c.email && <a className="ct-card-email" href={`mailto:${c.email}`}>{c.email}</a>}
{sourceBadge(c.source)}
{c.subscribe && <span className="ct-badge ct-badge--sub">subscriber</span>}
{c.submissionCount > 1 && (
<span className="ct-count-badge">{c.submissionCount}</span>
)}
{c.submissionCount > 1 && <span className="ct-count-badge">{c.submissionCount}</span>}
</div>
<p className={`ct-card-notes${!c.notes ? ' ct-card-notes--empty' : ''}`}>
{c.notes || 'No notes — click Edit to add'}
</p>
<div className="ct-card-bottom">
<span className="ct-card-date">{fmtDate(c.submittedAt)}</span>
{c.message && (
<span className="ct-card-preview">
{c.message.length > 90 ? c.message.slice(0, 90) + '…' : c.message}
{/* Tags row */}
{c.tags.length > 0 && (
<div className="ct-tags-row">
{c.tags.map(t => (
<span key={t} className="ct-tag" style={{ background: tagBg(t) }}>{t}</span>
))}
</div>
)}
<div className="ct-card-meta-row">
<span className="ct-card-date">
First: {fmtDate(c.firstContactAt)}
{c.lastContactedAt && <> · <span className="ct-last-contacted">Last replied: {fmtShort(c.lastContactedAt)}</span></>}
</span>
)}
</div>
{c.notes && <p className="ct-card-notes">{c.notes}</p>}
{c.message && (
<p className="ct-card-preview">
{c.message.length > 100 ? c.message.slice(0, 100) + '…' : c.message}
</p>
)}
</>
)}
</div>
{editingId !== c.mainId && (
{/* Actions */}
{!isEditing && (
<div className="ct-card-actions">
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => startEdit(c)}>Edit</button>
<button
type="button"
className="em-btn em-btn--ghost em-btn--sm"
onClick={() => startEdit(c)}
className={`em-btn em-btn--sm ${historyOpen ? 'em-btn--secondary' : 'em-btn--ghost'}`}
onClick={() => setExpandedHistoryKey(prev => prev === c.key ? null : c.key)}
>
Edit
</button>
<button
type="button"
className="em-btn em-btn--danger em-btn--sm"
onClick={() => deleteContact(c)}
>
Delete
History{c.submissionCount > 1 || replyHistory.some(r => r.toEmail?.trim().toLowerCase() === c.key) ? ` (${c.submissionCount})` : ''}
</button>
<button type="button" className="em-btn em-btn--danger em-btn--sm" onClick={() => deleteContact(c)}>Delete</button>
</div>
)}
{/* Conversation history panel */}
{historyOpen && !isEditing && (
<div className="ct-history-panel">
{history.length === 0 && <p className="ct-history-empty">No conversation history.</p>}
{history.map((item, i) => (
item.kind === 'inbound' ? (
<div key={item.id || i} className="ct-history-item ct-history-item--in">
<div className="ct-history-meta">
<span className="ct-history-who">{item.name}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<p className="ct-history-body">{item.message || '(no message body)'}</p>
</div>
) : (
<div key={i} className="ct-history-item ct-history-item--out">
<div className="ct-history-meta">
<span className="ct-history-who">You {item.toEmail}</span>
<span className="ct-history-date">{fmtShort(item.date)}</span>
</div>
<div className="ct-history-subject">{item.subject}</div>
<p className="ct-history-body">{item.preview}</p>
</div>
)
))}
</div>
)}
</div>
))}
)
})}
</div>
</div>
)