@khorsheed/dsh-library 0.0.0-stage → 0.1.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (130) hide show
  1. package/CHANGELOG.md +19 -0
  2. package/README.en.md +49 -0
  3. package/README.i18n.yaml +6 -0
  4. package/README.md +48 -2
  5. package/cordis.patch.yml +17 -0
  6. package/lib/client.js +66334 -0
  7. package/lib/index.js +3318 -0
  8. package/lib/profiles-CFrPQdfO.js +1692 -0
  9. package/lib/tool.js +1597 -0
  10. package/lib/typert.host.d.ts +3 -0
  11. package/lib/typert.host.js +3811 -0
  12. package/lib/typert.remote-client.d.ts +137 -0
  13. package/lib/typert.remote-client.js +3077 -0
  14. package/lib/types/cards.d.ts +41 -0
  15. package/lib/types/cards.js +111 -0
  16. package/lib/types/client/LibraryPane.d.ts +26 -0
  17. package/lib/types/client/LibraryPane.js +129 -0
  18. package/lib/types/client/bind/BindChip.d.ts +33 -0
  19. package/lib/types/client/bind/BindChip.js +102 -0
  20. package/lib/types/client/contract.d.ts +167 -0
  21. package/lib/types/client/contract.js +1 -0
  22. package/lib/types/client/covers.d.ts +36 -0
  23. package/lib/types/client/covers.js +66 -0
  24. package/lib/types/client/definition.d.ts +27 -0
  25. package/lib/types/client/definition.js +32 -0
  26. package/lib/types/client/icons-local.d.ts +41 -0
  27. package/lib/types/client/icons-local.js +25 -0
  28. package/lib/types/client/icons.d.ts +27 -0
  29. package/lib/types/client/icons.js +11 -0
  30. package/lib/types/client/index.d.ts +35 -0
  31. package/lib/types/client/index.js +231 -0
  32. package/lib/types/client/judge.d.ts +70 -0
  33. package/lib/types/client/judge.js +262 -0
  34. package/lib/types/client/locales.d.ts +553 -0
  35. package/lib/types/client/locales.js +1076 -0
  36. package/lib/types/client/look.d.ts +63 -0
  37. package/lib/types/client/look.js +64 -0
  38. package/lib/types/client/math.d.ts +24 -0
  39. package/lib/types/client/math.js +47 -0
  40. package/lib/types/client/mistakes.d.ts +21 -0
  41. package/lib/types/client/mistakes.js +28 -0
  42. package/lib/types/client/preset-visibility.d.ts +109 -0
  43. package/lib/types/client/preset-visibility.js +123 -0
  44. package/lib/types/client/quiz-drafts.d.ts +29 -0
  45. package/lib/types/client/quiz-drafts.js +50 -0
  46. package/lib/types/client/reveal.d.ts +21 -0
  47. package/lib/types/client/reveal.js +31 -0
  48. package/lib/types/client/store.d.ts +111 -0
  49. package/lib/types/client/store.js +48 -0
  50. package/lib/types/client/titles.d.ts +17 -0
  51. package/lib/types/client/titles.js +23 -0
  52. package/lib/types/client/ui.d.ts +96 -0
  53. package/lib/types/client/ui.js +124 -0
  54. package/lib/types/client/views/book.d.ts +57 -0
  55. package/lib/types/client/views/book.js +164 -0
  56. package/lib/types/client/views/cards.d.ts +17 -0
  57. package/lib/types/client/views/cards.js +53 -0
  58. package/lib/types/client/views/home.d.ts +26 -0
  59. package/lib/types/client/views/home.js +263 -0
  60. package/lib/types/client/views/lesson.d.ts +42 -0
  61. package/lib/types/client/views/lesson.js +146 -0
  62. package/lib/types/client/views/path.d.ts +32 -0
  63. package/lib/types/client/views/path.js +64 -0
  64. package/lib/types/client/views/quiz.d.ts +42 -0
  65. package/lib/types/client/views/quiz.js +366 -0
  66. package/lib/types/client/views/station-quiz.d.ts +43 -0
  67. package/lib/types/client/views/station-quiz.js +203 -0
  68. package/lib/types/client/views/station.d.ts +18 -0
  69. package/lib/types/client/views/station.js +142 -0
  70. package/lib/types/image-refs.d.ts +35 -0
  71. package/lib/types/image-refs.js +71 -0
  72. package/lib/types/index.d.ts +51 -0
  73. package/lib/types/index.js +31 -0
  74. package/lib/types/mastery.d.ts +81 -0
  75. package/lib/types/mastery.js +110 -0
  76. package/lib/types/profiles.d.ts +31 -0
  77. package/lib/types/profiles.js +25 -0
  78. package/lib/types/remote.d.ts +355 -0
  79. package/lib/types/remote.js +332 -0
  80. package/lib/types/service/assess.d.ts +144 -0
  81. package/lib/types/service/assess.js +773 -0
  82. package/lib/types/service/bindings.d.ts +92 -0
  83. package/lib/types/service/bindings.js +198 -0
  84. package/lib/types/service/cards.d.ts +85 -0
  85. package/lib/types/service/cards.js +320 -0
  86. package/lib/types/service/core.d.ts +124 -0
  87. package/lib/types/service/core.js +169 -0
  88. package/lib/types/service/pages.d.ts +60 -0
  89. package/lib/types/service/pages.js +125 -0
  90. package/lib/types/service/roadmap.d.ts +99 -0
  91. package/lib/types/service/roadmap.js +284 -0
  92. package/lib/types/service/rules.d.ts +63 -0
  93. package/lib/types/service/rules.js +245 -0
  94. package/lib/types/service/shelf.d.ts +136 -0
  95. package/lib/types/service/shelf.js +422 -0
  96. package/lib/types/service.d.ts +42 -0
  97. package/lib/types/service.js +41 -0
  98. package/lib/types/store.d.ts +257 -0
  99. package/lib/types/store.js +1376 -0
  100. package/lib/types/tool-definition.d.ts +49 -0
  101. package/lib/types/tool-definition.js +49 -0
  102. package/lib/types/tool.d.ts +71 -0
  103. package/lib/types/tool.js +174 -0
  104. package/lib/types/tools/assess.d.ts +11 -0
  105. package/lib/types/tools/assess.js +82 -0
  106. package/lib/types/tools/binding.d.ts +11 -0
  107. package/lib/types/tools/binding.js +46 -0
  108. package/lib/types/tools/cards.d.ts +11 -0
  109. package/lib/types/tools/cards.js +100 -0
  110. package/lib/types/tools/kit.d.ts +215 -0
  111. package/lib/types/tools/kit.js +281 -0
  112. package/lib/types/tools/notes.d.ts +11 -0
  113. package/lib/types/tools/notes.js +40 -0
  114. package/lib/types/tools/page.d.ts +11 -0
  115. package/lib/types/tools/page.js +59 -0
  116. package/lib/types/tools/project.d.ts +11 -0
  117. package/lib/types/tools/project.js +67 -0
  118. package/lib/types/tools/quiz.d.ts +11 -0
  119. package/lib/types/tools/quiz.js +74 -0
  120. package/lib/types/tools/read.d.ts +11 -0
  121. package/lib/types/tools/read.js +127 -0
  122. package/lib/types/tools/resource.d.ts +11 -0
  123. package/lib/types/tools/resource.js +147 -0
  124. package/lib/types/tools/roadmap.d.ts +11 -0
  125. package/lib/types/tools/roadmap.js +102 -0
  126. package/lib/types/types.d.ts +752 -0
  127. package/lib/types/types.js +159 -0
  128. package/locale/en.json +6 -0
  129. package/locale/zh.json +6 -0
  130. package/package.json +135 -3
