diff --git a/.gitignore b/.gitignore index 6087ca9..2d0c9da 100644 --- a/.gitignore +++ b/.gitignore @@ -62,6 +62,12 @@ docker-compose.override.yml content/settings.yaml content/gallery.yaml content/about.md +# Von der App zur Laufzeit geschrieben: yaml-service legt bis zu 10 Backups je +# Datei ab, und die Backups von gallery.yaml/settings.yaml wuerden genau den +# Inhalt ins Repo tragen, den die drei Zeilen darueber heraushalten sollen. +content/.backups/ +content/analytics.json +content/*.screenshot-bak # ── Internal Dev Docs ──────────────────────────────── docs/dev/ diff --git a/CHANGELOG.md b/CHANGELOG.md new file mode 100644 index 0000000..3e5874c --- /dev/null +++ b/CHANGELOG.md @@ -0,0 +1,111 @@ +# Changelog + +All notable changes to this project are documented here. + +The format is based on [Keep a Changelog](https://keepachangelog.com/en/1.1.0/), +and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0.html). +Releases up to and including v0.9.2 are documented in the +[GitHub releases](https://github.com/ralksta/immich-folio/releases). + +## [0.10.0] — 2026-08-10 + +### Added + +- **Photo Essay mode** — storytelling pages with an RSC markdown parser and + fullbleed/pair layouts, plus a visual essay block builder in the admin panel. +- **Client proofing** — favorite selection with compact URL bitmasking and an + export modal. +- **Subpage enable toggle** — deactivate a subpage without deleting it. +- **SEO** — configurable subpage title template, metadata descriptions, and + `generateMetadata` on the about page. +- **New theme preset `studio-modern`.** The Leica language of `studio` rebuilt + around the precision grotesque Archivo, with IBM Plex Mono for every piece of + photographic metadata. Hairline rules, zero radius, an indexed hero navigation + with album counts, an always-visible caption bar under album covers, and a + film-edge EXIF strip in the lightbox. Selectable in the admin panel, the dev + toolbar, and `content/settings.yaml`. See [docs/theming.md](docs/theming.md). + +### Security + +- `GET /api/admin/analytics` now requires an admin session. +- Docker images are scanned with Trivy on every publish; results land in the + GitHub Security tab. + +### Changed + +- **Album sort order now mirrors the Immich timeline** ([#350](https://github.com/ralksta/immich-folio/issues/350)). Albums were + sorted by `fileCreatedAt`, the capture instant in UTC. Immich sorts its + timeline primarily by `localDateTime` — the capture time in the + photographer's local zone — and uses `fileCreatedAt` only as a tie-breaker. + The two keys are identical for the vast majority of albums; they diverge for + albums spanning time zones, where the order shown in Immich Folio no longer + matched the Immich UI. The sort keys were changed accordingly, and + unparseable dates now fall back to `0` instead of `NaN` (a `NaN` comparator + makes `sort()` free to return any permutation). + +### Fixed + +- **Cache staleness** — `?fresh=1` bypass, an admin diagnostic banner for albums + Immich returns empty, and revalidation after admin saves. +- **Admin status panel reported two faults that were not faults** ([#341](https://github.com/ralksta/immich-folio/issues/341)). + `settings.yaml` was treated as mandatory although `getConfig()` falls back to + defaults without it, so anyone running with only a `gallery.yaml` saw a + permanent "Config Integrity: Degraded". Only a file that exists and cannot be + parsed counts as degraded now. A failed status request was also rendered as if + it were a result — an expired admin session looked identical to a real + outage — so there is now a third state for "the check did not run". +- **Lightbox showed its photo counter twice.** The counter renders a + screen-reader label ("Photo 3 of 53") alongside the compact display + ("3 / 53"), but `.sr-only` was not defined in any stylesheet, so both strings + were visible on top of each other. +- **Docker** — health check targets `127.0.0.1` instead of `localhost`, base + image moved to `node:22-alpine`. +- **Docs** — the "nature / travel journal" example in `docs/theming.md` used a + preset named `botanica` that does not exist; copying it produced an + `Unknown theme preset` error. It now uses `editorial`. +- **Map page ignored the theme.** `app/map/map.css` referenced custom properties + that never existed (`--font-heading`, `--font-body`, `--border-color`, + `--radius`), so the map title, popups, and container fell back to browser + defaults instead of the configured fonts, borders, and radius. This affects + every preset — the map now picks up the theme like the rest of the site. + +### Upgrade notes + +**No migration is required.** No configuration schema changed, and the six +existing presets render exactly as before. Pull the new image, restart, done. + +**Rolling back after switching presets needs one edit.** If you select +"Studio Modern" and later downgrade to a version that predates it, the +`theme.preset: studio-modern` left in `content/settings.yaml` is a preset the +older version does not know, and config loading fails with: + +``` +Unknown theme preset "studio-modern". Valid presets: studio, minimal, editorial, classic, noir, monograph +``` + +Set `theme.preset` back to one of the listed presets in `content/settings.yaml` +before downgrading. `/admin` stays reachable if you hit this, so the change can +also be made after the fact. + +**If you copied `docker-compose.override.yml.example`, update your health +check by hand.** The example now uses `wget -O /dev/null` instead of +`--spider`, but your own `docker-compose.override.yml` is not tracked by git, +so pulling the new version does not touch it — and a health check defined +there overrides the one baked into the image. `--spider` does not read the +response body and disconnects mid-RSC-stream; when that abort coincides with a +slow render after `CACHE_TTL` expiry, Node tears down the `TransformStream` +controller while Next.js is still writing into it: + +``` +TypeError: controller[kState].transformAlgorithm is not a function +``` + +Replace `"--spider"` with `"-O", "/dev/null"` in your override file: + +```yaml +healthcheck: + test: ['CMD', 'wget', '--no-verbose', '--tries=1', '-O', '/dev/null', 'http://127.0.0.1:7211/'] +``` + +A full GET checks the same thing without the race. Installations without an +own health check need no action — the image ships the fixed one. diff --git a/CLAUDE.md b/CLAUDE.md index 2cc3453..4ec15cc 100644 --- a/CLAUDE.md +++ b/CLAUDE.md @@ -36,7 +36,7 @@ Configuration is loaded once at startup (cached in a module-level singleton, re- - `lib/env.ts` — parses and validates all environment variables into a typed `Env` object. All env access in the codebase must go through this module. - `lib/config/schema.ts` — TypeScript types for `AppConfig`, `GalleryYaml`, `SettingsYaml`, `SubpageConfig`, `GridConfig`, `ThemeConfig`, etc., plus the `slugify()` utility. - `lib/config/index.ts` — `getConfig()`: reads `content/gallery.yaml` and `content/settings.yaml`, merges them into a single `AppConfig`. Also exports `buildSubpageGrid()` and re-exports from schema/theme. -- `lib/config/theme.ts` — six built-in theme presets (`studio`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset. +- `lib/config/theme.ts` — seven built-in theme presets (`studio`, `studio-modern`, `minimal`, `editorial`, `classic`, `noir`, `monograph`) and `resolveTheme()` which merges partial overrides over a preset. `getConfig()` returns a `needsSetup: true` dummy config (instead of throwing) when `gallery.yaml` or credentials are missing — this lets the app render a `SetupScreen` instead of crashing. diff --git a/Dockerfile b/Dockerfile index d040982..9c2e44c 100644 --- a/Dockerfile +++ b/Dockerfile @@ -1,4 +1,4 @@ -FROM node:20-alpine AS base +FROM node:22-alpine AS base # ── Dependencies ────────────────────────────────── FROM base AS deps @@ -51,8 +51,12 @@ EXPOSE 7211 ENV PORT=7211 ENV HOSTNAME="0.0.0.0" +# 127.0.0.1 statt localhost: der Server bindet auf 0.0.0.0 (siehe HOSTNAME oben), +# lauscht also nur auf IPv4. In Alpine loest 'localhost' zuerst nach ::1 auf, und +# wget bleibt bei diesem ersten Ergebnis — der Check schlaegt dauerhaft mit +# "Connection refused" fehl und der Container gilt als unhealthy. HEALTHCHECK --interval=30s --timeout=5s --start-period=10s --retries=3 \ - CMD wget --no-verbose --tries=1 --spider http://localhost:7211/api/health || exit 1 + CMD wget --no-verbose --tries=1 --spider http://127.0.0.1:7211/api/health || exit 1 # Run as root initially so entrypoint can fix bind-mount permissions, # then drops to nextjs user via su-exec diff --git a/README.md b/README.md index 939d4be..e8dfc5f 100644 --- a/README.md +++ b/README.md @@ -5,9 +5,55 @@ A self-hosted photography portfolio powered by [Immich](https://immich.app). Tur Immich Folio acts as a **secure reverse proxy** between your visitors and your private Immich instance. Your Immich server stays on your local network, completely invisible to the outside world.
-
-
+
+
+
+
+
+
+
+
+
Studio Modern in light mode — every preset ships a light and a dark variant.
+ +- **Photo Essay mode** — storytelling pages that alternate text with fullbleed and paired image layouts, written in Markdown or assembled in the new visual block editor +- **Client proofing** — let clients pick favorites and export the selection; the picks live in the URL as a compact bitmask, so nothing is stored server-side and a selection can be shared as a link +- **Lightbox watermark** — configurable overlay on fullscreen images +- **Privacy-friendly analytics** — page and album view counts without cookies or third parties, switchable off entirely +- **Subpage on/off toggle** — take a page offline without deleting it +- **Admin panel overhaul** — slide-over page-builder drawer with search, visual preview cards for grid, theme, photo frame and hero style, a Google SEO snippet preview, light/dark toggle, subpage live preview, and a backup manager with one-click restore + +
+
+
+
+
+
+
The Markdown about page and the GPS map, both generated from your Immich data.
+ ### Admin Panel - **Visual page builder** — drag & drop interface to manage hero images, standalone albums, subpages, and sections - **Album picker** — browse all shared Immich albums with search, see photo counts, and add them with one click - **Settings editor** — configure theme, grid layout, footer, legal/impressum, and SEO from a visual UI +- **Visual previews everywhere** — grid layout, theme, photo frame, hero style and the Google search snippet are picked from preview cards instead of text fields +- **Essay block editor** — assemble photo essays block by block without touching Markdown +- **Backup manager** — every save is backed up automatically; restore any of them with one click - **Live YAML sync** — changes are written directly to `gallery.yaml` and `settings.yaml` with automatic backups - **Password protected** — secured with its own admin password, separate from album passwords @@ -44,14 +103,14 @@ Immich Folio acts as a **secure reverse proxy** between your visitors and your p
+
+
+
+
+
+
Login · page builder · album picker · settings editor
→ **[Admin Panel Guide](docs/admin-panel.md)** diff --git a/app/[...path]/AlbumDetailView.tsx b/app/[...path]/AlbumDetailView.tsx index 6c9dfea..111a9e9 100644 --- a/app/[...path]/AlbumDetailView.tsx +++ b/app/[...path]/AlbumDetailView.tsx @@ -20,6 +20,31 @@ interface AlbumDetailViewProps { watermark?: LightboxWatermark; } +/** + * Extra detail for the album header — shooting date and camera/lens, read off + * the first asset that carries EXIF. Both are rendered in their own spans and + * hidden by default; only presets that ask for them show them, so the header + * keeps reading "86 photos" everywhere else. + */ +function albumMetaDetail(album: ImmichAlbum): { date?: string; gear?: string } { + const first = album.assets.find((a) => a.exifInfo); + const exif = first?.exifInfo; + + const taken = exif?.dateTimeOriginal || first?.fileCreatedAt; + const gear = [exif?.model, exif?.lensModel].filter(Boolean).join(' · '); + + return { + ...(taken ? { date: formatMonthYear(taken) } : {}), + ...(gear ? { gear } : {}), + }; +} + +function formatMonthYear(iso: string): string | undefined { + const d = new Date(iso); + if (Number.isNaN(d.getTime())) return undefined; + return `${String(d.getMonth() + 1).padStart(2, '0')}/${d.getFullYear()}`; +} + export function AlbumDetailView({ album, images, @@ -32,6 +57,8 @@ export function AlbumDetailView({ heroBlurDataURL, watermark, }: AlbumDetailViewProps) { + const metaDetail = albumMetaDetail(album); + return ( <> {heroImageUrl && ( @@ -49,16 +76,26 @@ export function AlbumDetailView({ )}- {subtitle} -
- )} - {album.description &&{album.description}
} ++ {subtitle} +
+ )} + {album.description &&{album.description}
} +- {images.length} {images.length === 1 ? 'photo' : 'photos'} + + {images.length} {images.length === 1 ? 'photo' : 'photos'} + + {metaDetail.date && ( + · {metaDetail.date} + )} + {metaDetail.gear && ( + {metaDetail.gear} + )}
{subtitle}
} +{subtitle}
} +{meta.location}
} diff --git a/app/admin/admin.css b/app/admin/admin.css index 37794cb..f28773e 100644 --- a/app/admin/admin.css +++ b/app/admin/admin.css @@ -539,7 +539,9 @@ border: 1px solid var(--admin-border); border-radius: 10px; overflow: hidden; - transition: border-color 0.15s, box-shadow 0.15s; + transition: + border-color 0.15s, + box-shadow 0.15s; } .album-tile:hover { @@ -706,7 +708,10 @@ border-radius: 10px; overflow: hidden; cursor: pointer; - transition: border-color 0.15s, box-shadow 0.15s, transform 0.1s; + transition: + border-color 0.15s, + box-shadow 0.15s, + transform 0.1s; } .subpage-tile:hover { @@ -771,8 +776,14 @@ } @keyframes slideDown { - from { opacity: 0; transform: translateY(-4px); } - to { opacity: 1; transform: translateY(0); } + from { + opacity: 0; + transform: translateY(-4px); + } + to { + opacity: 1; + transform: translateY(0); + } } .subpage-detail-header { @@ -1118,8 +1129,11 @@ cursor: pointer; padding: 0; text-align: left; - transition: transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), border-color 0.2s, box-shadow 0.2s; - box-shadow: 0 1px 3px rgba(0,0,0,0.05); + transition: + transform 0.2s cubic-bezier(0.16, 1, 0.3, 1), + border-color 0.2s, + box-shadow 0.2s; + box-shadow: 0 1px 3px rgba(0, 0, 0, 0.05); } .preset-card:hover { @@ -1243,6 +1257,12 @@ animation: pulse-red 1.5s infinite; } +/* The diagnostics request itself did not run — deliberately not red, and not + animated: an unanswered check is not a fault report. */ +.status-badge-btn.unknown .status-dot { + background-color: var(--admin-text-secondary); +} + .status-text { font-size: 0.75rem; font-weight: 500; @@ -1341,6 +1361,10 @@ color: var(--admin-error, #ef4444); } +.status-val.unknown { + color: var(--admin-text-secondary); +} + /* ── Search Bar ────────────────────────────────────────────── */ .builder-search-container { padding: 0.75rem 1.5rem; @@ -1399,15 +1423,27 @@ /* Keyframes */ @keyframes pulse-green { - 0% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); } - 70% { box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); } - 100% { box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); } + 0% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(16, 185, 129, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(16, 185, 129, 0); + } } @keyframes pulse-red { - 0% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); } - 70% { box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); } - 100% { box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); } + 0% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0.4); + } + 70% { + box-shadow: 0 0 0 6px rgba(239, 68, 68, 0); + } + 100% { + box-shadow: 0 0 0 0 rgba(239, 68, 68, 0); + } } /* Color Field */ @@ -1530,7 +1566,9 @@ cursor: pointer; background: var(--admin-bg-elevated); border: 2px solid transparent; - transition: border-color 0.15s, transform 0.1s; + transition: + border-color 0.15s, + transform 0.1s; } .asset-picker-tile:hover:not(.used) { @@ -1739,7 +1777,9 @@ width: 1em; height: 1em; stroke-width: 2px; - transition: transform 0.2s ease, color 0.15s ease; + transition: + transform 0.2s ease, + color 0.15s ease; } .svg-drag { @@ -2147,7 +2187,9 @@ h3 .svg-icon { padding: 1.25rem; margin-bottom: 1.25rem; box-shadow: 0 2px 8px rgba(0, 0, 0, 0.04); - transition: border-color 0.15s ease, box-shadow 0.15s ease; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; } .section-card:hover { @@ -2805,7 +2847,10 @@ h3 .svg-icon { border-radius: 50%; border: 2px solid transparent; cursor: pointer; - transition: transform 0.15s ease, border-color 0.15s ease, box-shadow 0.15s ease; + transition: + transform 0.15s ease, + border-color 0.15s ease, + box-shadow 0.15s ease; } .color-swatch-btn:hover { @@ -2978,11 +3023,27 @@ h3 .svg-icon { 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-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%; @@ -3021,9 +3082,15 @@ h3 .svg-icon { flex: 1; } -.demo-tile.h-high { flex: 2; } -.demo-tile.h-low { flex: 1; } -.demo-tile.h-med { flex: 1.5; } +.demo-tile.h-high { + flex: 2; +} +.demo-tile.h-low { + flex: 1; +} +.demo-tile.h-med { + flex: 1.5; +} .demo-uniform-grid { display: grid; @@ -3038,7 +3105,9 @@ h3 .svg-icon { height: 100%; } -.demo-tile.hero-tile { flex: 2; } +.demo-tile.hero-tile { + flex: 2; +} .demo-filmstrip-row { display: flex; @@ -3154,8 +3223,12 @@ h3 .svg-icon { } @keyframes slideInRight { - from { transform: translateX(100%); } - to { transform: translateX(0); } + from { + transform: translateX(100%); + } + to { + transform: translateX(0); + } } .subpage-drawer-header { @@ -3249,7 +3322,9 @@ h3 .svg-icon { 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); + 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; @@ -3272,9 +3347,18 @@ h3 .svg-icon { } @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); } + 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 ────────────────────────────────────────── */ @@ -3546,9 +3630,194 @@ h3 .svg-icon { text-overflow: ellipsis; } +/* ── Photo Essay & Storytelling Builder ───────────────────────── */ +.essay-block-editor { + display: flex; + flex-direction: column; + gap: 1rem; +} +.essay-toolbar-sticky { + display: flex; + flex-wrap: wrap; + align-items: center; + gap: 0.5rem; + padding: 0.75rem 1rem; + background: var(--admin-bg-raised, #1a1a1a); + border: 1px solid var(--admin-border, #2e2e2e); + border-radius: 10px; + backdrop-filter: blur(8px); +} +.essay-toolbar-label { + font-size: 0.8rem; + font-weight: 600; + color: var(--admin-text, #f0f0f0); + margin-right: 0.5rem; +} +.essay-toolbar-divider { + width: 1px; + height: 20px; + background: var(--admin-border, #2e2e2e); + margin: 0 0.25rem; +} +.essay-block-card { + background: var(--admin-bg-raised, #161616); + border: 1px solid var(--admin-border, #2e2e2e); + border-radius: 10px; + padding: 1rem; + display: flex; + flex-direction: column; + gap: 0.75rem; + transition: + border-color 0.15s ease, + box-shadow 0.15s ease; +} +.essay-block-card:hover { + border-color: var(--admin-border-hover, #444444); +} + +.essay-block-card-header { + display: flex; + align-items: center; + justify-content: space-between; + padding-bottom: 0.5rem; + border-bottom: 1px solid var(--admin-border, rgba(255, 255, 255, 0.06)); +} + +.essay-block-badge { + display: inline-flex; + align-items: center; + gap: 0.35rem; + padding: 0.2rem 0.55rem; + border-radius: 9999px; + font-size: 0.7rem; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.04em; + background: var(--admin-bg-elevated, #222); + color: var(--admin-text-secondary, #a0a0a0); + border: 1px solid var(--admin-border, #2e2e2e); +} + +.essay-block-badge.badge-photo { + background: rgba(10, 132, 255, 0.15); + color: #64d2ff; + border-color: rgba(10, 132, 255, 0.3); +} + +.essay-block-badge.badge-heading { + background: rgba(191, 90, 242, 0.15); + color: #da8fff; + border-color: rgba(191, 90, 242, 0.3); +} + +.essay-block-badge.badge-quote { + background: rgba(255, 159, 10, 0.15); + color: #ffd60a; + border-color: rgba(255, 159, 10, 0.3); +} + +.essay-block-actions { + display: flex; + align-items: center; + gap: 0.25rem; +} + +/* Polished dark inputs for Essay Editor */ +.essay-block-editor input[type='text'], +.essay-block-editor textarea, +.essay-block-editor select { + background: var(--admin-bg-input, #111111) !important; + border: 1px solid var(--admin-border, #2e2e2e) !important; + border-radius: 6px !important; + color: var(--admin-text, #f0f0f0) !important; + font-size: 0.85rem !important; + padding: 0.55rem 0.75rem !important; + outline: none !important; + transition: border-color 0.15s !important; +} + +.essay-block-editor input[type='text']:focus, +.essay-block-editor textarea:focus, +.essay-block-editor select:focus { + border-color: var(--admin-border-focus, #888888) !important; +} + +.essay-photo-card { + display: flex; + flex-direction: column; + gap: 0.75rem; + background: var(--admin-bg, #111111); + padding: 0.85rem; + border-radius: 8px; + border: 1px solid var(--admin-border, #2e2e2e); +} + +.essay-photo-preview-container { + display: flex; + gap: 1rem; + align-items: flex-start; +} + +.essay-photo-thumbnail { + width: 120px; + height: 80px; + border-radius: 6px; + background: var(--admin-bg-elevated, #222); + object-fit: cover; + border: 1px solid var(--admin-border, #333); + flex-shrink: 0; +} + +.essay-photo-empty { + width: 120px; + height: 80px; + border-radius: 6px; + background: var(--admin-bg-elevated, #1a1a1a); + border: 1px dashed var(--admin-border, #333); + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + color: var(--admin-text-muted, #707070); + font-size: 0.75rem; + gap: 4px; + flex-shrink: 0; +} + +.essay-photo-meta { + flex: 1; + display: flex; + flex-direction: column; + gap: 0.5rem; +} + +.essay-pill-selector { + display: inline-flex; + background: var(--admin-bg-input, #000); + padding: 2px; + border-radius: 6px; + border: 1px solid var(--admin-border, #2e2e2e); +} + +.essay-pill-btn { + padding: 0.25rem 0.65rem; + border-radius: 4px; + font-size: 0.75rem; + font-weight: 500; + border: none; + background: transparent; + color: var(--admin-text-muted, #888); + cursor: pointer; + transition: all 0.15s ease; +} + +.essay-pill-btn.active { + background: var(--admin-btn-primary-bg, #ffffff); + color: var(--admin-btn-primary-text, #000000); +} diff --git a/app/admin/components/AdminDashboard.tsx b/app/admin/components/AdminDashboard.tsx index 83da17e..165f17c 100644 --- a/app/admin/components/AdminDashboard.tsx +++ b/app/admin/components/AdminDashboard.tsx @@ -22,6 +22,10 @@ export default function AdminDashboard({ onLogout }: Props) { const [showBackupModal, setShowBackupModal] = useState(false); const [status, setStatus] = useState