diff --git a/src/AdminPage.tsx b/src/AdminPage.tsx index 61ba870..ce32971 100644 --- a/src/AdminPage.tsx +++ b/src/AdminPage.tsx @@ -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 { DEFAULTS } from './content' import { AnalyticsPanel } from './components/AnalyticsPanel' +import { useAutosave } from './hooks/useAutosave' interface SortableLessonSectionProps { section: ColossiansStudySection @@ -295,12 +296,14 @@ type StringField = Exclude('idle') const [errorMsg, setErrorMsg] = useState('') const [adminView, setAdminView] = useState('dashboard') + const [podcastTab, setPodcastTab] = useState('current-series') const [stats, setStats] = useState(null) const [statsStatus, setStatsStatus] = useState<'loading' | 'ready' | 'error'>('loading') 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 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 useEffect(() => { if (!previewOpen) return @@ -2193,12 +2200,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
{/* ── Top bar ── */}
-
- - Site Admin - Verse by Verse with Nate -
-
+
+
+ + Site Admin + Verse by Verse with Nate +
+
+
{isDirty && ● Unsaved} Draft: {formatDate(publishState.draftUpdatedAt)} - Published: {formatDate(publishState.publishedAt)} + Live: {formatDate(publishState.publishedAt)} +
+
@@ -2263,10 +2274,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) {
Podcast - - - - +
@@ -2756,8 +2764,24 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} + {/* PODCAST HUB */} + {adminView === 'podcast' && ( +
+
+

Podcast Hub

+

Manage all podcast-related content from one place.

+
+
+ + + + +
+
+ )} + {/* CURRENT SERIES */} - {adminView === 'current-series' && ( + {(adminView === 'current-series' || (adminView === 'podcast' && podcastTab === 'current-series')) && (

Current Series

@@ -2782,7 +2806,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} {/* EPISODE HIGHLIGHTS */} - {adminView === 'episode-highlights' && ( + {(adminView === 'episode-highlights' || (adminView === 'podcast' && podcastTab === 'episode-highlights')) && (

Episode Highlights

@@ -2843,7 +2867,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} {/* PODCAST CHECKLIST */} - {adminView === 'podcast-checklist' && ( + {(adminView === 'podcast-checklist' || (adminView === 'podcast' && podcastTab === 'podcast-checklist')) && (

Podcast Production Checklist

@@ -3055,7 +3079,7 @@ export default function AdminPage({ content, onSave, onLogout }: Props) { )} {/* ARCHIVED SERIES */} - {adminView === 'archived-series' && ( + {(adminView === 'archived-series' || (adminView === 'podcast' && podcastTab === 'archived-series')) && (

Archived Series

diff --git a/src/App.css b/src/App.css index 3e0879b..4aa0a08 100644 --- a/src/App.css +++ b/src/App.css @@ -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; +} + diff --git a/src/components/AdminCollapsibleCard.tsx b/src/components/AdminCollapsibleCard.tsx new file mode 100644 index 0000000..61e9136 --- /dev/null +++ b/src/components/AdminCollapsibleCard.tsx @@ -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
/ pattern throughout AdminPage. + */ +export function AdminCollapsibleCard({ + title, + subtitle, + hint = 'Expand to edit', + previewUrl, + children, + open = false, + className = '', +}: AdminCollapsibleCardProps) { + return ( +
+ +
+ {title} + {subtitle &&

{subtitle}

} +
+
+ {previewUrl && ( + e.stopPropagation()} + > + Preview + + )} + {hint} +
+
+
{children}
+
+ ) +} diff --git a/src/components/AnalyticsPanel.tsx b/src/components/AnalyticsPanel.tsx index 45e1241..ec4a5d4 100644 --- a/src/components/AnalyticsPanel.tsx +++ b/src/components/AnalyticsPanel.tsx @@ -238,21 +238,21 @@ export function AnalyticsPanel({
{/* Chart Tabs */} -
- - - +
+ + +
{/* Overview Charts */} {chartTab === 'overview' && ( -
-
-

7-Day Trend (Real vs Bot)

+
+
+

7-Day Trend (Real vs Bot)

-
-

Traffic Composition

+
+

Traffic Composition

@@ -260,25 +260,25 @@ export function AnalyticsPanel({ {/* Bot Breakdown Charts */} {chartTab === 'breakdown' && ( -
-
-

Bot Sources

+
+
+

Bot Sources

{botReasons.length === 0 ? (

No bot traffic detected.

) : ( )}
-
-

Bot Detection Details

+
+

Bot Detection Details

{botReasons.length === 0 ? (

No bots detected yet.

) : ( -
    +
      {botReasons.map((reason: { reason: string; count: number }) => ( -
    • +
    • {reason.reason.replace(/-/g, ' ')} - {reason.count.toLocaleString()} + {reason.count.toLocaleString()}
    • ))}
    @@ -289,9 +289,9 @@ export function AnalyticsPanel({ {/* Geographic Charts */} {chartTab === 'geographic' && ( -
    -
    -

    Top Pages

    +
    +
    +

    Top Pages

    +

    Visitor Details

    Real-time tracking of visitor activity, geography, and returning behavior.

    Privacy: visitor analytics only run after cookie consent. IPs below are masked.

    -
    +

    Total Visits

    {stats.visitors.totalVisits.toLocaleString()}

    Unique Visitors

    {stats.visitors.uniqueVisitors.toLocaleString()}

    Returning Visits

    {stats.visitors.returningVisits.toLocaleString()}

    @@ -355,23 +355,15 @@ export function AnalyticsPanel({ {stats.visitors.recentVisits.map(row => ( {formatDate(row.at)} - {maskIp(row.ip)} + {maskIp(row.ip)} {row.country || '—'} - {row.path} + {row.path} - + {row.returningVisitor ? 'Returning' : 'New'} - {row.visitCount} + {row.visitCount} ))} @@ -381,7 +373,7 @@ export function AnalyticsPanel({
    {/* Contact Summary */} -
    +

    Contact Summary

    Submission totals from the contact form.

    @@ -391,7 +383,7 @@ export function AnalyticsPanel({
    {/* Deployment & Cache Status */} -
    +

    Deployment & Cache Status

    Current deployment metadata and cache purge health for the live app.

    @@ -403,7 +395,7 @@ export function AnalyticsPanel({
    {/* Data Management */} -
    +

    Data Management

    Export, backup, or retain only recent analytics data.

    diff --git a/src/hooks/useAutosave.ts b/src/hooks/useAutosave.ts new file mode 100644 index 0000000..9bbbe9f --- /dev/null +++ b/src/hooks/useAutosave.ts @@ -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, + 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]) +}