From c9036fdc94b2d2146b8cab2982ea71e296c82ab5 Mon Sep 17 00:00:00 2001
From: Ralksta <16941050+ralksta@users.noreply.github.com>
Date: Fri, 7 Aug 2026 11:08:04 +0200
Subject: [PATCH] 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
---
.github/workflows/docker-publish.yml | 16 +
README.md | 2 +
app/[...path]/AlbumDetailView.tsx | 5 +-
app/[...path]/EssayView.tsx | 351 +++++
app/[...path]/PhotoGrid.tsx | 264 +++-
app/[...path]/essay.css | 157 +++
app/[...path]/page.tsx | 94 +-
app/about/page.tsx | 21 +
app/admin/admin.css | 1357 +++++++++++++++++++
app/admin/components/AdminDashboard.tsx | 45 +-
app/admin/components/AnalyticsView.tsx | 177 +++
app/admin/components/BackupManagerModal.tsx | 226 +++
app/admin/components/EssayBlockEditor.tsx | 455 +++++++
app/admin/components/Icons.tsx | 267 ++++
app/admin/components/PageBuilder.tsx | 615 ++++++---
app/admin/components/SettingsEditor.tsx | 910 ++++++++++---
app/api/admin/analytics/route.ts | 50 +
app/api/admin/backups/route.ts | 109 ++
app/api/analytics/track/route.ts | 75 +
app/layout.tsx | 72 +-
components/AnalyticsTracker.tsx | 22 +
components/AssetProtection.tsx | 42 +
components/Lightbox.module.css | 33 +
components/Lightbox.tsx | 45 +-
components/ProofingContext.tsx | 152 +++
components/ProofingModal.tsx | 197 +++
content/essays/sample-story.md | 24 +
content/gallery.yaml.example | 11 +
content/settings.yaml.example | 6 +
lib/__tests__/derive-gallery.test.ts | 12 +
lib/__tests__/essay.test.ts | 120 ++
lib/__tests__/proofing.test.ts | 38 +
lib/config/index.ts | 24 +-
lib/config/schema.ts | 46 +-
lib/config/theme.ts | 2 +-
lib/essay.ts | 273 ++++
lib/immich.ts | 11 +-
lib/proofing.ts | 65 +
38 files changed, 5872 insertions(+), 519 deletions(-)
create mode 100644 app/[...path]/EssayView.tsx
create mode 100644 app/[...path]/essay.css
create mode 100644 app/admin/components/AnalyticsView.tsx
create mode 100644 app/admin/components/BackupManagerModal.tsx
create mode 100644 app/admin/components/EssayBlockEditor.tsx
create mode 100644 app/admin/components/Icons.tsx
create mode 100644 app/api/admin/analytics/route.ts
create mode 100644 app/api/admin/backups/route.ts
create mode 100644 app/api/analytics/track/route.ts
create mode 100644 components/AnalyticsTracker.tsx
create mode 100644 components/AssetProtection.tsx
create mode 100644 components/ProofingContext.tsx
create mode 100644 components/ProofingModal.tsx
create mode 100644 content/essays/sample-story.md
create mode 100644 lib/__tests__/essay.test.ts
create mode 100644 lib/__tests__/proofing.test.ts
create mode 100644 lib/essay.ts
create mode 100644 lib/proofing.ts
diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml
index dc3f1d6..1c66cfd 100644
--- a/.github/workflows/docker-publish.yml
+++ b/.github/workflows/docker-publish.yml
@@ -15,6 +15,7 @@ jobs:
permissions:
contents: read
packages: write
+ security-events: write
steps:
- name: Checkout repository
@@ -47,6 +48,7 @@ jobs:
type=raw,value=latest,enable=${{ github.event_name == 'release' }}
- name: Build and push Docker image
+ id: build
uses: docker/build-push-action@v7
with:
context: .
@@ -55,3 +57,17 @@ jobs:
labels: ${{ steps.meta.outputs.labels }}
cache-from: type=gha
cache-to: type=gha,mode=max
+
+ - name: Scan image for vulnerabilities
+ uses: aquasecurity/trivy-action@0.29.0
+ with:
+ image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }}
+ format: sarif
+ output: trivy-results.sarif
+ severity: CRITICAL,HIGH
+ exit-code: '0'
+
+ - name: Upload scan results to GitHub Security tab
+ uses: github/codeql-action/upload-sarif@v3
+ with:
+ sarif_file: trivy-results.sarif
diff --git a/README.md b/README.md
index 2a93690..939d4be 100644
--- a/README.md
+++ b/README.md
@@ -52,10 +52,12 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
| **Album scope** | Only albums in `gallery.yaml` are accessible |
| **Password protection** | Per-subpage password support |
| **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) |
+| **Vulnerability scanning** | Docker image scanned with Trivy on every release, results in the GitHub Security tab |
- Health check endpoint at `GET /api/health`
- In-memory caching with configurable TTL
- Standalone Docker image — multi-stage, non-root, ~150 MB
+- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly)
diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx
index bb1fdaa..6c9dfea 100644
--- a/app/[...path]/AlbumDetailView.tsx
+++ b/app/[...path]/AlbumDetailView.tsx
@@ -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'}
-
+
>
);
}
diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx
new file mode 100644
index 0000000..522cb53
--- /dev/null
+++ b/app/[...path]/EssayView.tsx
@@ -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(null);
+
+ // Map assetId / index to PhotoItem and its index in `assets`
+ const assetMap = useMemo(() => {
+ const map = new Map();
+ 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 (
+
+ {block.text}
+
+ );
+ }
+
+ case 'paragraph':
+ return (
+
+ );
+
+ case 'quote':
+ return (
+
+
+ {block.author && — {block.author} }
+
+ );
+
+ 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 (
+
+
+ setLightboxIndex(index)}
+ style={{
+ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
+ ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
+ }}
+ >
+
+
+ {proofing && (
+
{
+ 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))',
+ }}
+ >
+
+
+
+
+ )}
+
+ {block.caption && (
+
+ )}
+
+
+ );
+ }
+
+ case 'photo-pair': {
+ const res1 = assetMap.get(block.assetIds[0]);
+ const res2 = assetMap.get(block.assetIds[1]);
+ if (!res1 || !res2) return null;
+
+ return (
+
+
+
+ {[res1, res2].map(({ item, index }, pIdx) => {
+ const isFav = proofing ? proofing.isFavorite(item.id) : false;
+ return (
+
setLightboxIndex(index)}
+ style={{
+ ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
+ ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
+ }}
+ >
+
+ {proofing && (
+
{
+ 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))',
+ }}
+ >
+
+
+
+
+ )}
+
+ );
+ })}
+
+ {block.caption && (
+
+ )}
+
+
+ );
+ }
+
+ default:
+ return null;
+ }
+ };
+
+ return (
+
+
+
+ {coverAsset && (
+
+ setLightboxIndex(coverAsset.index)}
+ style={{
+ ...(coverAsset.item.dominantColor
+ ? { backgroundColor: coverAsset.item.dominantColor }
+ : {}),
+ ...(coverAsset.item.aspectRatio
+ ? { aspectRatio: `${coverAsset.item.aspectRatio}` }
+ : {}),
+ }}
+ >
+
+
+
+ )}
+
+
{essay.blocks.map(renderBlock)}
+
+ {proofing && proofing.favorites.size > 0 && (
+
+ 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`}
+
+ proofing.setIsModalOpen(true)}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'inherit',
+ fontSize: '0.85rem',
+ fontWeight: 500,
+ cursor: 'pointer',
+ textDecoration: 'underline',
+ }}
+ >
+ Share & Export
+
+
+ )}
+
+
+
+ {lightboxIndex !== null && (
+
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}
+ />
+ )}
+
+ );
+}
+
+export function EssayView(props: EssayViewProps) {
+ const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
+
+ return (
+
+
+
+ );
+}
diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx
index 0288e57..e887005 100644
--- a/app/[...path]/PhotoGrid.tsx
+++ b/app/[...path]/PhotoGrid.tsx
@@ -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(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) => (
-
- 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}` }
- : {}),
- }}
- >
-
- {asset.type === 'video' && (
-
- )}
- {(asset.camera || asset.lens) && (
-
- {[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
-
- )}
-
-
- ));
- }, [assets, layout, openLightbox]);
+ return displayedAssets.map((asset, index) => {
+ const isFav = proofing ? proofing.isFavorite(asset.id) : false;
+
+ return (
+
+ 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',
+ }}
+ >
+
+
+ {proofing && (
+
{
+ 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,
+ }}
+ >
+
+
+
+
+ )}
+
+ {asset.type === 'video' && (
+
+ )}
+ {(asset.camera || asset.lens) && (
+
+ {[asset.camera, asset.lens, asset.focalLength].filter(Boolean).join(' · ')}
+
+ )}
+
+
+ );
+ });
+ }, [displayedAssets, layout, openLightbox, proofing]);
return (
<>
@@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
{gridItems}
+ {proofing && proofing.favorites.size > 0 && (
+
+ 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`}
+
+ proofing.setIsModalOpen(true)}
+ style={{
+ background: 'none',
+ border: 'none',
+ color: 'inherit',
+ fontSize: '0.85rem',
+ fontWeight: 500,
+ cursor: 'pointer',
+ textDecoration: 'underline',
+ }}
+ >
+ Share & Export
+
+
+ )}
+
+
+
{lightboxIndex !== null && (
)}
>
);
}
+
+export function PhotoGrid(props: PhotoGridProps) {
+ const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
+
+ return (
+
+
+
+ );
+}
diff --git a/app/[...path]/essay.css b/app/[...path]/essay.css
new file mode 100644
index 0000000..0b5560f
--- /dev/null
+++ b/app/[...path]/essay.css
@@ -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;
+ }
+}
diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx
index 7e014d0..5bb0c7d 100644
--- a/app/[...path]/page.tsx
+++ b/app/[...path]/page.tsx
@@ -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 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 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
);
@@ -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 (
+
+ );
+ }
+
// ── 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}
/>
);
diff --git a/app/about/page.tsx b/app/about/page.tsx
index 66203f9..7df8fd7 100644
--- a/app/about/page.tsx
+++ b/app/about/page.tsx
@@ -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 {
+ 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();
diff --git a/app/admin/admin.css b/app/admin/admin.css
index b18f6be..37794cb 100644
--- a/app/admin/admin.css
+++ b/app/admin/admin.css
@@ -700,6 +700,7 @@
}
.subpage-tile {
+ position: relative;
background: var(--admin-bg-raised);
border: 1px solid var(--admin-border);
border-radius: 10px;
@@ -719,6 +720,7 @@
}
.subpage-tile-cover {
+ position: relative;
aspect-ratio: 16 / 10;
background: var(--admin-bg-elevated);
overflow: hidden;
@@ -2195,3 +2197,1358 @@ h3 .svg-icon {
background: var(--admin-bg-raised);
border-bottom: 1px solid var(--admin-border);
}
+
+/* ── Status Dropdown Footer ── */
+.status-dropdown-footer {
+ padding: 0.5rem 0.85rem;
+ background: var(--admin-bg-elevated);
+ border-top: 1px solid var(--admin-border);
+ display: flex;
+ justify-content: flex-end;
+}
+
+.admin-btn-sm {
+ padding: 0.35rem 0.65rem !important;
+ font-size: 0.75rem !important;
+}
+
+/* ── Backup Manager Modal ────────────────────────────────────── */
+.backup-modal-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(4px);
+ z-index: 999;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ padding: 1.5rem;
+ animation: fadeIn 0.15s ease-out;
+}
+
+.backup-modal-container {
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 14px;
+ width: 100%;
+ max-width: 640px;
+ max-height: 85vh;
+ display: flex;
+ flex-direction: column;
+ box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35);
+ overflow: hidden;
+ animation: scaleUp 0.15s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+.backup-modal-header {
+ display: flex;
+ align-items: flex-start;
+ justify-content: space-between;
+ padding: 1.25rem 1.5rem 0.75rem;
+ border-bottom: 1px solid var(--admin-border);
+ background: var(--admin-bg-elevated);
+}
+
+.backup-modal-header h2 {
+ margin: 0;
+ font-size: 1.15rem;
+ font-weight: 700;
+ color: var(--admin-text);
+}
+
+.backup-modal-subtitle {
+ margin: 0.25rem 0 0;
+ font-size: 0.8rem;
+ color: var(--admin-text-muted);
+}
+
+.backup-modal-close-btn {
+ background: none;
+ border: none;
+ font-size: 1.1rem;
+ color: var(--admin-text-muted);
+ cursor: pointer;
+ padding: 0.25rem 0.5rem;
+ border-radius: 6px;
+ line-height: 1;
+ transition: all 0.15s;
+}
+
+.backup-modal-close-btn:hover {
+ color: var(--admin-text);
+ background: var(--admin-bg-input);
+}
+
+/* Modal Tabs */
+.backup-modal-tabs {
+ display: flex;
+ gap: 0.5rem;
+ padding: 0.75rem 1.5rem;
+ border-bottom: 1px solid var(--admin-border);
+ background: var(--admin-bg-raised);
+}
+
+.backup-modal-tab {
+ padding: 0.4rem 0.85rem;
+ font-size: 0.82rem;
+ font-weight: 600;
+ border-radius: 8px;
+ background: transparent;
+ border: 1px solid transparent;
+ color: var(--admin-text-muted);
+ cursor: pointer;
+ transition: all 0.15s;
+}
+
+.backup-modal-tab:hover {
+ color: var(--admin-text);
+ background: var(--admin-bg-elevated);
+}
+
+.backup-modal-tab.active {
+ background: var(--admin-bg-elevated);
+ border-color: var(--admin-border);
+ color: var(--admin-accent);
+}
+
+/* Modal Status Alert */
+.backup-status-alert {
+ margin: 0.75rem 1.5rem 0;
+ padding: 0.65rem 0.85rem;
+ border-radius: 8px;
+ font-size: 0.82rem;
+ font-weight: 500;
+}
+
+.backup-status-alert.error {
+ background: rgba(239, 68, 68, 0.1);
+ border: 1px solid var(--admin-error, #ef4444);
+ color: var(--admin-error, #ef4444);
+}
+
+.backup-status-alert.success {
+ background: rgba(16, 185, 129, 0.1);
+ border: 1px solid var(--admin-success, #10b981);
+ color: var(--admin-success, #10b981);
+}
+
+/* Confirmation Box */
+.backup-confirm-box {
+ margin: 0.75rem 1.5rem 0;
+ padding: 1rem;
+ background: rgba(239, 68, 68, 0.05);
+ border: 1px solid rgba(239, 68, 68, 0.3);
+ border-radius: 10px;
+}
+
+.backup-confirm-content strong {
+ display: block;
+ font-size: 0.9rem;
+ color: var(--admin-text);
+ margin-bottom: 0.25rem;
+}
+
+.backup-confirm-content p {
+ margin: 0 0 0.75rem;
+ font-size: 0.8rem;
+ color: var(--admin-text-secondary);
+ line-height: 1.4;
+}
+
+.backup-confirm-actions {
+ display: flex;
+ gap: 0.5rem;
+ justify-content: flex-end;
+}
+
+/* Modal Body & List */
+.backup-modal-body {
+ flex: 1;
+ overflow-y: auto;
+ padding: 1rem 1.5rem;
+}
+
+.backup-loading,
+.backup-empty {
+ text-align: center;
+ padding: 2.5rem 1rem;
+ font-size: 0.85rem;
+ color: var(--admin-text-muted);
+}
+
+.backup-list {
+ display: flex;
+ flex-direction: column;
+ gap: 0.65rem;
+}
+
+.backup-item {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 0.75rem 1rem;
+ background: var(--admin-bg-elevated);
+ border: 1px solid var(--admin-border);
+ border-radius: 10px;
+ transition: border-color 0.15s;
+}
+
+.backup-item:hover {
+ border-color: var(--admin-border-hover);
+}
+
+.backup-item-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+}
+
+.backup-item-title {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.backup-filename {
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--admin-text);
+}
+
+.backup-badge {
+ font-size: 0.68rem;
+ font-weight: 600;
+ padding: 0.15rem 0.45rem;
+ border-radius: 4px;
+ text-transform: uppercase;
+ letter-spacing: 0.03em;
+}
+
+.backup-badge.pre-restore {
+ background: rgba(245, 158, 11, 0.15);
+ color: #f59e0b;
+ border: 1px solid rgba(245, 158, 11, 0.3);
+}
+
+.backup-item-meta code {
+ font-family: monospace;
+ font-size: 0.72rem;
+ color: var(--admin-text-muted);
+}
+
+.backup-modal-footer {
+ padding: 0.85rem 1.5rem;
+ border-top: 1px solid var(--admin-border);
+ background: var(--admin-bg-elevated);
+ display: flex;
+ justify-content: flex-end;
+}
+
+.backup-toggle-btn {
+ margin-top: 0.4rem;
+ padding: 0.55rem 0.85rem;
+ background: var(--admin-bg-elevated);
+ border: 1px dashed var(--admin-border);
+ border-radius: 8px;
+ color: var(--admin-text-secondary);
+ font-size: 0.78rem;
+ font-weight: 500;
+ cursor: pointer;
+ width: 100%;
+ text-align: center;
+ transition: all 0.15s ease;
+}
+
+.backup-toggle-btn:hover {
+ background: var(--admin-bg-raised);
+ border-color: var(--admin-accent);
+ color: var(--admin-accent);
+}
+
+/* ── Visual Cards for Photo Frame & Hero Style ────────────────── */
+.frame-card-preview,
+.hero-card-preview {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ padding: 0.5rem;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--admin-bg-elevated);
+ border-bottom: 1px solid var(--admin-border);
+ position: relative;
+ overflow: hidden;
+}
+
+/* Photo Frame Demos */
+.mini-frame-demo {
+ width: 70%;
+ height: 70%;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ transition: all 0.2s ease;
+}
+
+.mini-frame-photo {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(135deg, #444, #222);
+ border-radius: 2px;
+}
+
+.mini-frame-demo.frame-none .mini-frame-photo {
+ border: none;
+}
+
+.mini-frame-demo.frame-passepartout {
+ padding: 6px;
+ background: #ffffff;
+ box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1);
+}
+
+.mini-frame-demo.frame-shadow .mini-frame-photo {
+ box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4);
+}
+
+/* Hero Style Demos */
+.mini-hero-demo {
+ width: 100%;
+ height: 100%;
+ display: flex;
+ gap: 0.35rem;
+ padding: 0.25rem;
+}
+
+.demo-line {
+ height: 3px;
+ background: var(--admin-text-muted);
+ border-radius: 2px;
+}
+
+.demo-line.title {
+ width: 80%;
+ height: 5px;
+ background: var(--admin-accent);
+}
+
+.demo-line.title.giant {
+ width: 95%;
+ height: 8px;
+ margin-bottom: 0.2rem;
+}
+
+.demo-line.title.short {
+ width: 50%;
+ margin: 0 auto;
+}
+
+.demo-line.title.light {
+ width: 60%;
+ background: #ffffff;
+}
+
+.demo-line.sub {
+ width: 50%;
+}
+
+.hero-demo-split {
+ align-items: center;
+}
+
+.hero-demo-text {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.hero-demo-photo {
+ flex: 1;
+ height: 100%;
+ background: linear-gradient(135deg, #3a3a3a, #1a1a1a);
+ border-radius: 3px;
+}
+
+.hero-demo-photo.small {
+ height: 60%;
+ width: 80%;
+ margin: 0 auto;
+}
+
+.hero-demo-photo.banner {
+ width: 100%;
+ height: 65%;
+}
+
+.hero-demo-full {
+ width: 100%;
+ height: 100%;
+ background: linear-gradient(135deg, #555, #111);
+ border-radius: 3px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.hero-demo-minimal,
+.hero-demo-stacked {
+ flex-direction: column;
+ justify-content: center;
+}
+
+.hero-demo-center-text {
+ text-align: center;
+ width: 100%;
+ margin-bottom: 0.2rem;
+}
+
+.hero-demo-typographic {
+ flex-direction: column;
+}
+
+.hero-demo-grid2 {
+ display: flex;
+ gap: 0.25rem;
+ flex: 1;
+}
+
+.hero-demo-mosaic {
+ display: flex;
+ gap: 0.25rem;
+ width: 100%;
+ height: 100%;
+}
+
+.hero-demo-photo.big {
+ flex: 1.5;
+}
+
+.hero-demo-photo-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+/* ── Global Button System ────────────────────────────────────── */
+.btn {
+ display: inline-flex;
+ align-items: center;
+ justify-content: center;
+ gap: 0.4rem;
+ padding: 0.5rem 1rem;
+ font-family: var(--font-sans, system-ui, sans-serif);
+ font-size: 0.82rem;
+ font-weight: 600;
+ border-radius: 8px;
+ border: 1px solid transparent;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ line-height: 1.2;
+}
+
+.btn-sm {
+ padding: 0.35rem 0.65rem !important;
+ font-size: 0.75rem !important;
+ border-radius: 6px !important;
+}
+
+.btn-secondary {
+ background: var(--admin-bg-elevated, #242424);
+ border-color: var(--admin-border, #333333);
+ color: var(--admin-text, #ffffff);
+}
+
+.btn-secondary:hover {
+ background: var(--admin-bg-raised, #2e2e2e);
+ border-color: var(--admin-border-hover, #444444);
+}
+
+.btn-danger {
+ background: #ef4444;
+ color: #ffffff;
+ border-color: #ef4444;
+}
+
+.btn-danger:hover {
+ background: #dc2626;
+ border-color: #dc2626;
+}
+
+.btn-outline-danger {
+ background: transparent;
+ border-color: rgba(239, 68, 68, 0.4);
+ color: #ef4444;
+}
+
+.btn-outline-danger:hover {
+ background: rgba(239, 68, 68, 0.12);
+ border-color: #ef4444;
+}
+
+/* ── Preset Grid & Buttons (Grid Layout & Aspect Ratio) ───────── */
+.preset-grid {
+ display: flex;
+ flex-wrap: wrap;
+ gap: 0.5rem;
+ margin-top: 0.35rem;
+}
+
+.preset-btn {
+ padding: 0.45rem 0.85rem;
+ background: var(--admin-bg-input, #1c1c1c);
+ border: 1px solid var(--admin-border, #333333);
+ border-radius: 8px;
+ color: var(--admin-text-secondary, #cccccc);
+ font-family: var(--font-sans, system-ui, sans-serif);
+ font-size: 0.8rem;
+ font-weight: 500;
+ cursor: pointer;
+ transition: all 0.15s ease;
+ text-transform: capitalize;
+}
+
+.preset-btn:hover {
+ background: var(--admin-bg-elevated, #282828);
+ border-color: var(--admin-border-hover, #555555);
+ color: var(--admin-text, #ffffff);
+}
+
+.preset-btn.active {
+ background: var(--admin-bg-elevated, #2a2a2a);
+ border-color: var(--admin-accent, #e60012);
+ color: var(--admin-accent, #e60012);
+ font-weight: 600;
+ box-shadow: 0 0 0 1px var(--admin-accent, #e60012);
+}
+
+/* ── Theme Settings Section Upgrades ─────────────────────────── */
+.theme-settings-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+}
+
+.settings-section-header {
+ margin-bottom: 0.5rem;
+}
+
+.settings-section-header h3 {
+ margin: 0 0 0.25rem 0;
+ font-size: 1.1rem;
+ font-weight: 700;
+ color: var(--admin-text);
+}
+
+.settings-section-sub {
+ margin: 0;
+ font-size: 0.8rem;
+ color: var(--admin-text-muted);
+}
+
+.settings-section-divider {
+ height: 1px;
+ background: var(--admin-border);
+ margin: 0.75rem 0;
+}
+
+/* Segmented Control (Mode Toggle) */
+.segmented-control {
+ display: inline-flex;
+ background: var(--admin-bg-input, #1c1c1c);
+ padding: 4px;
+ border-radius: 10px;
+ border: 1px solid var(--admin-border, #333);
+ gap: 4px;
+}
+
+.segment-btn {
+ padding: 0.5rem 1.1rem;
+ font-size: 0.82rem;
+ font-weight: 600;
+ border-radius: 7px;
+ border: none;
+ background: transparent;
+ color: var(--admin-text-muted);
+ cursor: pointer;
+ transition: all 0.15s ease;
+}
+
+.segment-btn:hover {
+ color: var(--admin-text);
+}
+
+.segment-btn.active {
+ background: var(--admin-bg-elevated, #2d2d2d);
+ color: var(--admin-text, #ffffff);
+ box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2);
+}
+
+/* Accent Color Swatches */
+.accent-picker-wrapper {
+ display: flex;
+ flex-direction: column;
+ gap: 0.85rem;
+}
+
+.color-swatches-row {
+ display: flex;
+ align-items: center;
+ gap: 0.65rem;
+ flex-wrap: wrap;
+}
+
+.color-swatch-btn {
+ width: 32px;
+ height: 32px;
+ border-radius: 50%;
+ border: 2px solid transparent;
+ cursor: pointer;
+ transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease;
+}
+
+.color-swatch-btn:hover {
+ transform: scale(1.15);
+}
+
+.color-swatch-btn.active {
+ border-color: #ffffff;
+ box-shadow: 0 0 0 2px var(--admin-accent, #e60012);
+ transform: scale(1.15);
+}
+
+/* Custom Toggle Cards Grid */
+.admin-toggle-cards-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
+ gap: 1rem;
+}
+
+.admin-toggle-card {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ padding: 1rem 1.25rem;
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ cursor: pointer;
+ text-align: left;
+ transition: all 0.15s ease;
+}
+
+.admin-toggle-card:hover {
+ border-color: var(--admin-border-hover);
+ background: var(--admin-bg-elevated);
+}
+
+.admin-toggle-card.active {
+ border-color: var(--admin-accent);
+ box-shadow: 0 0 0 1px var(--admin-accent);
+}
+
+.toggle-card-info {
+ display: flex;
+ flex-direction: column;
+ gap: 0.2rem;
+ padding-right: 1rem;
+}
+
+.toggle-card-title {
+ font-size: 0.88rem;
+ font-weight: 600;
+ color: var(--admin-text);
+}
+
+.toggle-card-desc {
+ font-size: 0.75rem;
+ color: var(--admin-text-muted);
+ line-height: 1.3;
+}
+
+/* iOS Style Switch Slider */
+.switch-toggle {
+ width: 42px;
+ height: 24px;
+ background: var(--admin-border, #444);
+ border-radius: 12px;
+ position: relative;
+ transition: background-color 0.2s ease;
+ flex-shrink: 0;
+}
+
+.switch-toggle.on {
+ background: var(--admin-accent, #e60012);
+}
+
+.switch-slider {
+ width: 18px;
+ height: 18px;
+ background: #ffffff;
+ border-radius: 50%;
+ position: absolute;
+ top: 3px;
+ left: 3px;
+ transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1);
+ box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3);
+}
+
+.switch-toggle.on .switch-slider {
+ transform: translateX(18px);
+}
+
+/* ── Google Search Result Snippet Preview ────────────────────── */
+.google-snippet-preview {
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ padding: 1rem 1.25rem;
+ margin-bottom: 1.25rem;
+}
+
+.google-snippet-header {
+ font-size: 0.8rem;
+ font-weight: 600;
+ color: var(--admin-text-muted);
+ margin-bottom: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.04em;
+}
+
+.google-snippet-card {
+ background: #ffffff;
+ border-radius: 8px;
+ padding: 1rem 1.25rem;
+ box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08);
+ font-family: Arial, sans-serif;
+ color: #202124;
+}
+
+.google-snippet-url {
+ font-size: 0.78rem;
+ color: #202124;
+ margin-bottom: 0.2rem;
+ word-break: break-all;
+}
+
+.google-snippet-arrow {
+ font-size: 0.6rem;
+ color: #5f6368;
+}
+
+.google-snippet-title {
+ font-size: 1.15rem;
+ font-weight: 400;
+ color: #1a0dab;
+ line-height: 1.3;
+ margin-bottom: 0.25rem;
+ cursor: pointer;
+}
+
+.google-snippet-title:hover {
+ text-decoration: underline;
+}
+
+.google-snippet-desc {
+ font-size: 0.85rem;
+ color: #4d5156;
+ line-height: 1.4;
+ word-break: break-word;
+}
+
+/* Aspect Ratio Mini Preview Box */
+.ratio-card-preview {
+ width: 100%;
+ aspect-ratio: 16 / 10;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ background: var(--admin-bg-elevated);
+ border-bottom: 1px solid var(--admin-border);
+}
+
+.mini-aspect-box {
+ width: 44px;
+ height: 44px;
+ border: 2px dashed var(--admin-accent);
+ border-radius: 4px;
+ display: flex;
+ align-items: center;
+ justify-content: center;
+}
+
+.mini-aspect-box.ratio-1 { width: 34px; height: 34px; }
+.mini-aspect-box.ratio-3-2 { width: 46px; height: 30px; }
+.mini-aspect-box.ratio-2-3 { width: 30px; height: 44px; }
+.mini-aspect-box.ratio-16-9 { width: 50px; height: 28px; }
+.mini-aspect-box.ratio-auto { width: 42px; height: 32px; border-style: solid; }
+
+.mini-aspect-inner {
+ width: 100%;
+ height: 100%;
+ background: var(--admin-accent);
+ opacity: 0.2;
+}
+
+/* Grid Layout Mini Demos */
+.mini-layout-demo {
+ width: 100%;
+ height: 100%;
+ padding: 0.35rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.demo-masonry-col-group {
+ display: flex;
+ gap: 0.25rem;
+ height: 100%;
+ width: 100%;
+}
+
+.demo-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.demo-tile {
+ background: linear-gradient(135deg, #444, #222);
+ border-radius: 2px;
+ flex: 1;
+}
+
+.demo-tile.h-high { flex: 2; }
+.demo-tile.h-low { flex: 1; }
+.demo-tile.h-med { flex: 1.5; }
+
+.demo-uniform-grid {
+ display: grid;
+ grid-template-columns: repeat(3, 1fr);
+ gap: 0.25rem;
+ height: 100%;
+}
+
+.demo-showcase-grid {
+ display: flex;
+ gap: 0.25rem;
+ height: 100%;
+}
+
+.demo-tile.hero-tile { flex: 2; }
+
+.demo-filmstrip-row {
+ display: flex;
+ gap: 0.25rem;
+ height: 100%;
+ align-items: center;
+}
+
+.demo-tile.strip {
+ width: 40%;
+ height: 90%;
+ flex-shrink: 0;
+}
+
+.demo-editorial-flow {
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+ height: 100%;
+}
+
+.demo-tile.wide {
+ width: 100%;
+ height: 45%;
+}
+
+.demo-row {
+ display: flex;
+ gap: 0.25rem;
+ flex: 1;
+}
+
+/* ── Subpage Tile Badges & Hover Actions ─────────────────────── */
+.subpage-badge-protected {
+ position: absolute;
+ top: 8px;
+ right: 8px;
+ background: rgba(0, 0, 0, 0.75);
+ backdrop-filter: blur(8px);
+ color: #ffca28;
+ font-size: 0.7rem;
+ font-weight: 600;
+ padding: 2px 8px;
+ border-radius: 12px;
+ border: 1px solid rgba(255, 202, 40, 0.3);
+ z-index: 2;
+}
+
+.subpage-hover-overlay {
+ position: absolute;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.55);
+ backdrop-filter: blur(3px);
+ display: flex;
+ align-items: center;
+ justify-content: center;
+ opacity: 0;
+ transition: opacity 0.2s ease;
+}
+
+.subpage-tile:hover .subpage-hover-overlay {
+ opacity: 1;
+}
+
+.hover-action-btn {
+ font-size: 0.78rem;
+ font-weight: 600;
+ color: #ffffff;
+ background: var(--admin-accent, #e60012);
+ padding: 0.4rem 0.85rem;
+ border-radius: 20px;
+ box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4);
+}
+
+.subpage-tile-title-row {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ gap: 0.5rem;
+}
+
+.subpage-tile-slug {
+ font-size: 0.7rem;
+ color: var(--admin-text-muted);
+ font-family: var(--font-mono, monospace);
+ background: var(--admin-bg-input);
+ padding: 1px 6px;
+ border-radius: 4px;
+}
+
+/* ── Subpage Slide-Over Drawer Modal ──────────────────────────── */
+.subpage-drawer-backdrop {
+ position: fixed;
+ inset: 0;
+ background: rgba(0, 0, 0, 0.65);
+ backdrop-filter: blur(6px);
+ z-index: 999;
+ display: flex;
+ justify-content: flex-end;
+ animation: fadeIn 0.2s ease-out;
+}
+
+.subpage-drawer-container {
+ width: 100%;
+ max-width: 620px;
+ height: 100vh;
+ background: var(--admin-bg-card, #181818);
+ border-left: 1px solid var(--admin-border, #333);
+ display: flex;
+ flex-direction: column;
+ box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5);
+ animation: slideInRight 0.25s cubic-bezier(0.16, 1, 0.3, 1);
+}
+
+@keyframes slideInRight {
+ from { transform: translateX(100%); }
+ to { transform: translateX(0); }
+}
+
+.subpage-drawer-header {
+ padding: 1.25rem 1.5rem;
+ border-bottom: 1px solid var(--admin-border);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ background: var(--admin-bg-raised);
+}
+
+.subpage-drawer-breadcrumb {
+ font-size: 0.75rem;
+ color: var(--admin-text-muted);
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ font-weight: 600;
+}
+
+.subpage-drawer-title {
+ margin: 0.2rem 0 0 0;
+ font-size: 1.2rem;
+ font-weight: 700;
+ color: var(--admin-text);
+}
+
+.subpage-drawer-header-actions {
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.subpage-drawer-body {
+ padding: 1.5rem;
+ overflow-y: auto;
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 1.25rem;
+}
+
+.subpage-drawer-footer {
+ padding: 1rem 1.5rem;
+ border-top: 1px solid var(--admin-border);
+ background: var(--admin-bg-raised);
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.input-slug-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.input-slug-preview {
+ position: absolute;
+ right: 12px;
+ font-size: 0.75rem;
+ font-family: var(--font-mono, monospace);
+ color: var(--admin-text-muted);
+ pointer-events: none;
+ background: var(--admin-bg-elevated);
+ padding: 2px 8px;
+ border-radius: 4px;
+}
+
+.password-input-wrapper {
+ position: relative;
+ display: flex;
+ align-items: center;
+}
+
+.password-icon {
+ position: absolute;
+ left: 12px;
+ font-size: 0.85rem;
+ pointer-events: none;
+}
+
+.password-input-wrapper input {
+ padding-left: 2.25rem !important;
+}
+
+/* ── Dynamic Floating Save Bar ───────────────────────────────── */
+.floating-save-bar {
+ position: fixed;
+ bottom: 1.5rem;
+ left: 50%;
+ transform: translateX(-50%);
+ background: var(--admin-bg-raised, #1f1f1f);
+ border: 1px solid var(--admin-border-hover, #444);
+ box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05);
+ border-radius: 16px;
+ padding: 0.75rem 1.25rem;
+ z-index: 900;
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ width: calc(100% - 3rem);
+ max-width: 800px;
+ transition: all 0.25s ease;
+}
+
+.badge-pulse-dot {
+ width: 8px;
+ height: 8px;
+ border-radius: 50%;
+ background: #ff9800;
+ display: inline-block;
+ margin-right: 6px;
+ animation: pulse 1.5s infinite;
+}
+
+@keyframes pulse {
+ 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7); }
+ 70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(255, 152, 0, 0); }
+ 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); }
+}
+
+/* ── Analytics View ────────────────────────────────────────── */
+.analytics-view {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ max-width: 1100px;
+}
+
+.analytics-header {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+}
+
+.analytics-header h2 {
+ font-size: 1.25rem;
+ font-weight: 600;
+ margin: 0;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.analytics-subtitle {
+ font-size: 0.8rem;
+ color: var(--admin-text-muted);
+ margin: 0.25rem 0 0;
+}
+
+.analytics-metrics-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fit, minmax(220px, 1fr));
+ gap: 1rem;
+}
+
+.metric-card {
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ padding: 1.25rem;
+ display: flex;
+ flex-direction: column;
+ gap: 0.25rem;
+}
+
+.metric-label {
+ font-size: 0.75rem;
+ text-transform: uppercase;
+ letter-spacing: 0.05em;
+ color: var(--admin-text-muted);
+}
+
+.metric-value {
+ font-size: 1.8rem;
+ font-weight: 700;
+ color: var(--admin-text);
+ font-family: var(--font-mono, monospace);
+}
+
+.metric-sub {
+ font-size: 0.7rem;
+ color: var(--admin-text-muted);
+}
+
+.analytics-panel {
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 12px;
+ padding: 1.5rem;
+}
+
+.analytics-section-title h3 {
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0 0 1.25rem;
+ display: flex;
+ align-items: center;
+ gap: 0.5rem;
+}
+
+.analytics-bar-chart {
+ display: flex;
+ align-items: flex-end;
+ gap: 0.75rem;
+ height: 180px;
+ padding-top: 1.5rem;
+}
+
+.bar-col {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ align-items: center;
+ height: 100%;
+}
+
+.bar-wrapper {
+ flex: 1;
+ width: 100%;
+ max-width: 32px;
+ display: flex;
+ align-items: flex-end;
+ background: var(--admin-bg-elevated);
+ border-radius: 6px;
+ overflow: hidden;
+}
+
+.bar-fill {
+ width: 100%;
+ background: var(--admin-primary, #3b82f6);
+ border-radius: 6px 6px 0 0;
+ transition: height 0.3s ease;
+ position: relative;
+ display: flex;
+ align-items: flex-start;
+ justify-content: center;
+}
+
+.bar-val {
+ font-size: 0.65rem;
+ font-weight: 600;
+ color: #fff;
+ margin-top: 4px;
+}
+
+.bar-label {
+ font-size: 0.68rem;
+ color: var(--admin-text-muted);
+ margin-top: 0.5rem;
+ font-family: var(--font-mono, monospace);
+}
+
+.analytics-top-pages {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.top-page-row {
+ display: flex;
+ align-items: center;
+ gap: 1rem;
+}
+
+.page-rank {
+ font-size: 0.8rem;
+ font-family: var(--font-mono, monospace);
+ color: var(--admin-text-muted);
+ width: 24px;
+}
+
+.page-info {
+ flex: 1;
+ display: flex;
+ flex-direction: column;
+ gap: 0.35rem;
+}
+
+.page-path-line {
+ display: flex;
+ align-items: center;
+ justify-content: space-between;
+ font-size: 0.85rem;
+}
+
+.page-path {
+ font-family: var(--font-mono, monospace);
+ color: var(--admin-text);
+}
+
+.page-count {
+ font-size: 0.75rem;
+ color: var(--admin-text-muted);
+}
+
+.page-progress-bg {
+ width: 100%;
+ height: 6px;
+ background: var(--admin-bg-elevated);
+ border-radius: 3px;
+ overflow: hidden;
+}
+
+/* ── Drawer Live Preview ────────────────────────────────────── */
+.drawer-live-preview-panel {
+ display: flex;
+ flex-direction: column;
+ gap: 1.5rem;
+ padding: 0.5rem 0;
+}
+
+.preview-hero-banner {
+ text-align: center;
+ padding: 1.75rem 1rem;
+ background: var(--admin-bg-elevated);
+ border-radius: 12px;
+ border: 1px dashed var(--admin-border);
+}
+
+.preview-hero-title {
+ font-size: 1.3rem;
+ font-weight: 700;
+ margin: 0;
+ color: var(--admin-text);
+}
+
+.preview-hero-sub {
+ font-size: 0.82rem;
+ color: var(--admin-text-muted);
+ margin: 0.35rem 0 0;
+}
+
+.preview-section-group {
+ display: flex;
+ flex-direction: column;
+ gap: 0.75rem;
+}
+
+.preview-section-title {
+ font-size: 0.95rem;
+ font-weight: 600;
+ margin: 0;
+ border-bottom: 1px solid var(--admin-border);
+ padding-bottom: 0.35rem;
+}
+
+.preview-section-desc {
+ font-size: 0.78rem;
+ color: var(--admin-text-muted);
+ margin: 0;
+}
+
+.preview-albums-grid {
+ display: grid;
+ grid-template-columns: repeat(auto-fill, minmax(130px, 1fr));
+ gap: 0.75rem;
+}
+
+.preview-album-tile {
+ background: var(--admin-bg-raised);
+ border: 1px solid var(--admin-border);
+ border-radius: 8px;
+ overflow: hidden;
+ display: flex;
+ flex-direction: column;
+}
+
+.preview-album-cover {
+ width: 100%;
+ aspect-ratio: 4/3;
+ background: var(--admin-bg-elevated);
+ overflow: hidden;
+}
+
+.preview-album-cover img {
+ width: 100%;
+ height: 100%;
+ object-fit: cover;
+}
+
+.preview-album-title {
+ font-size: 0.75rem;
+ font-weight: 500;
+ padding: 0.4rem 0.5rem;
+ white-space: nowrap;
+ overflow: hidden;
+ text-overflow: ellipsis;
+}
+
+
+
+
+
+
+
diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx
index a7270cf..83da17e 100644
--- a/app/admin/components/AdminDashboard.tsx
+++ b/app/admin/components/AdminDashboard.tsx
@@ -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('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(null);
const [statusLoading, setStatusLoading] = useState(false);
@@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) {
>
Settings
+ setActiveTab('analytics')}
+ >
+ Analytics
+
@@ -136,6 +146,17 @@ export default function AdminDashboard({ onLogout }: Props) {
{status?.cache?.size ?? 0} items
+
+ {
+ setShowStatus(false);
+ setShowBackupModal(true);
+ }}
+ >
+ Manage Backups
+
+
>
)}
@@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) {
className="admin-btn admin-btn-ghost"
title="Open site in new tab"
>
- ↗ Site
+ Site
+ setShowBackupModal(true)}
+ title="Manage config backups & restore"
+ >
+ Backups
+
- ↻ Reload
+ Reload
Logout
@@ -167,7 +195,16 @@ export default function AdminDashboard({ onLogout }: Props) {
{activeTab === 'pages' && }
{activeTab === 'settings' && }
+ {activeTab === 'analytics' && }
+
+ setShowBackupModal(false)}
+ onRestoreSuccess={() => {
+ fetchStatus();
+ }}
+ />
);
}
diff --git a/app/admin/components/AnalyticsView.tsx b/app/admin/components/AnalyticsView.tsx
new file mode 100644
index 0000000..002da40
--- /dev/null
+++ b/app/admin/components/AnalyticsView.tsx
@@ -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;
+ devices: Record;
+ }
+ >;
+ topPages: { path: string; count: number }[];
+ devices: { desktop: number; mobile: number };
+}
+
+export default function AnalyticsView() {
+ const [data, setData] = useState(null);
+ const [loading, setLoading] = useState(true);
+ const [error, setError] = useState(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 (
+
+ );
+ }
+
+ if (error || !data) {
+ return (
+
+
{error || 'No analytics data'}
+
+ Retry
+
+
+ );
+ }
+
+ 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 (
+
+ {/* Header */}
+
+
+
Visitor Insights & Analytics
+
Privacy-first aggregate traffic stats. No personal data collected.
+
+
+ Refresh
+
+
+
+ {data.trackingEnabled === false && (
+
+
+
+ Analytics Tracking is Currently Disabled. No new visitor views are being recorded. You can re-enable tracking anytime under Settings → General .
+
+
+ )}
+
+ {/* Top Metric Cards */}
+
+
+ Total Page Views
+ {totalViews.toLocaleString()}
+ Lifetime aggregate
+
+
+ Active Days Tracked
+ {Object.keys(data.days || {}).length}
+ Days recorded
+
+
+ Desktop vs Mobile
+ {desktopRatio}% / {100 - desktopRatio}%
+ Device distribution
+
+
+
+ {/* Daily Trends Chart */}
+
+
+
Daily Traffic Trend (Last 14 Days)
+
+ {daysList.length === 0 ? (
+
No pageviews recorded yet. Visit portfolio pages to start tracking!
+ ) : (
+
+ {daysList.map(([dateStr, dayData]) => {
+ const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
+ const formattedDate = dateStr.slice(5); // MM-DD
+ return (
+
+
+
+ {dayData.pageviews}
+
+
+
{formattedDate}
+
+ );
+ })}
+
+ )}
+
+
+ {/* Top Pages List */}
+
+
+
Top Visited Pages & Subpages
+
+ {data.topPages.length === 0 ? (
+
No pages recorded yet.
+ ) : (
+
+ {data.topPages.map((p, idx) => {
+ const widthPct = Math.round((p.count / maxPageViews) * 100);
+ return (
+
+
#{idx + 1}
+
+
+ {p.path}
+ {p.count} views
+
+
+
+
+ );
+ })}
+
+ )}
+
+
+ );
+}
diff --git a/app/admin/components/BackupManagerModal.tsx b/app/admin/components/BackupManagerModal.tsx
new file mode 100644
index 0000000..b3f3489
--- /dev/null
+++ b/app/admin/components/BackupManagerModal.tsx
@@ -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(null);
+ const [restoringFilename, setRestoringFilename] = useState(null);
+ const [confirmFilename, setConfirmFilename] = useState(null);
+ const [successMsg, setSuccessMsg] = useState(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 (
+
+
e.stopPropagation()}>
+
+
+
Backup History & Restoration
+
+ Restore previous states of your configuration files with 1-click.
+
+
+
+ ✕
+
+
+
+ {/* Tab Selection */}
+
+ {
+ setActiveTab('gallery');
+ setConfirmFilename(null);
+ setShowAllBackups(false);
+ }}
+ >
+ Gallery Backups ({backups.gallery.length})
+
+ {
+ setActiveTab('settings');
+ setConfirmFilename(null);
+ setShowAllBackups(false);
+ }}
+ >
+ Settings Backups ({backups.settings.length})
+
+
+
+ {/* Status Messages */}
+ {error &&
{error}
}
+ {successMsg &&
{successMsg}
}
+
+ {/* Confirmation Modal Section */}
+ {confirmFilename && (
+
+
+
Confirm Restoration
+
+ Are you sure you want to restore {confirmFilename}?
+ A safety snapshot of your current state will be created automatically before reverting.
+
+
+ setConfirmFilename(null)}
+ disabled={!!restoringFilename}
+ >
+ Cancel
+
+ handleRestore(confirmFilename)}
+ disabled={!!restoringFilename}
+ >
+ {restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
+
+
+
+
+ )}
+
+ {/* Backup List Content */}
+
+ {loading ? (
+
Loading backups...
+ ) : currentList.length === 0 ? (
+
No backups available yet for {activeTab}.yaml
+ ) : (
+
+ {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 (
+
+
+
+ {formattedDate}
+ {item.isPreRestore && (
+
+ Pre-Restore Snapshot
+
+ )}
+
+
+ {item.filename}
+
+
+
setConfirmFilename(item.filename)}
+ disabled={!!restoringFilename || confirmFilename === item.filename}
+ >
+ Restore
+
+
+ );
+ })}
+
+ {currentList.length > INITIAL_VISIBLE_COUNT && (
+
setShowAllBackups(!showAllBackups)}
+ >
+
+ {showAllBackups
+ ? 'Ältere Einträge einklappen'
+ : `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
+
+ )}
+
+ )}
+
+
+
+
+ Close
+
+
+
+
+ );
+}
diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx
new file mode 100644
index 0000000..274310d
--- /dev/null
+++ b/app/admin/components/EssayBlockEditor.tsx
@@ -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(() =>
+ 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 (
+
+
+
+ ✨ Add Story Block:
+
+ handleAddBlock('paragraph')}
+ >
+ + Paragraph
+
+ handleAddBlock('heading')}
+ >
+ + Heading
+
+ handleAddBlock('quote')}
+ >
+ + Pullquote
+
+ handleAddBlock('photo')}
+ >
+ + Photo
+
+ handleAddBlock('photo-pair')}
+ >
+ + 2-Photo Pair
+
+
+
+ {essay.blocks.length === 0 && (
+
+ No story blocks yet. Use the buttons above to build your photo essay!
+
+ )}
+
+ {essay.blocks.map((block, idx) => (
+
+ {/* Block Header / Action Controls */}
+
+
+ Block {idx + 1}: {block.type}
+
+
+ handleMoveBlock(idx, 'up')}
+ disabled={idx === 0}
+ title="Move Up"
+ style={{ padding: '2px 6px' }}
+ >
+ ▲
+
+ handleMoveBlock(idx, 'down')}
+ disabled={idx === essay.blocks.length - 1}
+ title="Move Down"
+ style={{ padding: '2px 6px' }}
+ >
+ ▼
+
+ handleDeleteBlock(idx)}
+ title="Delete Block"
+ style={{ padding: '2px 6px', color: '#ff4d4f' }}
+ >
+
+
+
+
+
+ {/* Render Block Form Controls */}
+ {block.type === 'heading' && (
+
+
+ handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
+ }
+ style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
+ >
+ H1
+ H2
+ H3
+
+ handleUpdateBlock(idx, { ...block, text: e.target.value })}
+ placeholder="Section title..."
+ style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
+ />
+
+ )}
+
+ {block.type === 'paragraph' && (
+
+ ))}
+
+ );
+}
diff --git a/app/admin/components/Icons.tsx b/app/admin/components/Icons.tsx
new file mode 100644
index 0000000..c3fa7c7
--- /dev/null
+++ b/app/admin/components/Icons.tsx
@@ -0,0 +1,267 @@
+'use client';
+
+import React from 'react';
+
+export interface IconProps extends React.SVGProps {
+ size?: number;
+ className?: string;
+ strokeWidth?: number;
+}
+
+export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+
+ );
+}
+
+export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+
+ );
+}
+
+export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
+
+export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+ );
+}
+
+export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+ );
+}
+
+export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
+ return (
+
+
+
+
+
+ );
+}
diff --git a/app/admin/components/PageBuilder.tsx b/app/admin/components/PageBuilder.tsx
index fea05f6..6d80d33 100644
--- a/app/admin/components/PageBuilder.tsx
+++ b/app/admin/components/PageBuilder.tsx
@@ -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 (
+
+ {sp.enabled === false && (
+
+ Disabled
+
+ )}
+
+ {sp.password && (
+
+ Password
+
+ )}
+
{firstThumb ? (
@@ -339,11 +357,17 @@ function SortableSubpageTile({
)}
+
+ Edit Page
+
-
{sp.title || sp.name}
+
+ {sp.title || sp.name}
+ /{slug}
+
- {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
+ {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
@@ -360,6 +384,7 @@ export default function PageBuilder() {
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [expandedSubpage, setExpandedSubpage] = useState(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> | undefined),
sections: sp.sections
? (sp.sections as Array>).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).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;
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> | 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 */}
-
🔍
+
- {/* Expanded subpage detail */}
+ {/* Expanded subpage detail (Slide-Over Drawer) */}
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
-
- {(() => {
- const sp = gallery.subpages[expandedSubpage];
- const spIndex = expandedSubpage;
- return (
- <>
-
-
-
updateSubpage(spIndex, { name: e.target.value })}
- placeholder="Page name (used for URL)"
- onClick={(e) => e.stopPropagation()}
- />
-
/{slugify(sp.name)}
+
setExpandedSubpage(null)}>
+
e.stopPropagation()}>
+ {(() => {
+ const sp = gallery.subpages[expandedSubpage];
+ const spIndex = expandedSubpage;
+ return (
+ <>
+
+
+ Pages / Subpages
+
{sp.title || sp.name || 'Untitled Page'}
+
+
+
+ setDrawerMode('edit')}
+ style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
+ >
+ Edit
+
+ setDrawerMode('preview')}
+ style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
+ >
+ Live Preview
+
+
+
+
+ /{slugify(sp.name)} ↗
+
+
setExpandedSubpage(null)}
+ title="Close drawer"
+ >
+ ✕
+
+
-
-
e.stopPropagation()}
- >
- ↗
-
+
+
+ {drawerMode === 'preview' ? (
+
+
+
{sp.title || sp.name || 'Untitled Page'}
+ {sp.subtitle &&
{sp.subtitle}
}
+
+
+ {sp.sections && sp.sections.length > 0 ? (
+ sp.sections.map((sec, sIdx) => (
+
+
{sec.title || 'Untitled Section'}
+ {sec.description &&
{sec.description}
}
+
+ {sec.albums.map((alb, aIdx) => {
+ const immichAlb = immichAlbums.find((a) => a.id === alb.id);
+ const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
+
+ return (
+
+
+ {thumb ? (
+
+ ) : (
+
+ )}
+
+
+ {alb.title || immichAlb?.albumName || alb.id}
+
+
+ );
+ })}
+
+
+ ))
+ ) : (
+
+ {sp.albums.map((alb, aIdx) => {
+ const immichAlb = immichAlbums.find((a) => a.id === alb.id);
+ const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
+
+ return (
+
+
+ {thumb ? (
+
+ ) : (
+
+ )}
+
+
+ {alb.title || immichAlb?.albumName || alb.id}
+
+
+ );
+ })}
+
+ )}
+
+ ) : (
+ <>
+
+
+
Page Name (URL Identifier)
+
+ updateSubpage(spIndex, { name: e.target.value })}
+ placeholder="e.g. dubai, travel, weddings"
+ />
+ /{slugify(sp.name)}
+
+
+
+
+ {/* Subpage metadata */}
+
+
+
+
Password Protection (optional)
+
+
+
+ updateSubpage(spIndex, { password: e.target.value || undefined })
+ }
+ placeholder="Leave empty for public access"
+ />
+
+
+
+
+
Page Visibility Status
+
updateSubpage(spIndex, { enabled: sp.enabled === false })}
+ style={{ padding: '10px 14px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderRadius: '8px', cursor: 'pointer' }}
+ >
+
+
+ {sp.enabled !== false ? '🟢 Page Active (Published)' : '🔴 Page Disabled (Hidden)'}
+
+
+ {sp.enabled !== false
+ ? 'Visible in header menu and reachable via URL'
+ : 'Hidden from navigation menu. Returns 404 if accessed directly.'}
+
+
+
+
+
+
+
+
+ Page Layout Style
+ {
+ const newLayout = e.target.value;
+ updateSubpage(spIndex, {
+ grid: { ...(sp.grid || {}), layout: newLayout },
+ });
+ }}
+ style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
+ >
+ 📷 Masonry Grid (Dynamic Height)
+ 🔲 Uniform Grid (Square Tiles)
+ ⭐ Showcase (Featured First Asset)
+ 🎞️ Filmstrip (Horizontal Scroll)
+ 📰 Editorial Flow
+ 📖 Photo Essay Mode (Storytelling Editor)
+
+
+
+
+ {/* Visual Photo Essay Builder (if layout === 'essay') */}
+ {(sp.grid?.layout === 'essay' || sp.essayText != null) && (
+
+
+
+ 📖 Storytelling & Photo Essay Builder
+
+
+ Compose text, quotes, and fullbleed/wide photos visually.
+
+
+
+
updateSubpage(spIndex, { essayText: newMarkdown })}
+ onSelectPhoto={(callback) => {
+ setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
+ // We can pass a photo picker handler if needed
+ }}
+ />
+
+ )}
+
+
+
+ {/* Albums (if no sections) with DnD */}
+ {(!sp.sections || sp.sections.length === 0) && (
+
+
+ Albums in this Page
+
+ setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
+ }
+ >
+ + Add Album
+
+
+
+ `album-${a.id}-${i}`)}
+ strategy={verticalListSortingStrategy}
+ >
+
+ {sp.albums.length === 0 && (
+
No albums added yet. Click + Add Album to pick from Immich.
+ )}
+ {sp.albums.map((album, aIndex) => (
+
removeSubpageAlbum(spIndex, aIndex)}
+ onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
+ />
+ ))}
+
+
+
+
+ )}
+
+ {/* Sections */}
+ {sp.sections && sp.sections.length > 0 && (
+
+ {sp.sections.map((sec, secIndex) => (
+
+
+
+ updateSection(spIndex, secIndex, { title: e.target.value })
+ }
+ placeholder="Section title"
+ />
+ removeSection(spIndex, secIndex)}
+ title="Remove section"
+ >
+
+
+
+
+
+ updateSection(spIndex, secIndex, {
+ description: e.target.value || undefined,
+ })
+ }
+ placeholder="Section description (optional)"
+ className="section-desc-input"
+ />
+
+
+ {sec.albums.map((album, aIndex) => (
+
removeSectionAlbum(spIndex, secIndex, aIndex)}
+ onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
+ />
+ ))}
+
+
+ setPickerTarget({
+ type: 'section',
+ subpageIndex: spIndex,
+ sectionIndex: secIndex,
+ })
+ }
+ >
+ + Add Album
+
+
+ ))}
+
+ )}
+
+
+ addSection(spIndex)}>
+ + Add Section
+
+ {sp.sections && sp.sections.length > 0 && (
+ setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
+ >
+ + Add Loose Album
+
+ )}
+
+ >
+ )}
+
+
+
setExpandedSubpage(null)}
- title="Collapse"
- >
- ✕
-
- {
removeSubpage(spIndex);
setExpandedSubpage(null);
}}
- title="Delete subpage"
>
- 🗑
+ Delete Subpage
-
-
-
- {/* Subpage metadata */}
-
-
-
-
- Password (optional)
-
- updateSubpage(spIndex, { password: e.target.value || undefined })
- }
- placeholder="Leave empty for public"
- />
-
-
-
-
- {/* Albums (if no sections) with DnD */}
- {(!sp.sections || sp.sections.length === 0) && (
-
-
- Albums
-
- setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
- }
- >
- + Add
-
-
-
- `album-${a.id}-${i}`)}
- strategy={verticalListSortingStrategy}
- >
-
- {sp.albums.map((album, aIndex) => (
- removeSubpageAlbum(spIndex, aIndex)}
- onEdit={() => setEditingAlbumAddress({ type: 'subpage', subpageIndex: spIndex, albumIndex: aIndex })}
- />
- ))}
-
-
-
-
- )}
-
- {/* Sections */}
- {sp.sections && sp.sections.length > 0 && (
-
- {sp.sections.map((sec, secIndex) => (
-
-
-
- updateSection(spIndex, secIndex, { title: e.target.value })
- }
- placeholder="Section title"
- />
- removeSection(spIndex, secIndex)}
- title="Remove section"
- >
-
-
-
-
-
- updateSection(spIndex, secIndex, {
- description: e.target.value || undefined,
- })
- }
- placeholder="Section description (optional)"
- className="section-desc-input"
- />
-
-
- {sec.albums.map((album, aIndex) => (
-
removeSectionAlbum(spIndex, secIndex, aIndex)}
- onEdit={() => setEditingAlbumAddress({ type: 'section', subpageIndex: spIndex, sectionIndex: secIndex, albumIndex: aIndex })}
- />
- ))}
-
-
- setPickerTarget({
- type: 'section',
- subpageIndex: spIndex,
- sectionIndex: secIndex,
- })
- }
- >
- + Add Album
-
-
- ))}
-
- )}
-
-
- addSection(spIndex)}>
- + Add Section
-
- {sp.sections && sp.sections.length > 0 && (
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
+ className="admin-btn admin-btn-primary admin-btn-sm"
+ onClick={() => setExpandedSubpage(null)}
>
- + Add Album (outside sections)
+ Done
- )}
-
- >
- );
- })()}
+
+ >
+ );
+ })()}
+
)}
diff --git a/app/admin/components/SettingsEditor.tsx b/app/admin/components/SettingsEditor.tsx
index 221c0ce..f500c1f 100644
--- a/app/admin/components/SettingsEditor.tsx
+++ b/app/admin/components/SettingsEditor.tsx
@@ -1,6 +1,8 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
+import { useRouter } from 'next/navigation';
+import * as Icons from './Icons';
interface Settings {
title?: string;
@@ -9,6 +11,7 @@ interface Settings {
exifOnHover?: boolean;
map?: boolean;
transitions?: boolean;
+ analytics?: boolean;
theme?: {
preset?: string;
accent?: string;
@@ -44,9 +47,24 @@ interface Settings {
seo?: {
title?: string;
description?: string;
+ titleTemplate?: string;
noIndex?: boolean;
noFollow?: boolean;
};
+ protection?: {
+ disableRightClick?: boolean;
+ disableImageDrag?: boolean;
+ };
+ proofing?: {
+ enabled?: boolean;
+ allowMailto?: boolean;
+ };
+ watermark?: {
+ enabled?: boolean;
+ text?: string;
+ opacity?: number;
+ position?: 'bottom-right' | 'bottom-left' | 'center';
+ };
}
const PRESETS = ['studio', 'minimal', 'editorial', 'classic', 'noir', 'monograph'];
@@ -101,26 +119,43 @@ const THEME_INFO: Record
({});
const [loading, setLoading] = useState(true);
const [saving, setSaving] = useState(false);
const [dirty, setDirty] = useState(false);
const [saveMessage, setSaveMessage] = useState('');
const [activeSection, setActiveSection] = useState('general');
+ const [currentMode, setCurrentMode] = useState<'dark' | 'light'>('dark');
useEffect(() => {
loadSettings();
+ if (typeof window !== 'undefined') {
+ const mode = (document.documentElement.getAttribute('data-theme') as 'dark' | 'light') || 'dark';
+ setCurrentMode(mode);
+ }
}, []);
- // Sync picked accent color to the admin panel UI immediately for live premium feel
+ // Sync picked preset & accent color to document element immediately for live feedback
useEffect(() => {
- if (settings.theme?.accent) {
- document.documentElement.style.setProperty('--admin-accent', settings.theme.accent);
+ if (typeof document !== 'undefined') {
+ if (settings.theme?.preset) {
+ document.documentElement.setAttribute('data-preset', settings.theme.preset);
+ }
+ if (settings.theme?.accent) {
+ document.documentElement.style.setProperty('--accent', settings.theme.accent);
+ document.documentElement.style.setProperty('--admin-accent', settings.theme.accent);
+ }
}
- return () => {
- document.documentElement.style.removeProperty('--admin-accent');
- };
- }, [settings.theme?.accent]);
+ }, [settings.theme?.preset, settings.theme?.accent]);
+
+ const toggleMode = (mode: 'dark' | 'light') => {
+ setCurrentMode(mode);
+ if (typeof document !== 'undefined') {
+ document.documentElement.setAttribute('data-theme', mode);
+ localStorage.setItem('theme', mode);
+ }
+ };
// ── Keyboard shortcut: ⌘+S / Ctrl+S ─────────────────────────
const handleSaveRef = useCallback(() => {
@@ -212,6 +247,7 @@ export default function SettingsEditor() {
const data = await res.json();
setDirty(false);
setSaveMessage(data.message || 'Saved!');
+ router.refresh();
setTimeout(() => setSaveMessage(''), 5000);
} else {
const err = await res.json();
@@ -239,6 +275,7 @@ export default function SettingsEditor() {
{ id: 'footer', label: 'Footer' },
{ id: 'legal', label: 'Legal' },
{ id: 'seo', label: 'SEO' },
+ { id: 'security', label: 'Security & Protection' },
];
return (
@@ -247,12 +284,16 @@ export default function SettingsEditor() {
{dirty && Unsaved changes }
- {saveMessage && (
+ {saveMessage ? (
{saveMessage}
+ ) : (
+
+ ⚡ Live Sync (No Docker restart required)
+
)}
{activeSection === 'general' && (
-
General
+
+
General Site Settings
+
Configure basic site identity, language, and core feature toggles.
+
+
Site Title
update('lang', e.target.value)}
>
- English
- Deutsch
- Français
- Español
- 日本語
+ English (US)
+ Deutsch (DE)
+ Français (FR)
+ Español (ES)
+ 日本語 (JA)
-
-
- update('exifOnHover', e.target.checked)}
- />
- EXIF on Hover
-
-
- update('map', e.target.checked)}
- />
- Map enabled
-
-
- update('transitions', e.target.checked)}
- />
- Page Transitions
-
+
+
+
+
+
Portfolio Features & Modules
+
Enable or disable optional portfolio modules, privacy analytics, and map widgets.
+
+
+
+
update('exifOnHover', settings.exifOnHover === false)}
+ >
+
+ EXIF Data on Hover
+ Display camera gear, lens, aperture & shutter speed on hover
+
+
+
+
+
+
+
update('map', !settings.map)}
+ >
+
+ Interactive GPS Map
+ Enable /map view showing photo locations on a world map
+
+
+
+
+
+
+
update('transitions', settings.transitions === false)}
+ >
+
+ Smooth Page Transitions
+ Enable subtle fade-in animations between page navigation
+
+
+
+
+
+
+
update('analytics', settings.analytics === false)}
+ >
+
+ Analytics Tracking
+ Collect anonymous privacy-friendly visit statistics
+
+
+
+
+
+
+
update('proofing.enabled', settings.proofing?.enabled === false)}
+ >
+
+ Client Proofing & Favorites
+ Allow visitors & clients to heart, filter, and export favorite photo selections
+
+
+
+
+
)}
{activeSection === 'theme' && (
-
-
Theme
+
+
+
Theme Presets & Color Mode
+
Choose a typography preset and preview in Light or Dark mode.
+
+
Preset
@@ -378,92 +480,356 @@ export default function SettingsEditor() {
})}
+
-
Accent Color
-
-
update('theme.accent', e.target.value)}
- />
-
update('theme.accent', e.target.value)}
- placeholder="#e60012"
- />
+
Color Mode
+
+ Presets like Editorial , Minimal & Classic feature warm light/cream backgrounds in Light Mode and charcoal in Dark Mode.
+
+
+ toggleMode('light')}
+ >
+ Light Mode (Cream / Beige)
+
+ toggleMode('dark')}
+ >
+ Dark Mode (Charcoal)
+
+
+
+
+
+
Accent Color
+
Pick a primary accent color for links, buttons, and highlights.
+
+
+
+
+
+ {[
+ { hex: '#e60012', name: 'Studio Red' },
+ { hex: '#b89053', name: 'Editorial Gold' },
+ { hex: '#10b981', name: 'Emerald' },
+ { hex: '#3b82f6', name: 'Sapphire' },
+ { hex: '#8b5cf6', name: 'Violet' },
+ { hex: '#ffffff', name: 'Monochrome White' },
+ { hex: '#000000', name: 'Obsidian Black' },
+ ].map((swatch) => {
+ const isSelected = (settings.theme?.accent || '#e60012').toLowerCase() === swatch.hex.toLowerCase();
+ return (
+ update('theme.accent', swatch.hex)}
+ title={swatch.name}
+ />
+ );
+ })}
+
+
+ update('theme.accent', e.target.value)}
+ />
+ update('theme.accent', e.target.value)}
+ placeholder="#e60012"
+ />
+
+
+
+
+
+
+
+
Photo Frame & Layout
+
Customize image presentation borders and hero layouts.
+
+
Photo Frame
-
- {PHOTO_FRAMES.map((f) => (
-
- update('theme.photoFrame', f)}
- />
- {f}
-
- ))}
+
+ {PHOTO_FRAMES.map((f) => {
+ const info = {
+ none: { label: 'None', desc: 'Flush image with crisp edges' },
+ passepartout: { label: 'Passepartout', desc: 'Classic gallery matting border' },
+ shadow: { label: 'Shadow', desc: 'Soft floating drop shadow' },
+ }[f] || { label: f, desc: '' };
+ const isActive = (settings.theme?.photoFrame || 'none') === f;
+
+ return (
+
update('theme.photoFrame', f)}
+ >
+
+
+ {info.label}
+ {info.desc}
+
+
+ );
+ })}
+
Hero Style
-
update('theme.heroStyle', e.target.value)}
- >
- {HERO_STYLES.map((s) => (
-
- {s}
-
- ))}
-
+
+ {HERO_STYLES.map((s) => {
+ const info = {
+ split: { label: 'Split', desc: 'Side-by-side title & photo' },
+ fullbleed: { label: 'Fullbleed', desc: 'Edge-to-edge full width banner' },
+ minimal: { label: 'Minimal', desc: 'Centered title with subtle photo' },
+ stacked: { label: 'Stacked', desc: 'Title stacked directly over photo' },
+ typographic: { label: 'Typographic', desc: 'Oversized magazine masthead' },
+ mosaic: { label: 'Mosaic', desc: 'Dynamic photo collage layout' },
+ }[s] || { label: s, desc: '' };
+ const isActive = (settings.theme?.heroStyle || 'split') === s;
+
+ return (
+
update('theme.heroStyle', s)}
+ >
+
+
+ {s === 'split' && (
+ <>
+
+
+ >
+ )}
+ {s === 'fullbleed' && (
+
+ )}
+ {s === 'minimal' && (
+ <>
+
+
+ >
+ )}
+ {s === 'stacked' && (
+ <>
+
+
+ >
+ )}
+ {s === 'typographic' && (
+ <>
+
+
+ >
+ )}
+ {s === 'mosaic' && (
+
+ )}
+
+
+
+ {info.label}
+ {info.desc}
+
+
+ );
+ })}
+
-
-
- update('theme.grain', e.target.checked)}
- />
- Film Grain
-
-
- update('theme.headerDot', e.target.checked)}
- />
- Header Dot
-
+
+
+
+
+
Finishing Touches
+
Enable optional visual effects and indicators.
+
+
+
+
update('theme.grain', !settings.theme?.grain)}
+ >
+
+ Film Grain Texture
+ Adds analog noise overlay across portfolio background
+
+
+
+
+
+
+
update('theme.headerDot', settings.theme?.headerDot === false)}
+ >
+
+ Header Accent Dot
+ Displays accent dot next to active section header
+
+
+
+
+
)}
{activeSection === 'grid' && (
-
Grid Layout
+
+
Grid & Layout Engine
+
Configure photography gallery column structures and thumbnail aspect ratios.
+
+
-
Layout
-
- {LAYOUTS.map((l) => (
-
update('grid.layout', l)}
- >
- {l}
-
- ))}
+
Layout Algorithm
+
+ {LAYOUTS.map((l) => {
+ const info = {
+ masonry: { label: 'Masonry', desc: 'Dynamic pinterest-style staggered columns' },
+ uniform: { label: 'Uniform Grid', desc: 'Clean equal aspect ratio grid' },
+ showcase: { label: 'Showcase', desc: 'Featured hero photos mixed with smaller tiles' },
+ filmstrip: { label: 'Filmstrip', desc: 'Horizontal scrollable film strip timeline' },
+ 'editorial-flow': { label: 'Editorial Flow', desc: 'Magazine story layout with varying photo sizes' },
+ }[l] || { label: l, desc: '' };
+ const isActive = (settings.grid?.layout || 'masonry') === l;
+
+ return (
+
update('grid.layout', l)}
+ >
+
+
+ {l === 'masonry' && (
+
+ )}
+ {l === 'uniform' && (
+
+ )}
+ {l === 'showcase' && (
+
+ )}
+ {l === 'filmstrip' && (
+
+ )}
+ {l === 'editorial-flow' && (
+
+ )}
+
+
+
+ {info.label}
+ {info.desc}
+
+
+ );
+ })}
+
+
+
Aspect Ratio
+
+ {ASPECT_RATIOS.map((r) => {
+ const info = {
+ '1': { label: 'Square (1:1)', desc: '1:1 ratio square crops' },
+ '3/2': { label: 'Landscape (3:2)', desc: 'Standard 35mm DSLR landscape' },
+ '2/3': { label: 'Portrait (2:3)', desc: 'Vertical portrait orientation' },
+ '16/9': { label: 'Cinema (16:9)', desc: 'Widescreen 16:9 cinematic ratio' },
+ auto: { label: 'Original Auto', desc: 'Uncropped original image proportions' },
+ }[r] || { label: r, desc: '' };
+ const isActive = (settings.grid?.aspectRatio || '1') === r;
+
+ return (
+
update('grid.aspectRatio', r)}
+ >
+
+
+ {info.label}
+ {info.desc}
+
+
+ );
+ })}
+
+
+
+
+
+
+
Spacing & Columns
+
Adjust column counts and grid gap spacing.
+
+
-
-
Aspect Ratio
-
- {ASPECT_RATIOS.map((r) => (
- update('grid.aspectRatio', r)}
- >
- {r === '1' ? 'Square' : r}
-
- ))}
-
-
)}
{activeSection === 'footer' && (
-
Footer
+
+
Footer & Social Links
+
Display branding, Instagram, email and website links in portfolio footer.
+
+
- Name
+ Footer Brand Name
update('footer.name', e.target.value)}
@@ -519,59 +875,73 @@ export default function SettingsEditor() {
placeholder="https://instagram.com/your-handle"
/>
-
- Email
- update('footer.email', e.target.value)}
- placeholder="hello@example.com"
- />
-
-
-
Website
-
update('footer.website', e.target.value)}
- placeholder="https://example.com"
- />
+
)}
{activeSection === 'legal' && (
-
Legal / Impressum
-
-
- update('legal.enabled', e.target.checked)}
- />
- Enable Impressum page
-
+
+
Legal Notice & Impressum
+
Configure required legal disclosure page for EU / German Telemediengesetz compliance.
+
+
+
update('legal.enabled', !settings.legal?.enabled)}
+ >
+
+ Enable Impressum Page (/impressum)
+ Automatically generates and links /impressum in footer
+
+
+
+
+
+
+
{settings.legal?.enabled && (
<>
-
- Full Name
- update('legal.name', e.target.value)}
- placeholder="Max Mustermann"
- />
-
-
-
Address
-
update('legal.address', e.target.value)}
- placeholder="Musterstraße 1"
- />
+
- ZIP & City
+ ZIP & City
update('legal.zipCity', e.target.value)}
@@ -589,7 +959,7 @@ export default function SettingsEditor() {
- Extra Info
+ Additional Disclosures / Tax ID
@@ -621,42 +991,202 @@ export default function SettingsEditor() {
{activeSection === 'seo' && (
-
SEO & Metadata
+
+
Search Engine Optimization (SEO)
+
Customize search engine metadata, OpenGraph tags, and indexing rules.
+
+
+ {/* Live Google Search Result Snippet Card */}
+
+
+ Google Search Result Preview
+
+
+
+ https://yourportfolio.com ▼
+
+
+ {settings.seo?.title || settings.title || 'My Photography Portfolio'}
+
+
+ {settings.seo?.description || settings.subtitle || 'A curated selection of photography work.'}
+
+
+
+
- SEO Title
+ SEO Meta Title
update('seo.title', e.target.value)}
- placeholder="Overrides site title for search engines"
+ placeholder="Overrides default site title for Google search results"
/>
+
-
SEO Description
+
Subpage Title Template
+
update('seo.titleTemplate', e.target.value)}
+ placeholder={`%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`}
+ />
+
+ Template for subpages & albums. Use %s as placeholder for the page title.
+
+ Preview: {' '}
+ {(
+ settings.seo?.titleTemplate ||
+ `%s | ${settings.seo?.title || settings.title || 'My Portfolio'}`
+ ).replace('%s', 'Landscapes')}
+
+
+
+
+ SEO Meta Description
-
-
- update('seo.noIndex', e.target.checked)}
- />
- noindex (hide from search engines)
-
-
- update('seo.noFollow', e.target.checked)}
- />
- nofollow (don't follow links)
-
+
+
+
+
+
Search Crawler Directives
+
Control how Googlebot and other web crawlers index your site.
+
+
+
update('seo.noIndex', !settings.seo?.noIndex)}
+ >
+
+ noindex (Hide from Google)
+ Instructs search engines NOT to index this site in search results
+
+
+
+
+
+
+
update('seo.noFollow', !settings.seo?.noFollow)}
+ >
+
+ nofollow (Block Link Following)
+ Instructs search engine crawlers not to follow outgoing links
+
+
+
+
+
+
+
+ )}
+
+ {activeSection === 'security' && (
+
+
+
Asset Protection & Watermark
+
Configure image protection rules, right-click prevention, and watermark overlay.
+
+
+
+
update('protection.disableRightClick', !settings.protection?.disableRightClick)}
+ >
+
+ Disable Right-Click Menu
+ Prevents context menu on portfolio images to hinder unauthorized downloads
+
+
+
+
+
+
+
update('protection.disableImageDrag', !settings.protection?.disableImageDrag)}
+ >
+
+ Disable Image Dragging
+ Prevents visitors from dragging images off the portfolio page
+
+
+
+
+
+
+
+
+
+
+
Dynamic Watermark Overlay
+
Overlay copyright branding text on Lightbox images.
+
+
+
+
update('watermark.enabled', !settings.watermark?.enabled)}
+ >
+
+ Enable Watermark
+ Overlay copyright text on portfolio image views
+
+
+
+
+
+
+
+ {settings.watermark?.enabled && (
+ <>
+
+ Watermark Text
+ update('watermark.text', e.target.value)}
+ placeholder="© Ralfo Photography"
+ />
+
+
+
+
+ Position
+ update('watermark.position', e.target.value)}
+ >
+ Bottom Right
+ Bottom Left
+ Center Overlay
+
+
+
+ Opacity ({Math.round((settings.watermark?.opacity ?? 0.3) * 100)}%)
+ update('watermark.opacity', parseFloat(e.target.value))}
+ />
+
+
+ >
+ )}
)}
diff --git a/app/api/admin/analytics/route.ts b/app/api/admin/analytics/route.ts
new file mode 100644
index 0000000..97e5e8d
--- /dev/null
+++ b/app/api/admin/analytics/route.ts
@@ -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
= {};
+ 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 });
+ }
+}
diff --git a/app/api/admin/backups/route.ts b/app/api/admin/backups/route.ts
new file mode 100644
index 0000000..3415eed
--- /dev/null
+++ b/app/api/admin/backups/route.ts
@@ -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 });
+ }
+}
diff --git a/app/api/analytics/track/route.ts b/app/api/analytics/track/route.ts
new file mode 100644
index 0000000..935b13b
--- /dev/null
+++ b/app/api/analytics/track/route.ts
@@ -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;
+ devices: Record;
+ }
+ >;
+}
+
+async function getAnalytics(): Promise {
+ 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 });
+ }
+}
diff --git a/app/layout.tsx b/app/layout.tsx
index ff78d93..637fcbd 100644
--- a/app/layout.tsx
+++ b/app/layout.tsx
@@ -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 {
const config = getConfigOrNull();
@@ -33,7 +35,10 @@ export async function generateMetadata(): Promise {
};
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 (
-
- Skip to content
-
-
-
-
- Home
-
-
-
- About
-
- {config.map && (
-
- Map
-
- )}
-
-
-
-
- {children}
-
-
-
- {process.env.NODE_ENV === 'development' && }
+ {isAdmin ? (
+ children
+ ) : (
+ <>
+
+ Skip to content
+
+
+
+
+ Home
+
+
+
+ About
+
+ {config.map && (
+
+ Map
+
+ )}
+
+
+
+
+ {children}
+
+
+
+
+
+ {process.env.NODE_ENV === 'development' && }
+ >
+ )}