From ede8f295a22591bc898a6e2b367b869e20987a01 Mon Sep 17 00:00:00 2001 From: MechaCat02 Date: Wed, 16 Sep 2026 22:05:54 +0200 Subject: [PATCH] =?UTF-8?q?feat(ui):=20=EB=8B=A8=EC=96=B4=20and=20?= =?UTF-8?q?=EC=98=A4=EB=8A=98,=20rebuilt=20for=20a=20thumb?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 단어. 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) --- app/src/ui/routes.tsx | 5 +- app/src/ui/shell/Route.tsx | 19 +- app/src/ui/tabs/TodayTab.tsx | 378 ++++++++++++++++------- app/src/ui/tabs/VocabTab.tsx | 579 ++++++++++++++++++++--------------- app/src/ui/tabs/today.css | 396 ++++++++++++++---------- app/src/ui/tabs/vocab.css | 225 +++++++++++--- 6 files changed, 1051 insertions(+), 551 deletions(-) diff --git a/app/src/ui/routes.tsx b/app/src/ui/routes.tsx index 9bc9c7b..d659b55 100644 --- a/app/src/ui/routes.tsx +++ b/app/src/ui/routes.tsx @@ -83,10 +83,7 @@ export function Routes() { - - - - + diff --git a/app/src/ui/shell/Route.tsx b/app/src/ui/shell/Route.tsx index 94c9a0d..1ce1db9 100644 --- a/app/src/ui/shell/Route.tsx +++ b/app/src/ui/shell/Route.tsx @@ -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(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(); }} > -
{children}
+ {bare ? children :
{children}
} ); } diff --git a/app/src/ui/tabs/TodayTab.tsx b/app/src/ui/tabs/TodayTab.tsx index 4612959..124ac13 100644 --- a/app/src/ui/tabs/TodayTab.tsx +++ b/app/src/ui/tabs/TodayTab.tsx @@ -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(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( - , - ); + 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( + + {first.toLocaleDateString(undefined, { month: "short" })} + , + ); + } + for (let d = 0; d < 7; d++) { + const day = start + w * 7 + d; + if (day > today) { + cells.push(); + continue; + } + const r = byDay.get(day); + const n = (r?.reviews ?? 0) + (r?.drills ?? 0); + total += n; + if (n) active++; + cells.push( + , + ); + } } - const total = rows.reduce((a, r) => a + r.reviews + r.drills, 0); - return ( -
-
-

공부 기록

- {total} answers in the last 22 weeks +
+
+
+ +
+
{months}
+
{cells}
+
+
-
-
{cells}
+
+ fewer + {[0, 1, 2, 3, 4].map((l) => ( + + ))} + more + + {total.toLocaleString()} answers over {active} day{active === 1 ? "" : "s"} · last {weeks} weeks +
); } +/* ── the screen ──────────────────────────────────────────────────── */ + export function TodayTab() { const { db, progress, prefs, today, revision } = useStore(); - const nav = useNavigator(); const { start } = useReview(); - const [stats, setStats] = useState(null); + const [pool, setPool] = useState(null); + const [words, setWords] = useState([]); + const [sentences, setSentences] = useState([]); const [log, setLog] = useState([]); + 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>(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 ( <> -
-
- - Phase {unit.phase} · unit {unit.id} - -

{unit.ko}

-

{unit.goal}

- -
- - -
- -
-
- -
- - {answered} / {prefs.goal} today - -
+
+
+ {pool ? total : "—"} + {sub}
+ +
-
-
- {stats?.due ?? "—"} - Due -
-
- {streak} - Day streak -
-
- {stats?.secure ?? "—"} - Secure - interval ≥ 21 days -
-
- - {doneUnits}/{totalUnits} - - Units done -
+
+ + 🔥 {streak} day{streak === 1 ? "" : "s"} + + + {(todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0)} answered today + + + {secure} words secure + +
+ +
+
+ Today's reviews + + {reviewedToday} / {prefs.goal} + +
+
+
-
-
-

읽기까지의 길

- The road to reading manhwa -
-
+
+ + + 진도 · Where you are + + + {curriculum.phases.length} phases · {UNITS.length} units + + +
{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 (
- {p.phase} -
-
- {p.ko} - {p.name} - - {state === "done" ? "complete" : state === "now" ? "you are here" : "ahead"} - -
-

- {isNow ? `${unit.ko} — ${unit.goal}` : p.units.map((u) => u.ko).join(" · ")} -

-
- -
- - {done} of {p.units.length} units - +
+ {p.phase} + {p.ko} + {p.name}
+ + {state === "done" ? "complete" : state === "now" ? "you are here" : "up next"} + + + {isNow ? `${unit.ko} — ${unit.goal}` : p.units.map((u) => u.ko).join(" · ")} + + + {done} of {p.units.length} units + + + +
); })}
-
+
- +
+ + + 기록 · Study log + + {streak ? `${streak}-day streak` : "no streak yet"} + +
+ +
+
+ +
+ + + 현황 · Deck breakdown + + {words.length} words + +
+ + + + + + + + + + + + + + + + + + + + + + + +
Words, not yet seen{words.filter((e) => e.status === "new").length}
Words in review{words.filter((e) => e.status === "learning" || e.status === "review").length}
Words secure (≥ 21 days){secure}
Sentences started + {sentences.filter((e) => e.status !== "new").length} / {sentences.length} +
Grammar points learned + {grammar} / {GRAMMAR_POINTS} +
+
+
+ +

+ {words.length} words · {sentences.length} sentence cards · {GRAMMAR_POINTS} grammar points ·{" "} + {SOUND_WORDS} sound words. +

); } diff --git a/app/src/ui/tabs/VocabTab.tsx b/app/src/ui/tabs/VocabTab.tsx index 8a2c830..6164d7f 100644 --- a/app/src/ui/tabs/VocabTab.tsx +++ b/app/src/ui/tabs/VocabTab.tsx @@ -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 = { 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([]); const [query, setQuery] = useState(""); - const [status, setStatus] = useState("all"); - const [pos, setPos] = useState("all"); + const [filter, setFilter] = useState("mine"); + const [topic, setTopic] = useState(null); + const [shownRows, setShownRows] = useState(PAGE); const [dict, setDict] = useState(null); const [adding, setAdding] = useState(false); const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" }); const [addNote, setAddNote] = useState(null); + /** The row whose actions are open, below 840px. */ + const [row, setRow] = useState(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(FILTERS.map(([f]) => [f, 0])); + const byTopic = new Map(); + 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) => { + await fn(); + setRow(null); + invalidate(); + }; + + const actions = (e: DeckEntry) => ( <> -
-
-

단어

- - {shown.length} of {entries.length} shown - -
- -
-
- setQuery(e.target.value)} - /> - - -
- - {adding && ( -
{ - 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(); - }} - > - setDraft({ ...draft, headword: e.target.value })} - /> - setDraft({ ...draft, gloss: e.target.value })} - /> - - -

