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:
@@ -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 ──────────────────
|
||||||
|
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);
|
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(() => {
|
||||||
|
|||||||
BIN
app/favicon.ico
BIN
app/favicon.ico
Binary file not shown.
|
Before Width: | Height: | Size: 25 KiB |
186
app/globals.css
186
app/globals.css
@@ -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
BIN
app/icon.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 1.4 KiB |
@@ -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,
|
||||||
|
|||||||
@@ -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 ────────────────────────────────────────── */
|
||||||
|
|||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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
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
BIN
public/icon-192.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 16 KiB |
BIN
public/icon-512.png
Normal file
BIN
public/icon-512.png
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 63 KiB |
Reference in New Issue
Block a user