admin redesign: topbar state zone, podcast hub tabs, AnalyticsPanel CSS classes, useAutosave

This commit is contained in:
nmemmert
2026-06-01 11:45:52 -04:00
parent a58d75c425
commit b9ed8761db
5 changed files with 277 additions and 54 deletions
+41 -17
View File
@@ -7,6 +7,7 @@ import { Link } from 'react-router-dom'
import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content' import type { SiteContent, CustomLink, CustomBlock, ArchivedSeries, ArchivedSeriesResourceLink, ArchivedSeriesNote, ColossiansStudySection, StudyProgram, RedirectRule, PodcastFeaturedLink, SeoSettings, LegalSettings } from './content'
import { DEFAULTS } from './content' import { DEFAULTS } from './content'
import { AnalyticsPanel } from './components/AnalyticsPanel' import { AnalyticsPanel } from './components/AnalyticsPanel'
import { useAutosave } from './hooks/useAutosave'
interface SortableLessonSectionProps { interface SortableLessonSectionProps {
section: ColossiansStudySection section: ColossiansStudySection
@@ -295,12 +296,14 @@ type StringField = Exclude<keyof SiteContent, 'customLinks' | 'customBlocks' | '
type AdminView = type AdminView =
| 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact' | 'dashboard' | 'homepage' | 'start-here' | 'about' | 'contact'
| 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series' | 'podcast' | 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
| 'downloads' | 'custom-links' | 'content-blocks' | 'downloads' | 'custom-links' | 'content-blocks'
| 'questions' | 'analytics' | 'assets' | 'colossians-study' | 'questions' | 'analytics' | 'assets' | 'colossians-study'
| 'emails' | 'subscribers' | 'contacts' | 'emails' | 'subscribers' | 'contacts'
| 'seo' | 'legal' | 'security' | 'brand' | 'global' | 'seo' | 'legal' | 'security' | 'brand' | 'global'
type PodcastTab = 'current-series' | 'episode-highlights' | 'podcast-checklist' | 'archived-series'
type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global' type MainContentSection = 'hero' | 'start-here' | 'about' | 'contact' | 'series' | 'share' | 'prism' | 'global'
const BRAND_KIT_SWATCHES = [ const BRAND_KIT_SWATCHES = [
@@ -499,6 +502,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle') const [status, setStatus] = useState<'idle' | 'saving' | 'saved' | 'error'>('idle')
const [errorMsg, setErrorMsg] = useState('') const [errorMsg, setErrorMsg] = useState('')
const [adminView, setAdminView] = useState<AdminView>('dashboard') const [adminView, setAdminView] = useState<AdminView>('dashboard')
const [podcastTab, setPodcastTab] = useState<PodcastTab>('current-series')
const [stats, setStats] = useState<AdminStats | null>(null) const [stats, setStats] = useState<AdminStats | null>(null)
const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading')
const [maintenanceMsg, setMaintenanceMsg] = useState('') const [maintenanceMsg, setMaintenanceMsg] = useState('')
@@ -565,6 +569,9 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length const unreadEmailCount = contactSubmissions.filter(s => !s.archived).length
const unansweredCount = questions.filter(q => !q.answer?.trim()).length const unansweredCount = questions.filter(q => !q.answer?.trim()).length
// Silently saves draft every 30s when there are unsaved changes
useAutosave(form, isDirty, handleSaveDraft)
// Broadcast live form state + active view into the preview iframe whenever they change // Broadcast live form state + active view into the preview iframe whenever they change
useEffect(() => { useEffect(() => {
if (!previewOpen) return if (!previewOpen) return
@@ -2193,12 +2200,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-shell"> <div className="admin-shell">
{/* ── Top bar ── */} {/* ── Top bar ── */}
<header className="admin-topbar"> <header className="admin-topbar">
<div className="admin-topbar-brand"> <div className="admin-topbar-left">
<span className="admin-topbar-ornament"></span>
<span className="admin-topbar-title">Site Admin</span>
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
</div>
<div className="admin-topbar-actions">
<button <button
type="button" type="button"
className={`admin-mobile-menu-btn${mobileNavOpen ? ' admin-mobile-menu-btn--open' : ''}`} className={`admin-mobile-menu-btn${mobileNavOpen ? ' admin-mobile-menu-btn--open' : ''}`}
@@ -2206,11 +2208,20 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
aria-expanded={mobileNavOpen} aria-expanded={mobileNavOpen}
aria-controls="admin-sidebar-nav" aria-controls="admin-sidebar-nav"
> >
{mobileNavOpen ? 'Close Menu' : 'Menu'} {mobileNavOpen ? 'Close' : 'Menu'}
</button> </button>
<div className="admin-topbar-brand">
<span className="admin-topbar-ornament"></span>
<span className="admin-topbar-title">Site Admin</span>
<span className="admin-topbar-sub">Verse by Verse with Nate</span>
</div>
</div>
<div className="admin-topbar-state">
{isDirty && <span className="admin-status admin-status--warn"> Unsaved</span>} {isDirty && <span className="admin-status admin-status--warn"> Unsaved</span>}
<span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span> <span className="admin-topbar-meta">Draft: {formatDate(publishState.draftUpdatedAt)}</span>
<span className="admin-topbar-meta">Published: {formatDate(publishState.publishedAt)}</span> <span className="admin-topbar-meta">Live: {formatDate(publishState.publishedAt)}</span>
</div>
<div className="admin-topbar-actions">
<button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}> <button type="button" className="btn-admin-save" onClick={handleSaveDraft} disabled={status === 'saving' || !isDirty}>
{status === 'saving' ? 'Saving…' : 'Save Draft'} {status === 'saving' ? 'Saving…' : 'Save Draft'}
</button> </button>
@@ -2263,10 +2274,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
<div className="admin-nav-group"> <div className="admin-nav-group">
<span className="admin-nav-label">Podcast</span> <span className="admin-nav-label">Podcast</span>
<button type="button" className={`admin-nav-item${adminView === 'current-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('current-series')}>Current Series</button> <button type="button" className={`admin-nav-item${adminView === 'podcast' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('podcast')}>Podcast Hub</button>
<button type="button" className={`admin-nav-item${adminView === 'episode-highlights' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-nav-item${adminView === 'podcast-checklist' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-nav-item${adminView === 'archived-series' ? ' admin-nav-item--active' : ''}`} onClick={() => navigateTo('archived-series')}>Archived Series</button>
</div> </div>
<div className="admin-nav-group"> <div className="admin-nav-group">
@@ -2756,8 +2764,24 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
</section> </section>
)} )}
{/* PODCAST HUB */}
{adminView === 'podcast' && (
<section className="admin-panel-section">
<div className="admin-panel-head">
<h2>Podcast Hub</h2>
<p>Manage all podcast-related content from one place.</p>
</div>
<div className="admin-tabs admin-tabs--podcast">
<button type="button" className={`admin-tab${podcastTab === 'current-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('current-series')}>Current Series</button>
<button type="button" className={`admin-tab${podcastTab === 'episode-highlights' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('episode-highlights')}>Ep. Highlights</button>
<button type="button" className={`admin-tab${podcastTab === 'podcast-checklist' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('podcast-checklist')}>Production Checklist</button>
<button type="button" className={`admin-tab${podcastTab === 'archived-series' ? ' admin-tab--active' : ''}`} onClick={() => setPodcastTab('archived-series')}>Archived Series</button>
</div>
</section>
)}
{/* CURRENT SERIES */} {/* CURRENT SERIES */}
{adminView === 'current-series' && ( {(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Current Series</h2> <h2>Current Series</h2>
@@ -2782,7 +2806,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)} )}
{/* EPISODE HIGHLIGHTS */} {/* EPISODE HIGHLIGHTS */}
{adminView === 'episode-highlights' && ( {(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === 'episode-highlights')) && (
<section className="admin-panel-section" aria-label="Episode highlights"> <section className="admin-panel-section" aria-label="Episode highlights">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Episode Highlights</h2> <h2>Episode Highlights</h2>
@@ -2843,7 +2867,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)} )}
{/* PODCAST CHECKLIST */} {/* PODCAST CHECKLIST */}
{adminView === 'podcast-checklist' && ( {(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (
<section className="admin-panel-section" aria-label="Podcast production checklist"> <section className="admin-panel-section" aria-label="Podcast production checklist">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Podcast Production Checklist</h2> <h2>Podcast Production Checklist</h2>
@@ -3055,7 +3079,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
)} )}
{/* ARCHIVED SERIES */} {/* ARCHIVED SERIES */}
{adminView === 'archived-series' && ( {(adminView === 'archived-series' || (adminView === 'podcast' && podcastTab === 'archived-series')) && (
<section className="admin-panel-section"> <section className="admin-panel-section">
<div className="admin-panel-head"> <div className="admin-panel-head">
<h2>Archived Series</h2> <h2>Archived Series</h2>
+112
View File
@@ -5852,3 +5852,115 @@
} }
} }
/* ── Admin Redesign: Topbar state zone ── */
.admin-topbar-left {
display: flex;
align-items: center;
gap: 0.75rem;
}
.admin-topbar-state {
display: flex;
align-items: center;
gap: 0.75rem;
flex: 1;
justify-content: center;
flex-wrap: wrap;
}
/* ── Admin Redesign: Analytics CSS classes ── */
.admin-analytics-grid {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(400px, 1fr));
gap: 2rem;
margin-bottom: 2rem;
}
.admin-analytics-card {
background: #1a1a15;
padding: 1.5rem;
border-radius: 0.5rem;
border: 1px solid #2a2518;
}
.admin-analytics-card--wide {
grid-column: 1 / -1;
}
.admin-analytics-card-title {
margin-bottom: 1rem;
}
.admin-analytics-geo {
margin-bottom: 2rem;
}
/* ── Bot reason list ── */
.admin-bot-reason-list {
list-style: none;
padding: 0;
margin: 0;
}
.admin-bot-reason-item {
padding: 0.5rem 0;
display: flex;
justify-content: space-between;
border-bottom: 1px solid #2a2518;
}
.admin-bot-reason-item strong {
color: var(--brand-gold);
}
/* ── Visitor table helpers ── */
.admin-stats-grid--visitor {
margin-bottom: 1.5rem;
}
.admin-visitor-ip {
font-size: 0.8rem;
color: #aaa;
}
.admin-visitor-path {
max-width: 220px;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.admin-visitor-tag {
display: inline-block;
padding: 0.1rem 0.5rem;
border-radius: 999px;
font-size: 0.75rem;
}
.admin-visitor-tag--returning {
background: #1a3a1a;
color: #6dbf6d;
border: 1px solid #2a5a2a;
}
.admin-visitor-tag--new {
background: #1a1a15;
color: #888;
border: 1px solid #333;
}
.admin-visitor-count {
text-align: center;
color: var(--brand-gold);
}
/* ── Podcast hub tabs ── */
.admin-tabs--podcast {
margin-bottom: 2rem;
}
/* ── Chart tabs ── */
.admin-tabs--charts {
margin-bottom: 1.5rem;
}
+51
View File
@@ -0,0 +1,51 @@
import type { ReactNode } from 'react'
interface AdminCollapsibleCardProps {
title: string
subtitle?: string
hint?: string
previewUrl?: string
children: ReactNode
open?: boolean
className?: string
}
/**
* Reusable collapsible card for admin forms.
* Replaces the repeated <details>/<summary> pattern throughout AdminPage.
*/
export function AdminCollapsibleCard({
title,
subtitle,
hint = 'Expand to edit',
previewUrl,
children,
open = false,
className = '',
}: AdminCollapsibleCardProps) {
return (
<details className={`admin-collapsible-card ${className}`.trim()} open={open}>
<summary className="admin-collapsible-summary">
<div>
<strong>{title}</strong>
{subtitle && <p>{subtitle}</p>}
</div>
<div className="admin-collapsible-actions">
{previewUrl && (
<a
href={previewUrl}
target="_blank"
rel="noopener noreferrer"
className="btn-admin-secondary"
onClick={e => e.stopPropagation()}
>
Preview
</a>
)}
<span className="admin-collapsible-hint">{hint}</span>
</div>
</summary>
<div className="admin-collapsible-body">{children}</div>
</details>
)
}
+29 -37
View File
@@ -238,21 +238,21 @@ export function AnalyticsPanel({
</div> </div>
{/* Chart Tabs */} {/* Chart Tabs */}
<div className="admin-actions admin-actions--maintenance" style={{ marginBottom: '1.5rem' }}> <div className="admin-tabs admin-tabs--charts">
<button type="button" className={`btn-admin-reset${chartTab === 'overview' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button> <button type="button" className={`admin-tab${chartTab === 'overview' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('overview')}>Overview</button>
<button type="button" className={`btn-admin-reset${chartTab === 'breakdown' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</button> <button type="button" className={`admin-tab${chartTab === 'breakdown' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('breakdown')}>Bot Breakdown</button>
<button type="button" className={`btn-admin-reset${chartTab === 'geographic' ? ' btn-admin-reset--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button> <button type="button" className={`admin-tab${chartTab === 'geographic' ? ' admin-tab--active' : ''}`} onClick={() => setChartTab('geographic')}>Geographic</button>
</div> </div>
{/* Overview Charts */} {/* Overview Charts */}
{chartTab === 'overview' && ( {chartTab === 'overview' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}> <div className="admin-analytics-grid">
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div className="admin-analytics-card">
<h3 style={{ marginBottom: '1rem' }}>7-Day Trend (Real vs Bot)</h3> <h3 className="admin-analytics-card-title">7-Day Trend (Real vs Bot)</h3>
<Line data={dailyChartData} options={chartOptions} /> <Line data={dailyChartData} options={chartOptions} />
</div> </div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div className="admin-analytics-card">
<h3 style={{ marginBottom: '1rem' }}>Traffic Composition</h3> <h3 className="admin-analytics-card-title">Traffic Composition</h3>
<Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Doughnut data={trafficRatioData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
</div> </div>
</div> </div>
@@ -260,25 +260,25 @@ export function AnalyticsPanel({
{/* Bot Breakdown Charts */} {/* Bot Breakdown Charts */}
{chartTab === 'breakdown' && ( {chartTab === 'breakdown' && (
<div style={{ display: 'grid', gridTemplateColumns: 'repeat(auto-fit, minmax(400px, 1fr))', gap: '2rem', marginBottom: '2rem' }}> <div className="admin-analytics-grid">
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div className="admin-analytics-card">
<h3 style={{ marginBottom: '1rem' }}>Bot Sources</h3> <h3 className="admin-analytics-card-title">Bot Sources</h3>
{botReasons.length === 0 ? ( {botReasons.length === 0 ? (
<p className="admin-stats-note">No bot traffic detected.</p> <p className="admin-stats-note">No bot traffic detected.</p>
) : ( ) : (
<Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} /> <Pie data={botPieData} options={{...chartOptions, plugins: {...chartOptions.plugins}}} />
)} )}
</div> </div>
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518' }}> <div className="admin-analytics-card">
<h3 style={{ marginBottom: '1rem' }}>Bot Detection Details</h3> <h3 className="admin-analytics-card-title">Bot Detection Details</h3>
{botReasons.length === 0 ? ( {botReasons.length === 0 ? (
<p className="admin-stats-note">No bots detected yet.</p> <p className="admin-stats-note">No bots detected yet.</p>
) : ( ) : (
<ul style={{ listStyle: 'none', padding: 0 }}> <ul className="admin-bot-reason-list">
{botReasons.map((reason: { reason: string; count: number }) => ( {botReasons.map((reason: { reason: string; count: number }) => (
<li key={reason.reason} style={{ padding: '0.5rem 0', display: 'flex', justifyContent: 'space-between', borderBottom: '1px solid #2a2518' }}> <li key={reason.reason} className="admin-bot-reason-item">
<span>{reason.reason.replace(/-/g, ' ')}</span> <span>{reason.reason.replace(/-/g, ' ')}</span>
<strong style={{ color: '#c9a84c' }}>{reason.count.toLocaleString()}</strong> <strong>{reason.count.toLocaleString()}</strong>
</li> </li>
))} ))}
</ul> </ul>
@@ -289,9 +289,9 @@ export function AnalyticsPanel({
{/* Geographic Charts */} {/* Geographic Charts */}
{chartTab === 'geographic' && ( {chartTab === 'geographic' && (
<div style={{ marginBottom: '2rem' }}> <div className="admin-analytics-geo">
<div style={{ backgroundColor: '#1a1a15', padding: '1.5rem', borderRadius: '0.5rem', border: '1px solid #2a2518', marginBottom: '2rem' }}> <div className="admin-analytics-card admin-analytics-card--wide">
<h3 style={{ marginBottom: '1rem' }}>Top Pages</h3> <h3 className="admin-analytics-card-title">Top Pages</h3>
<Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} /> <Bar data={topPathsChartData} options={{...chartOptions, indexAxis: 'y' as const}} />
</div> </div>
<GeoMaps <GeoMaps
@@ -302,13 +302,13 @@ export function AnalyticsPanel({
)} )}
{/* Recent Visitors Table */} {/* Recent Visitors Table */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Visitor Details</h2> <h2>Visitor Details</h2>
<p>Real-time tracking of visitor activity, geography, and returning behavior.</p> <p>Real-time tracking of visitor activity, geography, and returning behavior.</p>
</div> </div>
<p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p> <p className="admin-privacy-note">Privacy: visitor analytics only run after cookie consent. IPs below are masked.</p>
<div className="admin-stats-grid" style={{ marginBottom: '1.5rem' }}> <div className="admin-stats-grid admin-stats-grid--visitor">
<article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article> <article><h3>Total Visits</h3><p>{stats.visitors.totalVisits.toLocaleString()}</p></article>
<article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article> <article><h3>Unique Visitors</h3><p>{stats.visitors.uniqueVisitors.toLocaleString()}</p></article>
<article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article> <article><h3>Returning Visits</h3><p>{stats.visitors.returningVisits.toLocaleString()}</p></article>
@@ -355,23 +355,15 @@ export function AnalyticsPanel({
{stats.visitors.recentVisits.map(row => ( {stats.visitors.recentVisits.map(row => (
<tr key={`${row.visitorId}-${row.at}`}> <tr key={`${row.visitorId}-${row.at}`}>
<td>{formatDate(row.at)}</td> <td>{formatDate(row.at)}</td>
<td><code style={{ fontSize: '0.8rem', color: '#aaa' }}>{maskIp(row.ip)}</code></td> <td><code className="admin-visitor-ip">{maskIp(row.ip)}</code></td>
<td>{row.country || '—'}</td> <td>{row.country || '—'}</td>
<td style={{ maxWidth: '220px', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }} title={row.path}>{row.path}</td> <td className="admin-visitor-path" title={row.path}>{row.path}</td>
<td> <td>
<span style={{ <span className={`admin-visitor-tag admin-visitor-tag--${row.returningVisitor ? 'returning' : 'new'}`}>
display: 'inline-block',
padding: '0.1rem 0.5rem',
borderRadius: '999px',
fontSize: '0.75rem',
backgroundColor: row.returningVisitor ? '#1a3a1a' : '#1a1a15',
color: row.returningVisitor ? '#6dbf6d' : '#888',
border: `1px solid ${row.returningVisitor ? '#2a5a2a' : '#333'}`,
}}>
{row.returningVisitor ? 'Returning' : 'New'} {row.returningVisitor ? 'Returning' : 'New'}
</span> </span>
</td> </td>
<td style={{ textAlign: 'center', color: '#c9a84c' }}>{row.visitCount}</td> <td className="admin-visitor-count">{row.visitCount}</td>
</tr> </tr>
))} ))}
</tbody> </tbody>
@@ -381,7 +373,7 @@ export function AnalyticsPanel({
</div> </div>
{/* Contact Summary */} {/* Contact Summary */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Contact Summary</h2> <h2>Contact Summary</h2>
<p>Submission totals from the contact form.</p> <p>Submission totals from the contact form.</p>
</div> </div>
@@ -391,7 +383,7 @@ export function AnalyticsPanel({
</div> </div>
{/* Deployment & Cache Status */} {/* Deployment & Cache Status */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Deployment &amp; Cache Status</h2> <h2>Deployment &amp; Cache Status</h2>
<p>Current deployment metadata and cache purge health for the live app.</p> <p>Current deployment metadata and cache purge health for the live app.</p>
</div> </div>
@@ -403,7 +395,7 @@ export function AnalyticsPanel({
</div> </div>
{/* Data Management */} {/* Data Management */}
<div className="admin-stats-head admin-stats-head--visitors" style={{ marginTop: '2rem' }}> <div className="admin-stats-head admin-stats-head--visitors">
<h2>Data Management</h2> <h2>Data Management</h2>
<p>Export, backup, or retain only recent analytics data.</p> <p>Export, backup, or retain only recent analytics data.</p>
</div> </div>
+44
View File
@@ -0,0 +1,44 @@
import { useEffect, useRef } from 'react'
import type { SiteContent } from '../content'
/**
* Silently saves a draft to /api/admin-content-draft every `interval` ms
* whenever the content has changed since the last save.
*
* Usage:
* useAutosave(form, isDirty, handleSaveDraft)
*
* The `saveFn` should be stable (useCallback) or a ref-wrapped function.
*/
export function useAutosave(
content: SiteContent,
isDirty: boolean,
saveFn: () => Promise<void>,
interval = 30_000,
) {
const saveFnRef = useRef(saveFn)
saveFnRef.current = saveFn
const isDirtyRef = useRef(isDirty)
isDirtyRef.current = isDirty
useEffect(() => {
const id = window.setInterval(() => {
if (!isDirtyRef.current) return
saveFnRef.current().catch(() => {
// Autosave failures are silent; the user still sees the "Unsaved" indicator
})
}, interval)
return () => window.clearInterval(id)
}, [interval])
// Persist to sessionStorage as an immediate local backup
useEffect(() => {
try {
sessionStorage.setItem('admin-autosave-draft', JSON.stringify(content))
} catch {
// sessionStorage quota exceeded — ignore silently
}
}, [content])
}