feat: portfolio with subpages, hero homepage, and obfuscated asset URLs

This commit is contained in:
ralksta
2026-02-14 00:59:57 +01:00
parent 688a28c7df
commit 415aef98f2
20 changed files with 2231 additions and 123 deletions

104
app/[...path]/PhotoGrid.tsx Normal file
View File

@@ -0,0 +1,104 @@
/**
* PhotoGrid — client component wrapping the masonry grid and lightbox.
* Handles image click → lightbox open, keyboard nav, and EXIF fetching.
*/
'use client';
import { useState, useCallback, useEffect } from 'react';
import { Lightbox } from '@/components/Lightbox';
export interface PhotoItem {
id: string;
thumbUrl: string;
previewUrl: string;
originalUrl: string;
exifUrl: string;
}
interface PhotoGridProps {
assets: PhotoItem[];
albumId: string;
}
export function PhotoGrid({ assets }: PhotoGridProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const openLightbox = useCallback((index: number) => {
setLightboxIndex(index);
}, []);
const closeLightbox = useCallback(() => {
setLightboxIndex(null);
}, []);
const goNext = useCallback(() => {
setLightboxIndex((prev) =>
prev !== null ? (prev + 1) % assets.length : null,
);
}, [assets.length]);
const goPrev = useCallback(() => {
setLightboxIndex((prev) =>
prev !== null ? (prev - 1 + assets.length) % assets.length : null,
);
}, [assets.length]);
// Keyboard navigation
useEffect(() => {
if (lightboxIndex === null) return;
const handleKeyDown = (e: KeyboardEvent) => {
switch (e.key) {
case 'Escape':
closeLightbox();
break;
case 'ArrowRight':
goNext();
break;
case 'ArrowLeft':
goPrev();
break;
}
};
document.addEventListener('keydown', handleKeyDown);
// Prevent body scroll when lightbox is open
document.body.style.overflow = 'hidden';
return () => {
document.removeEventListener('keydown', handleKeyDown);
document.body.style.overflow = '';
};
}, [lightboxIndex, closeLightbox, goNext, goPrev]);
return (
<>
<div className="photo-grid">
{assets.map((asset, index) => (
<div
key={asset.id}
className="photo-grid__item"
onClick={() => openLightbox(index)}
>
<img
src={asset.thumbUrl}
alt=""
loading={index < 6 ? 'eager' : 'lazy'}
/>
</div>
))}
</div>
{lightboxIndex !== null && (
<Lightbox
assets={assets}
currentIndex={lightboxIndex}
onClose={closeLightbox}
onNext={goNext}
onPrev={goPrev}
/>
)}
</>
);
}

156
app/[...path]/page.tsx Normal file
View File

