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.
* 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<number | null>(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(() => {