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

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

351
app/[...path]/EssayView.tsx Normal file
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 Image from 'next/image';
import { Lightbox } from '@/components/Lightbox';
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn';
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
import { ProofingModal } from '@/components/ProofingModal';
export interface PhotoItem {
id: string;
@@ -32,8 +34,9 @@ export interface PhotoItem {
interface PhotoGridProps {
assets: PhotoItem[];
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
gridStyle?: React.CSSProperties;
watermark?: LightboxWatermark;
}
/** Parse `#photo-N` from a hash string. Returns index or null. */
@@ -49,36 +52,42 @@ function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
const proofing = useProofing();
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const displayedAssets = useMemo(() => {
if (proofing && proofing.isFilterActive) {
return assets.filter((a) => proofing.isFavorite(a.id));
}
return assets;
}, [assets, proofing]);
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
useEffect(() => {
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) {
// state from URL hash on mount only (equivalent to useState lazy initializer for
// client-only browser APIs).
if (idx !== null && idx < displayedAssets.length) {
// state from URL hash on mount only
// eslint-disable-next-line react-hooks/set-state-in-effect
setLightboxIndex(idx);
}
}, [assets.length]);
}, [displayedAssets.length]);
// ── Sync URL hash when lightbox state changes ─────────────────
useEffect(() => {
if (lightboxIndex !== null && lightboxIndex < assets.length) {
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
const hash = buildPhotoHash(lightboxIndex);
// Use replaceState to avoid flooding history with every prev/next tap
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
}
}, [lightboxIndex, assets.length]);
}, [lightboxIndex, displayedAssets.length]);
// ── Listen for browser back/forward (hash change) ─────────────
useEffect(() => {
const handleHashChange = () => {
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) {
if (idx !== null && idx < displayedAssets.length) {
setLightboxIndex(idx);
} else {
setLightboxIndex(null);
@@ -87,27 +96,25 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [assets.length]);
}, [displayedAssets.length]);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
// Push state (not replace) so the user can press Back to close
window.history.pushState(null, '', buildPhotoHash(index));
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
// Remove hash from URL cleanly
window.history.pushState(null, '', window.location.pathname + window.location.search);
}, []);
const goNext = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null));
}, [assets.length]);
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null));
}, [displayedAssets.length]);
const goPrev = useCallback(() => {
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null));
}, [assets.length]);
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
}, [displayedAssets.length]);
// Keyboard navigation
useEffect(() => {
@@ -128,7 +135,6 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
};
document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden';
return () => {
@@ -137,60 +143,105 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
};
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
// Memoize grid items to prevent re-renders of the entire grid (which includes
// Image and FadeIn components) when navigating the lightbox.
const gridItems = useMemo(() => {
return assets.map((asset, index) => (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
<div
className={`photo-grid__item${
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
}`}
onClick={() => openLightbox(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openLightbox(index);
}
}}
role="button"
tabIndex={0}
aria-label={`View photo ${index + 1}`}
aria-haspopup="dialog"
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
}}
>
<Image
src={asset.thumbUrl}
alt=""
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
{asset.type === 'video' && (
<div className="photo-grid__item-play" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
<polygon points="10,8 10,16 17,12" fill="white" />
</svg>
</div>
)}
{(asset.camera || asset.lens) && (
<div className="photo-grid__item-exif" aria-hidden="true">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
</div>
)}
</div>
</FadeIn>
));
}, [assets, layout, openLightbox]);
return displayedAssets.map((asset, index) => {
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
return (
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
<div
className={`photo-grid__item${
layout === 'showcase' && index === 0 ? ' photo-grid__featured' : ''
}`}
onClick={() => openLightbox(index)}
onKeyDown={(e) => {
if (e.key === 'Enter' || e.key === ' ') {
e.preventDefault();
openLightbox(index);
}
}}
role="button"
tabIndex={0}
aria-label={`View photo ${index + 1}`}
aria-haspopup="dialog"
style={{
...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}),
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
? { aspectRatio: `${asset.aspectRatio}` }
: {}),
position: 'relative',
}}
>
<Image
src={asset.thumbUrl}
alt=""
fill
sizes="(max-width: 600px) 50vw, (max-width: 1000px) 33vw, 25vw"
loading={index < 6 ? 'eager' : 'lazy'}
{...(asset.blurDataURL
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
: {})}
/>
{proofing && (
<button
type="button"
className="photo-grid__fav-btn"
onClick={(e) => {
e.stopPropagation();
proofing.toggleFavorite(asset.id);
}}
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
title={isFav ? 'Remove favorite' : 'Add favorite'}
style={{
position: 'absolute',
top: '8px',
right: '8px',
zIndex: 5,
width: '44px',
height: '44px',
display: 'flex',
alignItems: 'center',
justifyContent: 'center',
background: 'none',
border: 'none',
cursor: 'pointer',
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
padding: 0,
}}
>
<svg
viewBox="0 0 24 24"
width="24"
height="24"
fill={isFav ? 'currentColor' : 'none'}
stroke="currentColor"
strokeWidth="2"
>
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
</svg>
</button>
)}
{asset.type === 'video' && (
<div className="photo-grid__item-play" aria-hidden="true">
<svg viewBox="0 0 24 24" fill="currentColor">
<circle cx="12" cy="12" r="12" fillOpacity="0.55" />
<polygon points="10,8 10,16 17,12" fill="white" />
</svg>
</div>
)}
{(asset.camera || asset.lens) && (
<div className="photo-grid__item-exif" aria-hidden="true">
{[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
</div>
)}
</div>
</FadeIn>
);
});
}, [displayedAssets, layout, openLightbox, proofing]);
return (
<>
@@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
{gridItems}
</div>
{proofing && proofing.favorites.size > 0 && (
<div
className="proofing-sticky-bar"
style={{
position: 'fixed',
bottom: '24px',
right: '24px',
zIndex: 990,
display: 'flex',
alignItems: 'center',
gap: '12px',
padding: '8px 16px',
borderRadius: '30px',
background: 'var(--bg-surface, #1e1e1e)',
color: 'var(--text-primary, #ffffff)',
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
backdropFilter: 'blur(8px)',
}}
>
<button
type="button"
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
style={{
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
color: '#fff',
border: 'none',
padding: '6px 14px',
borderRadius: '20px',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
}}
>
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
</button>
<button
type="button"
onClick={() => proofing.setIsModalOpen(true)}
style={{
background: 'none',
border: 'none',
color: 'inherit',
fontSize: '0.85rem',
fontWeight: 500,
cursor: 'pointer',
textDecoration: 'underline',
}}
>
Share & Export
</button>
</div>
)}
<ProofingModal />
{lightboxIndex !== null && (
<Lightbox
assets={assets}
assets={displayedAssets}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
watermark={watermark}
/>
)}
</>
);
}
export function PhotoGrid(props: PhotoGridProps) {
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
return (
<ProofingProvider albumTokens={albumTokens}>
<PhotoGridInner {...props} />
</ProofingProvider>
);
}

