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:
Ralksta
2026-08-07 11:08:04 +02:00
committed by GitHub
parent 716ae14b0f
commit c9036fdc94
38 changed files with 5872 additions and 519 deletions

View File

@@ -15,6 +15,7 @@ jobs:
permissions: permissions:
contents: read contents: read
packages: write packages: write
security-events: write
steps: steps:
- name: Checkout repository - name: Checkout repository
@@ -47,6 +48,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }} type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push Docker image - name: Build and push Docker image
id: build
uses: docker/build-push-action@v7 uses: docker/build-push-action@v7
with: with:
context: . context: .
@@ -55,3 +57,17 @@ jobs:
labels: ${{ steps.meta.outputs.labels }} labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha cache-from: type=gha
cache-to: type=gha,mode=max cache-to: type=gha,mode=max
- name: Scan image for vulnerabilities
uses: aquasecurity/trivy-action@0.29.0
with:
image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
format: sarif
output: trivy-results.sarif
severity: CRITICAL,HIGH
exit-code: '0'
- name: Upload scan results to GitHub Security tab
uses: github/codeql-action/upload-sarif@v3
with:
sarif_file: trivy-results.sarif

View File

@@ -52,10 +52,12 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
| **Album scope** | Only albums in `gallery.yaml` are accessible | | **Album scope** | Only albums in `gallery.yaml` are accessible |
| **Password protection** | Per-subpage password support | | **Password protection** | Per-subpage password support |
| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | | **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) |
| **Vulnerability scanning** | Docker image scanned with Trivy on every release, results in the GitHub Security tab |
- Health check endpoint at `GET /api/health` - Health check endpoint at `GET /api/health`
- In-memory caching with configurable TTL - In-memory caching with configurable TTL
- Standalone Docker image — multi-stage, non-root, ~150 MB - Standalone Docker image — multi-stage, non-root, ~150 MB
- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
</details> </details>

View File

