1612 lines
78 KiB
TypeScript
1612 lines
78 KiB
TypeScript
'use client';
|
|
|
|
import React, { useState, useEffect, useCallback } from 'react';
|
|
import {
|
|
OperationsRole,
|
|
CmsDocumentSummary,
|
|
CmsDocumentDetail,
|
|
CmsRevisionItem,
|
|
} from './types';
|
|
|
|
interface OperationsCmsProps {
|
|
role: OperationsRole;
|
|
}
|
|
|
|
const DOCUMENT_SECTIONS = [
|
|
{ slug: 'homepage_hero', label: 'Homepage Hero', icon: 'splitscreen', desc: 'Hero headline, copy, and primary CTA' },
|
|
{ slug: 'features', label: 'Key Features', icon: 'featured_play_list', desc: '20Hz telemetry & sensor fusion cards' },
|
|
{ slug: 'screenshots', label: 'Screenshots', icon: 'screenshot_monitor', desc: 'App gallery visuals and captions' },
|
|
{ slug: 'faqs', label: 'FAQs', icon: 'help_outline', desc: 'Mounting & offline recording guidelines' },
|
|
{ slug: 'download_links', label: 'Download Links', icon: 'download', desc: 'APK, Play Store, and build versions' },
|
|
{ slug: 'changelog', label: 'Changelog', icon: 'history', desc: 'Release versions, dates, and notes' },
|
|
{ slug: 'policy_pages', label: 'Legal & Policies', icon: 'policy', desc: 'Privacy policy & terms of service' },
|
|
] as const;
|
|
|
|
export function OperationsCms({ role: _role }: OperationsCmsProps) {
|
|
// Document list & selection state
|
|
const [documents, setDocuments] = useState<CmsDocumentSummary[]>([]);
|
|
const [selectedSlug, setSelectedSlug] = useState<string>('homepage_hero');
|
|
const [activeDoc, setActiveDoc] = useState<CmsDocumentDetail | null>(null);
|
|
const [isLoadingList, setIsLoadingList] = useState(true);
|
|
const [isLoadingDetail, setIsLoadingDetail] = useState(false);
|
|
const [isRefreshing, setIsRefreshing] = useState(false);
|
|
const [errorBanner, setErrorBanner] = useState<string | null>(null);
|
|
const [successBanner, setSuccessBanner] = useState<string | null>(null);
|
|
|
|
// Editor form state
|
|
const [editMode, setEditMode] = useState<'structured' | 'json'>('structured');
|
|
const [workingContent, setWorkingContent] = useState<Record<string, any>>({});
|
|
const [rawJsonText, setRawJsonText] = useState<string>('{}');
|
|
const [jsonError, setJsonError] = useState<string | null>(null);
|
|
const [changeSummary, setChangeSummary] = useState<string>('Content updated');
|
|
const [isSubmitting, setIsSubmitting] = useState(false);
|
|
|
|
// Preview modal state
|
|
const [isPreviewOpen, setIsPreviewOpen] = useState(false);
|
|
|
|
// Revision history modal & rollback state
|
|
const [isHistoryOpen, setIsHistoryOpen] = useState(false);
|
|
const [selectedRevisionForRollback, setSelectedRevisionForRollback] = useState<CmsRevisionItem | null>(null);
|
|
const [rollbackReason, setRollbackReason] = useState<string>('');
|
|
const [isRollingBack, setIsRollingBack] = useState(false);
|
|
const [rollbackError, setRollbackError] = useState<string | null>(null);
|
|
|
|
// Fetch document list
|
|
const fetchDocuments = useCallback(async (isManual = false) => {
|
|
if (isManual) setIsRefreshing(true);
|
|
else setIsLoadingList(true);
|
|
setErrorBanner(null);
|
|
|
|
try {
|
|
const response = await fetch('/api/ops/cms/documents', {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setErrorBanner('Your Operations session has expired.');
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setErrorBanner('You do not have authority to view website content.');
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setErrorBanner('Failed to load CMS documents from platform.');
|
|
return;
|
|
}
|
|
|
|
const data = await response.json();
|
|
if (Array.isArray(data)) {
|
|
setDocuments(data);
|
|
}
|
|
} catch {
|
|
setErrorBanner('Network error connecting to Operations CMS service.');
|
|
} finally {
|
|
setIsLoadingList(false);
|
|
setIsRefreshing(false);
|
|
}
|
|
}, []);
|
|
|
|
// Fetch single document detail
|
|
const fetchDocumentDetail = useCallback(async (slug: string) => {
|
|
setIsLoadingDetail(true);
|
|
setErrorBanner(null);
|
|
setSuccessBanner(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/ops/cms/documents/${encodeURIComponent(slug)}`, {
|
|
method: 'GET',
|
|
headers: { accept: 'application/json' },
|
|
cache: 'no-store',
|
|
});
|
|
|
|
if (response.status === 401) {
|
|
setErrorBanner('Your Operations session has expired.');
|
|
return;
|
|
}
|
|
if (response.status === 403) {
|
|
setErrorBanner('You do not have authority to view this content document.');
|
|
return;
|
|
}
|
|
if (response.status === 404) {
|
|
setErrorBanner('CMS document not found.');
|
|
return;
|
|
}
|
|
if (!response.ok) {
|
|
setErrorBanner('Failed to load content details.');
|
|
return;
|
|
}
|
|
|
|
const data: CmsDocumentDetail = await response.json();
|
|
setActiveDoc(data);
|
|
|
|
// Initialize working content from draft if present, else published
|
|
const initialContent = data.draft ? JSON.parse(JSON.stringify(data.draft)) : JSON.parse(JSON.stringify(data.published));
|
|
setWorkingContent(initialContent);
|
|
setRawJsonText(JSON.stringify(initialContent, null, 2));
|
|
setJsonError(null);
|
|
setChangeSummary('Content updated');
|
|
} catch {
|
|
setErrorBanner('Network error loading document detail.');
|
|
} finally {
|
|
setIsLoadingDetail(false);
|
|
}
|
|
}, []);
|
|
|
|
// Initial load
|
|
useEffect(() => {
|
|
void fetchDocuments();
|
|
}, [fetchDocuments]);
|
|
|
|
// Load detail whenever selectedSlug changes
|
|
useEffect(() => {
|
|
if (selectedSlug) {
|
|
void fetchDocumentDetail(selectedSlug);
|
|
}
|
|
}, [selectedSlug, fetchDocumentDetail]);
|
|
|
|
// Update working content and sync JSON string
|
|
const updateContent = (updater: (prev: Record<string, any>) => Record<string, any>) => {
|
|
setWorkingContent((prev) => {
|
|
const next = updater(prev);
|
|
setRawJsonText(JSON.stringify(next, null, 2));
|
|
setJsonError(null);
|
|
return next;
|
|
});
|
|
};
|
|
|
|
// Handle raw JSON textarea changes
|
|
const handleJsonChange = (e: React.ChangeEvent<HTMLTextAreaElement>) => {
|
|
const text = e.target.value;
|
|
setRawJsonText(text);
|
|
try {
|
|
const parsed = JSON.parse(text);
|
|
if (typeof parsed === 'object' && parsed !== null) {
|
|
setWorkingContent(parsed);
|
|
setJsonError(null);
|
|
} else {
|
|
setJsonError('JSON root must be an object.');
|
|
}
|
|
} catch (err: any) {
|
|
setJsonError(err.message ?? 'Invalid JSON syntax');
|
|
}
|
|
};
|
|
|
|
// Save draft or publish live
|
|
const handleSaveOrPublish = async (action: 'draft' | 'publish') => {
|
|
if (jsonError) {
|
|
setErrorBanner('Please fix JSON syntax errors before submitting.');
|
|
return;
|
|
}
|
|
|
|
setIsSubmitting(true);
|
|
setErrorBanner(null);
|
|
setSuccessBanner(null);
|
|
|
|
try {
|
|
const response = await fetch(`/api/ops/cms/documents/${encodeURIComponent(selectedSlug)}`, {
|
|
method: 'PUT',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
action,
|
|
content: workingContent,
|
|
change_summary: changeSummary.trim() || (action === 'draft' ? 'Draft saved' : 'Published live'),
|
|
}),
|
|
});
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => null);
|
|
setErrorBanner(errorData?.detail ?? `Failed to ${action} changes.`);
|
|
return;
|
|
}
|
|
|
|
const updatedDoc: CmsDocumentDetail = await response.json();
|
|
setActiveDoc(updatedDoc);
|
|
setSuccessBanner(
|
|
action === 'publish'
|
|
? 'Content successfully published live!'
|
|
: 'Draft successfully saved! Pending review.'
|
|
);
|
|
|
|
// Re-fetch summaries to update "has_draft" badges
|
|
void fetchDocuments(false);
|
|
} catch {
|
|
setErrorBanner(`Network error attempting to ${action} changes.`);
|
|
} finally {
|
|
setIsSubmitting(false);
|
|
}
|
|
};
|
|
|
|
// Rollback to revision
|
|
const handleRollbackSubmit = async () => {
|
|
if (!selectedRevisionForRollback || rollbackReason.trim().length < 3) {
|
|
setRollbackError('Rollback reason must be at least 3 characters.');
|
|
return;
|
|
}
|
|
|
|
setIsRollingBack(true);
|
|
setRollbackError(null);
|
|
|
|
try {
|
|
const response = await fetch(
|
|
`/api/ops/cms/documents/${encodeURIComponent(selectedSlug)}/rollback`,
|
|
{
|
|
method: 'POST',
|
|
headers: {
|
|
'content-type': 'application/json',
|
|
accept: 'application/json',
|
|
},
|
|
body: JSON.stringify({
|
|
revision_id: selectedRevisionForRollback.revision_id,
|
|
reason: rollbackReason.trim(),
|
|
}),
|
|
}
|
|
);
|
|
|
|
if (!response.ok) {
|
|
const errorData = await response.json().catch(() => null);
|
|
setRollbackError(errorData?.detail ?? 'Failed to restore revision.');
|
|
return;
|
|
}
|
|
|
|
const updatedDoc: CmsDocumentDetail = await response.json();
|
|
setActiveDoc(updatedDoc);
|
|
const initialContent = updatedDoc.published;
|
|
setWorkingContent(initialContent);
|
|
setRawJsonText(JSON.stringify(initialContent, null, 2));
|
|
|
|
setSelectedRevisionForRollback(null);
|
|
setRollbackReason('');
|
|
setIsHistoryOpen(false);
|
|
setSuccessBanner(`Successfully restored revision ${selectedRevisionForRollback.revision_id}`);
|
|
|
|
// Re-fetch summaries
|
|
void fetchDocuments(false);
|
|
} catch {
|
|
setRollbackError('Network error during rollback.');
|
|
} finally {
|
|
setIsRollingBack(false);
|
|
}
|
|
};
|
|
|
|
// Keyboard accessibility for modals
|
|
useEffect(() => {
|
|
const handleKeyDown = (e: KeyboardEvent) => {
|
|
if (e.key === 'Escape') {
|
|
if (selectedRevisionForRollback) {
|
|
setSelectedRevisionForRollback(null);
|
|
} else if (isHistoryOpen) {
|
|
setIsHistoryOpen(false);
|
|
} else if (isPreviewOpen) {
|
|
setIsPreviewOpen(false);
|
|
}
|
|
}
|
|
};
|
|
window.addEventListener('keydown', handleKeyDown);
|
|
return () => window.removeEventListener('keydown', handleKeyDown);
|
|
}, [selectedRevisionForRollback, isHistoryOpen, isPreviewOpen]);
|
|
|
|
const hasDraftPending = activeDoc ? activeDoc.draft !== null : false;
|
|
|
|
return (
|
|
<div className="space-y-6">
|
|
{/* Header Bar */}
|
|
<div className="flex flex-col sm:flex-row sm:items-end justify-between gap-4 pb-4 border-b border-outline-variant/30">
|
|
<div>
|
|
<span className="text-[11px] font-mono text-primary-fixed-dim block font-semibold">
|
|
Content Management System
|
|
</span>
|
|
<div className="flex items-center gap-2.5 mt-1">
|
|
<span className="material-symbols-outlined text-primary-container text-2xl sm:text-3xl">
|
|
edit_note
|
|
</span>
|
|
<h1 className="font-headline-xl text-2xl sm:text-3xl lg:text-4xl font-extrabold text-on-surface tracking-tight">
|
|
Website Content CMS
|
|
</h1>
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-outline mt-1.5 max-w-2xl">
|
|
Manage public website copy, hero positioning, feature lists, download links, and legal policies.
|
|
</p>
|
|
</div>
|
|
|
|
{/* Refresh & Quick Actions */}
|
|
<div className="flex items-center gap-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
void fetchDocuments(true);
|
|
if (selectedSlug) void fetchDocumentDetail(selectedSlug);
|
|
}}
|
|
disabled={isRefreshing || isLoadingList || isLoadingDetail}
|
|
className="inline-flex items-center gap-2 px-4 py-2.5 rounded-xl border border-outline-variant/30 bg-surface-container-low hover:bg-surface-container-high text-xs sm:text-sm font-semibold text-on-surface transition-colors disabled:opacity-50 self-start sm:self-auto shrink-0 shadow-sm"
|
|
>
|
|
<span
|
|
className={`material-symbols-outlined text-[18px] ${
|
|
isRefreshing ? 'animate-spin text-primary-container' : 'text-outline'
|
|
}`}
|
|
>
|
|
refresh
|
|
</span>
|
|
<span>{isRefreshing ? 'Refreshing...' : 'Refresh'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Banners */}
|
|
{errorBanner && (
|
|
<div className="p-3.5 rounded-lg bg-error-container/20 border border-error-container/40 text-on-error-container flex items-start gap-2.5 text-xs animate-in fade-in duration-150">
|
|
<span className="material-symbols-outlined text-error text-base shrink-0 mt-0.5">
|
|
error
|
|
</span>
|
|
<div className="flex-1">{errorBanner}</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setErrorBanner(null)}
|
|
className="text-outline hover:text-on-surface"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">close</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{successBanner && (
|
|
<div className="p-3.5 rounded-lg bg-primary-container/15 border border-primary-container/30 text-primary-container flex items-start gap-2.5 text-xs animate-in fade-in duration-150">
|
|
<span className="material-symbols-outlined text-base shrink-0 mt-0.5">
|
|
check_circle
|
|
</span>
|
|
<div className="flex-1">{successBanner}</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setSuccessBanner(null)}
|
|
className="text-outline hover:text-on-surface"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">close</span>
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main Grid: Document Navigation + Editor */}
|
|
<div className="grid grid-cols-1 lg:grid-cols-12 gap-6 items-start">
|
|
{/* Document Navigation Tabs (Sidebar on Desktop, Horizontal Scroller on Mobile) */}
|
|
<aside className="lg:col-span-3 space-y-1.5">
|
|
<div className="text-[11px] font-mono font-semibold text-outline px-2 mb-2">
|
|
Content Documents ({DOCUMENT_SECTIONS.length})
|
|
</div>
|
|
|
|
<div className="flex lg:flex-col gap-1.5 overflow-x-auto pb-2 lg:pb-0">
|
|
{DOCUMENT_SECTIONS.map((sec) => {
|
|
const isSelected = selectedSlug === sec.slug;
|
|
const summary = documents.find((d) => d.slug === sec.slug);
|
|
const hasDraft = summary ? summary.has_draft : false;
|
|
|
|
return (
|
|
<button
|
|
key={sec.slug}
|
|
type="button"
|
|
onClick={() => setSelectedSlug(sec.slug)}
|
|
className={`flex-1 lg:w-full text-left p-2.5 rounded-lg border transition-all flex items-center justify-between gap-2 shrink-0 ${
|
|
isSelected
|
|
? 'bg-surface-container-high border-primary-container/50 text-primary-container shadow-[0_0_12px_rgba(0,242,254,0.1)]'
|
|
: 'bg-surface-container-lowest border-outline-variant/30 text-on-surface-variant hover:text-on-surface hover:bg-surface-container-low'
|
|
}`}
|
|
>
|
|
<div className="flex items-center gap-2.5 min-w-0">
|
|
<span
|
|
className={`material-symbols-outlined text-[18px] shrink-0 ${
|
|
isSelected ? 'text-primary-container' : 'text-outline'
|
|
}`}
|
|
>
|
|
{sec.icon}
|
|
</span>
|
|
<div className="truncate">
|
|
<div className="text-xs font-semibold truncate">{sec.label}</div>
|
|
<div className="text-[10px] text-outline truncate hidden sm:block">
|
|
{sec.desc}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
{hasDraft && (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono font-semibold bg-amber-500/15 text-amber-400 border border-amber-500/30 shrink-0">
|
|
Draft
|
|
</span>
|
|
)}
|
|
</button>
|
|
);
|
|
})}
|
|
</div>
|
|
</aside>
|
|
|
|
{/* Main Editor Body */}
|
|
<section className="lg:col-span-9 space-y-4">
|
|
{isLoadingDetail ? (
|
|
<div className="p-12 rounded-xl bg-surface-container-lowest border border-outline-variant/30 flex flex-col items-center justify-center gap-3 text-outline">
|
|
<span className="material-symbols-outlined text-3xl animate-spin text-primary-container">
|
|
progress_activity
|
|
</span>
|
|
<span className="text-xs font-mono">Loading content document...</span>
|
|
</div>
|
|
) : activeDoc ? (
|
|
<div className="rounded-2xl bg-surface-container-lowest border border-outline-variant/30 p-6 sm:p-7 space-y-6 shadow-sm">
|
|
{/* Document Status Header */}
|
|
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-3 pb-5 border-b border-outline-variant/20">
|
|
<div>
|
|
<div className="flex items-center gap-2.5">
|
|
<h2 className="text-lg sm:text-xl font-bold text-on-surface font-headline-sm">
|
|
{activeDoc.title}
|
|
</h2>
|
|
{hasDraftPending ? (
|
|
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-mono font-medium tracking-wide bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
Draft Pending
|
|
</span>
|
|
) : (
|
|
<span className="px-2.5 py-0.5 rounded-full text-[11px] font-mono font-medium tracking-wide bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
Published
|
|
</span>
|
|
)}
|
|
</div>
|
|
<p className="text-xs sm:text-sm text-on-surface-variant mt-1">
|
|
{activeDoc.description}
|
|
</p>
|
|
<div className="text-[11px] font-mono text-outline mt-2 flex flex-wrap items-center gap-3">
|
|
<span>
|
|
Updated: {new Date(activeDoc.updated_at).toLocaleString()}
|
|
</span>
|
|
<span>•</span>
|
|
<span>By: {activeDoc.updated_by}</span>
|
|
<span>•</span>
|
|
<span>Revisions: {activeDoc.revisions?.length ?? 0}</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* View/Edit Mode Toggles */}
|
|
<div className="flex items-center gap-1 self-start sm:self-center p-1 rounded-xl bg-surface-container-low border border-outline-variant/30 text-xs sm:text-sm">
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode('structured')}
|
|
className={`px-3 py-1.5 rounded-lg font-semibold transition-colors ${
|
|
editMode === 'structured'
|
|
? 'bg-surface-container-high text-primary-container shadow-sm'
|
|
: 'text-outline hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Structured
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => setEditMode('json')}
|
|
className={`px-3 py-1.5 rounded-lg font-semibold transition-colors ${
|
|
editMode === 'json'
|
|
? 'bg-surface-container-high text-primary-container shadow-sm'
|
|
: 'text-outline hover:text-on-surface'
|
|
}`}
|
|
>
|
|
Raw JSON
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Action Toolbar */}
|
|
<div className="flex flex-wrap items-center justify-between gap-3 p-4 rounded-xl bg-surface-container-low border border-outline-variant/20 shadow-sm">
|
|
<div className="flex items-center gap-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPreviewOpen(true)}
|
|
className="flex items-center gap-2 px-3.5 py-2 rounded-xl border border-outline-variant/40 bg-surface-container hover:bg-surface-container-high text-on-surface text-xs sm:text-sm font-semibold transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px] text-primary-fixed-dim">
|
|
visibility
|
|
</span>
|
|
<span>Preview Changes</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsHistoryOpen(true)}
|
|
className="flex items-center gap-2 px-3.5 py-2 rounded-xl border border-outline-variant/40 bg-surface-container hover:bg-surface-container-high text-on-surface text-xs sm:text-sm font-medium transition-colors"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px] text-outline">
|
|
history
|
|
</span>
|
|
<span>Revision History</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="flex items-center gap-2.5">
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleSaveOrPublish('draft')}
|
|
disabled={isSubmitting || Boolean(jsonError)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl border border-amber-500/40 bg-amber-500/10 hover:bg-amber-500/20 text-amber-300 text-xs sm:text-sm font-semibold transition-colors disabled:opacity-50"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">save</span>
|
|
<span>{isSubmitting ? 'Saving...' : 'Save Draft'}</span>
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleSaveOrPublish('publish')}
|
|
disabled={isSubmitting || Boolean(jsonError)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl bg-primary-container hover:brightness-110 text-on-primary-container text-xs sm:text-sm font-bold transition-all shadow-[0_0_12px_rgba(0,242,254,0.25)] disabled:opacity-50"
|
|
>
|
|
<span className="material-symbols-outlined text-[18px]">cloud_upload</span>
|
|
<span>{isSubmitting ? 'Publishing...' : 'Publish Live'}</span>
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Change Summary Field */}
|
|
<div>
|
|
<label
|
|
htmlFor="change-summary"
|
|
className="block text-[11px] font-mono text-outline mb-1.5"
|
|
>
|
|
Change Summary / Audit Note
|
|
</label>
|
|
<input
|
|
id="change-summary"
|
|
type="text"
|
|
value={changeSummary}
|
|
onChange={(e) => setChangeSummary(e.target.value)}
|
|
placeholder="e.g. Updated 20Hz sensor copy for iOS 18 release"
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs placeholder:text-outline focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
{/* Editor Mode: Structured Form vs Raw JSON */}
|
|
{editMode === 'json' ? (
|
|
<div className="space-y-2">
|
|
<div className="flex items-center justify-between text-xs text-outline font-mono">
|
|
<span>Direct Document Payload (JSON)</span>
|
|
{jsonError && (
|
|
<span className="text-error font-medium">{jsonError}</span>
|
|
)}
|
|
</div>
|
|
<textarea
|
|
rows={18}
|
|
value={rawJsonText}
|
|
onChange={handleJsonChange}
|
|
className="w-full p-3.5 rounded-lg bg-surface-container-lowest border border-outline-variant/40 font-mono text-xs text-on-surface leading-relaxed focus:outline-none focus:border-primary-container"
|
|
spellCheck={false}
|
|
/>
|
|
</div>
|
|
) : (
|
|
<div className="space-y-6 pt-2">
|
|
{/* Homepage Hero Structured Form */}
|
|
{selectedSlug === 'homepage_hero' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Headline
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.headline ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, headline: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Subheadline
|
|
</label>
|
|
<textarea
|
|
rows={3}
|
|
value={workingContent.subheadline ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, subheadline: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
CTA Button Text
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.cta_text ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, cta_text: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
CTA Link Target
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.cta_link ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, cta_link: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Pill Badge Text
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.badge_text ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, badge_text: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Features Structured Form */}
|
|
{selectedSlug === 'features' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-on-surface font-headline-sm">
|
|
Features List ({Array.isArray(workingContent.items) ? workingContent.items.length : 0})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: [
|
|
...(Array.isArray(prev.items) ? prev.items : []),
|
|
{ title: 'New Feature', description: 'Feature description', icon: 'speed' },
|
|
],
|
|
}))
|
|
}
|
|
className="flex items-center gap-1 text-xs font-medium text-primary-container hover:underline"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">add</span>
|
|
<span>Add Feature Item</span>
|
|
</button>
|
|
</div>
|
|
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3.5 rounded-lg bg-surface-container-low border border-outline-variant/20 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[11px] font-mono text-outline">
|
|
Item #{idx + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: prev.items.filter((_: any, i: number) => i !== idx),
|
|
}))
|
|
}
|
|
className="text-outline hover:text-error text-xs flex items-center gap-0.5"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">delete</span>
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div className="md:col-span-2">
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.title ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], title: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Material Icon Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.icon ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], icon: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Description
|
|
</label>
|
|
<textarea
|
|
rows={2}
|
|
value={item.description ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], description: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Screenshots Structured Form */}
|
|
{selectedSlug === 'screenshots' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-on-surface font-headline-sm">
|
|
Gallery Screenshots ({Array.isArray(workingContent.items) ? workingContent.items.length : 0})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: [
|
|
...(Array.isArray(prev.items) ? prev.items : []),
|
|
{ title: 'New Screenshot', caption: 'Visual caption', image_url: '/screenshots/sample.png' },
|
|
],
|
|
}))
|
|
}
|
|
className="flex items-center gap-1 text-xs font-medium text-primary-container hover:underline"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">add</span>
|
|
<span>Add Screenshot</span>
|
|
</button>
|
|
</div>
|
|
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3.5 rounded-lg bg-surface-container-low border border-outline-variant/20 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[11px] font-mono text-outline">
|
|
Screenshot #{idx + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: prev.items.filter((_: any, i: number) => i !== idx),
|
|
}))
|
|
}
|
|
className="text-outline hover:text-error text-xs flex items-center gap-0.5"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">delete</span>
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-3">
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.title ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], title: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Image URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.image_url ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], image_url: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Caption
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.caption ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], caption: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* FAQs Structured Form */}
|
|
{selectedSlug === 'faqs' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-on-surface font-headline-sm">
|
|
FAQ Items ({Array.isArray(workingContent.items) ? workingContent.items.length : 0})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: [
|
|
...(Array.isArray(prev.items) ? prev.items : []),
|
|
{ question: 'Question topic?', answer: 'Detailed guidance answer.' },
|
|
],
|
|
}))
|
|
}
|
|
className="flex items-center gap-1 text-xs font-medium text-primary-container hover:underline"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">add</span>
|
|
<span>Add FAQ</span>
|
|
</button>
|
|
</div>
|
|
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3.5 rounded-lg bg-surface-container-low border border-outline-variant/20 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[11px] font-mono text-outline">
|
|
FAQ #{idx + 1}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
items: prev.items.filter((_: any, i: number) => i !== idx),
|
|
}))
|
|
}
|
|
className="text-outline hover:text-error text-xs flex items-center gap-0.5"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">delete</span>
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Question
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={item.question ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], question: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Answer
|
|
</label>
|
|
<textarea
|
|
rows={2}
|
|
value={item.answer ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextItems = [...prev.items];
|
|
nextItems[idx] = { ...nextItems[idx], answer: e.target.value };
|
|
return { ...prev, items: nextItems };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Download Links Structured Form */}
|
|
{selectedSlug === 'download_links' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Android APK Direct URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.android_url ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, android_url: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Google Play Store URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.play_store_url ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, play_store_url: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Apple TestFlight URL
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.testflight_url ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, testflight_url: e.target.value }))
|
|
}
|
|
placeholder="Optional TestFlight public beta link"
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-2 gap-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Version Name
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={workingContent.version_name ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, version_name: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Build Number
|
|
</label>
|
|
<input
|
|
type="number"
|
|
value={workingContent.build_number ?? 0}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
build_number: parseInt(e.target.value, 10) || 0,
|
|
}))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Changelog Structured Form */}
|
|
{selectedSlug === 'changelog' && (
|
|
<div className="space-y-4">
|
|
<div className="flex items-center justify-between">
|
|
<span className="text-xs font-bold text-on-surface font-headline-sm">
|
|
Releases List ({Array.isArray(workingContent.releases) ? workingContent.releases.length : 0})
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
releases: [
|
|
{
|
|
version: 'v1.0.5',
|
|
release_date: new Date().toISOString().split('T')[0],
|
|
title: 'New Release',
|
|
notes: 'Description of changes',
|
|
},
|
|
...(Array.isArray(prev.releases) ? prev.releases : []),
|
|
],
|
|
}))
|
|
}
|
|
className="flex items-center gap-1 text-xs font-medium text-primary-container hover:underline"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">add</span>
|
|
<span>Add Release</span>
|
|
</button>
|
|
</div>
|
|
|
|
{Array.isArray(workingContent.releases) &&
|
|
workingContent.releases.map((rel: any, idx: number) => (
|
|
<div
|
|
key={idx}
|
|
className="p-3.5 rounded-lg bg-surface-container-low border border-outline-variant/20 space-y-3"
|
|
>
|
|
<div className="flex items-center justify-between gap-2">
|
|
<span className="text-[11px] font-mono text-primary-container font-semibold">
|
|
{rel.version || `Release #${idx + 1}`}
|
|
</span>
|
|
<button
|
|
type="button"
|
|
onClick={() =>
|
|
updateContent((prev) => ({
|
|
...prev,
|
|
releases: prev.releases.filter((_: any, i: number) => i !== idx),
|
|
}))
|
|
}
|
|
className="text-outline hover:text-error text-xs flex items-center gap-0.5"
|
|
>
|
|
<span className="material-symbols-outlined text-sm">delete</span>
|
|
<span>Remove</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-3">
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Version Tag
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={rel.version ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextReleases = [...prev.releases];
|
|
nextReleases[idx] = { ...nextReleases[idx], version: e.target.value };
|
|
return { ...prev, releases: nextReleases };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Release Date
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={rel.release_date ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextReleases = [...prev.releases];
|
|
nextReleases[idx] = { ...nextReleases[idx], release_date: e.target.value };
|
|
return { ...prev, releases: nextReleases };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Title
|
|
</label>
|
|
<input
|
|
type="text"
|
|
value={rel.title ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextReleases = [...prev.releases];
|
|
nextReleases[idx] = { ...nextReleases[idx], title: e.target.value };
|
|
return { ...prev, releases: nextReleases };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-[11px] text-on-surface-variant mb-1">
|
|
Release Notes / Changelog Details
|
|
</label>
|
|
<textarea
|
|
rows={2}
|
|
value={rel.notes ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => {
|
|
const nextReleases = [...prev.releases];
|
|
nextReleases[idx] = { ...nextReleases[idx], notes: e.target.value };
|
|
return { ...prev, releases: nextReleases };
|
|
})
|
|
}
|
|
className="w-full px-2.5 py-1.5 rounded bg-surface-container border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Policy Pages Structured Form */}
|
|
{selectedSlug === 'policy_pages' && (
|
|
<div className="space-y-4">
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Privacy Policy Body
|
|
</label>
|
|
<textarea
|
|
rows={6}
|
|
value={workingContent.privacy_policy ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, privacy_policy: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs leading-relaxed focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
|
|
<div>
|
|
<label className="block text-xs font-medium text-on-surface-variant mb-1">
|
|
Terms of Service Body
|
|
</label>
|
|
<textarea
|
|
rows={6}
|
|
value={workingContent.terms_of_service ?? ''}
|
|
onChange={(e) =>
|
|
updateContent((prev) => ({ ...prev, terms_of_service: e.target.value }))
|
|
}
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs leading-relaxed focus:outline-none focus:border-primary-container"
|
|
/>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
)}
|
|
</div>
|
|
) : (
|
|
<div className="p-12 rounded-xl bg-surface-container-lowest border border-outline-variant/30 text-center text-outline text-xs font-mono">
|
|
Select a content document to edit.
|
|
</div>
|
|
)}
|
|
</section>
|
|
</div>
|
|
|
|
{/* ========================================================================= */}
|
|
{/* PREVIEW DRAWER / MODAL */}
|
|
{/* ========================================================================= */}
|
|
{isPreviewOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="preview-modal-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"
|
|
>
|
|
<div className="w-full max-w-5xl lg:max-w-6xl max-h-[90vh] flex flex-col rounded-2xl bg-surface-container-lowest border border-outline-variant/40 shadow-2xl overflow-hidden">
|
|
<div className="px-6 sm:px-8 py-5 border-b border-outline-variant/20 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<span className="material-symbols-outlined text-primary-container text-2xl">
|
|
visibility
|
|
</span>
|
|
<h3 id="preview-modal-title" className="text-base sm:text-lg font-bold text-on-surface font-headline-sm">
|
|
Preview: {activeDoc?.title}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPreviewOpen(false)}
|
|
className="p-1 rounded-lg text-outline hover:text-on-surface transition-colors"
|
|
aria-label="Close preview modal"
|
|
>
|
|
<span className="material-symbols-outlined text-xl">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 overflow-y-auto flex-1 space-y-6">
|
|
{/* Homepage Hero Preview */}
|
|
{selectedSlug === 'homepage_hero' && (
|
|
<div className="rounded-xl border border-primary-container/30 bg-surface-container-low p-8 text-center space-y-4">
|
|
{workingContent.badge_text && (
|
|
<span className="inline-block px-3 py-1 rounded-full text-[10px] font-mono font-semibold bg-primary-container/15 text-primary-container border border-primary-container/30">
|
|
{workingContent.badge_text}
|
|
</span>
|
|
)}
|
|
<h1 className="text-2xl sm:text-3xl font-extrabold text-on-surface font-headline-sm tracking-tight">
|
|
{workingContent.headline || 'Headline placeholder'}
|
|
</h1>
|
|
<p className="text-sm text-on-surface-variant max-w-xl mx-auto leading-relaxed">
|
|
{workingContent.subheadline || 'Subheadline placeholder'}
|
|
</p>
|
|
<div className="pt-2">
|
|
<span className="inline-flex items-center gap-2 px-5 py-2 rounded-lg bg-primary-container text-on-primary-container font-bold text-xs shadow-lg">
|
|
<span>{workingContent.cta_text || 'Download'}</span>
|
|
<span className="material-symbols-outlined text-sm">arrow_forward</span>
|
|
</span>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Features Preview */}
|
|
{selectedSlug === 'features' && (
|
|
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, i: number) => (
|
|
<div
|
|
key={i}
|
|
className="p-4 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-2"
|
|
>
|
|
<div className="w-8 h-8 rounded-lg bg-primary-container/15 text-primary-container flex items-center justify-center">
|
|
<span className="material-symbols-outlined text-base">
|
|
{item.icon || 'star'}
|
|
</span>
|
|
</div>
|
|
<h4 className="text-xs font-bold text-on-surface">{item.title}</h4>
|
|
<p className="text-[11px] text-on-surface-variant leading-relaxed">
|
|
{item.description}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Screenshots Preview */}
|
|
{selectedSlug === 'screenshots' && (
|
|
<div className="grid grid-cols-1 sm:grid-cols-2 gap-4">
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, i: number) => (
|
|
<div
|
|
key={i}
|
|
className="rounded-xl border border-outline-variant/30 bg-surface-container-low overflow-hidden space-y-2"
|
|
>
|
|
<div className="h-40 bg-surface-container flex items-center justify-center text-outline">
|
|
<span className="material-symbols-outlined text-3xl">image</span>
|
|
</div>
|
|
<div className="p-3">
|
|
<div className="text-xs font-semibold text-on-surface">{item.title}</div>
|
|
<div className="text-[11px] text-outline">{item.caption}</div>
|
|
</div>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* FAQs Preview */}
|
|
{selectedSlug === 'faqs' && (
|
|
<div className="space-y-3">
|
|
{Array.isArray(workingContent.items) &&
|
|
workingContent.items.map((item: any, i: number) => (
|
|
<div
|
|
key={i}
|
|
className="p-4 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-1.5"
|
|
>
|
|
<div className="text-xs font-bold text-on-surface flex items-center gap-2">
|
|
<span className="text-primary-container font-mono">Q:</span>
|
|
<span>{item.question}</span>
|
|
</div>
|
|
<p className="text-xs text-on-surface-variant pl-4 leading-relaxed">
|
|
{item.answer}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Download Links Preview */}
|
|
{selectedSlug === 'download_links' && (
|
|
<div className="p-6 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-4">
|
|
<div className="flex items-center justify-between pb-3 border-b border-outline-variant/20">
|
|
<div>
|
|
<div className="text-xs font-bold text-on-surface">MotorSignal Telemetry</div>
|
|
<div className="text-[10px] font-mono text-outline">
|
|
Production Version {workingContent.version_name} (Build {workingContent.build_number})
|
|
</div>
|
|
</div>
|
|
</div>
|
|
|
|
<div className="space-y-2 text-xs">
|
|
<div className="flex items-center justify-between p-2 rounded bg-surface-container">
|
|
<span className="font-mono text-on-surface-variant">Android Direct APK</span>
|
|
<span className="text-primary-container font-medium truncate max-w-xs">
|
|
{workingContent.android_url}
|
|
</span>
|
|
</div>
|
|
<div className="flex items-center justify-between p-2 rounded bg-surface-container">
|
|
<span className="font-mono text-on-surface-variant">Google Play Store</span>
|
|
<span className="text-primary-container font-medium truncate max-w-xs">
|
|
{workingContent.play_store_url}
|
|
</span>
|
|
</div>
|
|
{workingContent.testflight_url && (
|
|
<div className="flex items-center justify-between p-2 rounded bg-surface-container">
|
|
<span className="font-mono text-on-surface-variant">Apple TestFlight</span>
|
|
<span className="text-primary-container font-medium truncate max-w-xs">
|
|
{workingContent.testflight_url}
|
|
</span>
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Changelog Preview */}
|
|
{selectedSlug === 'changelog' && (
|
|
<div className="space-y-4">
|
|
{Array.isArray(workingContent.releases) &&
|
|
workingContent.releases.map((rel: any, i: number) => (
|
|
<div
|
|
key={i}
|
|
className="p-4 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-2"
|
|
>
|
|
<div className="flex items-center justify-between">
|
|
<span className="px-2 py-0.5 rounded font-mono text-xs font-bold bg-primary-container/15 text-primary-container">
|
|
{rel.version}
|
|
</span>
|
|
<span className="text-[10px] font-mono text-outline">
|
|
{rel.release_date}
|
|
</span>
|
|
</div>
|
|
<h4 className="text-xs font-bold text-on-surface">{rel.title}</h4>
|
|
<p className="text-xs text-on-surface-variant leading-relaxed">
|
|
{rel.notes}
|
|
</p>
|
|
</div>
|
|
))}
|
|
</div>
|
|
)}
|
|
|
|
{/* Policy Pages Preview */}
|
|
{selectedSlug === 'policy_pages' && (
|
|
<div className="space-y-6">
|
|
<div className="p-4 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-2">
|
|
<h4 className="text-xs font-bold text-on-surface font-mono">
|
|
Privacy Policy
|
|
</h4>
|
|
<p className="text-xs text-on-surface-variant leading-relaxed whitespace-pre-wrap">
|
|
{workingContent.privacy_policy}
|
|
</p>
|
|
</div>
|
|
|
|
<div className="p-4 rounded-xl border border-outline-variant/30 bg-surface-container-low space-y-2">
|
|
<h4 className="text-xs font-bold text-on-surface font-mono">
|
|
Terms of Service
|
|
</h4>
|
|
<p className="text-xs text-on-surface-variant leading-relaxed whitespace-pre-wrap">
|
|
{workingContent.terms_of_service}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-outline-variant/20 bg-surface-container-lowest flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsPreviewOpen(false)}
|
|
className="px-4 py-1.5 rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface text-xs font-medium"
|
|
>
|
|
Close Preview
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================================= */}
|
|
{/* REVISION HISTORY DRAWER / MODAL */}
|
|
{/* ========================================================================= */}
|
|
{isHistoryOpen && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="history-modal-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 sm:p-6 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"
|
|
>
|
|
<div className="w-full max-w-4xl lg:max-w-5xl max-h-[90vh] flex flex-col rounded-2xl bg-surface-container-lowest border border-outline-variant/40 shadow-2xl overflow-hidden">
|
|
<div className="px-6 sm:px-8 py-5 border-b border-outline-variant/20 flex items-center justify-between shrink-0">
|
|
<div className="flex items-center gap-3">
|
|
<span className="material-symbols-outlined text-primary-container text-2xl">
|
|
history
|
|
</span>
|
|
<h3 id="history-modal-title" className="text-base sm:text-lg font-bold text-on-surface font-headline-sm">
|
|
Revision History: {activeDoc?.title}
|
|
</h3>
|
|
</div>
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsHistoryOpen(false)}
|
|
className="p-1 rounded-lg text-outline hover:text-on-surface transition-colors"
|
|
aria-label="Close revision history modal"
|
|
>
|
|
<span className="material-symbols-outlined text-xl">close</span>
|
|
</button>
|
|
</div>
|
|
|
|
<div className="p-6 overflow-y-auto flex-1 space-y-3">
|
|
{activeDoc?.revisions && activeDoc.revisions.length > 0 ? (
|
|
activeDoc.revisions.map((rev) => (
|
|
<div
|
|
key={rev.revision_id}
|
|
className="p-4 rounded-xl border border-outline-variant/20 bg-surface-container-low flex flex-col sm:flex-row sm:items-center justify-between gap-3"
|
|
>
|
|
<div className="space-y-1">
|
|
<div className="flex items-center gap-2">
|
|
<span className="font-mono text-xs font-semibold text-on-surface">
|
|
{rev.revision_id}
|
|
</span>
|
|
{rev.status === 'published' ? (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono capitalize bg-emerald-500/15 text-emerald-400 border border-emerald-500/30">
|
|
published
|
|
</span>
|
|
) : (
|
|
<span className="px-1.5 py-0.5 rounded text-[9px] font-mono capitalize bg-amber-500/15 text-amber-400 border border-amber-500/30">
|
|
draft
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div className="text-xs text-on-surface-variant font-medium">
|
|
{rev.change_summary}
|
|
</div>
|
|
<div className="text-[10px] font-mono text-outline flex items-center gap-2">
|
|
<span>{new Date(rev.created_at).toLocaleString()}</span>
|
|
<span>•</span>
|
|
<span>{rev.created_by}</span>
|
|
</div>
|
|
</div>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => {
|
|
setSelectedRevisionForRollback(rev);
|
|
setRollbackReason('');
|
|
setRollbackError(null);
|
|
}}
|
|
className="self-start sm:self-center px-3 py-1.5 rounded-lg border border-outline-variant/40 bg-surface-container hover:bg-surface-container-high text-xs font-semibold text-on-surface transition-colors shrink-0"
|
|
>
|
|
Restore Revision
|
|
</button>
|
|
</div>
|
|
))
|
|
) : (
|
|
<div className="p-8 text-center text-xs font-mono text-outline">
|
|
No historical revisions found for this document.
|
|
</div>
|
|
)}
|
|
</div>
|
|
|
|
<div className="p-4 border-t border-outline-variant/20 bg-surface-container-lowest flex justify-end">
|
|
<button
|
|
type="button"
|
|
onClick={() => setIsHistoryOpen(false)}
|
|
className="px-4 py-1.5 rounded-lg bg-surface-container hover:bg-surface-container-high text-on-surface text-xs font-medium"
|
|
>
|
|
Close History
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* ========================================================================= */}
|
|
{/* ROLLBACK CONFIRMATION MODAL */}
|
|
{/* ========================================================================= */}
|
|
{selectedRevisionForRollback && (
|
|
<div
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="rollback-dialog-title"
|
|
className="fixed inset-0 z-50 flex items-center justify-center p-4 bg-black/80 backdrop-blur-sm animate-in fade-in duration-200"
|
|
>
|
|
<div className="w-full max-w-md rounded-2xl bg-surface-container-lowest border border-amber-500/40 shadow-2xl p-6 space-y-4">
|
|
<div className="flex items-center gap-2 text-amber-400">
|
|
<span className="material-symbols-outlined text-2xl">warning</span>
|
|
<h3 id="rollback-dialog-title" className="text-sm font-bold font-headline-sm">
|
|
Confirm Content Rollback
|
|
</h3>
|
|
</div>
|
|
|
|
<p className="text-xs text-on-surface-variant leading-relaxed">
|
|
You are about to restore revision{' '}
|
|
<code className="px-1 py-0.5 rounded bg-surface-container font-mono text-on-surface">
|
|
{selectedRevisionForRollback.revision_id}
|
|
</code>
|
|
. This will immediately overwrite current published website content.
|
|
</p>
|
|
|
|
{rollbackError && (
|
|
<div className="p-2.5 rounded bg-error-container/20 border border-error-container/40 text-on-error-container text-xs">
|
|
{rollbackError}
|
|
</div>
|
|
)}
|
|
|
|
<div>
|
|
<label
|
|
htmlFor="rollback-reason"
|
|
className="block text-[11px] font-mono text-outline mb-1.5"
|
|
>
|
|
Rollback Reason (min 3 characters) *
|
|
</label>
|
|
<input
|
|
id="rollback-reason"
|
|
type="text"
|
|
value={rollbackReason}
|
|
onChange={(e) => setRollbackReason(e.target.value)}
|
|
placeholder="e.g. Revert inaccurate 20Hz sensor copy"
|
|
className="w-full px-3 py-2 rounded-lg bg-surface-container-low border border-outline-variant/30 text-on-surface text-xs focus:outline-none focus:border-amber-400"
|
|
/>
|
|
</div>
|
|
|
|
<div className="flex items-center justify-end gap-2 pt-2">
|
|
<button
|
|
type="button"
|
|
onClick={() => setSelectedRevisionForRollback(null)}
|
|
disabled={isRollingBack}
|
|
className="px-3 py-1.5 rounded-lg border border-outline-variant/30 text-xs font-medium text-outline hover:text-on-surface"
|
|
>
|
|
Cancel
|
|
</button>
|
|
|
|
<button
|
|
type="button"
|
|
onClick={() => void handleRollbackSubmit()}
|
|
disabled={isRollingBack || rollbackReason.trim().length < 3}
|
|
className="px-4 py-1.5 rounded-lg bg-amber-500 hover:bg-amber-400 text-black text-xs font-bold transition-all disabled:opacity-50"
|
|
>
|
|
{isRollingBack ? 'Restoring...' : 'Restore Revision'}
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|