diff --git a/.github/workflows/docker-publish.yml b/.github/workflows/docker-publish.yml index dc3f1d6..1c66cfd 100644 --- a/.github/workflows/docker-publish.yml +++ b/.github/workflows/docker-publish.yml @@ -15,6 +15,7 @@ jobs: permissions: contents: read packages: write + security-events: write steps: - name: Checkout repository @@ -47,6 +48,7 @@ jobs: type=raw,value=latest,enable=${{ github.event_name == 'release' }} - name: Build and push Docker image + id: build uses: docker/build-push-action@v7 with: context: . @@ -55,3 +57,17 @@ jobs: labels: ${{ steps.meta.outputs.labels }} cache-from: type=gha cache-to: type=gha,mode=max + + - name: Scan image for vulnerabilities + uses: aquasecurity/trivy-action@0.29.0 + with: + image-ref: ${{ env.REGISTRY }}/${{ env.IMAGE_NAME }}@${{ steps.build.outputs.digest }} + format: sarif + output: trivy-results.sarif + severity: CRITICAL,HIGH + exit-code: '0' + + - name: Upload scan results to GitHub Security tab + uses: github/codeql-action/upload-sarif@v3 + with: + sarif_file: trivy-results.sarif diff --git a/README.md b/README.md index 2a93690..939d4be 100644 --- a/README.md +++ b/README.md @@ -52,10 +52,12 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p | **Album scope** | Only albums in `gallery.yaml` are accessible | | **Password protection** | Per-subpage password support | | **Rate limiting** | Per-IP sliding-window rate limiter (configurable RPM) | +| **Vulnerability scanning** | Docker image scanned with Trivy on every release, results in the GitHub Security tab | - Health check endpoint at `GET /api/health` - In-memory caching with configurable TTL - Standalone Docker image — multi-stage, non-root, ~150 MB +- Dependencies kept current via Dependabot (npm + GitHub Actions, weekly) diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index bb1fdaa..6c9dfea 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -3,6 +3,7 @@ import type { PhotoItem } from './PhotoGrid'; import { PhotoGrid } from './PhotoGrid'; import { BackLink } from '@/components/BackLink'; import type { GridConfig } from '@/lib/config'; +import type { LightboxWatermark } from '@/components/Lightbox'; import Image from 'next/image'; import React from 'react'; @@ -16,6 +17,7 @@ interface AlbumDetailViewProps { subtitle?: string; heroImageUrl?: string; heroBlurDataURL?: string; + watermark?: LightboxWatermark; } export function AlbumDetailView({ @@ -28,6 +30,7 @@ export function AlbumDetailView({ subtitle, heroImageUrl, heroBlurDataURL, + watermark, }: AlbumDetailViewProps) { return ( <> @@ -58,7 +61,7 @@ export function AlbumDetailView({ {images.length} {images.length === 1 ? 'photo' : 'photos'}

- + ); } diff --git a/app/[...path]/EssayView.tsx b/app/[...path]/EssayView.tsx new file mode 100644 index 0000000..522cb53 --- /dev/null +++ b/app/[...path]/EssayView.tsx @@ -0,0 +1,351 @@ +'use client'; + +import { useState, useMemo } from 'react'; +import Image from 'next/image'; +import { Lightbox, type LightboxWatermark } from '@/components/Lightbox'; +import { ProofingProvider, useProofing } from '@/components/ProofingContext'; +import { ProofingModal } from '@/components/ProofingModal'; +import { FadeIn } from '@/components/FadeIn'; +import type { ParsedEssay, EssayBlock } from '@/lib/essay'; +import type { PhotoItem } from './PhotoGrid'; +import './essay.css'; + +interface EssayViewProps { + essay: ParsedEssay; + assets: PhotoItem[]; + title?: string; + subtitle?: string; + watermark?: LightboxWatermark; +} + +function EssayViewContent({ essay, assets, title, subtitle, watermark }: EssayViewProps) { + const proofing = useProofing(); + const [lightboxIndex, setLightboxIndex] = useState(null); + + // Map assetId / index to PhotoItem and its index in `assets` + const assetMap = useMemo(() => { + const map = new Map(); + assets.forEach((item, index) => { + map.set(item.id, { item, index }); + // Also map by 1-based index string ("1", "2") for convenience + map.set(`${index + 1}`, { item, index }); + }); + return map; + }, [assets]); + + const displayTitle = title || essay.frontmatter.title; + const displaySubtitle = subtitle || essay.frontmatter.subtitle; + + // Cover photo if specified + const coverAsset = essay.frontmatter.coverAssetId + ? assetMap.get(essay.frontmatter.coverAssetId) + : undefined; + + const renderBlock = (block: EssayBlock, idx: number) => { + switch (block.type) { + case 'heading': { + const Tag = `h${Math.min(block.level + 1, 4)}` as keyof React.JSX.IntrinsicElements; + return ( + + {block.text} + + ); + } + + case 'paragraph': + return ( +
+ ); + + case 'quote': + return ( +
+
+ {block.author && — {block.author}} +
+ ); + + case 'photo': { + const resolved = assetMap.get(block.assetId); + if (!resolved) return null; + const { item, index } = resolved; + const isFav = proofing ? proofing.isFavorite(item.id) : false; + + return ( + +
+
setLightboxIndex(index)} + style={{ + ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), + ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), + }} + > + {block.caption + + {proofing && ( + + )} +
+ {block.caption && ( +
+ )} +
+
+ ); + } + + case 'photo-pair': { + const res1 = assetMap.get(block.assetIds[0]); + const res2 = assetMap.get(block.assetIds[1]); + if (!res1 || !res2) return null; + + return ( + +
+
+ {[res1, res2].map(({ item, index }, pIdx) => { + const isFav = proofing ? proofing.isFavorite(item.id) : false; + return ( +
setLightboxIndex(index)} + style={{ + ...(item.dominantColor ? { backgroundColor: item.dominantColor } : {}), + ...(item.aspectRatio ? { aspectRatio: `${item.aspectRatio}` } : {}), + }} + > + + {proofing && ( + + )} +
+ ); + })} +
+ {block.caption && ( +
+ )} +
+
+ ); + } + + default: + return null; + } + }; + + return ( +
+
+ {displayTitle &&

{displayTitle}

} + {displaySubtitle &&

{displaySubtitle}

} + {(essay.frontmatter.author || essay.frontmatter.date) && ( +
+ {[essay.frontmatter.author, essay.frontmatter.date].filter(Boolean).join(' • ')} +
+ )} +
+ + {coverAsset && ( +
+
setLightboxIndex(coverAsset.index)} + style={{ + ...(coverAsset.item.dominantColor + ? { backgroundColor: coverAsset.item.dominantColor } + : {}), + ...(coverAsset.item.aspectRatio + ? { aspectRatio: `${coverAsset.item.aspectRatio}` } + : {}), + }} + > + +
+
+ )} + +
{essay.blocks.map(renderBlock)}
+ + {proofing && proofing.favorites.size > 0 && ( +
+ + +
+ )} + + + + {lightboxIndex !== null && ( + setLightboxIndex(null)} + onNext={() => setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null))} + onPrev={() => setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null))} + watermark={watermark} + /> + )} +
+ ); +} + +export function EssayView(props: EssayViewProps) { + const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]); + + return ( + + + + ); +} diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index 0288e57..e887005 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -10,8 +10,10 @@ import { useState, useCallback, useEffect, useMemo } from 'react'; import Image from 'next/image'; -import { Lightbox } from '@/components/Lightbox'; +import { Lightbox, type LightboxWatermark } from '@/components/Lightbox'; import { FadeIn } from '@/components/FadeIn'; +import { ProofingProvider, useProofing } from '@/components/ProofingContext'; +import { ProofingModal } from '@/components/ProofingModal'; export interface PhotoItem { id: string; @@ -32,8 +34,9 @@ export interface PhotoItem { interface PhotoGridProps { assets: PhotoItem[]; - layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow'; + layout?: 'masonry' | 'uniform' | 'showcase' | 'filmstrip' | 'editorial-flow' | 'essay'; gridStyle?: React.CSSProperties; + watermark?: LightboxWatermark; } /** Parse `#photo-N` from a hash string. Returns index or null. */ @@ -49,36 +52,42 @@ function buildPhotoHash(index: number): string { return `#photo-${index + 1}`; // 1-indexed for user-friendliness } -export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) { +function PhotoGridInner({ assets, layout = 'masonry', gridStyle, watermark }: PhotoGridProps) { + const proofing = useProofing(); const [lightboxIndex, setLightboxIndex] = useState(null); + const displayedAssets = useMemo(() => { + if (proofing && proofing.isFilterActive) { + return assets.filter((a) => proofing.isFavorite(a.id)); + } + return assets; + }, [assets, proofing]); + // ── Initial hash check (Client-only to avoid hydration mismatch) ── useEffect(() => { const idx = parsePhotoHash(window.location.hash); - if (idx !== null && idx < assets.length) { - // state from URL hash on mount only (equivalent to useState lazy initializer for - // client-only browser APIs). + if (idx !== null && idx < displayedAssets.length) { + // state from URL hash on mount only // eslint-disable-next-line react-hooks/set-state-in-effect setLightboxIndex(idx); } - }, [assets.length]); + }, [displayedAssets.length]); // ── Sync URL hash when lightbox state changes ───────────────── useEffect(() => { - if (lightboxIndex !== null && lightboxIndex < assets.length) { + if (lightboxIndex !== null && lightboxIndex < displayedAssets.length) { const hash = buildPhotoHash(lightboxIndex); - // Use replaceState to avoid flooding history with every prev/next tap if (window.location.hash !== hash) { window.history.replaceState(null, '', hash); } } - }, [lightboxIndex, assets.length]); + }, [lightboxIndex, displayedAssets.length]); // ── Listen for browser back/forward (hash change) ───────────── useEffect(() => { const handleHashChange = () => { const idx = parsePhotoHash(window.location.hash); - if (idx !== null && idx < assets.length) { + if (idx !== null && idx < displayedAssets.length) { setLightboxIndex(idx); } else { setLightboxIndex(null); @@ -87,27 +96,25 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr window.addEventListener('hashchange', handleHashChange); return () => window.removeEventListener('hashchange', handleHashChange); - }, [assets.length]); + }, [displayedAssets.length]); const openLightbox = useCallback((index: number) => { setLightboxIndex(index); - // Push state (not replace) so the user can press Back to close window.history.pushState(null, '', buildPhotoHash(index)); }, []); const closeLightbox = useCallback(() => { setLightboxIndex(null); - // Remove hash from URL cleanly window.history.pushState(null, '', window.location.pathname + window.location.search); }, []); const goNext = useCallback(() => { - setLightboxIndex((prev) => (prev !== null ? (prev + 1) % assets.length : null)); - }, [assets.length]); + setLightboxIndex((prev) => (prev !== null ? (prev + 1) % displayedAssets.length : null)); + }, [displayedAssets.length]); const goPrev = useCallback(() => { - setLightboxIndex((prev) => (prev !== null ? (prev - 1 + assets.length) % assets.length : null)); - }, [assets.length]); + setLightboxIndex((prev) => (prev !== null ? (prev - 1 + displayedAssets.length) % displayedAssets.length : null)); + }, [displayedAssets.length]); // Keyboard navigation useEffect(() => { @@ -128,7 +135,6 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr }; document.addEventListener('keydown', handleKeyDown); - // Prevent body scroll when lightbox is open document.body.style.overflow = 'hidden'; return () => { @@ -137,60 +143,105 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr }; }, [lightboxIndex, closeLightbox, goNext, goPrev]); - // Memoize grid items to prevent re-renders of the entire grid (which includes - // Image and FadeIn components) when navigating the lightbox. const gridItems = useMemo(() => { - return assets.map((asset, index) => ( - -
openLightbox(index)} - onKeyDown={(e) => { - if (e.key === 'Enter' || e.key === ' ') { - e.preventDefault(); - openLightbox(index); - } - }} - role="button" - tabIndex={0} - aria-label={`View photo ${index + 1}`} - aria-haspopup="dialog" - style={{ - ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), - ...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio - ? { aspectRatio: `${asset.aspectRatio}` } - : {}), - }} - > - - {asset.type === 'video' && ( - - )} - {(asset.camera || asset.lens) && ( - - )} -
-
- )); - }, [assets, layout, openLightbox]); + return displayedAssets.map((asset, index) => { + const isFav = proofing ? proofing.isFavorite(asset.id) : false; + + return ( + +
openLightbox(index)} + onKeyDown={(e) => { + if (e.key === 'Enter' || e.key === ' ') { + e.preventDefault(); + openLightbox(index); + } + }} + role="button" + tabIndex={0} + aria-label={`View photo ${index + 1}`} + aria-haspopup="dialog" + style={{ + ...(asset.dominantColor ? { backgroundColor: asset.dominantColor } : {}), + ...((layout === 'masonry' || layout === 'showcase') && asset.aspectRatio + ? { aspectRatio: `${asset.aspectRatio}` } + : {}), + position: 'relative', + }} + > + + + {proofing && ( + + )} + + {asset.type === 'video' && ( + + )} + {(asset.camera || asset.lens) && ( + + )} +
+
+ ); + }); + }, [displayedAssets, layout, openLightbox, proofing]); return ( <> @@ -198,15 +249,82 @@ export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridPr {gridItems}
+ {proofing && proofing.favorites.size > 0 && ( +
+ + +
+ )} + + + {lightboxIndex !== null && ( )} ); } + +export function PhotoGrid(props: PhotoGridProps) { + const albumTokens = useMemo(() => props.assets.map((a) => a.id), [props.assets]); + + return ( + + + + ); +} diff --git a/app/[...path]/essay.css b/app/[...path]/essay.css new file mode 100644 index 0000000..0b5560f --- /dev/null +++ b/app/[...path]/essay.css @@ -0,0 +1,157 @@ +/* Photo Essay & Storytelling Mode Styles */ + +.essay-container { + width: 100%; + max-width: 100%; + margin: 0 auto; + padding: 2rem 1.5rem 6rem 1.5rem; +} + +/* Header & Cover */ +.essay-header { + max-width: 68ch; + margin: 0 auto 3.5rem auto; + text-align: center; +} + +.essay-header__title { + font-family: var(--font-heading, serif); + font-size: clamp(2.2rem, 5vw, 3.8rem); + font-weight: 700; + line-height: 1.15; + margin-bottom: 1rem; + letter-spacing: -0.02em; +} + +.essay-header__subtitle { + font-family: var(--font-body, sans-serif); + font-size: clamp(1.1rem, 2.5vw, 1.4rem); + opacity: 0.8; + line-height: 1.5; + margin-bottom: 1.5rem; +} + +.essay-header__meta { + font-family: var(--font-caption, monospace); + font-size: 0.85rem; + opacity: 0.6; + text-transform: uppercase; + letter-spacing: 0.05em; +} + +/* Reading column */ +.essay-prose { + max-width: 68ch; + margin: 0 auto 2.5rem auto; + font-family: var(--font-body, sans-serif); + font-size: clamp(1.05rem, 2vw, 1.2rem); + line-height: 1.75; + color: var(--text-primary, rgba(255, 255, 255, 0.92)); +} + +.essay-prose p { + margin-bottom: 1.75rem; +} + +.essay-heading { + font-family: var(--font-heading, serif); + max-width: 68ch; + margin: 3.5rem auto 1.5rem auto; + font-weight: 600; + line-height: 1.25; +} + +.essay-heading--1 { font-size: clamp(1.8rem, 3.5vw, 2.5rem); } +.essay-heading--2 { font-size: clamp(1.5rem, 3vw, 2rem); } +.essay-heading--3 { font-size: clamp(1.25rem, 2.5vw, 1.6rem); } + +/* Pullquote */ +.essay-quote { + max-width: 68ch; + margin: 3.5rem auto; + padding: 1.5rem 2rem; + border-left: 3px solid var(--accent, #e60012); + background: var(--bg-surface, rgba(255, 255, 255, 0.03)); + border-radius: 0 8px 8px 0; + font-family: var(--font-heading, serif); + font-style: italic; + font-size: clamp(1.15rem, 2.2vw, 1.35rem); + line-height: 1.6; +} + +.essay-quote__author { + display: block; + margin-top: 0.75rem; + font-family: var(--font-caption, monospace); + font-style: normal; + font-size: 0.85rem; + opacity: 0.7; + text-transform: uppercase; +} + +/* Essay Photos & Layout Modes */ +.essay-figure { + margin: 3.5rem auto; + width: 100%; +} + +.essay-figure--contained { + max-width: 68ch; +} + +.essay-figure--wide { + max-width: 1100px; +} + +.essay-figure--fullbleed { + max-width: 100vw; + margin-left: calc(-50vw + 50%); + margin-right: calc(-50vw + 50%); +} + +.essay-image-wrapper { + position: relative; + width: 100%; + overflow: hidden; + border-radius: var(--radius, 0px); + max-height: 80vh; +} + +.essay-image-wrapper img { + object-fit: cover; + width: 100%; + height: auto; + max-height: 80vh; + display: block; + transition: transform 0.4s ease; +} + +.essay-image-wrapper:hover img { + transform: scale(1.015); +} + +.essay-figcaption { + max-width: 68ch; + margin: 0.75rem auto 0 auto; + text-align: center; + font-family: var(--font-caption, monospace); + font-size: 0.85rem; + opacity: 0.75; +} + +/* Side by side photo pair */ +.essay-pair-grid { + display: grid; + grid-template-columns: 1fr 1fr; + gap: 1.5rem; + width: 100%; + max-width: 1100px; + margin: 3.5rem auto; +} + +@media (max-width: 640px) { + .essay-pair-grid { + grid-template-columns: 1fr; + gap: 1.5rem; + } +} diff --git a/app/[...path]/page.tsx b/app/[...path]/page.tsx index 7e014d0..5bb0c7d 100644 --- a/app/[...path]/page.tsx +++ b/app/[...path]/page.tsx @@ -31,6 +31,8 @@ import { isProtected, isAuthenticated } from '@/lib/auth'; import PasswordGate from '@/components/PasswordGate'; import { AlbumDetailView } from './AlbumDetailView'; import { SubpageGridView } from './SubpageGridView'; +import { EssayView } from './EssayView'; +import { parseEssayMarkdown, loadEssayFromFile } from '@/lib/essay'; // Render at request time — requires live Immich connection export const dynamic = 'force-dynamic'; @@ -46,18 +48,13 @@ export async function generateMetadata({ params }: PathPageProps): Promise a.type === 'IMAGE' || a.type === 'VIDEO').length; - subtitle = `${count} photo${count === 1 ? '' : 's'}`; - } - } else if (immich.isSubpageSlug(slug)) { + let description: string | undefined = undefined; + if (path.length === 1 && immich.isSubpageSlug(slug)) { const result = await immich.getSubpageAlbums(slug); if (result) { - // Single album → use album name; multiple → use subpage name + if (result.subpage.subtitle) { + description = result.subpage.subtitle; + } if (result.albums.length === 1) { const album = await immich.getAlbumBySlug(result.albums[0].slug, slug); if (album) { @@ -66,9 +63,16 @@ export async function generateMetadata({ params }: PathPageProps): Promise a.type === 'IMAGE' || a.type === 'VIDEO').length; + subtitle = `${count} photo${count === 1 ? '' : 's'}`; + } } else { const album = await immich.getAlbumBySlug(slug); if (album) { @@ -82,8 +86,18 @@ export async function generateMetadata({ params }: PathPageProps): Promise ); @@ -225,6 +240,57 @@ export default async function PathPage({ params }: PathPageProps) { const { albums } = result; + // ── Photo Essay / Storytelling Mode ─────────────────────── + const isEssay = + result.subpage.grid?.layout === 'essay' || + !!result.subpage.essayFile || + !!result.subpage.essayText; + + if (isEssay) { + let essayParsed = result.subpage.essayText + ? parseEssayMarkdown(result.subpage.essayText) + : result.subpage.essayFile + ? loadEssayFromFile(result.subpage.essayFile) + : null; + + // Fetch assets from all subpage albums + const allAlbums = await Promise.all( + albums.map((a) => immich.getAlbumBySlug(a.slug, slug)) + ); + const allAssets = allAlbums.flatMap((a) => (a ? a.assets : [])); + const images = toPhotoItems(allAssets, config.exifOnHover); + + // Fallback structured essay if layout: 'essay' is set without custom markdown file + if (!essayParsed) { + essayParsed = { + frontmatter: { + title: result.subpage.title || result.subpage.name, + subtitle: result.subpage.subtitle, + }, + blocks: albums.flatMap((a) => [ + { type: 'heading' as const, level: 2, text: a.albumName }, + ...a.assets.map((asset) => ({ + type: 'photo' as const, + assetId: encodeAssetId(asset.id), + caption: asset.exifInfo?.description || undefined, + layout: 'contained' as const, + })), + ]), + referencedAssetIds: [], + }; + } + + return ( + + ); + } + // ── Single album → full-bleed (skip album grid) ────────── if (albums.length === 1) { const album = await immich.getAlbumBySlug(albums[0].slug, slug); @@ -247,6 +313,7 @@ export default async function PathPage({ params }: PathPageProps) { subtitle={result.subpage.subtitle} backLinkHref="/" backLinkLabel="Back to Gallery" + watermark={config.watermark} {...heroData} /> ); @@ -303,6 +370,7 @@ export default async function PathPage({ params }: PathPageProps) { gridStyle={buildGridStyle()} backLinkHref="/" backLinkLabel="Back to Gallery" + watermark={config.watermark} {...heroData} /> ); diff --git a/app/about/page.tsx b/app/about/page.tsx index 66203f9..7df8fd7 100644 --- a/app/about/page.tsx +++ b/app/about/page.tsx @@ -7,6 +7,7 @@ import { promises as fsPromises } from 'fs'; import { join } from 'path'; import yaml from 'js-yaml'; import Image from 'next/image'; +import type { Metadata } from 'next'; import { imageUrl, assetPlaceholder } from '@/lib/urls'; import { immich } from '@/lib/immich'; import './about.css'; @@ -46,6 +47,26 @@ async function getAboutContent() { return { meta, body: body.trim() }; } +export async function generateMetadata(): Promise { + const { meta, body } = await getAboutContent(); + const title = meta.name ? `About — ${meta.name}` : 'About'; + const description = body ? body.slice(0, 160).replace(/\s+/g, ' ').trim() : undefined; + + return { + title, + description, + openGraph: { + title, + description, + }, + twitter: { + card: 'summary_large_image', + title, + description, + }, + }; +} + export default async function AboutPage() { const { meta, body } = await getAboutContent(); diff --git a/app/admin/admin.css b/app/admin/admin.css index b18f6be..37794cb 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -700,6 +700,7 @@ } .subpage-tile { + position: relative; background: var(--admin-bg-raised); border: 1px solid var(--admin-border); border-radius: 10px; @@ -719,6 +720,7 @@ } .subpage-tile-cover { + position: relative; aspect-ratio: 16 / 10; background: var(--admin-bg-elevated); overflow: hidden; @@ -2195,3 +2197,1358 @@ h3 .svg-icon { background: var(--admin-bg-raised); border-bottom: 1px solid var(--admin-border); } + +/* ── Status Dropdown Footer ── */ +.status-dropdown-footer { + padding: 0.5rem 0.85rem; + background: var(--admin-bg-elevated); + border-top: 1px solid var(--admin-border); + display: flex; + justify-content: flex-end; +} + +.admin-btn-sm { + padding: 0.35rem 0.65rem !important; + font-size: 0.75rem !important; +} + +/* ── Backup Manager Modal ────────────────────────────────────── */ +.backup-modal-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(4px); + z-index: 999; + display: flex; + align-items: center; + justify-content: center; + padding: 1.5rem; + animation: fadeIn 0.15s ease-out; +} + +.backup-modal-container { + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 14px; + width: 100%; + max-width: 640px; + max-height: 85vh; + display: flex; + flex-direction: column; + box-shadow: 0 16px 48px rgba(0, 0, 0, 0.35); + overflow: hidden; + animation: scaleUp 0.15s cubic-bezier(0.16, 1, 0.3, 1); +} + +.backup-modal-header { + display: flex; + align-items: flex-start; + justify-content: space-between; + padding: 1.25rem 1.5rem 0.75rem; + border-bottom: 1px solid var(--admin-border); + background: var(--admin-bg-elevated); +} + +.backup-modal-header h2 { + margin: 0; + font-size: 1.15rem; + font-weight: 700; + color: var(--admin-text); +} + +.backup-modal-subtitle { + margin: 0.25rem 0 0; + font-size: 0.8rem; + color: var(--admin-text-muted); +} + +.backup-modal-close-btn { + background: none; + border: none; + font-size: 1.1rem; + color: var(--admin-text-muted); + cursor: pointer; + padding: 0.25rem 0.5rem; + border-radius: 6px; + line-height: 1; + transition: all 0.15s; +} + +.backup-modal-close-btn:hover { + color: var(--admin-text); + background: var(--admin-bg-input); +} + +/* Modal Tabs */ +.backup-modal-tabs { + display: flex; + gap: 0.5rem; + padding: 0.75rem 1.5rem; + border-bottom: 1px solid var(--admin-border); + background: var(--admin-bg-raised); +} + +.backup-modal-tab { + padding: 0.4rem 0.85rem; + font-size: 0.82rem; + font-weight: 600; + border-radius: 8px; + background: transparent; + border: 1px solid transparent; + color: var(--admin-text-muted); + cursor: pointer; + transition: all 0.15s; +} + +.backup-modal-tab:hover { + color: var(--admin-text); + background: var(--admin-bg-elevated); +} + +.backup-modal-tab.active { + background: var(--admin-bg-elevated); + border-color: var(--admin-border); + color: var(--admin-accent); +} + +/* Modal Status Alert */ +.backup-status-alert { + margin: 0.75rem 1.5rem 0; + padding: 0.65rem 0.85rem; + border-radius: 8px; + font-size: 0.82rem; + font-weight: 500; +} + +.backup-status-alert.error { + background: rgba(239, 68, 68, 0.1); + border: 1px solid var(--admin-error, #ef4444); + color: var(--admin-error, #ef4444); +} + +.backup-status-alert.success { + background: rgba(16, 185, 129, 0.1); + border: 1px solid var(--admin-success, #10b981); + color: var(--admin-success, #10b981); +} + +/* Confirmation Box */ +.backup-confirm-box { + margin: 0.75rem 1.5rem 0; + padding: 1rem; + background: rgba(239, 68, 68, 0.05); + border: 1px solid rgba(239, 68, 68, 0.3); + border-radius: 10px; +} + +.backup-confirm-content strong { + display: block; + font-size: 0.9rem; + color: var(--admin-text); + margin-bottom: 0.25rem; +} + +.backup-confirm-content p { + margin: 0 0 0.75rem; + font-size: 0.8rem; + color: var(--admin-text-secondary); + line-height: 1.4; +} + +.backup-confirm-actions { + display: flex; + gap: 0.5rem; + justify-content: flex-end; +} + +/* Modal Body & List */ +.backup-modal-body { + flex: 1; + overflow-y: auto; + padding: 1rem 1.5rem; +} + +.backup-loading, +.backup-empty { + text-align: center; + padding: 2.5rem 1rem; + font-size: 0.85rem; + color: var(--admin-text-muted); +} + +.backup-list { + display: flex; + flex-direction: column; + gap: 0.65rem; +} + +.backup-item { + display: flex; + align-items: center; + justify-content: space-between; + padding: 0.75rem 1rem; + background: var(--admin-bg-elevated); + border: 1px solid var(--admin-border); + border-radius: 10px; + transition: border-color 0.15s; +} + +.backup-item:hover { + border-color: var(--admin-border-hover); +} + +.backup-item-info { + display: flex; + flex-direction: column; + gap: 0.2rem; +} + +.backup-item-title { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.backup-filename { + font-size: 0.88rem; + font-weight: 600; + color: var(--admin-text); +} + +.backup-badge { + font-size: 0.68rem; + font-weight: 600; + padding: 0.15rem 0.45rem; + border-radius: 4px; + text-transform: uppercase; + letter-spacing: 0.03em; +} + +.backup-badge.pre-restore { + background: rgba(245, 158, 11, 0.15); + color: #f59e0b; + border: 1px solid rgba(245, 158, 11, 0.3); +} + +.backup-item-meta code { + font-family: monospace; + font-size: 0.72rem; + color: var(--admin-text-muted); +} + +.backup-modal-footer { + padding: 0.85rem 1.5rem; + border-top: 1px solid var(--admin-border); + background: var(--admin-bg-elevated); + display: flex; + justify-content: flex-end; +} + +.backup-toggle-btn { + margin-top: 0.4rem; + padding: 0.55rem 0.85rem; + background: var(--admin-bg-elevated); + border: 1px dashed var(--admin-border); + border-radius: 8px; + color: var(--admin-text-secondary); + font-size: 0.78rem; + font-weight: 500; + cursor: pointer; + width: 100%; + text-align: center; + transition: all 0.15s ease; +} + +.backup-toggle-btn:hover { + background: var(--admin-bg-raised); + border-color: var(--admin-accent); + color: var(--admin-accent); +} + +/* ── Visual Cards for Photo Frame & Hero Style ────────────────── */ +.frame-card-preview, +.hero-card-preview { + width: 100%; + aspect-ratio: 16 / 10; + padding: 0.5rem; + display: flex; + align-items: center; + justify-content: center; + background: var(--admin-bg-elevated); + border-bottom: 1px solid var(--admin-border); + position: relative; + overflow: hidden; +} + +/* Photo Frame Demos */ +.mini-frame-demo { + width: 70%; + height: 70%; + display: flex; + align-items: center; + justify-content: center; + transition: all 0.2s ease; +} + +.mini-frame-photo { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #444, #222); + border-radius: 2px; +} + +.mini-frame-demo.frame-none .mini-frame-photo { + border: none; +} + +.mini-frame-demo.frame-passepartout { + padding: 6px; + background: #ffffff; + box-shadow: inset 0 0 0 1px rgba(0, 0, 0, 0.1); +} + +.mini-frame-demo.frame-shadow .mini-frame-photo { + box-shadow: 0 8px 16px rgba(0, 0, 0, 0.4); +} + +/* Hero Style Demos */ +.mini-hero-demo { + width: 100%; + height: 100%; + display: flex; + gap: 0.35rem; + padding: 0.25rem; +} + +.demo-line { + height: 3px; + background: var(--admin-text-muted); + border-radius: 2px; +} + +.demo-line.title { + width: 80%; + height: 5px; + background: var(--admin-accent); +} + +.demo-line.title.giant { + width: 95%; + height: 8px; + margin-bottom: 0.2rem; +} + +.demo-line.title.short { + width: 50%; + margin: 0 auto; +} + +.demo-line.title.light { + width: 60%; + background: #ffffff; +} + +.demo-line.sub { + width: 50%; +} + +.hero-demo-split { + align-items: center; +} + +.hero-demo-text { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.hero-demo-photo { + flex: 1; + height: 100%; + background: linear-gradient(135deg, #3a3a3a, #1a1a1a); + border-radius: 3px; +} + +.hero-demo-photo.small { + height: 60%; + width: 80%; + margin: 0 auto; +} + +.hero-demo-photo.banner { + width: 100%; + height: 65%; +} + +.hero-demo-full { + width: 100%; + height: 100%; + background: linear-gradient(135deg, #555, #111); + border-radius: 3px; + display: flex; + align-items: center; + justify-content: center; +} + +.hero-demo-minimal, +.hero-demo-stacked { + flex-direction: column; + justify-content: center; +} + +.hero-demo-center-text { + text-align: center; + width: 100%; + margin-bottom: 0.2rem; +} + +.hero-demo-typographic { + flex-direction: column; +} + +.hero-demo-grid2 { + display: flex; + gap: 0.25rem; + flex: 1; +} + +.hero-demo-mosaic { + display: flex; + gap: 0.25rem; + width: 100%; + height: 100%; +} + +.hero-demo-photo.big { + flex: 1.5; +} + +.hero-demo-photo-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +/* ── Global Button System ────────────────────────────────────── */ +.btn { + display: inline-flex; + align-items: center; + justify-content: center; + gap: 0.4rem; + padding: 0.5rem 1rem; + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 0.82rem; + font-weight: 600; + border-radius: 8px; + border: 1px solid transparent; + cursor: pointer; + transition: all 0.15s ease; + line-height: 1.2; +} + +.btn-sm { + padding: 0.35rem 0.65rem !important; + font-size: 0.75rem !important; + border-radius: 6px !important; +} + +.btn-secondary { + background: var(--admin-bg-elevated, #242424); + border-color: var(--admin-border, #333333); + color: var(--admin-text, #ffffff); +} + +.btn-secondary:hover { + background: var(--admin-bg-raised, #2e2e2e); + border-color: var(--admin-border-hover, #444444); +} + +.btn-danger { + background: #ef4444; + color: #ffffff; + border-color: #ef4444; +} + +.btn-danger:hover { + background: #dc2626; + border-color: #dc2626; +} + +.btn-outline-danger { + background: transparent; + border-color: rgba(239, 68, 68, 0.4); + color: #ef4444; +} + +.btn-outline-danger:hover { + background: rgba(239, 68, 68, 0.12); + border-color: #ef4444; +} + +/* ── Preset Grid & Buttons (Grid Layout & Aspect Ratio) ───────── */ +.preset-grid { + display: flex; + flex-wrap: wrap; + gap: 0.5rem; + margin-top: 0.35rem; +} + +.preset-btn { + padding: 0.45rem 0.85rem; + background: var(--admin-bg-input, #1c1c1c); + border: 1px solid var(--admin-border, #333333); + border-radius: 8px; + color: var(--admin-text-secondary, #cccccc); + font-family: var(--font-sans, system-ui, sans-serif); + font-size: 0.8rem; + font-weight: 500; + cursor: pointer; + transition: all 0.15s ease; + text-transform: capitalize; +} + +.preset-btn:hover { + background: var(--admin-bg-elevated, #282828); + border-color: var(--admin-border-hover, #555555); + color: var(--admin-text, #ffffff); +} + +.preset-btn.active { + background: var(--admin-bg-elevated, #2a2a2a); + border-color: var(--admin-accent, #e60012); + color: var(--admin-accent, #e60012); + font-weight: 600; + box-shadow: 0 0 0 1px var(--admin-accent, #e60012); +} + +/* ── Theme Settings Section Upgrades ─────────────────────────── */ +.theme-settings-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; +} + +.settings-section-header { + margin-bottom: 0.5rem; +} + +.settings-section-header h3 { + margin: 0 0 0.25rem 0; + font-size: 1.1rem; + font-weight: 700; + color: var(--admin-text); +} + +.settings-section-sub { + margin: 0; + font-size: 0.8rem; + color: var(--admin-text-muted); +} + +.settings-section-divider { + height: 1px; + background: var(--admin-border); + margin: 0.75rem 0; +} + +/* Segmented Control (Mode Toggle) */ +.segmented-control { + display: inline-flex; + background: var(--admin-bg-input, #1c1c1c); + padding: 4px; + border-radius: 10px; + border: 1px solid var(--admin-border, #333); + gap: 4px; +} + +.segment-btn { + padding: 0.5rem 1.1rem; + font-size: 0.82rem; + font-weight: 600; + border-radius: 7px; + border: none; + background: transparent; + color: var(--admin-text-muted); + cursor: pointer; + transition: all 0.15s ease; +} + +.segment-btn:hover { + color: var(--admin-text); +} + +.segment-btn.active { + background: var(--admin-bg-elevated, #2d2d2d); + color: var(--admin-text, #ffffff); + box-shadow: 0 2px 8px rgba(0, 0, 0, 0.2); +} + +/* Accent Color Swatches */ +.accent-picker-wrapper { + display: flex; + flex-direction: column; + gap: 0.85rem; +} + +.color-swatches-row { + display: flex; + align-items: center; + gap: 0.65rem; + flex-wrap: wrap; +} + +.color-swatch-btn { + width: 32px; + height: 32px; + border-radius: 50%; + border: 2px solid transparent; + cursor: pointer; + transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; +} + +.color-swatch-btn:hover { + transform: scale(1.15); +} + +.color-swatch-btn.active { + border-color: #ffffff; + box-shadow: 0 0 0 2px var(--admin-accent, #e60012); + transform: scale(1.15); +} + +/* Custom Toggle Cards Grid */ +.admin-toggle-cards-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); + gap: 1rem; +} + +.admin-toggle-card { + display: flex; + align-items: center; + justify-content: space-between; + padding: 1rem 1.25rem; + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + cursor: pointer; + text-align: left; + transition: all 0.15s ease; +} + +.admin-toggle-card:hover { + border-color: var(--admin-border-hover); + background: var(--admin-bg-elevated); +} + +.admin-toggle-card.active { + border-color: var(--admin-accent); + box-shadow: 0 0 0 1px var(--admin-accent); +} + +.toggle-card-info { + display: flex; + flex-direction: column; + gap: 0.2rem; + padding-right: 1rem; +} + +.toggle-card-title { + font-size: 0.88rem; + font-weight: 600; + color: var(--admin-text); +} + +.toggle-card-desc { + font-size: 0.75rem; + color: var(--admin-text-muted); + line-height: 1.3; +} + +/* iOS Style Switch Slider */ +.switch-toggle { + width: 42px; + height: 24px; + background: var(--admin-border, #444); + border-radius: 12px; + position: relative; + transition: background-color 0.2s ease; + flex-shrink: 0; +} + +.switch-toggle.on { + background: var(--admin-accent, #e60012); +} + +.switch-slider { + width: 18px; + height: 18px; + background: #ffffff; + border-radius: 50%; + position: absolute; + top: 3px; + left: 3px; + transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1); + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.3); +} + +.switch-toggle.on .switch-slider { + transform: translateX(18px); +} + +/* ── Google Search Result Snippet Preview ────────────────────── */ +.google-snippet-preview { + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + padding: 1rem 1.25rem; + margin-bottom: 1.25rem; +} + +.google-snippet-header { + font-size: 0.8rem; + font-weight: 600; + color: var(--admin-text-muted); + margin-bottom: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.04em; +} + +.google-snippet-card { + background: #ffffff; + border-radius: 8px; + padding: 1rem 1.25rem; + box-shadow: 0 2px 10px rgba(0, 0, 0, 0.08); + font-family: Arial, sans-serif; + color: #202124; +} + +.google-snippet-url { + font-size: 0.78rem; + color: #202124; + margin-bottom: 0.2rem; + word-break: break-all; +} + +.google-snippet-arrow { + font-size: 0.6rem; + color: #5f6368; +} + +.google-snippet-title { + font-size: 1.15rem; + font-weight: 400; + color: #1a0dab; + line-height: 1.3; + margin-bottom: 0.25rem; + cursor: pointer; +} + +.google-snippet-title:hover { + text-decoration: underline; +} + +.google-snippet-desc { + font-size: 0.85rem; + color: #4d5156; + line-height: 1.4; + word-break: break-word; +} + +/* Aspect Ratio Mini Preview Box */ +.ratio-card-preview { + width: 100%; + aspect-ratio: 16 / 10; + display: flex; + align-items: center; + justify-content: center; + background: var(--admin-bg-elevated); + border-bottom: 1px solid var(--admin-border); +} + +.mini-aspect-box { + width: 44px; + height: 44px; + border: 2px dashed var(--admin-accent); + border-radius: 4px; + display: flex; + align-items: center; + justify-content: center; +} + +.mini-aspect-box.ratio-1 { width: 34px; height: 34px; } +.mini-aspect-box.ratio-3-2 { width: 46px; height: 30px; } +.mini-aspect-box.ratio-2-3 { width: 30px; height: 44px; } +.mini-aspect-box.ratio-16-9 { width: 50px; height: 28px; } +.mini-aspect-box.ratio-auto { width: 42px; height: 32px; border-style: solid; } + +.mini-aspect-inner { + width: 100%; + height: 100%; + background: var(--admin-accent); + opacity: 0.2; +} + +/* Grid Layout Mini Demos */ +.mini-layout-demo { + width: 100%; + height: 100%; + padding: 0.35rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.demo-masonry-col-group { + display: flex; + gap: 0.25rem; + height: 100%; + width: 100%; +} + +.demo-col { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.demo-tile { + background: linear-gradient(135deg, #444, #222); + border-radius: 2px; + flex: 1; +} + +.demo-tile.h-high { flex: 2; } +.demo-tile.h-low { flex: 1; } +.demo-tile.h-med { flex: 1.5; } + +.demo-uniform-grid { + display: grid; + grid-template-columns: repeat(3, 1fr); + gap: 0.25rem; + height: 100%; +} + +.demo-showcase-grid { + display: flex; + gap: 0.25rem; + height: 100%; +} + +.demo-tile.hero-tile { flex: 2; } + +.demo-filmstrip-row { + display: flex; + gap: 0.25rem; + height: 100%; + align-items: center; +} + +.demo-tile.strip { + width: 40%; + height: 90%; + flex-shrink: 0; +} + +.demo-editorial-flow { + display: flex; + flex-direction: column; + gap: 0.25rem; + height: 100%; +} + +.demo-tile.wide { + width: 100%; + height: 45%; +} + +.demo-row { + display: flex; + gap: 0.25rem; + flex: 1; +} + +/* ── Subpage Tile Badges & Hover Actions ─────────────────────── */ +.subpage-badge-protected { + position: absolute; + top: 8px; + right: 8px; + background: rgba(0, 0, 0, 0.75); + backdrop-filter: blur(8px); + color: #ffca28; + font-size: 0.7rem; + font-weight: 600; + padding: 2px 8px; + border-radius: 12px; + border: 1px solid rgba(255, 202, 40, 0.3); + z-index: 2; +} + +.subpage-hover-overlay { + position: absolute; + inset: 0; + background: rgba(0, 0, 0, 0.55); + backdrop-filter: blur(3px); + display: flex; + align-items: center; + justify-content: center; + opacity: 0; + transition: opacity 0.2s ease; +} + +.subpage-tile:hover .subpage-hover-overlay { + opacity: 1; +} + +.hover-action-btn { + font-size: 0.78rem; + font-weight: 600; + color: #ffffff; + background: var(--admin-accent, #e60012); + padding: 0.4rem 0.85rem; + border-radius: 20px; + box-shadow: 0 4px 12px rgba(0, 0, 0, 0.4); +} + +.subpage-tile-title-row { + display: flex; + align-items: center; + justify-content: space-between; + gap: 0.5rem; +} + +.subpage-tile-slug { + font-size: 0.7rem; + color: var(--admin-text-muted); + font-family: var(--font-mono, monospace); + background: var(--admin-bg-input); + padding: 1px 6px; + border-radius: 4px; +} + +/* ── Subpage Slide-Over Drawer Modal ──────────────────────────── */ +.subpage-drawer-backdrop { + position: fixed; + inset: 0; + background: rgba(0, 0, 0, 0.65); + backdrop-filter: blur(6px); + z-index: 999; + display: flex; + justify-content: flex-end; + animation: fadeIn 0.2s ease-out; +} + +.subpage-drawer-container { + width: 100%; + max-width: 620px; + height: 100vh; + background: var(--admin-bg-card, #181818); + border-left: 1px solid var(--admin-border, #333); + display: flex; + flex-direction: column; + box-shadow: -10px 0 30px rgba(0, 0, 0, 0.5); + animation: slideInRight 0.25s cubic-bezier(0.16, 1, 0.3, 1); +} + +@keyframes slideInRight { + from { transform: translateX(100%); } + to { transform: translateX(0); } +} + +.subpage-drawer-header { + padding: 1.25rem 1.5rem; + border-bottom: 1px solid var(--admin-border); + display: flex; + align-items: center; + justify-content: space-between; + background: var(--admin-bg-raised); +} + +.subpage-drawer-breadcrumb { + font-size: 0.75rem; + color: var(--admin-text-muted); + text-transform: uppercase; + letter-spacing: 0.05em; + font-weight: 600; +} + +.subpage-drawer-title { + margin: 0.2rem 0 0 0; + font-size: 1.2rem; + font-weight: 700; + color: var(--admin-text); +} + +.subpage-drawer-header-actions { + display: flex; + align-items: center; + gap: 0.5rem; +} + +.subpage-drawer-body { + padding: 1.5rem; + overflow-y: auto; + flex: 1; + display: flex; + flex-direction: column; + gap: 1.25rem; +} + +.subpage-drawer-footer { + padding: 1rem 1.5rem; + border-top: 1px solid var(--admin-border); + background: var(--admin-bg-raised); + display: flex; + align-items: center; + justify-content: space-between; +} + +.input-slug-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.input-slug-preview { + position: absolute; + right: 12px; + font-size: 0.75rem; + font-family: var(--font-mono, monospace); + color: var(--admin-text-muted); + pointer-events: none; + background: var(--admin-bg-elevated); + padding: 2px 8px; + border-radius: 4px; +} + +.password-input-wrapper { + position: relative; + display: flex; + align-items: center; +} + +.password-icon { + position: absolute; + left: 12px; + font-size: 0.85rem; + pointer-events: none; +} + +.password-input-wrapper input { + padding-left: 2.25rem !important; +} + +/* ── Dynamic Floating Save Bar ───────────────────────────────── */ +.floating-save-bar { + position: fixed; + bottom: 1.5rem; + left: 50%; + transform: translateX(-50%); + background: var(--admin-bg-raised, #1f1f1f); + border: 1px solid var(--admin-border-hover, #444); + box-shadow: 0 10px 30px rgba(0, 0, 0, 0.5), 0 0 0 1px rgba(255, 255, 255, 0.05); + border-radius: 16px; + padding: 0.75rem 1.25rem; + z-index: 900; + display: flex; + align-items: center; + justify-content: space-between; + width: calc(100% - 3rem); + max-width: 800px; + transition: all 0.25s ease; +} + +.badge-pulse-dot { + width: 8px; + height: 8px; + border-radius: 50%; + background: #ff9800; + display: inline-block; + margin-right: 6px; + animation: pulse 1.5s infinite; +} + +@keyframes pulse { + 0% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0.7); } + 70% { transform: scale(1); box-shadow: 0 0 0 8px rgba(255, 152, 0, 0); } + 100% { transform: scale(0.95); box-shadow: 0 0 0 0 rgba(255, 152, 0, 0); } +} + +/* ── Analytics View ────────────────────────────────────────── */ +.analytics-view { + display: flex; + flex-direction: column; + gap: 1.5rem; + max-width: 1100px; +} + +.analytics-header { + display: flex; + align-items: center; + justify-content: space-between; +} + +.analytics-header h2 { + font-size: 1.25rem; + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.analytics-subtitle { + font-size: 0.8rem; + color: var(--admin-text-muted); + margin: 0.25rem 0 0; +} + +.analytics-metrics-grid { + display: grid; + grid-template-columns: repeat(auto-fit, minmax(220px, 1fr)); + gap: 1rem; +} + +.metric-card { + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + padding: 1.25rem; + display: flex; + flex-direction: column; + gap: 0.25rem; +} + +.metric-label { + font-size: 0.75rem; + text-transform: uppercase; + letter-spacing: 0.05em; + color: var(--admin-text-muted); +} + +.metric-value { + font-size: 1.8rem; + font-weight: 700; + color: var(--admin-text); + font-family: var(--font-mono, monospace); +} + +.metric-sub { + font-size: 0.7rem; + color: var(--admin-text-muted); +} + +.analytics-panel { + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 12px; + padding: 1.5rem; +} + +.analytics-section-title h3 { + font-size: 0.95rem; + font-weight: 600; + margin: 0 0 1.25rem; + display: flex; + align-items: center; + gap: 0.5rem; +} + +.analytics-bar-chart { + display: flex; + align-items: flex-end; + gap: 0.75rem; + height: 180px; + padding-top: 1.5rem; +} + +.bar-col { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + height: 100%; +} + +.bar-wrapper { + flex: 1; + width: 100%; + max-width: 32px; + display: flex; + align-items: flex-end; + background: var(--admin-bg-elevated); + border-radius: 6px; + overflow: hidden; +} + +.bar-fill { + width: 100%; + background: var(--admin-primary, #3b82f6); + border-radius: 6px 6px 0 0; + transition: height 0.3s ease; + position: relative; + display: flex; + align-items: flex-start; + justify-content: center; +} + +.bar-val { + font-size: 0.65rem; + font-weight: 600; + color: #fff; + margin-top: 4px; +} + +.bar-label { + font-size: 0.68rem; + color: var(--admin-text-muted); + margin-top: 0.5rem; + font-family: var(--font-mono, monospace); +} + +.analytics-top-pages { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.top-page-row { + display: flex; + align-items: center; + gap: 1rem; +} + +.page-rank { + font-size: 0.8rem; + font-family: var(--font-mono, monospace); + color: var(--admin-text-muted); + width: 24px; +} + +.page-info { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.35rem; +} + +.page-path-line { + display: flex; + align-items: center; + justify-content: space-between; + font-size: 0.85rem; +} + +.page-path { + font-family: var(--font-mono, monospace); + color: var(--admin-text); +} + +.page-count { + font-size: 0.75rem; + color: var(--admin-text-muted); +} + +.page-progress-bg { + width: 100%; + height: 6px; + background: var(--admin-bg-elevated); + border-radius: 3px; + overflow: hidden; +} + +/* ── Drawer Live Preview ────────────────────────────────────── */ +.drawer-live-preview-panel { + display: flex; + flex-direction: column; + gap: 1.5rem; + padding: 0.5rem 0; +} + +.preview-hero-banner { + text-align: center; + padding: 1.75rem 1rem; + background: var(--admin-bg-elevated); + border-radius: 12px; + border: 1px dashed var(--admin-border); +} + +.preview-hero-title { + font-size: 1.3rem; + font-weight: 700; + margin: 0; + color: var(--admin-text); +} + +.preview-hero-sub { + font-size: 0.82rem; + color: var(--admin-text-muted); + margin: 0.35rem 0 0; +} + +.preview-section-group { + display: flex; + flex-direction: column; + gap: 0.75rem; +} + +.preview-section-title { + font-size: 0.95rem; + font-weight: 600; + margin: 0; + border-bottom: 1px solid var(--admin-border); + padding-bottom: 0.35rem; +} + +.preview-section-desc { + font-size: 0.78rem; + color: var(--admin-text-muted); + margin: 0; +} + +.preview-albums-grid { + display: grid; + grid-template-columns: repeat(auto-fill, minmax(130px, 1fr)); + gap: 0.75rem; +} + +.preview-album-tile { + background: var(--admin-bg-raised); + border: 1px solid var(--admin-border); + border-radius: 8px; + overflow: hidden; + display: flex; + flex-direction: column; +} + +.preview-album-cover { + width: 100%; + aspect-ratio: 4/3; + background: var(--admin-bg-elevated); + overflow: hidden; +} + +.preview-album-cover img { + width: 100%; + height: 100%; + object-fit: cover; +} + +.preview-album-title { + font-size: 0.75rem; + font-weight: 500; + padding: 0.4rem 0.5rem; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + + + + + + + diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index a7270cf..83da17e 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -3,19 +3,23 @@ import { useState, useEffect } from 'react'; import PageBuilder from './PageBuilder'; import SettingsEditor from './SettingsEditor'; +import AnalyticsView from './AnalyticsView'; +import BackupManagerModal from './BackupManagerModal'; +import * as Icons from './Icons'; interface Props { onLogout: () => void; } -type Tab = 'pages' | 'settings'; +type Tab = 'pages' | 'settings' | 'analytics'; export default function AdminDashboard({ onLogout }: Props) { const [activeTab, setActiveTab] = useState('pages'); const [saving, setSaving] = useState(false); - // Diagnostics state + // Diagnostics & Backup state const [showStatus, setShowStatus] = useState(false); + const [showBackupModal, setShowBackupModal] = useState(false); const [status, setStatus] = useState(null); const [statusLoading, setStatusLoading] = useState(false); @@ -74,6 +78,12 @@ export default function AdminDashboard({ onLogout }: Props) { > Settings +
@@ -136,6 +146,17 @@ export default function AdminDashboard({ onLogout }: Props) { {status?.cache?.size ?? 0} items
+
+ +
)} @@ -148,15 +169,22 @@ export default function AdminDashboard({ onLogout }: Props) { className="admin-btn admin-btn-ghost" title="Open site in new tab" > - ↗ Site + Site + + + ); + } + + const totalViews = data.summary?.totalViews || 0; + const maxPageViews = data.topPages[0]?.count || 1; + const daysList = Object.entries(data.days || {}) + .sort((a, b) => a[0].localeCompare(b[0])) + .slice(-14); // Last 14 days + const maxDayViews = Math.max(...daysList.map(([, d]) => d.pageviews || 0), 1); + + const desktopRatio = data.devices.desktop + data.devices.mobile > 0 + ? Math.round((data.devices.desktop / (data.devices.desktop + data.devices.mobile)) * 100) + : 50; + + return ( +
+ {/* Header */} +
+
+