@@ -3,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid';
import { PhotoGrid } from './PhotoGrid'; import { PhotoGrid } from './PhotoGrid';
import { BackLink } from '@/components/BackLink'; import { BackLink } from '@/components/BackLink';
import type { GridConfig } from '@/lib/config'; import type { GridConfig } from '@/lib/config';
import type { LightboxWatermark } from '@/components/Lightbox';
import Image from 'next/image'; import Image from 'next/image';
import React from 'react'; import React from 'react';
@@ -16,6 +17,7 @@ interface AlbumDetailViewProps {
subtitle?: string; subtitle?: string;
heroImageUrl?: string; heroImageUrl?: string;
heroBlurDataURL?: string; heroBlurDataURL?: string;
watermark?: LightboxWatermark;
} }
export function AlbumDetailView({ export function AlbumDetailView({
@@ -28,6 +30,7 @@ export function AlbumDetailView({
subtitle, subtitle,
heroImageUrl, heroImageUrl,
heroBlurDataURL, heroBlurDataURL,
watermark,
}: AlbumDetailViewProps) { }: AlbumDetailViewProps) {
return ( return (
<> <>
@@ -58,7 +61,7 @@ export function AlbumDetailView({
{images.length} {images.length === 1 ? 'photo' : 'photos'} {images.length} {images.length === 1 ? 'photo' : 'photos'}
</p> </p>
</div> </div>
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} /> <PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
</> </>
); );
} }

351
app/[...path]/EssayView.tsx Normal file
View 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>
);
}

View File

@@ -10,8 +10,10 @@
import { useState, useCallback, useEffect, useMemo } from 'react'; import { useState, useCallback, useEffect, useMemo } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Lightbox } from '@/components/Lightbox'; import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn'; import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
export interface PhotoItem { export interface PhotoItem {
id: string; id: string;
@@ -32,8 +34,9 @@ export interface PhotoItem {
interface PhotoGridProps { interface PhotoGridProps {
assets: PhotoItem[]; assets: PhotoItem[];
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow'; layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
gridStyle?: React.CSSProperties; gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
} }
/** Parse `#photo-N` from a hash string. Returns index or null. */ /** 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 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 [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) ── // ── Initial hash check (Client-only to avoid hydration mismatch) ──
useEffect(() => { useEffect(() => {
const idx = parsePhotoHash(window.location.hash); const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) { if (idx !== null && idx < displayedAssets.length) {
// state from URL hash on mount only (equivalent to useState lazy initializer for // state from URL hash on mount only
// client-only browser APIs).
// eslint-disable-next-line react-hooks/set-state-in-effect // eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(idx); setLightboxIndex(idx);
} }
}, [assets.length]); }, [displayedAssets.length]);
// ── Sync URL hash when lightbox state changes ───────────────── // ── Sync URL hash when lightbox state changes ─────────────────
useEffect(() => { useEffect(() => {
if (lightboxIndex !== null && lightboxIndex < assets.length) { if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
const hash = buildPhotoHash(lightboxIndex); const hash = buildPhotoHash(lightboxIndex);
// Use replaceState to avoid flooding history with every prev/next tap
if (window.location.hash !== hash) { if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash); window.history.replaceState(null, '', hash);
} }
} }
}, [lightboxIndex, assets.length]); }, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward (hash change) ───────────── // ── Listen for browser back/forward (hash change) ─────────────
useEffect(() => { useEffect(() => {
const handleHashChange = () => { const handleHashChange = () => {
const idx = parsePhotoHash(window.location.hash); const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) { if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx); setLightboxIndex(idx);
} else { } else {
setLightboxIndex(null); setLightboxIndex(null);
@@ -87,27 +96,25 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
window.addEventListener('hashchange', handleHashChange); window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange);
}, [assets.length]); }, [displayedAssets.length]);
const openLightbox = useCallback((index: number) => { const openLightbox = useCallback((index: number) => {
setLightboxIndex(index); setLightboxIndex(index);
// Push state (not replace) so the user can press Back to close
window.history.pushState(null, '', buildPhotoHash(index)); window.history.pushState(null, '', buildPhotoHash(index));
}, []); }, []);
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightboxIndex(null); setLightboxIndex(null);
// Remove hash from URL cleanly
window.history.pushState(null, '', window.location.pathname + window.location.search); window.history.pushState(null, '', window.location.pathname + window.location.search);
}, []); }, []);
const goNext = useCallback(() => { const goNext = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null)); setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null));
}, [assets.length]); }, [displayedAssets.length]);
const goPrev = useCallback(() => { const goPrev = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null)); setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
}, [assets.length]); }, [displayedAssets.length]);
// Keyboard navigation // Keyboard navigation
useEffect(() => { useEffect(() => {
@@ -128,7 +135,6 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
}; };
document.addEventListener('keydown', handleKeyDown); document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden'; document.body.style.overflow = 'hidden';
return () => { return () => {
@@ -137,60 +143,105 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
}; };
}, [lightboxIndex, closeLightbox, goNext, goPrev]); }, [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(() => { const gridItems = useMemo(() => {
return assets.map((asset, index) => ( return displayedAssets.map((asset, index) => {
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}> const isFav = proofing ? proofing.isFavorite(asset.id) : false;
<div
className={`photo-grid__item${ return (
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : '' <FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
}`} <div
onClick={() => openLightbox(index)} className={`photo-grid__item${
onKeyDown={(e) => { layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
if (e.key === 'Enter' || e.key === ' ') { }`}
e.preventDefault(); onClick={() => openLightbox(index)}
openLightbox(index); onKeyDown={(e) => {
} if (e.key === 'Enter' || e.key === ' ') {
}} e.preventDefault();
role="button" openLightbox(index);
tabIndex={0} }
aria-label={`View photo ${index + 1}`} }}
aria-haspopup="dialog" role="button"
style={{ tabIndex={0}
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), aria-label={`View photo ${index + 1}`}
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio aria-haspopup="dialog"
? { aspectRatio: `${asset.aspectRatio}` } style={{
: {}), ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
}} ...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
> ? { aspectRatio: `${asset.aspectRatio}` }
<Image : {}),
src={asset.thumbUrl} position: 'relative',
alt="" }}
fill >
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw" <Image
loading={index < 6 ? 'eager' : 'lazy'} src={asset.thumbUrl}
{...(asset.blurDataURL alt=""
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL } fill
: {})} sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
/> loading={index < 6 ? 'eager' : 'lazy'}
{asset.type === 'video' && ( {...(asset.blurDataURL
<div className="photo-grid__item-play" aria-hidden="true"> ? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
<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> {proofing && (
</div> <button
)} type="button"
{(asset.camera || asset.lens) && ( className="photo-grid__fav-btn"
<div className="photo-grid__item-exif" aria-hidden="true"> onClick={(e) => {
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')} e.stopPropagation();
</div> proofing.toggleFavorite(asset.id);
)} }}
</div> aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
</FadeIn> title={isFav ? 'Remove favorite' : 'Add favorite'}
)); style={{
}, [assets, layout, openLightbox]); 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 ( return (
<> <>
@@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
{gridItems} {gridItems}
</div> </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 && ( {lightboxIndex !== null && (
<Lightbox <Lightbox
assets={assets} assets={displayedAssets}
currentIndex={lightboxIndex} currentIndex={lightboxIndex}
onClose={closeLightbox} onClose={closeLightbox}
onNext={goNext} onNext={goNext}
onPrev={goPrev} 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
View 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;
}
}

View File

@@ -31,6 +31,8 @@ import { isProtected, isAuthenticated } from '@/lib/auth';
import PasswordGate from '@/components/PasswordGate'; import PasswordGate from '@/components/PasswordGate';
import { AlbumDetailView } from './AlbumDetailView'; import { AlbumDetailView } from './AlbumDetailView';
import { SubpageGridView } from './SubpageGridView'; import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay';
// Render at request time — requires live Immich connection // Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic'; export const dynamic = 'force-dynamic';
@@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
const slug = path[0]; const slug = path[0];
let title = slug; let title = slug;
let subtitle = ''; let subtitle = '';
let description: string | undefined = undefined;
if (path.length === 2) { if (path.length === 1 && immich.isSubpageSlug(slug)) {
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)) {
const result = await immich.getSubpageAlbums(slug); const result = await immich.getSubpageAlbums(slug);
if (result) { if (result) {
// Single album → use album name; multiple → use subpage name if (result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) { if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug); const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album) { if (album) {
@@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
subtitle = `${count} photo${count === 1 ? '' : 's'}`; subtitle = `${count} photo${count === 1 ? '' : 's'}`;
} }
} else { } 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 { } else {
const album = await immich.getAlbumBySlug(slug); const album = await immich.getAlbumBySlug(slug);
if (album) { if (album) {
@@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
return { return {
title, title,
openGraph: { title, images: [ogUrl] }, description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
twitter: { card: 'summary_large_image', title, images: [ogUrl] }, 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)} gridStyle={buildGridStyle(spGrid)}
backLinkHref={`/${subpageSlug}`} backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`} backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
{...heroData} {...heroData}
/> />
); );
@@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) {
const { albums } = result; 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) ────────── // ── Single album → full-bleed (skip album grid) ──────────
if (albums.length === 1) { if (albums.length === 1) {
const album = await immich.getAlbumBySlug(albums[0].slug, slug); const album = await immich.getAlbumBySlug(albums[0].slug, slug);
@@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) {
subtitle={result.subpage.subtitle} subtitle={result.subpage.subtitle}
backLinkHref="/" backLinkHref="/"
backLinkLabel="Back to Gallery" backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData} {...heroData}
/> />
); );
@@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle()} gridStyle={buildGridStyle()}
backLinkHref="/" backLinkHref="/"
backLinkLabel="Back to Gallery" backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData} {...heroData}
/> />
); );

View File

@@ -7,6 +7,7 @@ import { promises as fsPromises } from 'fs';
import { join } from 'path'; import { join } from 'path';
import yaml from 'js-yaml'; import yaml from 'js-yaml';
import Image from 'next/image'; import Image from 'next/image';
import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { immich } from '@/lib/immich'; import { immich } from '@/lib/immich';
import './about.css'; import './about.css';
@@ -46,6 +47,26 @@ async function getAboutContent() {
return { meta, body: body.trim() }; return { meta, body: body.trim() };
} }
export async function generateMetadata(): Promise<Metadata> {
const { meta, body } = await getAboutContent();
const title = meta.name ? `About — ${meta.name}` : 'About';
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
return {
title,
description,
openGraph: {
title,
description,
},
twitter: {
card: 'summary_large_image',
title,
description,
},
};
}
export default async function AboutPage() { export default async function AboutPage() {
const { meta, body } = await getAboutContent(); const { meta, body } = await getAboutContent();

File diff suppressed because it is too large Load Diff

View File

@@ -3,19 +3,23 @@
import { useState, useEffect } from 'react'; import { useState, useEffect } from 'react';
import PageBuilder from './PageBuilder'; import PageBuilder from './PageBuilder';
import SettingsEditor from './SettingsEditor'; import SettingsEditor from './SettingsEditor';
import AnalyticsView from './AnalyticsView';
import BackupManagerModal from './BackupManagerModal';
import * as Icons from './Icons';
interface Props { interface Props {
onLogout: () => void; onLogout: () => void;
} }
type Tab = 'pages' | 'settings'; type Tab = 'pages' | 'settings' | 'analytics';
export default function AdminDashboard({ onLogout }: Props) { export default function AdminDashboard({ onLogout }: Props) {
const [activeTab, setActiveTab] = useState<Tab>('pages'); const [activeTab, setActiveTab] = useState<Tab>('pages');
const [saving, setSaving] = useState(false); const [saving, setSaving] = useState(false);
// Diagnostics state // Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false); const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [status, setStatus] = useState<any>(null); const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false); const [statusLoading, setStatusLoading] = useState(false);
@@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) {
> >
Settings Settings
</button> </button>
<button
className={`admin-tab ${activeTab === 'analytics' ? 'active' : ''}`}
onClick={() => setActiveTab('analytics')}
>
Analytics
</button>
</nav> </nav>
</div> </div>
<div className="admin-header-right"> <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> <span className="status-val">{status?.cache?.size ?? 0} items</span>
</div> </div>
</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> </div>
</> </>
)} )}
@@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) {
className="admin-btn admin-btn-ghost" className="admin-btn admin-btn-ghost"
title="Open site in new tab" title="Open site in new tab"
> >
↗ Site <Icons.IconLink size={14} /> Site
</a> </a>
<button
className="admin-btn admin-btn-ghost"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} /> Backups
</button>
<button <button
className="admin-btn admin-btn-ghost" className="admin-btn admin-btn-ghost"
onClick={handleReload} onClick={handleReload}
disabled={saving} disabled={saving}
title="Reload config & clear cache" title="Reload config & clear cache"
> >
↻ Reload <Icons.IconRefresh size={14} /> Reload
</button> </button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}> <button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
Logout Logout
@@ -167,7 +195,16 @@ export default function AdminDashboard({ onLogout }: Props) {
<main className="admin-main"> <main className="admin-main">
{activeTab === 'pages' && <PageBuilder />} {activeTab === 'pages' && <PageBuilder />}
{activeTab === 'settings' && <SettingsEditor />} {activeTab === 'settings' && <SettingsEditor />}
{activeTab === 'analytics' && <AnalyticsView />}
</main> </main>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}
onRestoreSuccess={() => {
fetchStatus();
}}
/>
</div> </div>
); );
} }

View 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 &amp; 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 &rarr; 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 &amp; 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>
);
}

View 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>
);
}

View 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>
);
}

View 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>
);
}

View File

@@ -21,6 +21,7 @@ import {
import { CSS } from '@dnd-kit/utilities'; import { CSS } from '@dnd-kit/utilities';
import AlbumPicker from './AlbumPicker'; import AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker'; import AssetPicker from './AssetPicker';
import { EssayBlockEditor } from './EssayBlockEditor';
// ── Icons ────────────────────────────────────────────────────── // ── Icons ──────────────────────────────────────────────────────
const Icons = { const Icons = {
@@ -133,6 +134,9 @@ interface Subpage {
title?: string; title?: string;
subtitle?: string; subtitle?: string;
password?: string; password?: string;
enabled?: boolean;
essayText?: string;
essayFile?: string;
sections?: Section[]; sections?: Section[];
albums: AlbumEntry[]; albums: AlbumEntry[];
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string }; grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
@@ -319,6 +323,7 @@ function SortableSubpageTile({
const totalAlbums = const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0); sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const firstThumb = getFirstThumb(sp); const firstThumb = getFirstThumb(sp);
const slug = sp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return ( return (
<div <div
@@ -331,6 +336,19 @@ function SortableSubpageTile({
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder"> <div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag /> <Icons.Drag />
</div> </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"> <div className="subpage-tile-cover">
{firstThumb ? ( {firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" /> <img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
@@ -339,11 +357,17 @@ function SortableSubpageTile({
<Icons.Folder /> <Icons.Folder />
</div> </div>
)} )}
<div className="subpage-hover-overlay">
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
</div>
</div> </div>
<div className="subpage-tile-info"> <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"> <span className="subpage-tile-meta">
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''} <Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span> </span>
</div> </div>
</div> </div>
@@ -360,6 +384,7 @@ export default function PageBuilder() {
const [dirty, setDirty] = useState(false); const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState(''); const [saveMessage, setSaveMessage] = useState('');
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null); const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit');
const [pickerTarget, setPickerTarget] = useState<{ const [pickerTarget, setPickerTarget] = useState<{
type: 'standalone' | 'subpage' | 'section'; type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number; subpageIndex?: number;
@@ -450,6 +475,9 @@ export default function PageBuilder() {
title: sp.title as string | undefined, title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined, subtitle: sp.subtitle as string | undefined,
password: sp.password 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), albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
sections: sp.sections sections: sp.sections
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({ ? (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') { } else if (raw.subpages && typeof raw.subpages === 'object') {
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => { subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
if (Array.isArray(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>; const sp = value as Record<string, unknown>;
return { return {
@@ -471,6 +499,9 @@ export default function PageBuilder() {
title: sp.title as string | undefined, title: sp.title as string | undefined,
subtitle: sp.subtitle as string | undefined, subtitle: sp.subtitle as string | undefined,
password: sp.password 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( albums: parseAlbumEntries(
sp.albums as Array<string | Record<string, string>> | undefined, 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.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle; if (sp.subtitle) entry.subtitle = sp.subtitle;
if (sp.password) entry.password = sp.password; 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.grid) entry.grid = sp.grid;
if (sp.sections && sp.sections.length > 0) { if (sp.sections && sp.sections.length > 0) {
@@ -895,7 +929,7 @@ export default function PageBuilder() {
{/* Search Bar */} {/* Search Bar */}
<div className="builder-search-container"> <div className="builder-search-container">
<div className="builder-search-wrapper"> <div className="builder-search-wrapper">
<span className="builder-search-icon">🔍</span> <span className="builder-search-icon"><Icons.Search /></span>
<input <input
type="text" type="text"
className="builder-search-input" className="builder-search-input"
@@ -1051,217 +1085,392 @@ export default function PageBuilder() {
</SortableContext> </SortableContext>
</DndContext> </DndContext>
{/* Expanded subpage detail */} {/* Expanded subpage detail (Slide-Over Drawer) */}
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && ( {expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
<div className="subpage-detail"> <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; const sp = gallery.subpages[expandedSubpage];
return ( const spIndex = expandedSubpage;
<> return (
<div className="subpage-detail-header"> <>
<div className="subpage-detail-header-left"> <div className="subpage-drawer-header">
<input <div className="subpage-drawer-title-group">
className="subpage-name-input" <span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
value={sp.name} <h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })} </div>
placeholder="Page name (used for URL)" <div className="subpage-drawer-header-actions">
onClick={(e) => e.stopPropagation()} <div className="segmented-control" style={{ padding: '2px' }}>
/> <button
<span className="subpage-slug-preview">/{slugify(sp.name)}</span> 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>
<div className="subpage-detail-header-right">
<a <div className="subpage-drawer-body">
href={`/${slugify(sp.name)}`} {drawerMode === 'preview' ? (
target="_blank" <div className="drawer-live-preview-panel">
rel="noopener noreferrer" <div className="preview-hero-banner">
className="admin-btn-icon" <h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
title="Preview this page" {sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
onClick={(e) => e.stopPropagation()} </div>
>
↗ {sp.sections && sp.sections.length > 0 ? (
</a> 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 &amp; 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 <button
className="admin-btn-icon" className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => setExpandedSubpage(null)}
title="Collapse"
>
✕
</button>
<button
className="admin-btn-icon admin-btn-icon-danger"
onClick={() => { onClick={() => {
removeSubpage(spIndex); removeSubpage(spIndex);
setExpandedSubpage(null); setExpandedSubpage(null);
}} }}
title="Delete subpage"
> >
🗑 <Icons.Trash /> Delete Subpage
</button> </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 <button
className="admin-btn admin-btn-xs" className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })} onClick={() => setExpandedSubpage(null)}
> >
+ Add Album (outside sections) Done
</button> </button>
)} </div>
</div> </>
</> );
); })()}
})()} </div>
</div> </div>
)} )}
</section> </section>

