'init'
This commit is contained in:
78
components/BookCover.tsx
Normal file
78
components/BookCover.tsx
Normal file
@@ -0,0 +1,78 @@
|
||||
import { ebookMeta } from "@/content/ebook";
|
||||
|
||||
export function BookCover({ className = "" }: { className?: string }) {
|
||||
return (
|
||||
<div
|
||||
className={`cover-float relative w-full max-w-[min(100%,300px)] sm:max-w-[320px] ${className}`}
|
||||
>
|
||||
<div
|
||||
className="absolute -inset-6 rounded-[2rem] bg-gradient-to-br from-teal-500/50 via-amber-400/40 to-sky-500/50 opacity-60 blur-2xl"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="relative aspect-[3/4] overflow-hidden rounded-2xl shadow-2xl ring-1 ring-white/25">
|
||||
<div
|
||||
className="absolute inset-0 bg-gradient-to-br from-teal-800 via-teal-700 to-sky-900"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 opacity-30"
|
||||
style={{
|
||||
backgroundImage:
|
||||
"repeating-linear-gradient(0deg, transparent, transparent 23px, rgba(255,255,255,0.06) 24px)",
|
||||
}}
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute inset-0 bg-[radial-gradient(ellipse_at_30%_12%,rgba(255,255,255,0.22),transparent_55%)]"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute bottom-0 left-0 right-0 h-1/2 bg-gradient-to-t from-black/50 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<div
|
||||
className="absolute left-0 top-0 h-full w-4 bg-gradient-to-r from-black/35 to-transparent"
|
||||
aria-hidden
|
||||
/>
|
||||
<div className="absolute right-5 top-6 opacity-20" aria-hidden>
|
||||
<GlobeMark className="h-24 w-24 text-white" />
|
||||
</div>
|
||||
<div className="relative flex h-full flex-col justify-between p-7 text-white sm:p-8">
|
||||
<div className="flex items-center justify-between gap-2">
|
||||
<p className="text-[10px] font-bold uppercase tracking-[0.28em] text-teal-200/90">
|
||||
Digital nomad
|
||||
</p>
|
||||
<span className="rounded-full border border-white/20 bg-white/10 px-2 py-0.5 font-mono text-[10px] text-white/80">
|
||||
EN
|
||||
</span>
|
||||
</div>
|
||||
<div>
|
||||
<h2 className="font-serif text-[1.7rem] font-medium leading-[1.1] tracking-tight sm:text-[1.9rem]">
|
||||
{ebookMeta.title}
|
||||
</h2>
|
||||
<p className="mt-3 text-xs leading-relaxed text-teal-100/85 sm:text-sm">
|
||||
{ebookMeta.subtitle}
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="font-mono text-[10px] tracking-wider text-white/50">
|
||||
48.8566° N · Work from anywhere
|
||||
</p>
|
||||
<p className="mt-2 text-xs font-medium text-white/70">
|
||||
{ebookMeta.author}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function GlobeMark({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg className={className} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="0.75">
|
||||
<circle cx="12" cy="12" r="10" />
|
||||
<path d="M2 12h20M12 2a15.3 15.3 0 014 10 15.3 15.3 0 01-4 10 15.3 15.3 0 01-4-10 15.3 15.3 0 014-10z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
64
components/ChapterContent.tsx
Normal file
64
components/ChapterContent.tsx
Normal file
@@ -0,0 +1,64 @@
|
||||
function parseInline(text: string) {
|
||||
const parts = text.split(/(\*\*[^*]+\*\*)/g);
|
||||
return parts.map((part, i) => {
|
||||
if (part.startsWith("**") && part.endsWith("**")) {
|
||||
return (
|
||||
<strong key={i}>{part.slice(2, -2)}</strong>
|
||||
);
|
||||
}
|
||||
return part;
|
||||
});
|
||||
}
|
||||
|
||||
export function ChapterContent({ content }: { content: string }) {
|
||||
const blocks = content.trim().split(/\n\n+/);
|
||||
|
||||
return (
|
||||
<>
|
||||
{blocks.map((block, index) => {
|
||||
const trimmed = block.trim();
|
||||
|
||||
if (trimmed.startsWith("> ")) {
|
||||
const quote = trimmed.replace(/^>\s?/gm, "").trim();
|
||||
return (
|
||||
<blockquote key={index}>
|
||||
<p>{parseInline(quote)}</p>
|
||||
</blockquote>
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("**") && trimmed.endsWith("**") && !trimmed.includes("\n")) {
|
||||
return (
|
||||
<p key={index} className="!mb-4 font-medium text-[var(--foreground)]">
|
||||
{parseInline(trimmed)}
|
||||
</p>
|
||||
);
|
||||
}
|
||||
|
||||
if (trimmed.startsWith("- ")) {
|
||||
const items = trimmed.split("\n").map((line) => line.replace(/^- /, ""));
|
||||
return (
|
||||
<ul key={index}>
|
||||
{items.map((item, i) => (
|
||||
<li key={i}>{parseInline(item)}</li>
|
||||
))}
|
||||
</ul>
|
||||
);
|
||||
}
|
||||
|
||||
if (/^\d+\.\s/.test(trimmed)) {
|
||||
const items = trimmed.split("\n").map((line) => line.replace(/^\d+\.\s/, ""));
|
||||
return (
|
||||
<ol key={index}>
|
||||
{items.map((item, i) => (
|
||||
<li key={i}>{parseInline(item)}</li>
|
||||
))}
|
||||
</ol>
|
||||
);
|
||||
}
|
||||
|
||||
return <p key={index}>{parseInline(trimmed)}</p>;
|
||||
})}
|
||||
</>
|
||||
);
|
||||
}
|
||||
50
components/ChapterNav.tsx
Normal file
50
components/ChapterNav.tsx
Normal file
@@ -0,0 +1,50 @@
|
||||
import Link from "next/link";
|
||||
import { chapters } from "@/content/ebook";
|
||||
|
||||
type ChapterNavProps = {
|
||||
chapterId: string;
|
||||
};
|
||||
|
||||
export function ChapterNav({ chapterId }: ChapterNavProps) {
|
||||
const index = chapters.findIndex((c) => c.id === chapterId);
|
||||
if (index < 0) return null;
|
||||
|
||||
const prev = index > 0 ? chapters[index - 1] : null;
|
||||
const next = index < chapters.length - 1 ? chapters[index + 1] : null;
|
||||
|
||||
return (
|
||||
<nav
|
||||
className="mt-12 flex flex-col gap-3 border-t border-[var(--border)] pt-8 sm:flex-row sm:justify-between"
|
||||
aria-label="Previous and next chapter"
|
||||
>
|
||||
{prev ? (
|
||||
<Link
|
||||
href={`#${prev.id}`}
|
||||
className="group glass-card flex flex-1 flex-col rounded-2xl px-5 py-4 transition-shadow hover:shadow-[var(--glow-sm)]"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
← Previous
|
||||
</span>
|
||||
<span className="mt-1 font-medium group-hover:text-[var(--accent)]">
|
||||
{prev.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : (
|
||||
<div className="hidden flex-1 sm:block" />
|
||||
)}
|
||||
{next ? (
|
||||
<Link
|
||||
href={`#${next.id}`}
|
||||
className="group glass-card flex flex-1 flex-col rounded-2xl px-5 py-4 text-left sm:text-right transition-shadow hover:shadow-[var(--glow-sm)]"
|
||||
>
|
||||
<span className="text-xs font-semibold uppercase tracking-wider text-[var(--muted)]">
|
||||
Next →
|
||||
</span>
|
||||
<span className="mt-1 font-medium group-hover:text-[var(--accent)]">
|
||||
{next.title}
|
||||
</span>
|
||||
</Link>
|
||||
) : null}
|
||||
</nav>
|
||||
);
|
||||
}
|
||||
54
components/FaqAccordion.tsx
Normal file
54
components/FaqAccordion.tsx
Normal file
@@ -0,0 +1,54 @@
|
||||
"use client";
|
||||
|
||||
import { useState } from "react";
|
||||
import { faq } from "@/content/ebook";
|
||||
|
||||
export function FaqAccordion() {
|
||||
const [openIndex, setOpenIndex] = useState<number | null>(0);
|
||||
|
||||
return (
|
||||
<div className="space-y-3">
|
||||
{faq.map((item, index) => {
|
||||
const isOpen = openIndex === index;
|
||||
return (
|
||||
<div key={item.question} className="glass-card overflow-hidden rounded-2xl">
|
||||
<button
|
||||
type="button"
|
||||
id={`faq-btn-${index}`}
|
||||
aria-expanded={isOpen}
|
||||
aria-controls={`faq-panel-${index}`}
|
||||
onClick={() => setOpenIndex(isOpen ? null : index)}
|
||||
className="flex w-full items-start justify-between gap-4 px-5 py-4 text-left sm:px-6 sm:py-5"
|
||||
>
|
||||
<span className="font-display text-base font-semibold leading-snug sm:text-lg">
|
||||
{item.question}
|
||||
</span>
|
||||
<span
|
||||
className={`mt-0.5 flex h-8 w-8 shrink-0 items-center justify-center rounded-full bg-[var(--gradient-1)]/10 text-[var(--accent)] transition-transform duration-200 ${
|
||||
isOpen ? "rotate-45" : ""
|
||||
}`}
|
||||
aria-hidden
|
||||
>
|
||||
+
|
||||
</span>
|
||||
</button>
|
||||
<div
|
||||
id={`faq-panel-${index}`}
|
||||
role="region"
|
||||
aria-labelledby={`faq-btn-${index}`}
|
||||
className={`grid transition-[grid-template-rows] duration-200 ${
|
||||
isOpen ? "grid-rows-[1fr]" : "grid-rows-[0fr]"
|
||||
}`}
|
||||
>
|
||||
<div className="overflow-hidden">
|
||||
<p className="border-t border-[var(--border)] px-5 pb-5 pt-0 text-[var(--muted)] leading-relaxed sm:px-6 sm:pb-6">
|
||||
{item.answer}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
25
components/HighlightIcon.tsx
Normal file
25
components/HighlightIcon.tsx
Normal file
@@ -0,0 +1,25 @@
|
||||
const paths: Record<string, string> = {
|
||||
globe:
|
||||
"M12 2a10 10 0 100 20 10 10 0 000-20zm0 0v20M2 12h20M4.5 7.5h15M4.5 16.5h15",
|
||||
map: "M9 20l-5.447-2.724A1 1 0 013 16.382V5.618a1 1 0 011.447-.894L9 7m0 13l6-3m-6 3V7m6 10l5.447 2.724A1 1 0 0021 18.382V7.618a1 1 0 00-1.447-.894L15 4m0 13V4m0 0L9 7",
|
||||
document:
|
||||
"M14 2H6a2 2 0 00-2 2v16a2 2 0 002 2h12a2 2 0 002-2V8zM14 2v6h6M16 13H8M16 17H8M10 9H8",
|
||||
compass:
|
||||
"M12 2v4M12 18v4M4.93 4.93l2.83 2.83M16.24 16.24l2.83 2.83M2 12h4M18 12h4M4.93 19.07l2.83-2.83M16.24 7.76l2.83-2.83M12 8a4 4 0 100 8 4 4 0 000-8z",
|
||||
};
|
||||
|
||||
export function HighlightIcon({ name }: { name: string }) {
|
||||
const d = paths[name] ?? paths.globe;
|
||||
return (
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden
|
||||
>
|
||||
<path d={d} strokeLinecap="round" strokeLinejoin="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
161
components/ReadSidebar.tsx
Normal file
161
components/ReadSidebar.tsx
Normal file
@@ -0,0 +1,161 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
import { tableOfContents } from "@/content/ebook";
|
||||
|
||||
export function ReadSidebar() {
|
||||
const [activeId, setActiveId] = useState(tableOfContents[0]?.id ?? "");
|
||||
const [mobileOpen, setMobileOpen] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
const sections = tableOfContents
|
||||
.map((item) => document.getElementById(item.id))
|
||||
.filter(Boolean) as HTMLElement[];
|
||||
|
||||
if (sections.length === 0) return;
|
||||
|
||||
const observer = new IntersectionObserver(
|
||||
(entries) => {
|
||||
const visible = entries
|
||||
.filter((e) => e.isIntersecting)
|
||||
.sort((a, b) => b.intersectionRatio - a.intersectionRatio);
|
||||
if (visible[0]?.target.id) {
|
||||
setActiveId(visible[0].target.id);
|
||||
}
|
||||
},
|
||||
{ rootMargin: "-30% 0px -55% 0px", threshold: [0, 0.25, 0.5, 1] }
|
||||
);
|
||||
|
||||
sections.forEach((el) => observer.observe(el));
|
||||
return () => observer.disconnect();
|
||||
}, []);
|
||||
|
||||
useEffect(() => {
|
||||
if (!mobileOpen) return;
|
||||
const prev = document.body.style.overflow;
|
||||
document.body.style.overflow = "hidden";
|
||||
return () => {
|
||||
document.body.style.overflow = prev;
|
||||
};
|
||||
}, [mobileOpen]);
|
||||
|
||||
const tocList = (
|
||||
<ul className="space-y-0.5">
|
||||
{tableOfContents.map((item, index) => {
|
||||
const isActive = activeId === item.id;
|
||||
return (
|
||||
<li key={item.id}>
|
||||
<a
|
||||
href={`#${item.id}`}
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className={`toc-link group flex gap-3 rounded-xl px-3 py-2.5 text-sm transition-colors ${
|
||||
isActive
|
||||
? "toc-link--active"
|
||||
: "text-[var(--muted)] hover:bg-[var(--gradient-1)]/5 hover:text-[var(--foreground)]"
|
||||
}`}
|
||||
>
|
||||
<span
|
||||
className={`toc-index flex h-7 w-7 shrink-0 items-center justify-center rounded-lg text-xs font-bold ${
|
||||
isActive
|
||||
? ""
|
||||
: "bg-[var(--gradient-1)]/15 text-[var(--accent)]"
|
||||
}`}
|
||||
>
|
||||
{index + 1}
|
||||
</span>
|
||||
<span className="min-w-0 leading-snug">
|
||||
<span className="block font-medium">{item.title}</span>
|
||||
{item.summary ? (
|
||||
<span className="mt-0.5 block text-xs font-normal opacity-80 line-clamp-2">
|
||||
{item.summary}
|
||||
</span>
|
||||
) : null}
|
||||
</span>
|
||||
</a>
|
||||
</li>
|
||||
);
|
||||
})}
|
||||
</ul>
|
||||
);
|
||||
|
||||
return (
|
||||
<>
|
||||
<aside className="hidden w-64 shrink-0 lg:block">
|
||||
<nav
|
||||
className="glass-card sticky top-[calc(var(--header-h)+1.25rem)] max-h-[calc(100vh-var(--header-h)-2.5rem)] overflow-y-auto rounded-2xl p-5"
|
||||
aria-label="Chapter navigation"
|
||||
>
|
||||
<p className="text-xs font-semibold uppercase tracking-[0.18em] text-[var(--accent)]">
|
||||
Chapters
|
||||
</p>
|
||||
<div className="mt-4">{tocList}</div>
|
||||
</nav>
|
||||
</aside>
|
||||
|
||||
<div className="fixed bottom-5 right-4 z-40 lg:hidden">
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(true)}
|
||||
className="flex h-12 items-center gap-2 rounded-full border border-[var(--border)] bg-[var(--surface-solid)] px-4 text-sm font-semibold text-[var(--foreground)] shadow-lg"
|
||||
aria-expanded={mobileOpen}
|
||||
aria-controls="mobile-toc-panel"
|
||||
>
|
||||
<ListIcon />
|
||||
Chapters
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{mobileOpen ? (
|
||||
<div
|
||||
className="fixed inset-0 z-50 lg:hidden"
|
||||
role="dialog"
|
||||
aria-modal="true"
|
||||
aria-labelledby="mobile-toc-title"
|
||||
>
|
||||
<button
|
||||
type="button"
|
||||
className="absolute inset-0 bg-black/50 backdrop-blur-sm"
|
||||
aria-label="Close chapter menu"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
/>
|
||||
<div
|
||||
id="mobile-toc-panel"
|
||||
className="absolute bottom-0 left-0 right-0 max-h-[80vh] overflow-hidden rounded-t-3xl border border-[var(--border)] bg-[var(--surface-solid)] shadow-2xl"
|
||||
>
|
||||
<div className="flex items-center justify-between border-b border-[var(--border)] px-5 py-4">
|
||||
<p
|
||||
id="mobile-toc-title"
|
||||
className="font-display text-sm font-bold"
|
||||
>
|
||||
Jump to chapter
|
||||
</p>
|
||||
<button
|
||||
type="button"
|
||||
onClick={() => setMobileOpen(false)}
|
||||
className="rounded-full px-3 py-1 text-sm text-[var(--muted)] hover:text-[var(--foreground)]"
|
||||
>
|
||||
Close
|
||||
</button>
|
||||
</div>
|
||||
<div className="overflow-y-auto px-3 py-4 pb-8">{tocList}</div>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
function ListIcon() {
|
||||
return (
|
||||
<svg
|
||||
className="h-4 w-4 text-[var(--accent)]"
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="2"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M8 6h13M8 12h13M8 18h13M3 6h.01M3 12h.01M3 18h.01" strokeLinecap="round" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
37
components/ReadingProgress.tsx
Normal file
37
components/ReadingProgress.tsx
Normal file
@@ -0,0 +1,37 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
export function ReadingProgress() {
|
||||
const [progress, setProgress] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
function onScroll() {
|
||||
const doc = document.documentElement;
|
||||
const scrollTop = doc.scrollTop;
|
||||
const scrollHeight = doc.scrollHeight - doc.clientHeight;
|
||||
const pct = scrollHeight > 0 ? (scrollTop / scrollHeight) * 100 : 0;
|
||||
setProgress(Math.min(100, Math.max(0, pct)));
|
||||
}
|
||||
|
||||
onScroll();
|
||||
window.addEventListener("scroll", onScroll, { passive: true });
|
||||
return () => window.removeEventListener("scroll", onScroll);
|
||||
}, []);
|
||||
|
||||
return (
|
||||
<div
|
||||
className="reading-progress"
|
||||
role="progressbar"
|
||||
aria-valuenow={Math.round(progress)}
|
||||
aria-valuemin={0}
|
||||
aria-valuemax={100}
|
||||
aria-label="Reading progress"
|
||||
>
|
||||
<div
|
||||
className="reading-progress__bar"
|
||||
style={{ width: `${progress}%` }}
|
||||
/>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
62
components/SiteHeader.tsx
Normal file
62
components/SiteHeader.tsx
Normal file
@@ -0,0 +1,62 @@
|
||||
import Link from "next/link";
|
||||
import { ThemeToggle } from "@/components/ThemeToggle";
|
||||
import { brand } from "@/content/ebook";
|
||||
|
||||
type SiteHeaderProps = {
|
||||
ctaHref?: string;
|
||||
ctaLabel?: string;
|
||||
showBack?: boolean;
|
||||
};
|
||||
|
||||
export function SiteHeader({
|
||||
ctaHref = "/read",
|
||||
ctaLabel = "Start reading",
|
||||
showBack = false,
|
||||
}: SiteHeaderProps) {
|
||||
return (
|
||||
<header className="sticky top-0 z-50 h-[var(--header-h)] border-b border-[var(--border)]/70 bg-[var(--background)]/80 backdrop-blur-xl">
|
||||
<div className="mx-auto flex h-full max-w-6xl items-center justify-between gap-4 px-4 sm:px-6 lg:px-8">
|
||||
{showBack ? (
|
||||
<Link
|
||||
href="/"
|
||||
className="flex shrink-0 items-center gap-1.5 text-sm font-medium text-[var(--muted)] transition-colors hover:text-[var(--foreground)] sm:hidden"
|
||||
>
|
||||
<span aria-hidden>←</span> Home
|
||||
</Link>
|
||||
) : null}
|
||||
<Link
|
||||
href="/"
|
||||
className={`group flex min-w-0 items-center gap-2.5 ${showBack ? "hidden sm:flex" : "flex"}`}
|
||||
>
|
||||
<span
|
||||
className="flex h-9 w-9 shrink-0 items-center justify-center rounded-xl bg-gradient-to-br from-[var(--gradient-1)] via-[var(--gradient-2)] to-[var(--gradient-3)] text-white shadow-lg shadow-teal-500/20"
|
||||
aria-hidden
|
||||
>
|
||||
<svg className="h-4 w-4" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2">
|
||||
<circle cx="12" cy="12" r="9" />
|
||||
<path d="M2 12h20M12 2c2.5 2.8 4 6 4 10s-1.5 7.2-4 10M12 2c-2.5 2.8-4 6-4 10s1.5 7.2 4 10" strokeLinecap="round" />
|
||||
</svg>
|
||||
</span>
|
||||
<span className="min-w-0">
|
||||
<span className="block truncate font-display text-sm font-bold tracking-tight text-[var(--foreground)] sm:text-base">
|
||||
{brand.name}
|
||||
</span>
|
||||
<span className="hidden truncate text-[10px] font-medium uppercase tracking-wider text-[var(--muted)] sm:block">
|
||||
{brand.tagline}
|
||||
</span>
|
||||
</span>
|
||||
</Link>
|
||||
|
||||
<div className="flex items-center gap-2 sm:gap-3">
|
||||
<ThemeToggle />
|
||||
<Link
|
||||
href={ctaHref}
|
||||
className="hidden h-10 items-center justify-center rounded-full bg-gradient-to-r from-[var(--gradient-1)] via-[var(--gradient-2)] to-[var(--gradient-3)] px-5 text-sm font-semibold text-white shadow-md shadow-teal-500/20 transition-transform hover:scale-[1.02] active:scale-[0.98] sm:inline-flex"
|
||||
>
|
||||
{ctaLabel}
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
</header>
|
||||
);
|
||||
}
|
||||
97
components/ThemeToggle.tsx
Normal file
97
components/ThemeToggle.tsx
Normal file
@@ -0,0 +1,97 @@
|
||||
"use client";
|
||||
|
||||
import { useEffect, useState } from "react";
|
||||
|
||||
type Theme = "light" | "dark";
|
||||
|
||||
function getPreferredTheme(): Theme {
|
||||
if (typeof window === "undefined") return "light";
|
||||
const stored = localStorage.getItem("theme");
|
||||
if (stored === "light" || stored === "dark") return stored;
|
||||
return window.matchMedia("(prefers-color-scheme: dark)").matches
|
||||
? "dark"
|
||||
: "light";
|
||||
}
|
||||
|
||||
function applyTheme(theme: Theme) {
|
||||
document.documentElement.classList.toggle("dark", theme === "dark");
|
||||
localStorage.setItem("theme", theme);
|
||||
}
|
||||
|
||||
export function ThemeToggle() {
|
||||
const [theme, setTheme] = useState<Theme>("light");
|
||||
const [mounted, setMounted] = useState(false);
|
||||
|
||||
useEffect(() => {
|
||||
setTheme(getPreferredTheme());
|
||||
setMounted(true);
|
||||
}, []);
|
||||
|
||||
function toggle() {
|
||||
const next: Theme = theme === "dark" ? "light" : "dark";
|
||||
setTheme(next);
|
||||
applyTheme(next);
|
||||
}
|
||||
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
onClick={toggle}
|
||||
aria-label={theme === "dark" ? "Switch to light mode" : "Switch to dark mode"}
|
||||
className="theme-toggle group relative flex h-10 w-10 items-center justify-center rounded-full border border-[var(--border)] bg-[var(--surface)]/80 text-[var(--foreground)] shadow-sm backdrop-blur-md transition-all hover:border-[var(--accent)]/40 hover:shadow-[var(--glow-sm)]"
|
||||
>
|
||||
<span className="sr-only">
|
||||
{mounted
|
||||
? theme === "dark"
|
||||
? "Light mode"
|
||||
: "Dark mode"
|
||||
: "Toggle theme"}
|
||||
</span>
|
||||
<SunIcon
|
||||
className={`absolute h-[18px] w-[18px] transition-all duration-300 ${
|
||||
mounted && theme === "dark"
|
||||
? "rotate-0 scale-100 opacity-100"
|
||||
: "rotate-90 scale-0 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
<MoonIcon
|
||||
className={`absolute h-[18px] w-[18px] transition-all duration-300 ${
|
||||
mounted && theme !== "dark"
|
||||
? "rotate-0 scale-100 opacity-100"
|
||||
: "-rotate-90 scale-0 opacity-0"
|
||||
}`}
|
||||
/>
|
||||
</button>
|
||||
);
|
||||
}
|
||||
|
||||
function SunIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden
|
||||
>
|
||||
<circle cx="12" cy="12" r="4" />
|
||||
<path d="M12 2v2M12 20v2M4.93 4.93l1.41 1.41M17.66 17.66l1.41 1.41M2 12h2M20 12h2M4.93 19.07l1.41-1.41M17.66 6.34l1.41-1.41" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
|
||||
function MoonIcon({ className }: { className?: string }) {
|
||||
return (
|
||||
<svg
|
||||
className={className}
|
||||
viewBox="0 0 24 24"
|
||||
fill="none"
|
||||
stroke="currentColor"
|
||||
strokeWidth="1.75"
|
||||
aria-hidden
|
||||
>
|
||||
<path d="M21 14.5A8.5 8.5 0 0 1 9.5 3 7 7 0 1 0 21 14.5z" />
|
||||
</svg>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user