/* @component-map * App — Main container, view switching between grid and slideshow [app.jsx] * ArtworkGrid — Grid browse view with artwork cards [components/ArtworkGrid.jsx] * Slideshow — Full-screen slideshow with Ken Burns effect [components/Slideshow.jsx] * AddArtworkModal — Modal form for uploading new artworks [components/AddArtworkModal.jsx] * EmptyState — Friendly empty state when no artworks exist [components/EmptyState.jsx] * @end-component-map */ import { useEffect, useMemo, useRef, useState } from 'react'; import { generatePDF, useCollection, useReactions } from '@deplixo/sdk'; import { ArtworkGrid } from './components/ArtworkGrid.jsx'; import { Slideshow } from './components/Slideshow.jsx'; import { AddArtworkModal } from './components/AddArtworkModal.jsx'; import { EmptyState } from './components/EmptyState.jsx'; function createGuestId() { if (typeof crypto !== 'undefined' && crypto.randomUUID) { return `guest-${crypto.randomUUID()}`; } return `guest-${Math.random().toString(36).slice(2, 10)}${Date.now().toString(36)}`; } function getSafeGuestName(name) { const cleaned = String(name || '').trim().replace(/\s+/g, ' '); return cleaned.slice(0, 32); } function getArtworkId(artwork) { return artwork?.id || artwork?._id || artwork?.artworkId; } function safeFilename(value) { return String(value || 'artwork') .trim() .replace(/[^a-z0-9-_]+/gi, '_') .replace(/_+/g, '_') .replace(/^_|_$/g, '') .slice(0, 64) || 'artwork'; } function normalizeArtworkList(items) { return (items || []).map((item, index) => { const artworkId = getArtworkId(item) || `artwork-${index + 1}`; return { ...item, artworkId, title: item.title || item.name || 'Untitled', artist: item.artist || item.creator || 'Unknown artist', medium: item.medium || item.materials || '', year: item.year || item.date || '', description: item.description || item.statement || '', }; }); } function getQrUrl(artworkId) { const encoded = encodeURIComponent(String(artworkId || '')); return `https://api.qrserver.com/v1/create-qr-code/?size=120x120&data=${encoded}`; } function PopularPiecesChart({ items }) { const reactionData = useMemo(() => { return (items || []) .map((item) => { const artworkId = getArtworkId(item); if (!artworkId) return null; return { item, artworkId, title: item.title || item.name || 'Untitled' }; }) .filter(Boolean); }, [items]); const reactionEntries = reactionData.map(({ item, artworkId, title }) => { const { counts } = useReactions(artworkId); const totalReactions = Object.values(counts || {}).reduce((sum, value) => sum + (Number(value) || 0), 0); return { item, title, artworkId, totalReactions }; }); const topPieces = reactionEntries.sort((a, b) => b.totalReactions - a.totalReactions).slice(0, 5); if (!topPieces.length) return null; const max = Math.max(...topPieces.map((piece) => piece.totalReactions), 1); return (

Most popular pieces

Ranked by total reactions from visitors.

{topPieces.map((piece, index) => (
#{index + 1}
{piece.title}
{piece.totalReactions} reaction{piece.totalReactions === 1 ? '' : 's'}
))}
); } function PdfCatalogExport({ items }) { const reportRef = useRef(null); const [includeQrCodes, setIncludeQrCodes] = useState(true); const [exporting, setExporting] = useState(false); const catalogItems = useMemo(() => normalizeArtworkList(items), [items]); const handleExport = async () => { if (!reportRef.current || exporting) return; setExporting(true); try { await generatePDF(reportRef.current, { filename: `gallery-catalog-${new Date().toISOString().slice(0, 10)}.pdf`, margin: 10, }); } finally { setExporting(false); } }; return (

PDF catalog export

Generate a printable catalog for the full collection.

{catalogItems.length} artwork{catalogItems.length === 1 ? '' : 's'} in catalog

Gallery Catalog

{catalogItems.length} artwork{catalogItems.length === 1 ? '' : 's'} · Generated {new Date().toLocaleString()}

{catalogItems.map((artwork, index) => (
#{index + 1}

{artwork.title}

{artwork.artist}
{includeQrCodes && artwork.artworkId && (
{`QR
QR
)}
Year: {artwork.year || '—'}
Medium: {artwork.medium || '—'}
ID: {artwork.artworkId}
{artwork.tags &&
Tags: {Array.isArray(artwork.tags) ? artwork.tags.join(', ') : String(artwork.tags)}
}
{artwork.description ? (

{artwork.description}

) : (

No description provided.

)}
))}
); } function App() { const { items, loading, add, remove, update } = useCollection('artworks', { personal: true }); const { items: identityItems, loading: identityLoading, add: addIdentity, update: updateIdentity } = useCollection('guest_identity', { personal: true }); const { items: favoriteItems, loading: favoritesLoading, add: addFavorite, remove: removeFavorite } = useCollection('guest_favorites', { personal: true }); const { items: guestbookItems, loading: guestbookLoading, add: addGuestbook } = useCollection('guestbook_comments', { personal: true }); const [view, setView] = useState('grid'); const [showAddModal, setShowAddModal] = useState(false); const [slideshowStart, setSlideshowStart] = useState(0); const [guestIdentity, setGuestIdentity] = useState(null); const [identityName, setIdentityName] = useState(''); const [identityDraft, setIdentityDraft] = useState(''); const [identityReady, setIdentityReady] = useState(false); const [showGuestbook, setShowGuestbook] = useState(false); const [guestbookDraft, setGuestbookDraft] = useState(''); const [guestbookContext, setGuestbookContext] = useState(''); const startSlideshow = (index = 0) => { setSlideshowStart(index); setView('slideshow'); }; useEffect(() => { if (identityLoading || identityReady) return; const existing = identityItems?.[0]; if (existing) { setGuestIdentity(existing); setIdentityName(existing.displayName || ''); setIdentityDraft(existing.displayName || ''); setIdentityReady(true); return; } const created = { guestId: createGuestId(), displayName: '', createdAt: new Date().toISOString(), updatedAt: new Date().toISOString(), }; addIdentity(created); setGuestIdentity(created); setIdentityName(''); setIdentityDraft(''); setIdentityReady(true); }, [identityItems, identityLoading, identityReady, addIdentity]); const displayName = useMemo(() => { return getSafeGuestName(identityName) || 'Guest'; }, [identityName]); const favoriteMap = useMemo(() => { const map = new Map(); (favoriteItems || []).forEach((item) => { const artworkId = item.artworkId || item.targetId; if (artworkId) map.set(artworkId, item); }); return map; }, [favoriteItems]); const favoriteCounts = useMemo(() => { const counts = {}; (favoriteItems || []).forEach((item) => { const artworkId = item.artworkId || item.targetId; if (!artworkId) return; counts[artworkId] = (counts[artworkId] || 0) + 1; }); return counts; }, [favoriteItems]); const guestbookEntries = useMemo(() => { return (guestbookItems || []) .map((entry) => ({ ...entry, createdAt: entry.createdAt || entry.updatedAt || '', authorName: getSafeGuestName(entry.displayName || entry.authorName || '') || 'Guest', })) .sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime()); }, [guestbookItems]); const isFavorited = (artwork) => { const artworkId = getArtworkId(artwork); return artworkId ? favoriteMap.has(artworkId) : false; }; const toggleFavorite = (artwork) => { const artworkId = getArtworkId(artwork); if (!artworkId) return; const current = guestIdentity || identityItems?.[0]; const visitorId = current?.guestId || current?.id || current?._id; if (!visitorId) return; const existingFavorite = favoriteMap.get(artworkId); if (existingFavorite) { removeFavorite(existingFavorite.id || existingFavorite._id || `${visitorId}-${artworkId}`); return; } addFavorite({ guestId: visitorId, artworkId, targetId: artworkId, createdAt: new Date().toISOString(), }); }; const saveIdentityName = () => { const nextName = getSafeGuestName(identityDraft); const current = guestIdentity || identityItems?.[0]; const nextIdentity = { ...(current || { guestId: createGuestId(), createdAt: new Date().toISOString() }), displayName: nextName, updatedAt: new Date().toISOString(), }; if (current) { updateIdentity(current.id || current._id || current.guestId, nextIdentity); } else { addIdentity(nextIdentity); } setGuestIdentity(nextIdentity); setIdentityName(nextName); setIdentityReady(true); }; const handlePostGuestbook = async () => { const message = guestbookDraft.trim(); if (!message) return; const current = guestIdentity || identityItems?.[0]; const visitorId = current?.guestId || current?.id || current?._id || createGuestId(); const authorName = getSafeGuestName(current?.displayName || identityName || identityDraft) || 'Guest'; await addGuestbook({ guestId: visitorId, displayName: authorName, message, context: guestbookContext.trim() || 'Gallery collection', artworkId: null, createdAt: new Date().toISOString(), }); setGuestbookDraft(''); }; if (loading || identityLoading || favoritesLoading || guestbookLoading || !identityReady) { return (
🖼️
Preparing your gallery…
); } return (
{view === 'slideshow' && items.length > 0 ? ( setView('grid')} onToggleFavorite={toggleFavorite} isFavorited={isFavorited} favoriteCounts={favoriteCounts} /> ) : ( <>

Gallery

{items.length} artwork{items.length !== 1 ? 's' : ''}
Visitor setIdentityDraft(e.target.value)} onBlur={saveIdentityName} onKeyDown={(e) => { if (e.key === 'Enter') { e.preventDefault(); saveIdentityName(); e.currentTarget.blur(); } }} placeholder={displayName} aria-label="Optional visitor display name" />
{items.length > 0 && ( )}
{showGuestbook && (

Guestbook

Leave a note for the collection or an artwork.

setGuestbookContext(e.target.value)} placeholder="Collection context (e.g. Spring Show)" aria-label="Guestbook context" />