File diff suppressed because it is too large Load Diff

View File

@@ -0,0 +1,50 @@
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getConfigOrNull } from '@/lib/config';
const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
export async function GET() {
try {
const config = getConfigOrNull();
let data;
try {
const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
data = JSON.parse(raw);
} catch {
data = { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
}
// Compute top pages across all days
const topPagesMap: Record<string, number> = {};
let desktopCount = 0;
let mobileCount = 0;
Object.values(data.days || {}).forEach((day: any) => {
Object.entries(day.pages || {}).forEach(([p, count]: [string, any]) => {
topPagesMap[p] = (topPagesMap[p] || 0) + count;
});
desktopCount += day.devices?.desktop || 0;
mobileCount += day.devices?.mobile || 0;
});
const topPages = Object.entries(topPagesMap)
.map(([path, count]) => ({ path, count }))
.sort((a, b) => b.count - a.count)
.slice(0, 10);
return NextResponse.json({
trackingEnabled: config?.analytics !== false,
summary: data.summary,
days: data.days,
topPages,
devices: {
desktop: desktopCount,
mobile: mobileCount,
},
});
} catch (err: any) {
return NextResponse.json({ error: err.message || 'Failed to fetch analytics' }, { status: 500 });
}
}

View File

@@ -0,0 +1,109 @@
import { NextResponse } from 'next/server';
import { revalidatePath } from 'next/cache';
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
import { invalidateConfigCache } from '@/lib/config';
import { immich } from '@/lib/immich';
export interface BackupItem {
filename: string;
target: 'gallery' | 'settings';
timestamp: string | null;
isPreRestore: boolean;
}
function parseBackupInfo(filename: string): BackupItem {
const target = filename.startsWith('gallery.yaml') ? 'gallery' : 'settings';
const isPreRestore = filename.includes('pre-restore');
// Extract timestamp from filename like filename.2026-05-31T17-30-00-000Z.bak
const match = filename.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z/);
let timestamp: string | null = null;
if (match) {
// Reconstruct valid ISO string: 2026-05-31T17:30:00.000Z
const parts = match[0].split('T');
const datePart = parts[0];
const timeParts = parts[1].replace('Z', '').split('-');
if (timeParts.length >= 4) {
timestamp = `${datePart}T${timeParts[0]}:${timeParts[1]}:${timeParts[2]}.${timeParts[3]}Z`;
}
}
return {
filename,
target,
timestamp,
isPreRestore,
};
}
/** GET: List all available backups for gallery and settings. */
export async function GET() {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const galleryRaw = await listBackups('gallery.yaml');
const settingsRaw = await listBackups('settings.yaml');
const gallery = galleryRaw.map(parseBackupInfo);
const settings = settingsRaw.map(parseBackupInfo);
return NextResponse.json({
backups: {
gallery,
settings,
},
});
} catch (err) {
console.error('[Admin API] Error listing backups:', err);
return NextResponse.json({ error: 'Failed to list backups' }, { status: 500 });
}
}
/** POST: Restore a specific backup file. */
export async function POST(req: Request) {
if (!isAdminEnabled()) {
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
}
if (!(await isAdminAuthenticated())) {
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
}
try {
const body = await req.json();
const { backupFilename } = body || {};
if (!backupFilename || typeof backupFilename !== 'string') {
return NextResponse.json({ error: 'backupFilename is required' }, { status: 400 });
}
// Security check: prevent directory traversal
if (backupFilename.includes('..') || backupFilename.includes('/') || backupFilename.includes('\\')) {
return NextResponse.json({ error: 'Invalid backup filename' }, { status: 400 });
}
if (!backupFilename.endsWith('.bak')) {
return NextResponse.json({ error: 'Invalid backup file extension' }, { status: 400 });
}
await restoreBackup(backupFilename);
// Invalidate caches & revalidate pages
invalidateConfigCache();
immich.invalidateAll();
revalidatePath('/', 'layout');
return NextResponse.json({
success: true,
message: `Successfully restored ${backupFilename}`,
});
} catch (err) {
console.error('[Admin API] Error restoring backup:', err);
return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 });
}
}

View File

