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,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import type { GridConfig } from '@/lib/config';
|
||||
import type { LightboxWatermark } from '@/components/Lightbox';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
|
||||
@@ -16,6 +17,7 @@ interface AlbumDetailViewProps {
|
||||
subtitle?: string;
|
||||
heroImageUrl?: string;
|
||||
heroBlurDataURL?: string;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
@@ -28,6 +30,7 @@ export function AlbumDetailView({
|
||||
subtitle,
|
||||
heroImageUrl,
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
}: AlbumDetailViewProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -58,7 +61,7 @@ export function AlbumDetailView({
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
351
app/[...path]/EssayView.tsx
Normal file
351
app/[...path]/EssayView.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'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>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,10 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Lightbox } from '@/components/Lightbox';
|
||||
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -32,8 +34,9 @@ export interface PhotoItem {
|
||||
|
||||
interface PhotoGridProps {
|
||||
assets: PhotoItem[];
|
||||
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
||||
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
|
||||
gridStyle?: React.CSSProperties;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
||||
@@ -49,36 +52,42 @@ function buildPhotoHash(index: number): string {
|
||||
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
|
||||
}
|
||||
|
||||
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
|
||||
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
|
||||
const proofing = useProofing();
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const displayedAssets = useMemo(() => {
|
||||
if (proofing && proofing.isFilterActive) {
|
||||
return assets.filter((a) => proofing.isFavorite(a.id));
|
||||
}
|
||||
return assets;
|
||||
}, [assets, proofing]);
|
||||
|
||||
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
|
||||
useEffect(() => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < assets.length) {
|
||||
// state from URL hash on mount only (equivalent to useState lazy initializer for
|
||||
// client-only browser APIs).
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
// state from URL hash on mount only
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLightboxIndex(idx);
|
||||
}
|
||||
}, [assets.length]);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// ── Sync URL hash when lightbox state changes ─────────────────
|
||||
useEffect(() => {
|
||||
if (lightboxIndex !== null && lightboxIndex < assets.length) {
|
||||
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
|
||||
const hash = buildPhotoHash(lightboxIndex);
|
||||
// Use replaceState to avoid flooding history with every prev/next tap
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.replaceState(null, '', hash);
|
||||
}
|
||||
}
|
||||
}, [lightboxIndex, assets.length]);
|
||||
}, [lightboxIndex, displayedAssets.length]);
|
||||
|
||||
// ── Listen for browser back/forward (hash change) ─────────────
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < assets.length) {
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
setLightboxIndex(idx);
|
||||
} else {
|
||||
setLightboxIndex(null);
|
||||
@@ -87,27 +96,25 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, [assets.length]);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const openLightbox = useCallback((index: number) => {
|
||||
setLightboxIndex(index);
|
||||
// Push state (not replace) so the user can press Back to close
|
||||
window.history.pushState(null, '', buildPhotoHash(index));
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxIndex(null);
|
||||
// Remove hash from URL cleanly
|
||||
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null));
|
||||
}, [assets.length]);
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null));
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null));
|
||||
}, [assets.length]);
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
@@ -128,7 +135,6 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Prevent body scroll when lightbox is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
@@ -137,60 +143,105 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
};
|
||||
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
|
||||
|
||||
// Memoize grid items to prevent re-renders of the entire grid (which includes
|
||||
// Image and FadeIn components) when navigating the lightbox.
|
||||
const gridItems = useMemo(() => {
|
||||
return assets.map((asset, index) => (
|
||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
||||
<div
|
||||
className={`photo-grid__item${
|
||||
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
||||
}`}
|
||||
onClick={() => openLightbox(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openLightbox(index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View photo ${index + 1}`}
|
||||
aria-haspopup="dialog"
|
||||
style={{
|
||||
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
||||
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
|
||||
? { aspectRatio: `${asset.aspectRatio}` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={asset.thumbUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||
loading={index < 6 ? 'eager' : 'lazy'}
|
||||
{...(asset.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
{asset.type === 'video' && (
|
||||
<div className="photo-grid__item-play" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
|
||||
<polygon points="10,8 10,16 17,12" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(asset.camera || asset.lens) && (
|
||||
<div className="photo-grid__item-exif" aria-hidden="true">
|
||||
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
));
|
||||
}, [assets, layout, openLightbox]);
|
||||
return displayedAssets.map((asset, index) => {
|
||||
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
|
||||
|
||||
return (
|
||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
||||
<div
|
||||
className={`photo-grid__item${
|
||||
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
|
||||
}`}
|
||||
onClick={() => openLightbox(index)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
e.preventDefault();
|
||||
openLightbox(index);
|
||||
}
|
||||
}}
|
||||
role="button"
|
||||
tabIndex={0}
|
||||
aria-label={`View photo ${index + 1}`}
|
||||
aria-haspopup="dialog"
|
||||
style={{
|
||||
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
|
||||
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
|
||||
? { aspectRatio: `${asset.aspectRatio}` }
|
||||
: {}),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={asset.thumbUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
|
||||
loading={index < 6 ? 'eager' : 'lazy'}
|
||||
{...(asset.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{proofing && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-grid__fav-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
proofing.toggleFavorite(asset.id);
|
||||
}}
|
||||
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
title={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
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))',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<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>
|
||||
)}
|
||||
|
||||
{asset.type === 'video' && (
|
||||
<div className="photo-grid__item-play" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
|
||||
<polygon points="10,8 10,16 17,12" fill="white" />
|
||||
</svg>
|
||||
</div>
|
||||
)}
|
||||
{(asset.camera || asset.lens) && (
|
||||
<div className="photo-grid__item-exif" aria-hidden="true">
|
||||
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
});
|
||||
}, [displayedAssets, layout, openLightbox, proofing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
{gridItems}
|
||||
</div>
|
||||
|
||||
{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}
|
||||
assets={displayedAssets}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onNext={goNext}
|
||||
onPrev={goPrev}
|
||||
watermark={watermark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotoGrid(props: PhotoGridProps) {
|
||||
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<PhotoGridInner {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
157
app/[...path]/essay.css
Normal file
157
app/[...path]/essay.css
Normal file
@@ -0,0 +1,157 @@
|
||||
/* Photo Essay & Storytelling Mode Styles */
|
||||
|
||||
.essay-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 6rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Header & Cover */
|
||||
.essay-header {
|
||||
max-width: 68ch;
|
||||
margin: 0 auto 3.5rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.essay-header__title {
|
||||
font-family: var(--font-heading, serif);
|
||||
font-size: clamp(2.2rem, 5vw, 3.8rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.essay-header__subtitle {
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.essay-header__meta {
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Reading column */
|
||||
.essay-prose {
|
||||
max-width: 68ch;
|
||||
margin: 0 auto 2.5rem auto;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-size: clamp(1.05rem, 2vw, 1.2rem);
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary, rgba(255, 255, 255, 0.92));
|
||||
}
|
||||
|
||||
.essay-prose p {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.essay-heading {
|
||||
font-family: var(--font-heading, serif);
|
||||
max-width: 68ch;
|
||||
margin: 3.5rem auto 1.5rem auto;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.essay-heading--1 { font-size: clamp(1.8rem, 3.5vw, 2.5rem); }
|
||||
.essay-heading--2 { font-size: clamp(1.5rem, 3vw, 2rem); }
|
||||
.essay-heading--3 { font-size: clamp(1.25rem, 2.5vw, 1.6rem); }
|
||||
|
||||
/* Pullquote */
|
||||
.essay-quote {
|
||||
max-width: 68ch;
|
||||
margin: 3.5rem auto;
|
||||
padding: 1.5rem 2rem;
|
||||
border-left: 3px solid var(--accent, #e60012);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-family: var(--font-heading, serif);
|
||||
font-style: italic;
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.35rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.essay-quote__author {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-style: normal;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Essay Photos & Layout Modes */
|
||||
.essay-figure {
|
||||
margin: 3.5rem auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.essay-figure--contained {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.essay-figure--wide {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.essay-figure--fullbleed {
|
||||
max-width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-right: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.essay-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius, 0px);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.essay-image-wrapper img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.essay-image-wrapper:hover img {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.essay-figcaption {
|
||||
max-width: 68ch;
|
||||
margin: 0.75rem auto 0 auto;
|
||||
text-align: center;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Side by side photo pair */
|
||||
.essay-pair-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 3.5rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.essay-pair-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay';
|
||||
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
const slug = path[0];
|
||||
let title = slug;
|
||||
let subtitle = '';
|
||||
|
||||
if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else if (immich.isSubpageSlug(slug)) {
|
||||
let description: string | undefined = undefined;
|
||||
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
if (result) {
|
||||
// Single album → use album name; multiple → use subpage name
|
||||
if (result.subpage.subtitle) {
|
||||
description = result.subpage.subtitle;
|
||||
}
|
||||
if (result.albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||
if (album) {
|
||||
@@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
title = result.subpage.name;
|
||||
title = result.subpage.title || result.subpage.name;
|
||||
}
|
||||
}
|
||||
} else if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
if (album) {
|
||||
@@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: { title, images: [ogUrl] },
|
||||
twitter: { card: 'summary_large_image', title, images: [ogUrl] },
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
openGraph: {
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +218,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
gridStyle={buildGridStyle(spGrid)}
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
backLinkLabel={`Back to ${subpageName}`}
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
const { albums } = result;
|
||||
|
||||
// ── Photo Essay / Storytelling Mode ───────────────────────
|
||||
const isEssay =
|
||||
result.subpage.grid?.layout === 'essay' ||
|
||||
!!result.subpage.essayFile ||
|
||||
!!result.subpage.essayText;
|
||||
|
||||
if (isEssay) {
|
||||
let essayParsed = result.subpage.essayText
|
||||
? parseEssayMarkdown(result.subpage.essayText)
|
||||
: result.subpage.essayFile
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
|
||||
// Fallback structured essay if layout: 'essay' is set without custom markdown file
|
||||
if (!essayParsed) {
|
||||
essayParsed = {
|
||||
frontmatter: {
|
||||
title: result.subpage.title || result.subpage.name,
|
||||
subtitle: result.subpage.subtitle,
|
||||
},
|
||||
blocks: albums.flatMap((a) => [
|
||||
{ type: 'heading' as const, level: 2, text: a.albumName },
|
||||
...a.assets.map((asset) => ({
|
||||
type: 'photo' as const,
|
||||
assetId: encodeAssetId(asset.id),
|
||||
caption: asset.exifInfo?.description || undefined,
|
||||
layout: 'contained' as const,
|
||||
})),
|
||||
]),
|
||||
referencedAssetIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<EssayView
|
||||
essay={essayParsed}
|
||||
assets={images}
|
||||
title={result.subpage.title || result.subpage.name}
|
||||
subtitle={result.subpage.subtitle}
|
||||
watermark={config.watermark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Single album → full-bleed (skip album grid) ──────────
|
||||
if (albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
|
||||
@@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
subtitle={result.subpage.subtitle}
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
gridStyle={buildGridStyle()}
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user