diff --git a/app/[...path]/PhotoGrid.tsx b/app/[...path]/PhotoGrid.tsx index a2ea7da..c58ba27 100644 --- a/app/[...path]/PhotoGrid.tsx +++ b/app/[...path]/PhotoGrid.tsx @@ -1,11 +1,14 @@ /** * PhotoGrid — client component wrapping the masonry grid and lightbox. * 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'; -import { useState, useCallback, useEffect } from 'react'; +import { useState, useCallback, useEffect, useRef } from 'react'; import Image from 'next/image'; import { Lightbox } from '@/components/Lightbox'; import { FadeIn } from '@/components/FadeIn'; @@ -33,15 +36,73 @@ interface PhotoGridProps { 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) { const [lightboxIndex, setLightboxIndex] = useState(null); + const isInitialized = useRef(false); - const openLightbox = useCallback((index: number) => { - setLightboxIndex(index); - }, []); + // ── On mount: check URL hash for a deep-link ────────────────── + 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(() => { setLightboxIndex(null); + // Remove hash from URL cleanly + window.history.pushState(null, '', window.location.pathname + window.location.search); }, []); const goNext = useCallback(() => { diff --git a/app/favicon.ico b/app/favicon.ico deleted file mode 100644 index 718d6fe..0000000 Binary files a/app/favicon.ico and /dev/null differ diff --git a/app/globals.css b/app/globals.css index 6c70e28..02d7959 100644 --- a/app/globals.css +++ b/app/globals.css @@ -5,7 +5,7 @@ position: sticky; top: 0; z-index: 100; - background: rgba(10, 10, 10, 0.9); + background: rgba(26, 26, 26, 0.9); backdrop-filter: blur(20px); -webkit-backdrop-filter: blur(20px); border-bottom: 1px solid var(--border-subtle); @@ -13,7 +13,7 @@ } [data-theme='light'] .header { - background: rgba(250, 250, 250, 0.9); + background: rgba(245, 245, 240, 0.9); } .header__nav { @@ -598,6 +598,182 @@ 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 ───────────────────────────────────── */ @media (max-width: 1024px) { .photo-grid { @@ -640,7 +816,7 @@ } .main { - padding: 24px 16px 64px; + padding: 32px 20px 80px; } .header__nav-link { @@ -659,4 +835,8 @@ .subpage-grid__item-overlay { opacity: 1; } + + .photo-grid__item { + padding: 6px 6px 18px; + } } \ No newline at end of file diff --git a/app/icon.png b/app/icon.png new file mode 100644 index 0000000..2a7720a Binary files /dev/null and b/app/icon.png differ diff --git a/app/layout.tsx b/app/layout.tsx index 87be027..4d14f86 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -17,6 +17,9 @@ const siteDescription = 'A curated photography portfolio'; export const metadata: Metadata = { title: siteTitle, description: siteDescription, + icons: { + apple: '/apple-touch-icon.png', + }, openGraph: { title: siteTitle, description: siteDescription, diff --git a/app/tokens.css b/app/tokens.css index 54a1442..a198c04 100644 --- a/app/tokens.css +++ b/app/tokens.css @@ -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 { - --bg-primary: #0a0a0a; - --bg-secondary: #111111; - --bg-card: #161616; - --bg-card-hover: #1a1a1a; - --bg-overlay: rgba(0, 0, 0, 0.92); + --bg-primary: #1a1a1a; + --bg-secondary: #141414; + --bg-card: #1e1e1e; + --bg-card-hover: #242424; + --bg-overlay: rgba(0, 0, 0, 0.98); - --text-primary: #f0f0f0; - --text-secondary: #999999; - --text-muted: #666666; + --text-primary: #f5f5f0; + --text-secondary: #a0a09a; + --text-muted: #706f6a; - --border-subtle: #222222; - --border-hover: #333333; + --border-subtle: #2a2a28; + --border-hover: #3a3a38; - --accent: #d4af37; - --accent-dim: rgba(212, 175, 55, 0.15); + --accent: #e60012; + --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-caption: 'EB Garamond', Georgia, serif; - --radius-sm: 4px; - --radius-md: 8px; - --radius-lg: 12px; + --radius-sm: 0px; + --radius-md: 0px; + --radius-lg: 0px; - --transition-fast: 150ms ease; - --transition-normal: 300ms ease; - --transition-slow: 500ms ease; + --transition-fast: 200ms ease; + --transition-normal: 400ms ease; + --transition-slow: 600ms ease; --max-width: 1400px; --header-height: 64px; } -/* ── Light Theme ──────────────────────────────────── */ +/* ── Light Theme (Studio Light) ──────────────────── */ [data-theme='light'] { - --bg-primary: #fafafa; - --bg-secondary: #f0f0f0; + --bg-primary: #f5f5f0; + --bg-secondary: #eaeae5; --bg-card: #ffffff; - --bg-card-hover: #f5f5f5; - --bg-overlay: rgba(255, 255, 255, 0.92); + --bg-card-hover: #f0f0eb; + --bg-overlay: rgba(245, 245, 240, 0.96); - --text-primary: #1a1a1a; - --text-secondary: #666666; - --text-muted: #999999; + --text-primary: #1a1a18; + --text-secondary: #5a5a55; + --text-muted: #8a8a85; - --border-subtle: #e0e0e0; - --border-hover: #cccccc; + --border-subtle: #ddddd8; + --border-hover: #c5c5c0; - --accent: #b8960e; - --accent-dim: rgba(184, 150, 14, 0.12); + --accent: #c8000f; + --accent-dim: rgba(200, 0, 15, 0.08); } /* ── Reset ────────────────────────────────────────── */ diff --git a/components/Lightbox.module.css b/components/Lightbox.module.css index d48fb83..ad09bca 100644 --- a/components/Lightbox.module.css +++ b/components/Lightbox.module.css @@ -227,3 +227,29 @@ 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; +} \ No newline at end of file diff --git a/components/ThemeToggle.tsx b/components/ThemeToggle.tsx index ce3af8d..b8820fc 100644 --- a/components/ThemeToggle.tsx +++ b/components/ThemeToggle.tsx @@ -41,7 +41,7 @@ function applyTheme(theme: Theme) { // Initialize from localStorage on module load (client-side only) if (typeof window !== 'undefined') { const stored = localStorage.getItem('theme') as Theme | null; - currentTheme = stored || 'dark'; + currentTheme = stored === 'light' ? 'light' : 'dark'; document.documentElement.setAttribute('data-theme', currentTheme); } diff --git a/public/apple-touch-icon.png b/public/apple-touch-icon.png new file mode 100644 index 0000000..d66700e Binary files /dev/null and b/public/apple-touch-icon.png differ diff --git a/public/icon-192.png b/public/icon-192.png new file mode 100644 index 0000000..f28f375 Binary files /dev/null and b/public/icon-192.png differ diff --git a/public/icon-512.png b/public/icon-512.png new file mode 100644 index 0000000..6880399 Binary files /dev/null and b/public/icon-512.png differ