@@ -0,0 +1,75 @@
import { NextResponse } from 'next/server';
import fs from 'fs/promises';
import path from 'path';
import { getConfigOrNull } from '@/lib/config';
const CONTENT_DIR = path.join(process.cwd(), 'content');
const ANALYTICS_FILE = path.join(CONTENT_DIR, 'analytics.json');
interface AnalyticsData {
summary: {
totalViews: number;
lastUpdated: string;
};
days: Record<
string,
{
pageviews: number;
pages: Record<string, number>;
devices: Record<string, number>;
}
>;
}
async function getAnalytics(): Promise<AnalyticsData> {
try {
const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
return JSON.parse(raw);
} catch {
return { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
}
}
async function saveAnalytics(data: AnalyticsData) {
try {
await fs.mkdir(CONTENT_DIR, { recursive: true });
await fs.writeFile(ANALYTICS_FILE, JSON.stringify(data, null, 2), 'utf8');
} catch (err) {
console.error('[Analytics] Failed to save analytics data:', err);
}
}
export async function POST(req: Request) {
try {
const config = getConfigOrNull();
if (config?.analytics === false) {
return NextResponse.json({ ok: true, trackingDisabled: true });
}
const body = await req.json();
const pagePath = (body.path || '/').split('?')[0];
const userAgent = req.headers.get('user-agent') || '';
const isMobile = /mobile|iphone|ipad|android/i.test(userAgent);
const deviceType = isMobile ? 'mobile' : 'desktop';
const dateKey = new Date().toISOString().split('T')[0];
const data = await getAnalytics();
data.summary.totalViews = (data.summary.totalViews || 0) + 1;
data.summary.lastUpdated = new Date().toISOString();
if (!data.days[dateKey]) {
data.days[dateKey] = { pageviews: 0, pages: {}, devices: {} };
}
const dayObj = data.days[dateKey];
dayObj.pageviews = (dayObj.pageviews || 0) + 1;
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
dayObj.devices[deviceType] = (dayObj.devices[deviceType] || 0) + 1;
await saveAnalytics(data);
return NextResponse.json({ ok: true });
} catch (err) {
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
}
}

View File

@@ -17,6 +17,8 @@ import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
import { isAdminPath } from '@/lib/admin/paths'; import { isAdminPath } from '@/lib/admin/paths';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there) // DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader'; import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
import AnalyticsTracker from '@/components/AnalyticsTracker';
export async function generateMetadata(): Promise<Metadata> { export async function generateMetadata(): Promise<Metadata> {
const config = getConfigOrNull(); const config = getConfigOrNull();
@@ -33,7 +35,10 @@ export async function generateMetadata(): Promise<Metadata> {
}; };
return { return {
title: siteTitle, title: {
default: siteTitle,
template: config.seo.titleTemplate,
},
description: siteDescription, description: siteDescription,
robots, robots,
icons: { icons: {
@@ -97,6 +102,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
); );
} }
const isAdmin = isAdminPath(pathname);
return ( return (
<html <html
lang={config.lang || 'en'} lang={config.lang || 'en'}
@@ -114,32 +121,43 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="stylesheet" href={fontsUrl} /> <link rel="stylesheet" href={fontsUrl} />
</head> </head>
<body> <body>
<a href="#main-content" className="skip-link"> {isAdmin ? (
Skip to content children
</a> ) : (
<header className="header"> <>
<nav className="header__nav"> <a href="#main-content" className="skip-link">
<Link href="/" className="header__nav-link"> Skip to content
Home </a>
</Link> <header className="header">
<SubpageNav /> <nav className="header__nav">
<Link href="/about" className="header__nav-link"> <Link href="/" className="header__nav-link">
About Home
</Link> </Link>
{config.map && ( <SubpageNav />
<Link href="/map" className="header__nav-link"> <Link href="/about" className="header__nav-link">
Map About
</Link> </Link>
)} {config.map && (
<ThemeToggle /> <Link href="/map" className="header__nav-link">
</nav> Map
</header> </Link>
<main id="main-content" tabIndex={-1} className="main"> )}
{children} <ThemeToggle />
</main> </nav>
<Footer /> </header>
<ScrollToTop /> <main id="main-content" tabIndex={-1} className="main">
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />} {children}
</main>
<Footer />
<ScrollToTop />
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
</body> </body>
</html> </html>
); );

View File

@@ -0,0 +1,22 @@
'use client';
import { useEffect } from 'react';
import { usePathname } from 'next/navigation';
export default function AnalyticsTracker() {
const pathname = usePathname();
useEffect(() => {
if (!pathname || pathname.startsWith('/admin') || pathname.startsWith('/api')) return;
fetch('/api/analytics/track', {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ path: pathname }),
}).catch(() => {
// Ignore background tracking errors
});
}, [pathname]);
return null;
}

View File

@@ -0,0 +1,42 @@
'use client';
import { useEffect } from 'react';
interface Props {
disableRightClick?: boolean;
disableImageDrag?: boolean;
}
export default function AssetProtection({ disableRightClick, disableImageDrag }: Props) {
useEffect(() => {
if (!disableRightClick && !disableImageDrag) return;
function handleContextMenu(e: MouseEvent) {
const target = e.target as HTMLElement;
if (disableRightClick && (target.tagName === 'IMG' || target.closest('img') || target.classList.contains('photo-tile') || target.classList.contains('lightbox-img'))) {
e.preventDefault();
}
}
function handleDragStart(e: DragEvent) {
const target = e.target as HTMLElement;
if (disableImageDrag && (target.tagName === 'IMG' || target.closest('img'))) {
e.preventDefault();
}
}
if (disableRightClick) {
document.addEventListener('contextmenu', handleContextMenu);
}
if (disableImageDrag) {
document.addEventListener('dragstart', handleDragStart);
}
return () => {
document.removeEventListener('contextmenu', handleContextMenu);
document.removeEventListener('dragstart', handleDragStart);
};
}, [disableRightClick, disableImageDrag]);
return null;
}

View File

@@ -252,6 +252,39 @@
opacity: 1; opacity: 1;
} }
/* ── Watermark Overlay ────────────────────────────── */
.watermark {
position: absolute;
z-index: 10;
pointer-events: none;
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 600;
letter-spacing: 0.1em;
text-transform: uppercase;
color: #ffffff;
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 0, 0, 0.7);
padding: 12px 16px;
user-select: none;
}
.watermark_bottom_right {
bottom: 12px;
right: 16px;
}
.watermark_bottom_left {
bottom: 12px;
left: 16px;
}
.watermark_center {
top: 50%;
left: 50%;
transform: translate(-50%, -50%);
}
@media (hover: none) { @media (hover: none) {
.close, .close,
.nav, .nav,

View File

@@ -17,6 +17,14 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
import { useExif } from '@/hooks/useExif'; import { useExif } from '@/hooks/useExif';
import { useSwipe } from '@/hooks/useSwipe'; import { useSwipe } from '@/hooks/useSwipe';
import styles from './Lightbox.module.css'; import styles from './Lightbox.module.css';
import { useProofing } from './ProofingContext';
export interface LightboxWatermark {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center' | string;
}
interface LightboxProps { interface LightboxProps {
assets: PhotoItem[]; assets: PhotoItem[];
@@ -24,9 +32,10 @@ interface LightboxProps {
onClose: () => void; onClose: () => void;
onNext: () => void; onNext: () => void;
onPrev: () => void; onPrev: () => void;
watermark?: LightboxWatermark;
} }
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) { export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, watermark }: LightboxProps) {
const [showExif, setShowExif] = useState(false); const [showExif, setShowExif] = useState(false);
const { exifData, exifLoading, fetchExif, clearExif } = useExif(); const { exifData, exifLoading, fetchExif, clearExif } = useExif();
const [imageLoaded, setImageLoaded] = useState(false); const [imageLoaded, setImageLoaded] = useState(false);
@@ -34,6 +43,8 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
const closeBtnRef = useRef<HTMLButtonElement>(null); const closeBtnRef = useRef<HTMLButtonElement>(null);
const current = assets[currentIndex]; const current = assets[currentIndex];
const proofing = useProofing();
const isFav = proofing && current ? proofing.isFavorite(current.id) : false;
const [mounted, setMounted] = useState(false); const [mounted, setMounted] = useState(false);
// Mount guard — createPortal needs document.body (client-only) // Mount guard — createPortal needs document.body (client-only)
@@ -193,6 +204,21 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)} onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
/> />
)} )}
{watermark?.enabled && watermark.text && (
<div
className={`${styles.watermark} ${
watermark.position === 'bottom-left'
? styles.watermark_bottom_left
: watermark.position === 'center'
? styles.watermark_center
: styles.watermark_bottom_right
}`}
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
>
{watermark.text}
</div>
)}
</div> </div>
{/* Next button */} {/* Next button */}
@@ -225,6 +251,23 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
</span> </span>
</div> </div>
{/* Proofing favorite button */}
{proofing && current && (
<button
className={styles.infoToggle}
style={{
right: '6.5rem',
color: isFav ? '#ff4d4f' : 'inherit',
fontWeight: isFav ? 600 : 400,
}}
onClick={() => proofing.toggleFavorite(current.id)}
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
>
{isFav ? '❤️ Saved' : '🤍 Favorite'}
</button>
)}
{/* EXIF toggle */} {/* EXIF toggle */}
<button <button
className={styles.infoToggle} className={styles.infoToggle}

View File

