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:
@@ -83,10 +83,7 @@ export function Routes() {
|
||||
</Route>
|
||||
|
||||
<Route id="words">
|
||||
<RouteHead title="단어" sub="Vocabulary" />
|
||||
<Scroll>
|
||||
<VocabTab />
|
||||
</Scroll>
|
||||
<VocabTab />
|
||||
</Route>
|
||||
|
||||
<Route id="learn">
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user