Visitor Insights & Analytics

+

Privacy-first aggregate traffic stats. No personal data collected.

+
+ +
+ + {data.trackingEnabled === false && ( +
+ + + Analytics Tracking is Currently Disabled. No new visitor views are being recorded. You can re-enable tracking anytime under Settings → General. + +
+ )} + + {/* Top Metric Cards */} +
+
+ Total Page Views + {totalViews.toLocaleString()} + Lifetime aggregate +
+
+ Active Days Tracked + {Object.keys(data.days || {}).length} + Days recorded +
+
+ Desktop vs Mobile + {desktopRatio}% / {100 - desktopRatio}% + Device distribution +
+
+ + {/* Daily Trends Chart */} +
+
+

Daily Traffic Trend (Last 14 Days)

+
+ {daysList.length === 0 ? ( +

No pageviews recorded yet. Visit portfolio pages to start tracking!

+ ) : ( +
+ {daysList.map(([dateStr, dayData]) => { + const heightPct = Math.round((dayData.pageviews / maxDayViews) * 100); + const formattedDate = dateStr.slice(5); // MM-DD + return ( +
+
+
+ {dayData.pageviews} +
+
+ {formattedDate} +
+ ); + })} +
+ )} +
+ + {/* Top Pages List */} +
+
+

Top Visited Pages & Subpages