@@ -0,0 +1,152 @@
'use client';
import React, { createContext, useContext, useState, useEffect } from 'react';
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
interface ProofingContextType {
favorites: Set<string>;
toggleFavorite: (token: string) => void;
isFavorite: (token: string) => boolean;
clearFavorites: () => void;
isFilterActive: boolean;
setIsFilterActive: React.Dispatch<React.SetStateAction<boolean>>;
isModalOpen: boolean;
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
getProofingUrl: () => string;
getFormattedList: () => string;
allowMailto: boolean;
}
const ProofingContext = createContext<ProofingContextType | null>(null);
export function ProofingProvider({
children,
albumTokens = [],
albumName = 'Gallery',
allowMailto = true,
}: {
children: React.ReactNode;
albumTokens?: string[];
albumName?: string;
allowMailto?: boolean;
}) {
const [favorites, setFavorites] = useState<Set<string>>(new Set());
const [isFilterActive, setIsFilterActive] = useState(false);
const [isModalOpen, setIsModalOpen] = useState(false);
const storageKey = `folio_fav_${albumName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
// Hydrate from localStorage and URL query params on mount
useEffect(() => {
try {
const saved = localStorage.getItem(storageKey);
const initialSet = new Set<string>();
if (saved) {
const parsed = JSON.parse(saved);
if (Array.isArray(parsed)) {
parsed.forEach((t) => initialSet.add(t));
}
}
// Check URL query param ?proof=...
const urlParams = new URLSearchParams(window.location.search);
const proofCode = urlParams.get('proof');
if (proofCode && albumTokens.length > 0) {
const decodedIndices = decodeProofBitmask(proofCode, albumTokens.length);
decodedIndices.forEach((idx) => {
if (albumTokens[idx]) {
initialSet.add(albumTokens[idx]);
}
});
}
setFavorites(initialSet);
} catch (e) {
console.error('Failed to load favorites', e);
}
}, [storageKey, albumTokens]);
const toggleFavorite = (token: string) => {
setFavorites((prev) => {
const next = new Set(prev);
if (next.has(token)) {
next.delete(token);
} else {
next.add(token);
}
try {
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
} catch (e) {
console.error('Failed to save favorites', e);
}
return next;
});
};
const isFavorite = (token: string) => favorites.has(token);
const clearFavorites = () => {
setFavorites(new Set());
try {
localStorage.removeItem(storageKey);
} catch (e) {
console.error('Failed to clear favorites', e);
}
};
const getProofingUrl = () => {
if (albumTokens.length === 0 || favorites.size === 0) {
return typeof window !== 'undefined' ? window.location.href : '';
}
const indices: number[] = [];
albumTokens.forEach((token, index) => {
if (favorites.has(token)) {
indices.push(index);
}
});
const code = encodeProofBitmask(indices);
const url = new URL(typeof window !== 'undefined' ? window.location.href : 'http://localhost');
if (code) {
url.searchParams.set('proof', code);
} else {
url.searchParams.delete('proof');
}
return url.toString();
};
const getFormattedList = () => {
const selectedIndices: number[] = [];
albumTokens.forEach((token, index) => {
if (favorites.has(token)) {
selectedIndices.push(index + 1);
}
});
if (selectedIndices.length === 0) return `${albumName}: No photos selected.`;
return `${albumName} — Selected Photos (${selectedIndices.length}): #${selectedIndices.join(', #')}`;
};
return (
<ProofingContext.Provider
value={{
favorites,
toggleFavorite,
isFavorite,
clearFavorites,
isFilterActive,
setIsFilterActive,
isModalOpen,
setIsModalOpen,
getProofingUrl,
getFormattedList,
allowMailto,
}}
>
{children}
</ProofingContext.Provider>
);
}
export function useProofing() {
const context = useContext(ProofingContext);
return context;
}

View File

@@ -0,0 +1,197 @@
'use client';
import React, { useState } from 'react';
import { useProofing } from './ProofingContext';
export function ProofingModal() {
const proofing = useProofing();
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
if (!proofing || !proofing.isModalOpen) return null;
const {
favorites,
isModalOpen,
setIsModalOpen,
getProofingUrl,
getFormattedList,
clearFavorites,
allowMailto,
} = proofing;
const handleCopyLink = () => {
const url = getProofingUrl();
navigator.clipboard.writeText(url).then(() => {
setCopiedState('link');
setTimeout(() => setCopiedState('none'), 2000);
});
};
const handleCopyList = () => {
const list = getFormattedList();
navigator.clipboard.writeText(list).then(() => {
setCopiedState('list');
setTimeout(() => setCopiedState('none'), 2000);
});
};
const handleMailto = () => {
const subject = encodeURIComponent(`Photo Selection (${favorites.size} items)`);
const body = encodeURIComponent(
`Hello,\n\nHere is my photo selection:\n\n${getFormattedList()}\n\nShare Link: ${getProofingUrl()}\n\nBest regards,`,
);
window.location.href = `mailto:?subject=${subject}&body=${body}`;
};
return (
<div
className="proofing-modal-overlay"
onClick={() => setIsModalOpen(false)}
style={{
position: 'fixed',
inset: 0,
backgroundColor: 'rgba(0,0,0,0.7)',
backdropFilter: 'blur(4px)',
zIndex: 9999,
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
padding: '1rem',
}}
>
<div
className="proofing-modal-card"
onClick={(e) => e.stopPropagation()}
style={{
background: 'var(--bg-surface, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
borderRadius: 'var(--radius-md, 12px)',
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
padding: '1.5rem',
maxWidth: '480px',
width: '100%',
boxShadow: '0 20px 40px rgba(0,0,0,0.5)',
}}
>
<div
style={{
display: 'flex',
justifyContent: 'space-between',
alignItems: 'center',
marginBottom: '1rem',
}}
>
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
❤️ Selection ({favorites.size})
</h3>
<button
type="button"
onClick={() => setIsModalOpen(false)}
aria-label="Close modal"
style={{
background: 'none',
border: 'none',
color: 'currentColor',
fontSize: '1.5rem',
cursor: 'pointer',
opacity: 0.7,
lineHeight: 1,
}}
>
&times;
</button>
</div>
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '1rem' }}>
You have selected {favorites.size} photo{favorites.size === 1 ? '' : 's'}. Choose an
export option below to share your selection:
</p>
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
<button
type="button"
onClick={handleCopyLink}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'var(--accent, #e60012)',
color: '#fff',
border: 'none',
fontWeight: 500,
cursor: 'pointer',
}}
>
{copiedState === 'link' ? '✓ Link Copied!' : '🔗 Copy Shareable Link'}
</button>
<button
type="button"
onClick={handleCopyList}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'rgba(255,255,255,0.1)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
fontWeight: 500,
cursor: 'pointer',
}}
>
{copiedState === 'list' ? '✓ List Copied!' : '📋 Copy Text List (#1, #2...)'}
</button>
{allowMailto && (
<button
type="button"
onClick={handleMailto}
style={{
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
gap: '0.5rem',
padding: '0.75rem 1rem',
borderRadius: 'var(--radius-sm, 6px)',
background: 'rgba(255,255,255,0.1)',
color: 'inherit',
border: '1px solid rgba(255,255,255,0.15)',
fontWeight: 500,
cursor: 'pointer',
}}
>
✉️ Send Email to Photographer
</button>
)}
<button
type="button"
onClick={() => {
if (confirm('Clear all selected favorites?')) {
clearFavorites();
setIsModalOpen(false);
}
}}
style={{
marginTop: '0.5rem',
background: 'none',
border: 'none',
color: '#ff4d4f',
fontSize: '0.85rem',
cursor: 'pointer',
opacity: 0.8,
}}
>
Clear Selection
</button>
</div>
</div>
</div>
);
}

View File

@@ -0,0 +1,24 @@
---
title: "The Icelandic Highlands"
subtitle: "A visual journey across volcanic deserts, rivers, and arctic silence"
author: "Portfolio Photographer"
date: "2026-08-05"
---
# Into the Wilderness
The journey began at dawn along the F-roads winding deep into the interior. As civil infrastructure faded behind us, the landscape shifted dramatically into vast fields of black volcanic ash framed by vivid neon-green moss.
> Silence in the highlands isn't just the absence of sound — it is a physical weight that fills every horizon.
![1:fullbleed](Crossing the icy glacial melt rivers of Landmannalaugar at early morning)
## Landmannalaugar & Beyond
Walking through the rhyolite mountains, the earth fumes with geothermal vents. The color spectrum ranges from deep ochre and sulfur yellow to dark basalt gray.
![2, 3](Side-by-side: Thermal vent details and volcanic obsidian formations)
### Reflection & Light
As light broke through the storm clouds late in the afternoon, the contrast between the dark terrain and glowing peaks created a surreal atmosphere that felt entirely unearthly.

View File

@@ -55,3 +55,14 @@ subpages:
albums: # description is optional albums: # description is optional
- "album-uuid-osaka-1" - "album-uuid-osaka-1"
- "album-uuid-osaka-2" - "album-uuid-osaka-2"
# ── Photo Essay / Storytelling Subpage Example ───────────────────
# Render editorial Markdown essays with fullbleed & side-by-side images
- name: Highlands
title: "Icelandic Highlands"
subtitle: "A Photo Essay"
essayFile: "content/essays/sample-story.md"
grid:
layout: essay
albums:
- "album-uuid-iceland-1"

