feat(ui): 단어 and 오늘, rebuilt for a thumb

단어. The search and the filters stay put above the list; the filters are
chips with counts — 내 진도 My units (the default), All, Due, New, Learning,
In review, Secure, then every topic. Below 840px the words are a list, 40
rows at a time as it scrolls, and a row opens its actions — Know it or
Reset, Delete for his own words — instead of three buttons in 360px. From
840px it is a table. The artifact chose between the two once, at first
render, so turning a tablet left the wrong one; this follows the width.
The add form sits behind +, and searching still reaches past the deck into
the dictionary — whose words can now be added from there.

오늘. One number and one action: what a review started now would hold (the
same number as 복습's badge), how long it should take, and Start. Below it a
line — streak, answered today, words secure — and the daily goal. The road,
the study log and the deck in numbers fold away underneath. The heatmap
shows as many weeks as fit, with weekdays, months and a legend.

Co-Authored-By: Claude Opus 5 (1M context) <noreply@anthropic.com>
This commit is contained in:
MechaCat02
2026-09-16 22:05:54 +02:00
parent ab9b75ff32
commit ede8f295a2
6 changed files with 1051 additions and 551 deletions

View File

@@ -83,10 +83,7 @@ export function Routes() {
</Route>
<Route id="words">
<RouteHead title="단어" sub="Vocabulary" />
<Scroll>
<VocabTab />
</Scroll>
<VocabTab />
</Route>
<Route id="learn">

View File

@@ -65,7 +65,17 @@ export function RouteHead({
* The route's scroller. The page itself never scrolls. The position is
* put back when the route is shown again: a display:none box loses it.
*/
export function Scroll({ children }: { children: ReactNode }) {
export function Scroll({
children,
bare = false,
onNearEnd,
}: {
children: ReactNode;
/** Full-bleed content: no centred column around it. */
bare?: boolean;
/** Called when the end comes within a screen's height — for a list that loads more. */
onNearEnd?: () => void;
}) {
const active = useRouteActive();
const ref = useRef<HTMLDivElement>(null);
const top = useRef(0);
@@ -79,10 +89,13 @@ export function Scroll({ children }: { children: ReactNode }) {
ref={ref}
className="scroll"
onScroll={(e) => {
if (active) top.current = e.currentTarget.scrollTop;
if (!active) return;
const el = e.currentTarget;
top.current = el.scrollTop;
if (onNearEnd && el.scrollHeight - el.scrollTop - el.clientHeight < el.clientHeight) onNearEnd();
}}
>
<div className="wrap page">{children}</div>
{bare ? children : <div className="wrap page">{children}</div>}
</div>
);
}

View File

@@ -1,66 +1,166 @@
/* 오늘 — where you are and what is due. The settings are their own page. */
/* 오늘 — the one number that drives the one action.
import { useEffect, useState } from "react";
What is due, a button to start it, and a line of where things stand.
Everything else — the road, the study log, the deck in numbers — is
folded away underneath, a tap from view. The settings are their own page. */
import { useEffect, useRef, useState } from "react";
import { useStore } from "../../state/store.js";
import { useReview } from "../review/useReview.js";
import { useNavigator } from "../shell/router.js";
import { counts, streakFrom, studyLog, type Counts, type DayRow } from "../../domain/cards.js";
import { curriculum } from "../../domain/gate.js";
import { deck, streakFrom, studyLog, type DayRow, type DeckEntry } from "../../domain/cards.js";
import { curriculum, UNITS } from "../../domain/gate.js";
import { currentUnit } from "../../domain/progress.js";
import { GRAMMAR_LEARNED, readJsonMeta } from "../../domain/notes.js";
import grammarJson from "@data/grammar.json";
import sfxJson from "@data/sfx.json";
import "./today.css";
const HEATMAP_DAYS = 7 * 22;
const GRAMMAR_POINTS = (grammarJson as unknown as { points: unknown[] }).points.length;
const SOUND_WORDS = (sfxJson as unknown as { items: unknown[] }).items.length;
/** Seconds a card takes, for the estimate under the number. */
const SECONDS_PER_CARD = 7;
const MAX_WEEKS = 36;
const CELL = 18;
const GAP = 4;
/** A day number (lib/srs.js dayNumber) as the local calendar date it names. */
function dateOf(day: number): Date {
const t = new Date(day * 864e5);
return new Date(t.getUTCFullYear(), t.getUTCMonth(), t.getUTCDate());
}
const short = (d: Date) => d.toLocaleDateString(undefined, { month: "short", day: "numeric" });
const isDue = (e: DeckEntry, today: number) => e.card !== null && e.status !== "new" && e.card.due <= today;
/* ── the study log ───────────────────────────────────────────────── */
function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) {
const byDay = new Map(rows.map((r) => [r.day, r.reviews + r.drills]));
const start = today - HEATMAP_DAYS + 1;
const box = useRef<HTMLDivElement>(null);
const [weeks, setWeeks] = useState(22);
// As many weeks as fit. The fold is closed at first, so this runs again
// once it opens and has a width.
useEffect(() => {
const el = box.current;
if (!el) return;
const ro = new ResizeObserver(() => {
const w = el.clientWidth;
if (w > 0) setWeeks(Math.max(10, Math.min(MAX_WEEKS, Math.floor((w - 36) / (CELL + GAP)))));
});
ro.observe(el);
return () => ro.disconnect();
}, []);
const byDay = new Map(rows.map((r) => [r.day, r]));
const monday = (dateOf(today).getDay() + 6) % 7;
const start = today - monday - (weeks - 1) * 7;
const level = (n: number) => (n === 0 ? 0 : n < 5 ? 1 : n < 15 ? 2 : n < 30 ? 3 : 4);
const cells = [];
for (let d = start; d <= today; d++) {
const n = byDay.get(d) ?? 0;
cells.push(
<i
key={d}
data-level={level(n)}
title={n ? `${n} answers` : "nothing"}
aria-label={n ? `${n} answers` : "nothing"}
/>,
);
const months = [];
let lastMonth = -1;
let lastLabel = -99;
let total = 0;
let active = 0;
for (let w = 0; w < weeks; w++) {
const first = dateOf(start + w * 7);
if (first.getMonth() !== lastMonth && (w === 0 || first.getDate() <= 7) && w - lastLabel >= 3) {
lastMonth = first.getMonth();
lastLabel = w;
months.push(
<span key={w} style={{ left: w * (CELL + GAP) }}>
{first.toLocaleDateString(undefined, { month: "short" })}
</span>,
);
}
for (let d = 0; d < 7; d++) {
const day = start + w * 7 + d;
if (day > today) {
cells.push(<i key={day} data-l="0" data-future="1" />);
continue;
}
const r = byDay.get(day);
const n = (r?.reviews ?? 0) + (r?.drills ?? 0);
total += n;
if (n) active++;
cells.push(
<i
key={day}
data-l={level(n)}
data-today={day === today ? "1" : undefined}
title={
n
? `${short(dateOf(day))}${r?.reviews ?? 0} reviews, ${r?.drills ?? 0} drill answers`
: `${short(dateOf(day))} — no study`
}
/>,
);
}
}
const total = rows.reduce((a, r) => a + r.reviews + r.drills, 0);
return (
<div className="panel">
<div className="panel-h">
<h2> </h2>
<span className="note tnum">{total} answers in the last 22 weeks</span>
<div ref={box}>
<div className="hm-scroll">
<div className="hm">
<div className="hm-days" aria-hidden="true">
<span className="ko"></span>
<span />
<span className="ko"></span>
<span />
<span className="ko"></span>
<span />
<span className="ko"></span>
</div>
<div className="hm-grid-wrap">
<div className="hm-months">{months}</div>
<div className="hm-grid">{cells}</div>
</div>
</div>
</div>
<div className="panel-b">
<div className="hm">{cells}</div>
<div className="hm-legend">
<span>fewer</span>
{[0, 1, 2, 3, 4].map((l) => (
<i key={l} data-l={l} />
))}
<span>more</span>
<span className="hm-total tnum">
{total.toLocaleString()} answers over {active} day{active === 1 ? "" : "s"} · last {weeks} weeks
</span>
</div>
</div>
);
}
/* ── the screen ──────────────────────────────────────────────────── */
export function TodayTab() {
const { db, progress, prefs, today, revision } = useStore();
const nav = useNavigator();
const { start } = useReview();
const [stats, setStats] = useState<Counts | null>(null);
const [pool, setPool] = useState<DeckEntry[] | null>(null);
const [words, setWords] = useState<DeckEntry[]>([]);
const [sentences, setSentences] = useState<DeckEntry[]>([]);
const [log, setLog] = useState<DayRow[]>([]);
const [grammar, setGrammar] = useState(0);
useEffect(() => {
let cancelled = false;
(async () => {
const [c, rows] = await Promise.all([
counts(db, today, { sentences: prefs.sentences, pool: progress }),
studyLog(db, today - HEATMAP_DAYS),
const [p, w, s, rows, learned] = await Promise.all([
deck(db, { sentences: prefs.sentences, pool: progress }),
deck(db),
deck(db, { only: "sentences" }),
studyLog(db, today - MAX_WEEKS * 7 - 7),
readJsonMeta<Record<string, boolean>>(db, GRAMMAR_LEARNED, {}),
]);
if (cancelled) return;
setStats(c);
setPool(p);
setWords(w);
setSentences(s);
setLog(rows);
setGrammar(Object.values(learned).filter(Boolean).length);
})();
return () => {
cancelled = true;
@@ -68,107 +168,159 @@ export function TodayTab() {
}, [db, prefs.sentences, progress, revision, today]);
const unit = currentUnit(progress);
const doneUnits = Object.keys(progress.done).length;
const totalUnits = curriculum.phases.reduce((a, p) => a + p.units.length, 0);
const streak = streakFrom(log, today);
// The same number as the badge on 복습: what a review started now holds.
const due = pool?.filter((e) => isDue(e, today)).length ?? 0;
const fresh = pool?.filter((e) => e.status === "new").length ?? 0;
const newToday = Math.max(0, Math.min(prefs.newPerDay, fresh));
const total = due + newToday;
const nextDue = (pool ?? [])
.filter((e) => e.card && e.status !== "new" && e.card.due > today)
.reduce((min, e) => Math.min(min, e.card!.due), Infinity);
const todayRow = log.find((r) => r.day === today);
const answered = (todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0);
const goalPct = Math.min(100, Math.round((answered / Math.max(1, prefs.goal)) * 100));
const reviewedToday = todayRow?.reviews ?? 0;
const streak = streakFrom(log, today);
const secure = words.filter((e) => e.status === "secure").length;
const goalPct = Math.min(100, Math.round((reviewedToday / Math.max(1, prefs.goal)) * 100));
const sub = !pool
? "…"
: total === 0
? `nothing due — next on ${Number.isFinite(nextDue) ? short(dateOf(nextDue)) : "your next new card"}`
: [due ? `${due} due` : "", newToday ? `${newToday} new` : ""].filter(Boolean).join(" · ") +
` · about ${Math.max(1, Math.round((total * SECONDS_PER_CARD) / 60))} min`;
return (
<>
<div className="hero panel">
<div className="hero-l">
<span className="eyebrow">
Phase {unit.phase} · unit {unit.id}
</span>
<h1 className="ko serif">{unit.ko}</h1>
<p className="hero-sub">{unit.goal}</p>
<div className="hero-acts">
<button
className="btn big primary"
disabled={!stats?.due && !stats?.fresh}
onClick={() => void start()}
>
Start review{stats ? ` · ${stats.due + Math.min(stats.fresh, prefs.newPerDay)}` : ""}
</button>
<button className="btn big" onClick={() => nav.go("lesson")}>
Go to
</button>
</div>
<div className="goal">
<div className="goalbar">
<i style={{ width: `${goalPct}%` }} />
</div>
<span className="tnum">
{answered} / {prefs.goal} today
</span>
</div>
<div className="card due-card">
<div className="due-top">
<span className="due-n tnum">{pool ? total : "—"}</span>
<span className="due-x">{sub}</span>
</div>
<button className="cta" disabled={!pool || total === 0} onClick={() => void start()}>
{pool && total === 0 ? "Nothing due" : `Start review${pool ? ` · ${total}` : ""}`}
</button>
</div>
<div className="tiles">
<div className="tile accent">
<span className="v tnum">{stats?.due ?? ""}</span>
<span className="k">Due</span>
</div>
<div className="tile">
<span className="v tnum">{streak}</span>
<span className="k">Day streak</span>
</div>
<div className="tile">
<span className="v tnum">{stats?.secure ?? "—"}</span>
<span className="k">Secure</span>
<span className="x">interval 21 days</span>
</div>
<div className="tile">
<span className="v tnum">
{doneUnits}/{totalUnits}
</span>
<span className="k">Units done</span>
</div>
<div className="strip">
<span>
🔥 <b>{streak}</b> day{streak === 1 ? "" : "s"}
</span>
<span>
<b>{(todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0)}</b> answered today
</span>
<span>
<b>{secure}</b> words secure
</span>
</div>
<div className="card goal-card">
<div className="goal-h">
<span>Today's reviews</span>
<span className="tnum">
<b>{reviewedToday}</b> / {prefs.goal}
</span>
</div>
<div className="goalbar">
<i style={{ width: `${goalPct}%` }} />
</div>
</div>
<div className="panel">
<div className="panel-h">
<h2> </h2>
<span className="note">The road to reading manhwa</span>
</div>
<div className="panel-b phases">
<details className="fold">
<summary>
<span>
<span className="ko"></span> · Where you are
</span>
<span className="note tnum">
{curriculum.phases.length} phases · {UNITS.length} units
</span>
</summary>
<div className="fold-b phases">
{curriculum.phases.map((p) => {
const done = p.units.filter((u) => progress.done[u.id]).length;
const isNow = p.units.some((u) => u.id === unit.id);
const state = done === p.units.length ? "done" : isNow ? "now" : "todo";
const state = isNow ? "now" : done === p.units.length ? "done" : "next";
return (
<div className="phase" data-st={state} key={p.phase}>
<span className="n serif">{p.phase}</span>
<div className="ph-body">
<div className="ph-title">
<span className="ko">{p.ko}</span>
<span className="nm">{p.name}</span>
<span className="ph-badge">
{state === "done" ? "complete" : state === "now" ? "you are here" : "ahead"}
</span>
</div>
<p className="dt">
{isNow ? `${unit.ko}${unit.goal}` : p.units.map((u) => u.ko).join(" · ")}
</p>
<div className="pbar">
<i style={{ width: `${(done / p.units.length) * 100}%` }} />
</div>
<span className="ph-count tnum">
{done} of {p.units.length} units
</span>
<div className="ph-top">
<span className="num serif">{p.phase}</span>
<span className="ko">{p.ko}</span>
<span className="nm">{p.name}</span>
</div>
<span className="ph-badge">
{state === "done" ? "complete" : state === "now" ? "you are here" : "up next"}
</span>
<span className="dt ko">
{isNow ? `${unit.ko}${unit.goal}` : p.units.map((u) => u.ko).join(" · ")}
</span>
<span className="units tnum">
{done} of {p.units.length} units
</span>
<span className="pbar">
<i style={{ width: `${(done / p.units.length) * 100}%` }} />
</span>
</div>
);
})}
</div>
</div>
</details>
<Heatmap rows={log} today={today} />
<details className="fold">
<summary>
<span>
<span className="ko"></span> · Study log
</span>
<span className="note tnum">{streak ? `${streak}-day streak` : "no streak yet"}</span>
</summary>
<div className="fold-b">
<Heatmap rows={log} today={today} />
</div>
</details>
<details className="fold">
<summary>
<span>
<span className="ko"></span> · Deck breakdown
</span>
<span className="note tnum">{words.length} words</span>
</summary>
<div className="fold-b">
<table className="breakdown tnum">
<tbody>
<tr>
<td>Words, not yet seen</td>
<td>{words.filter((e) => e.status === "new").length}</td>
</tr>
<tr>
<td>Words in review</td>
<td>{words.filter((e) => e.status === "learning" || e.status === "review").length}</td>
</tr>
<tr>
<td>Words secure ( 21 days)</td>
<td>{secure}</td>
</tr>
<tr>
<td>Sentences started</td>
<td>
{sentences.filter((e) => e.status !== "new").length} / {sentences.length}
</td>
</tr>
<tr>
<td>Grammar points learned</td>
<td>
{grammar} / {GRAMMAR_POINTS}
</td>
</tr>
</tbody>
</table>
</div>
</details>
<p className="today-foot tnum">
{words.length} words · {sentences.length} sentence cards · {GRAMMAR_POINTS} grammar points ·{" "}
{SOUND_WORDS} sound words.
</p>
</>
);
}

View File

@@ -1,60 +1,92 @@
/* 단어 — the reviewable deck, filtered and searchable.
/* 단어 — his words: the deck, filtered, searched, added to.
Rows come from `lemma` joined to `card`, not from an in-memory array, so
this is the same data the tutor's word rail and the review overlay see. */
this is the same data the lesson's word list and the review see.
Below 840px it is a list, not a table: several hundred rows is a browse-
and-act job, and a grid only pays for itself when columns are compared.
A row opens its actions rather than crowding three buttons into 360px.
The artifact chose between list and table once, at first render; turning
a tablet round left the wrong one. */
import { useEffect, useMemo, useState } from "react";
import { useStore } from "../../state/store.js";
import { useReview } from "../review/useReview.js";
import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js";
import {
deck,
forget,
isMine,
markAsKnown,
type CardStatus,
type DeckEntry,
} from "../../domain/cards.js";
import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
import { search, type Entry } from "../../domain/lexicon.js";
import { ensureReferenceBand } from "../../domain/dictionary.js";
import { statusOf } from "@lib/srs.js";
import { RouteHead, Scroll } from "../shell/Route.js";
import { Pop } from "../shell/Pop.js";
import { WIDE, useMedia } from "../shell/useMedia.js";
import "./vocab.css";
const STATUSES: (CardStatus | "all")[] = ["all", "new", "learning", "review", "secure"];
type Filter = "mine" | "all" | "due" | CardStatus;
const FILTERS: [Filter, string][] = [
["mine", "내 진도 My units"],
["all", "전체 All"],
["due", "Due"],
["new", "New"],
["learning", "Learning"],
["review", "In review"],
["secure", "Secure"],
];
const STATUS_LABEL: Record<CardStatus, string> = {
new: "New",
learning: "Learning",
review: "Review",
review: "In review",
secure: "Secure",
};
function statusText(e: DeckEntry): string {
if (!e.card || e.status === "new") return STATUS_LABEL.new;
if (e.status === "learning") return STATUS_LABEL.learning;
const days = e.card.interval;
return `${STATUS_LABEL[e.status]} · every ${days} d`;
/** His own words, and dictionary words he added, have no topic of their own. */
const OWN_TOPIC = "내 단어 My words";
const topicOf = (e: DeckEntry) => e.topic ?? OWN_TOPIC;
const PAGE = 40;
const POSES = ["noun", "verb", "adj", "adv", "pron", "phrase", "particle"];
function detail(e: DeckEntry): string {
if (e.card && e.status !== "new" && e.status !== "learning") return `every ${e.card.interval} d`;
return "";
}
export function VocabTab() {
const { db, today, revision, invalidate } = useStore();
const { start } = useReview();
const { db, today, progress, revision, invalidate } = useStore();
const wide = useMedia(WIDE);
const [entries, setEntries] = useState<DeckEntry[]>([]);
const [query, setQuery] = useState("");
const [status, setStatus] = useState<CardStatus | "all">("all");
const [pos, setPos] = useState<string>("all");
const [filter, setFilter] = useState<Filter>("mine");
const [topic, setTopic] = useState<string | null>(null);
const [shownRows, setShownRows] = useState(PAGE);
const [dict, setDict] = useState<Entry[] | null>(null);
const [adding, setAdding] = useState(false);
const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" });
const [addNote, setAddNote] = useState<string | null>(null);
/** The row whose actions are open, below 840px. */
const [row, setRow] = useState<DeckEntry | null>(null);
useEffect(() => {
let cancelled = false;
(async () => {
const rows = await deck(db, { sentences: true });
void deck(db).then((rows) => {
if (!cancelled) setEntries(rows);
})();
});
return () => {
cancelled = true;
};
}, [db, revision]);
/* Searching past the deck reaches into the whole dictionary — for looking
something up, not for studying it. Those rows are read-only here. */
/* Searching past the deck reaches into the whole dictionary — a word met
in the wild can be looked up, and added. */
useEffect(() => {
const q = query.trim();
if (q.length < 2) {
@@ -73,233 +105,300 @@ export function VocabTab() {
};
}, [db, query]);
const positions = useMemo(
() => ["all", ...[...new Set(entries.map((e) => e.pos))].sort()],
[entries],
);
const passes = useMemo(() => {
const due = (e: DeckEntry) => e.card !== null && e.status !== "new" && e.card.due <= today;
return (e: DeckEntry, f: Filter) =>
f === "all" ? true : f === "mine" ? isMine(e, progress) : f === "due" ? due(e) : e.status === f;
}, [progress, today]);
const shown = useMemo(() => {
const counts = useMemo(() => {
const byFilter = new Map<Filter, number>(FILTERS.map(([f]) => [f, 0]));
const byTopic = new Map<string, number>();
for (const e of entries) {
for (const [f] of FILTERS) if (passes(e, f)) byFilter.set(f, byFilter.get(f)! + 1);
byTopic.set(topicOf(e), (byTopic.get(topicOf(e)) ?? 0) + 1);
}
return { byFilter, byTopic };
}, [entries, passes]);
const matching = useMemo(() => {
const q = query.trim().toLowerCase();
return entries.filter((e) => {
if (status !== "all" && e.status !== status) return false;
if (pos !== "all" && e.pos !== pos) return false;
if (!q) return true;
return `${e.headword} ${e.glossEn}`.toLowerCase().includes(q);
});
}, [entries, pos, query, status]);
return entries.filter(
(e) =>
passes(e, filter) &&
(topic === null || topicOf(e) === topic) &&
(!q || `${e.headword} ${e.glossEn}`.toLowerCase().includes(q)),
);
}, [entries, filter, passes, query, topic]);
// A new filter starts the list from the top again.
useEffect(() => setShownRows(PAGE), [filter, topic, query]);
const inDeck = useMemo(() => new Set(entries.map((e) => e.lemmaId)), [entries]);
const extra = dict?.filter((d) => !inDeck.has(d.lemmaId)) ?? [];
return (
const act = async (fn: () => Promise<void>) => {
await fn();
setRow(null);
invalidate();
};
const actions = (e: DeckEntry) => (
<>
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note tnum">
{shown.length} of {entries.length} shown
</span>
</div>
<div className="panel-b">
<div className="toolbar">
<input
className="grow"
type="search"
value={query}
placeholder="Search 한글 or English…"
aria-label="Search your words in 한글 or English"
onChange={(e) => setQuery(e.target.value)}
/>
<button className="btn" onClick={() => setAdding((a) => !a)} aria-expanded={adding}>
{adding ? "Cancel" : "+ Add a word"}
</button>
<button className="btn primary" onClick={() => void start()}>
Review these
</button>
</div>
{adding && (
<form
className="add-word"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.headword.trim() || !draft.gloss.trim()) return;
try {
const { created } = await editAddCustomWord(db, draft);
setAddNote(
created
? `Added ${draft.headword.trim()}.`
: `${draft.headword.trim()} was already in the dictionary — added to your deck.`,
);
setDraft({ headword: "", gloss: "", pos: "noun" });
} catch (err) {
setAddNote(err instanceof Error ? err.message : "Could not add that word.");
}
invalidate();
}}
>
<input
className="ko"
value={draft.headword}
placeholder="한글"
aria-label="The word, in 한글"
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
<input
value={draft.gloss}
placeholder="What it means"
aria-label="What it means"
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
<select
aria-label="Part of speech"
value={draft.pos}
onChange={(e) => setDraft({ ...draft, pos: e.target.value })}
>
{["noun", "verb", "adj", "adv", "pron", "phrase", "particle"].map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
<button className="btn primary" type="submit">
Add
</button>
<p className="add-note">
{addNote ??
"Your own words sit alongside the dictionary and survive a rebuild of it."}
</p>
</form>
)}
<div className="topics" style={{ marginTop: 11 }}>
{STATUSES.map((s) => (
<button key={s} aria-pressed={status === s} onClick={() => setStatus(s)}>
{s === "all" ? "전체 All" : STATUS_LABEL[s]}
</button>
))}
</div>
<div className="topics" style={{ marginTop: 7 }}>
{positions.map((p) => (
<button key={p} aria-pressed={pos === p} onClick={() => setPos(p)}>
{p === "all" ? "품사 All" : p}
</button>
))}
</div>
</div>
<div className="tbl-scroll">
<table className="words">
<thead>
<tr>
<th></th>
<th>Meaning</th>
<th>State</th>
<th />
</tr>
</thead>
<tbody>
{shown.map((e) => (
<tr key={e.lemmaId}>
<td>
<div className="w-ko ko">{e.headword}</div>
</td>
<td>
<div className="w-en">{e.glossEn}</div>
<div className="w-ro">{e.pos}</div>
</td>
<td>
<span className={`state ${e.status}`}>{statusText(e)}</span>
</td>
<td>
<div className="rowacts">
{e.status === "new" ? (
<button
className="btn sm"
onClick={async () => {
await markAsKnown(db, e.lemmaId, today);
invalidate();
}}
>
Know it
</button>
) : (
<button
className="btn sm"
onClick={async () => {
await forget(db, e.lemmaId);
invalidate();
}}
>
Reset
</button>
)}
{e.source === "custom" && (
<button
className="btn sm"
title="Remove this word entirely"
onClick={async () => {
await editRemoveCustomWord(db, e.lemmaId);
invalidate();
}}
>
Delete
</button>
)}
</div>
</td>
</tr>
))}
{!shown.length && (
<tr>
<td colSpan={4}>
<p className="empty">Nothing matches.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
{extra.length > 0 && (
<div className="panel">
<div className="panel-h">
<h2></h2>
<span className="note">
{extra.length} more in the dictionary reference only, not in the deck
</span>
</div>
<div className="tbl-scroll">
<table className="words">
<tbody>
{extra.map((d) => (
<tr key={d.lemmaId}>
<td>
<div className="w-ko ko">{d.headword}</div>
</td>
<td>
<div className="w-en">{d.glossEn}</div>
<div className="w-ro">
{d.pos}
{d.freqRank ? ` · rank ${d.freqRank}` : ""}
</div>
</td>
<td>
<span className="state new">{statusOf(null) === "new" ? "Reference" : ""}</span>
</td>
<td />
</tr>
))}
</tbody>
</table>
</div>
</div>
{e.status === "new" ? (
<button className="btn sm" onClick={() => void act(() => markAsKnown(db, e.lemmaId, today))}>
Know it
</button>
) : (
<button className="btn sm" onClick={() => void act(() => forget(db, e.lemmaId))}>
Reset
</button>
)}
{e.source === "custom" && (
<button
className="btn sm"
title="Remove this word entirely"
onClick={() => void act(() => editRemoveCustomWord(db, e.lemmaId))}
>
Delete
</button>
)}
</>
);
const universe = filter === "mine" ? counts.byFilter.get("mine")! : entries.length;
return (
<>
<RouteHead title="단어" sub="Vocabulary">
<button
className="iconbtn"
title="Add a word"
aria-label="Add a word"
aria-pressed={adding}
onClick={() => setAdding((a) => !a)}
>
</button>
</RouteHead>
<div className="listhead">
<input
type="search"
value={query}
placeholder="Search 한글 or English…"
aria-label="Search your words in 한글 or English"
autoComplete="off"
onChange={(e) => setQuery(e.target.value)}
/>
<div className="filters" role="group" aria-label="Filter">
{FILTERS.map(([f, label]) => (
<button
key={f}
className="ko"
aria-pressed={filter === f && (f !== "all" || topic === null)}
onClick={() => {
setFilter(f);
if (f === "all") setTopic(null);
}}
>
{label} · {counts.byFilter.get(f)}
</button>
))}
{[...counts.byTopic].map(([t, n]) => (
<button
key={t}
className="ko"
aria-pressed={topic === t}
onClick={() => setTopic((cur) => (cur === t ? null : t))}
>
{t} · {n}
</button>
))}
</div>
</div>
<Scroll bare onNearEnd={() => setShownRows((n) => (n < matching.length ? n + PAGE : n))}>
{adding && (
<form
className="wrap add-word"
onSubmit={async (e) => {
e.preventDefault();
if (!draft.headword.trim() || !draft.gloss.trim()) return;
try {
const { created } = await editAddCustomWord(db, draft);
setAddNote(
created
? `Added ${draft.headword.trim()}.`
: `${draft.headword.trim()} was already in the dictionary — it is in your deck now.`,
);
setDraft({ headword: "", gloss: "", pos: "noun" });
} catch (err) {
setAddNote(err instanceof Error ? err.message : "Could not add that word.");
}
invalidate();
}}
>
<div className="card add-card">
<label className="field">
<span className="ko"></span>
<input
className="ko"
value={draft.headword}
required
onChange={(e) => setDraft({ ...draft, headword: e.target.value })}
/>
</label>
<label className="field">
Meaning
<input
value={draft.gloss}
required
onChange={(e) => setDraft({ ...draft, gloss: e.target.value })}
/>
</label>
<label className="field">
Part of speech
<select value={draft.pos} onChange={(e) => setDraft({ ...draft, pos: e.target.value })}>
{POSES.map((p) => (
<option key={p} value={p}>
{p}
</option>
))}
</select>
</label>
<button className="cta add-go" type="submit">
Add to deck
</button>
<p className="add-note">
{addNote ?? "Your own words sit alongside the dictionary and survive a rebuild of it."}
</p>
</div>
</form>
)}
<p className={`count tnum${wide ? " wrap" : ""}`}>
{matching.length} of {universe} words
</p>
{wide ? (
<div className="wrap">
<div className="tbl-scroll">
<table className="words">
<thead>
<tr>
<th className="shrink"></th>
<th>Meaning</th>
<th className="shrink">Topic</th>
<th className="shrink">State</th>
<th className="shrink" />
</tr>
</thead>
<tbody>
{matching.slice(0, shownRows).map((e) => (
<tr key={e.lemmaId}>
<td>
<div className="w-ko ko">{e.headword}</div>
</td>
<td>
<div className="w-en">{e.glossEn}</div>
<div className="w-pos">{e.pos}</div>
</td>
<td className="w-tag ko">{topicOf(e)}</td>
<td>
<span className={`state ${e.status}`}>
{STATUS_LABEL[e.status]}
{detail(e) && ` · ${detail(e)}`}
</span>
</td>
<td>
<div className="rowacts">{actions(e)}</div>
</td>
</tr>
))}
{!matching.length && (
<tr>
<td colSpan={5}>
<p className="empty">No words match that filter.</p>
</td>
</tr>
)}
</tbody>
</table>
</div>
</div>
) : (
<div className="vlist">
{matching.slice(0, shownRows).map((e) => (
<button className="vrow" key={e.lemmaId} onClick={() => setRow(e)}>
<span className="tx">
<span className="k ko">{e.headword}</span>
<span className="m">
{e.glossEn}
<span className="meta">
{" "}
· {[e.pos, e.status === "learning" ? "learning" : detail(e)].filter(Boolean).join(" · ")}
</span>
</span>
</span>
<span className={`dot ${e.status}`} title={STATUS_LABEL[e.status]} />
</button>
))}
{!matching.length && <p className="empty">No words match that filter.</p>}
</div>
)}
{extra.length > 0 && (
<div className="wrap dict-more">
<p className="count">
<span className="ko"></span> {extra.length} more in the dictionary, not in your deck
</p>
<div className="rows">
{extra.map((d) => (
<div className="row dict-row" key={d.lemmaId}>
<span className="tx">
<span className="t1 ko">{d.headword}</span>
<span className="t2">
{d.glossEn} · {d.pos}
{d.freqRank ? ` · rank ${d.freqRank}` : ""}
</span>
</span>
<button
className="btn sm"
onClick={async () => {
await editAddCustomWord(db, { headword: d.headword, pos: d.pos, gloss: d.glossEn });
invalidate();
}}
>
+ Add
</button>
</div>
))}
</div>
</div>
)}
</Scroll>
<Pop id="word-row" open={row !== null && !wide} onClose={() => setRow(null)} label="Word">
{row && (
<>
<div className="pop-b">
<span className="pop-k ko">{row.headword}</span>
<span className="pop-m">{row.glossEn}</span>
<span className="pop-n ko">
{topicOf(row)} · {row.pos} · {STATUS_LABEL[row.status]}
{detail(row) && ` · ${detail(row)}`}
</span>
</div>
<div className="pop-f">
{actions(row)}
<button className="btn sm" onClick={() => setRow(null)}>
Close
</button>
</div>
</>
)}
</Pop>
</>
);
}

View File

@@ -1,224 +1,312 @@
/* 오늘 — hero, tiles, phase cards, heatmap. */
/* 오늘 — the due card, the strip, the goal, and the folds. */
.hero {
display: grid;
grid-template-columns: 1.1fr 0.9fr;
gap: 20px;
padding: 20px;
.due-card {
display: flex;
flex-direction: column;
align-items: center;
gap: 14px;
padding: 22px 18px;
text-align: center;
}
.hero-l {
.due-top {
display: flex;
flex-direction: column;
gap: 2px;
}
.due-n {
font-family: var(--serif);
font-size: 52px;
font-weight: 700;
line-height: 1;
color: var(--jade);
}
.due-x {
font-size: 13px;
color: var(--ink3);
}
.goal-card {
display: flex;
flex-direction: column;
gap: 9px;
min-width: 0;
padding: 14px 16px;
}
.hero-l h1 {
font-size: 34px;
font-weight: 600;
line-height: 1.2;
}
.hero-sub {
font-size: 14px;
color: var(--ink2);
}
.hero-acts {
.goal-h {
display: flex;
gap: 9px;
flex-wrap: wrap;
margin-top: 5px;
}
.goal {
display: flex;
align-items: center;
gap: 10px;
margin-top: 6px;
font-size: 12px;
justify-content: space-between;
font-size: 12.5px;
color: var(--ink3);
}
.goal-h b {
color: var(--ink);
font-weight: 600;
}
.goalbar {
flex: 1;
height: 5px;
height: 8px;
position: relative;
overflow: hidden;
border-radius: 4px;
background: var(--sunk);
border: 1px solid var(--line);
}
.goalbar i {
position: absolute;
inset: 0 auto 0 0;
display: block;
height: 100%;
background: var(--jade);
}
.tiles {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 1px;
background: var(--line);
border: 1px solid var(--line);
align-content: start;
/* The background IS the hairline between tiles, so the box must not be
taller than its rows: as a stretched grid item it filled the rest of
the hero's height with a solid slab of line colour. */
align-self: start;
}
.tile {
background: var(--paper);
padding: 14px;
display: flex;
flex-direction: column;
gap: 1px;
}
.tile.accent {
background: var(--jade-soft);
}
.tile .v {
font-family: var(--serif);
font-size: 28px;
line-height: 1.1;
}
.tile .k {
font-size: 12px;
color: var(--ink2);
}
.tile .x {
font-size: 10.5px;
color: var(--ink3);
}
/* ── phases ──────────────────────────────────────────────────────── */
details.fold .fold-b.phases {
padding: 0;
}
.phases {
display: grid;
grid-template-columns: 1fr 1fr;
gap: 12px;
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
}
.phase {
display: flex;
gap: 13px;
padding: 13px;
border: 1px solid var(--line);
background: var(--raise);
}
.phase[data-st="now"] {
box-shadow: inset 3px 0 0 0 var(--hwang);
}
.phase[data-st="done"] {
box-shadow: inset 3px 0 0 0 var(--jade);
}
.phase .n {
font-size: 30px;
color: var(--ink3);
line-height: 1;
}
.ph-body {
flex: 1;
min-width: 0;
position: relative;
display: flex;
flex-direction: column;
gap: 5px;
gap: 6px;
padding: 15px 16px;
border: solid var(--line);
border-width: 0 1px 1px 0;
}
.ph-title {
.ph-top {
display: flex;
align-items: baseline;
gap: 8px;
flex-wrap: wrap;
gap: 9px;
}
.ph-title .ko {
font-size: 16px;
font-weight: 600;
.phase .num {
font-size: 26px;
font-weight: 700;
line-height: 1;
color: var(--line2);
}
.ph-title .nm {
.phase .ko {
font-size: 17px;
font-weight: 500;
}
.phase .nm {
font-size: 12px;
color: var(--ink3);
}
.ph-badge {
margin-left: auto;
font-size: 10.5px;
padding: 1px 7px;
border: 1px solid var(--line2);
color: var(--ink3);
white-space: nowrap;
.phase .dt {
max-width: 38ch;
font-size: 12.5px;
line-height: 1.5;
color: var(--ink2);
}
.phase[data-st="now"] .ph-badge {
border-color: var(--hwang);
color: var(--hwang);
background: var(--hwang-soft);
.phase .units {
font-size: 11.5px;
color: var(--ink3);
}
.ph-badge {
align-self: flex-start;
padding: 2px 7px;
border: 1px solid var(--line2);
font-size: 10.5px;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink3);
}
.phase .pbar {
height: 4px;
position: relative;
overflow: hidden;
background: var(--sunk);
}
.phase .pbar i {
position: absolute;
inset: 0 auto 0 0;
display: block;
background: var(--jade);
}
.phase[data-st="done"] .num {
color: var(--jade);
}
.phase[data-st="done"] .ph-badge {
border-color: var(--jade);
color: var(--jade-ink);
background: var(--jade-soft);
color: var(--jade-ink);
}
.ph-body .dt {
font-size: 12.5px;
color: var(--ink2);
.phase[data-st="now"] {
background: var(--raise);
box-shadow: inset 3px 0 0 var(--hwang);
}
.pbar {
height: 4px;
background: var(--sunk);
border: 1px solid var(--line);
.phase[data-st="now"] .num {
color: var(--hwang);
}
.pbar i {
display: block;
height: 100%;
background: var(--jade);
.phase[data-st="now"] .ph-badge {
border-color: var(--hwang);
background: var(--hwang-soft);
color: var(--hwang);
}
.ph-count {
font-size: 11px;
/* ── the study log ───────────────────────────────────────────────── */
.hm-scroll {
overflow-x: auto;
padding-bottom: 4px;
}
.hm {
display: flex;
gap: 9px;
min-width: max-content;
}
.hm-days {
display: grid;
grid-template-rows: repeat(7, 18px);
gap: 4px;
padding-top: 18px;
}
.hm-days span {
font-size: 10px;
line-height: 18px;
color: var(--ink3);
}
/* ── heatmap ─────────────────────────────────────────────────────── */
.hm {
display: grid;
grid-template-rows: repeat(7, 14px);
grid-auto-flow: column;
grid-auto-columns: 14px;
.hm-grid-wrap {
display: flex;
flex-direction: column;
gap: 3px;
overflow-x: auto;
}
.hm i {
.hm-months {
position: relative;
height: 14px;
font-size: 10.5px;
color: var(--ink3);
}
.hm-months span {
position: absolute;
white-space: nowrap;
}
.hm-grid {
display: grid;
grid-auto-flow: column;
grid-template-rows: repeat(7, 18px);
gap: 4px;
}
.hm-grid i,
.hm-legend i {
display: block;
width: 18px;
height: 18px;
border-radius: 3px;
background: var(--h0);
}
.hm i[data-level="1"] { background: var(--h1); }
.hm i[data-level="2"] { background: var(--h2); }
.hm i[data-level="3"] { background: var(--h3); }
.hm i[data-level="4"] { background: var(--h4); }
@media (max-width: 820px) {
.phases { grid-template-columns: 1fr; }
.hm-grid i[data-l="1"],
.hm-legend i[data-l="1"] {
background: var(--h1);
}
@media (max-width: 760px) {
.hero { grid-template-columns: 1fr; }
.hero-l h1 { font-size: 28px; }
.hm-grid i[data-l="2"],
.hm-legend i[data-l="2"] {
background: var(--h2);
}
.hm-grid i[data-l="3"],
.hm-legend i[data-l="3"] {
background: var(--h3);
}
.hm-grid i[data-l="4"],
.hm-legend i[data-l="4"] {
background: var(--h4);
}
.hm-grid i[data-future="1"] {
opacity: 0.35;
}
.hm-grid i[data-today="1"] {
outline: 1.5px solid var(--ink2);
outline-offset: 1px;
}
.hm-legend {
display: flex;
align-items: center;
flex-wrap: wrap;
gap: 6px;
margin-top: 10px;
font-size: 11.5px;
color: var(--ink3);
}
.hm-legend i {
width: 13px;
height: 13px;
}
.hm-total {
margin-left: auto;
}
/* ── the deck in numbers ─────────────────────────────────────────── */
.breakdown {
width: 100%;
border-collapse: collapse;
font-size: 14px;
}
.breakdown td {
padding: 7px 0;
border-bottom: 1px solid var(--line);
color: var(--ink2);
}
.breakdown tr:last-child td {
border-bottom: none;
}
.breakdown td:last-child {
text-align: right;
font-weight: 500;
color: var(--ink);
}
.today-foot {
padding: 2px 2px 0;
font-size: 12px;
color: var(--ink3);
}
@media (max-width: 599px) {
.phases {
grid-template-columns: 1fr;
}
}

View File

@@ -1,9 +1,143 @@
/* 단어 — the word table. */
/* 단어 — the fixed search and filters, the list, the table. */
.listhead {
flex: none;
padding: 10px var(--gutter) 0;
border-bottom: 1px solid var(--line);
background: var(--paper);
}
.listhead input[type="search"] {
width: 100%;
padding: 10px 12px;
border-radius: 10px;
}
.filters {
display: flex;
gap: 6px;
margin: 9px calc(-1 * var(--gutter)) 0;
padding: 0 var(--gutter) 9px;
overflow-x: auto;
scrollbar-width: none;
}
.filters::-webkit-scrollbar {
display: none;
}
.filters button {
flex: 0 0 auto;
min-height: 32px;
padding: 6px 12px;
border: 1px solid var(--line);
border-radius: 16px;
background: var(--raise);
font-size: 12.5px;
color: var(--ink2);
white-space: nowrap;
font-variant-numeric: tabular-nums;
}
.filters button[aria-pressed="true"] {
border-color: var(--jade);
background: var(--jade);
color: var(--on-jade);
}
.count {
padding: 8px var(--gutter) 2px;
font-size: 12px;
color: var(--ink3);
}
.wrap > .count,
.wrap .count {
padding-inline: 2px;
}
@media (min-width: 600px) {
.count.wrap {
padding-inline: 24px;
}
}
/* ── the list ────────────────────────────────────────────────────── */
.vlist {
background: var(--paper);
border-top: 1px solid var(--line);
}
.vrow {
display: flex;
align-items: center;
gap: 12px;
width: 100%;
min-height: var(--row-h);
padding: 10px var(--gutter);
border-bottom: 1px solid var(--line);
text-align: left;
}
.vrow:hover {
background: var(--raise);
}
.vrow .tx {
flex: 1;
min-width: 0;
display: flex;
flex-direction: column;
gap: 2px;
}
.vrow .k {
font-size: 18.5px;
font-weight: 500;
line-height: 1.35;
}
.vrow .m {
overflow: hidden;
white-space: nowrap;
text-overflow: ellipsis;
font-size: 14px;
}
.vrow .meta {
font-size: 12px;
color: var(--ink3);
}
.dot {
width: 9px;
height: 9px;
flex: none;
border-radius: 50%;
background: var(--line2);
}
.dot.learning {
background: var(--hwang);
}
.dot.review {
background: var(--jade);
}
.dot.secure {
background: var(--jade);
box-shadow: 0 0 0 3px var(--jade-soft);
}
/* ── the table, from 840px ───────────────────────────────────────── */
.tbl-scroll {
max-height: 62vh;
overflow: auto;
border-top: 1px solid var(--line);
overflow-x: auto;
border: 1px solid var(--line);
border-radius: var(--radius);
background: var(--paper);
}
table.words {
@@ -15,74 +149,91 @@ table.words th {
position: sticky;
top: 0;
z-index: 1;
background: var(--sunk);
padding: 9px 12px;
border-bottom: 1px solid var(--line);
background: var(--paper);
text-align: left;
font-size: 11px;
letter-spacing: 0.08em;
font-size: 10.5px;
font-weight: 500;
letter-spacing: 0.12em;
text-transform: uppercase;
color: var(--ink3);
font-weight: 600;
padding: 8px 12px;
border-bottom: 1px solid var(--line);
}
table.words th.shrink {
width: 1%;
white-space: nowrap;
}
table.words td {
padding: 8px 12px;
padding: 9px 12px;
border-bottom: 1px solid var(--line);
vertical-align: top;
vertical-align: middle;
}
table.words tr:last-child td {
border-bottom: none;
}
.w-ko {
font-size: 18px;
font-size: 19px;
font-weight: 500;
white-space: nowrap;
}
.w-en {
font-size: 14px;
}
.w-ro {
.w-pos {
font-size: 11.5px;
color: var(--ink3);
}
.w-tag {
font-size: 11px;
color: var(--ink3);
font-family: var(--mono);
white-space: nowrap;
}
.rowacts {
display: flex;
gap: 6px;
gap: 5px;
justify-content: flex-end;
}
@media (max-width: 640px) {
table.words td:nth-child(3),
table.words th:nth-child(3) {
display: none;
}
.rowacts .btn {
white-space: nowrap;
}
/* Add-your-own-word form. */
/* ── adding a word, and the dictionary beyond the deck ───────────── */
.add-word {
display: flex;
flex-wrap: wrap;
gap: 8px;
align-items: center;
margin-top: 12px;
padding: 12px;
background: var(--raise);
border: 1px solid var(--line);
padding-top: 14px;
}
.add-word input {
flex: 1 1 160px;
min-width: 0;
.add-card {
display: grid;
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
gap: 10px;
align-items: end;
padding: 14px;
}
.add-word input.ko {
font-size: 17px;
flex: 0 1 150px;
.add-go {
min-height: 44px;
}
.add-note {
flex-basis: 100%;
grid-column: 1 / -1;
font-size: 11.5px;
color: var(--ink3);
}
.dict-more {
padding-top: 16px;
}
.dict-row {
min-height: 56px;
}