+
+ {data.topPages.length === 0 ? ( +

No pages recorded yet.

+ ) : ( +
+ {data.topPages.map((p, idx) => { + const widthPct = Math.round((p.count / maxPageViews) * 100); + return ( +
+ #{idx + 1} +
+
+ {p.path} + {p.count} views +
+
+
+
+
+
+ ); + })} +
+ )} +
+
+ ); +} diff --git a/app/admin/components/BackupManagerModal.tsx b/app/admin/components/BackupManagerModal.tsx new file mode 100644 index 0000000..b3f3489 --- /dev/null +++ b/app/admin/components/BackupManagerModal.tsx @@ -0,0 +1,226 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import type { BackupItem } from '@/app/api/admin/backups/route'; +import * as Icons from './Icons'; + +interface Props { + isOpen: boolean; + onClose: () => void; + onRestoreSuccess: () => void; +} + +export default function BackupManagerModal({ isOpen, onClose, onRestoreSuccess }: Props) { + const [activeTab, setActiveTab] = useState<'gallery' | 'settings'>('gallery'); + const [showAllBackups, setShowAllBackups] = useState(false); + const [backups, setBackups] = useState<{ gallery: BackupItem[]; settings: BackupItem[] }>({ + gallery: [], + settings: [], + }); + const [loading, setLoading] = useState(false); + const [error, setError] = useState(null); + const [restoringFilename, setRestoringFilename] = useState(null); + const [confirmFilename, setConfirmFilename] = useState(null); + const [successMsg, setSuccessMsg] = useState(null); + + const fetchBackups = useCallback(async () => { + setLoading(true); + setError(null); + try { + const res = await fetch('/api/admin/backups'); + if (!res.ok) { + throw new Error('Failed to fetch backup history'); + } + const data = await res.json(); + setBackups(data.backups || { gallery: [], settings: [] }); + } catch (err: any) { + setError(err.message || 'Error loading backups'); + } finally { + setLoading(false); + } + }, []); + + useEffect(() => { + if (isOpen) { + fetchBackups(); + setConfirmFilename(null); + setSuccessMsg(null); + setShowAllBackups(false); + } + }, [isOpen, fetchBackups]); + + if (!isOpen) return null; + + async function handleRestore(filename: string) { + setRestoringFilename(filename); + setError(null); + setSuccessMsg(null); + + try { + const res = await fetch('/api/admin/backups', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ backupFilename: filename }), + }); + + const data = await res.json(); + + if (!res.ok) { + throw new Error(data.error || 'Failed to restore backup'); + } + + setSuccessMsg(`Backup restored successfully! (${filename})`); + setConfirmFilename(null); + await fetchBackups(); + onRestoreSuccess(); + } catch (err: any) { + setError(err.message || 'Error restoring backup'); + } finally { + setRestoringFilename(null); + } + } + + const currentList = backups[activeTab] || []; + const INITIAL_VISIBLE_COUNT = 3; + const visibleList = showAllBackups ? currentList : currentList.slice(0, INITIAL_VISIBLE_COUNT); + const hiddenCount = currentList.length - INITIAL_VISIBLE_COUNT; + + return ( +
+
e.stopPropagation()}> +
+
+

