/* @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 (
{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
)}
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}
/>
) : (
<>
{showGuestbook && (
{guestbookEntries.length === 0 ? (
No guestbook entries yet. Be the first to say hello.
) : (
guestbookEntries.map((entry) => (
{entry.authorName}
{entry.context || 'Gallery collection'}
{entry.message}
))
)}
)}
{items.length === 0 ? (
setShowAddModal(true)} />
) : (
)}
{showAddModal && (
setShowAddModal(false)} />
)}
>
)}
);
}
ReactDOM.createRoot(document.getElementById('root')).render();