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:
@@ -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}
|
||||
/>
|
||||
);
|
||||
|
||||
Reference in New Issue
Block a user