157
app/[...path]/essay.css Normal file
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 { AlbumDetailView } from './AlbumDetailView';
import { SubpageGridView } from './SubpageGridView';
import { EssayView } from './EssayView';
import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
@@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
const slug = path[0];
let title = slug;
let subtitle = '';
if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug);
if (album) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else if (immich.isSubpageSlug(slug)) {
let description: string | undefined = undefined;
if (path.length === 1 && immich.isSubpageSlug(slug)) {
const result = await immich.getSubpageAlbums(slug);
if (result) {
// Single album → use album name; multiple → use subpage name
if (result.subpage.subtitle) {
description = result.subpage.subtitle;
}
if (result.albums.length === 1) {
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
if (album) {
@@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else {
title = result.subpage.name;
title = result.subpage.title || result.subpage.name;
}
}
} else if (path.length === 2) {
const album = await immich.getAlbumBySlug(path[1], slug);
if (album) {
title = album.albumName;
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
}
} else {
const album = await immich.getAlbumBySlug(slug);
if (album) {
@@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
return {
title,
openGraph: { title, images: [ogUrl] },
twitter: { card: 'summary_large_image', title, images: [ogUrl] },
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
openGraph: {
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
},
twitter: {
card: 'summary_large_image',
title,
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
images: [ogUrl],
},
};
}
@@ -204,6 +218,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle(spGrid)}
backLinkHref={`/${subpageSlug}`}
backLinkLabel={`Back to ${subpageName}`}
watermark={config.watermark}
{...heroData}
/>
);
@@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) {
const { albums } = result;
// ── Photo Essay / Storytelling Mode ───────────────────────
const isEssay =
result.subpage.grid?.layout === 'essay' ||
!!result.subpage.essayFile ||
!!result.subpage.essayText;
if (isEssay) {
let essayParsed = result.subpage.essayText
? parseEssayMarkdown(result.subpage.essayText)
: result.subpage.essayFile
? loadEssayFromFile(result.subpage.essayFile)
: null;
// Fetch assets from all subpage albums
const allAlbums = await Promise.all(
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
);
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
const images = toPhotoItems(allAssets, config.exifOnHover);
// Fallback structured essay if layout: 'essay' is set without custom markdown file
if (!essayParsed) {
essayParsed = {
frontmatter: {
title: result.subpage.title || result.subpage.name,
subtitle: result.subpage.subtitle,
},
blocks: albums.flatMap((a) => [
{ type: 'heading' as const, level: 2, text: a.albumName },
...a.assets.map((asset) => ({
type: 'photo' as const,
assetId: encodeAssetId(asset.id),
caption: asset.exifInfo?.description || undefined,
layout: 'contained' as const,
})),
]),
referencedAssetIds: [],
};
}
return (
<EssayView
essay={essayParsed}
assets={images}
title={result.subpage.title || result.subpage.name}
subtitle={result.subpage.subtitle}
watermark={config.watermark}
/>
);
}
// ── Single album → full-bleed (skip album grid) ──────────
if (albums.length === 1) {
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
@@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) {
subtitle={result.subpage.subtitle}
backLinkHref="/"
backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData}
/>
);
@@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) {
gridStyle={buildGridStyle()}
backLinkHref="/"
backLinkLabel="Back to Gallery"
watermark={config.watermark}
{...heroData}
/>
);

