security: add Trivy container scan to Docker publish workflow (#399)
* feat(admin): add backup manager UI with 1-click restore * feat(admin): collapse older backup entries by default * fix(theme): isolate admin layout, instant DOM theme updates, and add Light/Dark mode toggle * feat(admin): replace text controls for Photo Frame and Hero Style with visual preview cards * fix(admin): define missing CSS rules for button system (.btn, .preset-btn) * feat(admin): redesign theme settings with segmented mode toggle, accent color swatches, and custom iOS-style toggle cards * feat(admin): upgrade all settings tabs with visual grid previews, Google SEO snippet preview, and toggle cards * feat(admin): overhaul Pages Builder UX with slide-over drawer, card hover badges, real-time search, and floating save bar * fix(admin): remove leftover dangling syntax snippet in PageBuilder.tsx * fix(admin): add position: relative to subpage-tile so hover overlay is contained inside the tile * style(admin): replace all emojis with clean Leica-style vector SVG icons * fix(types): extend IconProps with SVGProps to allow style prop on Leica SVG icons * feat(admin): add subpage live-preview, privacy analytics, and asset protection settings * fix(typecheck): fix AlbumEntry property access in subpage drawer preview mode * fix(types): fix ImmichAlbumInfo property names thumbnailAssetId and albumName in PageBuilder.tsx * fix(types): add protection and watermark optional properties to AppConfig interface * feat(settings): add toggle for disabling analytics tracking (defaults to enabled) * feat(ux): show status banner when analytics tracking is disabled + add live sync indicator * feat(watermark): render customizable watermark overlay on Lightbox images + rename section to Portfolio Features & Modules * fix(types): import type LightboxWatermark in PhotoGrid.tsx * fix(types): destructure watermark prop in PhotoGrid function signature * feat(seo): add configurable subpage title template and metadata descriptions * feat(seo): add generateMetadata to about page for title template support * feat(proofing): add client proofing, favorites selection, compact URL bitmasking, and export modal * feat(essay): add Photo Essay storytelling mode with RSC markdown parser and fullbleed/pair layouts * feat(subpages): add page enabled toggle to deactivate subpages without deleting them * feat(admin): add visual essay block builder for photo essay storytelling pages * security: add Trivy container scan to Docker publish workflow Scans the built image for CRITICAL/HIGH CVEs on every release and uploads results to the GitHub Security tab. Non-blocking for now (exit-code 0) so releases aren't gated on findings. Also documents the scan and existing Dependabot coverage in the README. --------- Co-authored-by: ralksta <ralksta@users.noreply.github.com>
This commit is contained in:
16
.github/workflows/docker-publish.yml
vendored
16
.github/workflows/docker-publish.yml
vendored
@@ -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
|
||||
|
||||
@@ -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)
|
||||
|
||||
</details>
|
||||
|
||||
|
||||
@@ -3,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid';
|
||||
import { PhotoGrid } from './PhotoGrid';
|
||||
import { BackLink } from '@/components/BackLink';
|
||||
import type { GridConfig } from '@/lib/config';
|
||||
import type { LightboxWatermark } from '@/components/Lightbox';
|
||||
import Image from 'next/image';
|
||||
import React from 'react';
|
||||
|
||||
@@ -16,6 +17,7 @@ interface AlbumDetailViewProps {
|
||||
subtitle?: string;
|
||||
heroImageUrl?: string;
|
||||
heroBlurDataURL?: string;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
export function AlbumDetailView({
|
||||
@@ -28,6 +30,7 @@ export function AlbumDetailView({
|
||||
subtitle,
|
||||
heroImageUrl,
|
||||
heroBlurDataURL,
|
||||
watermark,
|
||||
}: AlbumDetailViewProps) {
|
||||
return (
|
||||
<>
|
||||
@@ -58,7 +61,7 @@ export function AlbumDetailView({
|
||||
{images.length} {images.length === 1 ? 'photo' : 'photos'}
|
||||
</p>
|
||||
</div>
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} />
|
||||
<PhotoGrid assets={images} layout={layout} gridStyle={gridStyle} watermark={watermark} />
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
351
app/[...path]/EssayView.tsx
Normal file
351
app/[...path]/EssayView.tsx
Normal file
@@ -0,0 +1,351 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
import type { ParsedEssay, EssayBlock } from '@/lib/essay';
|
||||
import type { PhotoItem } from './PhotoGrid';
|
||||
import './essay.css';
|
||||
|
||||
interface EssayViewProps {
|
||||
essay: ParsedEssay;
|
||||
assets: PhotoItem[];
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayViewProps) {
|
||||
const proofing = useProofing();
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
// Map assetId / index to PhotoItem and its index in `assets`
|
||||
const assetMap = useMemo(() => {
|
||||
const map = new Map<string, { item: PhotoItem; index: number }>();
|
||||
assets.forEach((item, index) => {
|
||||
map.set(item.id, { item, index });
|
||||
// Also map by 1-based index string ("1", "2") for convenience
|
||||
map.set(`${index + 1}`, { item, index });
|
||||
});
|
||||
return map;
|
||||
}, [assets]);
|
||||
|
||||
const displayTitle = title || essay.frontmatter.title;
|
||||
const displaySubtitle = subtitle || essay.frontmatter.subtitle;
|
||||
|
||||
// Cover photo if specified
|
||||
const coverAsset = essay.frontmatter.coverAssetId
|
||||
? assetMap.get(essay.frontmatter.coverAssetId)
|
||||
: undefined;
|
||||
|
||||
const renderBlock = (block: EssayBlock, idx: number) => {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const Tag = `h${Math.min(block.level + 1, 4)}` as keyof React.JSX.IntrinsicElements;
|
||||
return (
|
||||
<Tag key={idx} className={`essay-heading essay-heading--${block.level}`}>
|
||||
{block.text}
|
||||
</Tag>
|
||||
);
|
||||
}
|
||||
|
||||
case 'paragraph':
|
||||
return (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-prose"
|
||||
dangerouslySetInnerHTML={{ __html: block.html }}
|
||||
/>
|
||||
);
|
||||
|
||||
case 'quote':
|
||||
return (
|
||||
<blockquote key={idx} className="essay-quote">
|
||||
<div dangerouslySetInnerHTML={{ __html: block.text }} />
|
||||
{block.author && <span className="essay-quote__author">— {block.author}</span>}
|
||||
</blockquote>
|
||||
);
|
||||
|
||||
case 'photo': {
|
||||
const resolved = assetMap.get(block.assetId);
|
||||
if (!resolved) return null;
|
||||
const { item, index } = resolved;
|
||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||
|
||||
return (
|
||||
<FadeIn key={idx}>
|
||||
<figure className={`essay-figure essay-figure--${block.layout}`}>
|
||||
<div
|
||||
className="essay-image-wrapper photo-grid__item"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
style={{
|
||||
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
||||
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.previewUrl || item.thumbUrl}
|
||||
alt={block.caption || ''}
|
||||
fill
|
||||
sizes={
|
||||
block.layout === 'fullbleed'
|
||||
? '100vw'
|
||||
: block.layout === 'wide'
|
||||
? '(max-width: 1100px) 100vw, 1100px'
|
||||
: '(max-width: 768px) 100vw, 680px'
|
||||
}
|
||||
loading={idx < 4 ? 'eager' : 'lazy'}
|
||||
{...(item.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{proofing && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-grid__fav-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
proofing.toggleFavorite(item.id);
|
||||
}}
|
||||
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
zIndex: 5,
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill={isFav ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{block.caption && (
|
||||
<figcaption
|
||||
className="essay-figcaption"
|
||||
dangerouslySetInnerHTML={{ __html: block.caption }}
|
||||
/>
|
||||
)}
|
||||
</figure>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
case 'photo-pair': {
|
||||
const res1 = assetMap.get(block.assetIds[0]);
|
||||
const res2 = assetMap.get(block.assetIds[1]);
|
||||
if (!res1 || !res2) return null;
|
||||
|
||||
return (
|
||||
<FadeIn key={idx}>
|
||||
<div className="essay-figure essay-figure--wide">
|
||||
<div className="essay-pair-grid">
|
||||
{[res1, res2].map(({ item, index }, pIdx) => {
|
||||
const isFav = proofing ? proofing.isFavorite(item.id) : false;
|
||||
return (
|
||||
<div
|
||||
key={pIdx}
|
||||
className="essay-image-wrapper photo-grid__item"
|
||||
onClick={() => setLightboxIndex(index)}
|
||||
style={{
|
||||
...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}),
|
||||
...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}),
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={item.previewUrl || item.thumbUrl}
|
||||
alt=""
|
||||
fill
|
||||
sizes="(max-width: 640px) 100vw, 550px"
|
||||
loading="lazy"
|
||||
{...(item.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: item.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
{proofing && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-grid__fav-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
proofing.toggleFavorite(item.id);
|
||||
}}
|
||||
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '12px',
|
||||
right: '12px',
|
||||
zIndex: 5,
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||
}}
|
||||
>
|
||||
<svg viewBox="0 0 24 24" width="24" height="24" fill={isFav ? 'currentColor' : 'none'} stroke="currentColor" strokeWidth="2">
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{block.caption && (
|
||||
<figcaption
|
||||
className="essay-figcaption"
|
||||
dangerouslySetInnerHTML={{ __html: block.caption }}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
);
|
||||
}
|
||||
|
||||
default:
|
||||
return null;
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-container">
|
||||
<header className="essay-header">
|
||||
{displayTitle && <h1 className="essay-header__title">{displayTitle}</h1>}
|
||||
{displaySubtitle && <p className="essay-header__subtitle">{displaySubtitle}</p>}
|
||||
{(essay.frontmatter.author || essay.frontmatter.date) && (
|
||||
<div className="essay-header__meta">
|
||||
{[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')}
|
||||
</div>
|
||||
)}
|
||||
</header>
|
||||
|
||||
{coverAsset && (
|
||||
<figure className="essay-figure essay-figure--wide" style={{ marginBottom: '4rem' }}>
|
||||
<div
|
||||
className="essay-image-wrapper photo-grid__item"
|
||||
onClick={() => setLightboxIndex(coverAsset.index)}
|
||||
style={{
|
||||
...(coverAsset.item.dominantColor
|
||||
? { backgroundColor: coverAsset.item.dominantColor }
|
||||
: {}),
|
||||
...(coverAsset.item.aspectRatio
|
||||
? { aspectRatio: `${coverAsset.item.aspectRatio}` }
|
||||
: {}),
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
src={coverAsset.item.previewUrl || coverAsset.item.thumbUrl}
|
||||
alt=""
|
||||
fill
|
||||
priority
|
||||
sizes="(max-width: 1100px) 100vw, 1100px"
|
||||
{...(coverAsset.item.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: coverAsset.item.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
</div>
|
||||
</figure>
|
||||
)}
|
||||
|
||||
<main>{essay.blocks.map(renderBlock)}</main>
|
||||
|
||||
{proofing && proofing.favorites.size > 0 && (
|
||||
<div
|
||||
className="proofing-sticky-bar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
zIndex: 990,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '30px',
|
||||
background: 'var(--bg-surface, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
||||
style={{
|
||||
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => proofing.setIsModalOpen(true)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Share & Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProofingModal />
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
assets={assets}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={() => setLightboxIndex(null)}
|
||||
onNext={() => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))}
|
||||
onPrev={() => setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null))}
|
||||
watermark={watermark}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function EssayView(props: EssayViewProps) {
|
||||
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<EssayViewContent {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
}
|
||||
@@ -10,8 +10,10 @@
|
||||
|
||||
import { useState, useCallback, useEffect, useMemo } from 'react';
|
||||
import Image from 'next/image';
|
||||
import { Lightbox } from '@/components/Lightbox';
|
||||
import { Lightbox, type LightboxWatermark } from '@/components/Lightbox';
|
||||
import { FadeIn } from '@/components/FadeIn';
|
||||
import { ProofingProvider, useProofing } from '@/components/ProofingContext';
|
||||
import { ProofingModal } from '@/components/ProofingModal';
|
||||
|
||||
export interface PhotoItem {
|
||||
id: string;
|
||||
@@ -32,8 +34,9 @@ export interface PhotoItem {
|
||||
|
||||
interface PhotoGridProps {
|
||||
assets: PhotoItem[];
|
||||
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
||||
layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
|
||||
gridStyle?: React.CSSProperties;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
/** Parse `#photo-N` from a hash string. Returns index or null. */
|
||||
@@ -49,36 +52,42 @@ function buildPhotoHash(index: number): string {
|
||||
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
|
||||
}
|
||||
|
||||
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
|
||||
function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) {
|
||||
const proofing = useProofing();
|
||||
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
|
||||
|
||||
const displayedAssets = useMemo(() => {
|
||||
if (proofing && proofing.isFilterActive) {
|
||||
return assets.filter((a) => proofing.isFavorite(a.id));
|
||||
}
|
||||
return assets;
|
||||
}, [assets, proofing]);
|
||||
|
||||
// ── Initial hash check (Client-only to avoid hydration mismatch) ──
|
||||
useEffect(() => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < assets.length) {
|
||||
// state from URL hash on mount only (equivalent to useState lazy initializer for
|
||||
// client-only browser APIs).
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
// state from URL hash on mount only
|
||||
// eslint-disable-next-line react-hooks/set-state-in-effect
|
||||
setLightboxIndex(idx);
|
||||
}
|
||||
}, [assets.length]);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// ── Sync URL hash when lightbox state changes ─────────────────
|
||||
useEffect(() => {
|
||||
if (lightboxIndex !== null && lightboxIndex < assets.length) {
|
||||
if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) {
|
||||
const hash = buildPhotoHash(lightboxIndex);
|
||||
// Use replaceState to avoid flooding history with every prev/next tap
|
||||
if (window.location.hash !== hash) {
|
||||
window.history.replaceState(null, '', hash);
|
||||
}
|
||||
}
|
||||
}, [lightboxIndex, assets.length]);
|
||||
}, [lightboxIndex, displayedAssets.length]);
|
||||
|
||||
// ── Listen for browser back/forward (hash change) ─────────────
|
||||
useEffect(() => {
|
||||
const handleHashChange = () => {
|
||||
const idx = parsePhotoHash(window.location.hash);
|
||||
if (idx !== null && idx < assets.length) {
|
||||
if (idx !== null && idx < displayedAssets.length) {
|
||||
setLightboxIndex(idx);
|
||||
} else {
|
||||
setLightboxIndex(null);
|
||||
@@ -87,27 +96,25 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
|
||||
window.addEventListener('hashchange', handleHashChange);
|
||||
return () => window.removeEventListener('hashchange', handleHashChange);
|
||||
}, [assets.length]);
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const openLightbox = useCallback((index: number) => {
|
||||
setLightboxIndex(index);
|
||||
// Push state (not replace) so the user can press Back to close
|
||||
window.history.pushState(null, '', buildPhotoHash(index));
|
||||
}, []);
|
||||
|
||||
const closeLightbox = useCallback(() => {
|
||||
setLightboxIndex(null);
|
||||
// Remove hash from URL cleanly
|
||||
window.history.pushState(null, '', window.location.pathname + window.location.search);
|
||||
}, []);
|
||||
|
||||
const goNext = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null));
|
||||
}, [assets.length]);
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null));
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
const goPrev = useCallback(() => {
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null));
|
||||
}, [assets.length]);
|
||||
setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null));
|
||||
}, [displayedAssets.length]);
|
||||
|
||||
// Keyboard navigation
|
||||
useEffect(() => {
|
||||
@@ -128,7 +135,6 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
};
|
||||
|
||||
document.addEventListener('keydown', handleKeyDown);
|
||||
// Prevent body scroll when lightbox is open
|
||||
document.body.style.overflow = 'hidden';
|
||||
|
||||
return () => {
|
||||
@@ -137,10 +143,11 @@ 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) => (
|
||||
return displayedAssets.map((asset, index) => {
|
||||
const isFav = proofing ? proofing.isFavorite(asset.id) : false;
|
||||
|
||||
return (
|
||||
<FadeIn key={asset.id} delay={index < 12 ? index * 50 : 0}>
|
||||
<div
|
||||
className={`photo-grid__item${
|
||||
@@ -162,6 +169,7 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio
|
||||
? { aspectRatio: `${asset.aspectRatio}` }
|
||||
: {}),
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
<Image
|
||||
@@ -174,6 +182,48 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
? { placeholder: 'blur' as const, blurDataURL: asset.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
|
||||
{proofing && (
|
||||
<button
|
||||
type="button"
|
||||
className="photo-grid__fav-btn"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
proofing.toggleFavorite(asset.id);
|
||||
}}
|
||||
aria-label={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
title={isFav ? 'Remove favorite' : 'Add favorite'}
|
||||
style={{
|
||||
position: 'absolute',
|
||||
top: '8px',
|
||||
right: '8px',
|
||||
zIndex: 5,
|
||||
width: '44px',
|
||||
height: '44px',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
cursor: 'pointer',
|
||||
color: isFav ? '#ff4d4f' : 'rgba(255,255,255,0.85)',
|
||||
filter: 'drop-shadow(0 2px 4px rgba(0,0,0,0.6))',
|
||||
padding: 0,
|
||||
}}
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
width="24"
|
||||
height="24"
|
||||
fill={isFav ? 'currentColor' : 'none'}
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{asset.type === 'video' && (
|
||||
<div className="photo-grid__item-play" aria-hidden="true">
|
||||
<svg viewBox="0 0 24 24" fill="currentColor">
|
||||
@@ -189,8 +239,9 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
)}
|
||||
</div>
|
||||
</FadeIn>
|
||||
));
|
||||
}, [assets, layout, openLightbox]);
|
||||
);
|
||||
});
|
||||
}, [displayedAssets, layout, openLightbox, proofing]);
|
||||
|
||||
return (
|
||||
<>
|
||||
@@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr
|
||||
{gridItems}
|
||||
</div>
|
||||
|
||||
{proofing && proofing.favorites.size > 0 && (
|
||||
<div
|
||||
className="proofing-sticky-bar"
|
||||
style={{
|
||||
position: 'fixed',
|
||||
bottom: '24px',
|
||||
right: '24px',
|
||||
zIndex: 990,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
gap: '12px',
|
||||
padding: '8px 16px',
|
||||
borderRadius: '30px',
|
||||
background: 'var(--bg-surface, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.15))',
|
||||
boxShadow: '0 8px 24px rgba(0,0,0,0.4)',
|
||||
backdropFilter: 'blur(8px)',
|
||||
}}
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => proofing.setIsFilterActive((prev) => !prev)}
|
||||
style={{
|
||||
background: proofing.isFilterActive ? 'var(--accent, #e60012)' : 'rgba(255,255,255,0.15)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
padding: '6px 14px',
|
||||
borderRadius: '20px',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{proofing.isFilterActive ? 'Show All' : `❤️ ${proofing.favorites.size} Selected`}
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => proofing.setIsModalOpen(true)}
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'inherit',
|
||||
fontSize: '0.85rem',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
textDecoration: 'underline',
|
||||
}}
|
||||
>
|
||||
Share & Export
|
||||
</button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<ProofingModal />
|
||||
|
||||
{lightboxIndex !== null && (
|
||||
<Lightbox
|
||||
assets={assets}
|
||||
assets={displayedAssets}
|
||||
currentIndex={lightboxIndex}
|
||||
onClose={closeLightbox}
|
||||
onNext={goNext}
|
||||
onPrev={goPrev}
|
||||
watermark={watermark}
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
export function PhotoGrid(props: PhotoGridProps) {
|
||||
const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]);
|
||||
|
||||
return (
|
||||
<ProofingProvider albumTokens={albumTokens}>
|
||||
<PhotoGridInner {...props} />
|
||||
</ProofingProvider>
|
||||
);
|
||||
}
|
||||
|
||||
157
app/[...path]/essay.css
Normal file
157
app/[...path]/essay.css
Normal file
@@ -0,0 +1,157 @@
|
||||
/* Photo Essay & Storytelling Mode Styles */
|
||||
|
||||
.essay-container {
|
||||
width: 100%;
|
||||
max-width: 100%;
|
||||
margin: 0 auto;
|
||||
padding: 2rem 1.5rem 6rem 1.5rem;
|
||||
}
|
||||
|
||||
/* Header & Cover */
|
||||
.essay-header {
|
||||
max-width: 68ch;
|
||||
margin: 0 auto 3.5rem auto;
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.essay-header__title {
|
||||
font-family: var(--font-heading, serif);
|
||||
font-size: clamp(2.2rem, 5vw, 3.8rem);
|
||||
font-weight: 700;
|
||||
line-height: 1.15;
|
||||
margin-bottom: 1rem;
|
||||
letter-spacing: -0.02em;
|
||||
}
|
||||
|
||||
.essay-header__subtitle {
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-size: clamp(1.1rem, 2.5vw, 1.4rem);
|
||||
opacity: 0.8;
|
||||
line-height: 1.5;
|
||||
margin-bottom: 1.5rem;
|
||||
}
|
||||
|
||||
.essay-header__meta {
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.6;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
}
|
||||
|
||||
/* Reading column */
|
||||
.essay-prose {
|
||||
max-width: 68ch;
|
||||
margin: 0 auto 2.5rem auto;
|
||||
font-family: var(--font-body, sans-serif);
|
||||
font-size: clamp(1.05rem, 2vw, 1.2rem);
|
||||
line-height: 1.75;
|
||||
color: var(--text-primary, rgba(255, 255, 255, 0.92));
|
||||
}
|
||||
|
||||
.essay-prose p {
|
||||
margin-bottom: 1.75rem;
|
||||
}
|
||||
|
||||
.essay-heading {
|
||||
font-family: var(--font-heading, serif);
|
||||
max-width: 68ch;
|
||||
margin: 3.5rem auto 1.5rem auto;
|
||||
font-weight: 600;
|
||||
line-height: 1.25;
|
||||
}
|
||||
|
||||
.essay-heading--1 { font-size: clamp(1.8rem, 3.5vw, 2.5rem); }
|
||||
.essay-heading--2 { font-size: clamp(1.5rem, 3vw, 2rem); }
|
||||
.essay-heading--3 { font-size: clamp(1.25rem, 2.5vw, 1.6rem); }
|
||||
|
||||
/* Pullquote */
|
||||
.essay-quote {
|
||||
max-width: 68ch;
|
||||
margin: 3.5rem auto;
|
||||
padding: 1.5rem 2rem;
|
||||
border-left: 3px solid var(--accent, #e60012);
|
||||
background: var(--bg-surface, rgba(255, 255, 255, 0.03));
|
||||
border-radius: 0 8px 8px 0;
|
||||
font-family: var(--font-heading, serif);
|
||||
font-style: italic;
|
||||
font-size: clamp(1.15rem, 2.2vw, 1.35rem);
|
||||
line-height: 1.6;
|
||||
}
|
||||
|
||||
.essay-quote__author {
|
||||
display: block;
|
||||
margin-top: 0.75rem;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-style: normal;
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.7;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
/* Essay Photos & Layout Modes */
|
||||
.essay-figure {
|
||||
margin: 3.5rem auto;
|
||||
width: 100%;
|
||||
}
|
||||
|
||||
.essay-figure--contained {
|
||||
max-width: 68ch;
|
||||
}
|
||||
|
||||
.essay-figure--wide {
|
||||
max-width: 1100px;
|
||||
}
|
||||
|
||||
.essay-figure--fullbleed {
|
||||
max-width: 100vw;
|
||||
margin-left: calc(-50vw + 50%);
|
||||
margin-right: calc(-50vw + 50%);
|
||||
}
|
||||
|
||||
.essay-image-wrapper {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
border-radius: var(--radius, 0px);
|
||||
max-height: 80vh;
|
||||
}
|
||||
|
||||
.essay-image-wrapper img {
|
||||
object-fit: cover;
|
||||
width: 100%;
|
||||
height: auto;
|
||||
max-height: 80vh;
|
||||
display: block;
|
||||
transition: transform 0.4s ease;
|
||||
}
|
||||
|
||||
.essay-image-wrapper:hover img {
|
||||
transform: scale(1.015);
|
||||
}
|
||||
|
||||
.essay-figcaption {
|
||||
max-width: 68ch;
|
||||
margin: 0.75rem auto 0 auto;
|
||||
text-align: center;
|
||||
font-family: var(--font-caption, monospace);
|
||||
font-size: 0.85rem;
|
||||
opacity: 0.75;
|
||||
}
|
||||
|
||||
/* Side by side photo pair */
|
||||
.essay-pair-grid {
|
||||
display: grid;
|
||||
grid-template-columns: 1fr 1fr;
|
||||
gap: 1.5rem;
|
||||
width: 100%;
|
||||
max-width: 1100px;
|
||||
margin: 3.5rem auto;
|
||||
}
|
||||
|
||||
@media (max-width: 640px) {
|
||||
.essay-pair-grid {
|
||||
grid-template-columns: 1fr;
|
||||
gap: 1.5rem;
|
||||
}
|
||||
}
|
||||
@@ -31,6 +31,8 @@ import { isProtected, isAuthenticated } from '@/lib/auth';
|
||||
import PasswordGate from '@/components/PasswordGate';
|
||||
import { AlbumDetailView } from './AlbumDetailView';
|
||||
import { SubpageGridView } from './SubpageGridView';
|
||||
import { EssayView } from './EssayView';
|
||||
import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay';
|
||||
|
||||
// Render at request time — requires live Immich connection
|
||||
export const dynamic = 'force-dynamic';
|
||||
@@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
const slug = path[0];
|
||||
let title = slug;
|
||||
let subtitle = '';
|
||||
|
||||
if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else if (immich.isSubpageSlug(slug)) {
|
||||
let description: string | undefined = undefined;
|
||||
if (path.length === 1 && immich.isSubpageSlug(slug)) {
|
||||
const result = await immich.getSubpageAlbums(slug);
|
||||
if (result) {
|
||||
// Single album → use album name; multiple → use subpage name
|
||||
if (result.subpage.subtitle) {
|
||||
description = result.subpage.subtitle;
|
||||
}
|
||||
if (result.albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(result.albums[0].slug, slug);
|
||||
if (album) {
|
||||
@@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
title = result.subpage.name;
|
||||
title = result.subpage.title || result.subpage.name;
|
||||
}
|
||||
}
|
||||
} else if (path.length === 2) {
|
||||
const album = await immich.getAlbumBySlug(path[1], slug);
|
||||
if (album) {
|
||||
title = album.albumName;
|
||||
const count = album.assets.filter((a) => a.type === 'IMAGE' || a.type === 'VIDEO').length;
|
||||
subtitle = `${count} photo${count === 1 ? '' : 's'}`;
|
||||
}
|
||||
} else {
|
||||
const album = await immich.getAlbumBySlug(slug);
|
||||
if (album) {
|
||||
@@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise<Metad
|
||||
|
||||
return {
|
||||
title,
|
||||
openGraph: { title, images: [ogUrl] },
|
||||
twitter: { card: 'summary_large_image', title, images: [ogUrl] },
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
openGraph: {
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description: description || (subtitle ? `${title} — ${subtitle}` : undefined),
|
||||
images: [ogUrl],
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
@@ -204,6 +218,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
gridStyle={buildGridStyle(spGrid)}
|
||||
backLinkHref={`/${subpageSlug}`}
|
||||
backLinkLabel={`Back to ${subpageName}`}
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
|
||||
const { albums } = result;
|
||||
|
||||
// ── Photo Essay / Storytelling Mode ───────────────────────
|
||||
const isEssay =
|
||||
result.subpage.grid?.layout === 'essay' ||
|
||||
!!result.subpage.essayFile ||
|
||||
!!result.subpage.essayText;
|
||||
|
||||
if (isEssay) {
|
||||
let essayParsed = result.subpage.essayText
|
||||
? parseEssayMarkdown(result.subpage.essayText)
|
||||
: result.subpage.essayFile
|
||||
? loadEssayFromFile(result.subpage.essayFile)
|
||||
: null;
|
||||
|
||||
// Fetch assets from all subpage albums
|
||||
const allAlbums = await Promise.all(
|
||||
albums.map((a) => immich.getAlbumBySlug(a.slug, slug))
|
||||
);
|
||||
const allAssets = allAlbums.flatMap((a) => (a ? a.assets : []));
|
||||
const images = toPhotoItems(allAssets, config.exifOnHover);
|
||||
|
||||
// Fallback structured essay if layout: 'essay' is set without custom markdown file
|
||||
if (!essayParsed) {
|
||||
essayParsed = {
|
||||
frontmatter: {
|
||||
title: result.subpage.title || result.subpage.name,
|
||||
subtitle: result.subpage.subtitle,
|
||||
},
|
||||
blocks: albums.flatMap((a) => [
|
||||
{ type: 'heading' as const, level: 2, text: a.albumName },
|
||||
...a.assets.map((asset) => ({
|
||||
type: 'photo' as const,
|
||||
assetId: encodeAssetId(asset.id),
|
||||
caption: asset.exifInfo?.description || undefined,
|
||||
layout: 'contained' as const,
|
||||
})),
|
||||
]),
|
||||
referencedAssetIds: [],
|
||||
};
|
||||
}
|
||||
|
||||
return (
|
||||
<EssayView
|
||||
essay={essayParsed}
|
||||
assets={images}
|
||||
title={result.subpage.title || result.subpage.name}
|
||||
subtitle={result.subpage.subtitle}
|
||||
watermark={config.watermark}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Single album → full-bleed (skip album grid) ──────────
|
||||
if (albums.length === 1) {
|
||||
const album = await immich.getAlbumBySlug(albums[0].slug, slug);
|
||||
@@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
subtitle={result.subpage.subtitle}
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
@@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) {
|
||||
gridStyle={buildGridStyle()}
|
||||
backLinkHref="/"
|
||||
backLinkLabel="Back to Gallery"
|
||||
watermark={config.watermark}
|
||||
{...heroData}
|
||||
/>
|
||||
);
|
||||
|
||||
@@ -7,6 +7,7 @@ import { promises as fsPromises } from 'fs';
|
||||
import { join } from 'path';
|
||||
import yaml from 'js-yaml';
|
||||
import Image from 'next/image';
|
||||
import type { Metadata } from 'next';
|
||||
import { imageUrl, assetPlaceholder } from '@/lib/urls';
|
||||
import { immich } from '@/lib/immich';
|
||||
import './about.css';
|
||||
@@ -46,6 +47,26 @@ async function getAboutContent() {
|
||||
return { meta, body: body.trim() };
|
||||
}
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const { meta, body } = await getAboutContent();
|
||||
const title = meta.name ? `About — ${meta.name}` : 'About';
|
||||
const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined;
|
||||
|
||||
return {
|
||||
title,
|
||||
description,
|
||||
openGraph: {
|
||||
title,
|
||||
description,
|
||||
},
|
||||
twitter: {
|
||||
card: 'summary_large_image',
|
||||
title,
|
||||
description,
|
||||
},
|
||||
};
|
||||
}
|
||||
|
||||
export default async function AboutPage() {
|
||||
const { meta, body } = await getAboutContent();
|
||||
|
||||
|
||||
1357
app/admin/admin.css
1357
app/admin/admin.css
File diff suppressed because it is too large
Load Diff
@@ -3,19 +3,23 @@
|
||||
import { useState, useEffect } from 'react';
|
||||
import PageBuilder from './PageBuilder';
|
||||
import SettingsEditor from './SettingsEditor';
|
||||
import AnalyticsView from './AnalyticsView';
|
||||
import BackupManagerModal from './BackupManagerModal';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
onLogout: () => void;
|
||||
}
|
||||
|
||||
type Tab = 'pages' | 'settings';
|
||||
type Tab = 'pages' | 'settings' | 'analytics';
|
||||
|
||||
export default function AdminDashboard({ onLogout }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<Tab>('pages');
|
||||
const [saving, setSaving] = useState(false);
|
||||
|
||||
// Diagnostics state
|
||||
// Diagnostics & Backup state
|
||||
const [showStatus, setShowStatus] = useState(false);
|
||||
const [showBackupModal, setShowBackupModal] = useState(false);
|
||||
const [status, setStatus] = useState<any>(null);
|
||||
const [statusLoading, setStatusLoading] = useState(false);
|
||||
|
||||
@@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
>
|
||||
Settings
|
||||
</button>
|
||||
<button
|
||||
className={`admin-tab ${activeTab === 'analytics' ? 'active' : ''}`}
|
||||
onClick={() => setActiveTab('analytics')}
|
||||
>
|
||||
Analytics
|
||||
</button>
|
||||
</nav>
|
||||
</div>
|
||||
<div className="admin-header-right">
|
||||
@@ -136,6 +146,17 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<span className="status-val">{status?.cache?.size ?? 0} items</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="status-dropdown-footer">
|
||||
<button
|
||||
className="admin-btn admin-btn-sm"
|
||||
onClick={() => {
|
||||
setShowStatus(false);
|
||||
setShowBackupModal(true);
|
||||
}}
|
||||
>
|
||||
<Icons.IconArchive size={14} /> Manage Backups
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
@@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
className="admin-btn admin-btn-ghost"
|
||||
title="Open site in new tab"
|
||||
>
|
||||
↗ Site
|
||||
<Icons.IconLink size={14} /> Site
|
||||
</a>
|
||||
<button
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={() => setShowBackupModal(true)}
|
||||
title="Manage config backups & restore"
|
||||
>
|
||||
<Icons.IconArchive size={14} /> Backups
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-ghost"
|
||||
onClick={handleReload}
|
||||
disabled={saving}
|
||||
title="Reload config & clear cache"
|
||||
>
|
||||
↻ Reload
|
||||
<Icons.IconRefresh size={14} /> Reload
|
||||
</button>
|
||||
<button className="admin-btn admin-btn-ghost" onClick={handleLogout}>
|
||||
Logout
|
||||
@@ -167,7 +195,16 @@ export default function AdminDashboard({ onLogout }: Props) {
|
||||
<main className="admin-main">
|
||||
{activeTab === 'pages' && <PageBuilder />}
|
||||
{activeTab === 'settings' && <SettingsEditor />}
|
||||
{activeTab === 'analytics' && <AnalyticsView />}
|
||||
</main>
|
||||
|
||||
<BackupManagerModal
|
||||
isOpen={showBackupModal}
|
||||
onClose={() => setShowBackupModal(false)}
|
||||
onRestoreSuccess={() => {
|
||||
fetchStatus();
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
177
app/admin/components/AnalyticsView.tsx
Normal file
177
app/admin/components/AnalyticsView.tsx
Normal file
@@ -0,0 +1,177 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface AnalyticsData {
|
||||
trackingEnabled?: boolean;
|
||||
summary: {
|
||||
totalViews: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
days: Record<
|
||||
string,
|
||||
{
|
||||
pageviews: number;
|
||||
pages: Record<string, number>;
|
||||
devices: Record<string, number>;
|
||||
}
|
||||
>;
|
||||
topPages: { path: string; count: number }[];
|
||||
devices: { desktop: number; mobile: number };
|
||||
}
|
||||
|
||||
export default function AnalyticsView() {
|
||||
const [data, setData] = useState<AnalyticsData | null>(null);
|
||||
const [loading, setLoading] = useState(true);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const fetchAnalytics = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/analytics');
|
||||
if (!res.ok) throw new Error('Failed to fetch analytics');
|
||||
const json = await res.json();
|
||||
setData(json);
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading analytics');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
fetchAnalytics();
|
||||
}, [fetchAnalytics]);
|
||||
|
||||
if (loading) {
|
||||
return (
|
||||
<div className="admin-loading-container">
|
||||
<div className="admin-spinner" />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
if (error || !data) {
|
||||
return (
|
||||
<div className="admin-panel" style={{ padding: '2rem', textAlign: 'center' }}>
|
||||
<p style={{ color: 'var(--admin-text-muted)', marginBottom: '1rem' }}>{error || 'No analytics data'}</p>
|
||||
<button className="admin-btn admin-btn-primary" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Retry
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
const totalViews = data.summary?.totalViews || 0;
|
||||
const maxPageViews = data.topPages[0]?.count || 1;
|
||||
const daysList = Object.entries(data.days || {})
|
||||
.sort((a, b) => a[0].localeCompare(b[0]))
|
||||
.slice(-14); // Last 14 days
|
||||
const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1);
|
||||
|
||||
const desktopRatio = data.devices.desktop + data.devices.mobile > 0
|
||||
? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100)
|
||||
: 50;
|
||||
|
||||
return (
|
||||
<div className="analytics-view">
|
||||
{/* Header */}
|
||||
<div className="analytics-header">
|
||||
<div>
|
||||
<h2><Icons.IconSparkles size={20} /> Visitor Insights & Analytics</h2>
|
||||
<p className="analytics-subtitle">Privacy-first aggregate traffic stats. No personal data collected.</p>
|
||||
</div>
|
||||
<button className="admin-btn admin-btn-ghost admin-btn-sm" onClick={fetchAnalytics}>
|
||||
<Icons.IconRefresh size={14} /> Refresh
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{data.trackingEnabled === false && (
|
||||
<div className="backup-status-alert error" style={{ margin: 0, display: 'flex', alignItems: 'center', gap: '0.5rem' }}>
|
||||
<Icons.IconLock size={16} />
|
||||
<span>
|
||||
<strong>Analytics Tracking is Currently Disabled.</strong> No new visitor views are being recorded. You can re-enable tracking anytime under <em>Settings → General</em>.
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Top Metric Cards */}
|
||||
<div className="analytics-metrics-grid">
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Total Page Views</span>
|
||||
<span className="metric-value">{totalViews.toLocaleString()}</span>
|
||||
<span className="metric-sub">Lifetime aggregate</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Active Days Tracked</span>
|
||||
<span className="metric-value">{Object.keys(data.days || {}).length}</span>
|
||||
<span className="metric-sub">Days recorded</span>
|
||||
</div>
|
||||
<div className="metric-card">
|
||||
<span className="metric-label">Desktop vs Mobile</span>
|
||||
<span className="metric-value">{desktopRatio}% / {100 - desktopRatio}%</span>
|
||||
<span className="metric-sub">Device distribution</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Daily Trends Chart */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconGrid size={16} /> Daily Traffic Trend (Last 14 Days)</h3>
|
||||
</div>
|
||||
{daysList.length === 0 ? (
|
||||
<p className="analytics-empty">No pageviews recorded yet. Visit portfolio pages to start tracking!</p>
|
||||
) : (
|
||||
<div className="analytics-bar-chart">
|
||||
{daysList.map(([dateStr, dayData]) => {
|
||||
const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100);
|
||||
const formattedDate = dateStr.slice(5); // MM-DD
|
||||
return (
|
||||
<div key={dateStr} className="bar-col" title={`${dateStr}: ${dayData.pageviews} views`}>
|
||||
<div className="bar-wrapper">
|
||||
<div className="bar-fill" style={{ height: `${Math.max(heightPct, 8)}%` }}>
|
||||
<span className="bar-val">{dayData.pageviews}</span>
|
||||
</div>
|
||||
</div>
|
||||
<span className="bar-label">{formattedDate}</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Top Pages List */}
|
||||
<div className="analytics-panel">
|
||||
<div className="analytics-section-title">
|
||||
<h3><Icons.IconTarget size={16} /> Top Visited Pages & Subpages</h3>
|
||||
</div>
|
||||
{data.topPages.length === 0 ? (
|
||||
<p className="analytics-empty">No pages recorded yet.</p>
|
||||
) : (
|
||||
<div className="analytics-top-pages">
|
||||
{data.topPages.map((p, idx) => {
|
||||
const widthPct = Math.round((p.count / maxPageViews) * 100);
|
||||
return (
|
||||
<div key={p.path} className="top-page-row">
|
||||
<span className="page-rank">#{idx + 1}</span>
|
||||
<div className="page-info">
|
||||
<div className="page-path-line">
|
||||
<span className="page-path">{p.path}</span>
|
||||
<span className="page-count">{p.count} views</span>
|
||||
</div>
|
||||
<div className="page-progress-bg">
|
||||
<div className="page-progress-fill" style={{ width: `${widthPct}%` }} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
226
app/admin/components/BackupManagerModal.tsx
Normal file
226
app/admin/components/BackupManagerModal.tsx
Normal file
@@ -0,0 +1,226 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import type { BackupItem } from '@/app/api/admin/backups/route';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface Props {
|
||||
isOpen: boolean;
|
||||
onClose: () => void;
|
||||
onRestoreSuccess: () => void;
|
||||
}
|
||||
|
||||
export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) {
|
||||
const [activeTab, setActiveTab] = useState<'gallery' | 'settings'>('gallery');
|
||||
const [showAllBackups, setShowAllBackups] = useState(false);
|
||||
const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({
|
||||
gallery: [],
|
||||
settings: [],
|
||||
});
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
const [restoringFilename, setRestoringFilename] = useState<string | null>(null);
|
||||
const [confirmFilename, setConfirmFilename] = useState<string | null>(null);
|
||||
const [successMsg, setSuccessMsg] = useState<string | null>(null);
|
||||
|
||||
const fetchBackups = useCallback(async () => {
|
||||
setLoading(true);
|
||||
setError(null);
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups');
|
||||
if (!res.ok) {
|
||||
throw new Error('Failed to fetch backup history');
|
||||
}
|
||||
const data = await res.json();
|
||||
setBackups(data.backups || { gallery: [], settings: [] });
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error loading backups');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (isOpen) {
|
||||
fetchBackups();
|
||||
setConfirmFilename(null);
|
||||
setSuccessMsg(null);
|
||||
setShowAllBackups(false);
|
||||
}
|
||||
}, [isOpen, fetchBackups]);
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
async function handleRestore(filename: string) {
|
||||
setRestoringFilename(filename);
|
||||
setError(null);
|
||||
setSuccessMsg(null);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/backups', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ backupFilename: filename }),
|
||||
});
|
||||
|
||||
const data = await res.json();
|
||||
|
||||
if (!res.ok) {
|
||||
throw new Error(data.error || 'Failed to restore backup');
|
||||
}
|
||||
|
||||
setSuccessMsg(`Backup restored successfully! (${filename})`);
|
||||
setConfirmFilename(null);
|
||||
await fetchBackups();
|
||||
onRestoreSuccess();
|
||||
} catch (err: any) {
|
||||
setError(err.message || 'Error restoring backup');
|
||||
} finally {
|
||||
setRestoringFilename(null);
|
||||
}
|
||||
}
|
||||
|
||||
const currentList = backups[activeTab] || [];
|
||||
const INITIAL_VISIBLE_COUNT = 3;
|
||||
const visibleList = showAllBackups ? currentList : currentList.slice(0, INITIAL_VISIBLE_COUNT);
|
||||
const hiddenCount = currentList.length - INITIAL_VISIBLE_COUNT;
|
||||
|
||||
return (
|
||||
<div className="backup-modal-backdrop" onClick={onClose}>
|
||||
<div className="backup-modal-container" onClick={(e) => e.stopPropagation()}>
|
||||
<div className="backup-modal-header">
|
||||
<div>
|
||||
<h2>Backup History & Restoration</h2>
|
||||
<p className="backup-modal-subtitle">
|
||||
Restore previous states of your configuration files with 1-click.
|
||||
</p>
|
||||
</div>
|
||||
<button className="backup-modal-close-btn" onClick={onClose} aria-label="Close">
|
||||
✕
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Tab Selection */}
|
||||
<div className="backup-modal-tabs">
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'gallery' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('gallery');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Gallery Backups ({backups.gallery.length})
|
||||
</button>
|
||||
<button
|
||||
className={`backup-modal-tab ${activeTab === 'settings' ? 'active' : ''}`}
|
||||
onClick={() => {
|
||||
setActiveTab('settings');
|
||||
setConfirmFilename(null);
|
||||
setShowAllBackups(false);
|
||||
}}
|
||||
>
|
||||
Settings Backups ({backups.settings.length})
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Status Messages */}
|
||||
{error && <div className="backup-status-alert error">{error}</div>}
|
||||
{successMsg && <div className="backup-status-alert success">{successMsg}</div>}
|
||||
|
||||
{/* Confirmation Modal Section */}
|
||||
{confirmFilename && (
|
||||
<div className="backup-confirm-box">
|
||||
<div className="backup-confirm-content">
|
||||
<strong>Confirm Restoration</strong>
|
||||
<p>
|
||||
Are you sure you want to restore <code>{confirmFilename}</code>?
|
||||
A safety snapshot of your current state will be created automatically before reverting.
|
||||
</p>
|
||||
<div className="backup-confirm-actions">
|
||||
<button
|
||||
className="btn btn-secondary"
|
||||
onClick={() => setConfirmFilename(null)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
className="btn btn-danger"
|
||||
onClick={() => handleRestore(confirmFilename)}
|
||||
disabled={!!restoringFilename}
|
||||
>
|
||||
{restoringFilename ? 'Restoring...' : 'Yes, Restore Now'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{/* Backup List Content */}
|
||||
<div className="backup-modal-body">
|
||||
{loading ? (
|
||||
<div className="backup-loading">Loading backups...</div>
|
||||
) : currentList.length === 0 ? (
|
||||
<div className="backup-empty">No backups available yet for {activeTab}.yaml</div>
|
||||
) : (
|
||||
<div className="backup-list">
|
||||
{visibleList.map((item) => {
|
||||
const dateObj = item.timestamp ? new Date(item.timestamp) : null;
|
||||
const formattedDate = dateObj && !isNaN(dateObj.getTime())
|
||||
? dateObj.toLocaleString('de-DE', {
|
||||
dateStyle: 'medium',
|
||||
timeStyle: 'medium',
|
||||
})
|
||||
: item.filename;
|
||||
|
||||
return (
|
||||
<div key={item.filename} className="backup-item">
|
||||
<div className="backup-item-info">
|
||||
<div className="backup-item-title">
|
||||
<span className="backup-filename">{formattedDate}</span>
|
||||
{item.isPreRestore && (
|
||||
<span className="backup-badge pre-restore" title="Safety snapshot taken before a restore">
|
||||
Pre-Restore Snapshot
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
<div className="backup-item-meta">
|
||||
<code>{item.filename}</code>
|
||||
</div>
|
||||
</div>
|
||||
<button
|
||||
className="btn btn-outline-danger btn-sm"
|
||||
onClick={() => setConfirmFilename(item.filename)}
|
||||
disabled={!!restoringFilename || confirmFilename === item.filename}
|
||||
>
|
||||
Restore
|
||||
</button>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
|
||||
{currentList.length > INITIAL_VISIBLE_COUNT && (
|
||||
<button
|
||||
className="backup-toggle-btn"
|
||||
onClick={() => setShowAllBackups(!showAllBackups)}
|
||||
>
|
||||
<Icons.IconChevronDown size={14} style={{ transform: showAllBackups ? 'rotate(180deg)' : 'none', transition: 'transform 0.2s ease' }} />
|
||||
{showAllBackups
|
||||
? 'Ältere Einträge einklappen'
|
||||
: `Ältere Einträge anzeigen (${hiddenCount} weitere)`}
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="backup-modal-footer">
|
||||
<button className="btn btn-secondary" onClick={onClose}>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
455
app/admin/components/EssayBlockEditor.tsx
Normal file
455
app/admin/components/EssayBlockEditor.tsx
Normal file
@@ -0,0 +1,455 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback } from 'react';
|
||||
import {
|
||||
parseEssayMarkdown,
|
||||
serializeEssayMarkdown,
|
||||
type ParsedEssay,
|
||||
type EssayBlock,
|
||||
} from '@/lib/essay';
|
||||
import * as Icons from './Icons';
|
||||
|
||||
interface EssayBlockEditorProps {
|
||||
markdown: string;
|
||||
onChange: (newMarkdown: string) => void;
|
||||
onSelectPhoto?: (callback: (assetId: string) => void) => void;
|
||||
}
|
||||
|
||||
export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) {
|
||||
const [essay, setEssay] = useState<ParsedEssay>(() =>
|
||||
parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...')
|
||||
);
|
||||
|
||||
// Sync internal state when prop changes from outside (e.g. initial load)
|
||||
useEffect(() => {
|
||||
const currentSerialized = serializeEssayMarkdown(essay);
|
||||
if (markdown !== currentSerialized && markdown) {
|
||||
setEssay(parseEssayMarkdown(markdown));
|
||||
}
|
||||
}, [markdown]); // eslint-disable-line react-hooks/exhaustive-deps
|
||||
|
||||
const updateBlocks = useCallback(
|
||||
(newBlocks: EssayBlock[]) => {
|
||||
const updatedEssay: ParsedEssay = {
|
||||
...essay,
|
||||
blocks: newBlocks,
|
||||
referencedAssetIds: Array.from(
|
||||
new Set(
|
||||
newBlocks.flatMap((b) => {
|
||||
if (b.type === 'photo') return [b.assetId];
|
||||
if (b.type === 'photo-pair') return b.assetIds;
|
||||
return [];
|
||||
})
|
||||
)
|
||||
),
|
||||
};
|
||||
|
||||
setEssay(updatedEssay);
|
||||
onChange(serializeEssayMarkdown(updatedEssay));
|
||||
},
|
||||
[essay, onChange]
|
||||
);
|
||||
|
||||
// Block manipulation helpers
|
||||
const handleMoveBlock = (index: number, direction: 'up' | 'down') => {
|
||||
const targetIdx = direction === 'up' ? index - 1 : index + 1;
|
||||
if (targetIdx < 0 || targetIdx >= essay.blocks.length) return;
|
||||
|
||||
const newBlocks = [...essay.blocks];
|
||||
const [moved] = newBlocks.splice(index, 1);
|
||||
newBlocks.splice(targetIdx, 0, moved);
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleDeleteBlock = (index: number) => {
|
||||
const newBlocks = essay.blocks.filter((_, i) => i !== index);
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleUpdateBlock = (index: number, updated: EssayBlock) => {
|
||||
const newBlocks = [...essay.blocks];
|
||||
newBlocks[index] = updated;
|
||||
updateBlocks(newBlocks);
|
||||
};
|
||||
|
||||
const handleAddBlock = (type: EssayBlock['type']) => {
|
||||
let newBlock: EssayBlock;
|
||||
|
||||
switch (type) {
|
||||
case 'heading':
|
||||
newBlock = { type: 'heading', level: 2, text: 'New Section Heading' };
|
||||
break;
|
||||
case 'paragraph':
|
||||
newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' };
|
||||
break;
|
||||
case 'quote':
|
||||
newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' };
|
||||
break;
|
||||
case 'photo':
|
||||
newBlock = {
|
||||
type: 'photo',
|
||||
assetId: 'sample-asset',
|
||||
caption: 'Photo caption',
|
||||
layout: 'contained',
|
||||
};
|
||||
break;
|
||||
case 'photo-pair':
|
||||
newBlock = {
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-1', 'asset-2'],
|
||||
caption: 'Side by side caption',
|
||||
};
|
||||
break;
|
||||
}
|
||||
|
||||
updateBlocks([...essay.blocks, newBlock]);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="essay-block-editor" style={{ display: 'flex', flexDirection: 'column', gap: '1.25rem' }}>
|
||||
<div
|
||||
className="essay-block-toolbar"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexWrap: 'wrap',
|
||||
alignItems: 'center',
|
||||
gap: '8px',
|
||||
padding: '10px 14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.04))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.1))',
|
||||
}}
|
||||
>
|
||||
<span style={{ fontSize: '0.85rem', fontWeight: 600, marginRight: 'auto' }}>
|
||||
✨ Add Story Block:
|
||||
</span>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('paragraph')}
|
||||
>
|
||||
+ Paragraph
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('heading')}
|
||||
>
|
||||
+ Heading
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() => handleAddBlock('quote')}
|
||||
>
|
||||
+ Pullquote
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo')}
|
||||
>
|
||||
+ Photo
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() => handleAddBlock('photo-pair')}
|
||||
>
|
||||
+ 2-Photo Pair
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{essay.blocks.length === 0 && (
|
||||
<p className="empty-hint" style={{ textAlign: 'center', padding: '2rem' }}>
|
||||
No story blocks yet. Use the buttons above to build your photo essay!
|
||||
</p>
|
||||
)}
|
||||
|
||||
{essay.blocks.map((block, idx) => (
|
||||
<div
|
||||
key={idx}
|
||||
className="essay-block-item"
|
||||
style={{
|
||||
display: 'flex',
|
||||
flexDirection: 'column',
|
||||
gap: '8px',
|
||||
padding: '14px',
|
||||
background: 'var(--admin-card-bg, rgba(255,255,255,0.03))',
|
||||
borderRadius: '8px',
|
||||
border: '1px solid var(--admin-border, rgba(255,255,255,0.08))',
|
||||
position: 'relative',
|
||||
}}
|
||||
>
|
||||
{/* Block Header / Action Controls */}
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'space-between',
|
||||
marginBottom: '4px',
|
||||
}}
|
||||
>
|
||||
<span
|
||||
style={{
|
||||
fontSize: '0.75rem',
|
||||
fontWeight: 600,
|
||||
textTransform: 'uppercase',
|
||||
letterSpacing: '0.05em',
|
||||
opacity: 0.7,
|
||||
}}
|
||||
>
|
||||
Block {idx + 1}: {block.type}
|
||||
</span>
|
||||
<div style={{ display: 'flex', gap: '4px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'up')}
|
||||
disabled={idx === 0}
|
||||
title="Move Up"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▲
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon"
|
||||
onClick={() => handleMoveBlock(idx, 'down')}
|
||||
disabled={idx === essay.blocks.length - 1}
|
||||
title="Move Down"
|
||||
style={{ padding: '2px 6px' }}
|
||||
>
|
||||
▼
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn-icon text-danger"
|
||||
onClick={() => handleDeleteBlock(idx)}
|
||||
title="Delete Block"
|
||||
style={{ padding: '2px 6px', color: '#ff4d4f' }}
|
||||
>
|
||||
<Icons.IconTrash size={14} />
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Render Block Form Controls */}
|
||||
{block.type === 'heading' && (
|
||||
<div style={{ display: 'flex', gap: '8px' }}>
|
||||
<select
|
||||
value={block.level}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, level: Number(e.target.value) })
|
||||
}
|
||||
style={{ width: '80px', padding: '6px', borderRadius: '4px' }}
|
||||
>
|
||||
<option value={1}>H1</option>
|
||||
<option value={2}>H2</option>
|
||||
<option value={3}>H3</option>
|
||||
</select>
|
||||
<input
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Section title..."
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'paragraph' && (
|
||||
<textarea
|
||||
rows={3}
|
||||
value={block.html.replace(/<[^>]+>/g, (t) =>
|
||||
t.startsWith('<strong>') ? '**' : t.startsWith('</strong>') ? '**' : ''
|
||||
)}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, html: e.target.value })}
|
||||
placeholder="Write text paragraph... (**bold**, *italic* allowed)"
|
||||
style={{ width: '100%', padding: '8px', borderRadius: '4px', resize: 'vertical' }}
|
||||
/>
|
||||
)}
|
||||
|
||||
{block.type === 'quote' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.text}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, text: e.target.value })}
|
||||
placeholder="Quote text..."
|
||||
style={{ padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
<input
|
||||
type="text"
|
||||
value={block.author || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, author: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Author / Attribution (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'photo' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '10px' }}>
|
||||
<div
|
||||
style={{
|
||||
width: '60px',
|
||||
height: '60px',
|
||||
background: '#222',
|
||||
borderRadius: '6px',
|
||||
overflow: 'hidden',
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
}}
|
||||
>
|
||||
<img
|
||||
src={`/api/admin/thumbnail/${block.assetId}`}
|
||||
alt=""
|
||||
style={{ width: '100%', height: '100%', objectFit: 'cover' }}
|
||||
onError={(e) => {
|
||||
(e.target as HTMLElement).style.display = 'none';
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div style={{ flex: 1, display: 'flex', flexDirection: 'column', gap: '4px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetId}
|
||||
onChange={(e) => handleUpdateBlock(idx, { ...block, assetId: e.target.value })}
|
||||
placeholder="Immich Asset ID or Index"
|
||||
style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, { ...block, assetId: pickedId })
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick Photo
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span style={{ fontSize: '0.8rem', opacity: 0.8 }}>Display Width:</span>
|
||||
<select
|
||||
value={block.layout}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
layout: e.target.value as 'contained' | 'wide' | 'fullbleed',
|
||||
})
|
||||
}
|
||||
style={{ padding: '4px 8px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
>
|
||||
<option value="contained">Contained (68ch)</option>
|
||||
<option value="wide">Wide (1100px)</option>
|
||||
<option value="fullbleed">Fullbleed (100vw)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Photo caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{block.type === 'photo-pair' && (
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '8px' }}>
|
||||
<div style={{ display: 'grid', gridTemplateColumns: '1fr 1fr', gap: '8px' }}>
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[0]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [e.target.value, block.assetIds[1]],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 1 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [pickedId, block.assetIds[1]],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div style={{ display: 'flex', gap: '6px' }}>
|
||||
<input
|
||||
type="text"
|
||||
value={block.assetIds[1]}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], e.target.value],
|
||||
})
|
||||
}
|
||||
placeholder="Photo 2 ID"
|
||||
style={{ flex: 1, padding: '6px' }}
|
||||
/>
|
||||
{onSelectPhoto && (
|
||||
<button
|
||||
type="button"
|
||||
className="admin-btn admin-btn-xs"
|
||||
onClick={() =>
|
||||
onSelectPhoto((pickedId) =>
|
||||
handleUpdateBlock(idx, {
|
||||
...block,
|
||||
assetIds: [block.assetIds[0], pickedId],
|
||||
})
|
||||
)
|
||||
}
|
||||
>
|
||||
Pick
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<input
|
||||
type="text"
|
||||
value={block.caption || ''}
|
||||
onChange={(e) =>
|
||||
handleUpdateBlock(idx, { ...block, caption: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Side-by-side caption (optional)"
|
||||
style={{ padding: '6px 10px', borderRadius: '4px', fontSize: '0.85rem' }}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
267
app/admin/components/Icons.tsx
Normal file
267
app/admin/components/Icons.tsx
Normal file
@@ -0,0 +1,267 @@
|
||||
'use client';
|
||||
|
||||
import React from 'react';
|
||||
|
||||
export interface IconProps extends React.SVGProps<SVGSVGElement> {
|
||||
size?: number;
|
||||
className?: string;
|
||||
strokeWidth?: number;
|
||||
}
|
||||
|
||||
export function IconSun({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMoon({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 3a6 6 0 0 0 9 9 9 9 0 1 1-9-9Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPalette({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="13.5" cy="6.5" r=".5" fill="currentColor" />
|
||||
<circle cx="17.5" cy="10.5" r=".5" fill="currentColor" />
|
||||
<circle cx="8.5" cy="7.5" r=".5" fill="currentColor" />
|
||||
<circle cx="6.5" cy="12.5" r=".5" fill="currentColor" />
|
||||
<path d="M12 2C6.5 2 2 6.5 2 12s4.5 10 10 10c.92 0 1.7-.72 1.7-1.63 0-.44-.18-.85-.46-1.15-.27-.31-.44-.72-.44-1.22 0-.91.75-1.67 1.67-1.67h2.2 0c3.16 0 5.83-2.39 6.1-5.55A10.012 10.012 0 0 0 12 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTarget({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<circle cx="12" cy="12" r="6" />
|
||||
<circle cx="12" cy="12" r="2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFilm({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="18" height="18" x="3" y="3" rx="2" />
|
||||
<path d="M7 3v18M17 3v18M3 7.5h4M3 12h18M3 16.5h4M17 7.5h4M17 16.5h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFrame({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<line x1="22" x2="2" y1="6" y2="6" />
|
||||
<line x1="22" x2="2" y1="18" y2="18" />
|
||||
<line x1="6" x2="6" y1="2" y2="22" />
|
||||
<line x1="18" x2="18" y1="2" y2="22" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSparkles({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m12 3-1.912 5.813a2 2 0 0 1-1.275 1.275L3 12l5.813 1.912a2 2 0 0 1 1.275 1.275L12 21l1.912-5.813a2 2 0 0 1 1.275-1.275L21 12l-5.813-1.912a2 2 0 0 1-1.275-1.275L12 3Z" />
|
||||
<path d="M5 3v4M3 5h4M19 17v4M17 19h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGear({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12.22 2h-.44a2 2 0 0 0-2 2v.18a2 2 0 0 1-1 1.73l-.43.25a2 2 0 0 1-2 0l-.15-.08a2 2 0 0 0-2.73.73l-.22.38a2 2 0 0 0 .73 2.73l.15.1a2 2 0 0 1 1 1.72v.51a2 2 0 0 1-1 1.74l-.15.09a2 2 0 0 0-.73 2.73l.22.38a2 2 0 0 0 2.73.73l.15-.08a2 2 0 0 1 2 0l.43.25a2 2 0 0 1 1 1.73V20a2 2 0 0 0 2 2h.44a2 2 0 0 0 2-2v-.18a2 2 0 0 1 1-1.73l.43-.25a2 2 0 0 1 2 0l.15.08a2 2 0 0 0 2.73-.73l.22-.39a2 2 0 0 0-.73-2.73l-.15-.08a2 2 0 0 1-1-1.74v-.5a2 2 0 0 1 1-1.74l.15-.09a2 2 0 0 0 .73-2.73l-.22-.38a2 2 0 0 0-2.73-.73l-.15.08a2 2 0 0 1-2 0l-.43-.25a2 2 0 0 1-1-1.73V4a2 2 0 0 0-2-2z" />
|
||||
<circle cx="12" cy="12" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconCamera({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M14.5 4h-5L7 7H4a2 2 0 0 0-2 2v9a2 2 0 0 0 2 2h16a2 2 0 0 0 2-2V9a2 2 0 0 0-2-2h-3l-2.5-3z" />
|
||||
<circle cx="12" cy="13" r="3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconMap({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<polygon points="3 6 9 3 15 6 21 3 21 18 15 21 9 18 3 21" />
|
||||
<line x1="9" x2="9" y1="3" y2="18" />
|
||||
<line x1="15" x2="15" y1="6" y2="21" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGrid({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="7" height="7" x="3" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="3" rx="1" />
|
||||
<rect width="7" height="7" x="14" y="14" rx="1" />
|
||||
<rect width="7" height="7" x="3" y="14" rx="1" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconColumns({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 3v18M3 5h18v14H3z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLink({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M10 13a5 5 0 0 0 7.54.54l3-3a5 5 0 0 0-7.07-7.07l-1.72 1.71" />
|
||||
<path d="M14 11a5 5 0 0 0-7.54-.54l-3 3a5 5 0 0 0 7.07 7.07l1.71-1.71" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconScale({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m16 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
|
||||
<path d="m2 16 3-8 3 8c-.87.65-1.92 1-3 1s-2.13-.35-3-1Z" />
|
||||
<path d="M7 21h10M12 3v18M3 7h18" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFileText({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M15 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V7Z" />
|
||||
<path d="M14 2v4a2 2 0 0 0 2 2h4M10 9H8M16 13H8M16 17H8" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconSearch({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="11" cy="11" r="8" />
|
||||
<path d="m21 21-4.3-4.3" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconGlobe({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" x2="22" y1="12" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBan({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="4.93" x2="19.07" y1="4.93" y2="19.07" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconArchive({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="20" height="5" x="2" y="3" rx="1" />
|
||||
<path d="M4 8v11a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8M10 12h4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconShieldCheck({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M20 13c0 5-3.5 7.5-7.66 8.95a1 1 0 0 1-.67-.01C7.5 20.5 4 18 4 13V6a1 1 0 0 1 1-1c2 0 4.5-1.2 6.24-2.72a1.17 1.17 0 0 1 1.52 0C14.51 3.81 17 5 19 5a1 1 0 0 1 1 1z" />
|
||||
<path d="m9 12 2 2 4-4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconRefresh({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M21 12a9 9 0 0 0-9-9 9.75 9.75 0 0 0-6.74 2.74L3 8" />
|
||||
<path d="M3 3v5h5M3 12a9 9 0 0 0 9 9 9.75 9.75 0 0 0 6.74-2.74L21 16" />
|
||||
<path d="M16 16h5v5" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconChevronDown({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="m6 9 6 6 6-6" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconFolder({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M20 20a2 2 0 0 0 2-2V8a2 2 0 0 0-2-2h-7.9a2 2 0 0 1-1.69-.9L9.6 3.9A2 2 0 0 0 7.93 3H4a2 2 0 0 0-2 2v13a2 2 0 0 0 2 2Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconTrash({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M3 6h18M19 6v14c0 1-1 2-2 2H7c-1 0-2-1-2-2V6M8 6V4c0-1 1-2 2-2h4c1 0 2 1 2 2v2" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconPencil({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 20h9M16.5 3.5a2.12 2.12 0 0 1 3 3L7 19l-4 1 1-4Z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconLock({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<rect width="18" height="11" x="3" y="11" rx="2" ry="2" />
|
||||
<path d="M7 11V7a5 5 0 0 1 10 0v4" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconHeart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<path d="M12 21.35l-1.45-1.32C5.4 15.36 2 12.28 2 8.5 2 5.42 4.42 3 7.5 3c1.74 0 3.41.81 4.5 2.09C13.09 3.81 14.76 3 16.5 3 19.58 3 22 5.42 22 8.5c0 3.78-3.4 6.86-8.55 11.54L12 21.35z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
export function IconBarChart({ size = 16, className = 'svg-icon', strokeWidth = 2, ...props }: IconProps) {
|
||||
return (
|
||||
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth={strokeWidth} strokeLinecap="round" strokeLinejoin="round" className={className} {...props}>
|
||||
<line x1="12" y1="20" x2="12" y2="10" />
|
||||
<line x1="18" y1="20" x2="18" y2="4" />
|
||||
<line x1="6" y1="20" x2="6" y2="16" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
@@ -21,6 +21,7 @@ import {
|
||||
import { CSS } from '@dnd-kit/utilities';
|
||||
import AlbumPicker from './AlbumPicker';
|
||||
import AssetPicker from './AssetPicker';
|
||||
import { EssayBlockEditor } from './EssayBlockEditor';
|
||||
|
||||
// ── Icons ──────────────────────────────────────────────────────
|
||||
const Icons = {
|
||||
@@ -133,6 +134,9 @@ interface Subpage {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
password?: string;
|
||||
enabled?: boolean;
|
||||
essayText?: string;
|
||||
essayFile?: string;
|
||||
sections?: Section[];
|
||||
albums: AlbumEntry[];
|
||||
grid?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string };
|
||||
@@ -319,6 +323,7 @@ function SortableSubpageTile({
|
||||
const totalAlbums =
|
||||
sp.albums.length + (sp.sections?.reduce((sum, sec) => sum + sec.albums.length, 0) || 0);
|
||||
const firstThumb = getFirstThumb(sp);
|
||||
const slug = sp.name.toLowerCase().replace(/[^a-z0-9]+/g, '-').replace(/^-|-$/g, '');
|
||||
|
||||
return (
|
||||
<div
|
||||
@@ -331,6 +336,19 @@ function SortableSubpageTile({
|
||||
<div className="subpage-tile-drag" {...listeners} title="Drag to reorder">
|
||||
<Icons.Drag />
|
||||
</div>
|
||||
|
||||
{sp.enabled === false && (
|
||||
<span className="subpage-badge-protected" style={{ background: '#e60012', color: '#fff' }} title="Page is disabled">
|
||||
Disabled
|
||||
</span>
|
||||
)}
|
||||
|
||||
{sp.password && (
|
||||
<span className="subpage-badge-protected" title="Password protected">
|
||||
<Icons.Lock /> Password
|
||||
</span>
|
||||
)}
|
||||
|
||||
<div className="subpage-tile-cover">
|
||||
{firstThumb ? (
|
||||
<img src={`/api/admin/thumbnail/${firstThumb}`} alt="" loading="lazy" />
|
||||
@@ -339,11 +357,17 @@ function SortableSubpageTile({
|
||||
<Icons.Folder />
|
||||
</div>
|
||||
)}
|
||||
<div className="subpage-hover-overlay">
|
||||
<span className="hover-action-btn"><Icons.Edit /> Edit Page</span>
|
||||
</div>
|
||||
</div>
|
||||
<div className="subpage-tile-info">
|
||||
<div className="subpage-tile-title-row">
|
||||
<span className="subpage-tile-name">{sp.title || sp.name}</span>
|
||||
<span className="subpage-tile-slug">/{slug}</span>
|
||||
</div>
|
||||
<span className="subpage-tile-meta">
|
||||
{totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
<Icons.Folder /> {totalAlbums} album{totalAlbums !== 1 ? 's' : ''}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
@@ -360,6 +384,7 @@ export default function PageBuilder() {
|
||||
const [dirty, setDirty] = useState(false);
|
||||
const [saveMessage, setSaveMessage] = useState('');
|
||||
const [expandedSubpage, setExpandedSubpage] = useState<number | null>(null);
|
||||
const [drawerMode, setDrawerMode] = useState<'edit' | 'preview'>('edit');
|
||||
const [pickerTarget, setPickerTarget] = useState<{
|
||||
type: 'standalone' | 'subpage' | 'section';
|
||||
subpageIndex?: number;
|
||||
@@ -450,6 +475,9 @@ export default function PageBuilder() {
|
||||
title: sp.title as string | undefined,
|
||||
subtitle: sp.subtitle as string | undefined,
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(sp.albums as Array<string | Record<string, string>> | undefined),
|
||||
sections: sp.sections
|
||||
? (sp.sections as Array<Record<string, unknown>>).map((sec) => ({
|
||||
@@ -463,7 +491,7 @@ export default function PageBuilder() {
|
||||
} else if (raw.subpages && typeof raw.subpages === 'object') {
|
||||
subpages = Object.entries(raw.subpages as Record<string, unknown>).map(([name, value]) => {
|
||||
if (Array.isArray(value)) {
|
||||
return { name, albums: parseAlbumEntries(value), sections: undefined };
|
||||
return { name, albums: parseAlbumEntries(value), sections: undefined, enabled: true };
|
||||
}
|
||||
const sp = value as Record<string, unknown>;
|
||||
return {
|
||||
@@ -471,6 +499,9 @@ export default function PageBuilder() {
|
||||
title: sp.title as string | undefined,
|
||||
subtitle: sp.subtitle as string | undefined,
|
||||
password: sp.password as string | undefined,
|
||||
enabled: sp.enabled !== false,
|
||||
essayText: sp.essayText as string | undefined,
|
||||
essayFile: sp.essayFile as string | undefined,
|
||||
albums: parseAlbumEntries(
|
||||
sp.albums as Array<string | Record<string, string>> | undefined,
|
||||
),
|
||||
@@ -507,6 +538,9 @@ export default function PageBuilder() {
|
||||
if (sp.title) entry.title = sp.title;
|
||||
if (sp.subtitle) entry.subtitle = sp.subtitle;
|
||||
if (sp.password) entry.password = sp.password;
|
||||
if (sp.enabled === false) entry.enabled = false;
|
||||
if (sp.essayText) entry.essayText = sp.essayText;
|
||||
if (sp.essayFile) entry.essayFile = sp.essayFile;
|
||||
if (sp.grid) entry.grid = sp.grid;
|
||||
|
||||
if (sp.sections && sp.sections.length > 0) {
|
||||
@@ -895,7 +929,7 @@ export default function PageBuilder() {
|
||||
{/* Search Bar */}
|
||||
<div className="builder-search-container">
|
||||
<div className="builder-search-wrapper">
|
||||
<span className="builder-search-icon">🔍</span>
|
||||
<span className="builder-search-icon"><Icons.Search /></span>
|
||||
<input
|
||||
type="text"
|
||||
className="builder-search-input"
|
||||
@@ -1051,61 +1085,141 @@ export default function PageBuilder() {
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
|
||||
{/* Expanded subpage detail */}
|
||||
{/* Expanded subpage detail (Slide-Over Drawer) */}
|
||||
{expandedSubpage !== null && gallery.subpages[expandedSubpage] && (
|
||||
<div className="subpage-detail">
|
||||
<div className="subpage-drawer-backdrop" onClick={() => setExpandedSubpage(null)}>
|
||||
<div className="subpage-drawer-container" onClick={(e) => e.stopPropagation()}>
|
||||
{(() => {
|
||||
const sp = gallery.subpages[expandedSubpage];
|
||||
const spIndex = expandedSubpage;
|
||||
return (
|
||||
<>
|
||||
<div className="subpage-detail-header">
|
||||
<div className="subpage-detail-header-left">
|
||||
<input
|
||||
className="subpage-name-input"
|
||||
value={sp.name}
|
||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||
placeholder="Page name (used for URL)"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
/>
|
||||
<span className="subpage-slug-preview">/{slugify(sp.name)}</span>
|
||||
<div className="subpage-drawer-header">
|
||||
<div className="subpage-drawer-title-group">
|
||||
<span className="subpage-drawer-breadcrumb">Pages / Subpages</span>
|
||||
<h3 className="subpage-drawer-title">{sp.title || sp.name || 'Untitled Page'}</h3>
|
||||
</div>
|
||||
<div className="subpage-detail-header-right">
|
||||
<div className="subpage-drawer-header-actions">
|
||||
<div className="segmented-control" style={{ padding: '2px' }}>
|
||||
<button
|
||||
type="button"
|
||||
className={`segment-btn ${drawerMode === 'edit' ? 'active' : ''}`}
|
||||
onClick={() => setDrawerMode('edit')}
|
||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
<Icons.Edit /> Edit
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
className={`segment-btn ${drawerMode === 'preview' ? 'active' : ''}`}
|
||||
onClick={() => setDrawerMode('preview')}
|
||||
style={{ padding: '0.2rem 0.6rem', fontSize: '0.75rem' }}
|
||||
>
|
||||
<Icons.Search /> Live Preview
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<a
|
||||
href={`/${slugify(sp.name)}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="admin-btn-icon"
|
||||
title="Preview this page"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
className="admin-btn admin-btn-xs admin-btn-ghost"
|
||||
title="Open live page in new tab"
|
||||
>
|
||||
↗
|
||||
/{slugify(sp.name)} ↗
|
||||
</a>
|
||||
<button
|
||||
className="admin-btn-icon"
|
||||
onClick={() => setExpandedSubpage(null)}
|
||||
title="Collapse"
|
||||
title="Close drawer"
|
||||
>
|
||||
✕
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn-icon admin-btn-icon-danger"
|
||||
onClick={() => {
|
||||
removeSubpage(spIndex);
|
||||
setExpandedSubpage(null);
|
||||
}}
|
||||
title="Delete subpage"
|
||||
>
|
||||
🗑
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="subpage-drawer-body">
|
||||
{drawerMode === 'preview' ? (
|
||||
<div className="drawer-live-preview-panel">
|
||||
<div className="preview-hero-banner">
|
||||
<h2 className="preview-hero-title">{sp.title || sp.name || 'Untitled Page'}</h2>
|
||||
{sp.subtitle && <p className="preview-hero-sub">{sp.subtitle}</p>}
|
||||
</div>
|
||||
|
||||
{sp.sections && sp.sections.length > 0 ? (
|
||||
sp.sections.map((sec, sIdx) => (
|
||||
<div key={sIdx} className="preview-section-group">
|
||||
<h3 className="preview-section-title">{sec.title || 'Untitled Section'}</h3>
|
||||
{sec.description && <p className="preview-section-desc">{sec.description}</p>}
|
||||
<div className="preview-albums-grid">
|
||||
{sec.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
))
|
||||
) : (
|
||||
<div className="preview-albums-grid">
|
||||
{sp.albums.map((alb, aIdx) => {
|
||||
const immichAlb = immichAlbums.find((a) => a.id === alb.id);
|
||||
const thumb = alb.heroImage || immichAlb?.thumbnailAssetId;
|
||||
|
||||
return (
|
||||
<div key={aIdx} className="preview-album-tile">
|
||||
<div className="preview-album-cover">
|
||||
{thumb ? (
|
||||
<img src={`/api/admin/thumbnail/${thumb}`} alt="" />
|
||||
) : (
|
||||
<div className="subpage-tile-placeholder"><Icons.Folder /></div>
|
||||
)}
|
||||
</div>
|
||||
<span className="preview-album-title">
|
||||
{alb.title || immichAlb?.albumName || alb.id}
|
||||
</span>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<>
|
||||
<div className="subpage-drawer-section">
|
||||
<div className="admin-field">
|
||||
<label>Page Name (URL Identifier)</label>
|
||||
<div className="input-slug-wrapper">
|
||||
<input
|
||||
className="subpage-name-input"
|
||||
value={sp.name}
|
||||
onChange={(e) => updateSubpage(spIndex, { name: e.target.value })}
|
||||
placeholder="e.g. dubai, travel, weddings"
|
||||
/>
|
||||
<span className="input-slug-preview">/{slugify(sp.name)}</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Subpage metadata */}
|
||||
<div className="subpage-meta">
|
||||
<div className="subpage-drawer-section">
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Title (optional)</label>
|
||||
<label>Display Title (optional)</label>
|
||||
<input
|
||||
value={sp.title || ''}
|
||||
onChange={(e) =>
|
||||
@@ -1125,33 +1239,103 @@ export default function PageBuilder() {
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="admin-field-row">
|
||||
<div className="admin-field">
|
||||
<label>Password (optional)</label>
|
||||
<label>Password Protection (optional)</label>
|
||||
<div className="password-input-wrapper">
|
||||
<span className="password-icon"><Icons.Lock /></span>
|
||||
<input
|
||||
type="password"
|
||||
value={sp.password || ''}
|
||||
onChange={(e) =>
|
||||
updateSubpage(spIndex, { password: e.target.value || undefined })
|
||||
}
|
||||
placeholder="Leave empty for public"
|
||||
placeholder="Leave empty for public access"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Page Visibility Status</label>
|
||||
<button
|
||||
type="button"
|
||||
className={`admin-toggle-card ${sp.enabled !== false ? 'active' : ''}`}
|
||||
onClick={() => updateSubpage(spIndex, { enabled: sp.enabled === false })}
|
||||
style={{ padding: '10px 14px', width: '100%', display: 'flex', alignItems: 'center', justifyContent: 'space-between', borderRadius: '8px', cursor: 'pointer' }}
|
||||
>
|
||||
<div className="toggle-card-info" style={{ textAlign: 'left' }}>
|
||||
<span className="toggle-card-title" style={{ fontWeight: 600 }}>
|
||||
{sp.enabled !== false ? '🟢 Page Active (Published)' : '🔴 Page Disabled (Hidden)'}
|
||||
</span>
|
||||
<span className="toggle-card-desc" style={{ fontSize: '0.8rem', display: 'block', opacity: 0.75 }}>
|
||||
{sp.enabled !== false
|
||||
? 'Visible in header menu and reachable via URL'
|
||||
: 'Hidden from navigation menu. Returns 404 if accessed directly.'}
|
||||
</span>
|
||||
</div>
|
||||
<div className={`switch-toggle ${sp.enabled !== false ? 'on' : ''}`}>
|
||||
<span className="switch-slider" />
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
<div className="admin-field" style={{ marginTop: '1rem' }}>
|
||||
<label>Page Layout Style</label>
|
||||
<select
|
||||
value={sp.grid?.layout || 'masonry'}
|
||||
onChange={(e) => {
|
||||
const newLayout = e.target.value;
|
||||
updateSubpage(spIndex, {
|
||||
grid: { ...(sp.grid || {}), layout: newLayout },
|
||||
});
|
||||
}}
|
||||
style={{ padding: '8px 12px', borderRadius: '6px', width: '100%', fontSize: '0.9rem' }}
|
||||
>
|
||||
<option value="masonry">📷 Masonry Grid (Dynamic Height)</option>
|
||||
<option value="uniform">🔲 Uniform Grid (Square Tiles)</option>
|
||||
<option value="showcase">⭐ Showcase (Featured First Asset)</option>
|
||||
<option value="filmstrip">🎞️ Filmstrip (Horizontal Scroll)</option>
|
||||
<option value="editorial-flow">📰 Editorial Flow</option>
|
||||
<option value="essay">📖 Photo Essay Mode (Storytelling Editor)</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Visual Photo Essay Builder (if layout === 'essay') */}
|
||||
{(sp.grid?.layout === 'essay' || sp.essayText != null) && (
|
||||
<div className="subpage-drawer-section" style={{ background: 'var(--admin-surface, rgba(255,255,255,0.02))', padding: '1rem', borderRadius: '8px', border: '1px solid var(--admin-border, rgba(255,255,255,0.1))' }}>
|
||||
<div style={{ marginBottom: '1rem' }}>
|
||||
<h4 style={{ margin: 0, fontSize: '1rem', display: 'flex', alignItems: 'center', gap: '8px' }}>
|
||||
<span>📖 Storytelling & Photo Essay Builder</span>
|
||||
</h4>
|
||||
<p style={{ margin: '4px 0 0 0', fontSize: '0.8rem', opacity: 0.75 }}>
|
||||
Compose text, quotes, and fullbleed/wide photos visually.
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<EssayBlockEditor
|
||||
markdown={sp.essayText || ''}
|
||||
onChange={(newMarkdown) => updateSubpage(spIndex, { essayText: newMarkdown })}
|
||||
onSelectPhoto={(callback) => {
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex });
|
||||
// We can pass a photo picker handler if needed
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="settings-section-divider" />
|
||||
|
||||
{/* Albums (if no sections) with DnD */}
|
||||
{(!sp.sections || sp.sections.length === 0) && (
|
||||
<div className="subpage-albums">
|
||||
<div className="subpage-albums-header">
|
||||
<span>Albums</span>
|
||||
<span>Albums in this Page</span>
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn admin-btn-xs admin-btn-primary"
|
||||
onClick={() =>
|
||||
setPickerTarget({ type: 'subpage', subpageIndex: spIndex })
|
||||
}
|
||||
>
|
||||
+ Add
|
||||
+ Add Album
|
||||
</button>
|
||||
</div>
|
||||
<DndContext
|
||||
@@ -1164,6 +1348,9 @@ export default function PageBuilder() {
|
||||
strategy={verticalListSortingStrategy}
|
||||
>
|
||||
<div className="album-list">
|
||||
{sp.albums.length === 0 && (
|
||||
<p className="empty-hint">No albums added yet. Click + Add Album to pick from Immich.</p>
|
||||
)}
|
||||
{sp.albums.map((album, aIndex) => (
|
||||
<SortableAlbumCard
|
||||
key={`${album.id}-${aIndex}`}
|
||||
@@ -1246,23 +1433,45 @@ export default function PageBuilder() {
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div className="subpage-actions">
|
||||
<button className="admin-btn admin-btn-xs" onClick={() => addSection(spIndex)}>
|
||||
<div className="subpage-actions" style={{ marginTop: '1rem' }}>
|
||||
<button className="admin-btn admin-btn-xs admin-btn-secondary" onClick={() => addSection(spIndex)}>
|
||||
+ Add Section
|
||||
</button>
|
||||
{sp.sections && sp.sections.length > 0 && (
|
||||
<button
|
||||
className="admin-btn admin-btn-xs"
|
||||
className="admin-btn admin-btn-xs admin-btn-secondary"
|
||||
onClick={() => setPickerTarget({ type: 'subpage', subpageIndex: spIndex })}
|
||||
>
|
||||
+ Add Album (outside sections)
|
||||
+ Add Loose Album
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
|
||||
<div className="subpage-drawer-footer">
|
||||
<button
|
||||
className="admin-btn admin-btn-danger admin-btn-sm"
|
||||
onClick={() => {
|
||||
removeSubpage(spIndex);
|
||||
setExpandedSubpage(null);
|
||||
}}
|
||||
>
|
||||
<Icons.Trash /> Delete Subpage
|
||||
</button>
|
||||
<button
|
||||
className="admin-btn admin-btn-primary admin-btn-sm"
|
||||
onClick={() => setExpandedSubpage(null)}
|
||||
>
|
||||
Done
|
||||
</button>
|
||||
</div>
|
||||
</>
|
||||
);
|
||||
})()}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</section>
|
||||
|
||||
|
||||
File diff suppressed because it is too large
Load Diff
50
app/api/admin/analytics/route.ts
Normal file
50
app/api/admin/analytics/route.ts
Normal file
@@ -0,0 +1,50 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const ANALYTICS_FILE = path.join(process.cwd(), 'content', 'analytics.json');
|
||||
|
||||
export async function GET() {
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
let data;
|
||||
try {
|
||||
const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
|
||||
data = JSON.parse(raw);
|
||||
} catch {
|
||||
data = { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
|
||||
}
|
||||
|
||||
// Compute top pages across all days
|
||||
const topPagesMap: Record<string, number> = {};
|
||||
let desktopCount = 0;
|
||||
let mobileCount = 0;
|
||||
|
||||
Object.values(data.days || {}).forEach((day: any) => {
|
||||
Object.entries(day.pages || {}).forEach(([p, count]: [string, any]) => {
|
||||
topPagesMap[p] = (topPagesMap[p] || 0) + count;
|
||||
});
|
||||
desktopCount += day.devices?.desktop || 0;
|
||||
mobileCount += day.devices?.mobile || 0;
|
||||
});
|
||||
|
||||
const topPages = Object.entries(topPagesMap)
|
||||
.map(([path, count]) => ({ path, count }))
|
||||
.sort((a, b) => b.count - a.count)
|
||||
.slice(0, 10);
|
||||
|
||||
return NextResponse.json({
|
||||
trackingEnabled: config?.analytics !== false,
|
||||
summary: data.summary,
|
||||
days: data.days,
|
||||
topPages,
|
||||
devices: {
|
||||
desktop: desktopCount,
|
||||
mobile: mobileCount,
|
||||
},
|
||||
});
|
||||
} catch (err: any) {
|
||||
return NextResponse.json({ error: err.message || 'Failed to fetch analytics' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
109
app/api/admin/backups/route.ts
Normal file
109
app/api/admin/backups/route.ts
Normal file
@@ -0,0 +1,109 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import { revalidatePath } from 'next/cache';
|
||||
import { isAdminAuthenticated, isAdminEnabled } from '@/lib/admin/auth';
|
||||
import { listBackups, restoreBackup } from '@/lib/admin/yaml-service';
|
||||
import { invalidateConfigCache } from '@/lib/config';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
export interface BackupItem {
|
||||
filename: string;
|
||||
target: 'gallery' | 'settings';
|
||||
timestamp: string | null;
|
||||
isPreRestore: boolean;
|
||||
}
|
||||
|
||||
function parseBackupInfo(filename: string): BackupItem {
|
||||
const target = filename.startsWith('gallery.yaml') ? 'gallery' : 'settings';
|
||||
const isPreRestore = filename.includes('pre-restore');
|
||||
|
||||
// Extract timestamp from filename like filename.2026-05-31T17-30-00-000Z.bak
|
||||
const match = filename.match(/\d{4}-\d{2}-\d{2}T\d{2}-\d{2}-\d{2}-\d{3}Z/);
|
||||
let timestamp: string | null = null;
|
||||
if (match) {
|
||||
// Reconstruct valid ISO string: 2026-05-31T17:30:00.000Z
|
||||
const parts = match[0].split('T');
|
||||
const datePart = parts[0];
|
||||
const timeParts = parts[1].replace('Z', '').split('-');
|
||||
if (timeParts.length >= 4) {
|
||||
timestamp = `${datePart}T${timeParts[0]}:${timeParts[1]}:${timeParts[2]}.${timeParts[3]}Z`;
|
||||
}
|
||||
}
|
||||
|
||||
return {
|
||||
filename,
|
||||
target,
|
||||
timestamp,
|
||||
isPreRestore,
|
||||
};
|
||||
}
|
||||
|
||||
/** GET: List all available backups for gallery and settings. */
|
||||
export async function GET() {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const galleryRaw = await listBackups('gallery.yaml');
|
||||
const settingsRaw = await listBackups('settings.yaml');
|
||||
|
||||
const gallery = galleryRaw.map(parseBackupInfo);
|
||||
const settings = settingsRaw.map(parseBackupInfo);
|
||||
|
||||
return NextResponse.json({
|
||||
backups: {
|
||||
gallery,
|
||||
settings,
|
||||
},
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Admin API] Error listing backups:', err);
|
||||
return NextResponse.json({ error: 'Failed to list backups' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
|
||||
/** POST: Restore a specific backup file. */
|
||||
export async function POST(req: Request) {
|
||||
if (!isAdminEnabled()) {
|
||||
return NextResponse.json({ error: 'Admin not enabled' }, { status: 403 });
|
||||
}
|
||||
if (!(await isAdminAuthenticated())) {
|
||||
return NextResponse.json({ error: 'Unauthorized' }, { status: 401 });
|
||||
}
|
||||
|
||||
try {
|
||||
const body = await req.json();
|
||||
const { backupFilename } = body || {};
|
||||
|
||||
if (!backupFilename || typeof backupFilename !== 'string') {
|
||||
return NextResponse.json({ error: 'backupFilename is required' }, { status: 400 });
|
||||
}
|
||||
|
||||
// Security check: prevent directory traversal
|
||||
if (backupFilename.includes('..') || backupFilename.includes('/') || backupFilename.includes('\\')) {
|
||||
return NextResponse.json({ error: 'Invalid backup filename' }, { status: 400 });
|
||||
}
|
||||
|
||||
if (!backupFilename.endsWith('.bak')) {
|
||||
return NextResponse.json({ error: 'Invalid backup file extension' }, { status: 400 });
|
||||
}
|
||||
|
||||
await restoreBackup(backupFilename);
|
||||
|
||||
// Invalidate caches & revalidate pages
|
||||
invalidateConfigCache();
|
||||
immich.invalidateAll();
|
||||
revalidatePath('/', 'layout');
|
||||
|
||||
return NextResponse.json({
|
||||
success: true,
|
||||
message: `Successfully restored ${backupFilename}`,
|
||||
});
|
||||
} catch (err) {
|
||||
console.error('[Admin API] Error restoring backup:', err);
|
||||
return NextResponse.json({ error: 'Failed to restore backup' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
75
app/api/analytics/track/route.ts
Normal file
75
app/api/analytics/track/route.ts
Normal file
@@ -0,0 +1,75 @@
|
||||
import { NextResponse } from 'next/server';
|
||||
import fs from 'fs/promises';
|
||||
import path from 'path';
|
||||
import { getConfigOrNull } from '@/lib/config';
|
||||
|
||||
const CONTENT_DIR = path.join(process.cwd(), 'content');
|
||||
const ANALYTICS_FILE = path.join(CONTENT_DIR, 'analytics.json');
|
||||
|
||||
interface AnalyticsData {
|
||||
summary: {
|
||||
totalViews: number;
|
||||
lastUpdated: string;
|
||||
};
|
||||
days: Record<
|
||||
string,
|
||||
{
|
||||
pageviews: number;
|
||||
pages: Record<string, number>;
|
||||
devices: Record<string, number>;
|
||||
}
|
||||
>;
|
||||
}
|
||||
|
||||
async function getAnalytics(): Promise<AnalyticsData> {
|
||||
try {
|
||||
const raw = await fs.readFile(ANALYTICS_FILE, 'utf8');
|
||||
return JSON.parse(raw);
|
||||
} catch {
|
||||
return { summary: { totalViews: 0, lastUpdated: new Date().toISOString() }, days: {} };
|
||||
}
|
||||
}
|
||||
|
||||
async function saveAnalytics(data: AnalyticsData) {
|
||||
try {
|
||||
await fs.mkdir(CONTENT_DIR, { recursive: true });
|
||||
await fs.writeFile(ANALYTICS_FILE, JSON.stringify(data, null, 2), 'utf8');
|
||||
} catch (err) {
|
||||
console.error('[Analytics] Failed to save analytics data:', err);
|
||||
}
|
||||
}
|
||||
|
||||
export async function POST(req: Request) {
|
||||
try {
|
||||
const config = getConfigOrNull();
|
||||
if (config?.analytics === false) {
|
||||
return NextResponse.json({ ok: true, trackingDisabled: true });
|
||||
}
|
||||
|
||||
const body = await req.json();
|
||||
const pagePath = (body.path || '/').split('?')[0];
|
||||
const userAgent = req.headers.get('user-agent') || '';
|
||||
const isMobile = /mobile|iphone|ipad|android/i.test(userAgent);
|
||||
const deviceType = isMobile ? 'mobile' : 'desktop';
|
||||
|
||||
const dateKey = new Date().toISOString().split('T')[0];
|
||||
const data = await getAnalytics();
|
||||
|
||||
data.summary.totalViews = (data.summary.totalViews || 0) + 1;
|
||||
data.summary.lastUpdated = new Date().toISOString();
|
||||
|
||||
if (!data.days[dateKey]) {
|
||||
data.days[dateKey] = { pageviews: 0, pages: {}, devices: {} };
|
||||
}
|
||||
|
||||
const dayObj = data.days[dateKey];
|
||||
dayObj.pageviews = (dayObj.pageviews || 0) + 1;
|
||||
dayObj.pages[pagePath] = (dayObj.pages[pagePath] || 0) + 1;
|
||||
dayObj.devices[deviceType] = (dayObj.devices[deviceType] || 0) + 1;
|
||||
|
||||
await saveAnalytics(data);
|
||||
return NextResponse.json({ ok: true });
|
||||
} catch (err) {
|
||||
return NextResponse.json({ error: 'Failed to record tracking' }, { status: 500 });
|
||||
}
|
||||
}
|
||||
@@ -17,6 +17,8 @@ import { getConfigOrNull, getGoogleFontsUrl, AppConfig } from '@/lib/config';
|
||||
import { isAdminPath } from '@/lib/admin/paths';
|
||||
// DevToolbarLoader is a Client Component (ssr: false is only allowed there)
|
||||
import { DevToolbarLoader } from '@/components/DevToolbarLoader';
|
||||
import AssetProtection from '@/components/AssetProtection';
|
||||
import AnalyticsTracker from '@/components/AnalyticsTracker';
|
||||
|
||||
export async function generateMetadata(): Promise<Metadata> {
|
||||
const config = getConfigOrNull();
|
||||
@@ -33,7 +35,10 @@ export async function generateMetadata(): Promise<Metadata> {
|
||||
};
|
||||
|
||||
return {
|
||||
title: siteTitle,
|
||||
title: {
|
||||
default: siteTitle,
|
||||
template: config.seo.titleTemplate,
|
||||
},
|
||||
description: siteDescription,
|
||||
robots,
|
||||
icons: {
|
||||
@@ -97,6 +102,8 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
);
|
||||
}
|
||||
|
||||
const isAdmin = isAdminPath(pathname);
|
||||
|
||||
return (
|
||||
<html
|
||||
lang={config.lang || 'en'}
|
||||
@@ -114,6 +121,10 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
<link rel="stylesheet" href={fontsUrl} />
|
||||
</head>
|
||||
<body>
|
||||
{isAdmin ? (
|
||||
children
|
||||
) : (
|
||||
<>
|
||||
<a href="#main-content" className="skip-link">
|
||||
Skip to content
|
||||
</a>
|
||||
@@ -139,7 +150,14 @@ export default async function RootLayout({ children }: { children: React.ReactNo
|
||||
</main>
|
||||
<Footer />
|
||||
<ScrollToTop />
|
||||
<AssetProtection
|
||||
disableRightClick={config.protection?.disableRightClick}
|
||||
disableImageDrag={config.protection?.disableImageDrag}
|
||||
/>
|
||||
<AnalyticsTracker />
|
||||
{process.env.NODE_ENV === 'development' && <DevToolbarLoader />}
|
||||
</>
|
||||
)}
|
||||
</body>
|
||||
</html>
|
||||
);
|
||||
|
||||
22
components/AnalyticsTracker.tsx
Normal file
22
components/AnalyticsTracker.tsx
Normal file
@@ -0,0 +1,22 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
import { usePathname } from 'next/navigation';
|
||||
|
||||
export default function AnalyticsTracker() {
|
||||
const pathname = usePathname();
|
||||
|
||||
useEffect(() => {
|
||||
if (!pathname || pathname.startsWith('/admin') || pathname.startsWith('/api')) return;
|
||||
|
||||
fetch('/api/analytics/track', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ path: pathname }),
|
||||
}).catch(() => {
|
||||
// Ignore background tracking errors
|
||||
});
|
||||
}, [pathname]);
|
||||
|
||||
return null;
|
||||
}
|
||||
42
components/AssetProtection.tsx
Normal file
42
components/AssetProtection.tsx
Normal file
@@ -0,0 +1,42 @@
|
||||
'use client';
|
||||
|
||||
import { useEffect } from 'react';
|
||||
|
||||
interface Props {
|
||||
disableRightClick?: boolean;
|
||||
disableImageDrag?: boolean;
|
||||
}
|
||||
|
||||
export default function AssetProtection({ disableRightClick, disableImageDrag }: Props) {
|
||||
useEffect(() => {
|
||||
if (!disableRightClick && !disableImageDrag) return;
|
||||
|
||||
function handleContextMenu(e: MouseEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (disableRightClick && (target.tagName === 'IMG' || target.closest('img') || target.classList.contains('photo-tile') || target.classList.contains('lightbox-img'))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
function handleDragStart(e: DragEvent) {
|
||||
const target = e.target as HTMLElement;
|
||||
if (disableImageDrag && (target.tagName === 'IMG' || target.closest('img'))) {
|
||||
e.preventDefault();
|
||||
}
|
||||
}
|
||||
|
||||
if (disableRightClick) {
|
||||
document.addEventListener('contextmenu', handleContextMenu);
|
||||
}
|
||||
if (disableImageDrag) {
|
||||
document.addEventListener('dragstart', handleDragStart);
|
||||
}
|
||||
|
||||
return () => {
|
||||
document.removeEventListener('contextmenu', handleContextMenu);
|
||||
document.removeEventListener('dragstart', handleDragStart);
|
||||
};
|
||||
}, [disableRightClick, disableImageDrag]);
|
||||
|
||||
return null;
|
||||
}
|
||||
@@ -252,6 +252,39 @@
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
/* ── Watermark Overlay ────────────────────────────── */
|
||||
.watermark {
|
||||
position: absolute;
|
||||
z-index: 10;
|
||||
pointer-events: none;
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 600;
|
||||
letter-spacing: 0.1em;
|
||||
text-transform: uppercase;
|
||||
color: #ffffff;
|
||||
text-shadow: 0 1px 4px rgba(0, 0, 0, 0.9), 0 0 10px rgba(0, 0, 0, 0.7);
|
||||
padding: 12px 16px;
|
||||
user-select: none;
|
||||
}
|
||||
|
||||
.watermark_bottom_right {
|
||||
bottom: 12px;
|
||||
right: 16px;
|
||||
}
|
||||
|
||||
.watermark_bottom_left {
|
||||
bottom: 12px;
|
||||
left: 16px;
|
||||
}
|
||||
|
||||
.watermark_center {
|
||||
top: 50%;
|
||||
left: 50%;
|
||||
transform: translate(-50%, -50%);
|
||||
}
|
||||
|
||||
|
||||
@media (hover: none) {
|
||||
.close,
|
||||
.nav,
|
||||
|
||||
@@ -17,6 +17,14 @@ import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import { useExif } from '@/hooks/useExif';
|
||||
import { useSwipe } from '@/hooks/useSwipe';
|
||||
import styles from './Lightbox.module.css';
|
||||
import { useProofing } from './ProofingContext';
|
||||
|
||||
export interface LightboxWatermark {
|
||||
enabled?: boolean;
|
||||
text?: string;
|
||||
opacity?: number;
|
||||
position?: 'bottom-right' | 'bottom-left' | 'center' | string;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
assets: PhotoItem[];
|
||||
@@ -24,9 +32,10 @@ interface LightboxProps {
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
watermark?: LightboxWatermark;
|
||||
}
|
||||
|
||||
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
|
||||
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev, watermark }: LightboxProps) {
|
||||
const [showExif, setShowExif] = useState(false);
|
||||
const { exifData, exifLoading, fetchExif, clearExif } = useExif();
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
@@ -34,6 +43,8 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
||||
const closeBtnRef = useRef<HTMLButtonElement>(null);
|
||||
|
||||
const current = assets[currentIndex];
|
||||
const proofing = useProofing();
|
||||
const isFav = proofing && current ? proofing.isFavorite(current.id) : false;
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
// Mount guard — createPortal needs document.body (client-only)
|
||||
@@ -193,6 +204,21 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
||||
onError={() => console.error(`[Lightbox] Failed to load image: ${current.previewUrl}`)}
|
||||
/>
|
||||
)}
|
||||
|
||||
{watermark?.enabled && watermark.text && (
|
||||
<div
|
||||
className={`${styles.watermark} ${
|
||||
watermark.position === 'bottom-left'
|
||||
? styles.watermark_bottom_left
|
||||
: watermark.position === 'center'
|
||||
? styles.watermark_center
|
||||
: styles.watermark_bottom_right
|
||||
}`}
|
||||
style={{ opacity: (watermark.opacity ?? 50) / 100 }}
|
||||
>
|
||||
{watermark.text}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Next button */}
|
||||
@@ -225,6 +251,23 @@ export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: Ligh
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{/* Proofing favorite button */}
|
||||
{proofing && current && (
|
||||
<button
|
||||
className={styles.infoToggle}
|
||||
style={{
|
||||
right: '6.5rem',
|
||||
color: isFav ? '#ff4d4f' : 'inherit',
|
||||
fontWeight: isFav ? 600 : 400,
|
||||
}}
|
||||
onClick={() => proofing.toggleFavorite(current.id)}
|
||||
aria-label={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
title={isFav ? 'Remove from favorites' : 'Add to favorites'}
|
||||
>
|
||||
{isFav ? '❤️ Saved' : '🤍 Favorite'}
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* EXIF toggle */}
|
||||
<button
|
||||
className={styles.infoToggle}
|
||||
|
||||
152
components/ProofingContext.tsx
Normal file
152
components/ProofingContext.tsx
Normal file
@@ -0,0 +1,152 @@
|
||||
'use client';
|
||||
|
||||
import React, { createContext, useContext, useState, useEffect } from 'react';
|
||||
import { encodeProofBitmask, decodeProofBitmask } from '@/lib/proofing';
|
||||
|
||||
interface ProofingContextType {
|
||||
favorites: Set<string>;
|
||||
toggleFavorite: (token: string) => void;
|
||||
isFavorite: (token: string) => boolean;
|
||||
clearFavorites: () => void;
|
||||
isFilterActive: boolean;
|
||||
setIsFilterActive: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
isModalOpen: boolean;
|
||||
setIsModalOpen: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
getProofingUrl: () => string;
|
||||
getFormattedList: () => string;
|
||||
allowMailto: boolean;
|
||||
}
|
||||
|
||||
const ProofingContext = createContext<ProofingContextType | null>(null);
|
||||
|
||||
export function ProofingProvider({
|
||||
children,
|
||||
albumTokens = [],
|
||||
albumName = 'Gallery',
|
||||
allowMailto = true,
|
||||
}: {
|
||||
children: React.ReactNode;
|
||||
albumTokens?: string[];
|
||||
albumName?: string;
|
||||
allowMailto?: boolean;
|
||||
}) {
|
||||
const [favorites, setFavorites] = useState<Set<string>>(new Set());
|
||||
const [isFilterActive, setIsFilterActive] = useState(false);
|
||||
const [isModalOpen, setIsModalOpen] = useState(false);
|
||||
|
||||
const storageKey = `folio_fav_${albumName.toLowerCase().replace(/[^a-z0-9]+/g, '-')}`;
|
||||
|
||||
// Hydrate from localStorage and URL query params on mount
|
||||
useEffect(() => {
|
||||
try {
|
||||
const saved = localStorage.getItem(storageKey);
|
||||
const initialSet = new Set<string>();
|
||||
if (saved) {
|
||||
const parsed = JSON.parse(saved);
|
||||
if (Array.isArray(parsed)) {
|
||||
parsed.forEach((t) => initialSet.add(t));
|
||||
}
|
||||
}
|
||||
|
||||
// Check URL query param ?proof=...
|
||||
const urlParams = new URLSearchParams(window.location.search);
|
||||
const proofCode = urlParams.get('proof');
|
||||
if (proofCode && albumTokens.length > 0) {
|
||||
const decodedIndices = decodeProofBitmask(proofCode, albumTokens.length);
|
||||
decodedIndices.forEach((idx) => {
|
||||
if (albumTokens[idx]) {
|
||||
initialSet.add(albumTokens[idx]);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
setFavorites(initialSet);
|
||||
} catch (e) {
|
||||
console.error('Failed to load favorites', e);
|
||||
}
|
||||
}, [storageKey, albumTokens]);
|
||||
|
||||
const toggleFavorite = (token: string) => {
|
||||
setFavorites((prev) => {
|
||||
const next = new Set(prev);
|
||||
if (next.has(token)) {
|
||||
next.delete(token);
|
||||
} else {
|
||||
next.add(token);
|
||||
}
|
||||
try {
|
||||
localStorage.setItem(storageKey, JSON.stringify(Array.from(next)));
|
||||
} catch (e) {
|
||||
console.error('Failed to save favorites', e);
|
||||
}
|
||||
return next;
|
||||
});
|
||||
};
|
||||
|
||||
const isFavorite = (token: string) => favorites.has(token);
|
||||
|
||||
const clearFavorites = () => {
|
||||
setFavorites(new Set());
|
||||
try {
|
||||
localStorage.removeItem(storageKey);
|
||||
} catch (e) {
|
||||
console.error('Failed to clear favorites', e);
|
||||
}
|
||||
};
|
||||
|
||||
const getProofingUrl = () => {
|
||||
if (albumTokens.length === 0 || favorites.size === 0) {
|
||||
return typeof window !== 'undefined' ? window.location.href : '';
|
||||
}
|
||||
const indices: number[] = [];
|
||||
albumTokens.forEach((token, index) => {
|
||||
if (favorites.has(token)) {
|
||||
indices.push(index);
|
||||
}
|
||||
});
|
||||
const code = encodeProofBitmask(indices);
|
||||
const url = new URL(typeof window !== 'undefined' ? window.location.href : 'http://localhost');
|
||||
if (code) {
|
||||
url.searchParams.set('proof', code);
|
||||
} else {
|
||||
url.searchParams.delete('proof');
|
||||
}
|
||||
return url.toString();
|
||||
};
|
||||
|
||||
const getFormattedList = () => {
|
||||
const selectedIndices: number[] = [];
|
||||
albumTokens.forEach((token, index) => {
|
||||
if (favorites.has(token)) {
|
||||
selectedIndices.push(index + 1);
|
||||
}
|
||||
});
|
||||
if (selectedIndices.length === 0) return `${albumName}: No photos selected.`;
|
||||
return `${albumName} — Selected Photos (${selectedIndices.length}): #${selectedIndices.join(', #')}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<ProofingContext.Provider
|
||||
value={{
|
||||
favorites,
|
||||
toggleFavorite,
|
||||
isFavorite,
|
||||
clearFavorites,
|
||||
isFilterActive,
|
||||
setIsFilterActive,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
allowMailto,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</ProofingContext.Provider>
|
||||
);
|
||||
}
|
||||
|
||||
export function useProofing() {
|
||||
const context = useContext(ProofingContext);
|
||||
return context;
|
||||
}
|
||||
197
components/ProofingModal.tsx
Normal file
197
components/ProofingModal.tsx
Normal file
@@ -0,0 +1,197 @@
|
||||
'use client';
|
||||
|
||||
import React, { useState } from 'react';
|
||||
import { useProofing } from './ProofingContext';
|
||||
|
||||
export function ProofingModal() {
|
||||
const proofing = useProofing();
|
||||
const [copiedState, setCopiedState] = useState<'none' | 'link' | 'list'>('none');
|
||||
|
||||
if (!proofing || !proofing.isModalOpen) return null;
|
||||
|
||||
const {
|
||||
favorites,
|
||||
isModalOpen,
|
||||
setIsModalOpen,
|
||||
getProofingUrl,
|
||||
getFormattedList,
|
||||
clearFavorites,
|
||||
allowMailto,
|
||||
} = proofing;
|
||||
|
||||
const handleCopyLink = () => {
|
||||
const url = getProofingUrl();
|
||||
navigator.clipboard.writeText(url).then(() => {
|
||||
setCopiedState('link');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleCopyList = () => {
|
||||
const list = getFormattedList();
|
||||
navigator.clipboard.writeText(list).then(() => {
|
||||
setCopiedState('list');
|
||||
setTimeout(() => setCopiedState('none'), 2000);
|
||||
});
|
||||
};
|
||||
|
||||
const handleMailto = () => {
|
||||
const subject = encodeURIComponent(`Photo Selection (${favorites.size} items)`);
|
||||
const body = encodeURIComponent(
|
||||
`Hello,\n\nHere is my photo selection:\n\n${getFormattedList()}\n\nShare Link: ${getProofingUrl()}\n\nBest regards,`,
|
||||
);
|
||||
window.location.href = `mailto:?subject=${subject}&body=${body}`;
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
className="proofing-modal-overlay"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
style={{
|
||||
position: 'fixed',
|
||||
inset: 0,
|
||||
backgroundColor: 'rgba(0,0,0,0.7)',
|
||||
backdropFilter: 'blur(4px)',
|
||||
zIndex: 9999,
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
padding: '1rem',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
className="proofing-modal-card"
|
||||
onClick={(e) => e.stopPropagation()}
|
||||
style={{
|
||||
background: 'var(--bg-surface, #1e1e1e)',
|
||||
color: 'var(--text-primary, #ffffff)',
|
||||
borderRadius: 'var(--radius-md, 12px)',
|
||||
border: '1px solid var(--border-color, rgba(255,255,255,0.1))',
|
||||
padding: '1.5rem',
|
||||
maxWidth: '480px',
|
||||
width: '100%',
|
||||
boxShadow: '0 20px 40px rgba(0,0,0,0.5)',
|
||||
}}
|
||||
>
|
||||
<div
|
||||
style={{
|
||||
display: 'flex',
|
||||
justifyContent: 'space-between',
|
||||
alignItems: 'center',
|
||||
marginBottom: '1rem',
|
||||
}}
|
||||
>
|
||||
<h3 style={{ margin: 0, fontSize: '1.2rem', fontWeight: 600 }}>
|
||||
❤️ Selection ({favorites.size})
|
||||
</h3>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setIsModalOpen(false)}
|
||||
aria-label="Close modal"
|
||||
style={{
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: 'currentColor',
|
||||
fontSize: '1.5rem',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.7,
|
||||
lineHeight: 1,
|
||||
}}
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<p style={{ fontSize: '0.9rem', opacity: 0.8, marginBottom: '1rem' }}>
|
||||
You have selected {favorites.size} photo{favorites.size === 1 ? '' : 's'}. Choose an
|
||||
export option below to share your selection:
|
||||
</p>
|
||||
|
||||
<div style={{ display: 'flex', flexDirection: 'column', gap: '0.75rem' }}>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyLink}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'var(--accent, #e60012)',
|
||||
color: '#fff',
|
||||
border: 'none',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{copiedState === 'link' ? '✓ Link Copied!' : '🔗 Copy Shareable Link'}
|
||||
</button>
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCopyList}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'inherit',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
{copiedState === 'list' ? '✓ List Copied!' : '📋 Copy Text List (#1, #2...)'}
|
||||
</button>
|
||||
|
||||
{allowMailto && (
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleMailto}
|
||||
style={{
|
||||
display: 'flex',
|
||||
alignItems: 'center',
|
||||
justifyContent: 'center',
|
||||
gap: '0.5rem',
|
||||
padding: '0.75rem 1rem',
|
||||
borderRadius: 'var(--radius-sm, 6px)',
|
||||
background: 'rgba(255,255,255,0.1)',
|
||||
color: 'inherit',
|
||||
border: '1px solid rgba(255,255,255,0.15)',
|
||||
fontWeight: 500,
|
||||
cursor: 'pointer',
|
||||
}}
|
||||
>
|
||||
✉️ Send Email to Photographer
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => {
|
||||
if (confirm('Clear all selected favorites?')) {
|
||||
clearFavorites();
|
||||
setIsModalOpen(false);
|
||||
}
|
||||
}}
|
||||
style={{
|
||||
marginTop: '0.5rem',
|
||||
background: 'none',
|
||||
border: 'none',
|
||||
color: '#ff4d4f',
|
||||
fontSize: '0.85rem',
|
||||
cursor: 'pointer',
|
||||
opacity: 0.8,
|
||||
}}
|
||||
>
|
||||
Clear Selection
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
24
content/essays/sample-story.md
Normal file
24
content/essays/sample-story.md
Normal file
@@ -0,0 +1,24 @@
|
||||
---
|
||||
title: "The Icelandic Highlands"
|
||||
subtitle: "A visual journey across volcanic deserts, rivers, and arctic silence"
|
||||
author: "Portfolio Photographer"
|
||||
date: "2026-08-05"
|
||||
---
|
||||
|
||||
# Into the Wilderness
|
||||
|
||||
The journey began at dawn along the F-roads winding deep into the interior. As civil infrastructure faded behind us, the landscape shifted dramatically into vast fields of black volcanic ash framed by vivid neon-green moss.
|
||||
|
||||
> Silence in the highlands isn't just the absence of sound — it is a physical weight that fills every horizon.
|
||||
|
||||

|
||||
|
||||
## Landmannalaugar & Beyond
|
||||
|
||||
Walking through the rhyolite mountains, the earth fumes with geothermal vents. The color spectrum ranges from deep ochre and sulfur yellow to dark basalt gray.
|
||||
|
||||

|
||||
|
||||
### Reflection & Light
|
||||
|
||||
As light broke through the storm clouds late in the afternoon, the contrast between the dark terrain and glowing peaks created a surreal atmosphere that felt entirely unearthly.
|
||||
@@ -55,3 +55,14 @@ subpages:
|
||||
albums: # description is optional
|
||||
- "album-uuid-osaka-1"
|
||||
- "album-uuid-osaka-2"
|
||||
|
||||
# ── Photo Essay / Storytelling Subpage Example ───────────────────
|
||||
# Render editorial Markdown essays with fullbleed & side-by-side images
|
||||
- name: Highlands
|
||||
title: "Icelandic Highlands"
|
||||
subtitle: "A Photo Essay"
|
||||
essayFile: "content/essays/sample-story.md"
|
||||
grid:
|
||||
layout: essay
|
||||
albums:
|
||||
- "album-uuid-iceland-1"
|
||||
|
||||
@@ -11,6 +11,7 @@ subtitle: "A visual journal"
|
||||
# seo:
|
||||
# title: "My Portfolio | Photography"
|
||||
# description: "A curated selection of my best photography work."
|
||||
# titleTemplate: "%s | My Portfolio" # Template for subpages (%s is replaced with subpage/album title)
|
||||
# noIndex: false # Set to true to hide from search engines
|
||||
# noFollow: false # Set to true to prevent search engines from following links
|
||||
|
||||
@@ -23,6 +24,11 @@ map: true
|
||||
# Enable smooth page transitions between routes
|
||||
transitions: true
|
||||
|
||||
# Client proofing & favorite photo selections
|
||||
# proofing:
|
||||
# enabled: true
|
||||
# allowMailto: true
|
||||
|
||||
# ── Theme configuration ──────────────────────────────────────────
|
||||
# Presets: studio, minimal, editorial, classic, noir, monograph
|
||||
theme:
|
||||
|
||||
@@ -103,4 +103,16 @@ describe('deriveGallery accepts valid structures', () => {
|
||||
expect(result.albumDescriptions[A]).toBe('A note');
|
||||
expect(result.albumHeroImages[A]).toBe(B);
|
||||
});
|
||||
|
||||
it('parses enabled property on subpages correctly', () => {
|
||||
const result = deriveGallery({
|
||||
subpages: [
|
||||
{ name: 'Active Page', albums: [A], enabled: true },
|
||||
{ name: 'Disabled Page', albums: [B], enabled: false },
|
||||
],
|
||||
} as unknown as GalleryYaml);
|
||||
|
||||
expect(result.subpages[0].enabled).toBe(true);
|
||||
expect(result.subpages[1].enabled).toBe(false);
|
||||
});
|
||||
});
|
||||
|
||||
120
lib/__tests__/essay.test.ts
Normal file
120
lib/__tests__/essay.test.ts
Normal file
@@ -0,0 +1,120 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { parseEssayMarkdown, parseFrontmatter, sanitizeHtml, renderInlineMarkdown, serializeEssayMarkdown } from '../essay';
|
||||
|
||||
describe('Essay Parser', () => {
|
||||
it('sanitizes script tags to prevent XSS', () => {
|
||||
const input = 'Hello <script>alert("XSS")</script> World';
|
||||
expect(sanitizeHtml(input)).toBe('Hello World');
|
||||
});
|
||||
|
||||
it('renders inline markdown formatting correctly', () => {
|
||||
const text = 'This is **bold** and *italic* with a [link](https://example.com)';
|
||||
const rendered = renderInlineMarkdown(text);
|
||||
expect(rendered).toContain('<strong>bold</strong>');
|
||||
expect(rendered).toContain('<em>italic</em>');
|
||||
expect(rendered).toContain('<a href="https://example.com" target="_blank" rel="noopener noreferrer">link</a>');
|
||||
});
|
||||
|
||||
it('parses frontmatter correctly', () => {
|
||||
const markdown = `---
|
||||
title: "The Icelandic Highlands"
|
||||
subtitle: "A visual journey across black sand and glaciers"
|
||||
author: "Ralf"
|
||||
date: "2026-08-05"
|
||||
coverAssetId: "asset-123"
|
||||
---
|
||||
|
||||
# Section 1
|
||||
First paragraph text.`;
|
||||
|
||||
const { frontmatter, body } = parseFrontmatter(markdown);
|
||||
expect(frontmatter.title).toBe('The Icelandic Highlands');
|
||||
expect(frontmatter.subtitle).toBe('A visual journey across black sand and glaciers');
|
||||
expect(frontmatter.author).toBe('Ralf');
|
||||
expect(frontmatter.date).toBe('2026-08-05');
|
||||
expect(frontmatter.coverAssetId).toBe('asset-123');
|
||||
expect(body.trim()).toMatch(/^# Section 1/);
|
||||
});
|
||||
|
||||
it('parses structured essay blocks (headings, quotes, photos, photo-pairs)', () => {
|
||||
const markdown = `---
|
||||
title: "Test Story"
|
||||
---
|
||||
|
||||
# Highland Sunrise
|
||||
|
||||
> Silence was absolute across the tundra. -- Ralf
|
||||
|
||||

|
||||
|
||||

|
||||
|
||||
A closing paragraph with **bold** details.`;
|
||||
|
||||
const parsed = parseEssayMarkdown(markdown);
|
||||
expect(parsed.frontmatter.title).toBe('Test Story');
|
||||
expect(parsed.blocks.length).toBe(5);
|
||||
|
||||
// Block 0: Heading
|
||||
expect(parsed.blocks[0]).toEqual({
|
||||
type: 'heading',
|
||||
level: 1,
|
||||
text: 'Highland Sunrise',
|
||||
});
|
||||
|
||||
// Block 1: Quote
|
||||
expect(parsed.blocks[1]).toEqual({
|
||||
type: 'quote',
|
||||
text: 'Silence was absolute across the tundra.',
|
||||
author: 'Ralf',
|
||||
});
|
||||
|
||||
// Block 2: Fullbleed Photo
|
||||
expect(parsed.blocks[2]).toEqual({
|
||||
type: 'photo',
|
||||
assetId: 'asset-1',
|
||||
caption: 'Sunrise over the ridge',
|
||||
layout: 'fullbleed',
|
||||
});
|
||||
|
||||
// Block 3: Photo Pair
|
||||
expect(parsed.blocks[3]).toEqual({
|
||||
type: 'photo-pair',
|
||||
assetIds: ['asset-2', 'asset-3'],
|
||||
caption: 'Side-by-side details',
|
||||
});
|
||||
|
||||
// Block 4: Paragraph
|
||||
expect(parsed.blocks[4].type).toBe('paragraph');
|
||||
if (parsed.blocks[4].type === 'paragraph') {
|
||||
expect(parsed.blocks[4].html).toContain('<strong>bold</strong>');
|
||||
}
|
||||
|
||||
// Referenced Assets
|
||||
expect(parsed.referencedAssetIds).toContain('asset-1');
|
||||
expect(parsed.referencedAssetIds).toContain('asset-2');
|
||||
expect(parsed.referencedAssetIds).toContain('asset-3');
|
||||
});
|
||||
|
||||
it('serializes structured essay blocks back to markdown', () => {
|
||||
const parsed = {
|
||||
frontmatter: { title: 'Story Title', author: 'Author' },
|
||||
blocks: [
|
||||
{ type: 'heading' as const, level: 1, text: 'Chapter One' },
|
||||
{ type: 'paragraph' as const, html: 'This is <strong>bold</strong> text.' },
|
||||
{ type: 'quote' as const, text: 'A great quote', author: 'Person' },
|
||||
{ type: 'photo' as const, assetId: 'photo-1', caption: 'Caption', layout: 'fullbleed' as const },
|
||||
{ type: 'photo-pair' as const, assetIds: ['p1', 'p2'] as [string, string], caption: 'Pair caption' },
|
||||
],
|
||||
referencedAssetIds: ['photo-1', 'p1', 'p2'],
|
||||
};
|
||||
|
||||
const markdown = serializeEssayMarkdown(parsed);
|
||||
expect(markdown).toContain('title: "Story Title"');
|
||||
expect(markdown).toContain('# Chapter One');
|
||||
expect(markdown).toContain('This is **bold** text.');
|
||||
expect(markdown).toContain('> A great quote -- Person');
|
||||
expect(markdown).toContain('');
|
||||
expect(markdown).toContain('');
|
||||
});
|
||||
});
|
||||
38
lib/__tests__/proofing.test.ts
Normal file
38
lib/__tests__/proofing.test.ts
Normal file
@@ -0,0 +1,38 @@
|
||||
import { describe, it, expect } from 'vitest';
|
||||
import { encodeProofBitmask, decodeProofBitmask } from '../proofing';
|
||||
|
||||
describe('lib/proofing', () => {
|
||||
it('returns empty string for empty or invalid indices', () => {
|
||||
expect(encodeProofBitmask([])).toBe('');
|
||||
expect(encodeProofBitmask([-1])).toBe('');
|
||||
});
|
||||
|
||||
it('encodes and decodes simple asset indices', () => {
|
||||
const indices = [0, 3, 5, 12];
|
||||
const encoded = encodeProofBitmask(indices);
|
||||
expect(typeof encoded).toBe('string');
|
||||
expect(encoded.length).toBeGreaterThan(0);
|
||||
|
||||
const decoded = decodeProofBitmask(encoded);
|
||||
expect(decoded).toEqual([0, 3, 5, 12]);
|
||||
});
|
||||
|
||||
it('handles duplicate and unsorted indices gracefully', () => {
|
||||
const indices = [15, 2, 2, 7, 0];
|
||||
const encoded = encodeProofBitmask(indices);
|
||||
const decoded = decodeProofBitmask(encoded);
|
||||
expect(decoded).toEqual([0, 2, 7, 15]);
|
||||
});
|
||||
|
||||
it('clamps indices to totalCount if provided', () => {
|
||||
const indices = [1, 5, 10];
|
||||
const encoded = encodeProofBitmask(indices);
|
||||
const decoded = decodeProofBitmask(encoded, 6);
|
||||
expect(decoded).toEqual([1, 5]);
|
||||
});
|
||||
|
||||
it('returns empty array for invalid bitmask strings', () => {
|
||||
expect(decodeProofBitmask('invalid-junk-!!!')).toEqual([]);
|
||||
expect(decodeProofBitmask('')).toEqual([]);
|
||||
});
|
||||
});
|
||||
@@ -194,6 +194,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
albumIds,
|
||||
sections,
|
||||
password: sp.password,
|
||||
proofing: sp.proofing,
|
||||
essayFile: sp.essayFile,
|
||||
essayText: sp.essayText,
|
||||
enabled: sp.enabled !== false,
|
||||
...buildSubpageGrid(sp.grid),
|
||||
};
|
||||
});
|
||||
@@ -204,6 +208,7 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
name,
|
||||
slug: slugify(name),
|
||||
albumIds: value.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
enabled: true,
|
||||
};
|
||||
}
|
||||
const sp = value as SubpageObjectValue;
|
||||
@@ -215,6 +220,10 @@ export function deriveGallery(gallery: GalleryYaml): GalleryDerivation {
|
||||
subtitle: sp.subtitle,
|
||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||
password: sp.password,
|
||||
proofing: sp.proofing,
|
||||
essayFile: sp.essayFile,
|
||||
essayText: sp.essayText,
|
||||
enabled: sp.enabled !== false,
|
||||
...buildSubpageGrid(sp.grid),
|
||||
};
|
||||
});
|
||||
@@ -260,6 +269,7 @@ export function getConfig(): AppConfig {
|
||||
seo: {
|
||||
title: 'Setup Required',
|
||||
description: 'Please configure Immich Folio',
|
||||
titleTemplate: '%s | Setup Required',
|
||||
noIndex: true,
|
||||
noFollow: true,
|
||||
},
|
||||
@@ -271,6 +281,8 @@ export function getConfig(): AppConfig {
|
||||
legal: { enabled: false, name: '', address: '', zipCity: '', country: '' },
|
||||
map: false,
|
||||
transitions: false,
|
||||
analytics: true,
|
||||
proofing: { enabled: false, allowMailto: false },
|
||||
albumOverrides: {},
|
||||
albumDescriptions: {},
|
||||
albumPasswords: {},
|
||||
@@ -296,6 +308,8 @@ export function getConfig(): AppConfig {
|
||||
albumHeroImages,
|
||||
} = deriveGallery(gallery);
|
||||
|
||||
const siteSeoTitle = settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery';
|
||||
|
||||
return {
|
||||
immich: { apiUrl: `${apiUrl}/api`, apiKey },
|
||||
authSecret,
|
||||
@@ -306,12 +320,13 @@ export function getConfig(): AppConfig {
|
||||
siteSubtitle: settings.subtitle ?? env.SITE_SUBTITLE,
|
||||
lang: settings.lang ?? 'en',
|
||||
seo: {
|
||||
title: settings.seo?.title || settings.title || env.SITE_TITLE || 'Gallery',
|
||||
title: siteSeoTitle,
|
||||
description:
|
||||
settings.seo?.description ||
|
||||
settings.subtitle ||
|
||||
env.SITE_SUBTITLE ||
|
||||
'A curated photography portfolio',
|
||||
titleTemplate: settings.seo?.titleTemplate || `%s | ${siteSeoTitle}`,
|
||||
noIndex: settings.seo?.noIndex === true,
|
||||
noFollow: settings.seo?.noFollow === true,
|
||||
},
|
||||
@@ -352,6 +367,13 @@ export function getConfig(): AppConfig {
|
||||
},
|
||||
map: settings.map === true,
|
||||
transitions: settings.transitions !== false,
|
||||
analytics: settings.analytics !== false,
|
||||
proofing: {
|
||||
enabled: settings.proofing?.enabled !== false,
|
||||
allowMailto: settings.proofing?.allowMailto !== false,
|
||||
},
|
||||
protection: settings.protection,
|
||||
watermark: settings.watermark,
|
||||
albumOverrides,
|
||||
albumDescriptions,
|
||||
albumPasswords,
|
||||
|
||||
@@ -14,6 +14,10 @@ export interface SubpageConfig {
|
||||
sections?: SubpageSectionConfig[];
|
||||
password?: string;
|
||||
grid?: Partial<GridConfig>;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
}
|
||||
|
||||
export interface SubpageObjectValue {
|
||||
@@ -21,6 +25,10 @@ export interface SubpageObjectValue {
|
||||
subtitle?: string;
|
||||
password?: string;
|
||||
grid?: Partial<GridConfig>;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
albums?: Array<
|
||||
string | Record<string, string | { title: string; description?: string; password?: string }>
|
||||
>;
|
||||
@@ -68,7 +76,7 @@ export interface GridConfig {
|
||||
columns: number;
|
||||
gap: number;
|
||||
aspectRatio: string;
|
||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow';
|
||||
layout: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay';
|
||||
}
|
||||
|
||||
export interface AppConfig {
|
||||
@@ -83,6 +91,7 @@ export interface AppConfig {
|
||||
seo: {
|
||||
title: string;
|
||||
description: string;
|
||||
titleTemplate: string;
|
||||
noIndex: boolean;
|
||||
noFollow: boolean;
|
||||
};
|
||||
@@ -94,6 +103,11 @@ export interface AppConfig {
|
||||
legal: LegalConfig;
|
||||
map: boolean;
|
||||
transitions: boolean;
|
||||
analytics: boolean;
|
||||
proofing: {
|
||||
enabled: boolean;
|
||||
allowMailto: boolean;
|
||||
};
|
||||
albumOverrides: Record<string, string>;
|
||||
albumDescriptions: Record<string, string>;
|
||||
albumPasswords: Record<string, string>;
|
||||
@@ -106,6 +120,16 @@ export interface AppConfig {
|
||||
rateLimitRpm: number;
|
||||
trustedProxyHops: number;
|
||||
needsSetup?: boolean;
|
||||
protection?: {
|
||||
disableRightClick?: boolean;
|
||||
disableImageDrag?: boolean;
|
||||
};
|
||||
watermark?: {
|
||||
enabled?: boolean;
|
||||
text?: string;
|
||||
opacity?: number;
|
||||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||||
};
|
||||
}
|
||||
|
||||
export interface AlbumEntryObject {
|
||||
@@ -131,6 +155,10 @@ export interface GalleryYaml {
|
||||
albums: Array<string | Record<string, string | AlbumEntryObject>>;
|
||||
}>;
|
||||
password?: string;
|
||||
proofing?: boolean;
|
||||
essayFile?: string;
|
||||
essayText?: string;
|
||||
enabled?: boolean;
|
||||
grid?: {
|
||||
columns?: number;
|
||||
gap?: number;
|
||||
@@ -147,12 +175,18 @@ export interface SettingsYaml {
|
||||
seo?: {
|
||||
title?: string;
|
||||
description?: string;
|
||||
titleTemplate?: string;
|
||||
noIndex?: boolean;
|
||||
noFollow?: boolean;
|
||||
};
|
||||
exifOnHover?: boolean;
|
||||
map?: boolean;
|
||||
transitions?: boolean;
|
||||
analytics?: boolean;
|
||||
proofing?: {
|
||||
enabled?: boolean;
|
||||
allowMailto?: boolean;
|
||||
};
|
||||
theme?:
|
||||
| string
|
||||
| {
|
||||
@@ -173,6 +207,16 @@ export interface SettingsYaml {
|
||||
};
|
||||
footer?: FooterConfig;
|
||||
legal?: Partial<LegalConfig>;
|
||||
protection?: {
|
||||
disableRightClick?: boolean;
|
||||
disableImageDrag?: boolean;
|
||||
};
|
||||
watermark?: {
|
||||
enabled?: boolean;
|
||||
text?: string;
|
||||
opacity?: number;
|
||||
position?: 'bottom-right' | 'bottom-left' | 'center';
|
||||
};
|
||||
}
|
||||
|
||||
export function slugify(name: string): string {
|
||||
|
||||
@@ -72,7 +72,7 @@ export const VALID_HERO_STYLES = [
|
||||
'typographic',
|
||||
'mosaic',
|
||||
];
|
||||
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow'];
|
||||
export const VALID_LAYOUTS = ['masonry', 'uniform', 'showcase', 'filmstrip', 'editorial-flow', 'essay'];
|
||||
|
||||
export function resolveTheme(raw?: SettingsYaml['theme']): ThemeConfig {
|
||||
if (!raw) return { ...THEME_PRESETS.studio };
|
||||
|
||||
273
lib/essay.ts
Normal file
273
lib/essay.ts
Normal file
@@ -0,0 +1,273 @@
|
||||
export interface EssayFrontmatter {
|
||||
title?: string;
|
||||
subtitle?: string;
|
||||
date?: string;
|
||||
author?: string;
|
||||
coverAssetId?: string;
|
||||
layout?: string;
|
||||
}
|
||||
|
||||
export type EssayBlock =
|
||||
| { type: 'heading'; level: number; text: string }
|
||||
| { type: 'paragraph'; html: string }
|
||||
| { type: 'quote'; text: string; author?: string }
|
||||
| { type: 'photo'; assetId: string; caption?: string; layout: 'fullbleed' | 'wide' | 'contained' }
|
||||
| { type: 'photo-pair'; assetIds: [string, string]; caption?: string };
|
||||
|
||||
export interface ParsedEssay {
|
||||
frontmatter: EssayFrontmatter;
|
||||
blocks: EssayBlock[];
|
||||
referencedAssetIds: string[];
|
||||
}
|
||||
|
||||
/** Sanitize inline HTML strings to prevent XSS attacks in essay blocks */
|
||||
export function sanitizeHtml(input: string): string {
|
||||
return input
|
||||
.replace(/<script\b[^<]*(?:(?!<\/script>)<[^<]*)*<\/script>/gi, '')
|
||||
.replace(/on\w+="[^"]*"/gi, '')
|
||||
.replace(/javascript:/gi, '');
|
||||
}
|
||||
|
||||
/** Simple Markdown inline formatting (bold, italic, links, code) */
|
||||
export function renderInlineMarkdown(text: string): string {
|
||||
let html = sanitizeHtml(text);
|
||||
// Bold: **text** or __text__
|
||||
html = html.replace(/(\*\*|__)(.*?)\1/g, '<strong>$2</strong>');
|
||||
// Italic: *text* or _text_
|
||||
html = html.replace(/(\*|_)(.*?)\1/g, '<em>$2</em>');
|
||||
// Links: [label](url)
|
||||
html = html.replace(/\[([^\]]+)\]\(([^)]+)\)/g, (match, label, url) => {
|
||||
const safeUrl = url.trim().replace(/^javascript:/i, '');
|
||||
return `<a href="${safeUrl}" target="_blank" rel="noopener noreferrer">${label}</a>`;
|
||||
});
|
||||
return html;
|
||||
}
|
||||
|
||||
/** Parse frontmatter (YAML block delimited by ---) */
|
||||
export function parseFrontmatter(content: string): { frontmatter: EssayFrontmatter; body: string } {
|
||||
const frontmatterMatch = content.match(/^---\r?\n([\s\S]*?)\r?\n---\r?\n?/);
|
||||
if (!frontmatterMatch) {
|
||||
return { frontmatter: {}, body: content };
|
||||
}
|
||||
|
||||
const rawYaml = frontmatterMatch[1];
|
||||
const body = content.slice(frontmatterMatch[0].length);
|
||||
const frontmatter: EssayFrontmatter = {};
|
||||
|
||||
for (const line of rawYaml.split('\n')) {
|
||||
const trimmed = line.trim();
|
||||
if (!trimmed || trimmed.startsWith('#')) continue;
|
||||
const colonIdx = trimmed.indexOf(':');
|
||||
if (colonIdx === -1) continue;
|
||||
|
||||
const key = trimmed.slice(0, colonIdx).trim();
|
||||
let val = trimmed.slice(colonIdx + 1).trim();
|
||||
if ((val.startsWith('"') && val.endsWith('"')) || (val.startsWith("'") && val.endsWith("'"))) {
|
||||
val = val.slice(1, -1);
|
||||
}
|
||||
|
||||
if (key === 'title') frontmatter.title = val;
|
||||
else if (key === 'subtitle') frontmatter.subtitle = val;
|
||||
else if (key === 'date') frontmatter.date = val;
|
||||
else if (key === 'author') frontmatter.author = val;
|
||||
else if (key === 'coverAssetId') frontmatter.coverAssetId = val;
|
||||
else if (key === 'layout') frontmatter.layout = val;
|
||||
}
|
||||
|
||||
return { frontmatter, body };
|
||||
}
|
||||
|
||||
/** Parse Essay Markdown content into structured blocks */
|
||||
export function parseEssayMarkdown(rawContent: string): ParsedEssay {
|
||||
const { frontmatter, body } = parseFrontmatter(rawContent);
|
||||
const blocks: EssayBlock[] = [];
|
||||
const referencedAssetIds = new Set<string>();
|
||||
|
||||
if (frontmatter.coverAssetId) {
|
||||
referencedAssetIds.add(frontmatter.coverAssetId);
|
||||
}
|
||||
|
||||
// Split body into paragraph chunks separated by blank lines
|
||||
const chunks = body
|
||||
.split(/\r?\n\s*\r?\n/)
|
||||
.map((c) => c.trim())
|
||||
.filter(Boolean);
|
||||
|
||||
for (const chunk of chunks) {
|
||||
// 1. Headings (# H1, ## H2, ### H3)
|
||||
const headingMatch = chunk.match(/^(#{1,6})\s+(.+)$/);
|
||||
if (headingMatch) {
|
||||
blocks.push({
|
||||
type: 'heading',
|
||||
level: headingMatch[1].length,
|
||||
text: headingMatch[2].trim(),
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 2. Blockquote (> Quote text)
|
||||
if (chunk.startsWith('>')) {
|
||||
const quoteText = chunk
|
||||
.split('\n')
|
||||
.map((l) => l.replace(/^>\s?/, ''))
|
||||
.join(' ')
|
||||
.trim();
|
||||
|
||||
// Check for quote author (-- Author Name)
|
||||
const authorMatch = quoteText.match(/^(.*?)(?:\s+--\s+(.+))?$/);
|
||||
if (authorMatch) {
|
||||
blocks.push({
|
||||
type: 'quote',
|
||||
text: renderInlineMarkdown(authorMatch[1]),
|
||||
author: authorMatch[2] ? authorMatch[2].trim() : undefined,
|
||||
});
|
||||
}
|
||||
continue;
|
||||
}
|
||||
|
||||
// 3. Image syntax:  or 
|
||||
const imgMatch = chunk.match(/^!\[([^\]]+)\]\(([^)]*)\)$/);
|
||||
if (imgMatch) {
|
||||
const rawTarget = imgMatch[1].trim();
|
||||
const caption = imgMatch[2].trim() ? renderInlineMarkdown(imgMatch[2].trim()) : undefined;
|
||||
|
||||
// Side-by-side pair: 
|
||||
if (rawTarget.includes(',')) {
|
||||
const parts = rawTarget.split(',').map((s) => s.trim());
|
||||
if (parts.length >= 2) {
|
||||
const id1 = parts[0];
|
||||
const id2 = parts[1];
|
||||
referencedAssetIds.add(id1);
|
||||
referencedAssetIds.add(id2);
|
||||
blocks.push({
|
||||
type: 'photo-pair',
|
||||
assetIds: [id1, id2],
|
||||
caption,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
}
|
||||
|
||||
// Single photo: 
|
||||
let assetId = rawTarget;
|
||||
let layout: 'fullbleed' | 'wide' | 'contained' = 'contained';
|
||||
|
||||
if (rawTarget.includes(':')) {
|
||||
const [id, l] = rawTarget.split(':');
|
||||
assetId = id.trim();
|
||||
const normLayout = l.trim().toLowerCase();
|
||||
if (normLayout === 'fullbleed') layout = 'fullbleed';
|
||||
else if (normLayout === 'wide') layout = 'wide';
|
||||
}
|
||||
|
||||
referencedAssetIds.add(assetId);
|
||||
blocks.push({
|
||||
type: 'photo',
|
||||
assetId,
|
||||
caption,
|
||||
layout,
|
||||
});
|
||||
continue;
|
||||
}
|
||||
|
||||
// 4. Standard Text Paragraph
|
||||
blocks.push({
|
||||
type: 'paragraph',
|
||||
html: renderInlineMarkdown(chunk.replace(/\r?\n/g, ' ')),
|
||||
});
|
||||
}
|
||||
|
||||
return {
|
||||
frontmatter,
|
||||
blocks,
|
||||
referencedAssetIds: Array.from(referencedAssetIds),
|
||||
};
|
||||
}
|
||||
|
||||
/** Load and parse an essay file from content/essays/ */
|
||||
export function loadEssayFromFile(filename: string): ParsedEssay | null {
|
||||
if (typeof window !== 'undefined') return null;
|
||||
try {
|
||||
// Dynamic require for Node.js modules so client bundler doesn't fail
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodeFs = require('fs');
|
||||
// eslint-disable-next-line @typescript-eslint/no-require-imports
|
||||
const nodePath = require('path');
|
||||
|
||||
const filePath = nodePath.isAbsolute(filename)
|
||||
? filename
|
||||
: nodePath.join(process.cwd(), 'content', 'essays', filename);
|
||||
|
||||
if (!nodeFs.existsSync(filePath)) {
|
||||
console.warn(`[Essay] File not found: ${filePath}`);
|
||||
return null;
|
||||
}
|
||||
|
||||
const content = nodeFs.readFileSync(filePath, 'utf-8');
|
||||
return parseEssayMarkdown(content);
|
||||
} catch (error) {
|
||||
console.error(`[Essay] Failed to load essay file "${filename}":`, error);
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Converts a ParsedEssay structure back into clean Markdown syntax */
|
||||
export function serializeEssayMarkdown(essay: ParsedEssay): string {
|
||||
const lines: string[] = [];
|
||||
|
||||
// Frontmatter
|
||||
const fmKeys = Object.keys(essay.frontmatter) as Array<keyof EssayFrontmatter>;
|
||||
if (fmKeys.length > 0 && fmKeys.some((k) => essay.frontmatter[k])) {
|
||||
lines.push('---');
|
||||
for (const key of fmKeys) {
|
||||
const val = essay.frontmatter[key];
|
||||
if (val) {
|
||||
lines.push(`${key}: "${val.replace(/"/g, '\\"')}"`);
|
||||
}
|
||||
}
|
||||
lines.push('---');
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
// Blocks
|
||||
for (const block of essay.blocks) {
|
||||
switch (block.type) {
|
||||
case 'heading': {
|
||||
const hashes = '#'.repeat(Math.min(Math.max(block.level, 1), 6));
|
||||
lines.push(`${hashes} ${block.text}`);
|
||||
break;
|
||||
}
|
||||
case 'paragraph': {
|
||||
const text = block.html.replace(/<[^>]+>/g, (tag) => {
|
||||
if (tag.startsWith('<strong>') || tag.startsWith('<b>')) return '**';
|
||||
if (tag.startsWith('</strong>') || tag.startsWith('</b>')) return '**';
|
||||
if (tag.startsWith('<em>') || tag.startsWith('<i>')) return '*';
|
||||
if (tag.startsWith('</em>') || tag.startsWith('</i>')) return '*';
|
||||
return '';
|
||||
});
|
||||
lines.push(text);
|
||||
break;
|
||||
}
|
||||
case 'quote': {
|
||||
const authorSuffix = block.author ? ` -- ${block.author}` : '';
|
||||
const text = block.text.replace(/<[^>]+>/g, '');
|
||||
lines.push(`> ${text}${authorSuffix}`);
|
||||
break;
|
||||
}
|
||||
case 'photo': {
|
||||
const layoutSuffix = block.layout !== 'contained' ? `:${block.layout}` : '';
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
lines.push(``);
|
||||
break;
|
||||
}
|
||||
case 'photo-pair': {
|
||||
const caption = block.caption ? block.caption.replace(/<[^>]+>/g, '') : '';
|
||||
lines.push(`![${block.assetIds[0]}, ${block.assetIds[1]}](${caption})`);
|
||||
break;
|
||||
}
|
||||
}
|
||||
lines.push('');
|
||||
}
|
||||
|
||||
return lines.join('\n').trim();
|
||||
}
|
||||
@@ -452,12 +452,13 @@ class ImmichClient {
|
||||
* Get enriched subpage summaries for the homepage.
|
||||
*/
|
||||
async getSubpages(): Promise<SubpageSummary[]> {
|
||||
if (this.config.subpages.length === 0) return [];
|
||||
const activeSubpages = this.config.subpages.filter((sp) => sp.enabled !== false);
|
||||
if (activeSubpages.length === 0) return [];
|
||||
|
||||
const albums = await this.getAlbums();
|
||||
const albumMap = new Map(albums.map((a) => [a.id, a]));
|
||||
|
||||
return this.config.subpages.map((sp) => {
|
||||
return activeSubpages.map((sp) => {
|
||||
const spAlbums = sp.albumIds
|
||||
.map((id) => albumMap.get(id))
|
||||
.filter((a): a is ImmichAlbum => a !== undefined);
|
||||
@@ -478,7 +479,9 @@ class ImmichClient {
|
||||
async getSubpageAlbums(
|
||||
subpageSlug: string,
|
||||
): Promise<{ subpage: SubpageConfig; albums: ImmichAlbum[] } | null> {
|
||||
const subpage = this.config.subpages.find((sp) => sp.slug === subpageSlug);
|
||||
const subpage = this.config.subpages.find(
|
||||
(sp) => sp.slug === subpageSlug && sp.enabled !== false,
|
||||
);
|
||||
if (!subpage) return null;
|
||||
|
||||
const allAlbums = await this.getAlbums();
|
||||
@@ -626,7 +629,7 @@ class ImmichClient {
|
||||
* Check if a slug corresponds to a subpage.
|
||||
*/
|
||||
isSubpageSlug(slug: string): boolean {
|
||||
return this.config.subpages.some((sp) => sp.slug === slug);
|
||||
return this.config.subpages.some((sp) => sp.slug === slug && sp.enabled !== false);
|
||||
}
|
||||
|
||||
/**
|
||||
|
||||
65
lib/proofing.ts
Normal file
65
lib/proofing.ts
Normal file
@@ -0,0 +1,65 @@
|
||||
/**
|
||||
* Bitmask encoder/decoder for compact proofing URL query parameters.
|
||||
* Serializes selected asset indices in an album into a short base64url string.
|
||||
*/
|
||||
|
||||
/**
|
||||
* Encode an array of 0-based asset indices into a compact base64url bitmask.
|
||||
*/
|
||||
export function encodeProofBitmask(indices: number[]): string {
|
||||
if (!indices || indices.length === 0) return '';
|
||||
const sorted = Array.from(new Set(indices)).sort((a, b) => a - b);
|
||||
const maxIndex = sorted[sorted.length - 1];
|
||||
if (maxIndex < 0) return '';
|
||||
|
||||
const numBytes = Math.ceil((maxIndex + 1) / 8);
|
||||
const bytes = new Uint8Array(numBytes);
|
||||
|
||||
for (const idx of sorted) {
|
||||
if (idx < 0) continue;
|
||||
const byteIdx = Math.floor(idx / 8);
|
||||
const bitIdx = idx % 8;
|
||||
bytes[byteIdx] |= 1 << bitIdx;
|
||||
}
|
||||
|
||||
let binary = '';
|
||||
for (let i = 0; i < bytes.length; i++) {
|
||||
binary += String.fromCharCode(bytes[i]);
|
||||
}
|
||||
|
||||
return btoa(binary)
|
||||
.replace(/\+/g, '-')
|
||||
.replace(/\//g, '_')
|
||||
.replace(/=+$/, '');
|
||||
}
|
||||
|
||||
/**
|
||||
* Decode a base64url bitmask string back into an array of selected asset indices.
|
||||
*/
|
||||
export function decodeProofBitmask(code: string, totalCount?: number): number[] {
|
||||
if (!code || typeof code !== 'string') return [];
|
||||
try {
|
||||
let base64 = code.trim().replace(/-/g, '+').replace(/_/g, '/');
|
||||
while (base64.length % 4) {
|
||||
base64 += '=';
|
||||
}
|
||||
const binary = atob(base64);
|
||||
const result: number[] = [];
|
||||
|
||||
for (let byteIdx = 0; byteIdx < binary.length; byteIdx++) {
|
||||
const byte = binary.charCodeAt(byteIdx);
|
||||
for (let bitIdx = 0; bitIdx < 8; bitIdx++) {
|
||||
if (byte & (1 << bitIdx)) {
|
||||
const index = byteIdx * 8 + bitIdx;
|
||||
if (totalCount === undefined || index < totalCount) {
|
||||
result.push(index);
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return result;
|
||||
} catch {
|
||||
return [];
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user