- {addNote ?? - "Your own words sit alongside the dictionary and survive a rebuild of it."} -

-
- )} - -
- {STATUSES.map((s) => ( - - ))} -
- -
- {positions.map((p) => ( - - ))} -
-
- -
- - - - - - - - - - {shown.map((e) => ( - - - - - - - ))} - {!shown.length && ( - - - - )} - -
한글MeaningState -
-
{e.headword}
-
-
{e.glossEn}
-
{e.pos}
-
- {statusText(e)} - -
- {e.status === "new" ? ( - - ) : ( - - )} - {e.source === "custom" && ( - - )} -
-
-

Nothing matches.

-
-
-
- - {extra.length > 0 && ( -
-
-

사전

- - {extra.length} more in the dictionary — reference only, not in the deck - -
-
- - - {extra.map((d) => ( - - - - - - ))} - -
-
{d.headword}
-
-
{d.glossEn}
-
- {d.pos} - {d.freqRank ? ` · rank ${d.freqRank}` : ""} -
-
- {statusOf(null) === "new" ? "Reference" : ""} - -
-
-
+ {e.status === "new" ? ( + + ) : ( + + )} + {e.source === "custom" && ( + )} ); + + const universe = filter === "mine" ? counts.byFilter.get("mine")! : entries.length; + + return ( + <> + + + + +
+ setQuery(e.target.value)} + /> +
+ {FILTERS.map(([f, label]) => ( + + ))} + {[...counts.byTopic].map(([t, n]) => ( + + ))} +
+
+ + setShownRows((n) => (n < matching.length ? n + PAGE : n))}> + {adding && ( +
{ + 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(); + }} + > +
+ + + + +

+ {addNote ?? "Your own words sit alongside the dictionary and survive a rebuild of it."} +

+
+
+ )} + +

+ {matching.length} of {universe} words +

+ + {wide ? ( +
+
+ + + + + + + + + + + {matching.slice(0, shownRows).map((e) => ( + + + + + + + + ))} + {!matching.length && ( + + + + )} + +
한글MeaningTopicState +
+
{e.headword}
+
+
{e.glossEn}
+
{e.pos}
+
{topicOf(e)} + + {STATUS_LABEL[e.status]} + {detail(e) && ` · ${detail(e)}`} + + +
{actions(e)}
+
+

No words match that filter.

+
+
+
+ ) : ( +
+ {matching.slice(0, shownRows).map((e) => ( + + ))} + {!matching.length &&

No words match that filter.

} +
+ )} + + {extra.length > 0 && ( +
+

+ 사전 — {extra.length} more in the dictionary, not in your deck +

+
+ {extra.map((d) => ( +
+ + {d.headword} + + {d.glossEn} · {d.pos} + {d.freqRank ? ` · rank ${d.freqRank}` : ""} + + + +
+ ))} +
+
+ )} +
+ + setRow(null)} label="Word"> + {row && ( + <> +
+ {row.headword} + {row.glossEn} + + {topicOf(row)} · {row.pos} · {STATUS_LABEL[row.status]} + {detail(row) && ` · ${detail(row)}`} + +
+
+ {actions(row)} + +
+ + )} +
+ + ); } diff --git a/app/src/ui/tabs/today.css b/app/src/ui/tabs/today.css index 9839c84..0210d98 100644 --- a/app/src/ui/tabs/today.css +++ b/app/src/ui/tabs/today.css @@ -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; + } } diff --git a/app/src/ui/tabs/vocab.css b/app/src/ui/tabs/vocab.css index 5bf0638..5922bb0 100644 --- a/app/src/ui/tabs/vocab.css +++ b/app/src/ui/tabs/vocab.css @@ -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; +}