191 lines
9.7 KiB
TypeScript
191 lines
9.7 KiB
TypeScript
import { useState } from 'react'
|
|
import { X, Plus, Trash2, FileText, Clock, Layers, ChevronRight } from 'lucide-react'
|
|
import type { Page } from '../App'
|
|
|
|
function formatDate(ts: number) {
|
|
const d = new Date(ts)
|
|
const now = Date.now()
|
|
const diff = now - ts
|
|
if (diff < 60_000) return 'Just now'
|
|
if (diff < 3_600_000) return `${Math.floor(diff / 60_000)}m ago`
|
|
if (diff < 86_400_000) return `${Math.floor(diff / 3_600_000)}h ago`
|
|
return d.toLocaleDateString('en-US', { month: 'short', day: 'numeric', year: d.getFullYear() !== new Date().getFullYear() ? 'numeric' : undefined })
|
|
}
|
|
|
|
function countBlocksDeep(blocks: Page['blocks']): number {
|
|
let n = 0
|
|
for (const b of blocks) {
|
|
n++
|
|
if (b.slots) for (const slot of b.slots) n += countBlocksDeep(slot)
|
|
}
|
|
return n
|
|
}
|
|
|
|
export function PagesModal({
|
|
pages,
|
|
currentPageId,
|
|
onLoad,
|
|
onNew,
|
|
onDelete,
|
|
onClose,
|
|
}: {
|
|
pages: Page[]
|
|
currentPageId: string | null
|
|
onLoad: (page: Page) => void
|
|
onNew: () => void
|
|
onDelete: (id: string) => Promise<void>
|
|
onClose: () => void
|
|
}) {
|
|
const [confirmDelete, setConfirmDelete] = useState<string | null>(null)
|
|
|
|
return (
|
|
<div
|
|
data-testid="cms-pages-modal"
|
|
role="dialog"
|
|
aria-modal="true"
|
|
aria-labelledby="cms-pages-title"
|
|
style={{ position: 'fixed', inset: 0, zIndex: 60, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'rgba(0,0,0,0.4)', backdropFilter: 'blur(4px)' }}
|
|
onClick={onClose}
|
|
>
|
|
<div
|
|
style={{ background: '#fff', borderRadius: '16px', width: '640px', maxWidth: '95vw', maxHeight: '80vh', display: 'flex', flexDirection: 'column', boxShadow: '0 24px 64px rgba(0,0,0,0.2)', overflow: 'hidden' }}
|
|
onClick={e => e.stopPropagation()}
|
|
>
|
|
{/* Header */}
|
|
<div style={{ display: 'flex', alignItems: 'center', justifyContent: 'space-between', padding: '1.25rem 1.5rem', borderBottom: '1px solid #f1f5f9' }}>
|
|
<div>
|
|
<h2 id="cms-pages-title" style={{ margin: 0, fontSize: '1rem', fontWeight: 700, color: '#0f172a' }}>Pages</h2>
|
|
<p style={{ margin: '2px 0 0', fontSize: '0.75rem', color: '#94a3b8' }}>{pages.length} saved page{pages.length !== 1 ? 's' : ''}</p>
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.5rem', alignItems: 'center' }}>
|
|
<button
|
|
data-testid="cms-pages-create"
|
|
onClick={onNew}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.4rem 0.875rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
|
|
>
|
|
<Plus size={13} />New page
|
|
</button>
|
|
<button
|
|
onClick={onClose}
|
|
aria-label="Close pages"
|
|
style={{ width: 32, height: 32, display: 'flex', alignItems: 'center', justifyContent: 'center', border: 'none', background: '#f1f5f9', borderRadius: '8px', cursor: 'pointer', color: '#64748b' }}
|
|
>
|
|
<X size={15} />
|
|
</button>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Page list */}
|
|
<div style={{ flex: 1, overflowY: 'auto', padding: '0.75rem' }}>
|
|
{pages.length === 0 ? (
|
|
<div style={{ display: 'flex', flexDirection: 'column', alignItems: 'center', justifyContent: 'center', padding: '3rem 1rem', color: '#94a3b8' }}>
|
|
<FileText size={36} style={{ opacity: 0.3, marginBottom: '0.75rem' }} />
|
|
<p style={{ margin: 0, fontSize: '0.9rem', fontWeight: 500 }}>No saved pages yet</p>
|
|
<p style={{ margin: '0.25rem 0 1rem', fontSize: '0.8rem' }}>Save your current canvas to get started</p>
|
|
<button
|
|
data-testid="cms-pages-create-first"
|
|
onClick={onNew}
|
|
style={{ display: 'flex', alignItems: 'center', gap: '0.375rem', padding: '0.5rem 1rem', background: '#6366f1', color: '#fff', border: 'none', borderRadius: '8px', cursor: 'pointer', fontSize: '0.8rem', fontWeight: 600 }}
|
|
>
|
|
<Plus size={13} />Create first page
|
|
</button>
|
|
</div>
|
|
) : (
|
|
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.375rem' }}>
|
|
{pages.map(page => {
|
|
const isCurrent = page.id === currentPageId
|
|
const blockCount = countBlocksDeep(page.blocks)
|
|
const isDeleting = confirmDelete === page.id
|
|
return (
|
|
<div
|
|
data-testid={`cms-page-row-${page.id}`}
|
|
key={page.id}
|
|
style={{
|
|
display: 'flex', alignItems: 'center', gap: '0.875rem',
|
|
padding: '0.875rem 1rem', borderRadius: '10px',
|
|
border: isCurrent ? '2px solid #6366f1' : '2px solid #f1f5f9',
|
|
background: isCurrent ? '#fafafe' : '#fafafa',
|
|
cursor: 'pointer', transition: 'all 0.12s',
|
|
}}
|
|
onClick={() => { if (!isDeleting) { onLoad(page); onClose() } }}
|
|
>
|
|
{/* Theme swatch */}
|
|
<div style={{ flexShrink: 0, width: 44, height: 44, borderRadius: '8px', border: '1px solid #e2e8f0', overflow: 'hidden', display: 'grid', gridTemplateColumns: '1fr 1fr', gridTemplateRows: '1fr 1fr' }}>
|
|
{[page.theme.background, page.theme.primary, page.theme.muted, page.theme.accent].map((c, i) => (
|
|
<div key={i} style={{ background: /^#[0-9a-fA-F]{3,8}$/.test(c) ? c : '#e2e8f0' }} />
|
|
))}
|
|
</div>
|
|
|
|
{/* Info */}
|
|
<div style={{ flex: 1, minWidth: 0 }}>
|
|
<div style={{ display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
|
<span style={{ fontSize: '0.875rem', fontWeight: 600, color: '#0f172a', overflow: 'hidden', textOverflow: 'ellipsis', whiteSpace: 'nowrap' }}>
|
|
{page.name}
|
|
</span>
|
|
{isCurrent && (
|
|
<span style={{ fontSize: '0.65rem', fontWeight: 700, background: '#6366f1', color: '#fff', padding: '1px 6px', borderRadius: '4px', letterSpacing: '0.04em', flexShrink: 0 }}>
|
|
OPEN
|
|
</span>
|
|
)}
|
|
</div>
|
|
<div style={{ display: 'flex', gap: '0.75rem', marginTop: '0.2rem' }}>
|
|
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
<Layers size={10} />{blockCount} block{blockCount !== 1 ? 's' : ''}
|
|
</span>
|
|
<span style={{ fontSize: '0.72rem', color: '#94a3b8', display: 'flex', alignItems: 'center', gap: '0.25rem' }}>
|
|
<Clock size={10} />Updated {formatDate(page.updatedAt)}
|
|
</span>
|
|
</div>
|
|
</div>
|
|
|
|
{/* Actions */}
|
|
<div style={{ display: 'flex', gap: '0.375rem', flexShrink: 0 }} onClick={e => e.stopPropagation()}>
|
|
{isDeleting ? (
|
|
<>
|
|
<span style={{ fontSize: '0.72rem', color: '#ef4444', fontWeight: 600, display: 'flex', alignItems: 'center' }}>Delete?</span>
|
|
<button
|
|
data-testid={`cms-page-delete-confirm-${page.id}`}
|
|
onClick={() => { void onDelete(page.id).then(() => setConfirmDelete(null)).catch(() => undefined) }}
|
|
style={{ padding: '0.25rem 0.625rem', background: '#ef4444', color: '#fff', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem', fontWeight: 600 }}
|
|
>
|
|
Yes
|
|
</button>
|
|
<button
|
|
onClick={() => setConfirmDelete(null)}
|
|
style={{ padding: '0.25rem 0.625rem', background: '#f1f5f9', color: '#64748b', border: 'none', borderRadius: '6px', cursor: 'pointer', fontSize: '0.72rem' }}
|
|
>
|
|
No
|
|
</button>
|
|
</>
|
|
) : (
|
|
<>
|
|
<button
|
|
data-testid={`cms-page-delete-${page.id}`}
|
|
aria-label={`Delete ${page.name}`}
|
|
onClick={() => setConfirmDelete(page.id)}
|
|
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: 'transparent', border: '1px solid #e2e8f0', borderRadius: '6px', cursor: 'pointer', color: '#94a3b8' }}
|
|
>
|
|
<Trash2 size={12} />
|
|
</button>
|
|
<button
|
|
data-testid={`cms-page-open-${page.id}`}
|
|
aria-label={`Open ${page.name}`}
|
|
onClick={() => { onLoad(page); onClose() }}
|
|
style={{ width: 28, height: 28, display: 'flex', alignItems: 'center', justifyContent: 'center', background: '#f1f5f9', border: 'none', borderRadius: '6px', cursor: 'pointer', color: '#6366f1' }}
|
|
>
|
|
<ChevronRight size={14} />
|
|
</button>
|
|
</>
|
|
)}
|
|
</div>
|
|
</div>
|
|
)
|
|
})}
|
|
</div>
|
|
)}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)
|
|
}
|