@@ -0,0 +1,156 @@
/**
* Catch-all route — handles both subpage listings and album detail pages.
*
* Single segment:
* - If slug matches a subpage → render subpage album grid
* - If slug matches a standalone album → render album detail
*
* Two segments:
* - Treat as subpage-slug/album-slug → render album detail
* with back-link to the subpage
*/
import Link from 'next/link';
import { immich } from '@/lib/immich';
import { notFound } from 'next/navigation';
import { PhotoGrid } from './PhotoGrid';
import { imageUrl, exifUrl } from '@/lib/urls';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
interface PathPageProps {
params: Promise<{ path: string[] }>;
}
export default async function PathPage({ params }: PathPageProps) {
const { path } = await params;
if (!path || path.length === 0) {
notFound();
}
// ── Two segments: subpage/album ──────────────────────────────
if (path.length === 2) {
const [subpageSlug, albumSlug] = path;
const album = await immich.getAlbumBySlug(albumSlug, subpageSlug);
if (!album) {
notFound();
}
// Get subpage name for the back link
const subpageData = await immich.getSubpageAlbums(subpageSlug);
const subpageName = subpageData?.subpage.name ?? subpageSlug;
const images = album.assets.filter((asset) => asset.type === 'IMAGE');
return (
<>
<div className="album-header">
<Link href={`/${subpageSlug}`} className="album-header__back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to {subpageName}
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => ({
id: a.id,
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
originalUrl: imageUrl(a.id, 'original'),
exifUrl: exifUrl(a.id),
}))}
albumId={album.id}
/>
</>
);
}
// ── Single segment: subpage or standalone album ──────────────
const slug = path[0];
// Check if it's a subpage
if (immich.isSubpageSlug(slug)) {
const result = await immich.getSubpageAlbums(slug);
if (!result || result.albums.length === 0) {
notFound();
}
const { albums } = result;
return (
<div className="subpage-grid">
{albums.map((album) => (
<Link
key={album.id}
href={`/${slug}/${album.slug}`}
className="subpage-grid__item"
>
{album.albumThumbnailAssetId ? (
<img
src={imageUrl(album.albumThumbnailAssetId, 'preview')}
alt={album.albumName}
loading="lazy"
/>
) : (
<div
className="skeleton"
style={{ width: '100%', height: '100%' }}
/>
)}
<div className="subpage-grid__item-overlay">
<span className="subpage-grid__item-title">{album.albumName}</span>
<span className="subpage-grid__item-count">
{album.assetCount} {album.assetCount === 1 ? 'photo' : 'photos'}
</span>
</div>
</Link>
))}
</div>
);
}
// Otherwise treat as a standalone album slug
const album = await immich.getAlbumBySlug(slug);
if (!album) {
notFound();
}
const images = album.assets.filter((asset) => asset.type === 'IMAGE');
return (
<>
<div className="album-header">
<Link href="/" className="album-header__back">
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polyline points="15 18 9 12 15 6" />
</svg>
Back to Gallery
</Link>
<h1 className="album-header__title">{album.albumName}</h1>
<p className="album-header__meta">
{images.length} {images.length === 1 ? 'photo' : 'photos'}
{album.description && ` · ${album.description}`}
</p>
</div>
<PhotoGrid
assets={images.map((a) => ({
id: a.id,
thumbUrl: imageUrl(a.id, 'preview'),
previewUrl: imageUrl(a.id, 'preview'),
originalUrl: imageUrl(a.id, 'original'),
exifUrl: exifUrl(a.id),
}))}
albumId={album.id}
/>
</>
);
}

View File

@@ -0,0 +1,41 @@
/**
* EXIF metadata API route — returns camera/lens/settings info for an asset.
* Called on-demand by the lightbox when the user clicks "Info".
*
* The :token is an encoded asset ID (not a raw UUID).
*/
import { NextResponse } from 'next/server';
import { immich } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
export async function GET(
_request: Request,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID
const assetId = decodeAssetId(token);
if (!assetId) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
const asset = await immich.getAssetInfo(assetId);
if (!asset?.exifInfo) {
return NextResponse.json({ error: 'No EXIF data' }, { status: 404 });
}
const exif = asset.exifInfo;
return NextResponse.json({
make: exif.make,
model: exif.model,
lensModel: exif.lensModel,
focalLength: exif.focalLength,
fNumber: exif.fNumber,
exposureTime: exif.exposureTime,
iso: exif.iso,
city: exif.city,
country: exif.country,
});
}

View File