View File

@@ -11,6 +11,7 @@ subtitle: "A visual journal"
# seo: # seo:
# title: "My Portfolio | Photography" # title: "My Portfolio | Photography"
# description: "A curated selection of my best photography work." # description: "A curated selection of my best photography work."
# titleTemplate: "%s | My Portfolio" # Template for subpages (%s is replaced with subpage/album title)
# noIndex: false # Set to true to hide from search engines # noIndex: false # Set to true to hide from search engines
# noFollow: false # Set to true to prevent search engines from following links # noFollow: false # Set to true to prevent search engines from following links
@@ -23,6 +24,11 @@ map: true
# Enable smooth page transitions between routes # Enable smooth page transitions between routes
transitions: true transitions: true
# Client proofing & favorite photo selections
# proofing:
# enabled: true
# allowMailto: true
# ── Theme configuration ────────────────────────────────────────── # ── Theme configuration ──────────────────────────────────────────
# Presets: studio, minimal, editorial, classic, noir, monograph # Presets: studio, minimal, editorial, classic, noir, monograph
theme: theme:

View File

@@ -103,4 +103,16 @@ describe('deriveGallery accepts valid structures', () => {
expect(result.albumDescriptions[A]).toBe('A note'); expect(result.albumDescriptions[A]).toBe('A note');
expect(result.albumHeroImages[A]).toBe(B); expect(result.albumHeroImages[A]).toBe(B);
}); });
it('parses enabled property on subpages correctly', () => {
const result = deriveGallery({
subpages: [
{ name: 'Active Page', albums: [A], enabled: true },
{ name: 'Disabled Page', albums: [B], enabled: false },
],
} as unknown as GalleryYaml);
expect(result.subpages[0].enabled).toBe(true);
expect(result.subpages[1].enabled).toBe(false);
});
}); });

120
lib/__tests__/essay.test.ts Normal file
View File

