diff --git a/frontend/src/Find.tsx b/frontend/src/Find.tsx new file mode 100644 index 0000000..d5c0934 --- /dev/null +++ b/frontend/src/Find.tsx @@ -0,0 +1,37 @@ +import { useEffect, useRef } from "react"; +import { componentCount } from "./data"; + +/** The search field; Slash focuses it, Enter takes the first match. */ +export function Find({ query, onQuery, onEnter, className }: { query: string; onQuery: (q: string) => void; onEnter: () => void; className: string }) { + const field = useRef(null); + useEffect(() => { + const slash = (e: KeyboardEvent) => { + if (e.key !== "/" || e.metaKey || e.ctrlKey || typing(e)) return; + e.preventDefault(); + field.current?.focus(); + }; + addEventListener("keydown", slash); + return () => removeEventListener("keydown", slash); + }, []); + return ( + onQuery(e.target.value)} + onKeyDown={(e) => { + if (e.key === "Enter") onEnter(); + if (e.key === "Escape") onQuery(""); + }} + /> + ); +} + +/** Whether a key went to a field, not the page. */ +export function typing(e: KeyboardEvent): boolean { + const t = e.target as HTMLElement; + return t.isContentEditable || ["INPUT", "TEXTAREA", "SELECT"].includes(t.tagName); +} diff --git a/frontend/src/data.ts b/frontend/src/data.ts index 6e13909..ef41b8e 100644 --- a/frontend/src/data.ts +++ b/frontend/src/data.ts @@ -21,6 +21,17 @@ export const componentCount = new Set(chapters.flatMap((c) => c.stories.flatMap( /** Every story in reading order, for paging and search. */ export const all = chapters.flatMap((chapter) => chapter.stories.map((story) => ({ chapter, story }))); +/** Chapters by title, else their stories by title or component. */ +export function search(query: string): Chapter[] { + const q = query.trim().toLowerCase(); + if (!q) return chapters; + return chapters.flatMap((c) => { + if (c.title.toLowerCase().includes(q)) return [c]; + const stories = c.stories.filter((s) => s.title.toLowerCase().includes(q) || s.components.some((n) => n.toLowerCase().includes(q))); + return stories.length ? [{ ...c, stories }] : []; + }); +} + export const REPO = "https://github.com/ZacharyZhang-NY/Ely-GPUI-Components"; export const pad = (n: number) => String(n).padStart(2, "0"); diff --git a/frontend/src/pages/Chapters.tsx b/frontend/src/pages/Chapters.tsx index b594588..44b176d 100644 --- a/frontend/src/pages/Chapters.tsx +++ b/frontend/src/pages/Chapters.tsx @@ -1,22 +1,35 @@ -import { Link } from "@tanstack/react-router"; -import { useEffect } from "react"; -import { chapters, pad } from "../data"; +import { Link, useNavigate } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; +import { pad, search } from "../data"; +import { Find } from "../Find"; export function Chapters() { + const [query, setQuery] = useState(""); + const navigate = useNavigate(); + const found = search(query); useEffect(() => { document.title = "Components · Ely"; }, []); + const first = () => found[0] && navigate({ to: "/components/$page/$story/", params: { page: found[0].slug, story: found[0].stories[0].slug } }); return (
-

Components

+

Components.

+ + {found.length === 0 &&

Nothing matches “{query}”.

}
    - {chapters.map((c) => ( + {found.map((c) => (
  1. - - {pad(c.number)} - {c.title} - {c.stories.length} + {pad(c.number)} + + {c.title} + + {c.stories.map((s) => ( + + {s.title} + + ))} +
  2. ))}
diff --git a/frontend/src/pages/Story.tsx b/frontend/src/pages/Story.tsx index ebe1153..4e35623 100644 --- a/frontend/src/pages/Story.tsx +++ b/frontend/src/pages/Story.tsx @@ -1,46 +1,92 @@ -import { Link, useParams } from "@tanstack/react-router"; -import { useEffect } from "react"; +import { Link, useNavigate, useParams } from "@tanstack/react-router"; +import { useEffect, useState } from "react"; import { Live } from "../Live"; import { Capture, Windows, isCaptured } from "../Native"; -import { all, chapters } from "../data"; +import { all, chapters, search } from "../data"; +import { Find, typing } from "../Find"; import { NotFound } from "./NotFound"; export function Story() { const { page, story } = useParams({ strict: false }); + const navigate = useNavigate(); + const [query, setQuery] = useState(""); const chapter = chapters.find((c) => c.slug === page); const found = chapter?.stories.find((s) => s.slug === story); + const at = all.findIndex((e) => e.story === found); + const [prev, next] = at < 0 ? [] : [all[at - 1], all[at + 1]]; + useEffect(() => { if (chapter && found) document.title = `${found.title} · ${chapter.title} · Ely`; }, [chapter, found]); + + useEffect(() => { + document.querySelector('.side [aria-current="page"]')?.scrollIntoView({ block: "nearest" }); + }, [page, story]); + + useEffect(() => { + const step = (e: KeyboardEvent) => { + if (e.metaKey || e.ctrlKey || e.altKey || typing(e)) return; + const to = e.key === "ArrowLeft" ? prev : e.key === "ArrowRight" ? next : undefined; + if (to) navigate({ to: "/components/$page/$story/", params: { page: to.chapter.slug, story: to.story.slug } }); + }; + addEventListener("keydown", step); + return () => removeEventListener("keydown", step); + }, [prev, next, navigate]); + if (!chapter || !found) return ; - const at = all.findIndex((e) => e.story === found); - const [prev, next] = [all[at - 1], all[at + 1]]; + const shown = search(query); + const first = () => shown[0] && navigate({ to: "/components/$page/$story/", params: { page: shown[0].slug, story: shown[0].stories[0].slug } }); return ( -
- - {chapter.title} - -

{found.title}

- {isCaptured(chapter.slug, found.slug) ? ( - - ) : ( - <> - - - - )} - -
+ + + ); } diff --git a/frontend/src/styles.css b/frontend/src/styles.css index 7cc3421..1dbcfd6 100644 --- a/frontend/src/styles.css +++ b/frontend/src/styles.css @@ -115,15 +115,29 @@ main > :first-child { animation: enter 480ms ease-out both; } @keyframes enter { from { opacity: 0; } } .chapters { padding: 160px 64px 128px; } -.chapters h1 { font-size: clamp(40px, 6vw, 96px); letter-spacing: -0.04em; margin-bottom: 96px; } +.chapters h1 { font-size: clamp(48px, 7vw, 112px); line-height: 0.95; font-weight: 800; letter-spacing: -0.045em; } +.find { display: block; width: 100%; margin: 56px 0 72px; padding: 12px 0; border: 0; border-bottom: 1px solid var(--line); border-radius: 0; background: none; color: var(--fg); font: 300 24px/32px var(--sans); outline: none; transition: border-color 160ms ease-out; } +.find::placeholder { color: var(--faint); } +.find:focus-visible { border-color: var(--accent); } +.find.small { margin: 0 0 24px; font-size: 15px; line-height: 24px; padding: 8px 0; } +.none { font-size: 18px; } .chapters ol { list-style: none; margin: 0; padding: 0; } -.chapters a { display: grid; grid-template-columns: 64px 1fr auto; align-items: baseline; padding: 20px 0; border-top: 1px solid var(--line); } -.chapters .n, .chapters .count { font: 400 13px/20px var(--mono); color: var(--faint); } +.chapters li { display: grid; grid-template-columns: 64px minmax(160px, 1fr) 2fr; gap: 0 24px; align-items: baseline; padding: 22px 0; border-top: 1px solid var(--line); } +.chapters .n { font: 400 13px/20px var(--mono); color: var(--faint); } .chapters .name { font-size: 28px; line-height: 36px; font-weight: 700; letter-spacing: -0.02em; } +.chapters .titles { display: flex; flex-wrap: wrap; gap: 4px 20px; font-size: 14px; line-height: 22px; min-width: 0; } +.titles a, .side a { color: var(--muted); transition: color 160ms ease-out; overflow-wrap: anywhere; } +.titles a:hover, .side a:hover, .side a[aria-current="page"], .side-chapter[data-on] { color: var(--fg); } -.story { padding: 128px 64px 96px; } +.reader { display: grid; grid-template-columns: 260px minmax(0, 1fr); gap: 64px; padding: 0 64px; } +.side { position: sticky; top: 64px; height: calc(100vh - 64px); overflow-y: auto; padding: 64px 8px 64px 0; overscroll-behavior: contain; } +.side nav > div { margin-bottom: 4px; } +.side-chapter { display: block; padding: 4px 0; font-size: 15px; font-weight: 500; } +.side ol { list-style: none; margin: 4px 0 12px; padding: 0 0 0 14px; border-left: 1px solid var(--line); font-size: 13px; line-height: 20px; } +.side li { padding: 4px 0; } +.story { padding: 128px 0 96px; min-width: 0; } .crumb { font-size: 14px; color: var(--muted); } -.story h1 { font-size: clamp(32px, 4vw, 56px); line-height: 1.05; letter-spacing: -0.035em; margin: 12px 0 64px; max-width: 18ch; } +.story h1 { font-size: clamp(28px, 3vw, 44px); line-height: 1.05; letter-spacing: -0.035em; font-weight: 800; margin: 12px 0 64px; overflow-wrap: anywhere; } .live { position: relative; height: 72vh; min-height: 520px; background: var(--bg); } .live iframe { display: block; width: 100%; height: 100%; border: 0; background: var(--bg); } @@ -142,8 +156,8 @@ img.shot { display: block; max-width: 100%; height: auto; } .windows { margin-top: 96px; } .shots { display: flex; flex-wrap: wrap; align-items: flex-start; gap: 32px; } -.pager { display: flex; justify-content: space-between; gap: 24px; margin-top: 96px; font-size: 18px; font-weight: 600; } -.pager a { color: var(--muted); transition: color 160ms ease-out; } +.pager { display: flex; flex-wrap: wrap; justify-content: space-between; gap: 24px; margin-top: 96px; font-size: 18px; font-weight: 600; } +.pager a { max-width: 100%; color: var(--muted); overflow-wrap: anywhere; transition: color 160ms ease-out; } .pager a:hover { color: var(--fg); } .pager .next { margin-left: auto; text-align: right; } @@ -156,13 +170,17 @@ img.shot { display: block; max-width: 100%; height: auto; } @media (max-width: 767px) { .bar { padding: 0 16px; gap: 12px; } .bar .end { gap: 0; } - .hero, .chapters, .story, .missing { padding-left: 20px; padding-right: 20px; } + .hero, .chapters, .missing { padding-left: 20px; padding-right: 20px; } + .reader { grid-template-columns: minmax(0, 1fr); padding: 0 20px; } + .side { display: none; } + .chapters li { grid-template-columns: 40px minmax(0, 1fr); } + .chapters .titles { grid-column: 2; margin-top: 8px; } .foot { padding: 32px 20px; } .chapters .name { font-size: 22px; } .hero-iso { width: 100%; height: 55%; } .hero > :not(.iso) { max-width: none; } .city { height: auto; } - .hero h1 { font-size: 13vw; } + .hero h1, .chapters h1 { font-size: 13vw; } .feature h2, .city h2, .close h2 { font-size: 10vw; } .feature, .feature:nth-of-type(odd), .city { grid-template-columns: 1fr; grid-template-rows: none; gap: 32px; padding: 112px 20px 48px; } .feature-words, .feature-iso, .feature-live, .feature:nth-of-type(odd) .feature-words, .feature:nth-of-type(odd) .feature-iso, .feature:nth-of-type(odd) .feature-live, .city h2 { grid-area: auto; }