refactor: deslop — remove dead botanica preset, extract shared helpers
- Remove dead theme preset (no CSS, not in DevToolbar) - Extract from closure → module-level export in lib/config/index.ts - Extract helper in app/[...path]/page.tsx to deduplicate password gate check - Extract inner component in app/page.tsx (was 4× inline in minimal/fullbleed/mosaic/split) - Add 8 unit tests for buildSubpageGrid covering undefined, subset fields, layout fallback, and all fields
This commit is contained in:
@@ -106,6 +106,21 @@ function toPhotoItems(assets: ImmichAsset[], showExif: boolean): PhotoItem[] {
|
|||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Returns a PasswordGate element if the slug is protected and not yet
|
||||||
|
* authenticated, otherwise returns null. Centralises the duplicated
|
||||||
|
* cookie-check boilerplate.
|
||||||
|
*/
|
||||||
|
async function gateIfProtected(slug: string): Promise<React.ReactElement | null> {
|
||||||
|
if (!isProtected(slug)) return null;
|
||||||
|
const cookieStore = await cookies();
|
||||||
|
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
||||||
|
if (isAuthenticated(slug, getCookie)) return null;
|
||||||
|
const subpageData = await immich.getSubpageAlbums(slug);
|
||||||
|
const title = subpageData?.subpage.name ?? slug;
|
||||||
|
return <PasswordGate slug={slug} title={title} />;
|
||||||
|
}
|
||||||
|
|
||||||
export default async function PathPage({ params }: PathPageProps) {
|
export default async function PathPage({ params }: PathPageProps) {
|
||||||
const { path } = await params;
|
const { path } = await params;
|
||||||
const config = getConfig();
|
const config = getConfig();
|
||||||
@@ -131,15 +146,8 @@ export default async function PathPage({ params }: PathPageProps) {
|
|||||||
const [subpageSlug, albumSlug] = path;
|
const [subpageSlug, albumSlug] = path;
|
||||||
|
|
||||||
// Password gate for protected subpages
|
// Password gate for protected subpages
|
||||||
if (isProtected(subpageSlug)) {
|
const gate = await gateIfProtected(subpageSlug);
|
||||||
const cookieStore = await cookies();
|
if (gate) return gate;
|
||||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
|
||||||
if (!isAuthenticated(subpageSlug, getCookie)) {
|
|
||||||
const subpageData = await immich.getSubpageAlbums(subpageSlug);
|
|
||||||
const subpageName = subpageData?.subpage.name ?? subpageSlug;
|
|
||||||
return <PasswordGate slug={subpageSlug} title={subpageName} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
|
||||||
|
|
||||||
@@ -172,15 +180,8 @@ export default async function PathPage({ params }: PathPageProps) {
|
|||||||
// Check if it's a subpage
|
// Check if it's a subpage
|
||||||
if (immich.isSubpageSlug(slug)) {
|
if (immich.isSubpageSlug(slug)) {
|
||||||
// Password gate for protected subpages
|
// Password gate for protected subpages
|
||||||
if (isProtected(slug)) {
|
const gate = await gateIfProtected(slug);
|
||||||
const cookieStore = await cookies();
|
if (gate) return gate;
|
||||||
const getCookie = (name: string) => cookieStore.get(name)?.value;
|
|
||||||
if (!isAuthenticated(slug, getCookie)) {
|
|
||||||
const subpageData = await immich.getSubpageAlbums(slug);
|
|
||||||
const subpageName = subpageData?.subpage.name ?? slug;
|
|
||||||
return <PasswordGate slug={slug} title={subpageName} />;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
const result = await immich.getSubpageAlbums(slug);
|
const result = await immich.getSubpageAlbums(slug);
|
||||||
if (!result || result.albums.length === 0) {
|
if (!result || result.albums.length === 0) {
|
||||||
|
|||||||
78
app/page.tsx
78
app/page.tsx
@@ -140,6 +140,27 @@ export default async function HomePage() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/** Title + optional subtitle + nav — shared by mosaic, minimal, fullbleed, split. */
|
||||||
|
function HeroTextContent() {
|
||||||
|
return (
|
||||||
|
<>
|
||||||
|
<FadeIn delay={0}>
|
||||||
|
<h1 className="hero__title">{config.siteTitle}</h1>
|
||||||
|
</FadeIn>
|
||||||
|
{config.siteSubtitle && (
|
||||||
|
<FadeIn delay={100}>
|
||||||
|
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
||||||
|
</FadeIn>
|
||||||
|
)}
|
||||||
|
<FadeIn delay={200}>
|
||||||
|
<nav className="hero__nav">
|
||||||
|
<HeroNavLinks subpages={subpages} albums={albums} />
|
||||||
|
</nav>
|
||||||
|
</FadeIn>
|
||||||
|
</>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
// ── Mosaic: multi-image grid with frosted title overlay ────────
|
// ── Mosaic: multi-image grid with frosted title overlay ────────
|
||||||
if (heroStyle === 'mosaic') {
|
if (heroStyle === 'mosaic') {
|
||||||
return (
|
return (
|
||||||
@@ -161,19 +182,7 @@ export default async function HomePage() {
|
|||||||
))}
|
))}
|
||||||
</div>
|
</div>
|
||||||
<div className="hero__mosaic-overlay">
|
<div className="hero__mosaic-overlay">
|
||||||
<FadeIn delay={0}>
|
<HeroTextContent />
|
||||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
|
||||||
</FadeIn>
|
|
||||||
{config.siteSubtitle && (
|
|
||||||
<FadeIn delay={100}>
|
|
||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
|
||||||
</FadeIn>
|
|
||||||
)}
|
|
||||||
<FadeIn delay={200}>
|
|
||||||
<nav className="hero__nav">
|
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -184,19 +193,7 @@ export default async function HomePage() {
|
|||||||
return (
|
return (
|
||||||
<div className="hero hero--minimal">
|
<div className="hero hero--minimal">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<FadeIn delay={0}>
|
<HeroTextContent />
|
||||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
|
||||||
</FadeIn>
|
|
||||||
{config.siteSubtitle && (
|
|
||||||
<FadeIn delay={100}>
|
|
||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
|
||||||
</FadeIn>
|
|
||||||
)}
|
|
||||||
<FadeIn delay={200}>
|
|
||||||
<nav className="hero__nav">
|
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -208,19 +205,7 @@ export default async function HomePage() {
|
|||||||
<div className="hero hero--fullbleed">
|
<div className="hero hero--fullbleed">
|
||||||
<HeroCarousel images={heroData} />
|
<HeroCarousel images={heroData} />
|
||||||
<div className="hero__fullbleed-overlay">
|
<div className="hero__fullbleed-overlay">
|
||||||
<FadeIn delay={0}>
|
<HeroTextContent />
|
||||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
|
||||||
</FadeIn>
|
|
||||||
{config.siteSubtitle && (
|
|
||||||
<FadeIn delay={100}>
|
|
||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
|
||||||
</FadeIn>
|
|
||||||
)}
|
|
||||||
<FadeIn delay={200}>
|
|
||||||
<nav className="hero__nav">
|
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
@@ -232,20 +217,7 @@ export default async function HomePage() {
|
|||||||
{/* ── Left Panel ──────────────────────────────── */}
|
{/* ── Left Panel ──────────────────────────────── */}
|
||||||
<div className="hero__left">
|
<div className="hero__left">
|
||||||
<div className="hero__content">
|
<div className="hero__content">
|
||||||
<FadeIn delay={0}>
|
<HeroTextContent />
|
||||||
<h1 className="hero__title">{config.siteTitle}</h1>
|
|
||||||
</FadeIn>
|
|
||||||
{config.siteSubtitle && (
|
|
||||||
<FadeIn delay={100}>
|
|
||||||
<p className="hero__subtitle">{config.siteSubtitle}</p>
|
|
||||||
</FadeIn>
|
|
||||||
)}
|
|
||||||
|
|
||||||
<FadeIn delay={200}>
|
|
||||||
<nav className="hero__nav">
|
|
||||||
<HeroNavLinks subpages={subpages} albums={albums} />
|
|
||||||
</nav>
|
|
||||||
</FadeIn>
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -12,7 +12,7 @@ vi.mock('@/lib/env', () => ({
|
|||||||
},
|
},
|
||||||
}));
|
}));
|
||||||
|
|
||||||
import { slugify } from '@/lib/config';
|
import { slugify, buildSubpageGrid } from '@/lib/config';
|
||||||
|
|
||||||
describe('slugify', () => {
|
describe('slugify', () => {
|
||||||
it('converts a simple name to lowercase with hyphens', () => {
|
it('converts a simple name to lowercase with hyphens', () => {
|
||||||
@@ -51,3 +51,50 @@ describe('slugify', () => {
|
|||||||
expect(slugify('Album 2024')).toBe('album-2024');
|
expect(slugify('Album 2024')).toBe('album-2024');
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
|
describe('buildSubpageGrid', () => {
|
||||||
|
it('returns empty object when called without arguments', () => {
|
||||||
|
expect(buildSubpageGrid()).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('returns empty object when called with undefined', () => {
|
||||||
|
expect(buildSubpageGrid(undefined)).toEqual({});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps columns and gap correctly', () => {
|
||||||
|
expect(buildSubpageGrid({ columns: 4, gap: 8 })).toEqual({
|
||||||
|
grid: { columns: 4, gap: 8 },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('maps aspectRatio correctly', () => {
|
||||||
|
expect(buildSubpageGrid({ aspectRatio: '16/9' })).toEqual({
|
||||||
|
grid: { aspectRatio: '16/9' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('preserves a valid layout value', () => {
|
||||||
|
expect(buildSubpageGrid({ layout: 'uniform' })).toEqual({
|
||||||
|
grid: { layout: 'uniform' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('falls back to masonry for an unknown layout', () => {
|
||||||
|
expect(buildSubpageGrid({ layout: 'unknown-layout' })).toEqual({
|
||||||
|
grid: { layout: 'masonry' },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
it('omits fields that are not supplied', () => {
|
||||||
|
const result = buildSubpageGrid({ columns: 2 });
|
||||||
|
expect(result).toEqual({ grid: { columns: 2 } });
|
||||||
|
expect((result as { grid: object }).grid).not.toHaveProperty('gap');
|
||||||
|
expect((result as { grid: object }).grid).not.toHaveProperty('layout');
|
||||||
|
});
|
||||||
|
|
||||||
|
it('handles all fields together', () => {
|
||||||
|
expect(
|
||||||
|
buildSubpageGrid({ columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' }),
|
||||||
|
).toEqual({ grid: { columns: 3, gap: 16, aspectRatio: '1', layout: 'masonry' } });
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|||||||
@@ -15,6 +15,28 @@ export * from './schema';
|
|||||||
export * from './theme';
|
export * from './theme';
|
||||||
|
|
||||||
let _config: AppConfig | null = null;
|
let _config: AppConfig | null = null;
|
||||||
|
|
||||||
|
/** Converts raw YAML grid overrides into a typed partial GridConfig. */
|
||||||
|
export function buildSubpageGrid(
|
||||||
|
raw?: { columns?: number; gap?: number; aspectRatio?: string; layout?: string },
|
||||||
|
): { grid: Partial<GridConfig> } | Record<string, never> {
|
||||||
|
if (!raw) return {};
|
||||||
|
return {
|
||||||
|
grid: {
|
||||||
|
...(raw.columns != null ? { columns: raw.columns } : {}),
|
||||||
|
...(raw.gap != null ? { gap: raw.gap } : {}),
|
||||||
|
...(raw.aspectRatio != null ? { aspectRatio: raw.aspectRatio } : {}),
|
||||||
|
...(raw.layout != null
|
||||||
|
? {
|
||||||
|
layout: (VALID_LAYOUTS.includes(raw.layout)
|
||||||
|
? raw.layout
|
||||||
|
: 'masonry') as GridConfig['layout'],
|
||||||
|
}
|
||||||
|
: {}),
|
||||||
|
},
|
||||||
|
};
|
||||||
|
}
|
||||||
|
|
||||||
export function getConfig(): AppConfig {
|
export function getConfig(): AppConfig {
|
||||||
const isDev = process.env.NODE_ENV !== 'production';
|
const isDev = process.env.NODE_ENV !== 'production';
|
||||||
if (_config && !isDev) return _config;
|
if (_config && !isDev) return _config;
|
||||||
@@ -135,22 +157,7 @@ export function getConfig(): AppConfig {
|
|||||||
albumIds,
|
albumIds,
|
||||||
sections,
|
sections,
|
||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...buildSubpageGrid(sp.grid),
|
||||||
? {
|
|
||||||
grid: {
|
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
|
||||||
...(sp.grid.layout != null
|
|
||||||
? {
|
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
|
||||||
? sp.grid.layout
|
|
||||||
: 'masonry') as GridConfig['layout'],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
} else if (gallery.subpages) {
|
} else if (gallery.subpages) {
|
||||||
@@ -178,22 +185,7 @@ export function getConfig(): AppConfig {
|
|||||||
subtitle: sp.subtitle,
|
subtitle: sp.subtitle,
|
||||||
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
albumIds: albumEntries.map((entry) => processAlbumEntry(entry, `subpage "${name}"`)),
|
||||||
password: sp.password,
|
password: sp.password,
|
||||||
...(sp.grid
|
...buildSubpageGrid(sp.grid),
|
||||||
? {
|
|
||||||
grid: {
|
|
||||||
...(sp.grid.columns != null ? { columns: sp.grid.columns } : {}),
|
|
||||||
...(sp.grid.gap != null ? { gap: sp.grid.gap } : {}),
|
|
||||||
...(sp.grid.aspectRatio != null ? { aspectRatio: sp.grid.aspectRatio } : {}),
|
|
||||||
...(sp.grid.layout != null
|
|
||||||
? {
|
|
||||||
layout: (VALID_LAYOUTS.includes(sp.grid.layout)
|
|
||||||
? sp.grid.layout
|
|
||||||
: 'masonry') as GridConfig['layout'],
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
},
|
|
||||||
}
|
|
||||||
: {}),
|
|
||||||
};
|
};
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -61,16 +61,6 @@ export const THEME_PRESETS: Record<string, ThemeConfig> = {
|
|||||||
headerDot: false,
|
headerDot: false,
|
||||||
heroStyle: 'typographic',
|
heroStyle: 'typographic',
|
||||||
},
|
},
|
||||||
botanica: {
|
|
||||||
preset: 'botanica',
|
|
||||||
accent: '#4a7c59',
|
|
||||||
fonts: { heading: 'Cormorant Garamond', body: 'Nunito Sans', caption: 'Inconsolata' },
|
|
||||||
radius: 8,
|
|
||||||
photoFrame: 'none',
|
|
||||||
grain: false,
|
|
||||||
headerDot: true,
|
|
||||||
heroStyle: 'split',
|
|
||||||
},
|
|
||||||
};
|
};
|
||||||
|
|
||||||
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
export const VALID_PHOTO_FRAMES = ['none', 'passepartout', 'shadow'];
|
||||||
|
|||||||
Reference in New Issue
Block a user