feat: promote studio theme to default, add favicon, deep-linkable lightbox

- Studio aesthetic now the default dark/light theme
- Removed MonochromToggle and 3-way theme cycle
- Light mode uses warm Studio-toned palette
- Added favicon (icon.png, apple-touch-icon, PWA icons)
- Lightbox URL sync: #photo-N hash for shareable photo links
- Browser back closes lightbox, shared links open directly
This commit is contained in:
ralksta
2026-02-14 12:14:56 +01:00
parent 52fa16c1c8
commit 90afe205f5
11 changed files with 312 additions and 41 deletions

View File

@@ -1,11 +1,14 @@
/** /**
* PhotoGrid — client component wrapping the masonry grid and lightbox. * PhotoGrid — client component wrapping the masonry grid and lightbox.
* Handles image click → lightbox open, keyboard nav, and EXIF fetching. * Handles image click → lightbox open, keyboard nav, and EXIF fetching.
*
* Deep-link support: syncs lightbox index with the URL hash (#photo-N).
* Sharing a link with a hash opens the lightbox to that photo directly.
*/ */
'use client'; 'use client';
import { useState, useCallback, useEffect } from 'react'; import { useState, useCallback, useEffect, useRef } from 'react';
import Image from 'next/image'; import Image from 'next/image';
import { Lightbox } from '@/components/Lightbox'; import { Lightbox } from '@/components/Lightbox';
import { FadeIn } from '@/components/FadeIn'; import { FadeIn } from '@/components/FadeIn';
@@ -33,15 +36,73 @@ interface PhotoGridProps {
gridStyle?: React.CSSProperties; gridStyle?: React.CSSProperties;
} }
/** Parse `#photo-N` from a hash string. Returns index or null. */
function parsePhotoHash(hash: string): number | null {
const match = hash.match(/^#photo-(\d+)$/);
if (!match) return null;
const idx = parseInt(match[1], 10) - 1; // 1-indexed in URL, 0-indexed internally
return idx >= 0 ? idx : null;
}
/** Build the hash string for a given 0-based index. */
function buildPhotoHash(index: number): string {
return `#photo-${index + 1}`; // 1-indexed for user-friendliness
}
export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) { export function PhotoGrid({ assets, layout = 'masonry', gridStyle }: PhotoGridProps) {
const [lightboxIndex, setLightboxIndex] = useState<number | null>(null); const [lightboxIndex, setLightboxIndex] = useState<number | null>(null);
const isInitialized = useRef(false);
const openLightbox = useCallback((index: number) => { // ── On mount: check URL hash for a deep-link ──────────────────
setLightboxIndex(index); useEffect(() => {
}, []); if (isInitialized.current) return;
isInitialized.current = true;
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) {
setLightboxIndex(idx);
}
}, [assets.length]);
// ── Sync URL hash when lightbox state changes ─────────────────
useEffect(() => {
if (lightboxIndex !== null && lightboxIndex < assets.length) {
const hash = buildPhotoHash(lightboxIndex);
// Use replaceState to avoid flooding history with every prev/next tap
if (window.location.hash !== hash) {
window.history.replaceState(null, '', hash);
}
}
}, [lightboxIndex, assets.length]);
// ── Listen for browser back/forward (hash change) ─────────────
useEffect(() => {
const handleHashChange = () => {
const idx = parsePhotoHash(window.location.hash);
if (idx !== null && idx < assets.length) {
setLightboxIndex(idx);
} else {
setLightboxIndex(null);
}
};
window.addEventListener('hashchange', handleHashChange);
return () => window.removeEventListener('hashchange', handleHashChange);
}, [assets.length]);
const openLightbox = useCallback(
(index: number) => {
setLightboxIndex(index);
// Push state (not replace) so the user can press Back to close
window.history.pushState(null, '', buildPhotoHash(index));
},
[],
);
const closeLightbox = useCallback(() => { const closeLightbox = useCallback(() => {
setLightboxIndex(null); setLightboxIndex(null);
// Remove hash from URL cleanly
window.history.pushState(null, '', window.location.pathname + window.location.search);
}, []); }, []);
const goNext = useCallback(() => { const goNext = useCallback(() => {

Binary file not shown.

Before

Width:  |  Height:  |  Size: 25 KiB

View File

@@ -5,7 +5,7 @@
position: sticky; position: sticky;
top: 0; top: 0;
z-index: 100; z-index: 100;
background: rgba(10, 10, 10, 0.9); background: rgba(26, 26, 26, 0.9);
backdrop-filter: blur(20px); backdrop-filter: blur(20px);
-webkit-backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px);
border-bottom: 1px solid var(--border-subtle); border-bottom: 1px solid var(--border-subtle);
@@ -13,7 +13,7 @@
} }
[data-theme='light'] .header { [data-theme='light'] .header {
background: rgba(250, 250, 250, 0.9); background: rgba(245, 245, 240, 0.9);
} }
.header__nav { .header__nav {
@@ -598,6 +598,182 @@
font-weight: 300; font-weight: 300;
} }
/* ── Studio-Inspired Defaults ────────────────────── */
/* Red dot in header */
.header__nav::before {
content: '';
display: block;
width: 10px;
height: 10px;
border-radius: 50%;
background: var(--accent);
margin-right: 8px;
flex-shrink: 0;
}
/* Generous spacing — gallery exhibition feel */
.main {
padding: 64px 40px 120px;
}
.album-header {
margin-bottom: 56px;
}
.album-header__title {
letter-spacing: 0.06em;
text-transform: uppercase;
font-weight: 300;
font-size: 2rem;
}
.section-label {
font-weight: 400;
letter-spacing: 0.2em;
}
/* Photo grid — passepartout white borders */
.photo-grid {
column-gap: var(--grid-gap, 24px);
}
.photo-grid__item {
border: 1px solid rgba(255, 255, 255, 0.12);
padding: 8px 8px 24px;
margin-bottom: var(--grid-gap, 24px);
background: #111;
border-radius: 0;
cursor: pointer;
position: relative;
overflow: visible;
}
[data-theme='light'] .photo-grid__item {
border-color: rgba(0, 0, 0, 0.1);
background: #fff;
}
.photo-grid__item img {
border-radius: 0;
}
/* Film grain overlay */
.photo-grid__item::after {
content: '';
position: absolute;
top: 8px;
left: 8px;
right: 8px;
bottom: 24px;
background-image: url("data:image/svg+xml,%3Csvg viewBox='0 0 256 256' xmlns='http://www.w3.org/2000/svg'%3E%3Cfilter id='n'%3E%3CfeTurbulence type='fractalNoise' baseFrequency='0.85' numOctaves='4' stitchTiles='stitch'/%3E%3C/filter%3E%3Crect width='100%25' height='100%25' filter='url(%23n)' opacity='0.04'/%3E%3C/svg%3E");
background-size: 128px 128px;
pointer-events: none;
z-index: 1;
mix-blend-mode: overlay;
}
/* Print-style captions (replaces EXIF overlay) */
.photo-grid__item-exif {
position: relative;
bottom: auto;
left: auto;
right: auto;
background: none;
padding: 8px 0 0;
color: var(--text-muted);
font-family: var(--font-caption, 'EB Garamond', Georgia, serif);
font-style: italic;
font-size: 0.75rem;
letter-spacing: 0.02em;
white-space: normal;
opacity: 1;
text-align: center;
}
.photo-grid__item:hover .photo-grid__item-exif {
opacity: 1;
}
/* Shutter-click feedback */
.photo-grid__item:active img {
filter: brightness(0.6);
transform: scale(0.98);
transition: all 80ms ease;
}
/* Hero — wider, cleaner */
.hero__title {
letter-spacing: 0.08em;
text-transform: uppercase;
font-weight: 300;
}
.hero__subtitle {
letter-spacing: 0.25em;
text-transform: uppercase;
font-style: normal;
font-weight: 300;
}
.hero__nav-link {
text-decoration: none;
text-transform: uppercase;
letter-spacing: 0.15em;
font-size: 0.75rem;
font-weight: 400;
padding: 6px 0;
border-bottom: 1px solid transparent;
transition: border-color var(--transition-fast), color var(--transition-fast);
}
.hero__nav-link:hover {
border-bottom-color: var(--accent);
color: var(--accent);
}
/* Album cards — sharper, no rounded corners */
.album-card {
border-radius: 0;
border-color: rgba(255, 255, 255, 0.08);
}
[data-theme='light'] .album-card {
border-color: rgba(0, 0, 0, 0.08);
}
.album-card:hover {
border-color: var(--accent);
transform: none;
}
/* Subpage grid — clean */
.subpage-grid__item {
border: 1px solid rgba(255, 255, 255, 0.08);
}
[data-theme='light'] .subpage-grid__item {
border-color: rgba(0, 0, 0, 0.08);
}
/* Nav link accent on hover */
.header__nav-link:hover,
.header__nav-link.active {
color: var(--accent);
border-bottom-color: var(--accent);
}
/* Footer — red accent on hover */
.footer__link:hover {
color: var(--accent);
}
/* Scroll-to-top — red accent on hover */
.scroll-to-top:hover {
border-color: var(--accent);
color: var(--accent);
}
/* ── Responsive ───────────────────────────────────── */ /* ── Responsive ───────────────────────────────────── */
@media (max-width: 1024px) { @media (max-width: 1024px) {
.photo-grid { .photo-grid {
@@ -640,7 +816,7 @@
} }
.main { .main {
padding: 24px 16px 64px; padding: 32px 20px 80px;
} }
.header__nav-link { .header__nav-link {
@@ -659,4 +835,8 @@
.subpage-grid__item-overlay { .subpage-grid__item-overlay {
opacity: 1; opacity: 1;
} }
.photo-grid__item {
padding: 6px 6px 18px;
}
} }

BIN
app/icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 1.4 KiB

View File

@@ -17,6 +17,9 @@ const siteDescription = 'A curated photography portfolio';
export const metadata: Metadata = { export const metadata: Metadata = {
title: siteTitle, title: siteTitle,
description: siteDescription, description: siteDescription,
icons: {
apple: '/apple-touch-icon.png',
},
openGraph: { openGraph: {
title: siteTitle, title: siteTitle,
description: siteDescription, description: siteDescription,

View File

@@ -1,55 +1,56 @@
@import url('https://fonts.googleapis.com/css2?family=Inter:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&display=swap'); @import url('https://fonts.googleapis.com/css2?family=DM+Sans:wght@300;400;500;600&family=Playfair+Display:wght@400;500;600&family=EB+Garamond:ital,wght@0,400;1,400&display=swap');
/* ── Design Tokens ────────────────────────────────── */ /* ── Design Tokens (Studio Dark — Default) ───────── */
:root { :root {
--bg-primary: #0a0a0a; --bg-primary: #1a1a1a;
--bg-secondary: #111111; --bg-secondary: #141414;
--bg-card: #161616; --bg-card: #1e1e1e;
--bg-card-hover: #1a1a1a; --bg-card-hover: #242424;
--bg-overlay: rgba(0, 0, 0, 0.92); --bg-overlay: rgba(0, 0, 0, 0.98);
--text-primary: #f0f0f0; --text-primary: #f5f5f0;
--text-secondary: #999999; --text-secondary: #a0a09a;
--text-muted: #666666; --text-muted: #706f6a;
--border-subtle: #222222; --border-subtle: #2a2a28;
--border-hover: #333333; --border-hover: #3a3a38;
--accent: #d4af37; --accent: #e60012;
--accent-dim: rgba(212, 175, 55, 0.15); --accent-dim: rgba(230, 0, 18, 0.12);
--font-sans: 'Inter', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif; --font-sans: 'DM Sans', -apple-system, BlinkMacSystemFont, 'Segoe UI', sans-serif;
--font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif; --font-serif: 'Playfair Display', Georgia, 'Times New Roman', serif;
--font-caption: 'EB Garamond', Georgia, serif;
--radius-sm: 4px; --radius-sm: 0px;
--radius-md: 8px; --radius-md: 0px;
--radius-lg: 12px; --radius-lg: 0px;
--transition-fast: 150ms ease; --transition-fast: 200ms ease;
--transition-normal: 300ms ease; --transition-normal: 400ms ease;
--transition-slow: 500ms ease; --transition-slow: 600ms ease;
--max-width: 1400px; --max-width: 1400px;
--header-height: 64px; --header-height: 64px;
} }
/* ── Light Theme ──────────────────────────────────── */ /* ── Light Theme (Studio Light) ──────────────────── */
[data-theme='light'] { [data-theme='light'] {
--bg-primary: #fafafa; --bg-primary: #f5f5f0;
--bg-secondary: #f0f0f0; --bg-secondary: #eaeae5;
--bg-card: #ffffff; --bg-card: #ffffff;
--bg-card-hover: #f5f5f5; --bg-card-hover: #f0f0eb;
--bg-overlay: rgba(255, 255, 255, 0.92); --bg-overlay: rgba(245, 245, 240, 0.96);
--text-primary: #1a1a1a; --text-primary: #1a1a18;
--text-secondary: #666666; --text-secondary: #5a5a55;
--text-muted: #999999; --text-muted: #8a8a85;
--border-subtle: #e0e0e0; --border-subtle: #ddddd8;
--border-hover: #cccccc; --border-hover: #c5c5c0;
--accent: #b8960e; --accent: #c8000f;
--accent-dim: rgba(184, 150, 14, 0.12); --accent-dim: rgba(200, 0, 15, 0.08);
} }
/* ── Reset ────────────────────────────────────────── */ /* ── Reset ────────────────────────────────────────── */

View File

@@ -227,3 +227,29 @@
right: 8px; right: 8px;
} }
} }
/* ── Studio-Inspired Lightbox Defaults ───────────── */
.overlay {
background: rgba(0, 0, 0, 0.98);
}
.close,
.nav,
.counter {
opacity: 0;
transition: opacity 400ms ease;
}
.overlay:hover .close,
.overlay:hover .nav,
.overlay:hover .counter {
opacity: 1;
}
.imageContainer {
border-radius: 0;
}
.image {
border-radius: 0;
}

View File

@@ -41,7 +41,7 @@ function applyTheme(theme: Theme) {
// Initialize from localStorage on module load (client-side only) // Initialize from localStorage on module load (client-side only)
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
const stored = localStorage.getItem('theme') as Theme | null; const stored = localStorage.getItem('theme') as Theme | null;
currentTheme = stored || 'dark'; currentTheme = stored === 'light' ? 'light' : 'dark';
document.documentElement.setAttribute('data-theme', currentTheme); document.documentElement.setAttribute('data-theme', currentTheme);
} }

BIN
public/apple-touch-icon.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

BIN
public/icon-192.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 16 KiB

BIN
public/icon-512.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 63 KiB