@@ -0,0 +1,120 @@
import { describe, it, expect } from 'vitest';
import { parseEssayMarkdown, parseFrontmatter, sanitizeHtml, renderInlineMarkdown, serializeEssayMarkdown } from '../essay';
describe('Essay Parser', () => {
it('sanitizes script tags to prevent XSS', () => {
const input = 'Hello <script>alert("XSS")</script> World';
expect(sanitizeHtml(input)).toBe('Hello World');
});
it('renders inline markdown formatting correctly', () => {
const text = 'This is **bold** and *italic* with a [link](https://example.com)';
const rendered = renderInlineMarkdown(text);
expect(rendered).toContain('<strong>bold</strong>');
expect(rendered).toContain('<em>italic</em>');
expect(rendered).toContain('<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>');
});
it('parses frontmatter correctly', () => {
const markdown = `---
title: "The Icelandic Highlands"
subtitle: "A visual journey across black sand and glaciers"
author: "Ralf"
date: "2026-08-05"
coverAssetId: "asset-123"
---
# Section 1
First paragraph text.`;
const { frontmatter, body } = parseFrontmatter(markdown);
expect(frontmatter.title).toBe('The Icelandic Highlands');
expect(frontmatter.subtitle).toBe('A visual journey across black sand and glaciers');
expect(frontmatter.author).toBe('Ralf');
expect(frontmatter.date).toBe('2026-08-05');
expect(frontmatter.coverAssetId).toBe('asset-123');
expect(body.trim()).toMatch(/^# Section 1/);
});
it('parses structured essay blocks (headings, quotes, photos, photo-pairs)', () => {
const markdown = `---
title: "Test Story"
---
# Highland Sunrise
> Silence was absolute across the tundra. -- Ralf
![asset-1:fullbleed](Sunrise over the ridge)
![asset-2, asset-3](Side-by-side details)
A closing paragraph with **bold** details.`;
const parsed = parseEssayMarkdown(markdown);
expect(parsed.frontmatter.title).toBe('Test Story');
expect(parsed.blocks.length).toBe(5);
// Block 0: Heading
expect(parsed.blocks[0]).toEqual({
type: 'heading',
level: 1,
text: 'Highland Sunrise',
});
// Block 1: Quote
expect(parsed.blocks[1]).toEqual({
type: 'quote',
text: 'Silence was absolute across the tundra.',
author: 'Ralf',
});
// Block 2: Fullbleed Photo
expect(parsed.blocks[2]).toEqual({
type: 'photo',
assetId: 'asset-1',
caption: 'Sunrise over the ridge',
layout: 'fullbleed',
});
// Block 3: Photo Pair
expect(parsed.blocks[3]).toEqual({
type: 'photo-pair',
assetIds: ['asset-2', 'asset-3'],
caption: 'Side-by-side details',
});
// Block 4: Paragraph
expect(parsed.blocks[4].type).toBe('paragraph');
if (parsed.blocks[4].type === 'paragraph') {
expect(parsed.blocks[4].html).toContain('<strong>bold</strong>');
}
// Referenced Assets
expect(parsed.referencedAssetIds).toContain('asset-1');
expect(parsed.referencedAssetIds).toContain('asset-2');
expect(parsed.referencedAssetIds).toContain('asset-3');
});
it('serializes structured essay blocks back to markdown', () => {
const parsed = {
frontmatter: { title: 'Story Title', author: 'Author' },
blocks: [
{ type: 'heading' as const, level: 1, text: 'Chapter One' },
{ type: 'paragraph' as const, html: 'This is <strong>bold</strong> text.' },
{ type: 'quote' as const, text: 'A great quote', author: 'Person' },
{ type: 'photo' as const, assetId: 'photo-1', caption: 'Caption', layout: 'fullbleed' as const },
{ type: 'photo-pair' as const, assetIds: ['p1', 'p2'] as [string, string], caption: 'Pair caption' },
],
referencedAssetIds: ['photo-1', 'p1', 'p2'],
};
const markdown = serializeEssayMarkdown(parsed);
expect(markdown).toContain('title: "Story Title"');
expect(markdown).toContain('# Chapter One');
expect(markdown).toContain('This is **bold** text.');
expect(markdown).toContain('> A great quote -- Person');
expect(markdown).toContain('![photo-1:fullbleed](Caption)');
expect(markdown).toContain('![p1, p2](Pair caption)');
});
});

View File

@@ -0,0 +1,38 @@
import { describe, it, expect } from 'vitest';
import { encodeProofBitmask, decodeProofBitmask } from '../proofing';
describe('lib/proofing', () => {
it('returns empty string for empty or invalid indices', () => {
expect(encodeProofBitmask([])).toBe('');
expect(encodeProofBitmask([-1])).toBe('');
});
it('encodes and decodes simple asset indices', () => {
const indices = [0, 3, 5, 12];
const encoded = encodeProofBitmask(indices);
expect(typeof encoded).toBe('string');
expect(encoded.length).toBeGreaterThan(0);
const decoded = decodeProofBitmask(encoded);
expect(decoded).toEqual([0, 3, 5, 12]);
});
it('handles duplicate and unsorted indices gracefully', () => {
const indices = [15, 2, 2, 7, 0];
const encoded = encodeProofBitmask(indices);
const decoded = decodeProofBitmask(encoded);
expect(decoded).toEqual([0, 2, 7, 15]);
});
it('clamps indices to totalCount if provided', () => {
const indices = [1, 5, 10];
const encoded = encodeProofBitmask(indices);
const decoded = decodeProofBitmask(encoded, 6);
expect(decoded).toEqual([1, 5]);
});
it('returns empty array for invalid bitmask strings', () => {
expect(decodeProofBitmask('invalid-junk-!!!')).toEqual([]);
expect(decodeProofBitmask('')).toEqual([]);
});
});

View File

@@ -194,6 +194,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
albumIds, albumIds,
sections, sections,
password: sp.password, password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
...buildSubpageGrid(sp.grid), ...buildSubpageGrid(sp.grid),
}; };
}); });
@@ -204,6 +208,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
name, name,
slug: slugify(name), slug: slugify(name),
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
enabled: true,
}; };
} }
const sp = value as SubpageObjectValue; const sp = value as SubpageObjectValue;
@@ -215,6 +220,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
subtitle: sp.subtitle, subtitle: sp.subtitle,
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)), albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
password: sp.password, password: sp.password,
proofing: sp.proofing,
essayFile: sp.essayFile,
essayText: sp.essayText,
enabled: sp.enabled !== false,
...buildSubpageGrid(sp.grid), ...buildSubpageGrid(sp.grid),
}; };
}); });
@@ -260,6 +269,7 @@ export function getConfig(): AppConfig {
seo: { seo: {
title: 'Setup Required', title: 'Setup Required',
description: 'Please configure Immich Folio', description: 'Please configure Immich Folio',
titleTemplate: '%s | Setup Required',
noIndex: true, noIndex: true,
noFollow: true, noFollow: true,
}, },
@@ -271,6 +281,8 @@ export function getConfig(): AppConfig {
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' }, legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
map: false, map: false,
transitions: false, transitions: false,
analytics: true,
proofing: { enabled: false, allowMailto: false },
albumOverrides: {}, albumOverrides: {},
albumDescriptions: {}, albumDescriptions: {},
albumPasswords: {}, albumPasswords: {},
@@ -296,6 +308,8 @@ export function getConfig(): AppConfig {
albumHeroImages, albumHeroImages,
} = deriveGallery(gallery); } = deriveGallery(gallery);
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
return { return {
immich: { apiUrl: `${apiUrl}/api`, apiKey }, immich: { apiUrl: `${apiUrl}/api`, apiKey },
authSecret, authSecret,
@@ -306,12 +320,13 @@ export function getConfig(): AppConfig {
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE, siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
lang: settings.lang ?? 'en', lang: settings.lang ?? 'en',
seo: { seo: {
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery', title: siteSeoTitle,
description: description:
settings.seo?.description || settings.seo?.description ||
settings.subtitle || settings.subtitle ||
env.SITE_SUBTITLE || env.SITE_SUBTITLE ||
'A curated photography portfolio', 'A curated photography portfolio',
titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`,
noIndex: settings.seo?.noIndex === true, noIndex: settings.seo?.noIndex === true,
noFollow: settings.seo?.noFollow === true, noFollow: settings.seo?.noFollow === true,
}, },
@@ -352,6 +367,13 @@ export function getConfig(): AppConfig {
}, },
map: settings.map === true, map: settings.map === true,
transitions: settings.transitions !== false, transitions: settings.transitions !== false,
analytics: settings.analytics !== false,
proofing: {
enabled: settings.proofing?.enabled !== false,
allowMailto: settings.proofing?.allowMailto !== false,
},
protection: settings.protection,
watermark: settings.watermark,
albumOverrides, albumOverrides,
albumDescriptions, albumDescriptions,
albumPasswords, albumPasswords,

View File

@@ -14,6 +14,10 @@ export interface SubpageConfig {
sections?: SubpageSectionConfig[]; sections?: SubpageSectionConfig[];
password?: string; password?: string;
grid?: Partial<GridConfig>; grid?: Partial<GridConfig>;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
} }
export interface SubpageObjectValue { export interface SubpageObjectValue {
@@ -21,6 +25,10 @@ export interface SubpageObjectValue {
subtitle?: string; subtitle?: string;
password?: string; password?: string;
grid?: Partial<GridConfig>; grid?: Partial<GridConfig>;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
albums?: Array< albums?: Array<
string | Record<string, string | { title: string; description?: string; password?: string }> string | Record<string, string | { title: string; description?: string; password?: string }>
>; >;
@@ -68,7 +76,7 @@ export interface GridConfig {
columns: number; columns: number;
gap: number; gap: number;
aspectRatio: string; aspectRatio: string;
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow'; layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
} }
export interface AppConfig { export interface AppConfig {
@@ -83,6 +91,7 @@ export interface AppConfig {
seo: { seo: {
title: string; title: string;
description: string; description: string;
titleTemplate: string;
noIndex: boolean; noIndex: boolean;
noFollow: boolean; noFollow: boolean;
}; };
@@ -94,6 +103,11 @@ export interface AppConfig {
legal: LegalConfig; legal: LegalConfig;
map: boolean; map: boolean;
transitions: boolean; transitions: boolean;
analytics: boolean;
proofing: {
enabled: boolean;
allowMailto: boolean;
};
albumOverrides: Record<string, string>; albumOverrides: Record<string, string>;
albumDescriptions: Record<string, string>; albumDescriptions: Record<string, string>;
albumPasswords: Record<string, string>; albumPasswords: Record<string, string>;
@@ -106,6 +120,16 @@ export interface AppConfig {
rateLimitRpm: number; rateLimitRpm: number;
trustedProxyHops: number; trustedProxyHops: number;
needsSetup?: boolean; needsSetup?: boolean;
protection?: {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
watermark?: {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
} }
export interface AlbumEntryObject { export interface AlbumEntryObject {
@@ -131,6 +155,10 @@ export interface GalleryYaml {
albums: Array<string | Record<string, string | AlbumEntryObject>>; albums: Array<string | Record<string, string | AlbumEntryObject>>;
}>; }>;
password?: string; password?: string;
proofing?: boolean;
essayFile?: string;
essayText?: string;
enabled?: boolean;
grid?: { grid?: {
columns?: number; columns?: number;
gap?: number; gap?: number;
@@ -147,12 +175,18 @@ export interface SettingsYaml {
seo?: { seo?: {
title?: string; title?: string;
description?: string; description?: string;
titleTemplate?: string;
noIndex?: boolean; noIndex?: boolean;
noFollow?: boolean; noFollow?: boolean;
}; };
exifOnHover?: boolean; exifOnHover?: boolean;
map?: boolean; map?: boolean;
transitions?: boolean; transitions?: boolean;
analytics?: boolean;
proofing?: {
enabled?: boolean;
allowMailto?: boolean;
};
theme?: theme?:
| string | string
| { | {
@@ -173,6 +207,16 @@ export interface SettingsYaml {
}; };
footer?: FooterConfig; footer?: FooterConfig;
legal?: Partial<LegalConfig>; legal?: Partial<LegalConfig>;
protection?: {
disableRightClick?: boolean;
disableImageDrag?: boolean;
};
watermark?: {
enabled?: boolean;
text?: string;
opacity?: number;
position?: 'bottom-right' | 'bottom-left' | 'center';
};
} }
export function slugify(name: string): string { export function slugify(name: string): string {

View File

@@ -72,7 +72,7 @@ export const VALID_HERO_STYLES = [
'typographic', 'typographic',
'mosaic', 'mosaic',
]; ];
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow']; export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'essay'];
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig { export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
if (!raw) return { ...THEME_PRESETS.studio }; if (!raw) return { ...THEME_PRESETS.studio };

273
lib/essay.ts Normal file
View File

@@ -0,0 +1,273 @@
export interface EssayFrontmatter {
title?: string;
subtitle?: string;
date?: string;
author?: string;
coverAssetId?: string;
layout?: string;
}
export type EssayBlock =
| { type: 'heading'; level: number; text: string }
| { type: 'paragraph'; html: string }
| { type: 'quote'; text: string; author?: string }
| { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string };
export interface ParsedEssay {
frontmatter: EssayFrontmatter;
blocks: EssayBlock[];
referencedAssetIds: string[];
}
/** Sanitize inline HTML strings to prevent XSS attacks in essay blocks */
export function sanitizeHtml(input: string): string {
return input
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
.replace(/on\w+="[^"]*"/gi, '')
.replace(/javascript:/gi, '');
}
/** Simple Markdown inline formatting (bold, italic, links, code) */
export function renderInlineMarkdown(text: string): string {
let html = sanitizeHtml(text);
// Bold: **text** or __text__
html = html.replace(/(\*\*|__)(.*?)\1/g, '<strong>$2</strong>');
// Italic: *text* or _text_
html = html.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
// Links: [label](url)
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
const safeUrl = url.trim().replace(/^javascript:/i, '');
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`;
});
return html;
}
/** Parse frontmatter (YAML block delimited by ---) */
export function parseFrontmatter(content: string): { frontmatter: EssayFrontmatter; body: string } {
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
if (!frontmatterMatch) {
return { frontmatter: {}, body: content };
}
const rawYaml = frontmatterMatch[1];
const body = content.slice(frontmatterMatch[0].length);
const frontmatter: EssayFrontmatter = {};
for (const line of rawYaml.split('\n')) {
const trimmed = line.trim();
if (!trimmed || trimmed.startsWith('#')) continue;
const colonIdx = trimmed.indexOf(':');
if (colonIdx === -1) continue;
const key = trimmed.slice(0, colonIdx).trim();
let val = trimmed.slice(colonIdx + 1).trim();
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
val = val.slice(1, -1);
}
if (key === 'title') frontmatter.title = val;
else if (key === 'subtitle') frontmatter.subtitle = val;
else if (key === 'date') frontmatter.date = val;
else if (key === 'author') frontmatter.author = val;
else if (key === 'coverAssetId') frontmatter.coverAssetId = val;
else if (key === 'layout') frontmatter.layout = val;
}
return { frontmatter, body };
}
/** Parse Essay Markdown content into structured blocks */
export function parseEssayMarkdown(rawContent: string): ParsedEssay {
const { frontmatter, body } = parseFrontmatter(rawContent);
const blocks: EssayBlock[] = [];
const referencedAssetIds = new Set<string>();
if (frontmatter.coverAssetId) {
referencedAssetIds.add(frontmatter.coverAssetId);
}
// Split body into paragraph chunks separated by blank lines
const chunks = body
.split(/\r?\n\s*\r?\n/)
.map((c) => c.trim())
.filter(Boolean);
for (const chunk of chunks) {
// 1. Headings (# H1, ## H2, ### H3)
const headingMatch = chunk.match(/^(#{1,6})\s+(.+)$/);
if (headingMatch) {
blocks.push({
type: 'heading',
level: headingMatch[1].length,
text: headingMatch[2].trim(),
});
continue;
}
// 2. Blockquote (> Quote text)
if (chunk.startsWith('>')) {
const quoteText = chunk
.split('\n')
.map((l) => l.replace(/^>\s?/, ''))
.join(' ')
.trim();
// Check for quote author (-- Author Name)
const authorMatch = quoteText.match(/^(.*?)(?:\s+--\s+(.+))?$/);
if (authorMatch) {
blocks.push({
type: 'quote',
text: renderInlineMarkdown(authorMatch[1]),
author: authorMatch[2] ? authorMatch[2].trim() : undefined,
});
}
continue;
}
// 3. Image syntax: ![assetId:layout](Caption) or ![assetId1, assetId2](Caption)
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/);
if (imgMatch) {
const rawTarget = imgMatch[1].trim();
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
// Side-by-side pair: ![asset1, asset2](Caption)
if (rawTarget.includes(',')) {
const parts = rawTarget.split(',').map((s) => s.trim());
if (parts.length >= 2) {
const id1 = parts[0];
const id2 = parts[1];
referencedAssetIds.add(id1);
referencedAssetIds.add(id2);
blocks.push({
type: 'photo-pair',
assetIds: [id1, id2],
caption,
});
continue;
}
}
// Single photo: ![assetId:layout](Caption)
let assetId = rawTarget;
let layout: 'fullbleed' | 'wide' | 'contained' = 'contained';
if (rawTarget.includes(':')) {
const [id, l] = rawTarget.split(':');
assetId = id.trim();
const normLayout = l.trim().toLowerCase();
if (normLayout === 'fullbleed') layout = 'fullbleed';
else if (normLayout === 'wide') layout = 'wide';
}
referencedAssetIds.add(assetId);
blocks.push({
type: 'photo',
assetId,
caption,
layout,
});
continue;
}
// 4. Standard Text Paragraph
blocks.push({
type: 'paragraph',
html: renderInlineMarkdown(chunk.replace(/\r?\n/g, ' ')),
});
}
return {
frontmatter,
blocks,
referencedAssetIds: Array.from(referencedAssetIds),
};
}
/** Load and parse an essay file from content/essays/ */
export function loadEssayFromFile(filename: string): ParsedEssay | null {
if (typeof window !== 'undefined') return null;
try {
// Dynamic require for Node.js modules so client bundler doesn't fail
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodeFs = require('fs');
// eslint-disable-next-line @typescript-eslint/no-require-imports
const nodePath = require('path');
const filePath = nodePath.isAbsolute(filename)
? filename
: nodePath.join(process.cwd(), 'content', 'essays', filename);
if (!nodeFs.existsSync(filePath)) {
console.warn(`[Essay] File not found: ${filePath}`);
return null;
}
const content = nodeFs.readFileSync(filePath, 'utf-8');
return parseEssayMarkdown(content);
} catch (error) {
console.error(`[Essay] Failed to load essay file "${filename}":`, error);
return null;
}
}
/** Converts a ParsedEssay structure back into clean Markdown syntax */
export function serializeEssayMarkdown(essay: ParsedEssay): string {
const lines: string[] = [];
// Frontmatter
const fmKeys = Object.keys(essay.frontmatter) as Array<keyof EssayFrontmatter>;
if (fmKeys.length > 0 && fmKeys.some((k) => essay.frontmatter[k])) {
lines.push('---');
for (const key of fmKeys) {
const val = essay.frontmatter[key];
if (val) {
lines.push(`${key}: "${val.replace(/"/g, '\\"')}"`);
}
}
lines.push('---');
lines.push('');
}
// Blocks
for (const block of essay.blocks) {
switch (block.type) {
case 'heading': {
const hashes = '#'.repeat(Math.min(Math.max(block.level, 1), 6));
lines.push(`${hashes} ${block.text}`);
break;
}
case 'paragraph': {
const text = block.html.replace(/<[^>]+>/g, (tag) => {
if (tag.startsWith('<strong>') || tag.startsWith('<b>')) return '**';
if (tag.startsWith('</strong>') || tag.startsWith('</b>')) return '**';
if (tag.startsWith('<em>') || tag.startsWith('<i>')) return '*';
if (tag.startsWith('</em>') || tag.startsWith('</i>')) return '*';
return '';
});
lines.push(text);
break;
}
case 'quote': {
const authorSuffix = block.author ? ` -- ${block.author}` : '';
const text = block.text.replace(/<[^>]+>/g, '');
lines.push(`> ${text}${authorSuffix}`);
break;
}
case 'photo': {
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
lines.push(`![${block.assetId}${layoutSuffix}](${caption})`);
break;
}
case 'photo-pair': {
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
break;
}
}
lines.push('');
}
return lines.join('\n').trim();
}

View File

@@ -452,12 +452,13 @@ class ImmichClient {
* Get enriched subpage summaries for the homepage. * Get enriched subpage summaries for the homepage.
*/ */
async getSubpages(): Promise<SubpageSummary[]> { async getSubpages(): Promise<SubpageSummary[]> {
if (this.config.subpages.length === 0) return []; const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
if (activeSubpages.length === 0) return [];
const albums = await this.getAlbums(); const albums = await this.getAlbums();
const albumMap = new Map(albums.map((a) => [a.id, a])); const albumMap = new Map(albums.map((a) => [a.id, a]));
return this.config.subpages.map((sp) => { return activeSubpages.map((sp) => {
const spAlbums = sp.albumIds const spAlbums = sp.albumIds
.map((id) => albumMap.get(id)) .map((id) => albumMap.get(id))
.filter((a): a is ImmichAlbum => a !== undefined); .filter((a): a is ImmichAlbum => a !== undefined);
@@ -478,7 +479,9 @@ class ImmichClient {
async getSubpageAlbums( async getSubpageAlbums(
subpageSlug: string, subpageSlug: string,
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> { ): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug); const subpage = this.config.subpages.find(
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
);
if (!subpage) return null; if (!subpage) return null;
const allAlbums = await this.getAlbums(); const allAlbums = await this.getAlbums();
@@ -626,7 +629,7 @@ class ImmichClient {
* Check if a slug corresponds to a subpage. * Check if a slug corresponds to a subpage.
*/ */
isSubpageSlug(slug: string): boolean { isSubpageSlug(slug: string): boolean {
return this.config.subpages.some((sp) => sp.slug === slug); return this.config.subpages.some((sp) => sp.slug === slug && sp.enabled !== false);
} }
/** /**

65
lib/proofing.ts Normal file
View File

@@ -0,0 +1,65 @@
/**
* Bitmask encoder/decoder for compact proofing URL query parameters.
* Serializes selected asset indices in an album into a short base64url string.
*/
/**
* Encode an array of 0-based asset indices into a compact base64url bitmask.
*/
export function encodeProofBitmask(indices: number[]): string {
if (!indices || indices.length === 0) return '';
const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
const maxIndex = sorted[sorted.length - 1];
if (maxIndex < 0) return '';
const numBytes = Math.ceil((maxIndex + 1) / 8);
const bytes = new Uint8Array(numBytes);
for (const idx of sorted) {
if (idx < 0) continue;
const byteIdx = Math.floor(idx / 8);
const bitIdx = idx % 8;
bytes[byteIdx] |= 1 << bitIdx;
}
let binary = '';
for (let i = 0; i < bytes.length; i++) {
binary += String.fromCharCode(bytes[i]);
}
return btoa(binary)
.replace(/\+/g, '-')
.replace(/\//g, '_')
.replace(/=+$/, '');
}
/**
* Decode a base64url bitmask string back into an array of selected asset indices.
*/
export function decodeProofBitmask(code: string, totalCount?: number): number[] {
if (!code || typeof code !== 'string') return [];
try {
let base64 = code.trim().replace(/-/g, '+').replace(/_/g, '/');
while (base64.length % 4) {
base64 += '=';
}
const binary = atob(base64);
const result: number[] = [];
for (let byteIdx = 0; byteIdx < binary.length; byteIdx++) {
const byte = binary.charCodeAt(byteIdx);
for (let bitIdx = 0; bitIdx < 8; bitIdx++) {
if (byte & (1 << bitIdx)) {
const index = byteIdx * 8 + bitIdx;
if (totalCount === undefined || index < totalCount) {
result.push(index);
}
}
}
}
return result;
} catch {
return [];
}
}