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>
|
||||||
|
|
||||||
<Route id="words">
|
<Route id="words">
|
||||||
<RouteHead title="단어" sub="Vocabulary" />
|
<VocabTab />
|
||||||
<Scroll>
|
|
||||||
<VocabTab />
|
|
||||||
</Scroll>
|
|
||||||
</Route>
|
</Route>
|
||||||
|
|
||||||
<Route id="learn">
|
<Route id="learn">
|
||||||
|
|||||||
@@ -65,7 +65,17 @@ export function RouteHead({
|
|||||||
* The route's scroller. The page itself never scrolls. The position is
|
* 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.
|
* 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 active = useRouteActive();
|
||||||
const ref = useRef<HTMLDivElement>(null);
|
const ref = useRef<HTMLDivElement>(null);
|
||||||
const top = useRef(0);
|
const top = useRef(0);
|
||||||
@@ -79,10 +89,13 @@ export function Scroll({ children }: { children: ReactNode }) {
|
|||||||
ref={ref}
|
ref={ref}
|
||||||
className="scroll"
|
className="scroll"
|
||||||
onScroll={(e) => {
|
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>
|
</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 { useStore } from "../../state/store.js";
|
||||||
import { useReview } from "../review/useReview.js";
|
import { useReview } from "../review/useReview.js";
|
||||||
import { useNavigator } from "../shell/router.js";
|
import { deck, streakFrom, studyLog, type DayRow, type DeckEntry } from "../../domain/cards.js";
|
||||||
import { counts, streakFrom, studyLog, type Counts, type DayRow } from "../../domain/cards.js";
|
import { curriculum, UNITS } from "../../domain/gate.js";
|
||||||
import { curriculum } from "../../domain/gate.js";
|
|
||||||
import { currentUnit } from "../../domain/progress.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";
|
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 }) {
|
function Heatmap({ rows, today }: { rows: DayRow[]; today: number }) {
|
||||||
const byDay = new Map(rows.map((r) => [r.day, r.reviews + r.drills]));
|
const box = useRef<HTMLDivElement>(null);
|
||||||
const start = today - HEATMAP_DAYS + 1;
|
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 level = (n: number) => (n === 0 ? 0 : n < 5 ? 1 : n < 15 ? 2 : n < 30 ? 3 : 4);
|
||||||
|
|
||||||
const cells = [];
|
const cells = [];
|
||||||
for (let d = start; d <= today; d++) {
|
const months = [];
|
||||||
const n = byDay.get(d) ?? 0;
|
let lastMonth = -1;
|
||||||
cells.push(
|
let lastLabel = -99;
|
||||||
<i
|
let total = 0;
|
||||||
key={d}
|
let active = 0;
|
||||||
data-level={level(n)}
|
for (let w = 0; w < weeks; w++) {
|
||||||
title={n ? `${n} answers` : "nothing"}
|
const first = dateOf(start + w * 7);
|
||||||
aria-label={n ? `${n} answers` : "nothing"}
|
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 (
|
return (
|
||||||
<div className="panel">
|
<div ref={box}>
|
||||||
<div className="panel-h">
|
<div className="hm-scroll">
|
||||||
<h2>공부 기록</h2>
|
<div className="hm">
|
||||||
<span className="note tnum">{total} answers in the last 22 weeks</span>
|
<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>
|
||||||
<div className="panel-b">
|
<div className="hm-legend">
|
||||||
<div className="hm">{cells}</div>
|
<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>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/* ── the screen ──────────────────────────────────────────────────── */
|
||||||
|
|
||||||
export function TodayTab() {
|
export function TodayTab() {
|
||||||
const { db, progress, prefs, today, revision } = useStore();
|
const { db, progress, prefs, today, revision } = useStore();
|
||||||
const nav = useNavigator();
|
|
||||||
const { start } = useReview();
|
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 [log, setLog] = useState<DayRow[]>([]);
|
||||||
|
const [grammar, setGrammar] = useState(0);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
(async () => {
|
||||||
const [c, rows] = await Promise.all([
|
const [p, w, s, rows, learned] = await Promise.all([
|
||||||
counts(db, today, { sentences: prefs.sentences, pool: progress }),
|
deck(db, { sentences: prefs.sentences, pool: progress }),
|
||||||
studyLog(db, today - HEATMAP_DAYS),
|
deck(db),
|
||||||
|
deck(db, { only: "sentences" }),
|
||||||
|
studyLog(db, today - MAX_WEEKS * 7 - 7),
|
||||||
|
readJsonMeta<Record<string, boolean>>(db, GRAMMAR_LEARNED, {}),
|
||||||
]);
|
]);
|
||||||
if (cancelled) return;
|
if (cancelled) return;
|
||||||
setStats(c);
|
setPool(p);
|
||||||
|
setWords(w);
|
||||||
|
setSentences(s);
|
||||||
setLog(rows);
|
setLog(rows);
|
||||||
|
setGrammar(Object.values(learned).filter(Boolean).length);
|
||||||
})();
|
})();
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
@@ -68,107 +168,159 @@ export function TodayTab() {
|
|||||||
}, [db, prefs.sentences, progress, revision, today]);
|
}, [db, prefs.sentences, progress, revision, today]);
|
||||||
|
|
||||||
const unit = currentUnit(progress);
|
const unit = currentUnit(progress);
|
||||||
const doneUnits = Object.keys(progress.done).length;
|
|
||||||
const totalUnits = curriculum.phases.reduce((a, p) => a + p.units.length, 0);
|
// The same number as the badge on 복습: what a review started now holds.
|
||||||
const streak = streakFrom(log, today);
|
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 todayRow = log.find((r) => r.day === today);
|
||||||
const answered = (todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0);
|
const reviewedToday = todayRow?.reviews ?? 0;
|
||||||
const goalPct = Math.min(100, Math.round((answered / Math.max(1, prefs.goal)) * 100));
|
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 (
|
return (
|
||||||
<>
|
<>
|
||||||
<div className="hero panel">
|
<div className="card due-card">
|
||||||
<div className="hero-l">
|
<div className="due-top">
|
||||||
<span className="eyebrow">
|
<span className="due-n tnum">{pool ? total : "—"}</span>
|
||||||
Phase {unit.phase} · unit {unit.id}
|
<span className="due-x">{sub}</span>
|
||||||
</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>
|
</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="strip">
|
||||||
<div className="tile accent">
|
<span>
|
||||||
<span className="v tnum">{stats?.due ?? "—"}</span>
|
🔥 <b>{streak}</b> day{streak === 1 ? "" : "s"}
|
||||||
<span className="k">Due</span>
|
</span>
|
||||||
</div>
|
<span>
|
||||||
<div className="tile">
|
<b>{(todayRow?.reviews ?? 0) + (todayRow?.drills ?? 0)}</b> answered today
|
||||||
<span className="v tnum">{streak}</span>
|
</span>
|
||||||
<span className="k">Day streak</span>
|
<span>
|
||||||
</div>
|
<b>{secure}</b> words secure
|
||||||
<div className="tile">
|
</span>
|
||||||
<span className="v tnum">{stats?.secure ?? "—"}</span>
|
</div>
|
||||||
<span className="k">Secure</span>
|
|
||||||
<span className="x">interval ≥ 21 days</span>
|
<div className="card goal-card">
|
||||||
</div>
|
<div className="goal-h">
|
||||||
<div className="tile">
|
<span>Today's reviews</span>
|
||||||
<span className="v tnum">
|
<span className="tnum">
|
||||||
{doneUnits}/{totalUnits}
|
<b>{reviewedToday}</b> / {prefs.goal}
|
||||||
</span>
|
</span>
|
||||||
<span className="k">Units done</span>
|
</div>
|
||||||
</div>
|
<div className="goalbar">
|
||||||
|
<i style={{ width: `${goalPct}%` }} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div className="panel">
|
<details className="fold">
|
||||||
<div className="panel-h">
|
<summary>
|
||||||
<h2>읽기까지의 길</h2>
|
<span>
|
||||||
<span className="note">The road to reading manhwa</span>
|
<span className="ko">진도</span> · Where you are
|
||||||
</div>
|
</span>
|
||||||
<div className="panel-b phases">
|
<span className="note tnum">
|
||||||
|
{curriculum.phases.length} phases · {UNITS.length} units
|
||||||
|
</span>
|
||||||
|
</summary>
|
||||||
|
<div className="fold-b phases">
|
||||||
{curriculum.phases.map((p) => {
|
{curriculum.phases.map((p) => {
|
||||||
const done = p.units.filter((u) => progress.done[u.id]).length;
|
const done = p.units.filter((u) => progress.done[u.id]).length;
|
||||||
const isNow = p.units.some((u) => u.id === unit.id);
|
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 (
|
return (
|
||||||
<div className="phase" data-st={state} key={p.phase}>
|
<div className="phase" data-st={state} key={p.phase}>
|
||||||
<span className="n serif">{p.phase}</span>
|
<div className="ph-top">
|
||||||
<div className="ph-body">
|
<span className="num serif">{p.phase}</span>
|
||||||
<div className="ph-title">
|
<span className="ko">{p.ko}</span>
|
||||||
<span className="ko">{p.ko}</span>
|
<span className="nm">{p.name}</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>
|
</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>
|
</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
|
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 { useEffect, useMemo, useState } from "react";
|
||||||
import { useStore } from "../../state/store.js";
|
import { useStore } from "../../state/store.js";
|
||||||
import { useReview } from "../review/useReview.js";
|
import {
|
||||||
import { deck, forget, markAsKnown, type CardStatus, type DeckEntry } from "../../domain/cards.js";
|
deck,
|
||||||
|
forget,
|
||||||
|
isMine,
|
||||||
|
markAsKnown,
|
||||||
|
type CardStatus,
|
||||||
|
type DeckEntry,
|
||||||
|
} from "../../domain/cards.js";
|
||||||
import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
|
import { editAddCustomWord, editRemoveCustomWord } from "../../db/writes.js";
|
||||||
import { search, type Entry } from "../../domain/lexicon.js";
|
import { search, type Entry } from "../../domain/lexicon.js";
|
||||||
import { ensureReferenceBand } from "../../domain/dictionary.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";
|
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> = {
|
const STATUS_LABEL: Record<CardStatus, string> = {
|
||||||
new: "New",
|
new: "New",
|
||||||
learning: "Learning",
|
learning: "Learning",
|
||||||
review: "Review",
|
review: "In review",
|
||||||
secure: "Secure",
|
secure: "Secure",
|
||||||
};
|
};
|
||||||
|
|
||||||
function statusText(e: DeckEntry): string {
|
/** His own words, and dictionary words he added, have no topic of their own. */
|
||||||
if (!e.card || e.status === "new") return STATUS_LABEL.new;
|
const OWN_TOPIC = "내 단어 My words";
|
||||||
if (e.status === "learning") return STATUS_LABEL.learning;
|
const topicOf = (e: DeckEntry) => e.topic ?? OWN_TOPIC;
|
||||||
const days = e.card.interval;
|
|
||||||
return `${STATUS_LABEL[e.status]} · every ${days} d`;
|
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() {
|
export function VocabTab() {
|
||||||
const { db, today, revision, invalidate } = useStore();
|
const { db, today, progress, revision, invalidate } = useStore();
|
||||||
const { start } = useReview();
|
const wide = useMedia(WIDE);
|
||||||
|
|
||||||
const [entries, setEntries] = useState<DeckEntry[]>([]);
|
const [entries, setEntries] = useState<DeckEntry[]>([]);
|
||||||
const [query, setQuery] = useState("");
|
const [query, setQuery] = useState("");
|
||||||
const [status, setStatus] = useState<CardStatus | "all">("all");
|
const [filter, setFilter] = useState<Filter>("mine");
|
||||||
const [pos, setPos] = useState<string>("all");
|
const [topic, setTopic] = useState<string | null>(null);
|
||||||
|
const [shownRows, setShownRows] = useState(PAGE);
|
||||||
const [dict, setDict] = useState<Entry[] | null>(null);
|
const [dict, setDict] = useState<Entry[] | null>(null);
|
||||||
const [adding, setAdding] = useState(false);
|
const [adding, setAdding] = useState(false);
|
||||||
const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" });
|
const [draft, setDraft] = useState({ headword: "", gloss: "", pos: "noun" });
|
||||||
const [addNote, setAddNote] = useState<string | null>(null);
|
const [addNote, setAddNote] = useState<string | null>(null);
|
||||||
|
/** The row whose actions are open, below 840px. */
|
||||||
|
const [row, setRow] = useState<DeckEntry | null>(null);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
let cancelled = false;
|
let cancelled = false;
|
||||||
(async () => {
|
void deck(db).then((rows) => {
|
||||||
const rows = await deck(db, { sentences: true });
|
|
||||||
if (!cancelled) setEntries(rows);
|
if (!cancelled) setEntries(rows);
|
||||||
})();
|
});
|
||||||
return () => {
|
return () => {
|
||||||
cancelled = true;
|
cancelled = true;
|
||||||
};
|
};
|
||||||
}, [db, revision]);
|
}, [db, revision]);
|
||||||
|
|
||||||
/* Searching past the deck reaches into the whole dictionary — for looking
|
/* Searching past the deck reaches into the whole dictionary — a word met
|
||||||
something up, not for studying it. Those rows are read-only here. */
|
in the wild can be looked up, and added. */
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const q = query.trim();
|
const q = query.trim();
|
||||||
if (q.length < 2) {
|
if (q.length < 2) {
|
||||||
@@ -73,233 +105,300 @@ export function VocabTab() {
|
|||||||
};
|
};
|
||||||
}, [db, query]);
|
}, [db, query]);
|
||||||
|
|
||||||
const positions = useMemo(
|
const passes = useMemo(() => {
|
||||||
() => ["all", ...[...new Set(entries.map((e) => e.pos))].sort()],
|
const due = (e: DeckEntry) => e.card !== null && e.status !== "new" && e.card.due <= today;
|
||||||
[entries],
|
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();
|
const q = query.trim().toLowerCase();
|
||||||
return entries.filter((e) => {
|
return entries.filter(
|
||||||
if (status !== "all" && e.status !== status) return false;
|
(e) =>
|
||||||
if (pos !== "all" && e.pos !== pos) return false;
|
passes(e, filter) &&
|
||||||
if (!q) return true;
|
(topic === null || topicOf(e) === topic) &&
|
||||||
return `${e.headword} ${e.glossEn}`.toLowerCase().includes(q);
|
(!q || `${e.headword} ${e.glossEn}`.toLowerCase().includes(q)),
|
||||||
});
|
);
|
||||||
}, [entries, pos, query, status]);
|
}, [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 inDeck = useMemo(() => new Set(entries.map((e) => e.lemmaId)), [entries]);
|
||||||
const extra = dict?.filter((d) => !inDeck.has(d.lemmaId)) ?? [];
|
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">
|
{e.status === "new" ? (
|
||||||
<div className="panel-h">
|
<button className="btn sm" onClick={() => void act(() => markAsKnown(db, e.lemmaId, today))}>
|
||||||
<h2>단어</h2>
|
Know it
|
||||||
<span className="note tnum">
|
</button>
|
||||||
{shown.length} of {entries.length} shown
|
) : (
|
||||||
</span>
|
<button className="btn sm" onClick={() => void act(() => forget(db, e.lemmaId))}>
|
||||||
</div>
|
Reset
|
||||||
|
</button>
|
||||||
<div className="panel-b">
|
)}
|
||||||
<div className="toolbar">
|
{e.source === "custom" && (
|
||||||
<input
|
<button
|
||||||
className="grow"
|
className="btn sm"
|
||||||
type="search"
|
title="Remove this word entirely"
|
||||||
value={query}
|
onClick={() => void act(() => editRemoveCustomWord(db, e.lemmaId))}
|
||||||
placeholder="Search 한글 or English…"
|
>
|
||||||
aria-label="Search your words in 한글 or English"
|
Delete
|
||||||
onChange={(e) => setQuery(e.target.value)}
|
</button>
|
||||||
/>
|
|
||||||
<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>
|
|
||||||
)}
|
)}
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
|
|
||||||
|
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 {
|
.due-card {
|
||||||
display: grid;
|
display: flex;
|
||||||
grid-template-columns: 1.1fr 0.9fr;
|
flex-direction: column;
|
||||||
gap: 20px;
|
align-items: center;
|
||||||
padding: 20px;
|
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;
|
display: flex;
|
||||||
flex-direction: column;
|
flex-direction: column;
|
||||||
gap: 9px;
|
gap: 9px;
|
||||||
min-width: 0;
|
padding: 14px 16px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.hero-l h1 {
|
.goal-h {
|
||||||
font-size: 34px;
|
|
||||||
font-weight: 600;
|
|
||||||
line-height: 1.2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-sub {
|
|
||||||
font-size: 14px;
|
|
||||||
color: var(--ink2);
|
|
||||||
}
|
|
||||||
|
|
||||||
.hero-acts {
|
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 9px;
|
justify-content: space-between;
|
||||||
flex-wrap: wrap;
|
font-size: 12.5px;
|
||||||
margin-top: 5px;
|
|
||||||
}
|
|
||||||
|
|
||||||
.goal {
|
|
||||||
display: flex;
|
|
||||||
align-items: center;
|
|
||||||
gap: 10px;
|
|
||||||
margin-top: 6px;
|
|
||||||
font-size: 12px;
|
|
||||||
color: var(--ink3);
|
color: var(--ink3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.goal-h b {
|
||||||
|
color: var(--ink);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
.goalbar {
|
.goalbar {
|
||||||
flex: 1;
|
height: 8px;
|
||||||
height: 5px;
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
border-radius: 4px;
|
||||||
background: var(--sunk);
|
background: var(--sunk);
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.goalbar i {
|
.goalbar i {
|
||||||
|
position: absolute;
|
||||||
|
inset: 0 auto 0 0;
|
||||||
display: block;
|
display: block;
|
||||||
height: 100%;
|
|
||||||
background: var(--jade);
|
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 ──────────────────────────────────────────────────────── */
|
/* ── phases ──────────────────────────────────────────────────────── */
|
||||||
|
|
||||||
|
details.fold .fold-b.phases {
|
||||||
|
padding: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.phases {
|
.phases {
|
||||||
display: grid;
|
display: grid;
|
||||||
grid-template-columns: 1fr 1fr;
|
grid-template-columns: repeat(auto-fit, minmax(210px, 1fr));
|
||||||
gap: 12px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase {
|
.phase {
|
||||||
display: flex;
|
position: relative;
|
||||||
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;
|
|
||||||
display: flex;
|
display: flex;
|
||||||
flex-direction: column;
|
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;
|
display: flex;
|
||||||
align-items: baseline;
|
align-items: baseline;
|
||||||
gap: 8px;
|
gap: 9px;
|
||||||
flex-wrap: wrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.ph-title .ko {
|
.phase .num {
|
||||||
font-size: 16px;
|
font-size: 26px;
|
||||||
font-weight: 600;
|
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;
|
font-size: 12px;
|
||||||
color: var(--ink3);
|
color: var(--ink3);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ph-badge {
|
.phase .dt {
|
||||||
margin-left: auto;
|
max-width: 38ch;
|
||||||
font-size: 10.5px;
|
font-size: 12.5px;
|
||||||
padding: 1px 7px;
|
line-height: 1.5;
|
||||||
border: 1px solid var(--line2);
|
color: var(--ink2);
|
||||||
color: var(--ink3);
|
|
||||||
white-space: nowrap;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.phase[data-st="now"] .ph-badge {
|
.phase .units {
|
||||||
border-color: var(--hwang);
|
font-size: 11.5px;
|
||||||
color: var(--hwang);
|
color: var(--ink3);
|
||||||
background: var(--hwang-soft);
|
}
|
||||||
|
|
||||||
|
.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 {
|
.phase[data-st="done"] .ph-badge {
|
||||||
border-color: var(--jade);
|
border-color: var(--jade);
|
||||||
color: var(--jade-ink);
|
|
||||||
background: var(--jade-soft);
|
background: var(--jade-soft);
|
||||||
|
color: var(--jade-ink);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ph-body .dt {
|
.phase[data-st="now"] {
|
||||||
font-size: 12.5px;
|
background: var(--raise);
|
||||||
color: var(--ink2);
|
box-shadow: inset 3px 0 0 var(--hwang);
|
||||||
}
|
}
|
||||||
|
|
||||||
.pbar {
|
.phase[data-st="now"] .num {
|
||||||
height: 4px;
|
color: var(--hwang);
|
||||||
background: var(--sunk);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.pbar i {
|
.phase[data-st="now"] .ph-badge {
|
||||||
display: block;
|
border-color: var(--hwang);
|
||||||
height: 100%;
|
background: var(--hwang-soft);
|
||||||
background: var(--jade);
|
color: var(--hwang);
|
||||||
}
|
}
|
||||||
|
|
||||||
.ph-count {
|
/* ── the study log ───────────────────────────────────────────────── */
|
||||||
font-size: 11px;
|
|
||||||
|
.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);
|
color: var(--ink3);
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── heatmap ─────────────────────────────────────────────────────── */
|
.hm-grid-wrap {
|
||||||
|
display: flex;
|
||||||
.hm {
|
flex-direction: column;
|
||||||
display: grid;
|
|
||||||
grid-template-rows: repeat(7, 14px);
|
|
||||||
grid-auto-flow: column;
|
|
||||||
grid-auto-columns: 14px;
|
|
||||||
gap: 3px;
|
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;
|
display: block;
|
||||||
|
width: 18px;
|
||||||
|
height: 18px;
|
||||||
|
border-radius: 3px;
|
||||||
background: var(--h0);
|
background: var(--h0);
|
||||||
}
|
}
|
||||||
|
|
||||||
.hm i[data-level="1"] { background: var(--h1); }
|
.hm-grid i[data-l="1"],
|
||||||
.hm i[data-level="2"] { background: var(--h2); }
|
.hm-legend i[data-l="1"] {
|
||||||
.hm i[data-level="3"] { background: var(--h3); }
|
background: var(--h1);
|
||||||
.hm i[data-level="4"] { background: var(--h4); }
|
|
||||||
|
|
||||||
@media (max-width: 820px) {
|
|
||||||
.phases { grid-template-columns: 1fr; }
|
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 760px) {
|
.hm-grid i[data-l="2"],
|
||||||
.hero { grid-template-columns: 1fr; }
|
.hm-legend i[data-l="2"] {
|
||||||
.hero-l h1 { font-size: 28px; }
|
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 {
|
.tbl-scroll {
|
||||||
max-height: 62vh;
|
overflow-x: auto;
|
||||||
overflow: auto;
|
border: 1px solid var(--line);
|
||||||
border-top: 1px solid var(--line);
|
border-radius: var(--radius);
|
||||||
|
background: var(--paper);
|
||||||
}
|
}
|
||||||
|
|
||||||
table.words {
|
table.words {
|
||||||
@@ -15,74 +149,91 @@ table.words th {
|
|||||||
position: sticky;
|
position: sticky;
|
||||||
top: 0;
|
top: 0;
|
||||||
z-index: 1;
|
z-index: 1;
|
||||||
background: var(--sunk);
|
padding: 9px 12px;
|
||||||
|
border-bottom: 1px solid var(--line);
|
||||||
|
background: var(--paper);
|
||||||
text-align: left;
|
text-align: left;
|
||||||
font-size: 11px;
|
font-size: 10.5px;
|
||||||
letter-spacing: 0.08em;
|
font-weight: 500;
|
||||||
|
letter-spacing: 0.12em;
|
||||||
text-transform: uppercase;
|
text-transform: uppercase;
|
||||||
color: var(--ink3);
|
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 {
|
table.words td {
|
||||||
padding: 8px 12px;
|
padding: 9px 12px;
|
||||||
border-bottom: 1px solid var(--line);
|
border-bottom: 1px solid var(--line);
|
||||||
vertical-align: top;
|
vertical-align: middle;
|
||||||
|
}
|
||||||
|
|
||||||
|
table.words tr:last-child td {
|
||||||
|
border-bottom: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-ko {
|
.w-ko {
|
||||||
font-size: 18px;
|
font-size: 19px;
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-en {
|
.w-en {
|
||||||
font-size: 14px;
|
font-size: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.w-ro {
|
.w-pos {
|
||||||
|
font-size: 11.5px;
|
||||||
|
color: var(--ink3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.w-tag {
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: var(--ink3);
|
color: var(--ink3);
|
||||||
font-family: var(--mono);
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
.rowacts {
|
.rowacts {
|
||||||
display: flex;
|
display: flex;
|
||||||
gap: 6px;
|
gap: 5px;
|
||||||
justify-content: flex-end;
|
justify-content: flex-end;
|
||||||
}
|
}
|
||||||
|
|
||||||
@media (max-width: 640px) {
|
.rowacts .btn {
|
||||||
table.words td:nth-child(3),
|
white-space: nowrap;
|
||||||
table.words th:nth-child(3) {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* Add-your-own-word form. */
|
/* ── adding a word, and the dictionary beyond the deck ───────────── */
|
||||||
|
|
||||||
.add-word {
|
.add-word {
|
||||||
display: flex;
|
padding-top: 14px;
|
||||||
flex-wrap: wrap;
|
|
||||||
gap: 8px;
|
|
||||||
align-items: center;
|
|
||||||
margin-top: 12px;
|
|
||||||
padding: 12px;
|
|
||||||
background: var(--raise);
|
|
||||||
border: 1px solid var(--line);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-word input {
|
.add-card {
|
||||||
flex: 1 1 160px;
|
display: grid;
|
||||||
min-width: 0;
|
grid-template-columns: repeat(auto-fit, minmax(150px, 1fr));
|
||||||
|
gap: 10px;
|
||||||
|
align-items: end;
|
||||||
|
padding: 14px;
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-word input.ko {
|
.add-go {
|
||||||
font-size: 17px;
|
min-height: 44px;
|
||||||
flex: 0 1 150px;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.add-note {
|
.add-note {
|
||||||
flex-basis: 100%;
|
grid-column: 1 / -1;
|
||||||
font-size: 11.5px;
|
font-size: 11.5px;
|
||||||
color: var(--ink3);
|
color: var(--ink3);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.dict-more {
|
||||||
|
padding-top: 16px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.dict-row {
|
||||||
|
min-height: 56px;
|
||||||
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user