@@ -0,0 +1,49 @@
/**
* Image proxy route — serves Immich assets to the browser
* without exposing the Immich server or API key.
*
* Usage: GET /api/image/:token?size=thumbnail|preview|original
* The :token is an encoded asset ID (not a raw UUID).
*/
import { NextRequest, NextResponse } from 'next/server';
import { immich, ImageSize } from '@/lib/immich';
import { decodeAssetId } from '@/lib/tokens';
const VALID_SIZES: ImageSize[] = ['thumbnail', 'preview', 'original'];
export async function GET(
request: NextRequest,
{ params }: { params: Promise<{ id: string }> },
) {
const { id: token } = await params;
// Decode the opaque token back to an Immich asset ID
const assetId = decodeAssetId(token);
if (!assetId) {
return NextResponse.json({ error: 'Invalid token' }, { status: 400 });
}
// Parse and validate size parameter
const sizeParam = request.nextUrl.searchParams.get('size') || 'preview';
const size: ImageSize = VALID_SIZES.includes(sizeParam as ImageSize)
? (sizeParam as ImageSize)
: 'preview';
const result = await immich.streamAsset(assetId, size);
if (!result) {
return NextResponse.json({ error: 'Asset not found' }, { status: 404 });
}
const headers: Record<string, string> = {
'Content-Type': result.contentType,
// Images are immutable once uploaded to Immich — cache aggressively
'Cache-Control': 'public, max-age=31536000, immutable',
};
if (result.contentLength) {
headers['Content-Length'] = result.contentLength;
}
return new NextResponse(result.stream, { headers });
}

View File

