'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([]); const [selectedSlug, setSelectedSlug] = useState('homepage_hero'); const [activeDoc, setActiveDoc] = useState(null); const [isLoadingList, setIsLoadingList] = useState(true); const [isLoadingDetail, setIsLoadingDetail] = useState(false); const [isRefreshing, setIsRefreshing] = useState(false); const [errorBanner, setErrorBanner] = useState(null); const [successBanner, setSuccessBanner] = useState(null); // Editor form state const [editMode, setEditMode] = useState<'structured' | 'json'>('structured'); const [workingContent, setWorkingContent] = useState>({}); const [rawJsonText, setRawJsonText] = useState('{}'); const [jsonError, setJsonError] = useState(null); const [changeSummary, setChangeSummary] = useState('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(null); const [rollbackReason, setRollbackReason] = useState(''); const [isRollingBack, setIsRollingBack] = useState(false); const [rollbackError, setRollbackError] = useState(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) => Record) => { 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) => { 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 (
{/* Header Bar */}
Content Management System
edit_note

Website Content CMS

Manage public website copy, hero positioning, feature lists, download links, and legal policies.

{/* Refresh & Quick Actions */}
{/* Banners */} {errorBanner && (
error
{errorBanner}
)} {successBanner && (
check_circle
{successBanner}
)} {/* Main Grid: Document Navigation + Editor */}
{/* Document Navigation Tabs (Sidebar on Desktop, Horizontal Scroller on Mobile) */} {/* Main Editor Body */}
{isLoadingDetail ? (
progress_activity Loading content document...
) : activeDoc ? (
{/* Document Status Header */}

{activeDoc.title}

{hasDraftPending ? ( Draft Pending ) : ( Published )}

{activeDoc.description}

Updated: {new Date(activeDoc.updated_at).toLocaleString()} • By: {activeDoc.updated_by} • Revisions: {activeDoc.revisions?.length ?? 0}
{/* View/Edit Mode Toggles */}
{/* Action Toolbar */}
{/* Change Summary Field */}
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" />
{/* Editor Mode: Structured Form vs Raw JSON */} {editMode === 'json' ? (
Direct Document Payload (JSON) {jsonError && ( {jsonError} )}