This commit is contained in:
eric
2026-05-20 18:29:19 -05:00
parent 9f4bf1b28c
commit 6809f833f8
31 changed files with 5963 additions and 78 deletions

78
components/BookCover.tsx Normal file
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}

View 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
View 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>
);
}

View 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
View 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>
);
}

View 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>
);
}