feat: portfolio with subpages, hero homepage, and obfuscated asset URLs
This commit is contained in:
104
app/[...path]/PhotoGrid.tsx
Normal file
104
app/[...path]/PhotoGrid.tsx
Normal 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
156
app/[...path]/page.tsx
Normal 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}
|
||||
/>
|
||||
</>
|
||||
);
|
||||
}
|
||||
41
app/api/exif/[id]/route.ts
Normal file
41
app/api/exif/[id]/route.ts
Normal 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,
|
||||
});
|
||||
}
|
||||
49
app/api/image/[id]/route.ts
Normal file
49
app/api/image/[id]/route.ts
Normal 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 });
|
||||
}
|
||||
732
app/globals.css
732
app/globals.css
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
|
||||
123
app/page.tsx
123
app/page.tsx
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user