Update zola.toml to include site title, description, theme, and additional URLs for navigation

This commit is contained in:
Hananoshika Yomaru 2026-05-18 21:06:12 -07:00
parent 5548bfce90
commit ab87bc2bd1
59 changed files with 4068 additions and 1 deletions

View file

@ -0,0 +1,25 @@
const MARQUEE_TEXT =
"Every human being deserves free high quality learning materials — Man Lung Yeung";
export function Marquee() {
const segment = () => (
<span class="marquee-text">
{MARQUEE_TEXT}
&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;&nbsp;
</span>
);
return (
<div class="marquee-shell" role="region" aria-label="Site quote">
<div class="marquee-line" />
<div class="marquee" aria-hidden="true">
<div class="marquee-track">
{segment()}
{segment()}
{segment()}
</div>
</div>
<div class="marquee-line" />
</div>
);
}

View file

@ -0,0 +1,35 @@
import { onCleanup, onMount } from "solid-js";
export function ScrollRevealBootstrap() {
onMount(() => {
const cards = document.querySelectorAll<HTMLElement>("[data-scroll-reveal-card]");
if (!cards.length) return;
const reducedMotion = window.matchMedia("(prefers-reduced-motion: reduce)").matches;
if (reducedMotion) {
cards.forEach((card) => card.classList.add("is-inview"));
return;
}
const observer = new IntersectionObserver(
(entries, obs) => {
entries.forEach((entry) => {
if (entry.isIntersecting) {
entry.target.classList.add("is-inview");
obs.unobserve(entry.target);
}
});
},
{ threshold: 0.14 }
);
cards.forEach((card, index) => {
card.style.transitionDelay = `${index * 80}ms`;
observer.observe(card);
});
onCleanup(() => observer.disconnect());
});
return null;
}

View file

@ -0,0 +1,94 @@
import { createSignal, onCleanup, onMount, For } from "solid-js";
import { ThemeToggle } from "./theme-toggle";
type NavLink = {
label: string;
href: string;
};
type SiteHeaderProps = {
brand: string;
homeUrl: string;
links: NavLink[];
};
export function SiteHeader(props: SiteHeaderProps) {
const [scrolled, setScrolled] = createSignal(false);
const [navOpen, setNavOpen] = createSignal(false);
onMount(() => {
const onScroll = () => setScrolled(window.scrollY > 24);
onScroll();
window.addEventListener("scroll", onScroll, { passive: true });
const onResize = () => {
if (window.innerWidth > 467 && navOpen()) {
setNavOpen(false);
document.body.style.overflow = "";
}
};
window.addEventListener("resize", onResize, { passive: true });
onCleanup(() => {
window.removeEventListener("scroll", onScroll);
window.removeEventListener("resize", onResize);
document.body.style.overflow = "";
});
});
const setOpen = (open: boolean) => {
setNavOpen(open);
document.body.style.overflow = open ? "hidden" : "";
};
const headerClass = () => {
const classes = ["site-header"];
if (scrolled()) classes.push("is-scrolled");
if (navOpen()) classes.push("nav-is-open");
return classes.join(" ");
};
return (
<header class={headerClass()}>
<div class="container header-row">
<div class="brand">
<a class="brand-name" href={props.homeUrl}>
{props.brand}
</a>
</div>
<div class="header-actions">
<nav class="main-nav" aria-label="Primary navigation">
<For each={props.links}>{(link) => <a href={link.href}>{link.label}</a>}</For>
</nav>
<ThemeToggle />
<button
type="button"
class="nav-burger"
aria-controls="mobile-nav"
aria-expanded={navOpen() ? "true" : "false"}
aria-label={navOpen() ? "Close menu" : "Open menu"}
onClick={() => setOpen(!navOpen())}
>
<span class="nav-burger-line" />
<span class="nav-burger-line" />
<span class="nav-burger-line" />
</button>
</div>
</div>
<div
class="nav-backdrop"
aria-hidden={navOpen() ? "false" : "true"}
onClick={() => setOpen(false)}
/>
<nav id="mobile-nav" class="mobile-nav-panel" aria-label="Mobile navigation">
<For each={props.links}>
{(link) => (
<a href={link.href} onClick={() => setOpen(false)}>
{link.label}
</a>
)}
</For>
</nav>
</header>
);
}

View file

@ -0,0 +1,30 @@
import { createSignal, onMount, Show } from "solid-js";
import { resolveTheme, setTheme, type ThemeChoice } from "../theme";
export function ThemeToggle() {
const [theme, setThemeState] = createSignal<ThemeChoice>("light");
onMount(() => {
setThemeState(resolveTheme());
});
const toggle = () => {
const next: ThemeChoice = theme() === "dark" ? "light" : "dark";
setTheme(next);
setThemeState(next);
};
return (
<button
type="button"
class="theme-toggle"
onClick={toggle}
aria-label={theme() === "dark" ? "Switch to light theme" : "Switch to dark theme"}
title={theme() === "dark" ? "Light mode" : "Dark mode"}
>
<Show when={theme() === "dark"} fallback={<span aria-hidden="true">☾</span>}>
<span aria-hidden="true">☀</span>
</Show>
</button>
);
}

59
frontend/src/main.tsx Normal file
View file

@ -0,0 +1,59 @@
import { render } from "solid-js/web";
import { SiteHeader } from "./components/site-header";
import { Marquee } from "./components/marquee";
import { ScrollRevealBootstrap } from "./components/scroll-reveal";
import { initTheme } from "./theme";
function readConfig() {
const el = document.getElementById("site-config");
if (!el?.textContent) {
return {
brand: "Open Free Books",
homeUrl: "/",
browseUrl: "/browse/",
aboutUrl: "/about/",
githubUrl: "https://github.com/openfreebooks/openfreebooks",
};
}
return JSON.parse(el.textContent) as {
brand: string;
homeUrl: string;
browseUrl: string;
aboutUrl: string;
githubUrl: string;
};
}
function mountFooterYear() {
const yearEl = document.getElementById("footer-year");
if (yearEl) {
yearEl.textContent = String(new Date().getFullYear());
}
}
initTheme();
const config = readConfig();
const navLinks = [
{ label: "Browse", href: config.browseUrl },
{ label: "About", href: config.aboutUrl },
{ label: "GitHub", href: config.githubUrl },
];
const headerRoot = document.getElementById("site-header");
if (headerRoot) {
render(
() => (
<SiteHeader brand={config.brand} homeUrl={config.homeUrl} links={navLinks} />
),
headerRoot
);
}
const marqueeRoot = document.getElementById("marquee");
if (marqueeRoot) {
render(() => <Marquee />, marqueeRoot);
}
render(() => <ScrollRevealBootstrap />, document.body);
mountFooterYear();

32
frontend/src/theme.ts Normal file
View file

@ -0,0 +1,32 @@
const STORAGE_KEY = "openfreebooks-theme";
export type ThemeChoice = "light" | "dark";
export function getSystemTheme(): ThemeChoice {
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
}
export function getStoredTheme(): ThemeChoice | null {
const value = localStorage.getItem(STORAGE_KEY);
if (value === "light" || value === "dark") {
return value;
}
return null;
}
export function resolveTheme(): ThemeChoice {
return getStoredTheme() ?? getSystemTheme();
}
export function applyTheme(theme: ThemeChoice) {
document.documentElement.setAttribute("data-theme", theme);
}
export function setTheme(theme: ThemeChoice) {
localStorage.setItem(STORAGE_KEY, theme);
applyTheme(theme);
}
export function initTheme() {
applyTheme(resolveTheme());
}