Backup History & Restoration

+

+ Restore previous states of your configuration files with 1-click. +

+
+ +
+ + {/* Tab Selection */} +
+ + +
+ + {/* Status Messages */} + {error &&
{error}
} + {successMsg &&
{successMsg}
} + + {/* Confirmation Modal Section */} + {confirmFilename && ( +
+
+ Confirm Restoration +

+ Are you sure you want to restore {confirmFilename}? + A safety snapshot of your current state will be created automatically before reverting. +

+
+ + +
+
+
+ )} + + {/* Backup List Content */} +
+ {loading ? ( +
Loading backups...
+ ) : currentList.length === 0 ? ( +
No backups available yet for {activeTab}.yaml
+ ) : ( +
+ {visibleList.map((item) => { + const dateObj = item.timestamp ? new Date(item.timestamp) : null; + const formattedDate = dateObj && !isNaN(dateObj.getTime()) + ? dateObj.toLocaleString('de-DE', { + dateStyle: 'medium', + timeStyle: 'medium', + }) + : item.filename; + + return ( +
+
+
+ {formattedDate} + {item.isPreRestore && ( + + Pre-Restore Snapshot + + )} +
+
+ {item.filename} +
+
+ +
+ ); + })} + + {currentList.length > INITIAL_VISIBLE_COUNT && ( + + )} +
+ )} +
+ +
+ +
+
+
+ ); +} diff --git a/app/admin/components/EssayBlockEditor.tsx b/app/admin/components/EssayBlockEditor.tsx new file mode 100644 index 0000000..274310d --- /dev/null +++ b/app/admin/components/EssayBlockEditor.tsx @@ -0,0 +1,455 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { + parseEssayMarkdown, + serializeEssayMarkdown, + type ParsedEssay, + type EssayBlock, +} from '@/lib/essay'; +import * as Icons from './Icons'; + +interface EssayBlockEditorProps { + markdown: string; + onChange: (newMarkdown: string) => void; + onSelectPhoto?: (callback: (assetId: string) => void) => void; +} + +export function EssayBlockEditor({ markdown, onChange, onSelectPhoto }: EssayBlockEditorProps) { + const [essay, setEssay] = useState(() => + parseEssayMarkdown(markdown || '# Title\n\nWrite your story here...') + ); + + // Sync internal state when prop changes from outside (e.g. initial load) + useEffect(() => { + const currentSerialized = serializeEssayMarkdown(essay); + if (markdown !== currentSerialized && markdown) { + setEssay(parseEssayMarkdown(markdown)); + } + }, [markdown]); // eslint-disable-line react-hooks/exhaustive-deps + + const updateBlocks = useCallback( + (newBlocks: EssayBlock[]) => { + const updatedEssay: ParsedEssay = { + ...essay, + blocks: newBlocks, + referencedAssetIds: Array.from( + new Set( + newBlocks.flatMap((b) => { + if (b.type === 'photo') return [b.assetId]; + if (b.type === 'photo-pair') return b.assetIds; + return []; + }) + ) + ), + }; + + setEssay(updatedEssay); + onChange(serializeEssayMarkdown(updatedEssay)); + }, + [essay, onChange] + ); + + // Block manipulation helpers + const handleMoveBlock = (index: number, direction: 'up' | 'down') => { + const targetIdx = direction === 'up' ? index - 1 : index + 1; + if (targetIdx < 0 || targetIdx >= essay.blocks.length) return; + + const newBlocks = [...essay.blocks]; + const [moved] = newBlocks.splice(index, 1); + newBlocks.splice(targetIdx, 0, moved); + updateBlocks(newBlocks); + }; + + const handleDeleteBlock = (index: number) => { + const newBlocks = essay.blocks.filter((_, i) => i !== index); + updateBlocks(newBlocks); + }; + + const handleUpdateBlock = (index: number, updated: EssayBlock) => { + const newBlocks = [...essay.blocks]; + newBlocks[index] = updated; + updateBlocks(newBlocks); + }; + + const handleAddBlock = (type: EssayBlock['type']) => { + let newBlock: EssayBlock; + + switch (type) { + case 'heading': + newBlock = { type: 'heading', level: 2, text: 'New Section Heading' }; + break; + case 'paragraph': + newBlock = { type: 'paragraph', html: 'Enter text paragraph here...' }; + break; + case 'quote': + newBlock = { type: 'quote', text: 'Enter quote text here...', author: 'Author' }; + break; + case 'photo': + newBlock = { + type: 'photo', + assetId: 'sample-asset', + caption: 'Photo caption', + layout: 'contained', + }; + break; + case 'photo-pair': + newBlock = { + type: 'photo-pair', + assetIds: ['asset-1', 'asset-2'], + caption: 'Side by side caption', + }; + break; + } + + updateBlocks([...essay.blocks, newBlock]); + }; + + return ( +
+
+ + ✨ Add Story Block: + + + + + + +
+ + {essay.blocks.length === 0 && ( +

+ No story blocks yet. Use the buttons above to build your photo essay! +

+ )} + + {essay.blocks.map((block, idx) => ( +
+ {/* Block Header / Action Controls */} +
+ + Block {idx + 1}: {block.type} + +
+ + + +
+
+ + {/* Render Block Form Controls */} + {block.type === 'heading' && ( +
+ + handleUpdateBlock(idx, { ...block, text: e.target.value })} + placeholder="Section title..." + style={{ flex: 1, padding: '6px 10px', borderRadius: '4px' }} + /> +
+ )} + + {block.type === 'paragraph' && ( +