@@ -1,33 +1,58 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap');
/* ── Design Tokens ────────────────────────────────── */
:root {
--background: #ffffff;
--foreground: #171717;
--bg-primary: #0a0a0a;
--bg-secondary: #111111;
--bg-card: #161616;
--bg-card-hover: #1a1a1a;
--bg-overlay: rgba(0, 0, 0, 0.92);
--text-primary: #f0f0f0;
--text-secondary: #999999;
--text-muted: #666666;
--border-subtle: #222222;
--border-hover: #333333;
--accent: #d4af37;
--accent-dim: rgba(212, 175, 55, 0.15);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
--radius-sm: 4px;
--radius-md: 8px;
--radius-lg: 12px;
--transition-fast: 150ms ease;
--transition-normal: 300ms ease;
--transition-slow: 500ms ease;
--max-width: 1400px;
--header-height: 64px;
}
@media (prefers-color-scheme: dark) {
:root {
--background: #0a0a0a;
--foreground: #ededed;
}
/* ── Reset ────────────────────────────────────────── */
*,
*::before,
*::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
html,
body {
max-width: 100vw;
overflow-x: hidden;
}
body {
color: var(--foreground);
background: var(--background);
font-family: Arial, Helvetica, sans-serif;
html {
-webkit-font-smoothing: antialiased;
-moz-osx-font-smoothing: grayscale;
}
* {
box-sizing: border-box;
padding: 0;
margin: 0;
body {
font-family: var(--font-sans);
background: var(--bg-primary);
color: var(--text-primary);
min-height: 100vh;
line-height: 1.6;
}
a {
@@ -35,8 +60,667 @@ a {
text-decoration: none;
}
@media (prefers-color-scheme: dark) {
html {
color-scheme: dark;
img {
display: block;
max-width: 100%;
}
/* ── Header / Navigation ──────────────────────────── */
.header {
position: sticky;
top: 0;
z-index: 100;
background: rgba(10, 10, 10, 0.9);
backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle);
padding: 0 24px;
}
.header__nav {
display: flex;
align-items: center;
gap: 0;
max-width: var(--max-width);
margin: 0 auto;
overflow-x: auto;
scrollbar-width: none;
-ms-overflow-style: none;
}
.header__nav::-webkit-scrollbar {
display: none;
}
.header__nav-link {
padding: 18px 16px;
font-family: var(--font-sans);
font-size: 0.7rem;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--text-secondary);
white-space: nowrap;
transition: color var(--transition-fast);
border-bottom: 2px solid transparent;
margin-bottom: -1px;
}
.header__nav-link:hover,
.header__nav-link.active {
color: var(--text-primary);
border-bottom-color: var(--text-primary);
}
/* ── Main Content ─────────────────────────────────── */
.main {
max-width: var(--max-width);
margin: 0 auto;
padding: 48px 24px 96px;
}
/* Homepage hero uses full bleed — override main padding */
.main:has(.hero) {
max-width: none;
padding: 0;
}
/* ── Hero Split Layout (Homepage) ────────────────── */
.hero {
display: grid;
grid-template-columns: 1fr 1fr;
min-height: calc(100vh - 56px);
}
.hero__left {
display: flex;
align-items: center;
justify-content: center;
padding: 64px 48px;
background: var(--bg-primary);
}
.hero__content {
text-align: center;
max-width: 400px;
}
.hero__title {
font-family: var(--font-serif);
font-size: 3rem;
font-weight: 600;
letter-spacing: 0.04em;
margin-bottom: 8px;
}
.hero__subtitle {
font-family: var(--font-sans);
font-size: 0.8rem;
font-weight: 300;
letter-spacing: 0.2em;
text-transform: lowercase;
color: var(--text-muted);
font-style: italic;
margin-bottom: 40px;
}
.hero__nav {
display: flex;
flex-direction: column;
gap: 6px;
align-items: center;
}
.hero__nav-link {
font-family: var(--font-sans);
font-size: 0.85rem;
font-weight: 300;
color: var(--text-secondary);
text-decoration: underline;
text-underline-offset: 3px;
text-decoration-color: rgba(255, 255, 255, 0.2);
transition: color var(--transition-fast), text-decoration-color var(--transition-fast);
padding: 4px 0;
}
.hero__nav-link:hover {
color: var(--text-primary);
text-decoration-color: var(--text-primary);
}
.hero__right {
position: relative;
overflow: hidden;
}
.hero__image {
width: 100%;
height: 100%;
object-fit: cover;
}
.hero__image-placeholder {
width: 100%;
height: 100%;
background: var(--bg-secondary);
}
/* ── Section Labels ──────────────────────────────── */
.section-label {
font-family: var(--font-sans);
font-size: 0.75rem;
font-weight: 500;
text-transform: uppercase;
letter-spacing: 0.15em;
color: var(--text-muted);
margin-top: 48px;
margin-bottom: 20px;
}
.section-label:first-child {
margin-top: 0;
}
/* ── Subpage Album Grid (2-column, edge-to-edge) ─── */
.subpage-grid {
display: grid;
grid-template-columns: repeat(2, 1fr);
gap: 4px;
}
.subpage-grid__item {
position: relative;
overflow: hidden;
aspect-ratio: 3 / 2;
cursor: pointer;
display: block;
}
.subpage-grid__item img {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-slow), filter var(--transition-slow);
}
.subpage-grid__item:hover img {
transform: scale(1.03);
filter: brightness(0.75);
}
.subpage-grid__item-overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 20px;
background: linear-gradient(to top,
rgba(0, 0, 0, 0.6) 0%,
transparent 50%);
opacity: 0;
transition: opacity var(--transition-normal);
}
.subpage-grid__item:hover .subpage-grid__item-overlay {
opacity: 1;
}
.subpage-grid__item-title {
font-family: var(--font-serif);
font-size: 1.2rem;
font-weight: 400;
letter-spacing: 0.03em;
}
.subpage-grid__item-count {
font-size: 0.8rem;
color: var(--text-secondary);
font-weight: 300;
margin-top: 2px;
}
/* ── Album Grid (Homepage) ────────────────────────── */
.album-grid {
display: grid;
grid-template-columns: repeat(auto-fill, minmax(380px, 1fr));
gap: 20px;
}
.album-card {
position: relative;
overflow: hidden;
border-radius: var(--radius-md);
aspect-ratio: 3 / 2;
cursor: pointer;
background: var(--bg-card);
border: 1px solid var(--border-subtle);
transition: border-color var(--transition-normal), transform var(--transition-normal);
}
.album-card:hover {
border-color: var(--border-hover);
transform: translateY(-2px);
}
.album-card__image {
width: 100%;
height: 100%;
object-fit: cover;
transition: transform var(--transition-slow), filter var(--transition-slow);
}
.album-card:hover .album-card__image {
transform: scale(1.03);
filter: brightness(0.7);
}
.album-card__overlay {
position: absolute;
inset: 0;
display: flex;
flex-direction: column;
justify-content: flex-end;
padding: 24px;
background: linear-gradient(to top,
rgba(0, 0, 0, 0.8) 0%,
rgba(0, 0, 0, 0.3) 40%,
transparent 100%);
opacity: 0;
transition: opacity var(--transition-normal);
}
.album-card:hover .album-card__overlay {
opacity: 1;
}
.album-card__title {
font-family: var(--font-serif);
font-size: 1.5rem;
font-weight: 400;
letter-spacing: 0.03em;
margin-bottom: 4px;
}
.album-card__count {
font-size: 0.85rem;
color: var(--text-secondary);
font-weight: 300;
}
/* ── Photo Grid (Album Detail) ────────────────────── */
.album-header {
margin-bottom: 40px;
}
.album-header__back {
display: inline-flex;
align-items: center;
gap: 8px;
font-size: 0.85rem;
color: var(--text-secondary);
margin-bottom: 16px;
transition: color var(--transition-fast);
}
.album-header__back:hover {
color: var(--text-primary);
}
.album-header__back svg {
width: 16px;
height: 16px;
}
.album-header__title {
font-family: var(--font-serif);
font-size: 2.5rem;
font-weight: 400;
letter-spacing: 0.02em;
}
.album-header__meta {
margin-top: 8px;
font-size: 0.9rem;
color: var(--text-secondary);
font-weight: 300;
}
.photo-grid {
column-count: 3;
column-gap: 12px;
}
.photo-grid__item {
break-inside: avoid;
margin-bottom: 12px;
border-radius: var(--radius-sm);
overflow: hidden;
cursor: pointer;
position: relative;
}
.photo-grid__item img {
width: 100%;
height: auto;
display: block;
transition: filter var(--transition-normal), transform var(--transition-slow);
}
.photo-grid__item:hover img {
filter: brightness(0.85);
transform: scale(1.02);
}
/* ── Lightbox ─────────────────────────────────────── */
.lightbox-overlay {
position: fixed;
inset: 0;
z-index: 1000;
background: var(--bg-overlay);
display: flex;
align-items: center;
justify-content: center;
animation: lightbox-fade-in var(--transition-normal) ease forwards;
}
@keyframes lightbox-fade-in {
from {
opacity: 0;
}
to {
opacity: 1;
}
}
.lightbox-overlay.closing {
animation: lightbox-fade-out var(--transition-fast) ease forwards;
}
@keyframes lightbox-fade-out {
from {
opacity: 1;
}
to {
opacity: 0;
}
}
.lightbox__image-container {
position: relative;
max-width: 95vw;
max-height: 90vh;
display: flex;
align-items: center;
justify-content: center;
}
.lightbox__image {
max-width: 95vw;
max-height: 90vh;
object-fit: contain;
border-radius: var(--radius-sm);
user-select: none;
-webkit-user-drag: none;
}
.lightbox__close {
position: fixed;
top: 20px;
right: 24px;
z-index: 1001;
background: none;
border: none;
color: var(--text-secondary);
cursor: pointer;
padding: 8px;
transition: color var(--transition-fast);
}
.lightbox__close:hover {
color: var(--text-primary);
}
.lightbox__close svg {
width: 28px;
height: 28px;
}
.lightbox__nav {
position: fixed;
top: 50%;
transform: translateY(-50%);
z-index: 1001;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: 50%;
width: 48px;
height: 48px;
display: flex;
align-items: center;
justify-content: center;
cursor: pointer;
color: var(--text-secondary);
transition: background var(--transition-fast), color var(--transition-fast);
}
.lightbox__nav:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.lightbox__nav--prev {
left: 20px;
}
.lightbox__nav--next {
right: 20px;
}
.lightbox__nav svg {
width: 20px;
height: 20px;
}
.lightbox__counter {
position: fixed;
bottom: 24px;
left: 50%;
transform: translateX(-50%);
font-size: 0.85rem;
color: var(--text-muted);
font-weight: 300;
letter-spacing: 0.1em;
}
/* ── EXIF Info Panel ──────────────────────────────── */
.lightbox__info-toggle {
position: fixed;
bottom: 24px;
right: 24px;
z-index: 1001;
background: rgba(255, 255, 255, 0.08);
backdrop-filter: blur(8px);
-webkit-backdrop-filter: blur(8px);
border: 1px solid rgba(255, 255, 255, 0.1);
border-radius: var(--radius-md);
padding: 8px 12px;
color: var(--text-secondary);
cursor: pointer;
font-size: 0.8rem;
font-family: var(--font-sans);
transition: background var(--transition-fast), color var(--transition-fast);
}
.lightbox__info-toggle:hover {
background: rgba(255, 255, 255, 0.15);
color: var(--text-primary);
}
.exif-panel {
position: fixed;
bottom: 60px;
right: 24px;
z-index: 1001;
background: rgba(20, 20, 20, 0.92);
backdrop-filter: blur(16px);
-webkit-backdrop-filter: blur(16px);
border: 1px solid var(--border-subtle);
border-radius: var(--radius-lg);
padding: 20px;
min-width: 240px;
animation: exif-slide-up var(--transition-normal) ease forwards;
}
@keyframes exif-slide-up {
from {
opacity: 0;
transform: translateY(10px);
}
to {
opacity: 1;
transform: translateY(0);
}
}
.exif-panel__row {
display: flex;
justify-content: space-between;
align-items: baseline;
padding: 6px 0;
border-bottom: 1px solid var(--border-subtle);
}
.exif-panel__row:last-child {
border-bottom: none;
}
.exif-panel__label {
font-size: 0.75rem;
color: var(--text-muted);
text-transform: uppercase;
letter-spacing: 0.08em;
}
.exif-panel__value {
font-size: 0.85rem;
color: var(--text-primary);
font-weight: 400;
}
/* ── Loading States ───────────────────────────────── */
.skeleton {
background: linear-gradient(90deg, var(--bg-card) 0%, var(--bg-card-hover) 50%, var(--bg-card) 100%);
background-size: 200% 100%;
animation: skeleton-shimmer 1.5s infinite;
border-radius: var(--radius-sm);
}
@keyframes skeleton-shimmer {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
/* ── Error / Empty States ─────────────────────────── */
.empty-state {
text-align: center;
padding: 96px 24px;
}
.empty-state__icon {
font-size: 3rem;
margin-bottom: 16px;
opacity: 0.3;
}
.empty-state__text {
font-size: 1.1rem;
color: var(--text-secondary);
font-weight: 300;
}
/* ── Responsive ───────────────────────────────────── */
@media (max-width: 1024px) {
.photo-grid {
column-count: 2;
}
.album-grid {
grid-template-columns: repeat(auto-fill, minmax(300px, 1fr));
}
}
@media (max-width: 640px) {
.hero {
grid-template-columns: 1fr;
}
.hero__right {
order: -1;
max-height: 40vh;
}
.hero__left {
padding: 40px 24px;
}
.hero__title {
font-size: 2rem;
}
.photo-grid {
column-count: 1;
}
.album-grid {
grid-template-columns: 1fr;
}
.subpage-grid {
grid-template-columns: 1fr;
}
.main {
padding: 24px 16px 64px;
}
.header__nav-link {
padding: 14px 10px;
font-size: 0.6rem;
}
.album-header__title {
font-size: 1.8rem;
}
.album-card__overlay {
opacity: 1;
}
.subpage-grid__item-overlay {
opacity: 1;
}
.lightbox__nav {
width: 40px;
height: 40px;
}
.lightbox__nav--prev {
left: 8px;
}
.lightbox__nav--next {
right: 8px;
}
}

View File

@@ -1,31 +1,35 @@
import type { Metadata } from "next";
import { Geist, Geist_Mono } from "next/font/google";
import "./globals.css";
/**
* Root layout — site header with portfolio-style navigation.
* Shows all subpages + standalone albums as nav links.
*/
const geistSans = Geist({
variable: "--font-geist-sans",
subsets: ["latin"],
});
const geistMono = Geist_Mono({
variable: "--font-geist-mono",
subsets: ["latin"],
});
import type { Metadata } from 'next';
import Link from 'next/link';
import './globals.css';
import { SubpageNav } from '@/components/SubpageNav';
export const metadata: Metadata = {
title: "Create Next App",
description: "Generated by create next app",
title: process.env.SITE_TITLE || 'Gallery',
description: 'A curated photography portfolio',
};
export default function RootLayout({
children,
}: Readonly<{
}: {
children: React.ReactNode;
}>) {
}) {
return (
<html lang="en">
<body className={`${geistSans.variable} ${geistMono.variable}`}>
{children}
<body>
<header className="header">
<nav className="header__nav">
<Link href="/" className="header__nav-link">
Home
</Link>
<SubpageNav />
</nav>
</header>
<main className="main">{children}</main>
</body>
</html>
);

View File

@@ -1,66 +1,69 @@
import Image from "next/image";
import styles from "./page.module.css";
/**
* Homepage — Split hero layout.
* Left panel: site title, subtitle, navigation links.
* Right panel: hero image from Immich.
*/
import Link from 'next/link';
import { immich } from '@/lib/immich';
import { getConfig } from '@/lib/config';
import { imageUrl } from '@/lib/urls';
// Render at request time — requires live Immich connection
export const dynamic = 'force-dynamic';
export default async function HomePage() {
const config = getConfig();
const [subpages, albums] = await Promise.all([
immich.getSubpages(),
immich.getStandaloneAlbums(),
]);
export default function Home() {
return (
<div className={styles.page}>
<main className={styles.main}>
<Image
className={styles.logo}
src="/next.svg"
alt="Next.js logo"
width={100}
height={20}
priority
/>
<div className={styles.intro}>
<h1>To get started, edit the page.tsx file.</h1>
<p>
Looking for a starting point or more instructions? Head over to{" "}
<a
href="https://vercel.com/templates?framework=next.js&utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Templates
</a>{" "}
or the{" "}
<a
href="https://nextjs.org/learn?utm_source=create-next-app&utm_medium=appdir-template-tw&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Learning
</a>{" "}
center.
</p>
<div className="hero">
{/* ── Left Panel ──────────────────────────────── */}
<div className="hero__left">
<div className="hero__content">
<h1 className="hero__title">{config.siteTitle}</h1>
{config.siteSubtitle && (
<p className="hero__subtitle">{config.siteSubtitle}</p>
)}
<nav className="hero__nav">
{subpages.map((sp) => (
<Link
key={sp.slug}
href={`/${sp.slug}`}
className="hero__nav-link"
>
{sp.name}
</Link>
))}
{albums.map((album) => (
<Link
key={album.id}
href={`/${album.slug}`}
className="hero__nav-link"
>
{album.albumName}
</Link>
))}
</nav>
</div>
<div className={styles.ctas}>
<a
className={styles.primary}
href="https://vercel.com/new?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
<Image
className={styles.logo}
src="/vercel.svg"
alt="Vercel logomark"
width={16}
height={16}
/>
Deploy Now
</a>
<a
className={styles.secondary}
href="https://nextjs.org/docs?utm_source=create-next-app&utm_medium=appdir-template&utm_campaign=create-next-app"
target="_blank"
rel="noopener noreferrer"
>
Documentation
</a>
</div>
</main>
</div>
{/* ── Right Panel (Hero Image) ────────────────── */}
<div className="hero__right">
{config.heroImage ? (
<img
src={imageUrl(config.heroImage, 'preview')}
alt=""
className="hero__image"
/>
) : (
<div className="hero__image-placeholder" />
)}
</div>
</div>
);
}