* 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>
352 lines
13 KiB
TypeScript
352 lines
13 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useMemo } from 'react';
|
|
import Image from 'next/image';
|
|
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
|
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
|
import { ProofingModal } from '@/components/ProofingModal';
|
|
import { FadeIn } from '@/components/FadeIn';
|
|
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
|
import type { PhotoItem } from './PhotoGrid';
|
|
import './essay.css';
|
|
|
|
interface EssayViewProps {
|
|
essay: ParsedEssay;
|
|
assets: PhotoItem[];
|
|
title?: string;
|
|
subtitle?: string;
|
|
watermark?: LightboxWatermark;
|
|
}
|
|
|
|
function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayViewProps) {
|
|
const proofing = useProofing();
|
|
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
|
|
|
// Map assetId / index to PhotoItem and its index in `assets`
|
|
const assetMap = useMemo(() => {
|
|
const map = new Map<string, { item: PhotoItem; index: number }>();
|
|
assets.forEach((item, index) => {
|
|
map.set(item.id, { item, index });
|
|
// Also map by 1-based index string ("1", "2") for convenience
|
|
map.set(`${index + 1}`, { item, index });
|
|
});
|
|
return map;
|
|
}, [assets]);
|
|
|
|
const displayTitle = title || essay.frontmatter.title;
|
|
const displaySubtitle = subtitle || essay.frontmatter.subtitle;
|
|
|
|
// Cover photo if specified
|
|
const coverAsset = essay.frontmatter.coverAssetId
|
|
? assetMap.get(essay.frontmatter.coverAssetId)
|
|
: undefined;
|
|
|
|
const renderBlock = (block: EssayBlock, idx: number) => {
|
|
switch (block.type) {
|
|
case 'heading': {
|
|
const Tag = `h${Math.min(block.level + 1, 4)}` as keyof React.JSX.IntrinsicElements;
|
|
return (
|
|
<Tag key={idx} className={`essay-heading essay-heading--${block.level}`}>
|
|
{block.text}
|
|
</Tag>
|
|
);
|
|
}
|
|
|
|
case 'paragraph':
|
|
return (
|
|
<div
|
|
key={idx}
|
|
className="essay-prose"
|
|
dangerouslySetInnerHTML={{ __html: block.html }}
|
|
/>
|
|
);
|
|
|
|
case 'quote':
|
|
return (
|
|
<blockquote key={idx} className="essay-quote">
|
|
<div dangerouslySetInnerHTML={{ __html: block.text }} />
|
|
{block.author && <span className="essay-quote__author">— {block.author}</span>}
|
|
</blockquote>
|
|
);
|
|
|
|
case 'photo': {
|
|
const resolved = assetMap.get(block.assetId);
|
|
if (!resolved) return null;
|
|
const { item, index } = resolved;
|
|
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
|
|
|
return (
|
|
<FadeIn key={idx}>
|
|
<figure className={`essay-figure essay-figure--${block.layout}`}>
|
|
<div
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => setLightboxIndex(index)}
|
|
style={{
|
|
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
|
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
|
}}
|
|
>
|
|
<Image
|
|
src={item.previewUrl || item.thumbUrl}
|
|
alt={block.caption || ''}
|
|
fill
|
|
sizes={
|
|
block.layout === 'fullbleed'
|
|
? '100vw'
|
|
: block.layout === 'wide'
|
|
? '(max-width: 1100px) 100vw, 1100px'
|
|
: '(max-width: 768px) 100vw, 680px'
|
|
}
|
|
loading={idx < 4 ? 'eager' : 'lazy'}
|
|
{...(item.blurDataURL
|
|
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
|
|
: {})}
|
|
/>
|
|
|
|
{proofing && (
|
|
<button
|
|
type="button"
|
|
className="photo-grid__fav-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
proofing.toggleFavorite(item.id);
|
|
}}
|
|
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
|
style={{
|
|
position: 'absolute',
|
|
top: '12px',
|
|
right: '12px',
|
|
zIndex: 5,
|
|
width: '44px',
|
|
height: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill={isFav ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
|
<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>
|
|
</button>
|
|
)}
|
|
</div>
|
|
{block.caption && (
|
|
<figcaption
|
|
className="essay-figcaption"
|
|
dangerouslySetInnerHTML={{ __html: block.caption }}
|
|
/>
|
|
)}
|
|
</figure>
|
|
</FadeIn>
|
|
);
|
|
}
|
|
|
|
case 'photo-pair': {
|
|
const res1 = assetMap.get(block.assetIds[0]);
|
|
const res2 = assetMap.get(block.assetIds[1]);
|
|
if (!res1 || !res2) return null;
|
|
|
|
return (
|
|
<FadeIn key={idx}>
|
|
<div className="essay-figure essay-figure--wide">
|
|
<div className="essay-pair-grid">
|
|
{[res1, res2].map(({ item, index }, pIdx) => {
|
|
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
|
return (
|
|
<div
|
|
key={pIdx}
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => setLightboxIndex(index)}
|
|
style={{
|
|
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
|
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
|
}}
|
|
>
|
|
<Image
|
|
src={item.previewUrl || item.thumbUrl}
|
|
alt=""
|
|
fill
|
|
sizes="(max-width: 640px) 100vw, 550px"
|
|
loading="lazy"
|
|
{...(item.blurDataURL
|
|
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
|
|
: {})}
|
|
/>
|
|
{proofing && (
|
|
<button
|
|
type="button"
|
|
className="photo-grid__fav-btn"
|
|
onClick={(e) => {
|
|
e.stopPropagation();
|
|
proofing.toggleFavorite(item.id);
|
|
}}
|
|
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
|
style={{
|
|
position: 'absolute',
|
|
top: '12px',
|
|
right: '12px',
|
|
zIndex: 5,
|
|
width: '44px',
|
|
height: '44px',
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
justifyContent: 'center',
|
|
background: 'none',
|
|
border: 'none',
|
|
cursor: 'pointer',
|
|
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
|
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
|
}}
|
|
>
|
|
<svg viewBox="0 0 24 24" width="24" height="24" fill={isFav ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
|
<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>
|
|
</button>
|
|
)}
|
|
</div>
|
|
);
|
|
})}
|
|
</div>
|
|
{block.caption && (
|
|
<figcaption
|
|
className="essay-figcaption"
|
|
dangerouslySetInnerHTML={{ __html: block.caption }}
|
|
/>
|
|
)}
|
|
</div>
|
|
</FadeIn>
|
|
);
|
|
}
|
|
|
|
default:
|
|
return null;
|
|
}
|
|
};
|
|
|
|
return (
|
|
<div className="essay-container">
|
|
<header className="essay-header">
|
|
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
|
|
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
|
|
{(essay.frontmatter.author || essay.frontmatter.date) && (
|
|
<div className="essay-header__meta">
|
|
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
|
|
</div>
|
|
)}
|
|
</header>
|
|
|
|
{coverAsset && (
|
|
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
|
|
<div
|
|
className="essay-image-wrapper photo-grid__item"
|
|
onClick={() => setLightboxIndex(coverAsset.index)}
|
|
style={{
|
|
...(coverAsset.item.dominantColor
|
|
? { backgroundColor: coverAsset.item.dominantColor }
|
|
: {}),
|
|
...(coverAsset.item.aspectRatio
|
|
? { aspectRatio: `${coverAsset.item.aspectRatio}` }
|
|
: {}),
|
|
}}
|
|
>
|
|
<Image
|
|
src={coverAsset.item.previewUrl || coverAsset.item.thumbUrl}
|
|
alt=""
|
|
fill
|
|
priority
|
|
sizes="(max-width: 1100px) 100vw, 1100px"
|
|
{...(coverAsset.item.blurDataURL
|
|
? { placeholder: 'blur' as const, blurDataURL: coverAsset.item.blurDataURL }
|
|
: {})}
|
|
/>
|
|
</div>
|
|
</figure>
|
|
)}
|
|
|
|
<main>{essay.blocks.map(renderBlock)}</main>
|
|
|
|
{proofing && proofing.favorites.size > 0 && (
|
|
<div
|
|
className="proofing-sticky-bar"
|
|
style={{
|
|
position: 'fixed',
|
|
bottom: '24px',
|
|
right: '24px',
|
|
zIndex: 990,
|
|
display: 'flex',
|
|
alignItems: 'center',
|
|
gap: '12px',
|
|
padding: '8px 16px',
|
|
borderRadius: '30px',
|
|
background: 'var(--bg-surface, #1e1e1e)',
|
|
color: 'var(--text-primary, #ffffff)',
|
|
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
|
|
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
|
backdropFilter: 'blur(8px)',
|
|
}}
|
|
>
|
|
<button
|
|
type="button"
|
|
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
|
style={{
|
|
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
|
|
color: '#fff',
|
|
border: 'none',
|
|
padding: '6px 14px',
|
|
borderRadius: '20px',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
}}
|
|
>
|
|
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
|
|
</button>
|
|
<button
|
|
type="button"
|
|
onClick={() => proofing.setIsModalOpen(true)}
|
|
style={{
|
|
background: 'none',
|
|
border: 'none',
|
|
color: 'inherit',
|
|
fontSize: '0.85rem',
|
|
fontWeight: 500,
|
|
cursor: 'pointer',
|
|
textDecoration: 'underline',
|
|
}}
|
|
>
|
|
Share & Export
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
<ProofingModal />
|
|
|
|
{lightboxIndex !== null && (
|
|
<Lightbox
|
|
assets={assets}
|
|
currentIndex={lightboxIndex}
|
|
onClose={() => setLightboxIndex(null)}
|
|
onNext={() => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))}
|
|
onPrev={() => setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null))}
|
|
watermark={watermark}
|
|
/>
|
|
)}
|
|
</div>
|
|
);
|
|
}
|
|
|
|
export function EssayView(props: EssayViewProps) {
|
|
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
|
|
|
return (
|
|
<ProofingProvider albumTokens={albumTokens}>
|
|
<EssayViewContent {...props} />
|
|
</ProofingProvider>
|
|
);
|
|
}
|