View File

@@ -7,6 +7,7 @@ import { promises as fsPromises } from 'fs';
import { join } from 'path';
import yaml from 'js-yaml';
import Image from 'next/image';
import type { Metadata } from 'next';
import { imageUrl, assetPlaceholder } from '@/lib/urls';
import { immich } from '@/lib/immich';
import './about.css';
@@ -46,6 +47,26 @@ async function getAboutContent() {
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() {
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 PageBuilder from './PageBuilder';
import SettingsEditor from './SettingsEditor';
import AnalyticsView from './AnalyticsView';
import BackupManagerModal from './BackupManagerModal';
import * as Icons from './Icons';
interface Props {
onLogout: () => void;
}
type Tab = 'pages' | 'settings';
type Tab = 'pages' | 'settings' | 'analytics';
export default function AdminDashboard({ onLogout }: Props) {
const [activeTab, setActiveTab] = useState<Tab>('pages');
const [saving, setSaving] = useState(false);
// Diagnostics state
// Diagnostics & Backup state
const [showStatus, setShowStatus] = useState(false);
const [showBackupModal, setShowBackupModal] = useState(false);
const [status, setStatus] = useState<any>(null);
const [statusLoading, setStatusLoading] = useState(false);
@@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) {
>
Settings
</button>
<button
className={`admin-tab ${activeTab === 'analytics' ? 'active' : ''}`}
onClick={() => setActiveTab('analytics')}
>
Analytics
</button>
</nav>
</div>
<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>
</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>
</>
)}
@@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) {
className="admin-btn admin-btn-ghost"
title="Open site in new tab"
>
↗ Site
<Icons.IconLink size={14} /> Site
</a>
<button
className="admin-btn admin-btn-ghost"
onClick={() => setShowBackupModal(true)}
title="Manage config backups & restore"
>
<Icons.IconArchive size={14} /> Backups
</button>
<button
className="admin-btn admin-btn-ghost"
onClick={handleReload}
disabled={saving}
title="Reload config & clear cache"
>
↻ Reload
<Icons.IconRefresh size={14} /> Reload
</button>
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
Logout
@@ -167,7 +195,16 @@ export default function AdminDashboard({ onLogout }: Props) {
<main className="admin-main">
{activeTab === 'pages' && <PageBuilder />}
{activeTab === 'settings' && <SettingsEditor />}
{activeTab === 'analytics' && <AnalyticsView />}
</main>
<BackupManagerModal
isOpen={showBackupModal}
onClose={() => setShowBackupModal(false)}
onRestoreSuccess={() => {
fetchStatus();
}}
/>
</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 AlbumPicker from './AlbumPicker';
import AssetPicker from './AssetPicker';
import { EssayBlockEditor } from './EssayBlockEditor';
// ── Icons ──────────────────────────────────────────────────────
const Icons = {
@@ -133,6 +134,9 @@ interface Subpage {
title?: string;
subtitle?: string;
password?: string;
enabled?: boolean;
essayText?: string;
essayFile?: string;
sections?: Section[];
albums: AlbumEntry[];
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
@@ -319,6 +323,7 @@ function SortableSubpageTile({
const totalAlbums =
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
const firstThumb = getFirstThumb(sp);
const slug = sp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
return (
<div
@@ -331,6 +336,19 @@ function SortableSubpageTile({
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
<Icons.Drag />
</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">
{firstThumb ? (
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
@@ -339,11 +357,17 @@ function SortableSubpageTile({
<Icons.Folder />
</div>
)}
<div className="subpage-hover-overlay">
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
</div>
</div>
<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">
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
</span>
</div>
</div>
@@ -360,6 +384,7 @@ export default function PageBuilder() {
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit');
const [pickerTarget, setPickerTarget] = useState<{
type: 'standalone' | 'subpage' | 'section';
subpageIndex?: number;
@@ -450,6 +475,9 @@ export default function PageBuilder() {
title: sp.title as string | undefined,
subtitle: sp.subtitle 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),
sections: sp.sections
? (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') {
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, 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>;
return {
@@ -471,6 +499,9 @@ export default function PageBuilder() {
title: sp.title as string | undefined,
subtitle: sp.subtitle 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,
),
@@ -507,6 +538,9 @@ export default function PageBuilder() {
if (sp.title) entry.title = sp.title;
if (sp.subtitle) entry.subtitle = sp.subtitle;
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.sections && sp.sections.length > 0) {
@@ -895,7 +929,7 @@ export default function PageBuilder() {
{/* Search Bar */}
<div className="builder-search-container">
<div className="builder-search-wrapper">
<span className="builder-search-icon">🔍</span>
<span className="builder-search-icon"><Icons.Search /></span>
<input
type="text"
className="builder-search-input"
@@ -1051,217 +1085,392 @@ export default function PageBuilder() {
</SortableContext>
</DndContext>
{/* Expanded subpage detail */}
{/* Expanded subpage detail (Slide-Over Drawer) */}
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
<div className="subpage-detail">
{(() => {
const sp = gallery.subpages[expandedSubpage];
const spIndex = expandedSubpage;
return (
<>
<div className="subpage-detail-header">
<div className="subpage-detail-header-left">
<input
className="subpage-name-input"
value={sp.name}
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
placeholder="Page name (used for URL)"
onClick={(e) => e.stopPropagation()}
/>
<span className="subpage-slug-preview">/{slugify(sp.name)}</span>
<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;
return (
<>
<div className="subpage-drawer-header">
<div className="subpage-drawer-title-group">
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
</div>
<div className="subpage-drawer-header-actions">
<div className="segmented-control" style={{ padding: '2px' }}>
<button
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 className="subpage-detail-header-right">
<a
href={`/${slugify(sp.name)}`}
target="_blank"
rel="noopener noreferrer"
className="admin-btn-icon"
title="Preview this page"
onClick={(e) => e.stopPropagation()}
>
↗
</a>
<div className="subpage-drawer-body">
{drawerMode === 'preview' ? (
<div className="drawer-live-preview-panel">
<div className="preview-hero-banner">
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
</div>
{sp.sections && sp.sections.length > 0 ? (
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
className="admin-btn-icon"
onClick={() => setExpandedSubpage(null)}
title="Collapse"
>
✕
</button>
<button
className="admin-btn-icon admin-btn-icon-danger"
className="admin-btn admin-btn-danger admin-btn-sm"
onClick={() => {
removeSubpage(spIndex);
setExpandedSubpage(null);
}}
title="Delete subpage"
>
🗑
<Icons.Trash /> Delete Subpage
</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
className="admin-btn admin-btn-xs"
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
className="admin-btn admin-btn-primary admin-btn-sm"
onClick={() => setExpandedSubpage(null)}
>
+ Add Album (outside sections)
Done
</button>
)}
</div>
</>
);
})()}
</div>
</>
);
})()}
</div>
</div>
)}
</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';
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
import AssetProtection from '@/components/AssetProtection';
import AnalyticsTracker from '@/components/AnalyticsTracker';
export async function generateMetadata(): Promise<Metadata> {
const config = getConfigOrNull();
@@ -33,7 +35,10 @@ export async function generateMetadata(): Promise<Metadata> {
};
return {
title: siteTitle,
title: {
default: siteTitle,
template: config.seo.titleTemplate,
},
description: siteDescription,
robots,
icons: {
@@ -97,6 +102,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
);
}
const isAdmin = isAdminPath(pathname);
return (
<html
lang={config.lang || 'en'}
@@ -114,32 +121,43 @@ export default async function RootLayout({ children }: { children: React.ReactNo
<link rel="stylesheet" href={fontsUrl} />
</head>
<body>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<header className="header">
<nav className="header__nav">
<Link href="/" className="header__nav-link">
Home
</Link>
<SubpageNav />
<Link href="/about" className="header__nav-link">
About
</Link>
{config.map && (
<Link href="/map" className="header__nav-link">
Map
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
<ScrollToTop />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
{isAdmin ? (
children
) : (
<>
<a href="#main-content" className="skip-link">
Skip to content
</a>
<header className="header">
<nav className="header__nav">
<Link href="/" className="header__nav-link">
Home
</Link>
<SubpageNav />
<Link href="/about" className="header__nav-link">
About
</Link>
{config.map && (
<Link href="/map" className="header__nav-link">
Map
</Link>
)}
<ThemeToggle />
</nav>
</header>
<main id="main-content" tabIndex={-1} className="main">
{children}
</main>
<Footer />
<ScrollToTop />
<AssetProtection
disableRightClick={config.protection?.disableRightClick}
disableImageDrag={config.protection?.disableImageDrag}
/>
<AnalyticsTracker />
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
</>
)}
</body>
</html>
);