security: add Trivy container scan to Docker publish workflow (#399)
* feat(admin): add backup manager UI with 1-click restore * feat(admin): collapse older backup entries by default * fix(theme): isolate admin layout, instant DOM theme updates, and add Light/Dark mode toggle * feat(admin): replace text controls for Photo Frame and Hero Style with visual preview cards * fix(admin): define missing CSS rules for button system (.btn, .preset-btn) * feat(admin): redesign theme settings with segmented mode toggle, accent color swatches, and custom iOS-style toggle cards * feat(admin): upgrade all settings tabs with visual grid previews, Google SEO snippet preview, and toggle cards * feat(admin): overhaul Pages Builder UX with slide-over drawer, card hover badges, real-time search, and floating save bar * fix(admin): remove leftover dangling syntax snippet in PageBuilder.tsx * fix(admin): add position: relative to subpage-tile so hover overlay is contained inside the tile * style(admin): replace all emojis with clean Leica-style vector SVG icons * fix(types): extend IconProps with SVGProps to allow style prop on Leica SVG icons * feat(admin): add subpage live-preview, privacy analytics, and asset protection settings * fix(typecheck): fix AlbumEntry property access in subpage drawer preview mode * fix(types): fix ImmichAlbumInfo property names thumbnailAssetId and albumName in PageBuilder.tsx * fix(types): add protection and watermark optional properties to AppConfig interface * feat(settings): add toggle for disabling analytics tracking (defaults to enabled) * feat(ux): show status banner when analytics tracking is disabled + add live sync indicator * feat(watermark): render customizable watermark overlay on Lightbox images + rename section to Portfolio Features & Modules * fix(types): import type LightboxWatermark in PhotoGrid.tsx * fix(types): destructure watermark prop in PhotoGrid function signature * feat(seo): add configurable subpage title template and metadata descriptions * feat(seo): add generateMetadata to about page for title template support * feat(proofing): add client proofing, favorites selection, compact URL bitmasking, and export modal * feat(essay): add Photo Essay storytelling mode with RSC markdown parser and fullbleed/pair layouts * feat(subpages): add page enabled toggle to deactivate subpages without deleting them * feat(admin): add visual essay block builder for photo essay storytelling pages * security: add Trivy container scan to Docker publish workflow Scans the built image for CRITICAL/HIGH CVEs on every release and uploads results to the GitHub Security tab. Non-blocking for now (exit-code 0) so releases aren't gated on findings. Also documents the scan and existing Dependabot coverage in the README. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
This commit is contained in:
@@ -3,19 +3,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageBuilder from './PageBuilder';
|
||||
import SettingsEditor from './SettingsEditor';
|
||||
import AnalyticsView from './AnalyticsView';
|
||||
import BackupManagerModal from './BackupManagerModal';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'pages' | 'settings';
|
||||
type Tab = 'pages' | 'settings' | 'analytics';
|
||||
|
||||
export default function AdminDashboard({ onLogout }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('pages');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Diagnostics state
|
||||
// Diagnostics & Backup state
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
|
||||
@@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'analytics' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="admin-header-right">
|
||||
@@ -136,6 +146,17 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<span className="status-val">{status?.cache?.size ?? 0} items</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
setShowStatus(false);
|
||||
setShowBackupModal(true);
|
||||
}}
|
||||
>
|
||||
<Icons.IconArchive size={14} /> Manage Backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="Open site in new tab"
|
||||
>
|
||||
↗ Site
|
||||
<Icons.IconLink size={14} /> Site
|
||||
</a>
|
||||
<button
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={() => setShowBackupModal(true)}
|
||||
title="Manage config backups & restore"
|
||||
>
|
||||
<Icons.IconArchive size={14} /> Backups
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={handleReload}
|
||||
disabled={saving}
|
||||
title="Reload config & clear cache"
|
||||
>
|
||||
↻ Reload
|
||||
<Icons.IconRefresh size={14} /> Reload
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
|
||||
Logout
|
||||
@@ -167,7 +195,16 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<main className="admin-main">
|
||||
{activeTab === 'pages' && <PageBuilder />}
|
||||
{activeTab === 'settings' && <SettingsEditor />}
|
||||
{activeTab === 'analytics' && <AnalyticsView />}
|
||||
</main>
|
||||
|
||||
<BackupManagerModal
|
||||
isOpen={showBackupModal}
|
||||
onClose={() => setShowBackupModal(false)}
|
||||
onRestoreSuccess={() => {
|
||||
fetchStatus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
177
app/admin/components/AnalyticsView.tsx
Normal file
177
app/admin/components/AnalyticsView.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface AnalyticsData {
|
||||
trackingEnabled?: boolean;
|
||||
summary: {
|
||||
totalViews: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
days: Record<
|
||||
string,
|
||||
{
|
||||
pageviews: number;
|
||||
pages: Record<string, number>;
|
||||
devices: Record<string, number>;
|
||||
}
|
||||
>;
|
||||
topPages: { path: string; count: number }[];
|
||||
devices: { desktop: number; mobile: number };
|
||||
}
|
||||
|
||||
export default function AnalyticsView() {
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/analytics');
|
||||
if (!res.ok) throw new Error('Failed to fetch analytics');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading-container">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>{error || 'No analytics data'}</p>
|
||||
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalViews = data.summary?.totalViews || 0;
|
||||
const maxPageViews = data.topPages[0]?.count || 1;
|
||||
const daysList = Object.entries(data.days || {})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.slice(-14); // Last 14 days
|
||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
||||
|
||||
const desktopRatio = data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
|
||||
return (
|
||||
<div className="analytics-view">
|
||||
{/* Header */}
|
||||
<div className="analytics-header">
|
||||
<div>
|
||||
<h2><Icons.IconSparkles size={20} /> Visitor Insights & Analytics</h2>
|
||||
<p className="analytics-subtitle">Privacy-first aggregate traffic stats. No personal data collected.</p>
|
||||
</div>
|
||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data.trackingEnabled === false && (
|
||||
<div className="backup-status-alert error" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Icons.IconLock size={16} />
|
||||
<span>
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are being recorded. You can re-enable tracking anytime under <em>Settings → General</em>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Metric Cards */}
|
||||
<div className="analytics-metrics-grid">
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Total Page Views</span>
|
||||
<span className="metric-value">{totalViews.toLocaleString()}</span>
|
||||
<span className="metric-sub">Lifetime aggregate</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Active Days Tracked</span>
|
||||
<span className="metric-value">{Object.keys(data.days || {}).length}</span>
|
||||
<span className="metric-sub">Days recorded</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Desktop vs Mobile</span>
|
||||
<span className="metric-value">{desktopRatio}% / {100 - desktopRatio}%</span>
|
||||
<span className="metric-sub">Device distribution</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Trends Chart */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)</h3>
|
||||
</div>
|
||||
{daysList.length === 0 ? (
|
||||
<p className="analytics-empty">No pageviews recorded yet. Visit portfolio pages to start tracking!</p>
|
||||
) : (
|
||||
<div className="analytics-bar-chart">
|
||||
{daysList.map(([dateStr, dayData]) => {
|
||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
||||
const formattedDate = dateStr.slice(5); // MM-DD
|
||||
return (
|
||||
<div key={dateStr} className="bar-col" title={`${dateStr}: ${dayData.pageviews} views`}>
|
||||
<div className="bar-wrapper">
|
||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
||||
<span className="bar-val">{dayData.pageviews}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bar-label">{formattedDate}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Pages List */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconTarget size={16} /> Top Visited Pages & Subpages</h3>
|
||||
</div>
|
||||
{data.topPages.length === 0 ? (
|
||||
<p className="analytics-empty">No pages recorded yet.</p>
|
||||
) : (
|
||||
<div className="analytics-top-pages">
|
||||
{data.topPages.map((p, idx) => {
|
||||
const widthPct = Math.round((p.count / maxPageViews) * 100);
|
||||
return (
|
||||
<div key={p.path} className="top-page-row">
|
||||
<span className="page-rank">#{idx + 1}</span>
|
||||
<div className="page-info">
|
||||
<div className="page-path-line">
|
||||
<span className="page-path">{p.path}</span>
|
||||
<span className="page-count">{p.count} views</span>
|
||||
</div>
|
||||
<div className="page-progress-bg">
|
||||
<div className="page-progress-fill" style={{ width: `${widthPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
app/admin/components/BackupManagerModal.tsx
Normal file
226
app/admin/components/BackupManagerModal.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { BackupItem } from '@/app/api/admin/backups/route';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRestoreSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'gallery' | 'settings'>('gallery');
|
||||
const [showAllBackups, setShowAllBackups] = useState(false);
|
||||
const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({
|
||||
gallery: [],
|
||||
settings: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
|
||||
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch backup history');
|
||||
}
|
||||
const data = await res.json();
|
||||
setBackups(data.backups || { gallery: [], settings: [] });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading backups');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBackups();
|
||||
setConfirmFilename(null);
|
||||
setSuccessMsg(null);
|
||||
setShowAllBackups(false);
|
||||
}
|
||||
}, [isOpen, fetchBackups]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
setRestoringFilename(filename);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backupFilename: filename }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to restore backup');
|
||||
}
|
||||
|
||||
setSuccessMsg(`Backup restored successfully! (${filename})`);
|
||||
setConfirmFilename(null);
|
||||
await fetchBackups();
|
||||
onRestoreSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error restoring backup');
|
||||
} finally {
|
||||
setRestoringFilename(null);
|
||||
}
|
||||
}
|
||||
|
||||
const currentList = backups[activeTab] || [];
|
||||
const INITIAL_VISIBLE_COUNT = 3;
|
||||
const visibleList = showAllBackups ? currentList : currentList.slice(0, INITIAL_VISIBLE_COUNT);
|
||||
const hiddenCount = currentList.length - INITIAL_VISIBLE_COUNT;
|
||||
|
||||
return (
|
||||
<div className="backup-modal-backdrop" onClick={onClose}>
|
||||
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="backup-modal-header">
|
||||
<div>
|
||||
<h2>Backup History & Restoration</h2>
|
||||
<p className="backup-modal-subtitle">
|
||||
Restore previous states of your configuration files with 1-click.
|
||||
</p>
|
||||
</div>
|
||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="backup-modal-tabs">
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'gallery' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('gallery');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Gallery Backups ({backups.gallery.length})
|
||||
</button>
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('settings');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Settings Backups ({backups.settings.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && <div className="backup-status-alert error">{error}</div>}
|
||||
{successMsg && <div className="backup-status-alert success">{successMsg}</div>}
|
||||
|
||||
{/* Confirmation Modal Section */}
|
||||
{confirmFilename && (
|
||||
<div className="backup-confirm-box">
|
||||
<div className="backup-confirm-content">
|
||||
<strong>Confirm Restoration</strong>
|
||||
<p>
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>?
|
||||
A safety snapshot of your current state will be created automatically before reverting.
|
||||
</p>
|
||||
<div className="backup-confirm-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setConfirmFilename(null)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => handleRestore(confirmFilename)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
{restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup List Content */}
|
||||
<div className="backup-modal-body">
|
||||
{loading ? (
|
||||
<div className="backup-loading">Loading backups...</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="backup-empty">No backups available yet for {activeTab}.yaml</div>
|
||||
) : (
|
||||
<div className="backup-list">
|
||||
{visibleList.map((item) => {
|
||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||
const formattedDate = dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
|
||||
return (
|
||||
<div key={item.filename} className="backup-item">
|
||||
<div className="backup-item-info">
|
||||
<div className="backup-item-title">
|
||||
<span className="backup-filename">{formattedDate}</span>
|
||||
{item.isPreRestore && (
|
||||
<span className="backup-badge pre-restore" title="Safety snapshot taken before a restore">
|
||||
Pre-Restore Snapshot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="backup-item-meta">
|
||||
<code>{item.filename}</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => setConfirmFilename(item.filename)}
|
||||
disabled={!!restoringFilename || confirmFilename === item.filename}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentList.length > INITIAL_VISIBLE_COUNT && (
|
||||
<button
|
||||
className="backup-toggle-btn"
|
||||
onClick={() => setShowAllBackups(!showAllBackups)}
|
||||
>
|
||||
<Icons.IconChevronDown size={14} style={{ transform: showAllBackups ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s ease' }} />
|
||||
{showAllBackups
|
||||
? 'Ältere Einträge einklappen'
|
||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="backup-modal-footer">
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
455
app/admin/components/EssayBlockEditor.tsx
Normal file
455
app/admin/components/EssayBlockEditor.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
parseEssayMarkdown,
|
||||
serializeEssayMarkdown,
|
||||
type ParsedEssay,
|
||||
type EssayBlock,
|
||||
} from '@/lib/essay';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
onChange: (newMarkdown: string) => void;
|
||||
onSelectPhoto?: (callback: (assetId: string) => void) => void;
|
||||
}
|
||||
|
||||
export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) {
|
||||
const [essay, setEssay] = useState<ParsedEssay>(() =>
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...')
|
||||
);
|
||||
|
||||
// Sync internal state when prop changes from outside (e.g. initial load)
|
||||
useEffect(() => {
|
||||
const currentSerialized = serializeEssayMarkdown(essay);
|
||||
if (markdown !== currentSerialized && markdown) {
|
||||
setEssay(parseEssayMarkdown(markdown));
|
||||
}
|
||||
}, [markdown]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateBlocks = useCallback(
|
||||
(newBlocks: EssayBlock[]) => {
|
||||
const updatedEssay: ParsedEssay = {
|
||||
...essay,
|
||||
blocks: newBlocks,
|
||||
referencedAssetIds: Array.from(
|
||||
new Set(
|
||||
newBlocks.flatMap((b) => {
|
||||
if (b.type === 'photo') return [b.assetId];
|
||||
if (b.type === 'photo-pair') return b.assetIds;
|
||||
return [];
|
||||
})
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
setEssay(updatedEssay);
|
||||
onChange(serializeEssayMarkdown(updatedEssay));
|
||||
},
|
||||
[essay, onChange]
|
||||
);
|
||||
|
||||
// Block manipulation helpers
|
||||
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
|
||||
const targetIdx = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIdx < 0 || targetIdx >= essay.blocks.length) return;
|
||||
|
||||
const newBlocks = [...essay.blocks];
|
||||
const [moved] = newBlocks.splice(index, 1);
|
||||
newBlocks.splice(targetIdx, 0, moved);
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleDeleteBlock = (index: number) => {
|
||||
const newBlocks = essay.blocks.filter((_, i) => i !== index);
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleUpdateBlock = (index: number, updated: EssayBlock) => {
|
||||
const newBlocks = [...essay.blocks];
|
||||
newBlocks[index] = updated;
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleAddBlock = (type: EssayBlock['type']) => {
|
||||
let newBlock: EssayBlock;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
newBlock = { type: 'heading', level: 2, text: 'New Section Heading' };
|
||||
break;
|
||||
case 'paragraph':
|
||||
newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' };
|
||||
break;
|
||||
case 'quote':
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' };
|
||||
break;
|
||||
case 'photo':
|
||||
newBlock = {
|
||||
type: 'photo',
|
||||
assetId: 'sample-asset',
|
||||
caption: 'Photo caption',
|
||||
layout: 'contained',
|
||||
};
|
||||
break;
|
||||
case 'photo-pair':
|
||||
newBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
caption: 'Side by side caption',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
updateBlocks([...essay.blocks, newBlock]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-block-editor" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div
|
||||
className="essay-block-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.04))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.1))',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, marginRight: 'auto' }}>
|
||||
✨ Add Story Block:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
+ Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('heading')}
|
||||
>
|
||||
+ Heading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('quote')}
|
||||
>
|
||||
+ Pullquote
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo')}
|
||||
>
|
||||
+ Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo-pair')}
|
||||
>
|
||||
+ 2-Photo Pair
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{essay.blocks.length === 0 && (
|
||||
<p className="empty-hint" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
No story blocks yet. Use the buttons above to build your photo essay!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{essay.blocks.map((block, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-block-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.03))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.08))',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Block Header / Action Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Move Up"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
disabled={idx === essay.blocks.length - 1}
|
||||
title="Move Down"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon text-danger"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
title="Delete Block"
|
||||
style={{ padding: '2px 6px', color: '#ff4d4f' }}
|
||||
>
|
||||
<Icons.IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render Block Form Controls */}
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<select
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
|
||||
>
|
||||
<option value={1}>H1</option>
|
||||
<option value={2}>H2</option>
|
||||
<option value={3}>H3</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section title..."
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={block.html.replace(/<[^>]+>/g, (t) =>
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* allowed)"
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', resize: 'vertical' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Quote text..."
|
||||
style={{ padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={block.author || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Author / Attribution (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'photo' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: '#222',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset ID or Index"
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', opacity: 0.8 }}>Display Width:</span>
|
||||
<select
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
|
||||
})
|
||||
}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="contained">Contained (68ch)</option>
|
||||
<option value="wide">Wide (1100px)</option>
|
||||
<option value="fullbleed">Fullbleed (100vw)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'photo-pair' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 1 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 2 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
app/admin/components/Icons.tsx
Normal file
267
app/admin/components/Icons.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
size?: number;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.92 0 1.7-.72 1.7-1.63 0-.44-.18-.85-.46-1.15-.27-.31-.44-.72-.44-1.22 0-.91.75-1.67 1.67-1.67h2.2 0c3.16 0 5.83-2.39 6.1-5.55A10.012 10.012 0 0 0 12 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M7 3v18M17 3v18M3 7.5h4M3 12h18M3 16.5h4M17 7.5h4M17 16.5h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<line x1="22" x2="2" y1="6" y2="6" />
|
||||
<line x1="22" x2="2" y1="18" y2="18" />
|
||||
<line x1="6" x2="6" y1="2" y2="22" />
|
||||
<line x1="18" x2="18" y1="2" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4M3 5h4M19 17v4M17 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
|
||||
<line x1="9" x2="9" y1="3" y2="18" />
|
||||
<line x1="15" x2="15" y1="6" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 3v18M3 5h18v14H3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
|
||||
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
|
||||
<path d="M7 21h10M12 3v18M3 7h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" x2="22" y1="12" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" x2="19.07" y1="4.93" y2="19.07" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import AlbumPicker from './AlbumPicker';
|
||||
import AssetPicker from './AssetPicker';
|
||||
import { EssayBlockEditor } from './EssayBlockEditor';
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────
|
||||
const Icons = {
|
||||
@@ -133,6 +134,9 @@ interface Subpage {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
password?: string;
|
||||
enabled?: boolean;
|
||||
essayText?: string;
|
||||
essayFile?: string;
|
||||
sections?: Section[];
|
||||
albums: AlbumEntry[];
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
@@ -319,6 +323,7 @@ function SortableSubpageTile({
|
||||
const totalAlbums =
|
||||
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
||||
const firstThumb = getFirstThumb(sp);
|
||||
const slug = sp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -331,6 +336,19 @@ function SortableSubpageTile({
|
||||
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||
<Icons.Drag />
|
||||
</div>
|
||||
|
||||
{sp.enabled === false && (
|
||||
<span className="subpage-badge-protected" style={{ background: '#e60012', color: '#fff' }} title="Page is disabled">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
|
||||
{sp.password && (
|
||||
<span className="subpage-badge-protected" title="Password protected">
|
||||
<Icons.Lock /> Password
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="subpage-tile-cover">
|
||||
{firstThumb ? (
|
||||
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
||||
@@ -339,11 +357,17 @@ function SortableSubpageTile({
|
||||
<Icons.Folder />
|
||||
</div>
|
||||
)}
|
||||
<div className="subpage-hover-overlay">
|
||||
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subpage-tile-info">
|
||||
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
||||
<div className="subpage-tile-title-row">
|
||||
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
||||
<span className="subpage-tile-slug">/{slug}</span>
|
||||
</div>
|
||||
<span className="subpage-tile-meta">
|
||||
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,6 +384,7 @@ export default function PageBuilder() {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit');
|
||||
const [pickerTarget, setPickerTarget] = useState<{
|
||||
type: 'standalone' | 'subpage' | 'section';
|
||||
subpageIndex?: number;
|
||||
@@ -450,6 +475,9 @@ export default function PageBuilder() {
|
||||
title: sp.title as string | undefined,
|
||||
subtitle: sp.subtitle as string | undefined,
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
|
||||
sections: sp.sections
|
||||
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
|
||||
@@ -463,7 +491,7 @@ export default function PageBuilder() {
|
||||
} else if (raw.subpages && typeof raw.subpages === 'object') {
|
||||
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return { name, albums: parseAlbumEntries(value), sections: undefined };
|
||||
return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true };
|
||||
}
|
||||
const sp = value as Record<string, unknown>;
|
||||
return {
|
||||
@@ -471,6 +499,9 @@ export default function PageBuilder() {
|
||||
title: sp.title as string | undefined,
|
||||
subtitle: sp.subtitle as string | undefined,
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(
|
||||
sp.albums as Array<string | Record<string, string>> | undefined,
|
||||
),
|
||||
@@ -507,6 +538,9 @@ export default function PageBuilder() {
|
||||
if (sp.title) entry.title = sp.title;
|
||||
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
||||
if (sp.password) entry.password = sp.password;
|
||||
if (sp.enabled === false) entry.enabled = false;
|
||||
if (sp.essayText) entry.essayText = sp.essayText;
|
||||
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||
if (sp.grid) entry.grid = sp.grid;
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
@@ -895,7 +929,7 @@ export default function PageBuilder() {
|
||||
{/* Search Bar */}
|
||||
<div className="builder-search-container">
|
||||
<div className="builder-search-wrapper">
|
||||
<span className="builder-search-icon">🔍</span>
|
||||
<span className="builder-search-icon"><Icons.Search /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="builder-search-input"
|
||||
@@ -1051,217 +1085,392 @@ export default function PageBuilder() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Expanded subpage detail */}
|
||||
{/* Expanded subpage detail (Slide-Over Drawer) */}
|
||||
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
|
||||
<div className="subpage-detail">
|
||||
{(() => {
|
||||
const sp = gallery.subpages[expandedSubpage];
|
||||
const spIndex = expandedSubpage;
|
||||
return (
|
||||
<>
|
||||
<div className="subpage-detail-header">
|
||||
<div className="subpage-detail-header-left">
|
||||
<input
|
||||
className="subpage-name-input"
|
||||
value={sp.name}
|
||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||
placeholder="Page name (used for URL)"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="subpage-slug-preview">/{slugify(sp.name)}</span>
|
||||
<div className="subpage-drawer-backdrop" onClick={() => setExpandedSubpage(null)}>
|
||||
<div className="subpage-drawer-container" onClick={(e) => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const sp = gallery.subpages[expandedSubpage];
|
||||
const spIndex = expandedSubpage;
|
||||
return (
|
||||
<>
|
||||
<div className="subpage-drawer-header">
|
||||
<div className="subpage-drawer-title-group">
|
||||
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
|
||||
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
|
||||
</div>
|
||||
<div className="subpage-drawer-header-actions">
|
||||
<div className="segmented-control" style={{ padding: '2px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
|
||||
onClick={() => setDrawerMode('edit')}
|
||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
<Icons.Edit /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setDrawerMode('preview')}
|
||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
<Icons.Search /> Live Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open live page in new tab"
|
||||
>
|
||||
/{slugify(sp.name)} ↗
|
||||
</a>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => setExpandedSubpage(null)}
|
||||
title="Close drawer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subpage-detail-header-right">
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn-icon"
|
||||
title="Preview this page"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
>
|
||||
↗
|
||||
</a>
|
||||
|
||||
<div className="subpage-drawer-body">
|
||||
{drawerMode === 'preview' ? (
|
||||
<div className="drawer-live-preview-panel">
|
||||
<div className="preview-hero-banner">
|
||||
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
|
||||
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{sp.sections && sp.sections.length > 0 ? (
|
||||
sp.sections.map((sec, sIdx) => (
|
||||
<div key={sIdx} className="preview-section-group">
|
||||
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
|
||||
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
|
||||
<div className="preview-albums-grid">
|
||||
{sec.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="preview-albums-grid">
|
||||
{sp.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="subpage-drawer-section">
|
||||
<div className="admin-field">
|
||||
<label>Page Name (URL Identifier)</label>
|
||||
<div className="input-slug-wrapper">
|
||||
<input
|
||||
className="subpage-name-input"
|
||||
value={sp.name}
|
||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||
placeholder="e.g. dubai, travel, weddings"
|
||||
/>
|
||||
<span className="input-slug-preview">/{slugify(sp.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subpage metadata */}
|
||||
<div className="subpage-drawer-section">
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Display Title (optional)</label>
|
||||
<input
|
||||
value={sp.title || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { title: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Display title (defaults to name)"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
value={sp.subtitle || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { subtitle: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Subtitle text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Password Protection (optional)</label>
|
||||
<div className="password-input-wrapper">
|
||||
<span className="password-icon"><Icons.Lock /></span>
|
||||
<input
|
||||
type="password"
|
||||
value={sp.password || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { password: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Leave empty for public access"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Page Visibility Status</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${sp.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => updateSubpage(spIndex, { enabled: sp.enabled === false })}
|
||||
style={{ padding: '10px 14px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderRadius: '8px', cursor: 'pointer' }}
|
||||
>
|
||||
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
|
||||
<span className="toggle-card-title" style={{ fontWeight: 600 }}>
|
||||
{sp.enabled !== false ? '🟢 Page Active (Published)' : '🔴 Page Disabled (Hidden)'}
|
||||
</span>
|
||||
<span className="toggle-card-desc" style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}>
|
||||
{sp.enabled !== false
|
||||
? 'Visible in header menu and reachable via URL'
|
||||
: 'Hidden from navigation menu. Returns 404 if accessed directly.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${sp.enabled !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Page Layout Style</label>
|
||||
<select
|
||||
value={sp.grid?.layout || 'masonry'}
|
||||
onChange={(e) => {
|
||||
const newLayout = e.target.value;
|
||||
updateSubpage(spIndex, {
|
||||
grid: { ...(sp.grid || {}), layout: newLayout },
|
||||
});
|
||||
}}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
|
||||
>
|
||||
<option value="masonry">📷 Masonry Grid (Dynamic Height)</option>
|
||||
<option value="uniform">🔲 Uniform Grid (Square Tiles)</option>
|
||||
<option value="showcase">⭐ Showcase (Featured First Asset)</option>
|
||||
<option value="filmstrip">🎞️ Filmstrip (Horizontal Scroll)</option>
|
||||
<option value="editorial-flow">📰 Editorial Flow</option>
|
||||
<option value="essay">📖 Photo Essay Mode (Storytelling Editor)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||
{(sp.grid?.layout === 'essay' || sp.essayText != null) && (
|
||||
<div className="subpage-drawer-section" style={{ background: 'var(--admin-surface, rgba(255,255,255,0.02))', padding: '1rem', borderRadius: '8px', border: '1px solid var(--admin-border, rgba(255,255,255,0.1))' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>📖 Storytelling & Photo Essay Builder</span>
|
||||
</h4>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.8rem', opacity: 0.75 }}>
|
||||
Compose text, quotes, and fullbleed/wide photos visually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EssayBlockEditor
|
||||
markdown={sp.essayText || ''}
|
||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||
onSelectPhoto={(callback) => {
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
|
||||
// We can pass a photo picker handler if needed
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
{/* Albums (if no sections) with DnD */}
|
||||
{(!sp.sections || sp.sections.length === 0) && (
|
||||
<div className="subpage-albums">
|
||||
<div className="subpage-albums-header">
|
||||
<span>Albums in this Page</span>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() =>
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
|
||||
}
|
||||
>
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSubpageAlbumDragEnd(spIndex)}
|
||||
>
|
||||
<SortableContext
|
||||
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="album-list">
|
||||
{sp.albums.length === 0 && (
|
||||
<p className="empty-hint">No albums added yet. Click + Add Album to pick from Immich.</p>
|
||||
)}
|
||||
{sp.albums.map((album, aIndex) => (
|
||||
<SortableAlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
index={aIndex}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||
onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<div className="subpage-sections">
|
||||
{sp.sections.map((sec, secIndex) => (
|
||||
<div key={secIndex} className="section-card">
|
||||
<div className="section-header">
|
||||
<input
|
||||
className="section-title-input"
|
||||
value={sec.title}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, { title: e.target.value })
|
||||
}
|
||||
placeholder="Section title"
|
||||
/>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => removeSection(spIndex, secIndex)}
|
||||
title="Remove section"
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<input
|
||||
value={sec.description || ''}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, {
|
||||
description: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Section description (optional)"
|
||||
className="section-desc-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="album-list">
|
||||
{sec.albums.map((album, aIndex) => (
|
||||
<AlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||
onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
type: 'section',
|
||||
subpageIndex: spIndex,
|
||||
sectionIndex: secIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
|
||||
<button className="admin-btn admin-btn-xs admin-btn-secondary" onClick={() => addSection(spIndex)}>
|
||||
+ Add Section
|
||||
</button>
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<button
|
||||
className="admin-btn admin-btn-xs admin-btn-secondary"
|
||||
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||
>
|
||||
+ Add Loose Album
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="subpage-drawer-footer">
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => setExpandedSubpage(null)}
|
||||
title="Collapse"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn-icon admin-btn-icon-danger"
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
onClick={() => {
|
||||
removeSubpage(spIndex);
|
||||
setExpandedSubpage(null);
|
||||
}}
|
||||
title="Delete subpage"
|
||||
>
|
||||
🗑
|
||||
<Icons.Trash /> Delete Subpage
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subpage metadata */}
|
||||
<div className="subpage-meta">
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Title (optional)</label>
|
||||
<input
|
||||
value={sp.title || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { title: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Display title (defaults to name)"
|
||||
/>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<label>Subtitle</label>
|
||||
<input
|
||||
value={sp.subtitle || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { subtitle: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Subtitle text"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Password (optional)</label>
|
||||
<input
|
||||
type="password"
|
||||
value={sp.password || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { password: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Leave empty for public"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Albums (if no sections) with DnD */}
|
||||
{(!sp.sections || sp.sections.length === 0) && (
|
||||
<div className="subpage-albums">
|
||||
<div className="subpage-albums-header">
|
||||
<span>Albums</span>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
|
||||
}
|
||||
>
|
||||
+ Add
|
||||
</button>
|
||||
</div>
|
||||
<DndContext
|
||||
sensors={sensors}
|
||||
collisionDetection={closestCenter}
|
||||
onDragEnd={handleSubpageAlbumDragEnd(spIndex)}
|
||||
>
|
||||
<SortableContext
|
||||
items={sp.albums.map((a, i) => `album-${a.id}-${i}`)}
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="album-list">
|
||||
{sp.albums.map((album, aIndex) => (
|
||||
<SortableAlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
index={aIndex}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
onRemove={() => removeSubpageAlbum(spIndex, aIndex)}
|
||||
onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Sections */}
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<div className="subpage-sections">
|
||||
{sp.sections.map((sec, secIndex) => (
|
||||
<div key={secIndex} className="section-card">
|
||||
<div className="section-header">
|
||||
<input
|
||||
className="section-title-input"
|
||||
value={sec.title}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, { title: e.target.value })
|
||||
}
|
||||
placeholder="Section title"
|
||||
/>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => removeSection(spIndex, secIndex)}
|
||||
title="Remove section"
|
||||
>
|
||||
<Icons.Close />
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-field">
|
||||
<input
|
||||
value={sec.description || ''}
|
||||
onChange={(e) =>
|
||||
updateSection(spIndex, secIndex, {
|
||||
description: e.target.value || undefined,
|
||||
})
|
||||
}
|
||||
placeholder="Section description (optional)"
|
||||
className="section-desc-input"
|
||||
/>
|
||||
</div>
|
||||
<div className="album-list">
|
||||
{sec.albums.map((album, aIndex) => (
|
||||
<AlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
album={album}
|
||||
name={getAlbumName(album.id)}
|
||||
count={getAlbumCount(album.id)}
|
||||
thumbnailId={getAlbumThumbnailId(album.id)}
|
||||
onRemove={() => removeSectionAlbum(spIndex, secIndex, aIndex)}
|
||||
onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
setPickerTarget({
|
||||
type: 'section',
|
||||
subpageIndex: spIndex,
|
||||
sectionIndex: secIndex,
|
||||
})
|
||||
}
|
||||
>
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="subpage-actions">
|
||||
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
|
||||
+ Add Section
|
||||
</button>
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={() => setExpandedSubpage(null)}
|
||||
>
|
||||
+ Add Album (outside sections)
|
||||
Done
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
Reference in New Issue
Block a user