Add /contacts and /calendar pages; email signature settings; v1.1.12
- /contacts: standalone page with hybrid contact list (submissions + manual entry), inline edit, search, archive - /calendar: monthly release scheduling calendar reading/writing podcast checklist episode dates - /email settings: editable signature panel; signature persisted server-side and injected into outgoing emails - Move contacts out of /admin panel (now links to /contacts route) - Partial PATCH for contact submissions (name, notes, archived independently) Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
+2
-132
@@ -688,7 +688,7 @@ type AdminView =
|
||||
| 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist'
|
||||
| 'downloads' | 'custom-links' | 'content-blocks'
|
||||
| 'questions' | 'study-comments' | 'analytics' | 'assets' | 'colossians-study' | 'qr-codes'
|
||||
| 'subscribers' | 'contacts' | 'study-users' | 'email-templates'
|
||||
| 'subscribers' | 'study-users' | 'email-templates'
|
||||
| 'seo' | 'legal' | 'security' | 'brand' | 'global'
|
||||
|
||||
interface AdminSectionLink {
|
||||
@@ -725,7 +725,6 @@ const ADMIN_VIEW_OPTIONS: Array<{ group: string; options: Array<{ value: AdminVi
|
||||
options: [
|
||||
{ value: 'questions', label: 'Questions' },
|
||||
{ value: 'study-comments', label: 'Study Comments' },
|
||||
{ value: 'contacts', label: 'Contacts' },
|
||||
{ value: 'subscribers', label: 'Subscribers' },
|
||||
{ value: 'study-users', label: 'Study Users' },
|
||||
],
|
||||
@@ -1123,7 +1122,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
const [navSearch, setNavSearch] = useState('')
|
||||
const [subscribers, setSubscribers] = useState<Subscriber[]>([])
|
||||
const [subscriberSearch, setSubscriberSearch] = useState('')
|
||||
const [contactSearch, setContactSearch] = useState('')
|
||||
const [downloadStats, setDownloadStats] = useState<Record<string, number>>({})
|
||||
// Study comments moderation state
|
||||
interface AdminComment { id: string; studySlug: string; sectionId: string; displayName: string; text: string; createdAt: string; isApproved: boolean; approvedAt: string | null }
|
||||
@@ -1420,12 +1418,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
setStatsStatus('ready')
|
||||
}
|
||||
|
||||
async function reloadContactSubmissions() {
|
||||
const r = await fetch('/api/admin-contact-submissions')
|
||||
if (!r.ok) throw new Error('Could not refresh contact submissions')
|
||||
const data = await r.json() as { submissions?: ContactSubmission[] }
|
||||
setContactSubmissions(Array.isArray(data.submissions) ? data.submissions : [])
|
||||
}
|
||||
|
||||
useEffect(() => {
|
||||
setQuestionPage(0)
|
||||
@@ -2209,9 +2201,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
if (view === 'questions' && unansweredCount > 0) {
|
||||
return { count: unansweredCount, neutral: false }
|
||||
}
|
||||
if (view === 'contacts' && contactSubmissions.length > 0) {
|
||||
return { count: contactSubmissions.length, neutral: true }
|
||||
}
|
||||
if (view === 'subscribers' && subscribers.length > 0) {
|
||||
return { count: subscribers.length, neutral: true }
|
||||
}
|
||||
@@ -3050,7 +3039,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
{c.message && <div className="admin-dashboard-feed-preview">{c.message.slice(0, 100)}{c.message.length > 100 ? '…' : ''}</div>}
|
||||
</div>
|
||||
))}
|
||||
<button type="button" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem' }} onClick={() => navigateTo('contacts')}>View All Contacts →</button>
|
||||
<Link to="/contacts" className="admin-dashboard-card-action" style={{ marginTop: '0.5rem', display: 'inline-block' }}>View All Contacts →</Link>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -3107,125 +3096,6 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* CONTACTS */}
|
||||
{adminView === 'contacts' && (() => {
|
||||
const searchTerm = contactSearch.trim().toLowerCase()
|
||||
const grouped = new Map<string, ContactSubmission[]>()
|
||||
|
||||
for (const submission of contactSubmissions) {
|
||||
const emailKey = submission.email.trim().toLowerCase()
|
||||
const nameKey = submission.name.trim().toLowerCase()
|
||||
const key = emailKey || nameKey || submission.id
|
||||
const entries = grouped.get(key)
|
||||
if (entries) entries.push(submission)
|
||||
else grouped.set(key, [submission])
|
||||
}
|
||||
|
||||
const rolledUp = Array.from(grouped.values())
|
||||
.map(entries => {
|
||||
const sortedEntries = [...entries].sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
const latest = sortedEntries[0]
|
||||
return {
|
||||
...latest,
|
||||
archived: sortedEntries.every(entry => entry.archived === true),
|
||||
subscribe: sortedEntries.some(entry => entry.subscribe),
|
||||
message: latest.message || sortedEntries.find(entry => entry.message)?.message || '',
|
||||
submissionCount: sortedEntries.length,
|
||||
allIds: sortedEntries.map(e => e.id),
|
||||
}
|
||||
})
|
||||
.filter(contact => {
|
||||
if (!searchTerm) return true
|
||||
return contact.name.toLowerCase().includes(searchTerm)
|
||||
|| contact.email.toLowerCase().includes(searchTerm)
|
||||
|| contact.message.toLowerCase().includes(searchTerm)
|
||||
})
|
||||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
|
||||
return (
|
||||
<section className="admin-panel-section">
|
||||
<div className="admin-panel-head">
|
||||
<h2>Contacts</h2>
|
||||
<p>{rolledUp.length} contacts from {contactSubmissions.length} total submissions — repeat senders are grouped together.</p>
|
||||
</div>
|
||||
<div className="admin-toolbar" style={{ marginBottom: '1rem', display: 'flex', gap: '0.75rem', alignItems: 'center', flexWrap: 'wrap' }}>
|
||||
<input
|
||||
type="search"
|
||||
placeholder="Search name, email, or message…"
|
||||
value={contactSearch}
|
||||
onChange={e => setContactSearch(e.target.value)}
|
||||
style={{ minWidth: '260px', maxWidth: '440px', width: '100%' }}
|
||||
/>
|
||||
<span className="admin-stats-note" style={{ margin: 0 }}>{rolledUp.length} result{rolledUp.length !== 1 ? 's' : ''}</span>
|
||||
</div>
|
||||
{rolledUp.length === 0
|
||||
? <p className="admin-stats-note">No contacts{contactSearch ? ' match your search' : ' yet'}.</p>
|
||||
: (
|
||||
<div className="admin-visits-table-wrap">
|
||||
<table className="admin-visits-table">
|
||||
<thead>
|
||||
<tr>
|
||||
<th>Name</th>
|
||||
<th>Email</th>
|
||||
<th>Type</th>
|
||||
<th>Subscriber</th>
|
||||
<th>Date</th>
|
||||
<th>Message</th>
|
||||
<th></th>
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{rolledUp.map(c => (
|
||||
<tr key={c.id} className={c.archived ? 'admin-contacts-row--archived' : ''}>
|
||||
<td>
|
||||
<div>{c.name}</div>
|
||||
{c.submissionCount > 1 && <div className="admin-stats-note" style={{ margin: '0.2rem 0 0' }}>{c.submissionCount} submissions</div>}
|
||||
</td>
|
||||
<td><a href={`mailto:${c.email}`}>{c.email}</a></td>
|
||||
<td>
|
||||
<span className="admin-badge admin-badge--pending" style={{ fontSize: '0.7rem' }}>{c.messageType ?? 'contact'}</span>
|
||||
{c.source === 'inbound-email' && (
|
||||
<span
|
||||
className="admin-badge"
|
||||
style={{
|
||||
fontSize: '0.7rem',
|
||||
marginLeft: '0.3rem',
|
||||
background: c.inboundTo?.includes('nate@') ? '#7c3aed' : '#0369a1',
|
||||
color: '#fff',
|
||||
}}
|
||||
title={c.inboundTo ?? 'Direct email'}
|
||||
>
|
||||
{c.inboundTo?.includes('nate@') ? 'nate@' : 'hello@'}
|
||||
</span>
|
||||
)}
|
||||
</td>
|
||||
<td style={{ textAlign: 'center' }}>{c.subscribe ? '✓' : ''}</td>
|
||||
<td style={{ whiteSpace: 'nowrap' }}>{formatDate(c.submittedAt)}</td>
|
||||
<td style={{ maxWidth: '280px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={c.message}>{c.message ?? '—'}</td>
|
||||
<td>
|
||||
<button
|
||||
type="button"
|
||||
className="btn-admin-remove"
|
||||
style={{ fontSize: '0.75rem', padding: '0.2rem 0.55rem', whiteSpace: 'nowrap' }}
|
||||
onClick={async () => {
|
||||
const label = c.name || c.email || 'this contact'
|
||||
if (!confirm(`Delete ${c.submissionCount > 1 ? `all ${c.submissionCount} submissions` : 'submission'} from ${label}?`)) return
|
||||
await Promise.all(c.allIds.map(id => fetch(`/api/admin-contact-submissions/${encodeURIComponent(id)}`, { method: 'DELETE' })))
|
||||
await reloadContactSubmissions()
|
||||
}}
|
||||
>Remove</button>
|
||||
</td>
|
||||
</tr>
|
||||
))}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
</section>
|
||||
)
|
||||
})()}
|
||||
|
||||
{/* SUBSCRIBERS */}
|
||||
{adminView === 'subscribers' && (() => {
|
||||
const filteredSubs = subscribers.filter(s =>
|
||||
|
||||
+748
@@ -10062,3 +10062,751 @@
|
||||
|
||||
.em-footer-hint { display: none; }
|
||||
}
|
||||
|
||||
/* ── Contacts Page (/contacts) ────────────────────────────────────────────── */
|
||||
|
||||
.ct-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: #0f0f0f;
|
||||
color: #e8e2d5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.ct-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ct-header-brand {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 1rem;
|
||||
font-weight: 600;
|
||||
color: #e8e2d5;
|
||||
}
|
||||
|
||||
.ct-header-count {
|
||||
background: #2e2e2e;
|
||||
color: #9a9080;
|
||||
font-size: 0.72rem;
|
||||
font-weight: 500;
|
||||
padding: 0.15rem 0.5rem;
|
||||
border-radius: 999px;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.ct-header-actions {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ct-add-banner {
|
||||
background: #141414;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 1rem 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ct-add-form { max-width: 720px; }
|
||||
|
||||
.ct-add-title {
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #b0a898;
|
||||
margin: 0 0 0.75rem;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
|
||||
.ct-add-row {
|
||||
display: flex;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.ct-add-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: #9a9080;
|
||||
flex: 1;
|
||||
min-width: 180px;
|
||||
}
|
||||
|
||||
.ct-add-label--full { width: 100%; flex: none; }
|
||||
|
||||
.ct-input {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 6px;
|
||||
color: #e8e2d5;
|
||||
padding: 0.45rem 0.65rem;
|
||||
font-size: 0.88rem;
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
font-family: inherit;
|
||||
}
|
||||
|
||||
.ct-input:focus {
|
||||
outline: none;
|
||||
border-color: #c8860a;
|
||||
box-shadow: 0 0 0 2px rgba(200, 134, 10, 0.18);
|
||||
}
|
||||
|
||||
.ct-notes-input { resize: vertical; }
|
||||
|
||||
.ct-add-actions { margin-top: 0.6rem; }
|
||||
|
||||
.ct-error {
|
||||
color: #f87171;
|
||||
font-size: 0.83rem;
|
||||
margin: 0.4rem 0 0;
|
||||
}
|
||||
|
||||
.ct-toolbar {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.65rem 1.25rem;
|
||||
background: #161616;
|
||||
border-bottom: 1px solid #242424;
|
||||
flex-shrink: 0;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ct-search {
|
||||
flex: 1;
|
||||
min-width: 200px;
|
||||
max-width: 480px;
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #2e2e2e;
|
||||
border-radius: 6px;
|
||||
color: #e8e2d5;
|
||||
padding: 0.42rem 0.75rem;
|
||||
font-size: 0.87rem;
|
||||
}
|
||||
|
||||
.ct-search:focus { outline: none; border-color: #c8860a; }
|
||||
|
||||
.ct-archived-toggle {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.4rem;
|
||||
font-size: 0.82rem;
|
||||
color: #7a7060;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.ct-count-label {
|
||||
font-size: 0.82rem;
|
||||
color: #7a7060;
|
||||
margin-left: auto;
|
||||
}
|
||||
|
||||
.ct-list {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem 1.25rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.ct-empty {
|
||||
color: #6a6050;
|
||||
font-size: 0.9rem;
|
||||
text-align: center;
|
||||
margin-top: 2rem;
|
||||
}
|
||||
|
||||
.ct-card {
|
||||
display: flex;
|
||||
gap: 0.9rem;
|
||||
align-items: flex-start;
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 10px;
|
||||
padding: 0.85rem 1rem;
|
||||
transition: border-color 0.15s;
|
||||
}
|
||||
|
||||
.ct-card:hover { border-color: #3a3a3a; }
|
||||
.ct-card--archived { opacity: 0.5; }
|
||||
|
||||
.ct-avatar {
|
||||
width: 38px;
|
||||
height: 38px;
|
||||
border-radius: 50%;
|
||||
background: linear-gradient(135deg, #c8860a, #9a6408);
|
||||
color: #fff;
|
||||
font-size: 1rem;
|
||||
font-weight: 700;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
flex-shrink: 0;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.ct-card-body { flex: 1; min-width: 0; }
|
||||
|
||||
.ct-card-top {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.4rem;
|
||||
}
|
||||
|
||||
.ct-card-name {
|
||||
font-size: 0.92rem;
|
||||
font-weight: 600;
|
||||
color: #e8e2d5;
|
||||
}
|
||||
|
||||
.ct-card-email {
|
||||
font-size: 0.82rem;
|
||||
color: #c8860a;
|
||||
text-decoration: none;
|
||||
}
|
||||
.ct-card-email:hover { text-decoration: underline; }
|
||||
|
||||
.ct-badge {
|
||||
font-size: 0.7rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.ct-badge--form { background: #1e3a2e; color: #4ade80; }
|
||||
.ct-badge--email { background: #1e2a4a; color: #60a5fa; }
|
||||
.ct-badge--download { background: #2a2015; color: #f59e0b; }
|
||||
.ct-badge--manual { background: #2a1a2e; color: #c084fc; }
|
||||
.ct-badge--sub { background: #0f2a1e; color: #34d399; }
|
||||
|
||||
.ct-count-badge {
|
||||
font-size: 0.7rem;
|
||||
background: #2e2e2e;
|
||||
color: #9a9080;
|
||||
padding: 0.12rem 0.45rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.ct-card-notes {
|
||||
font-size: 0.83rem;
|
||||
color: #a09080;
|
||||
margin: 0.25rem 0;
|
||||
line-height: 1.4;
|
||||
}
|
||||
.ct-card-notes--empty { color: #554f45; font-style: italic; }
|
||||
|
||||
.ct-card-bottom {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
margin-top: 0.3rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.ct-card-date {
|
||||
font-size: 0.78rem;
|
||||
color: #6a6050;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.ct-card-preview {
|
||||
font-size: 0.8rem;
|
||||
color: #706050;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.ct-card-actions {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
flex-shrink: 0;
|
||||
align-self: center;
|
||||
}
|
||||
|
||||
.ct-edit-form { display: flex; flex-direction: column; gap: 0.55rem; }
|
||||
.ct-edit-row { display: flex; gap: 0.6rem; }
|
||||
.ct-edit-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: #9a9080;
|
||||
flex: 1;
|
||||
}
|
||||
.ct-edit-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
margin-top: 0.2rem;
|
||||
}
|
||||
|
||||
/* ── Calendar Page (/calendar) ─────────────────────────────────────────────── */
|
||||
|
||||
.cal-app {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100dvh;
|
||||
background: #0f0f0f;
|
||||
color: #e8e2d5;
|
||||
font-family: system-ui, -apple-system, sans-serif;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cal-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.75rem 1.25rem;
|
||||
background: #1a1a1a;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
flex-shrink: 0;
|
||||
gap: 0.75rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cal-header-left {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.cal-header-right {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.cal-month-title {
|
||||
font-size: 1.1rem;
|
||||
font-weight: 600;
|
||||
color: #e8e2d5;
|
||||
margin: 0;
|
||||
min-width: 180px;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.cal-nav-btn {
|
||||
background: #2a2a2a;
|
||||
border: 1px solid #363636;
|
||||
color: #c0b8a8;
|
||||
border-radius: 6px;
|
||||
width: 32px;
|
||||
height: 32px;
|
||||
font-size: 1.1rem;
|
||||
cursor: pointer;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
transition: background 0.12s;
|
||||
}
|
||||
.cal-nav-btn:hover { background: #333; }
|
||||
|
||||
.cal-saving {
|
||||
font-size: 0.8rem;
|
||||
color: #c8860a;
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
|
||||
.cal-scheduling-hint {
|
||||
font-size: 0.82rem;
|
||||
color: #c8860a;
|
||||
}
|
||||
|
||||
.cal-cancel-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c8860a;
|
||||
cursor: pointer;
|
||||
text-decoration: underline;
|
||||
font-size: inherit;
|
||||
padding: 0;
|
||||
}
|
||||
|
||||
.cal-new-ep-banner {
|
||||
background: #141414;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
padding: 0.9rem 1.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-new-ep-form { max-width: 900px; }
|
||||
|
||||
.cal-new-ep-title {
|
||||
font-size: 0.82rem;
|
||||
font-weight: 600;
|
||||
color: #8a8070;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
margin: 0 0 0.6rem;
|
||||
}
|
||||
|
||||
.cal-new-ep-row {
|
||||
display: flex;
|
||||
gap: 0.6rem;
|
||||
flex-wrap: wrap;
|
||||
margin-bottom: 0.6rem;
|
||||
}
|
||||
|
||||
.cal-new-ep-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.77rem;
|
||||
color: #8a8070;
|
||||
flex: 1;
|
||||
min-width: 140px;
|
||||
}
|
||||
|
||||
.cal-new-ep-actions { display: flex; gap: 0.5rem; }
|
||||
|
||||
.cal-body {
|
||||
flex: 1;
|
||||
display: flex;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cal-main {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
padding: 0.75rem;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.cal-loading {
|
||||
color: #6a6050;
|
||||
text-align: center;
|
||||
margin-top: 3rem;
|
||||
}
|
||||
|
||||
.cal-dow-row {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
margin-bottom: 2px;
|
||||
}
|
||||
|
||||
.cal-dow {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #6a6050;
|
||||
text-align: center;
|
||||
padding: 0.35rem 0;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
.cal-grid {
|
||||
display: grid;
|
||||
grid-template-columns: repeat(7, 1fr);
|
||||
gap: 2px;
|
||||
}
|
||||
|
||||
.cal-day {
|
||||
background: #161616;
|
||||
border: 1px solid #232323;
|
||||
border-radius: 6px;
|
||||
min-height: 90px;
|
||||
padding: 0.4rem;
|
||||
cursor: default;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
transition: border-color 0.12s;
|
||||
}
|
||||
|
||||
.cal-day--out { opacity: 0.35; }
|
||||
.cal-day--today { border-color: #c8860a; }
|
||||
.cal-day--selectable { cursor: pointer; }
|
||||
.cal-day--selectable:hover { background: #1c1c12; border-color: #c8860a66; }
|
||||
|
||||
.cal-day-num {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: #7a7060;
|
||||
display: block;
|
||||
margin-bottom: 0.3rem;
|
||||
}
|
||||
.cal-day--today .cal-day-num { color: #c8860a; }
|
||||
|
||||
.cal-day-events {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 2px;
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
.cal-ep-chip {
|
||||
background: linear-gradient(135deg, #1e2e1a, #172015);
|
||||
border: 1px solid #2a3a24;
|
||||
color: #6adb88;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.2rem 0.4rem;
|
||||
border-radius: 4px;
|
||||
cursor: pointer;
|
||||
text-align: left;
|
||||
transition: background 0.12s;
|
||||
white-space: nowrap;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
width: 100%;
|
||||
}
|
||||
.cal-ep-chip:hover { background: #2a3e24; }
|
||||
|
||||
.cal-ep-chip-title { color: #4a8a58; }
|
||||
|
||||
.cal-sidebar {
|
||||
width: 220px;
|
||||
flex-shrink: 0;
|
||||
background: #141414;
|
||||
border-left: 1px solid #222;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cal-sidebar-title {
|
||||
font-size: 0.78rem;
|
||||
font-weight: 600;
|
||||
color: #8a8070;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
padding: 0.75rem 0.9rem 0.5rem;
|
||||
border-bottom: 1px solid #232323;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
|
||||
.cal-sidebar-count {
|
||||
background: #2e2e2e;
|
||||
color: #9a9080;
|
||||
font-size: 0.68rem;
|
||||
padding: 0.1rem 0.4rem;
|
||||
border-radius: 999px;
|
||||
}
|
||||
|
||||
.cal-sidebar-hint {
|
||||
font-size: 0.78rem;
|
||||
color: #c8860a;
|
||||
padding: 0.4rem 0.9rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cal-sidebar-empty {
|
||||
font-size: 0.82rem;
|
||||
color: #6a6050;
|
||||
padding: 0.75rem 0.9rem;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
.cal-sidebar-list {
|
||||
overflow-y: auto;
|
||||
flex: 1;
|
||||
padding: 0.4rem 0.6rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.35rem;
|
||||
}
|
||||
|
||||
.cal-sidebar-ep {
|
||||
background: #1a1a1a;
|
||||
border: 1px solid #2a2a2a;
|
||||
border-radius: 7px;
|
||||
padding: 0.5rem 0.65rem;
|
||||
cursor: pointer;
|
||||
transition: border-color 0.12s, background 0.12s;
|
||||
}
|
||||
.cal-sidebar-ep:hover { border-color: #3a3a3a; }
|
||||
.cal-sidebar-ep--selected { border-color: #c8860a; background: #1e1a12; }
|
||||
|
||||
.cal-sidebar-ep-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-bottom: 0.2rem;
|
||||
}
|
||||
|
||||
.cal-sidebar-ep-num {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
color: #c8860a;
|
||||
}
|
||||
|
||||
.cal-sidebar-ep-edit {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #5a5040;
|
||||
cursor: pointer;
|
||||
font-size: 0.8rem;
|
||||
padding: 0;
|
||||
line-height: 1;
|
||||
}
|
||||
.cal-sidebar-ep-edit:hover { color: #9a9080; }
|
||||
|
||||
.cal-sidebar-ep-title {
|
||||
font-size: 0.8rem;
|
||||
color: #c8b898;
|
||||
line-height: 1.3;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.cal-sidebar-ep-series {
|
||||
font-size: 0.7rem;
|
||||
color: #6a6050;
|
||||
margin-top: 0.1rem;
|
||||
}
|
||||
|
||||
/* Calendar popover */
|
||||
.cal-popover-overlay {
|
||||
position: fixed;
|
||||
inset: 0;
|
||||
background: rgba(0,0,0,0.55);
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
z-index: 100;
|
||||
}
|
||||
|
||||
.cal-popover {
|
||||
background: #1e1e1e;
|
||||
border: 1px solid #333;
|
||||
border-radius: 12px;
|
||||
width: 340px;
|
||||
max-width: 90vw;
|
||||
box-shadow: 0 16px 40px rgba(0,0,0,0.7);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.cal-popover-head {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.85rem 1rem;
|
||||
border-bottom: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
.cal-popover-head h3 {
|
||||
margin: 0;
|
||||
font-size: 0.95rem;
|
||||
color: #e8e2d5;
|
||||
}
|
||||
|
||||
.cal-popover-close {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #6a6050;
|
||||
font-size: 1.2rem;
|
||||
cursor: pointer;
|
||||
line-height: 1;
|
||||
padding: 0;
|
||||
}
|
||||
.cal-popover-close:hover { color: #c0b8a8; }
|
||||
|
||||
.cal-popover-body {
|
||||
padding: 0.9rem 1rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.65rem;
|
||||
}
|
||||
|
||||
.cal-popover-label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.3rem;
|
||||
font-size: 0.78rem;
|
||||
color: #8a8070;
|
||||
}
|
||||
|
||||
.cal-popover-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
padding: 0.75rem 1rem;
|
||||
border-top: 1px solid #2a2a2a;
|
||||
}
|
||||
|
||||
/* Email settings panel additions */
|
||||
.em-settings-section {
|
||||
margin-bottom: 1.25rem;
|
||||
}
|
||||
|
||||
.em-settings-label {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
color: #b0a898;
|
||||
margin-bottom: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
|
||||
.em-settings-note {
|
||||
font-size: 0.8rem;
|
||||
color: #6a6050;
|
||||
margin: 0.2rem 0 0.5rem;
|
||||
line-height: 1.4;
|
||||
}
|
||||
|
||||
.em-settings-sig-textarea {
|
||||
width: 100%;
|
||||
box-sizing: border-box;
|
||||
margin-top: 0.4rem;
|
||||
}
|
||||
|
||||
.em-settings-status {
|
||||
font-size: 0.72rem;
|
||||
font-weight: 600;
|
||||
padding: 0.15rem 0.45rem;
|
||||
border-radius: 4px;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
}
|
||||
.em-settings-status--ok { background: #1e3a2e; color: #4ade80; }
|
||||
.em-settings-status--warn { background: #3a1a1a; color: #f87171; }
|
||||
|
||||
.em-compose-signature-text {
|
||||
white-space: pre-line;
|
||||
}
|
||||
|
||||
.em-sig-edit-link {
|
||||
background: none;
|
||||
border: none;
|
||||
color: #c8860a;
|
||||
cursor: pointer;
|
||||
font-size: 0.75rem;
|
||||
padding: 0;
|
||||
text-decoration: underline;
|
||||
}
|
||||
.em-sig-edit-link:hover { color: #e0a020; }
|
||||
|
||||
@media (max-width: 600px) {
|
||||
.ct-card { flex-direction: column; gap: 0.6rem; }
|
||||
.ct-card-actions { flex-direction: row; }
|
||||
.cal-sidebar { display: none; }
|
||||
.cal-month-title { min-width: 120px; font-size: 0.95rem; }
|
||||
}
|
||||
|
||||
@@ -3,6 +3,8 @@ import type { ReactElement } from 'react'
|
||||
import { Link, NavLink, Routes, Route, useLocation, useNavigate, useParams } from 'react-router-dom'
|
||||
import AdminPage from './AdminPage'
|
||||
import EmailShell from './EmailPage'
|
||||
import ContactsShell from './ContactsPage'
|
||||
import CalendarShell from './CalendarPage'
|
||||
import QASection from './components/QASection'
|
||||
import ContactForm from './components/ContactForm'
|
||||
import { ColossiansStudyIndexPage, ColossiansStudyNotesPage, ColossiansStudySectionPage, StudyLandingPage, StudySignupPage, StudyAccountPage, StudyCommunityPage, StudyQuizPage } from './colossiansStudy'
|
||||
@@ -2496,6 +2498,8 @@ export default function App() {
|
||||
<Route path="/certificate/:token" element={<PublicCertificatePage />} />
|
||||
<Route path="/admin" element={<AdminShell content={content} onSave={setContent} />} />
|
||||
<Route path="/email" element={<EmailShell />} />
|
||||
<Route path="/contacts" element={<ContactsShell />} />
|
||||
<Route path="/calendar" element={<CalendarShell />} />
|
||||
<Route path="/preview" element={<PreviewPage />} />
|
||||
<Route
|
||||
path="/privacy"
|
||||
|
||||
@@ -0,0 +1,526 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface PodcastChecklistTask {
|
||||
id: string
|
||||
label: string
|
||||
required: boolean
|
||||
}
|
||||
|
||||
interface PodcastChecklistEpisode {
|
||||
id: string
|
||||
series: string
|
||||
episodeNumber: number | null
|
||||
title: string
|
||||
datePublished: string
|
||||
expanded: boolean
|
||||
tasks: Record<string, boolean>
|
||||
}
|
||||
|
||||
interface PodcastChecklistData {
|
||||
tasks: PodcastChecklistTask[]
|
||||
episodes: PodcastChecklistEpisode[]
|
||||
}
|
||||
|
||||
// ── Auth Shell ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function CalendarShell() {
|
||||
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totp, setTotp] = useState('')
|
||||
const [authError, setAuthError] = useState('')
|
||||
const [authBusy, setAuthBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin-auth/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then((data: { authenticated?: boolean }) => {
|
||||
setAuthState(data.authenticated ? 'ok' : 'needs-password')
|
||||
})
|
||||
.catch(() => setAuthState('needs-password'))
|
||||
}, [])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
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 }
|
||||
if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
|
||||
if (data.requiresTOTP) { 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('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-auth/totp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 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')
|
||||
} catch { setAuthError('Verification failed.') }
|
||||
setAuthBusy(false)
|
||||
}
|
||||
|
||||
if (authState === 'checking') {
|
||||
return <div className="em-auth-loading">Loading…</div>
|
||||
}
|
||||
|
||||
if (authState === 'needs-password') {
|
||||
return (
|
||||
<div className="em-auth-wrap">
|
||||
<form className="em-auth-form" onSubmit={handleLogin}>
|
||||
<h1 className="em-auth-title">Release Calendar</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>
|
||||
{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>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{authError && <p className="em-auth-error">{authError}</p>}
|
||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||
{authBusy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <CalendarClient />
|
||||
}
|
||||
|
||||
// ── Calendar Client ──────────────────────────────────────────────────────────
|
||||
|
||||
function toDateKey(date: Date) {
|
||||
return `${date.getFullYear()}-${String(date.getMonth() + 1).padStart(2, '0')}-${String(date.getDate()).padStart(2, '0')}`
|
||||
}
|
||||
|
||||
const MONTH_NAMES = [
|
||||
'January', 'February', 'March', 'April', 'May', 'June',
|
||||
'July', 'August', 'September', 'October', 'November', 'December',
|
||||
]
|
||||
|
||||
function CalendarClient() {
|
||||
const today = new Date()
|
||||
const [checklist, setChecklist] = useState<PodcastChecklistData | null>(null)
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [saving, setSaving] = useState(false)
|
||||
const [viewYear, setViewYear] = useState(today.getFullYear())
|
||||
const [viewMonth, setViewMonth] = useState(today.getMonth())
|
||||
const [selectedEpisodeId, setSelectedEpisodeId] = useState<string | null>(null)
|
||||
const [editEp, setEditEp] = useState<PodcastChecklistEpisode | null>(null)
|
||||
const [editForm, setEditForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
|
||||
const [newEpOpen, setNewEpOpen] = useState(false)
|
||||
const [newForm, setNewForm] = useState({ series: '', episodeNumber: '', title: '', datePublished: '' })
|
||||
const [newBusy, setNewBusy] = useState(false)
|
||||
|
||||
const load = useCallback(async () => {
|
||||
try {
|
||||
const res = await fetch('/api/admin-podcast-checklist', { credentials: 'include' })
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { checklist: PodcastChecklistData }
|
||||
setChecklist(data.checklist)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { load() }, [load])
|
||||
|
||||
async function save(episodes: PodcastChecklistEpisode[]) {
|
||||
if (!checklist) return
|
||||
setSaving(true)
|
||||
try {
|
||||
const updated = { ...checklist, episodes }
|
||||
const res = await fetch('/api/admin-podcast-checklist', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ checklist: updated }),
|
||||
})
|
||||
if (res.ok) setChecklist(updated)
|
||||
} catch { /* silent */ }
|
||||
setSaving(false)
|
||||
}
|
||||
|
||||
const calDays = useMemo(() => {
|
||||
const first = new Date(viewYear, viewMonth, 1)
|
||||
const last = new Date(viewYear, viewMonth + 1, 0)
|
||||
const days: Array<{ date: Date; inMonth: boolean }> = []
|
||||
const startDow = (first.getDay() + 6) % 7 // Mon=0 Sun=6
|
||||
for (let i = startDow - 1; i >= 0; i--) {
|
||||
days.push({ date: new Date(viewYear, viewMonth, -i), inMonth: false })
|
||||
}
|
||||
for (let d = 1; d <= last.getDate(); d++) {
|
||||
days.push({ date: new Date(viewYear, viewMonth, d), inMonth: true })
|
||||
}
|
||||
const rem = (7 - (days.length % 7)) % 7
|
||||
for (let i = 1; i <= rem; i++) {
|
||||
days.push({ date: new Date(viewYear, viewMonth + 1, i), inMonth: false })
|
||||
}
|
||||
return days
|
||||
}, [viewYear, viewMonth])
|
||||
|
||||
const episodesByDate = useMemo(() => {
|
||||
const map = new Map<string, PodcastChecklistEpisode[]>()
|
||||
for (const ep of (checklist?.episodes ?? [])) {
|
||||
if (!ep.datePublished?.trim()) continue
|
||||
const key = ep.datePublished.trim().slice(0, 10)
|
||||
const arr = map.get(key) ?? []
|
||||
arr.push(ep)
|
||||
map.set(key, arr)
|
||||
}
|
||||
return map
|
||||
}, [checklist])
|
||||
|
||||
const unscheduled = useMemo(
|
||||
() => (checklist?.episodes ?? []).filter(ep => !ep.datePublished?.trim()),
|
||||
[checklist]
|
||||
)
|
||||
|
||||
const todayKey = toDateKey(today)
|
||||
|
||||
function prevMonth() {
|
||||
if (viewMonth === 0) { setViewMonth(11); setViewYear(y => y - 1) }
|
||||
else setViewMonth(m => m - 1)
|
||||
}
|
||||
|
||||
function nextMonth() {
|
||||
if (viewMonth === 11) { setViewMonth(0); setViewYear(y => y + 1) }
|
||||
else setViewMonth(m => m + 1)
|
||||
}
|
||||
|
||||
function handleDayClick(date: Date, inMonth: boolean) {
|
||||
if (!inMonth || !selectedEpisodeId || !checklist) return
|
||||
const dk = toDateKey(date)
|
||||
const updated = checklist.episodes.map(ep =>
|
||||
ep.id === selectedEpisodeId ? { ...ep, datePublished: dk } : ep
|
||||
)
|
||||
save(updated)
|
||||
setSelectedEpisodeId(null)
|
||||
}
|
||||
|
||||
function openEdit(ep: PodcastChecklistEpisode) {
|
||||
setEditEp(ep)
|
||||
setEditForm({
|
||||
series: ep.series,
|
||||
episodeNumber: ep.episodeNumber != null ? String(ep.episodeNumber) : '',
|
||||
title: ep.title,
|
||||
datePublished: ep.datePublished?.trim() ?? '',
|
||||
})
|
||||
}
|
||||
|
||||
function saveEdit() {
|
||||
if (!editEp || !checklist) return
|
||||
const updated = checklist.episodes.map(ep =>
|
||||
ep.id === editEp.id
|
||||
? {
|
||||
...ep,
|
||||
series: editForm.series.trim(),
|
||||
episodeNumber: editForm.episodeNumber.trim() ? Number(editForm.episodeNumber) : null,
|
||||
title: editForm.title.trim(),
|
||||
datePublished: editForm.datePublished.trim(),
|
||||
}
|
||||
: ep
|
||||
)
|
||||
save(updated)
|
||||
setEditEp(null)
|
||||
}
|
||||
|
||||
function unschedule(ep: PodcastChecklistEpisode) {
|
||||
if (!checklist) return
|
||||
const updated = checklist.episodes.map(e =>
|
||||
e.id === ep.id ? { ...e, datePublished: '' } : e
|
||||
)
|
||||
save(updated)
|
||||
setEditEp(null)
|
||||
}
|
||||
|
||||
async function handleNewEpisode(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
if (!checklist) return
|
||||
setNewBusy(true)
|
||||
const id = typeof crypto?.randomUUID === 'function'
|
||||
? crypto.randomUUID()
|
||||
: `ep-${Date.now()}-${Math.random().toString(36).slice(2)}`
|
||||
const ep: PodcastChecklistEpisode = {
|
||||
id,
|
||||
series: newForm.series.trim(),
|
||||
episodeNumber: newForm.episodeNumber.trim() ? Number(newForm.episodeNumber) : null,
|
||||
title: newForm.title.trim(),
|
||||
datePublished: newForm.datePublished.trim(),
|
||||
expanded: false,
|
||||
tasks: {},
|
||||
}
|
||||
const updated = [...checklist.episodes, ep]
|
||||
await save(updated)
|
||||
setNewForm({ series: '', episodeNumber: '', title: '', datePublished: '' })
|
||||
setNewEpOpen(false)
|
||||
setNewBusy(false)
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="cal-app">
|
||||
<header className="cal-header">
|
||||
<div className="cal-header-left">
|
||||
<button type="button" className="cal-nav-btn" onClick={prevMonth} aria-label="Previous month">‹</button>
|
||||
<h1 className="cal-month-title">
|
||||
{MONTH_NAMES[viewMonth]} {viewYear}
|
||||
</h1>
|
||||
<button type="button" className="cal-nav-btn" onClick={nextMonth} aria-label="Next month">›</button>
|
||||
{saving && <span className="cal-saving">Saving…</span>}
|
||||
</div>
|
||||
<div className="cal-header-right">
|
||||
{selectedEpisodeId && (
|
||||
<span className="cal-scheduling-hint">
|
||||
Click a date to schedule ·{' '}
|
||||
<button
|
||||
type="button"
|
||||
className="cal-cancel-link"
|
||||
onClick={() => setSelectedEpisodeId(null)}
|
||||
>
|
||||
cancel
|
||||
</button>
|
||||
</span>
|
||||
)}
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--secondary em-btn--sm"
|
||||
onClick={() => setNewEpOpen(o => !o)}
|
||||
>
|
||||
{newEpOpen ? 'Cancel' : '+ New Episode'}
|
||||
</button>
|
||||
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{newEpOpen && (
|
||||
<div className="cal-new-ep-banner">
|
||||
<form className="cal-new-ep-form" onSubmit={handleNewEpisode}>
|
||||
<h3 className="cal-new-ep-title">New Episode</h3>
|
||||
<div className="cal-new-ep-row">
|
||||
<label className="cal-new-ep-label">
|
||||
Series
|
||||
<input className="ct-input" type="text" placeholder="e.g. Colossians" value={newForm.series} onChange={e => setNewForm(f => ({ ...f, series: e.target.value }))} />
|
||||
</label>
|
||||
<label className="cal-new-ep-label">
|
||||
Episode #
|
||||
<input className="ct-input" type="number" placeholder="42" value={newForm.episodeNumber} onChange={e => setNewForm(f => ({ ...f, episodeNumber: e.target.value }))} />
|
||||
</label>
|
||||
<label className="cal-new-ep-label">
|
||||
Title
|
||||
<input className="ct-input" type="text" placeholder="Episode title" value={newForm.title} onChange={e => setNewForm(f => ({ ...f, title: e.target.value }))} />
|
||||
</label>
|
||||
<label className="cal-new-ep-label">
|
||||
Date
|
||||
<input className="ct-input" type="date" value={newForm.datePublished} onChange={e => setNewForm(f => ({ ...f, datePublished: e.target.value }))} />
|
||||
</label>
|
||||
</div>
|
||||
<div className="cal-new-ep-actions">
|
||||
<button type="submit" className="em-btn em-btn--primary em-btn--sm" disabled={newBusy}>
|
||||
{newBusy ? 'Adding…' : 'Add Episode'}
|
||||
</button>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="cal-body">
|
||||
<div className="cal-main">
|
||||
{loading ? (
|
||||
<p className="cal-loading">Loading calendar…</p>
|
||||
) : (
|
||||
<>
|
||||
<div className="cal-dow-row">
|
||||
{['Mon', 'Tue', 'Wed', 'Thu', 'Fri', 'Sat', 'Sun'].map(d => (
|
||||
<div key={d} className="cal-dow">{d}</div>
|
||||
))}
|
||||
</div>
|
||||
<div className="cal-grid">
|
||||
{calDays.map(({ date, inMonth }, i) => {
|
||||
const dk = toDateKey(date)
|
||||
const eps = episodesByDate.get(dk) ?? []
|
||||
const isToday = dk === todayKey
|
||||
const isSelecting = Boolean(selectedEpisodeId)
|
||||
return (
|
||||
<div
|
||||
key={i}
|
||||
className={[
|
||||
'cal-day',
|
||||
!inMonth ? 'cal-day--out' : '',
|
||||
isToday ? 'cal-day--today' : '',
|
||||
isSelecting && inMonth ? 'cal-day--selectable' : '',
|
||||
].filter(Boolean).join(' ')}
|
||||
onClick={() => handleDayClick(date, inMonth)}
|
||||
>
|
||||
<span className="cal-day-num">{date.getDate()}</span>
|
||||
<div className="cal-day-events">
|
||||
{eps.map(ep => (
|
||||
<button
|
||||
key={ep.id}
|
||||
type="button"
|
||||
className="cal-ep-chip"
|
||||
title={[ep.series, ep.episodeNumber ? `Ep ${ep.episodeNumber}` : null, ep.title].filter(Boolean).join(' · ')}
|
||||
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
||||
>
|
||||
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series?.slice(0, 6) ?? '—'}
|
||||
{ep.title && <span className="cal-ep-chip-title"> {ep.title.slice(0, 18)}{ep.title.length > 18 ? '…' : ''}</span>}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<aside className="cal-sidebar">
|
||||
<h2 className="cal-sidebar-title">
|
||||
Unscheduled
|
||||
{unscheduled.length > 0 && <span className="cal-sidebar-count">{unscheduled.length}</span>}
|
||||
</h2>
|
||||
{selectedEpisodeId && (
|
||||
<p className="cal-sidebar-hint">Click a date on the calendar to schedule.</p>
|
||||
)}
|
||||
{unscheduled.length === 0 ? (
|
||||
<p className="cal-sidebar-empty">All episodes are scheduled.</p>
|
||||
) : (
|
||||
<div className="cal-sidebar-list">
|
||||
{unscheduled.map(ep => (
|
||||
<div
|
||||
key={ep.id}
|
||||
className={`cal-sidebar-ep${selectedEpisodeId === ep.id ? ' cal-sidebar-ep--selected' : ''}`}
|
||||
onClick={() => setSelectedEpisodeId(id => id === ep.id ? null : ep.id)}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
onKeyDown={e => { if (e.key === 'Enter' || e.key === ' ') setSelectedEpisodeId(id => id === ep.id ? null : ep.id) }}
|
||||
>
|
||||
<div className="cal-sidebar-ep-header">
|
||||
<span className="cal-sidebar-ep-num">
|
||||
{ep.episodeNumber ? `Ep ${ep.episodeNumber}` : ep.series}
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="cal-sidebar-ep-edit"
|
||||
title="Edit"
|
||||
onClick={e => { e.stopPropagation(); openEdit(ep) }}
|
||||
>
|
||||
✎
|
||||
</button>
|
||||
</div>
|
||||
<div className="cal-sidebar-ep-title">{ep.title || <em>Untitled</em>}</div>
|
||||
{ep.series && ep.episodeNumber && (
|
||||
<div className="cal-sidebar-ep-series">{ep.series}</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</aside>
|
||||
</div>
|
||||
|
||||
{/* Edit popover */}
|
||||
{editEp && (
|
||||
<div className="cal-popover-overlay" onClick={() => setEditEp(null)}>
|
||||
<div className="cal-popover" onClick={e => e.stopPropagation()}>
|
||||
<div className="cal-popover-head">
|
||||
<h3>Edit Episode</h3>
|
||||
<button type="button" className="cal-popover-close" onClick={() => setEditEp(null)}>×</button>
|
||||
</div>
|
||||
<div className="cal-popover-body">
|
||||
<label className="cal-popover-label">
|
||||
Series
|
||||
<input
|
||||
className="ct-input"
|
||||
type="text"
|
||||
value={editForm.series}
|
||||
onChange={e => setEditForm(f => ({ ...f, series: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="cal-popover-label">
|
||||
Episode #
|
||||
<input
|
||||
className="ct-input"
|
||||
type="number"
|
||||
value={editForm.episodeNumber}
|
||||
onChange={e => setEditForm(f => ({ ...f, episodeNumber: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="cal-popover-label">
|
||||
Title
|
||||
<input
|
||||
className="ct-input"
|
||||
type="text"
|
||||
value={editForm.title}
|
||||
onChange={e => setEditForm(f => ({ ...f, title: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
<label className="cal-popover-label">
|
||||
Date Published
|
||||
<input
|
||||
className="ct-input"
|
||||
type="date"
|
||||
value={editForm.datePublished}
|
||||
onChange={e => setEditForm(f => ({ ...f, datePublished: e.target.value }))}
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
<div className="cal-popover-actions">
|
||||
<button type="button" className="em-btn em-btn--primary em-btn--sm" onClick={saveEdit}>
|
||||
Save
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => unschedule(editEp)}>
|
||||
Unschedule
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setEditEp(null)}>
|
||||
Cancel
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,487 @@
|
||||
import React, { useCallback, useEffect, useMemo, useState } from 'react'
|
||||
import { Link } from 'react-router-dom'
|
||||
|
||||
// ── Types ────────────────────────────────────────────────────────────────────
|
||||
|
||||
interface ContactSubmission {
|
||||
id: string
|
||||
submittedAt: string
|
||||
name: string
|
||||
email: string
|
||||
message: string
|
||||
messageType: string
|
||||
subscribe: boolean
|
||||
archived?: boolean
|
||||
source?: string
|
||||
inboundTo?: string
|
||||
notes?: string
|
||||
}
|
||||
|
||||
interface Contact {
|
||||
key: string
|
||||
email: string
|
||||
name: string
|
||||
source: string | undefined
|
||||
inboundTo: string | undefined
|
||||
subscribe: boolean
|
||||
archived: boolean
|
||||
submittedAt: string
|
||||
message: string
|
||||
notes: string
|
||||
submissionCount: number
|
||||
mainId: string
|
||||
allIds: string[]
|
||||
}
|
||||
|
||||
// ── Auth Shell ───────────────────────────────────────────────────────────────
|
||||
|
||||
export default function ContactsShell() {
|
||||
const [authState, setAuthState] = useState<'checking' | 'needs-password' | 'needs-totp' | 'ok'>('checking')
|
||||
const [password, setPassword] = useState('')
|
||||
const [totp, setTotp] = useState('')
|
||||
const [authError, setAuthError] = useState('')
|
||||
const [authBusy, setAuthBusy] = useState(false)
|
||||
|
||||
useEffect(() => {
|
||||
fetch('/api/admin-auth/status', { credentials: 'include' })
|
||||
.then(r => r.json())
|
||||
.then((data: { authenticated?: boolean }) => {
|
||||
setAuthState(data.authenticated ? 'ok' : 'needs-password')
|
||||
})
|
||||
.catch(() => setAuthState('needs-password'))
|
||||
}, [])
|
||||
|
||||
async function handleLogin(e: React.FormEvent) {
|
||||
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 }
|
||||
if (!res.ok) { setAuthError(data.message ?? 'Invalid password.'); setAuthBusy(false); return }
|
||||
if (data.requiresTOTP) { 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('')
|
||||
try {
|
||||
const res = await fetch('/api/admin-auth/totp', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ token: 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')
|
||||
} catch { setAuthError('Verification failed.') }
|
||||
setAuthBusy(false)
|
||||
}
|
||||
|
||||
if (authState === 'checking') {
|
||||
return <div className="em-auth-loading">Loading…</div>
|
||||
}
|
||||
|
||||
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>
|
||||
{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>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
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>
|
||||
{authError && <p className="em-auth-error">{authError}</p>}
|
||||
<button type="submit" className="em-btn em-btn--primary" disabled={authBusy}>
|
||||
{authBusy ? 'Verifying…' : 'Verify'}
|
||||
</button>
|
||||
</form>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return <ContactsClient />
|
||||
}
|
||||
|
||||
// ── Contacts Client ──────────────────────────────────────────────────────────
|
||||
|
||||
function ContactsClient() {
|
||||
const [submissions, setSubmissions] = useState<ContactSubmission[]>([])
|
||||
const [loading, setLoading] = useState(true)
|
||||
const [search, setSearch] = useState('')
|
||||
const [showArchived, setShowArchived] = useState(false)
|
||||
const [editingId, setEditingId] = useState<string | null>(null)
|
||||
const [editName, setEditName] = useState('')
|
||||
const [editNotes, setEditNotes] = useState('')
|
||||
const [editSaving, setEditSaving] = useState(false)
|
||||
const [addOpen, setAddOpen] = useState(false)
|
||||
const [addName, setAddName] = useState('')
|
||||
const [addEmail, setAddEmail] = useState('')
|
||||
const [addNotes, setAddNotes] = useState('')
|
||||
const [addBusy, setAddBusy] = useState(false)
|
||||
const [addError, setAddError] = 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 ?? [])
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setLoading(false)
|
||||
}, [])
|
||||
|
||||
useEffect(() => { reload() }, [reload])
|
||||
|
||||
const contacts: Contact[] = useMemo(() => {
|
||||
const grouped = new Map<string, ContactSubmission[]>()
|
||||
for (const s of submissions) {
|
||||
const key = s.email?.trim().toLowerCase() || s.id
|
||||
const arr = grouped.get(key) ?? []
|
||||
arr.push(s)
|
||||
grouped.set(key, arr)
|
||||
}
|
||||
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]
|
||||
return {
|
||||
key: latest.email?.trim().toLowerCase() || latest.id,
|
||||
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,
|
||||
message: latest.message || sorted.find(s => s.message)?.message || '',
|
||||
notes: latest.notes ?? '',
|
||||
submissionCount: sorted.length,
|
||||
mainId: latest.id,
|
||||
allIds: sorted.map(s => s.id),
|
||||
}
|
||||
})
|
||||
.sort((a, b) => new Date(b.submittedAt).getTime() - new Date(a.submittedAt).getTime())
|
||||
}, [submissions])
|
||||
|
||||
const filtered = contacts.filter(c => {
|
||||
if (!showArchived && c.archived) 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)
|
||||
)
|
||||
})
|
||||
|
||||
function startEdit(c: Contact) {
|
||||
setEditingId(c.mainId)
|
||||
setEditName(c.name)
|
||||
setEditNotes(c.notes)
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!editingId) return
|
||||
setEditSaving(true)
|
||||
await fetch(`/api/admin-contact-submissions/${encodeURIComponent(editingId)}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
credentials: 'include',
|
||||
body: JSON.stringify({ name: editName, notes: editNotes }),
|
||||
})
|
||||
setEditSaving(false)
|
||||
setEditingId(null)
|
||||
await reload()
|
||||
}
|
||||
|
||||
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',
|
||||
})
|
||||
)
|
||||
)
|
||||
await reload()
|
||||
}
|
||||
|
||||
async function handleAdd(e: React.FormEvent) {
|
||||
e.preventDefault()
|
||||
setAddBusy(true)
|
||||
setAddError('')
|
||||
try {
|
||||
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 }),
|
||||
})
|
||||
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('')
|
||||
await reload()
|
||||
} catch { setAddError('Network error.') }
|
||||
setAddBusy(false)
|
||||
}
|
||||
|
||||
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>
|
||||
if (source === 'download') return <span className="ct-badge ct-badge--download">download</span>
|
||||
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 '—' }
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="ct-app">
|
||||
<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" />
|
||||
</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('') }}
|
||||
>
|
||||
{addOpen ? 'Cancel' : '+ Add Contact'}
|
||||
</button>
|
||||
<Link to="/email" className="em-btn em-btn--ghost em-btn--sm">Email</Link>
|
||||
<Link to="/admin" className="em-btn em-btn--ghost em-btn--sm">← Admin</Link>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{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>
|
||||
</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>
|
||||
{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>
|
||||
</div>
|
||||
</form>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<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)}
|
||||
/>
|
||||
<label className="ct-archived-toggle">
|
||||
<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>
|
||||
</div>
|
||||
|
||||
<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>
|
||||
)}
|
||||
|
||||
{filtered.map(c => (
|
||||
<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 ? (
|
||||
<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>
|
||||
</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…"
|
||||
/>
|
||||
</label>
|
||||
<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>
|
||||
</div>
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<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>
|
||||
)}
|
||||
{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>
|
||||
)}
|
||||
</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}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{editingId !== c.mainId && (
|
||||
<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--danger em-btn--sm"
|
||||
onClick={() => deleteContact(c)}
|
||||
>
|
||||
Delete
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
+84
-3
@@ -60,6 +60,10 @@ interface ReplyConfig {
|
||||
note: string
|
||||
}
|
||||
|
||||
interface EmailSettings {
|
||||
signature: string
|
||||
}
|
||||
|
||||
// ── Helpers ──────────────────────────────────────────────────────────────────
|
||||
|
||||
function extractSubject(message: string): string {
|
||||
@@ -120,6 +124,11 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
const [templateStatus, setTemplateStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
|
||||
const [showTemplatesMgr, setShowTemplatesMgr] = useState(false)
|
||||
const [showHistory, setShowHistory] = useState(false)
|
||||
const [showSettings, setShowSettings] = useState(false)
|
||||
const [emailSettings, setEmailSettings] = useState<EmailSettings>({ signature: 'Grace and peace,\nVerse by Verse with Nate' })
|
||||
const [settingsSig, setSettingsSig] = useState('')
|
||||
const [settingsSaving, setSettingsSaving] = useState(false)
|
||||
const [settingsSaved, setSettingsSaved] = useState(false)
|
||||
const [actionMsg, setActionMsg] = useState('')
|
||||
const composeRef = useRef<HTMLTextAreaElement>(null)
|
||||
const listRef = useRef<HTMLElement>(null)
|
||||
@@ -128,11 +137,12 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
|
||||
const loadAll = useCallback(async () => {
|
||||
try {
|
||||
const [subRes, tplRes, histRes, cfgRes] = await Promise.all([
|
||||
const [subRes, tplRes, histRes, cfgRes, settingsRes] = await Promise.all([
|
||||
fetch('/api/admin-contact-submissions'),
|
||||
fetch('/api/admin-contact-reply-templates'),
|
||||
fetch('/api/admin-contact-reply-history'),
|
||||
fetch('/api/admin-reply-config'),
|
||||
fetch('/api/admin-email-settings'),
|
||||
])
|
||||
if (!subRes.ok) throw new Error('submissions failed')
|
||||
const subData = await subRes.json() as { submissions?: ContactSubmission[] }
|
||||
@@ -147,6 +157,10 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
setHistory(d.items ?? [])
|
||||
}
|
||||
if (cfgRes.ok) setConfig(await cfgRes.json() as ReplyConfig)
|
||||
if (settingsRes.ok) {
|
||||
const s = await settingsRes.json() as EmailSettings
|
||||
setEmailSettings(s)
|
||||
}
|
||||
} catch {
|
||||
setLoadStatus('error')
|
||||
}
|
||||
@@ -543,8 +557,8 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
onChange={e => setReplyDraft({ ...replyDraft, message: e.target.value })}
|
||||
/>
|
||||
<div className="em-compose-signature">
|
||||
Grace and peace, · Verse by Verse with Nate
|
||||
<span className="em-compose-signature-note">auto-appended</span>
|
||||
<span className="em-compose-signature-text">{emailSettings.signature}</span>
|
||||
<span className="em-compose-signature-note">auto-appended · <button type="button" className="em-sig-edit-link" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>edit signature</button></span>
|
||||
</div>
|
||||
<div className="em-compose-actions">
|
||||
<button type="button" className="em-btn em-btn--primary" onClick={handleSend} disabled={replySending}>
|
||||
@@ -788,6 +802,70 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Settings drawer */}
|
||||
{showSettings && (
|
||||
<div className="em-drawer-overlay" onClick={e => { if (e.target === e.currentTarget) setShowSettings(false) }}>
|
||||
<div className="em-drawer">
|
||||
<div className="em-drawer-header">
|
||||
<h3>Email Settings</h3>
|
||||
<button type="button" className="em-compose-close" onClick={() => setShowSettings(false)}>×</button>
|
||||
</div>
|
||||
<div className="em-drawer-body">
|
||||
<div className="em-settings-section">
|
||||
<label className="em-settings-label">
|
||||
Outgoing Signature
|
||||
<p className="em-settings-note">Appended to every reply and compose email you send.</p>
|
||||
<textarea
|
||||
className="em-compose-body em-settings-sig-textarea"
|
||||
rows={4}
|
||||
value={settingsSig}
|
||||
onChange={e => { setSettingsSig(e.target.value); setSettingsSaved(false) }}
|
||||
placeholder="Grace and peace, Verse by Verse with Nate"
|
||||
/>
|
||||
</label>
|
||||
</div>
|
||||
{config && (
|
||||
<div className="em-settings-section">
|
||||
<p className="em-settings-label">
|
||||
Email Configuration
|
||||
<span className={`em-settings-status${config.canSendReplies ? ' em-settings-status--ok' : ' em-settings-status--warn'}`}>
|
||||
{config.canSendReplies ? 'Configured' : 'Not configured'}
|
||||
</span>
|
||||
</p>
|
||||
<p className="em-settings-note">{config.note}</p>
|
||||
{config.fromEmail && <p className="em-settings-note">Reply-to: {config.fromEmail}</p>}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
<div className="em-drawer-actions">
|
||||
<button
|
||||
type="button"
|
||||
className="em-btn em-btn--primary"
|
||||
disabled={settingsSaving}
|
||||
onClick={async () => {
|
||||
setSettingsSaving(true)
|
||||
try {
|
||||
const res = await fetch('/api/admin-email-settings', {
|
||||
method: 'PUT',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ signature: settingsSig }),
|
||||
})
|
||||
if (res.ok) {
|
||||
const data = await res.json() as { settings?: EmailSettings }
|
||||
if (data.settings) setEmailSettings(data.settings)
|
||||
setSettingsSaved(true)
|
||||
}
|
||||
} catch { /* silent */ }
|
||||
setSettingsSaving(false)
|
||||
}}
|
||||
>
|
||||
{settingsSaving ? 'Saving…' : settingsSaved ? 'Saved ✓' : 'Save Settings'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Footer toolbar */}
|
||||
<div className="em-footer-toolbar">
|
||||
{config && !config.canSendReplies && (
|
||||
@@ -799,6 +877,9 @@ function EmailClient({ onLogout }: { onLogout: () => void }) {
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => setShowHistory(true)}>
|
||||
Sent History
|
||||
</button>
|
||||
<button type="button" className="em-btn em-btn--ghost em-btn--sm" onClick={() => { setSettingsSig(emailSettings.signature); setShowSettings(true) }}>
|
||||
Settings
|
||||
</button>
|
||||
<span className="em-footer-hint">↑ ↓ navigate · r reply · e archive · Esc close</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
Reference in New Issue
Block a user