@@ -0,0 +1,164 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * One textbook in the sidebar (a book, a course, a coach-authored outline):
4
+ * its place in the study order and origin, its roadmap draft waiting for
5
+ * 采用, its adopted path as an S-shaped trail of nodes, the chapters the path
6
+ * skips, and one node's page. Every path belongs to a textbook — the
7
+ * project carries none. 「我的记录」 exists only when the station covers
8
+ * its own textbook. Every station carries the same four tabs —
9
+ * 导读 / 我的记录 / 讲义 / 章节考: the gate tab is the station's quiz on a
10
+ * quiz / read node, and its acceptance sheet (the coach judges the rubric
11
+ * with library_assess) on an acceptance node.
12
+ *
13
+ * @module @khorsheed/dsh-library/client/views/book
14
+ */
15
+ import { useState } from 'react';
16
+ import { ListRow, PanelHeader } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
17
+ import { coverKind, isThickResource, parseLocator, } from "../../types.js";
18
+ import { colorOf, Cover, coverOf } from "../look.js";
19
+ import { CoverChips } from "../covers.js";
20
+ import { IconRefreshOutlineMedium, IconSparkleMedium, IconTrashOutlineMedium } from "../icons.js";
21
+ import { ConfirmDialog, OriginTag, outcome, StateLine, useLoad, value, when } from "../ui.js";
22
+ import css from '../LibraryPane.module.css';
23
+ import { Path } from "./path.js";
24
+ import { splitTitle } from "../titles.js";
25
+ /** A node's contents rows of one thick resource, as titles. */
26
+ export function rowsOf(node, resource) {
27
+ const toc = resource.toc ?? [];
28
+ return node.covers
29
+ .flatMap(cover => (coverKind(cover) === 'resource' && cover.ref === resource.id && cover.locator !== undefined ? parseLocator(cover.locator) : []))
30
+ .flatMap(range => toc.filter(row => row.n >= range.from && row.n <= range.to).map(row => row.title));
31
+ }
32
+ /** The kind line of a thick resource: a book's bookKind, else its kind label. */
33
+ export function kindLabelOf(resource, t) {
34
+ return resource.kind === 'book' ? t(`bookKind.${resource.meta?.bookKind ?? 'knowledge'}`) : t(`resource.kind.${resource.kind}`);
35
+ }
36
+ /** One resource: draft card, path, or the ask for one. */
37
+ export function BookView({ p, projectId, resourceId }) {
38
+ const { t, actions, useStore } = p;
39
+ const rev = useStore(s => s.rev);
40
+ const loaded = useLoad(async () => {
41
+ const [detail, roadmaps] = await Promise.all([outcome(p.getProject(projectId)), value(p.getRoadmap(projectId, resourceId))]);
42
+ return { resource: detail.resources.find(candidate => candidate.id === resourceId), resources: detail.resources, project: detail.project, shade: Math.max(0, detail.resources.filter(isThickResource).findIndex(candidate => candidate.id === resourceId)), ...roadmaps };
43
+ }, [projectId, resourceId, rev]);
44
+ // Resource-level quizzes hang on no station — this section is their entry.
45
+ const assessments = useLoad(() => value(p.listAssessments(projectId, resourceId)), [projectId, resourceId, rev]);
46
+ const [notice, setNotice] = useState();
47
+ const [busy, setBusy] = useState(false);
48
+ const [confirmingDelete, setConfirmingDelete] = useState(false);
49
+ const [deleteError, setDeleteError] = useState();
50
+ // The delete dialog names the photographed pages that go with the textbook.
51
+ const pages = useLoad(() => confirmingDelete ? value(p.listPages(projectId, resourceId)) : Promise.resolve(undefined), [projectId, resourceId, confirmingDelete]);
52
+ const resource = loaded.data?.resource;
53
+ const looseQuizzes = (assessments.data?.assessments ?? []).filter(quiz => quiz.type === 'quiz' && quiz.scope.nodeId === undefined).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
54
+ const prime = (text) => {
55
+ void p.bindTarget({ projectId, resourceId });
56
+ setNotice(p.primeConversation(text) ? t('notice.primed') : t('notice.noConversation'));
57
+ };
58
+ const act = async (run) => {
59
+ setBusy(true);
60
+ try {
61
+ await run();
62
+ actions.refresh();
63
+ }
64
+ catch (failure) {
65
+ setNotice(failure instanceof Error ? failure.message : String(failure));
66
+ }
67
+ finally {
68
+ setBusy(false);
69
+ }
70
+ };
71
+ const remove = async () => {
72
+ setBusy(true);
73
+ setDeleteError(undefined);
74
+ try {
75
+ await outcome(p.removeResource(projectId, resourceId));
76
+ setConfirmingDelete(false);
77
+ actions.refresh();
78
+ actions.pop();
79
+ }
80
+ catch (failure) {
81
+ setDeleteError(failure instanceof Error ? failure.message : String(failure));
82
+ setBusy(false);
83
+ }
84
+ };
85
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: resource === undefined ? '' : splitTitle(resource.title).main, onBack: () => { actions.pop(); }, backLabel: t('action.back'), actions: resource === undefined ? [] : [
86
+ { id: 'ask', label: t('action.askCoach'), icon: _jsx(IconSparkleMedium, {}), onSelect: () => { prime(t('prime.readBook', { book: resource.title })); } },
87
+ { id: 'replan', label: t('book.replan'), icon: _jsx(IconRefreshOutlineMedium, {}), onSelect: () => { prime(t('prime.replan', { book: resource.title })); } },
88
+ { id: 'bind', label: t('action.bindBook'), icon: _jsx(IconSparkleMedium, {}), menuOnly: true, onSelect: () => { void p.bindTarget({ projectId, resourceId }).then(() => { setNotice(t('notice.bound')); }); } },
89
+ // 知识型 / 材料型 is rarely changed: it lives in the menu, not on the page.
90
+ ...(isThickResource(resource) ? [{ id: 'kind', label: t(resource.meta?.bookKind === 'material' ? 'book.kindToKnowledge' : 'book.kindToMaterial'), icon: _jsx(IconRefreshOutlineMedium, {}), menuOnly: true, onSelect: () => { void act(() => outcome(p.setBookKind(projectId, resourceId, resource.meta?.bookKind === 'material' ? 'knowledge' : 'material'))); } }] : []),
91
+ { id: 'delete', label: t('book.delete'), icon: _jsx(IconTrashOutlineMedium, {}), menuOnly: true, onSelect: () => { setDeleteError(undefined); setConfirmingDelete(true); } },
92
+ ], moreLabel: t('action.more') }), _jsxs("div", { className: css.body, "data-color": loaded.data?.project === undefined ? undefined : colorOf(loaded.data.project), children: [_jsx(StateLine, { loaded: loaded, t: t }), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), resource !== undefined && (_jsxs("section", { className: css.bookHero, children: [_jsx(Cover, { book: coverOf(resource), shade: loaded.data?.shade ?? 0, size: "hero" }), _jsxs("span", { className: css.bookHeroText, children: [_jsx("span", { className: css.bookHeroTitle, children: splitTitle(resource.title).main }), splitTitle(resource.title).sub !== undefined && _jsx("span", { className: css.muted, children: splitTitle(resource.title).sub }), _jsxs("span", { className: css.bookHeroMeta, children: [_jsx(OriginTag, { origin: resource.origin, digital: resource.kind === 'course', t: t }), resource.meta?.bookKind === 'material' && _jsx("span", { className: css.chip, "data-tone": "warn", children: t('bookKind.material') }), [resource.meta?.edition, resource.seq === undefined ? undefined : t('book.seqOf', { seq: String(resource.seq) })].filter(Boolean).map(part => _jsx("span", { children: part }, part))] })] })] })), resource?.meta?.extras !== undefined && resource.meta.extras.length > 0 && _jsx(Extras, { p: p, resource: resource }), resource !== undefined && loaded.data?.draft !== undefined && (_jsx(DraftCard, { p: p, draft: loaded.data.draft, context: { resources: loaded.data.resources }, tocBy: resource.tocSource?.by, skipped: skippedRows(loaded.data.draft, resource), replaces: loaded.data.roadmap !== undefined, busy: busy, onAdopt: () => { void act(() => outcome(p.adoptRoadmap(projectId, resourceId))); }, onDiscard: () => { void act(() => outcome(p.discardDraft(projectId, resourceId))); } })), resource !== undefined && loaded.data?.roadmap !== undefined && (_jsx(_Fragment, { children: _jsx(Path, { p: p, projectId: projectId, resource: resource, roadmap: loaded.data.roadmap, onOpen: node => { actions.push({ view: 'node', projectId, resourceId, nodeId: node.id, tab: 'guide' }); }, onLesson: node => { actions.push({ view: 'lesson', projectId, resourceId, nodeId: node.id }); }, onRetake: node => { actions.push({ view: 'node', projectId, resourceId, nodeId: node.id, tab: 'quiz' }); } }) })), resource !== undefined && loaded.data?.roadmap === undefined && loaded.data?.draft === undefined && (_jsxs("div", { className: css.empty, children: [_jsx("div", { className: css.emptyTitle, children: t('book.noRoadmap') }), _jsx("div", { className: css.muted, children: t('book.noRoadmapBody') }), _jsx("button", { type: "button", className: css.primary, onClick: () => { prime(t('prime.plan', { book: resource.title })); }, children: t('book.askPlan') })] })), resource !== undefined && looseQuizzes.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('book.quizzes', { count: String(looseQuizzes.length) }) }), _jsx(QuizRows, { p: p, projectId: projectId, quizzes: looseQuizzes })] })), resource !== undefined && isThickResource(resource) && (_jsx(Contents, { p: p, resource: resource, skipped: loaded.data?.roadmap === undefined ? [] : skippedRows(loaded.data.roadmap, resource) }))] }), confirmingDelete && resource !== undefined && (_jsx(ConfirmDialog, { title: t('book.deleteTitle', { book: resource.title, project: loaded.data?.project.name ?? '' }), lines: [
93
+ loaded.data?.roadmap !== undefined
94
+ ? t('book.deletePath', { done: String(loaded.data.roadmap.nodes.filter(node => node.state === 'done').length), total: String(loaded.data.roadmap.nodes.length) })
95
+ : loaded.data?.draft !== undefined ? t('book.deleteDraft') : undefined,
96
+ t('book.deletePages', { count: String(pages.data?.pages.length ?? 0) }),
97
+ (assessments.data?.assessments.length ?? 0) > 0 ? t('book.deleteQuizzes', { count: String(assessments.data.assessments.length) }) : undefined,
98
+ ].filter((line) => line !== undefined), keep: t('book.deleteKeep'), note: resource.seq !== undefined ? t('book.deleteRenumber') : undefined, confirmLabel: t('book.deleteConfirmButton'), cancelLabel: t('action.cancel'), busy: busy, error: deleteError, onConfirm: () => { void remove(); }, onCancel: () => { setConfirmingDelete(false); } }))] }));
99
+ }
100
+ /** The textbook's contents rows no station of `roadmap` covers — what this path skips. */
101
+ function skippedRows(roadmap, resource) {
102
+ const covered = new Set();
103
+ let whole = false;
104
+ for (const node of roadmap.nodes) {
105
+ for (const cover of node.covers) {
106
+ if (coverKind(cover) !== 'resource' || cover.ref !== resource.id)
107
+ continue;
108
+ if (cover.locator === undefined) {
109
+ whole = true;
110
+ continue;
111
+ }
112
+ for (const range of parseLocator(cover.locator)) {
113
+ for (let n = range.from; n <= range.to; n += 1)
114
+ covered.add(n);
115
+ }
116
+ }
117
+ }
118
+ if (whole || covered.size === 0)
119
+ return [];
120
+ return (resource.toc ?? []).filter(row => !covered.has(row.n)).map(row => ({ n: row.n, title: row.title }));
121
+ }
122
+ /** 「本路线不学」 on a draft card: the skipped chapters, folded. */
123
+ function SkippedRows({ p, rows }) {
124
+ const { t } = p;
125
+ if (rows.length === 0)
126
+ return null;
127
+ return (_jsxs("details", { className: css.skipped, children: [_jsx("summary", { children: t('book.skipped', { count: String(rows.length) }) }), _jsx("ul", { children: rows.map(row => _jsx("li", { children: row.title }, row.n)) })] }));
128
+ }
129
+ /** The contents, folded into one line: how many chapters, how many this path skips; open for the rows (skipped ones dimmed) and where the contents came from. */
130
+ function Contents({ p, resource, skipped }) {
131
+ const { t } = p;
132
+ const skippedRows = new Set(skipped.map(row => row.n));
133
+ const rows = resource.toc ?? [];
134
+ return (_jsxs("details", { className: css.contentsFold, children: [_jsxs("summary", { children: [_jsxs("span", { children: [t('book.contentsLine', { rows: String(rows.length) }), skipped.length > 0 ? ` · ${t('book.skipped', { count: String(skipped.length) })}` : ''] }), _jsx("span", { className: css.muted, children: t('book.contentsOpen') })] }), _jsx("ol", { className: css.contentsList, children: rows.map(row => _jsxs("li", { "data-skipped": skippedRows.has(row.n) ? 'true' : undefined, children: [row.part === undefined ? row.title : `${row.part} · ${row.title}`, skippedRows.has(row.n) ? ` · ${t('book.skippedTag')}` : ''] }, row.n)) }), resource.tocSource?.url !== undefined && _jsx("a", { className: css.link, href: resource.tocSource.url, target: "_blank", rel: "noreferrer", children: t('book.contentsSource') })] }));
135
+ }
136
+ /** The draft card on a textbook's page: what the coach proposes, what it skips, and the learner's 采用 / 放弃. */
137
+ export function DraftCard({ p, draft, context, tocBy, skipped, replaces, busy, onAdopt, onDiscard }) {
138
+ const { t } = p;
139
+ return (_jsxs("section", { className: css.draft, children: [_jsxs("div", { className: css.draftHead, children: [_jsx("span", { className: css.draftTitle, children: t('book.draftTitle') }), tocBy !== undefined && _jsx("span", { className: css.chip, "data-tone": tocBy === 'web' ? 'web' : 'know', children: t(`tocSource.${tocBy}`) })] }), _jsxs("div", { className: css.muted, children: [t('book.draftMeta', { count: String(draft.nodes.length) }), draft.note === undefined ? '' : ` · ${draft.note}`] }), _jsx("ol", { className: css.draftNodes, children: draft.nodes.map(node => (_jsxs("li", { children: [_jsx("span", { className: css.kind, "data-kind": node.gate, children: t(`nodeKind.${node.gate}`) }), _jsxs("div", { className: css.extraText, children: [_jsx("span", { children: node.title }), node.goals.length > 0 && _jsx("span", { className: css.muted, children: node.goals[0] }), node.gate === 'quiz' && node.points !== undefined && node.points.length > 0 && (_jsx("span", { className: css.muted, children: t('book.points', { points: node.points.map(point => point.title).join(' / ') }) })), node.gate === 'acceptance' && node.acceptance !== undefined && (_jsx("span", { className: css.muted, children: t('accept.meta', { count: String(node.acceptance.length) }) })), _jsx(CoverChips, { covers: node.covers, context: context, t: t })] })] }, node.id))) }), _jsx(SkippedRows, { p: p, rows: skipped }), replaces && _jsx("div", { className: css.muted, children: t('book.draftReplaces') }), _jsxs("div", { className: css.actions, children: [_jsx("button", { type: "button", className: css.primary, disabled: busy, onClick: onAdopt, children: t('book.adopt') }), _jsx("button", { type: "button", className: css.ghost, disabled: busy, onClick: onDiscard, children: t('book.discard') })] })] }));
140
+ }
141
+ /** The 「开始前 / 附录」 strip: what the resource holds that is not a stop on the path. */
142
+ function Extras({ p, resource }) {
143
+ const { t } = p;
144
+ const [open, setOpen] = useState(false);
145
+ const extras = resource.meta?.extras ?? [];
146
+ const before = extras.filter(extra => extra.when === 'before');
147
+ return (_jsxs("section", { className: css.extras, children: [_jsxs("button", { type: "button", className: css.extrasHead, "aria-expanded": open, onClick: () => { setOpen(!open); }, children: [_jsx("span", { children: before.length > 0 ? t('book.extrasBefore', { first: before[0].title }) : t('book.extrasTitle') }), _jsxs("span", { className: css.muted, children: [t('book.extrasCount', { count: String(extras.length) }), open ? ' ▴' : ' ▾'] })] }), open && (_jsx("ul", { className: css.extrasList, children: extras.map(extra => (_jsxs("li", { children: [_jsx("span", { className: css.kind, "data-kind": extra.when === 'before' ? 'quiz' : 'read', children: t(extra.when === 'before' ? 'book.extraBefore' : 'book.extraReference') }), _jsxs("span", { className: css.extraText, children: [_jsx("b", { children: extra.title }), (extra.note !== undefined || extra.where !== undefined) && _jsx("span", { className: css.muted, children: [extra.where, extra.note].filter(Boolean).join(' · ') })] })] }, extra.title))) }))] }));
148
+ }
149
+ /**
150
+ * Rows of quizzes that gate no station (the resource page's 本书测验, the project
151
+ * page's 自由测验, a station's same-scope list) — each opens the paper. The
152
+ * route's resource comes from the quiz's own scope.
153
+ */
154
+ export function QuizRows({ p, projectId, quizzes }) {
155
+ const { t, actions } = p;
156
+ return (_jsx(_Fragment, { children: quizzes.map(quiz => (_jsx(ListRow, { title: t('node.quizRow', { count: String(quiz.questions?.length ?? 0) }), meta: [t(`focus.${quiz.focus ?? 'mixed'}`), sourceMix((quiz.questions ?? []).map(question => question.evidence.kind), t), when(quiz.createdAt, t)].join(' · '), trailing: _jsx("span", { className: css.linkText, children: t('quiz.start') }), onSelect: () => { actions.push({ view: 'quiz', projectId, ...(quiz.scope.resourceId === undefined ? {} : { resourceId: quiz.scope.resourceId }), quizId: quiz.id }); } }, quiz.id))) }));
157
+ }
158
+ /** 「照片 3 · 领域知识 7」 for a quiz row. */
159
+ export function sourceMix(kinds, t) {
160
+ const counts = new Map();
161
+ for (const kind of kinds)
162
+ counts.set(kind, (counts.get(kind) ?? 0) + 1);
163
+ return [...counts].map(([kind, count]) => `${t(kind === 'photo' ? 'evidence.photo' : kind === 'web' ? 'evidence.web' : 'evidence.knowledge')} ${count}`).join(' · ');
164
+ }
@@ -0,0 +1,17 @@
1
+ /**
2
+ * 知识卡片: the project's cards — what the learner marked, asked about and
3
+ * missed — filtered by where they came from, searchable with the same
4
+ * matcher the coach uses, each card showing its sentence, its sources, how
5
+ * weak it is and where its review stands.
6
+ *
7
+ * @module @khorsheed/dsh-library/client/views/cards
8
+ */
9
+ import { type ReactNode } from 'react';
10
+ import type { LibraryCardFilter } from '../store.ts';
11
+ import { type ViewProps } from '../ui.tsx';
12
+ /** The project's knowledge cards. */
13
+ export declare function CardsView({ p, projectId, filter }: ViewProps & {
14
+ projectId: string;
15
+ filter: LibraryCardFilter;
16
+ }): ReactNode;
17
+ //# sourceMappingURL=cards.d.ts.map
@@ -0,0 +1,53 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * 知识卡片: the project's cards — what the learner marked, asked about and
4
+ * missed — filtered by where they came from, searchable with the same
5
+ * matcher the coach uses, each card showing its sentence, its sources, how
6
+ * weak it is and where its review stands.
7
+ *
8
+ * @module @khorsheed/dsh-library/client/views/cards
9
+ */
10
+ import { useState } from 'react';
11
+ import { ChipRow, PanelHeader } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
12
+ import { IconSearchOutlineMedium, IconSparkleMedium } from "../icons.js";
13
+ import { BoxDots, daysUntil, Highlight, outcome, shortGloss, StateLine, useLoad, value, WeaknessBars } from "../ui.js";
14
+ import { MathText } from "../math.js";
15
+ import css from '../LibraryPane.module.css';
16
+ const FILTERS = ['all', 'photo', 'chat', 'quiz', 'due'];
17
+ /** The project's knowledge cards. */
18
+ export function CardsView({ p, projectId, filter }) {
19
+ const { t, actions, useStore } = p;
20
+ const rev = useStore(s => s.rev);
21
+ const [searching, setSearching] = useState(false);
22
+ const [query, setQuery] = useState('');
23
+ const [open, setOpen] = useState();
24
+ const project = useLoad(() => outcome(p.getProject(projectId)), [projectId, rev]);
25
+ const cards = useLoad(() => value(p.listCards({
26
+ projectId,
27
+ ...(filter === 'due' ? { due: true, sort: 'due' } : {}),
28
+ ...(filter === 'photo' || filter === 'chat' || filter === 'quiz' ? { source: filter } : {}),
29
+ ...(filter === 'all' ? { sort: 'recent' } : {}),
30
+ ...(query.trim() === '' ? {} : { query }),
31
+ })), [projectId, filter, query, rev]);
32
+ const resources = new Map((project.data?.resources ?? []).map(resource => [resource.id, resource.title]));
33
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: t('cards.title'), meta: cards.data === undefined ? undefined : String(cards.data.total), onBack: () => { actions.pop(); }, backLabel: t('action.back'), actions: [
34
+ { id: 'search', label: t('cards.search'), icon: _jsx(IconSearchOutlineMedium, {}), pressed: searching, onSelect: () => { setSearching(!searching); setQuery(''); } },
35
+ { id: 'review', label: t('review.start'), icon: _jsx(IconSparkleMedium, {}), onSelect: () => { actions.push({ view: 'review', projectId }); } },
36
+ ], moreLabel: t('action.more') }), searching
37
+ ? (_jsx("div", { className: css.searchRow, children: _jsx("input", { className: css.input, autoFocus: true, value: query, placeholder: t('cards.searchPlaceholder'), onChange: event => { setQuery(event.target.value); } }) }))
38
+ : (_jsx(ChipRow, { label: t('cards.filters'), chips: FILTERS.map(id => ({ id, label: t(`cards.filter.${id}`) })), value: filter, onSelect: next => { actions.replace({ view: 'cards', projectId, filter: next }); } })), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: cards, t: t }), cards.data !== undefined && cards.data.cards.length === 0 && _jsx("div", { className: css.muted, children: t('cards.empty') }), _jsx("div", { className: css.cardList, children: (cards.data?.cards ?? []).map(card => (_jsx(CardRow, { p: p, projectId: projectId, card: card, bookTitle: card.resourceId === undefined ? undefined : resources.get(card.resourceId), open: open === card.id, onToggle: () => { setOpen(open === card.id ? undefined : card.id); } }, card.id))) })] })] }));
39
+ }
40
+ /** One card; tapping opens its sentences, sources and actions. */
41
+ function CardRow({ p, projectId, card, bookTitle, open, onToggle }) {
42
+ const { t, actions } = p;
43
+ const [notice, setNotice] = useState();
44
+ const due = daysUntil(card.mastery.dueAt);
45
+ const [busy, setBusy] = useState(false);
46
+ const setStatus = (status) => {
47
+ if (busy)
48
+ return;
49
+ setBusy(true);
50
+ void outcome(p.editCard(projectId, card.id, { status })).then(() => { actions.refresh(); }, (failure) => { setNotice(String(failure)); setBusy(false); });
51
+ };
52
+ return (_jsxs("article", { className: css.cardRow, "data-open": open ? 'true' : 'false', children: [_jsxs("button", { type: "button", className: css.cardMain, onClick: onToggle, "aria-expanded": open, children: [_jsxs("span", { className: css.cardTop, children: [_jsx("span", { className: card.kind === 'word' ? css.term : css.concept, children: _jsx(MathText, { text: card.term }) }), _jsx("span", { className: css.cardGloss, children: _jsx(MathText, { text: shortGloss(card.gloss, 24) }) }), _jsx(WeaknessBars, { weakness: card.weakness, label: t('cards.weakness', { level: String(card.weakness) }) })] }), card.contexts[0] !== undefined && (_jsx("span", { className: css.sentence, children: _jsx(Highlight, { sentence: card.contexts[0].sentence, term: card.term }) })), _jsxs("span", { className: css.chips, children: [_jsx("span", { className: css.chip, children: bookTitle ?? t('cards.projectLevel') }), [...new Set(card.sources.map(source => source.type))].map(type => (_jsx("span", { className: css.chip, "data-tone": type === 'photo' ? 'photo' : type === 'chat' || type === 'quiz' ? 'weak' : undefined, children: t(`source.${type}`) }, type))), _jsx("span", { className: css.chipPlain, children: due === 0 ? t('cards.dueNow') : t('cards.dueIn', { days: String(due) }) })] })] }), open && (_jsxs("div", { className: css.cardMore, children: [_jsx("div", { children: _jsx(MathText, { text: card.gloss }) }), card.userGloss !== undefined && _jsx("div", { children: t('cards.userGloss', { gloss: card.userGloss }) }), card.aliases.length > 0 && _jsx("div", { className: css.muted, children: t('cards.aliases', { aliases: card.aliases.join('、') }) }), card.contexts.slice(1).map(context => (_jsx("div", { className: css.sentence, children: _jsx(Highlight, { sentence: context.sentence, term: card.term }) }, context.sentence))), _jsx("ul", { className: css.sources, children: card.sources.slice(-5).map(source => (_jsxs("li", { children: [t(`source.${source.type}`), source.note === undefined ? '' : `:${source.note}`] }, `${source.type}-${source.at}`))) }), _jsx("div", { className: css.row, children: _jsx(BoxDots, { box: card.mastery.box, label: t('cards.box', { box: String(card.mastery.box) }) }) }), _jsxs("div", { className: css.actions, children: [_jsx("button", { type: "button", className: css.secondary, onClick: () => { setNotice(p.primeConversation(t('prime.card', { term: card.term })) ? t('notice.primed') : t('notice.noConversation')); }, children: t('action.askCoach') }), _jsx("button", { type: "button", className: css.ghost, disabled: busy, onClick: () => { setStatus('mastered'); }, children: t('cards.mastered') }), _jsx("button", { type: "button", className: css.danger, disabled: busy, onClick: () => { setStatus('deleted'); }, children: t('cards.delete') })] }), notice !== undefined && _jsx("div", { className: css.notice, children: notice })] }))] }));
53
+ }
@@ -0,0 +1,26 @@
1
+ /**
2
+ * The sidebar's top screens: 今日 (what to do now — continue learning,
3
+ * reviews due, the projects, what the coach just noted), the new-project
4
+ * form, and one project's page: its goal, its textbooks in study order (each
5
+ * with its own path — the project carries none), the drafts waiting for
6
+ * 采用, its links. Deleting a project sits in the page's menu and asks
7
+ * through a dialog that spells out what goes.
8
+ *
9
+ * @module @khorsheed/dsh-library/client/views/home
10
+ */
11
+ import { type ReactNode } from 'react';
12
+ import { type ViewProps } from '../ui.tsx';
13
+ /**
14
+ * 今日: the projects first — each card carries its goal, its progress and
15
+ * where to continue (the newest project's 继续 is the screen's one solid
16
+ * button) — then the reviews due as one quiet row, then what the coach just
17
+ * noted (math typeset, one line each).
18
+ */
19
+ export declare function TodayView({ p }: ViewProps): ReactNode;
20
+ /** 新建项目. */
21
+ export declare function NewProjectView({ p }: ViewProps): ReactNode;
22
+ /** One project: its goal band, continue learning, drafts waiting, its textbooks in study order, its links. */
23
+ export declare function ProjectView({ p, projectId }: ViewProps & {
24
+ projectId: string;
25
+ }): ReactNode;
26
+ //# sourceMappingURL=home.d.ts.map
@@ -0,0 +1,263 @@
1
+ import { jsx as _jsx, jsxs as _jsxs, Fragment as _Fragment } from "react/jsx-runtime";
2
+ /**
3
+ * The sidebar's top screens: 今日 (what to do now — continue learning,
4
+ * reviews due, the projects, what the coach just noted), the new-project
5
+ * form, and one project's page: its goal, its textbooks in study order (each
6
+ * with its own path — the project carries none), the drafts waiting for
7
+ * 采用, its links. Deleting a project sits in the page's menu and asks
8
+ * through a dialog that spells out what goes.
9
+ *
10
+ * @module @khorsheed/dsh-library/client/views/home
11
+ */
12
+ import { useState } from 'react';
13
+ import { ChipRow, PanelHeader } from '@khorsheed/dsh-client-ui-panel-kit/src/client/index.ts';
14
+ import { isThickResource, LIBRARY_COLORS, LIBRARY_ICONS } from "../../types.js";
15
+ import { colorOf, Cover, coverOf, GlyphStrokes, iconOf, ProjectGlyph, Ring } from "../look.js";
16
+ import { QuizRows } from "./book.js";
17
+ import { IconLinkOutlineMedium, IconPlayOutlineMedium, IconPlusOutlineMedium, IconRefreshOutlineMedium, IconSparkleMedium, IconTrashOutlineMedium } from "../icons.js";
18
+ import { IconArticleOutlineMedium, IconCardsOutlineMedium, IconCourseOutlineMedium, IconHardwareOutlineMedium, IconPenOutlineMedium, IconRepoOutlineMedium, IconToolOutlineMedium } from "../icons-local.js";
19
+ import { ConfirmDialog, OriginTag, outcome, Segments, StateLine, useLoad, value, when } from "../ui.js";
20
+ import { splitTitle, stationTitle } from "../titles.js";
21
+ import { MathText } from "../math.js";
22
+ import css from '../LibraryPane.module.css';
23
+ /** A project's textbooks (thick rows), in study order. */
24
+ function textbooksOf(project) {
25
+ return project.resources.filter(row => row.seq !== undefined || row.roadmap !== undefined || row.hasDraft || row.kind === 'book' || row.kind === 'course');
26
+ }
27
+ /** Where to continue in a project: the first textbook (study order) with a current station. */
28
+ function continueOf(project) {
29
+ for (const textbook of textbooksOf(project)) {
30
+ if (textbook.current !== undefined)
31
+ return { textbook, station: textbook.current };
32
+ }
33
+ return undefined;
34
+ }
35
+ /** 「继续学习」: one card that jumps straight into the current station. */
36
+ function ContinueCard({ p, projectId, label, target }) {
37
+ const { t, actions } = p;
38
+ return (_jsxs("section", { className: `${css.continue} ${css.projectNext}`, children: [_jsxs("span", { className: css.projectNextText, children: [_jsx("span", { className: css.continueLabel, children: label }), _jsx("span", { className: css.continueTitle, children: stationTitle(target.station.title) }), _jsx("span", { className: css.muted, children: t('continue.where', { seq: String(target.textbook.seq ?? ''), book: splitTitle(target.textbook.title).main }) })] }), _jsx("button", { type: "button", className: css.primary, onClick: () => { actions.push({ view: 'node', projectId, resourceId: target.textbook.id, nodeId: target.station.id, tab: 'guide' }); }, children: t('continue.go') })] }));
39
+ }
40
+ /**
41
+ * 今日: the projects first — each card carries its goal, its progress and
42
+ * where to continue (the newest project's 继续 is the screen's one solid
43
+ * button) — then the reviews due as one quiet row, then what the coach just
44
+ * noted (math typeset, one line each).
45
+ */
46
+ export function TodayView({ p }) {
47
+ const { t, actions, useStore } = p;
48
+ const rev = useStore(s => s.rev);
49
+ const projects = useLoad(() => value(p.listProjects()), [rev]);
50
+ const due = useLoad(() => value(p.dueCards()), [rev]);
51
+ const records = useLoad(() => value(p.recentRecords()), [rev]);
52
+ const changes = useLoad(() => value(p.recentChanges()), [rev]);
53
+ // 「最近的变化」: knowledge points that moved, and (language projects) the words the coach noted — newest first.
54
+ const feed = [
55
+ ...(changes.data?.changes ?? []).map(change => ({ kind: 'point', at: change.at, change })),
56
+ ...(records.data?.records ?? []).filter(record => record.source === 'chat').map(record => ({ kind: 'card', at: record.at, record })),
57
+ ].sort((a, b) => b.at.localeCompare(a.at)).slice(0, 5);
58
+ const list = projects.data?.projects ?? [];
59
+ const dueCards = due.data?.cards ?? [];
60
+ // 今日复习: every project's open misses (one retake paper across its stations), then the due cards.
61
+ const retakeRows = list.filter(project => (project.openMisses ?? 0) > 0);
62
+ const [retaking, setRetaking] = useState();
63
+ const [retakeError, setRetakeError] = useState();
64
+ const startRetake = (projectId) => {
65
+ setRetaking(projectId);
66
+ setRetakeError(undefined);
67
+ void outcome(p.createRetake(projectId)).then(created => {
68
+ actions.push({ view: 'quiz', projectId, quizId: created.assessment.id });
69
+ }, (failure) => { setRetakeError(failure instanceof Error ? failure.message : String(failure)); }).finally(() => { setRetaking(undefined); });
70
+ };
71
+ // Projects come most-recent first: the first one with a current station owns the solid 继续.
72
+ const leadId = list.find(project => continueOf(project) !== undefined)?.id;
73
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: t('today.title'), actions: [
74
+ { id: 'new', label: t('project.new'), icon: _jsx(IconPlusOutlineMedium, {}), onSelect: () => { actions.push({ view: 'newProject' }); } },
75
+ { id: 'refresh', label: t('action.refresh'), icon: _jsx(IconRefreshOutlineMedium, {}), onSelect: () => { actions.refresh(); } },
76
+ ], moreLabel: t('action.more') }), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: projects, t: t }), projects.data !== undefined && list.length === 0 && (_jsxs("div", { className: css.empty, children: [_jsx("div", { className: css.emptyTitle, children: t('today.emptyTitle') }), _jsx("div", { className: css.muted, children: t('today.emptyBody') }), _jsx("button", { type: "button", className: css.primary, onClick: () => { actions.push({ view: 'newProject' }); }, children: t('project.new') })] })), list.length > 0 && (_jsxs("section", { className: css.section, children: [_jsxs("div", { className: css.sectionHead, children: [_jsx("h3", { className: css.sectionTitle, children: t('today.projects') }), _jsxs("button", { type: "button", className: css.textButton, onClick: () => { actions.push({ view: 'newProject' }); }, children: ["\uFF0B ", t('project.new')] })] }), _jsx("div", { className: css.projectList, children: list.map(project => _jsx(ProjectCard, { p: p, project: project, lead: project.id === leadId }, project.id)) })] })), (retakeRows.length > 0 || dueCards.length > 0) && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('today.dueTitle') }), retakeRows.map(project => (_jsxs("div", { className: css.reviewRow, children: [_jsx("span", { className: css.big, children: project.openMisses }), _jsxs("span", { className: css.reviewText, children: [_jsx("span", { children: t('today.retakeLine', { project: project.name }) }), _jsx("span", { className: css.muted, children: t('today.retakeHint') })] }), _jsx("button", { type: "button", className: leadId === undefined && project === retakeRows[0] ? css.primary : css.secondary, disabled: retaking !== undefined, onClick: () => { startRetake(project.id); }, children: t('node.retakeStart') })] }, project.id))), dueCards.length > 0 && (_jsxs("div", { className: css.reviewRow, children: [_jsx("span", { className: css.big, children: dueCards.length }), _jsxs("span", { className: css.reviewText, children: [_jsx("span", { children: t('today.cardsDue') }), _jsx("span", { className: css.muted, children: t('today.dueMinutes', { minutes: String(Math.max(1, Math.round(dueCards.length / 4))) }) })] }), _jsx("button", { type: "button", className: leadId === undefined && retakeRows.length === 0 ? css.primary : css.secondary, onClick: () => { actions.push({ view: 'review' }); }, children: t('today.startReview') })] })), retakeError !== undefined && _jsx("div", { className: css.error, children: retakeError })] })), feed.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('today.changes') }), _jsx("div", { className: css.recordList, children: feed.map(entry => entry.kind === 'point' ? (_jsxs("button", { type: "button", className: css.record, onClick: () => {
77
+ const { change } = entry;
78
+ if (change.nodeId !== undefined && change.resourceId !== undefined)
79
+ actions.push({ view: 'node', projectId: change.projectId, resourceId: change.resourceId, nodeId: change.nodeId, tab: 'quiz' });
80
+ else
81
+ actions.push({ view: 'project', projectId: change.projectId });
82
+ }, children: [_jsx("span", { className: css.changeDot, "data-state": entry.change.to, "aria-hidden": "true" }), _jsxs("span", { className: css.recordText, children: [_jsx("span", { className: css.recordTitle, children: t(entry.change.to === 'mastered' ? 'today.changeMastered' : entry.change.to === 'weak' ? 'today.changeWeak' : entry.change.to === 'practicing' ? 'today.changePracticing' : 'today.changeUntested', { title: entry.change.title ?? '' }) }), _jsx("span", { className: css.muted, children: [
83
+ ...((entry.change.count ?? 0) > 0 ? [t('today.changeOpen', { count: String(entry.change.count) })] : []),
84
+ ...(entry.change.detail === undefined ? [] : [entry.change.detail]),
85
+ entry.change.projectName,
86
+ when(entry.change.at, t),
87
+ ].join(' · ') })] })] }, `${entry.change.projectId}-${entry.change.at}-${entry.change.id ?? ''}`)) : (_jsxs("button", { type: "button", className: css.record, onClick: () => { actions.push({ view: 'cards', projectId: entry.record.projectId, filter: 'chat' }); }, children: [_jsx("span", { className: css.recordIcon, "aria-hidden": "true", children: _jsx(IconSparkleMedium, { size: 14 }) }), _jsxs("span", { className: css.recordText, children: [_jsx("span", { className: css.recordTitle, children: _jsx(MathText, { text: entry.record.title ?? '' }) }), _jsxs("span", { className: css.muted, children: [entry.record.projectName, " \u00B7 ", t('source.chat'), " \u00B7 ", when(entry.record.at, t)] })] })] }, `${entry.record.projectId}-${entry.record.at}`))) })] }))] })] }));
88
+ }
89
+ const TEMPLATES = ['language', 'subject', 'custom'];
90
+ /** 新建项目. */
91
+ export function NewProjectView({ p }) {
92
+ const { t, actions } = p;
93
+ const [name, setName] = useState('');
94
+ const [template, setTemplate] = useState('subject');
95
+ const [goal, setGoal] = useState('');
96
+ const [error, setError] = useState();
97
+ const [busy, setBusy] = useState(false);
98
+ const submit = async () => {
99
+ setBusy(true);
100
+ setError(undefined);
101
+ try {
102
+ const created = await outcome(p.createProject({ name, template, ...(goal.trim() === '' ? {} : { goal }) }));
103
+ actions.replace({ view: 'project', projectId: created.project.id });
104
+ }
105
+ catch (failure) {
106
+ setError(failure instanceof Error ? failure.message : String(failure));
107
+ }
108
+ finally {
109
+ setBusy(false);
110
+ }
111
+ };
112
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: t('project.new'), onBack: () => { actions.pop(); }, backLabel: t('action.back'), moreLabel: t('action.more') }), _jsxs("form", { className: css.body, onSubmit: event => { event.preventDefault(); void submit(); }, children: [_jsxs("label", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.name') }), _jsx("input", { className: css.input, value: name, placeholder: t('project.namePlaceholder'), onChange: event => { setName(event.target.value); } })] }), _jsxs("div", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.template') }), _jsx(ChipRow, { label: t('project.template'), chips: TEMPLATES.map(id => ({ id, label: t(`template.${id}`) })), value: template, onSelect: setTemplate }), _jsx("span", { className: css.muted, children: t(`template.${template}.hint`) })] }), _jsxs("label", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.goal') }), _jsx("input", { className: css.input, value: goal, placeholder: t('project.goalPlaceholder'), onChange: event => { setGoal(event.target.value); } })] }), error !== undefined && _jsx("div", { className: css.error, children: error }), _jsx("div", { className: css.actions, children: _jsx("button", { type: "submit", className: css.primary, disabled: busy || name.trim() === '', children: t('project.create') }) })] })] }));
113
+ }
114
+ /** Overall progress of a project: done nodes over all nodes of its adopted paths. */
115
+ /** A project's stations, passed and in all, across its textbooks' paths. */
116
+ function stationsOf(project) {
117
+ return {
118
+ done: project.resources.reduce((sum, resource) => sum + (resource.roadmap?.done ?? 0), 0),
119
+ total: project.resources.reduce((sum, resource) => sum + (resource.roadmap?.total ?? 0), 0),
120
+ };
121
+ }
122
+ function progressOf(project) {
123
+ const total = project.resources.reduce((sum, resource) => sum + (resource.roadmap?.total ?? 0), 0);
124
+ const done = project.resources.reduce((sum, resource) => sum + (resource.roadmap?.done ?? 0), 0);
125
+ return total === 0 ? 0 : Math.round((done / total) * 100);
126
+ }
127
+ /** A project card on 今日: glyph, name, goal, progress — and where to continue, with its own button. */
128
+ function ProjectCard({ p, project, lead }) {
129
+ const { t, actions } = p;
130
+ const target = continueOf(project);
131
+ const textbooks = textbooksOf(project);
132
+ const drafts = textbooks.filter(row => row.hasDraft).length;
133
+ const open = () => { actions.push({ view: 'project', projectId: project.id }); };
134
+ return (_jsxs("article", { className: css.projectCard, "data-color": colorOf(project), children: [_jsxs("button", { type: "button", className: css.projectOpen, onClick: open, children: [_jsxs("span", { className: css.projectTop, children: [_jsx(ProjectGlyph, { icon: iconOf(project) }), _jsxs("span", { className: css.projectText, children: [_jsx("span", { className: css.projectName, children: project.name }), _jsxs("span", { className: css.muted, children: [t(`template.${project.template}`), " \u00B7 ", when(project.lastActiveAt, t)] })] }), _jsx(Ring, { percent: progressOf(project) })] }), project.goal !== undefined && _jsx("span", { className: css.projectGoal, children: project.goal }), _jsxs("span", { className: css.muted, children: [t('project.cardMeta', { books: String(textbooks.length), cards: String(project.cardCount), due: String(project.dueCount) }), drafts > 0 ? ` · ${t('project.draftsWaiting', { count: String(drafts) })}` : ''] })] }), target !== undefined && (_jsxs("div", { className: css.projectNext, children: [_jsxs("span", { className: css.projectNextText, children: [_jsx("span", { className: css.continueLabel, children: t('continue.label') }), _jsx("span", { className: css.continueTitle, children: target.station.title }), _jsx("span", { className: css.muted, children: t('continue.where', { seq: String(target.textbook.seq ?? ''), book: target.textbook.title }) })] }), _jsx("button", { type: "button", className: lead ? css.primary : css.secondary, onClick: () => { actions.push({ view: 'node', projectId: project.id, resourceId: target.textbook.id, nodeId: target.station.id, tab: 'guide' }); }, children: t('continue.go') })] }))] }));
135
+ }
136
+ /** Editing a project in place: name, goal, template, color, icon — saved together (deleting lives in the page menu). */
137
+ function ProjectEditor({ p, project, onDone }) {
138
+ const { t, actions } = p;
139
+ const [name, setName] = useState(project.name);
140
+ const [goal, setGoal] = useState(project.goal ?? '');
141
+ const [template, setTemplate] = useState(project.template);
142
+ const [color, setColor] = useState(colorOf(project));
143
+ const [icon, setIcon] = useState(iconOf(project));
144
+ const [busy, setBusy] = useState(false);
145
+ const [error, setError] = useState();
146
+ const save = async () => {
147
+ setBusy(true);
148
+ setError(undefined);
149
+ try {
150
+ await outcome(p.updateProject(project.id, { name: name.trim(), goal: goal.trim(), template, color, icon }));
151
+ actions.refresh();
152
+ onDone();
153
+ }
154
+ catch (failure) {
155
+ setError(failure instanceof Error ? failure.message : String(failure));
156
+ }
157
+ finally {
158
+ setBusy(false);
159
+ }
160
+ };
161
+ return (_jsxs("form", { className: css.editor, "data-color": color, onSubmit: event => { event.preventDefault(); void save(); }, children: [_jsxs("span", { className: css.projectTop, children: [_jsx(ProjectGlyph, { icon: icon }), _jsx("span", { className: css.projectText, children: _jsx("span", { className: css.projectName, children: t('project.edit') }) })] }), _jsxs("label", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.name') }), _jsx("input", { className: css.input, value: name, onChange: event => { setName(event.target.value); } })] }), _jsxs("label", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.goal') }), _jsx("textarea", { className: css.input, rows: 2, value: goal, onChange: event => { setGoal(event.target.value); } })] }), _jsxs("div", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.template') }), _jsx(ChipRow, { label: t('project.template'), chips: TEMPLATES.map(id => ({ id, label: t(`template.${id}`) })), value: template, onSelect: setTemplate }), template !== project.template && _jsx("span", { className: css.muted, children: t('project.templateChange') })] }), _jsxs("div", { className: css.field, children: [_jsx("span", { className: css.fieldLabel, children: t('project.style') }), _jsx("span", { className: css.swatches, children: LIBRARY_COLORS.map(entry => (_jsx("button", { type: "button", className: css.swatch, "data-color": entry, "aria-label": entry, "aria-pressed": color === entry, onClick: () => { setColor(entry); } }, entry))) }), _jsx("span", { className: css.swatches, children: LIBRARY_ICONS.map(entry => (_jsx("button", { type: "button", className: css.iconPick, "aria-label": entry, "aria-pressed": icon === entry, onClick: () => { setIcon(entry); }, children: _jsx(GlyphStrokes, { icon: entry }) }, entry))) })] }), error !== undefined && _jsx("div", { className: css.error, children: error }), _jsxs("div", { className: css.actions, children: [_jsx("button", { type: "button", className: css.secondary, onClick: onDone, children: t('action.cancel') }), _jsx("button", { type: "submit", className: css.primary, disabled: busy || name.trim() === '', children: t('action.save') })] })] }));
162
+ }
163
+ /** One project: its goal band, continue learning, drafts waiting, its textbooks in study order, its links. */
164
+ export function ProjectView({ p, projectId }) {
165
+ const { t, actions, useStore } = p;
166
+ const rev = useStore(s => s.rev);
167
+ const loaded = useLoad(() => outcome(p.getProject(projectId)), [projectId, rev]);
168
+ // Free quizzes hang on no resource and no station — this section is their only entry.
169
+ const assessments = useLoad(() => value(p.listAssessments(projectId, undefined, undefined, 'quiz')), [projectId, rev]);
170
+ // The 错题 entry's count and target ride the shared collection (mistakes.ts) — the same rows the station's 章节考 tab lists.
171
+ const attempts = useLoad(() => value(p.listAttempts(projectId)), [projectId, rev]);
172
+ const openMisses = loaded.data?.project.openMisses ?? 0;
173
+ const [notice, setNotice] = useState();
174
+ const [editing, setEditing] = useState(false);
175
+ const [goalOpen, setGoalOpen] = useState(false);
176
+ const [ordering, setOrdering] = useState(false);
177
+ const [deleting, setDeleting] = useState(false);
178
+ const [busy, setBusy] = useState(false);
179
+ const [error, setError] = useState();
180
+ const project = loaded.data?.project;
181
+ const resources = loaded.data?.resources ?? [];
182
+ const thick = resources.filter(isThickResource);
183
+ const thin = resources.filter(resource => !isThickResource(resource));
184
+ const rows = thick.map(resource => ({ resource, row: project?.resources.find(candidate => candidate.id === resource.id) }));
185
+ const drafts = rows.filter(entry => entry.row?.hasDraft === true);
186
+ const resume = project === undefined ? undefined : continueOf(project);
187
+ const freeQuizzes = (assessments.data?.assessments ?? []).filter(quiz => quiz.scope.resourceId === undefined && quiz.scope.nodeId === undefined).sort((a, b) => b.createdAt.localeCompare(a.createdAt));
188
+ // The 错题 entry jumps to wherever the newest miss lives: its station's 章节考 tab, else its textbook page.
189
+ // The 错题 stat: every station's open misses, retaken in one paper (订正不计分).
190
+ const openMissed = () => {
191
+ if (busy)
192
+ return;
193
+ setBusy(true);
194
+ void outcome(p.createRetake(projectId)).then(created => {
195
+ actions.push({ view: 'quiz', projectId, quizId: created.assessment.id });
196
+ }, (failure) => { setNotice(failure instanceof Error ? failure.message : String(failure)); }).finally(() => { setBusy(false); });
197
+ };
198
+ const prime = (key) => {
199
+ if (project === undefined)
200
+ return;
201
+ void p.bindTarget({ projectId });
202
+ setNotice(p.primeConversation(t(key, { project: project.name })) ? t('notice.primed') : t('notice.noConversation'));
203
+ };
204
+ const move = async (resourceId, seq) => {
205
+ setBusy(true);
206
+ try {
207
+ await outcome(p.moveResource(projectId, resourceId, seq));
208
+ actions.refresh();
209
+ }
210
+ catch (failure) {
211
+ setNotice(failure instanceof Error ? failure.message : String(failure));
212
+ }
213
+ finally {
214
+ setBusy(false);
215
+ }
216
+ };
217
+ const remove = async () => {
218
+ setBusy(true);
219
+ setError(undefined);
220
+ try {
221
+ await outcome(p.deleteProject(projectId));
222
+ setDeleting(false);
223
+ actions.refresh();
224
+ actions.pop();
225
+ }
226
+ catch (failure) {
227
+ setError(failure instanceof Error ? failure.message : String(failure));
228
+ }
229
+ finally {
230
+ setBusy(false);
231
+ }
232
+ };
233
+ return (_jsxs(_Fragment, { children: [_jsx(PanelHeader, { title: project?.name ?? '', onBack: () => { actions.pop(); }, backLabel: t('action.back'), actions: ordering ? [] : [
234
+ { id: 'add', label: t('book.add'), icon: _jsx(IconPlusOutlineMedium, {}), onSelect: () => { prime('prime.addBook'); } },
235
+ { id: 'addLink', label: t('resource.add'), icon: _jsx(IconLinkOutlineMedium, {}), onSelect: () => { prime('prime.addResource'); } },
236
+ { id: 'cards', label: t('cards.title'), icon: _jsx(IconCardsOutlineMedium, {}), onSelect: () => { actions.push({ view: 'cards', projectId, filter: 'all' }); } },
237
+ { id: 'edit', label: t('project.edit'), icon: _jsx(IconPenOutlineMedium, {}), menuOnly: true, onSelect: () => { setEditing(true); } },
238
+ ...(thick.length > 1 ? [{ id: 'order', label: t('project.reorder'), icon: _jsx("span", { "aria-hidden": "true", children: "\u21C5" }), menuOnly: true, onSelect: () => { setOrdering(true); } }] : []),
239
+ { id: 'bind', label: t('action.bindProject'), icon: _jsx(IconSparkleMedium, {}), menuOnly: true, onSelect: () => { void p.bindTarget({ projectId }).then(() => { setNotice(t('notice.bound')); }); } },
240
+ { id: 'delete', label: t('project.delete'), icon: _jsx(IconTrashOutlineMedium, {}), menuOnly: true, onSelect: () => { setError(undefined); setDeleting(true); } },
241
+ ], moreLabel: t('action.more') }), project !== undefined && editing && (_jsx(ProjectEditor, { p: p, project: project, onDone: () => { setEditing(false); } })), project !== undefined && !editing && !ordering && (_jsxs("section", { className: css.band, "data-color": colorOf(project), children: [_jsxs("span", { className: css.projectTop, children: [_jsx(ProjectGlyph, { icon: iconOf(project) }), _jsxs("span", { className: css.projectText, children: [project.goal !== undefined && _jsx("span", { className: css.bandGoal, "data-open": goalOpen ? 'true' : undefined, children: project.goal }), _jsxs("span", { className: css.bandMeta, children: [_jsxs("span", { children: [t(`template.${project.template}`), " \u00B7 ", when(project.lastActiveAt, t)] }), project.goal !== undefined && project.goal.length > 60 && _jsx("button", { type: "button", className: css.textButton, "aria-expanded": goalOpen, onClick: () => { setGoalOpen(open => !open); }, children: t(goalOpen ? 'project.goalLess' : 'project.goalMore') })] })] }), _jsx(Ring, { percent: progressOf(project), label: t('project.ringLabel'), title: t('project.ringTitle', { done: String(stationsOf(project).done), total: String(stationsOf(project).total) }) })] }), _jsxs("span", { className: css.statTiles, children: [_jsxs("button", { type: "button", className: css.statTile, onClick: () => { actions.push({ view: 'cards', projectId, filter: 'all' }); }, children: [_jsx("b", { children: project.cardCount }), _jsx("span", { children: t('cards.title') })] }), _jsxs("button", { type: "button", className: css.statTile, disabled: project.dueCount === 0, onClick: () => { actions.push({ view: 'review', projectId }); }, children: [_jsx("b", { children: project.dueCount }), _jsx("span", { children: t('review.due') })] }), _jsxs("button", { type: "button", className: css.statTile, "data-tone": openMisses > 0 ? 'bad' : undefined, disabled: openMisses === 0 || busy, onClick: openMissed, children: [_jsx("b", { children: openMisses }), _jsx("span", { children: t('mistakes.statOpen') })] })] })] })), _jsxs("div", { className: css.body, children: [_jsx(StateLine, { loaded: loaded, t: t }), notice !== undefined && _jsx("div", { className: css.notice, children: notice }), ordering && (_jsxs(_Fragment, { children: [_jsx("div", { className: css.notice, children: t('project.reorderHint') }), _jsx("ol", { className: css.textbooks, children: rows.map(({ resource }, index) => (_jsxs("li", { className: css.textbook, children: [_jsx("span", { className: css.seq, children: index + 1 }), _jsx("span", { className: css.textbookMain, children: _jsx("span", { className: css.textbookTitle, children: resource.title }) }), _jsxs("span", { className: css.order, children: [_jsx("button", { type: "button", "aria-label": t('project.moveUp', { book: resource.title }), disabled: busy || index === 0, onClick: () => { void move(resource.id, index); }, children: "\u25B2" }), _jsx("button", { type: "button", "aria-label": t('project.moveDown', { book: resource.title }), disabled: busy || index === rows.length - 1, onClick: () => { void move(resource.id, index + 2); }, children: "\u25BC" })] })] }, resource.id))) }), _jsx("div", { className: css.actions, children: _jsx("button", { type: "button", className: css.primary, onClick: () => { setOrdering(false); }, children: t('action.done') }) })] })), project !== undefined && !ordering && (_jsxs(_Fragment, { children: [resume !== undefined && _jsx(ContinueCard, { p: p, projectId: projectId, label: t('continue.label'), target: resume }), drafts.length > 0 && (_jsxs("div", { className: css.banner, children: [_jsx("span", { children: t('project.draftsBanner', { count: String(drafts.length) }) }), _jsx("button", { type: "button", className: css.ghost, onClick: () => { actions.push({ view: 'resource', projectId, resourceId: drafts[0].resource.id }); }, children: t('project.draftsOpen', { seq: String(drafts[0].resource.seq ?? 1) }) })] })), loaded.data !== undefined && thick.length === 0 && (_jsxs("div", { className: css.empty, children: [_jsx("div", { className: css.emptyTitle, children: t('project.noTextbooks') }), _jsx("div", { className: css.muted, children: t('project.noTextbooksBody') }), _jsxs("div", { className: css.starts, children: [_jsxs("button", { type: "button", className: css.start, onClick: () => { prime('prime.addBook'); }, children: [_jsx("b", { children: t('project.startOwn') }), _jsx("span", { children: t('project.startOwnHint') })] }), _jsxs("button", { type: "button", className: css.start, onClick: () => { prime('prime.pickTextbooks'); }, children: [_jsx("b", { children: t('project.startPick') }), _jsx("span", { children: t('project.startPickHint') })] })] })] })), thick.length > 0 && (_jsxs("section", { className: css.section, children: [_jsxs("div", { className: css.sectionHead, children: [_jsx("h3", { className: css.sectionTitle, children: t('project.textbooks', { count: String(thick.length) }) }), thick.length > 1 && _jsx("button", { type: "button", className: css.textButton, onClick: () => { setOrdering(true); }, children: t('project.reorder') })] }), _jsx("div", { className: css.bookCards, "data-color": colorOf(project), children: rows.map(({ resource, row }, index) => {
242
+ const title = splitTitle(resource.title);
243
+ const path = row?.roadmap;
244
+ return (_jsxs("button", { type: "button", className: css.bookCard, "data-pending": path === undefined ? 'true' : undefined, onClick: () => { actions.push({ view: 'resource', projectId, resourceId: resource.id }); }, children: [_jsx(Cover, { book: coverOf(resource), shade: index, size: "card" }), _jsxs("span", { className: css.bookCardText, children: [_jsxs("span", { className: css.bookCardTitle, children: [title.main, _jsx(OriginTag, { origin: resource.origin, digital: resource.kind === 'course', t: t })] }), path !== undefined ? (_jsxs(_Fragment, { children: [_jsxs("span", { className: css.muted, children: [path.done === path.total && path.total > 0 ? t('book.finished') : t('project.bookProgress', { done: String(path.done), total: String(path.total) }), row?.hasDraft === true ? ` · ${t('book.draftWaiting')}` : ''] }), _jsx(Segments, { done: path.done, total: path.total })] })) : (_jsxs(_Fragment, { children: [title.sub !== undefined && _jsx("span", { className: css.muted, children: title.sub }), _jsx("span", { className: css.muted, children: row?.hasDraft === true ? t('project.draftRow', { count: String(row.draftTotal ?? 0) }) : t('project.noPathYet') })] }))] })] }, resource.id));
245
+ }) })] })), freeQuizzes.length > 0 && (_jsxs("section", { className: css.section, children: [_jsx("h3", { className: css.sectionTitle, children: t('project.quizzes', { count: String(freeQuizzes.length) }) }), _jsx(QuizRows, { p: p, projectId: projectId, quizzes: freeQuizzes })] })), thin.length === 0 && loaded.data !== undefined && (_jsxs("div", { className: css.resEmpty, children: [_jsx("span", { children: t('resource.emptyLine') }), _jsxs("button", { type: "button", className: css.textButton, onClick: () => { prime('prime.addResource'); }, children: ["\uFF0B ", t('resource.add')] })] })), thin.length > 0 && (_jsxs("section", { className: css.section, children: [_jsxs("div", { className: css.sectionHead, children: [_jsx("h3", { className: css.sectionTitle, children: t('resource.title', { count: String(thin.length) }) }), _jsxs("button", { type: "button", className: css.textButton, onClick: () => { prime('prime.addResource'); }, children: ["\uFF0B ", t('resource.add')] })] }), thin.length > 0 && (_jsx("div", { className: css.resList, children: thin.map(resource => (_jsxs("div", { className: css.res, children: [_jsx("span", { className: css.resKind, title: t(`resource.kind.${resource.kind}`), children: resourceKindIcon(resource.kind) }), _jsxs("div", { className: css.resMain, children: [_jsx("button", { type: "button", className: css.resTitle, title: resource.url, onClick: () => { if (resource.url !== undefined)
246
+ window.open(resource.url, '_blank', 'noopener,noreferrer'); }, children: resource.title }), resource.note !== undefined && _jsx("span", { className: css.resNote, children: resource.note })] })] }, resource.id))) }))] }))] }))] }), deleting && project !== undefined && (_jsx(ConfirmDialog, { title: t('project.deleteTitle', { project: project.name }), lines: [
247
+ t('project.deleteTextbooks', { count: String(thick.length) }),
248
+ t('project.deleteCards', { count: String(project.cardCount), due: String(project.dueCount) }),
249
+ t('project.deleteAttempts', { count: String(attempts.data?.attempts.length ?? 0) }),
250
+ ...(thin.length > 0 ? [t('project.deleteLinks', { count: String(thin.length) })] : []),
251
+ ], note: t('project.deleteNote'), confirmLabel: t('project.deleteConfirmButton'), cancelLabel: t('action.cancel'), busy: busy, error: error, onConfirm: () => { void remove(); }, onCancel: () => { setDeleting(false); } }))] }));
252
+ }
253
+ /** A resource row's kind tile glyph (article is the default). */
254
+ function resourceKindIcon(kind) {
255
+ switch (kind) {
256
+ case 'video': return _jsx(IconPlayOutlineMedium, { size: 13 });
257
+ case 'repo': return _jsx(IconRepoOutlineMedium, { size: 13 });
258
+ case 'tool': return _jsx(IconToolOutlineMedium, { size: 13 });
259
+ case 'course': return _jsx(IconCourseOutlineMedium, { size: 13 });
260
+ case 'hardware': return _jsx(IconHardwareOutlineMedium, { size: 13 });
261
+ default: return _jsx(IconArticleOutlineMedium, { size: 13 });
262
+ }
263
+ }