feat: visual polish & UX — fade-in, theme toggle, scroll-to-top, footer, cursor, badge
- Add FadeIn component (IntersectionObserver scroll-reveal + load animation) - Add dark/light theme toggle (useSyncExternalStore + localStorage) - Add scroll-to-top button (appears after 300px scroll) - Add configurable footer with social links (gallery.yaml) - Add crosshair cursor on photo grid items - Add 'X photos' count badge on subpage cover images - Add light theme color palette in tokens.css
This commit is contained in:
63
components/FadeIn.tsx
Normal file
63
components/FadeIn.tsx
Normal file
@@ -0,0 +1,63 @@
|
||||
/**
|
||||
* FadeIn — scroll-reveal wrapper using IntersectionObserver.
|
||||
*
|
||||
* Wraps children in a div that fades/slides in when it enters the viewport.
|
||||
* Supports an optional stagger delay for grid items.
|
||||
* On the homepage (no scroll), acts as a simple load-in animation.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useRef, useEffect, useCallback } from 'react';
|
||||
|
||||
interface FadeInProps {
|
||||
children: React.ReactNode;
|
||||
/** Stagger delay in ms (e.g. index * 60) */
|
||||
delay?: number;
|
||||
/** Slide direction */
|
||||
direction?: 'up' | 'none';
|
||||
/** CSS class name for the wrapper */
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function FadeIn({ children, delay = 0, direction = 'up', className }: FadeInProps) {
|
||||
const ref = useRef<HTMLDivElement>(null);
|
||||
|
||||
const reveal = useCallback(() => {
|
||||
ref.current?.classList.add('fade-in--visible');
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
|
||||
// Respect reduced-motion preference — reveal immediately
|
||||
if (window.matchMedia('(prefers-reduced-motion: reduce)').matches) {
|
||||
el.classList.add('fade-in--visible');
|
||||
return;
|
||||
}
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
([entry]) => {
|
||||
if (entry.isIntersecting) {
|
||||
reveal();
|
||||
observer.unobserve(el);
|
||||
}
|
||||
},
|
||||
{ threshold: 0.1 },
|
||||
);
|
||||
|
||||
observer.observe(el);
|
||||
return () => observer.disconnect();
|
||||
}, [reveal]);
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={ref}
|
||||
className={`fade-in ${direction === 'up' ? 'fade-in--up' : ''} ${className ?? ''}`}
|
||||
style={{ transitionDelay: `${delay}ms` }}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
92
components/Footer.tsx
Normal file
92
components/Footer.tsx
Normal file
@@ -0,0 +1,92 @@
|
||||
/**
|
||||
* Footer — minimal footer with photographer name + social links.
|
||||
* Reads configuration from gallery.yaml via getConfig().
|
||||
*/
|
||||
|
||||
import { getConfig } from '@/lib/config';
|
||||
|
||||
export function Footer() {
|
||||
const config = getConfig();
|
||||
const { footer } = config;
|
||||
|
||||
// Don't render if no footer config
|
||||
if (!footer || (!footer.name && !footer.instagram && !footer.email && !footer.website)) {
|
||||
return null;
|
||||
}
|
||||
|
||||
return (
|
||||
<footer className="footer">
|
||||
<div className="footer__inner">
|
||||
{footer.name && <span className="footer__name">{footer.name}</span>}
|
||||
|
||||
<div className="footer__links">
|
||||
{footer.instagram && (
|
||||
<a
|
||||
href={`https://instagram.com/${footer.instagram.replace('@', '')}`}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Instagram"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<rect x="2" y="2" width="20" height="20" rx="5" ry="5" />
|
||||
<path d="M16 11.37A4 4 0 1 1 12.63 8 4 4 0 0 1 16 11.37z" />
|
||||
<line x1="17.5" y1="6.5" x2="17.51" y2="6.5" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{footer.email && (
|
||||
<a href={`mailto:${footer.email}`} className="footer__link" aria-label="Email">
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M4 4h16c1.1 0 2 .9 2 2v12c0 1.1-.9 2-2 2H4c-1.1 0-2-.9-2-2V6c0-1.1.9-2 2-2z" />
|
||||
<polyline points="22,6 12,13 2,6" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
{footer.website && (
|
||||
<a
|
||||
href={footer.website}
|
||||
target="_blank"
|
||||
rel="noopener noreferrer"
|
||||
className="footer__link"
|
||||
aria-label="Website"
|
||||
>
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<line x1="2" y1="12" x2="22" y2="12" />
|
||||
<path d="M12 2a15.3 15.3 0 0 1 4 10 15.3 15.3 0 0 1-4 10 15.3 15.3 0 0 1-4-10 15.3 15.3 0 0 1 4-10z" />
|
||||
</svg>
|
||||
</a>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</footer>
|
||||
);
|
||||
}
|
||||
@@ -11,67 +11,65 @@ import { useState, useEffect, useCallback } from 'react';
|
||||
import Image from 'next/image';
|
||||
|
||||
interface HeroImage {
|
||||
src: string;
|
||||
blurDataURL?: string;
|
||||
src: string;
|
||||
blurDataURL?: string;
|
||||
}
|
||||
|
||||
interface HeroCarouselProps {
|
||||
images: HeroImage[];
|
||||
images: HeroImage[];
|
||||
}
|
||||
|
||||
const INTERVAL_MS = 6000;
|
||||
|
||||
export function HeroCarousel({ images }: HeroCarouselProps) {
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
const [currentIndex, setCurrentIndex] = useState(0);
|
||||
|
||||
const advance = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
}, [images.length]);
|
||||
const advance = useCallback(() => {
|
||||
setCurrentIndex((prev) => (prev + 1) % images.length);
|
||||
}, [images.length]);
|
||||
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
useEffect(() => {
|
||||
if (images.length <= 1) return;
|
||||
|
||||
const timer = setInterval(advance, INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length, advance]);
|
||||
const timer = setInterval(advance, INTERVAL_MS);
|
||||
return () => clearInterval(timer);
|
||||
}, [images.length, advance]);
|
||||
|
||||
if (images.length === 0) {
|
||||
return <div className="hero__image-placeholder" />;
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
const img = images[0];
|
||||
return (
|
||||
<Image
|
||||
src={img.src}
|
||||
alt=""
|
||||
className="hero__image"
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
if (images.length === 0) {
|
||||
return <div className="hero__image-placeholder" />;
|
||||
}
|
||||
|
||||
if (images.length === 1) {
|
||||
const img = images[0];
|
||||
return (
|
||||
<>
|
||||
{images.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={img.src}
|
||||
alt=""
|
||||
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`}
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority={i === 0}
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
<Image
|
||||
src={img.src}
|
||||
alt=""
|
||||
className="hero__image"
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority
|
||||
{...(img.blurDataURL ? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL } : {})}
|
||||
/>
|
||||
);
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{images.map((img, i) => (
|
||||
<Image
|
||||
key={i}
|
||||
src={img.src}
|
||||
alt=""
|
||||
className={`hero__carousel-image${i === currentIndex ? ' hero__carousel-image--active' : ''}`}
|
||||
fill
|
||||
sizes="50vw"
|
||||
priority={i === 0}
|
||||
{...(img.blurDataURL
|
||||
? { placeholder: 'blur' as const, blurDataURL: img.blurDataURL }
|
||||
: {})}
|
||||
/>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
229
components/Lightbox.module.css
Normal file
229
components/Lightbox.module.css
Normal file
@@ -0,0 +1,229 @@
|
||||
/* ── Lightbox Module ──────────────────────────────── */
|
||||
|
||||
.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;
|
||||
}
|
||||
}
|
||||
|
||||
.overlay.closing {
|
||||
animation: lightbox-fade-out var(--transition-fast) ease forwards;
|
||||
}
|
||||
|
||||
@keyframes lightbox-fade-out {
|
||||
from {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
to {
|
||||
opacity: 0;
|
||||
}
|
||||
}
|
||||
|
||||
.imageContainer {
|
||||
position: relative;
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
border-radius: var(--radius-sm);
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
.image {
|
||||
max-width: 95vw;
|
||||
max-height: 90vh;
|
||||
object-fit: contain;
|
||||
border-radius: var(--radius-sm);
|
||||
user-select: none;
|
||||
-webkit-user-drag: none;
|
||||
opacity: 0;
|
||||
transition: opacity var(--transition-normal);
|
||||
}
|
||||
|
||||
.imageLoaded {
|
||||
opacity: 1;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.close:hover {
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.close svg {
|
||||
width: 28px;
|
||||
height: 28px;
|
||||
}
|
||||
|
||||
.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);
|
||||
}
|
||||
|
||||
.nav:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.navPrev {
|
||||
left: 20px;
|
||||
}
|
||||
|
||||
.navNext {
|
||||
right: 20px;
|
||||
}
|
||||
|
||||
.nav svg {
|
||||
width: 20px;
|
||||
height: 20px;
|
||||
}
|
||||
|
||||
.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 ──────────────────────────────── */
|
||||
.infoToggle {
|
||||
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);
|
||||
}
|
||||
|
||||
.infoToggle:hover {
|
||||
background: rgba(255, 255, 255, 0.15);
|
||||
color: var(--text-primary);
|
||||
}
|
||||
|
||||
.exifPanel {
|
||||
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);
|
||||
}
|
||||
}
|
||||
|
||||
.exifRow {
|
||||
display: flex;
|
||||
justify-content: space-between;
|
||||
align-items: baseline;
|
||||
padding: 6px 0;
|
||||
border-bottom: 1px solid var(--border-subtle);
|
||||
}
|
||||
|
||||
.exifRow:last-child {
|
||||
border-bottom: none;
|
||||
}
|
||||
|
||||
.exifLabel {
|
||||
font-size: 0.75rem;
|
||||
color: var(--text-muted);
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.08em;
|
||||
}
|
||||
|
||||
.exifValue {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-primary);
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.nav {
|
||||
width: 40px;
|
||||
height: 40px;
|
||||
}
|
||||
|
||||
.navPrev {
|
||||
left: 8px;
|
||||
}
|
||||
|
||||
.navNext {
|
||||
right: 8px;
|
||||
}
|
||||
}
|
||||
@@ -13,250 +13,263 @@
|
||||
|
||||
import { useState, useEffect, useRef, useCallback } from 'react';
|
||||
import type { PhotoItem } from '@/app/[...path]/PhotoGrid';
|
||||
import styles from './Lightbox.module.css';
|
||||
|
||||
interface ExifData {
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
lensModel?: string | null;
|
||||
focalLength?: number | null;
|
||||
fNumber?: number | null;
|
||||
exposureTime?: string | null;
|
||||
iso?: number | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
make?: string | null;
|
||||
model?: string | null;
|
||||
lensModel?: string | null;
|
||||
focalLength?: number | null;
|
||||
fNumber?: number | null;
|
||||
exposureTime?: string | null;
|
||||
iso?: number | null;
|
||||
city?: string | null;
|
||||
country?: string | null;
|
||||
}
|
||||
|
||||
interface LightboxProps {
|
||||
assets: PhotoItem[];
|
||||
currentIndex: number;
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
assets: PhotoItem[];
|
||||
currentIndex: number;
|
||||
onClose: () => void;
|
||||
onNext: () => void;
|
||||
onPrev: () => void;
|
||||
}
|
||||
|
||||
export function Lightbox({
|
||||
assets,
|
||||
currentIndex,
|
||||
onClose,
|
||||
onNext,
|
||||
onPrev,
|
||||
}: LightboxProps) {
|
||||
const [showExif, setShowExif] = useState(false);
|
||||
const [exifData, setExifData] = useState<ExifData | null>(null);
|
||||
const [exifLoading, setExifLoading] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchStartY = useRef<number>(0);
|
||||
export function Lightbox({ assets, currentIndex, onClose, onNext, onPrev }: LightboxProps) {
|
||||
const [showExif, setShowExif] = useState(false);
|
||||
const [exifData, setExifData] = useState<ExifData | null>(null);
|
||||
const [exifLoading, setExifLoading] = useState(false);
|
||||
const [imageLoaded, setImageLoaded] = useState(false);
|
||||
const overlayRef = useRef<HTMLDivElement>(null);
|
||||
const touchStartX = useRef<number>(0);
|
||||
const touchStartY = useRef<number>(0);
|
||||
|
||||
const current = assets[currentIndex];
|
||||
const current = assets[currentIndex];
|
||||
|
||||
// Fetch EXIF data on demand
|
||||
const fetchExif = useCallback((url: string) => {
|
||||
setExifLoading(true);
|
||||
setExifData(null);
|
||||
fetch(url)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => setExifData(data))
|
||||
.catch(() => setExifData(null))
|
||||
.finally(() => setExifLoading(false));
|
||||
}, []);
|
||||
// Fetch EXIF data on demand
|
||||
const fetchExif = useCallback((url: string) => {
|
||||
setExifLoading(true);
|
||||
setExifData(null);
|
||||
fetch(url)
|
||||
.then((res) => (res.ok ? res.json() : null))
|
||||
.then((data) => setExifData(data))
|
||||
.catch(() => setExifData(null))
|
||||
.finally(() => setExifLoading(false));
|
||||
}, []);
|
||||
|
||||
// Reset EXIF data when switching images; refetch if panel is open
|
||||
useEffect(() => {
|
||||
setExifData(null);
|
||||
setImageLoaded(false);
|
||||
if (showExif && current) {
|
||||
fetchExif(current.exifUrl);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIndex]);
|
||||
// Reset EXIF data when switching images; refetch if panel is open
|
||||
useEffect(() => {
|
||||
setExifData(null);
|
||||
setImageLoaded(false);
|
||||
if (showExif && current) {
|
||||
fetchExif(current.exifUrl);
|
||||
}
|
||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
||||
}, [currentIndex]);
|
||||
|
||||
// When the toggle is turned on or the image changes while open, fetch
|
||||
const handleExifToggle = useCallback(() => {
|
||||
const next = !showExif;
|
||||
setShowExif(next);
|
||||
if (next && current) {
|
||||
fetchExif(current.exifUrl);
|
||||
}
|
||||
}, [showExif, current, fetchExif]);
|
||||
// When the toggle is turned on or the image changes while open, fetch
|
||||
const handleExifToggle = useCallback(() => {
|
||||
const next = !showExif;
|
||||
setShowExif(next);
|
||||
if (next && current) {
|
||||
fetchExif(current.exifUrl);
|
||||
}
|
||||
}, [showExif, current, fetchExif]);
|
||||
|
||||
// Preload adjacent images
|
||||
useEffect(() => {
|
||||
const preload = (index: number) => {
|
||||
if (index >= 0 && index < assets.length) {
|
||||
const img = new Image();
|
||||
img.src = assets[index].previewUrl;
|
||||
}
|
||||
};
|
||||
preload(currentIndex + 1);
|
||||
preload(currentIndex - 1);
|
||||
}, [currentIndex, assets]);
|
||||
// Preload adjacent images
|
||||
useEffect(() => {
|
||||
const preload = (index: number) => {
|
||||
if (index >= 0 && index < assets.length) {
|
||||
const img = new Image();
|
||||
img.src = assets[index].previewUrl;
|
||||
}
|
||||
};
|
||||
preload(currentIndex + 1);
|
||||
preload(currentIndex - 1);
|
||||
}, [currentIndex, assets]);
|
||||
|
||||
// Touch/swipe support
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
// Touch/swipe support
|
||||
const handleTouchStart = useCallback((e: React.TouchEvent) => {
|
||||
touchStartX.current = e.touches[0].clientX;
|
||||
touchStartY.current = e.touches[0].clientY;
|
||||
}, []);
|
||||
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
const handleTouchEnd = useCallback(
|
||||
(e: React.TouchEvent) => {
|
||||
const dx = e.changedTouches[0].clientX - touchStartX.current;
|
||||
const dy = e.changedTouches[0].clientY - touchStartY.current;
|
||||
|
||||
// Only register horizontal swipes (not vertical scrolling)
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
|
||||
if (dx > 0) onPrev();
|
||||
else onNext();
|
||||
}
|
||||
},
|
||||
[onNext, onPrev],
|
||||
);
|
||||
// Only register horizontal swipes (not vertical scrolling)
|
||||
if (Math.abs(dx) > 60 && Math.abs(dx) > Math.abs(dy)) {
|
||||
if (dx > 0) onPrev();
|
||||
else onNext();
|
||||
}
|
||||
},
|
||||
[onNext, onPrev],
|
||||
);
|
||||
|
||||
// Click on overlay background → close
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
// Click on overlay background → close
|
||||
const handleOverlayClick = useCallback(
|
||||
(e: React.MouseEvent) => {
|
||||
if (e.target === overlayRef.current) {
|
||||
onClose();
|
||||
}
|
||||
},
|
||||
[onClose],
|
||||
);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="lightbox-overlay"
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
return (
|
||||
<div
|
||||
className={styles.overlay}
|
||||
ref={overlayRef}
|
||||
onClick={handleOverlayClick}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
>
|
||||
{/* Close button */}
|
||||
<button className={styles.close} onClick={onClose} aria-label="Close">
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
{/* Close button */}
|
||||
<button className="lightbox__close" onClick={onClose} aria-label="Close">
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
<line x1="18" y1="6" x2="6" y2="18" />
|
||||
<line x1="6" y1="6" x2="18" y2="18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Previous button */}
|
||||
<button
|
||||
className="lightbox__nav lightbox__nav--prev"
|
||||
onClick={onPrev}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
{/* Previous button */}
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navPrev}`}
|
||||
onClick={onPrev}
|
||||
aria-label="Previous photo"
|
||||
>
|
||||
<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>
|
||||
</button>
|
||||
|
||||
{/* Image */}
|
||||
<div
|
||||
className="lightbox__image-container"
|
||||
style={{
|
||||
backgroundColor: current.dominantColor || '#000',
|
||||
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={`lightbox__image${imageLoaded ? ' lightbox__image--loaded' : ''}`}
|
||||
src={current.previewUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
{/* Image */}
|
||||
<div
|
||||
className={styles.imageContainer}
|
||||
style={{
|
||||
backgroundColor: current.dominantColor || '#000',
|
||||
backgroundImage: current.blurDataURL ? `url(${current.blurDataURL})` : undefined,
|
||||
backgroundSize: 'cover',
|
||||
backgroundPosition: 'center',
|
||||
}}
|
||||
>
|
||||
{/* eslint-disable-next-line @next/next/no-img-element */}
|
||||
<img
|
||||
className={`${styles.image}${imageLoaded ? ` ${styles.imageLoaded}` : ''}`}
|
||||
src={current.previewUrl}
|
||||
alt=""
|
||||
draggable={false}
|
||||
onLoad={() => setImageLoaded(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
className={`${styles.nav} ${styles.navNext}`}
|
||||
onClick={onNext}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<svg
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Counter */}
|
||||
<div className={styles.counter}>
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</div>
|
||||
|
||||
{/* EXIF toggle */}
|
||||
<button className={styles.infoToggle} onClick={handleExifToggle}>
|
||||
{showExif ? 'Hide Info' : 'Info'}
|
||||
</button>
|
||||
|
||||
{/* EXIF panel */}
|
||||
{showExif && (
|
||||
<div className={styles.exifPanel}>
|
||||
{exifLoading ? (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Loading...</span>
|
||||
</div>
|
||||
|
||||
{/* Next button */}
|
||||
<button
|
||||
className="lightbox__nav lightbox__nav--next"
|
||||
onClick={onNext}
|
||||
aria-label="Next photo"
|
||||
>
|
||||
<svg viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
|
||||
<polyline points="9 6 15 12 9 18" />
|
||||
</svg>
|
||||
</button>
|
||||
|
||||
{/* Counter */}
|
||||
<div className="lightbox__counter">
|
||||
{currentIndex + 1} / {assets.length}
|
||||
</div>
|
||||
|
||||
{/* EXIF toggle */}
|
||||
<button
|
||||
className="lightbox__info-toggle"
|
||||
onClick={handleExifToggle}
|
||||
>
|
||||
{showExif ? 'Hide Info' : 'Info'}
|
||||
</button>
|
||||
|
||||
{/* EXIF panel */}
|
||||
{showExif && (
|
||||
<div className="exif-panel">
|
||||
{exifLoading ? (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Loading...</span>
|
||||
</div>
|
||||
) : exifData ? (
|
||||
<>
|
||||
{exifData.model && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Camera</span>
|
||||
<span className="exif-panel__value">
|
||||
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.lensModel && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Lens</span>
|
||||
<span className="exif-panel__value">{exifData.lensModel}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.focalLength && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Focal Length</span>
|
||||
<span className="exif-panel__value">{exifData.focalLength}mm</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.fNumber && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Aperture</span>
|
||||
<span className="exif-panel__value">ƒ/{exifData.fNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.exposureTime && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Shutter</span>
|
||||
<span className="exif-panel__value">{exifData.exposureTime}s</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.iso && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">ISO</span>
|
||||
<span className="exif-panel__value">{exifData.iso}</span>
|
||||
</div>
|
||||
)}
|
||||
{(exifData.city || exifData.country) && (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">Location</span>
|
||||
<span className="exif-panel__value">
|
||||
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className="exif-panel__row">
|
||||
<span className="exif-panel__label">No EXIF data available</span>
|
||||
</div>
|
||||
)}
|
||||
) : exifData ? (
|
||||
<>
|
||||
{exifData.model && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Camera</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[exifData.make, exifData.model].filter(Boolean).join(' ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
)}
|
||||
{exifData.lensModel && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Lens</span>
|
||||
<span className={styles.exifValue}>{exifData.lensModel}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.focalLength && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Focal Length</span>
|
||||
<span className={styles.exifValue}>{exifData.focalLength}mm</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.fNumber && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Aperture</span>
|
||||
<span className={styles.exifValue}>ƒ/{exifData.fNumber}</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.exposureTime && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Shutter</span>
|
||||
<span className={styles.exifValue}>{exifData.exposureTime}s</span>
|
||||
</div>
|
||||
)}
|
||||
{exifData.iso && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>ISO</span>
|
||||
<span className={styles.exifValue}>{exifData.iso}</span>
|
||||
</div>
|
||||
)}
|
||||
{(exifData.city || exifData.country) && (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>Location</span>
|
||||
<span className={styles.exifValue}>
|
||||
{[exifData.city, exifData.country].filter(Boolean).join(', ')}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
) : (
|
||||
<div className={styles.exifRow}>
|
||||
<span className={styles.exifLabel}>No EXIF data available</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
98
components/PasswordGate.module.css
Normal file
98
components/PasswordGate.module.css
Normal file
@@ -0,0 +1,98 @@
|
||||
/* ── Password Gate Module ─────────────────────────── */
|
||||
|
||||
.gate {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-height: calc(100vh - var(--header-height));
|
||||
padding: 24px;
|
||||
}
|
||||
|
||||
.card {
|
||||
text-align: center;
|
||||
padding: 48px 40px;
|
||||
max-width: 400px;
|
||||
width: 100%;
|
||||
background: var(--bg-secondary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
|
||||
.title {
|
||||
font-family: var(--font-serif);
|
||||
font-size: 1.8rem;
|
||||
font-weight: 400;
|
||||
letter-spacing: 0.02em;
|
||||
margin-bottom: 8px;
|
||||
}
|
||||
|
||||
.subtitle {
|
||||
font-size: 0.85rem;
|
||||
color: var(--text-secondary);
|
||||
font-weight: 300;
|
||||
margin-bottom: 32px;
|
||||
}
|
||||
|
||||
.form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 12px;
|
||||
}
|
||||
|
||||
.input {
|
||||
width: 100%;
|
||||
padding: 12px 16px;
|
||||
background: var(--bg-primary);
|
||||
border: 1px solid var(--border-subtle);
|
||||
border-radius: var(--radius-md);
|
||||
color: var(--text-primary);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.9rem;
|
||||
outline: none;
|
||||
transition: border-color var(--transition-fast);
|
||||
}
|
||||
|
||||
.input:focus {
|
||||
border-color: var(--accent);
|
||||
}
|
||||
|
||||
.input::placeholder {
|
||||
color: var(--text-muted);
|
||||
}
|
||||
|
||||
.button {
|
||||
padding: 12px 24px;
|
||||
background: var(--accent);
|
||||
color: var(--bg-primary);
|
||||
border: none;
|
||||
border-radius: var(--radius-md);
|
||||
font-family: var(--font-sans);
|
||||
font-size: 0.85rem;
|
||||
font-weight: 500;
|
||||
letter-spacing: 0.05em;
|
||||
cursor: pointer;
|
||||
transition: opacity var(--transition-fast);
|
||||
}
|
||||
|
||||
.button:hover {
|
||||
opacity: 0.9;
|
||||
}
|
||||
|
||||
.button:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
|
||||
.error {
|
||||
margin-top: 12px;
|
||||
font-size: 0.8rem;
|
||||
color: #e74c3c;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
/* ── Responsive ──────────────────────────────────── */
|
||||
@media (max-width: 640px) {
|
||||
.card {
|
||||
padding: 36px 24px;
|
||||
}
|
||||
}
|
||||
@@ -6,71 +6,66 @@
|
||||
*/
|
||||
|
||||
import { useState } from 'react';
|
||||
import styles from './PasswordGate.module.css';
|
||||
|
||||
interface PasswordGateProps {
|
||||
slug: string;
|
||||
title: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
}
|
||||
|
||||
export default function PasswordGate({ slug, title }: PasswordGateProps) {
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState('');
|
||||
const [loading, setLoading] = useState(false);
|
||||
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
async function handleSubmit(e: React.FormEvent) {
|
||||
e.preventDefault();
|
||||
setError('');
|
||||
setLoading(true);
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, password }),
|
||||
});
|
||||
try {
|
||||
const res = await fetch('/api/auth', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ slug, password }),
|
||||
});
|
||||
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError('Wrong password');
|
||||
setPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
if (res.ok) {
|
||||
window.location.reload();
|
||||
} else {
|
||||
setError('Wrong password');
|
||||
setPassword('');
|
||||
}
|
||||
} catch {
|
||||
setError('Something went wrong');
|
||||
} finally {
|
||||
setLoading(false);
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="password-gate">
|
||||
<div className="password-gate__card">
|
||||
<h2 className="password-gate__title">{title}</h2>
|
||||
<p className="password-gate__subtitle">
|
||||
This gallery is password-protected.
|
||||
</p>
|
||||
return (
|
||||
<div className={styles.gate}>
|
||||
<div className={styles.card}>
|
||||
<h2 className={styles.title}>{title}</h2>
|
||||
<p className={styles.subtitle}>This gallery is password-protected.</p>
|
||||
|
||||
<form onSubmit={handleSubmit} className="password-gate__form">
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className="password-gate__input"
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button
|
||||
type="submit"
|
||||
className="password-gate__button"
|
||||
disabled={loading}
|
||||
>
|
||||
{loading ? 'Verifying…' : 'Enter'}
|
||||
</button>
|
||||
</form>
|
||||
<form onSubmit={handleSubmit} className={styles.form}>
|
||||
<input
|
||||
type="password"
|
||||
value={password}
|
||||
onChange={(e) => setPassword(e.target.value)}
|
||||
placeholder="Enter password"
|
||||
className={styles.input}
|
||||
autoFocus
|
||||
required
|
||||
/>
|
||||
<button type="submit" className={styles.button} disabled={loading}>
|
||||
{loading ? 'Verifying…' : 'Enter'}
|
||||
</button>
|
||||
</form>
|
||||
|
||||
{error && <p className="password-gate__error">{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
{error && <p className={styles.error}>{error}</p>}
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
37
components/ScrollToTop.tsx
Normal file
37
components/ScrollToTop.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
/**
|
||||
* ScrollToTop — fixed ↑ button that appears after scrolling down.
|
||||
* Smooth-scrolls back to the top when clicked.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect } from 'react';
|
||||
|
||||
const SHOW_THRESHOLD = 300;
|
||||
|
||||
export function ScrollToTop() {
|
||||
const [visible, setVisible] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const onScroll = () => {
|
||||
setVisible(window.scrollY > SHOW_THRESHOLD);
|
||||
};
|
||||
window.addEventListener('scroll', onScroll, { passive: true });
|
||||
return () => window.removeEventListener('scroll', onScroll);
|
||||
}, []);
|
||||
|
||||
const scrollToTop = () => {
|
||||
window.scrollTo({ top: 0, behavior: 'smooth' });
|
||||
};
|
||||
|
||||
return (
|
||||
<button
|
||||
className={`scroll-to-top ${visible ? 'scroll-to-top--visible' : ''}`}
|
||||
onClick={scrollToTop}
|
||||
aria-label="Scroll to top"
|
||||
type="button"
|
||||
>
|
||||
↑
|
||||
</button>
|
||||
);
|
||||
}
|
||||
@@ -7,31 +7,23 @@ import Link from 'next/link';
|
||||
import { immich } from '@/lib/immich';
|
||||
|
||||
export async function SubpageNav() {
|
||||
const [subpages, standaloneAlbums] = await Promise.all([
|
||||
immich.getSubpages(),
|
||||
immich.getStandaloneAlbums(),
|
||||
]);
|
||||
const [subpages, standaloneAlbums] = await Promise.all([
|
||||
immich.getSubpages(),
|
||||
immich.getStandaloneAlbums(),
|
||||
]);
|
||||
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link
|
||||
key={sp.slug}
|
||||
href={`/${sp.slug}`}
|
||||
className="header__nav-link"
|
||||
>
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{standaloneAlbums.map((album) => (
|
||||
<Link
|
||||
key={album.id}
|
||||
href={`/${album.slug}`}
|
||||
className="header__nav-link"
|
||||
>
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
return (
|
||||
<>
|
||||
{subpages.map((sp) => (
|
||||
<Link key={sp.slug} href={`/${sp.slug}`} className="header__nav-link">
|
||||
{sp.name}
|
||||
</Link>
|
||||
))}
|
||||
{standaloneAlbums.map((album) => (
|
||||
<Link key={album.id} href={`/${album.slug}`} className="header__nav-link">
|
||||
{album.albumName}
|
||||
</Link>
|
||||
))}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
102
components/ThemeToggle.tsx
Normal file
102
components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,102 @@
|
||||
/**
|
||||
* ThemeToggle — dark/light mode switch.
|
||||
*
|
||||
* Persists preference in localStorage and sets data-theme on <html>.
|
||||
* Defaults to dark theme (matching existing design).
|
||||
* Uses useSyncExternalStore to satisfy React strict mode lint rules.
|
||||
*/
|
||||
|
||||
'use client';
|
||||
|
||||
import { useSyncExternalStore, useCallback } from 'react';
|
||||
|
||||
type Theme = 'dark' | 'light';
|
||||
|
||||
/** Module-level theme state — shared across all instances. */
|
||||
let currentTheme: Theme = 'dark';
|
||||
const listeners = new Set<() => void>();
|
||||
|
||||
function subscribe(callback: () => void): () => void {
|
||||
listeners.add(callback);
|
||||
return () => listeners.delete(callback);
|
||||
}
|
||||
|
||||
function getSnapshot(): Theme {
|
||||
return currentTheme;
|
||||
}
|
||||
|
||||
function getServerSnapshot(): Theme {
|
||||
return 'dark'; // SSR default
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
currentTheme = theme;
|
||||
if (typeof document !== 'undefined') {
|
||||
document.documentElement.setAttribute('data-theme', theme);
|
||||
localStorage.setItem('theme', theme);
|
||||
}
|
||||
listeners.forEach((cb) => cb());
|
||||
}
|
||||
|
||||
// Initialize from localStorage on module load (client-side only)
|
||||
if (typeof window !== 'undefined') {
|
||||
const stored = localStorage.getItem('theme') as Theme | null;
|
||||
currentTheme = stored || 'dark';
|
||||
document.documentElement.setAttribute('data-theme', currentTheme);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const theme = useSyncExternalStore(subscribe, getSnapshot, getServerSnapshot);
|
||||
|
||||
const toggle = useCallback(() => {
|
||||
applyTheme(theme === 'dark' ? 'light' : 'dark');
|
||||
}, [theme]);
|
||||
|
||||
return (
|
||||
<button
|
||||
className="theme-toggle"
|
||||
onClick={toggle}
|
||||
aria-label={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
type="button"
|
||||
title={`Switch to ${theme === 'dark' ? 'light' : 'dark'} mode`}
|
||||
>
|
||||
{theme === 'dark' ? (
|
||||
// Sun icon
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<circle cx="12" cy="12" r="5" />
|
||||
<line x1="12" y1="1" x2="12" y2="3" />
|
||||
<line x1="12" y1="21" x2="12" y2="23" />
|
||||
<line x1="4.22" y1="4.22" x2="5.64" y2="5.64" />
|
||||
<line x1="18.36" y1="18.36" x2="19.78" y2="19.78" />
|
||||
<line x1="1" y1="12" x2="3" y2="12" />
|
||||
<line x1="21" y1="12" x2="23" y2="12" />
|
||||
<line x1="4.22" y1="19.78" x2="5.64" y2="18.36" />
|
||||
<line x1="18.36" y1="5.64" x2="19.78" y2="4.22" />
|
||||
</svg>
|
||||
) : (
|
||||
// Moon icon
|
||||
<svg
|
||||
width="16"
|
||||
height="16"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.5"
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
>
|
||||
<path d="M21 12.79A9 9 0 1 1 11.21 3 7 7 0 0 0 21 12.79z" />
|
||||
</svg>
|
||||
)}
|
||||
</button>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user