@khorsheed/dsh-library 0.0.0-stage → 0.1.0
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.
- package/CHANGELOG.md +14 -0
- package/README.en.md +49 -0
- package/README.i18n.yaml +6 -0
- package/README.md +48 -2
- package/cordis.patch.yml +17 -0
- package/lib/client.js +66276 -0
- package/lib/index.js +3318 -0
- package/lib/profiles-BXM8xNUf.js +1608 -0
- package/lib/profiles-BrbeM-bi.js +1635 -0
- package/lib/profiles-CFrPQdfO.js +1692 -0
- package/lib/profiles-DxtzIUvd.js +1632 -0
- package/lib/profiles-O0ddEit3.js +1631 -0
- package/lib/profiles-Oj-zBAY9.js +1692 -0
- package/lib/profiles-gIEviEuO.js +1610 -0
- package/lib/store-CIcZgTsg.js +1456 -0
- package/lib/store-DfYOmS44.js +1418 -0
- package/lib/tool.js +1597 -0
- package/lib/typert.host.d.ts +3 -0
- package/lib/typert.host.js +3811 -0
- package/lib/typert.remote-client.d.ts +137 -0
- package/lib/typert.remote-client.js +3077 -0
- package/lib/types/cards.d.ts +41 -0
- package/lib/types/cards.js +111 -0
- package/lib/types/client/LibraryPane.d.ts +26 -0
- package/lib/types/client/LibraryPane.js +117 -0
- package/lib/types/client/bind/BindChip.d.ts +33 -0
- package/lib/types/client/bind/BindChip.js +102 -0
- package/lib/types/client/contract.d.ts +164 -0
- package/lib/types/client/contract.js +1 -0
- package/lib/types/client/covers.d.ts +36 -0
- package/lib/types/client/covers.js +66 -0
- package/lib/types/client/definition.d.ts +27 -0
- package/lib/types/client/definition.js +32 -0
- package/lib/types/client/icons-local.d.ts +41 -0
- package/lib/types/client/icons-local.js +25 -0
- package/lib/types/client/icons.d.ts +27 -0
- package/lib/types/client/icons.js +11 -0
- package/lib/types/client/index.d.ts +35 -0
- package/lib/types/client/index.js +219 -0
- package/lib/types/client/judge.d.ts +70 -0
- package/lib/types/client/judge.js +262 -0
- package/lib/types/client/locales.d.ts +553 -0
- package/lib/types/client/locales.js +1076 -0
- package/lib/types/client/look.d.ts +63 -0
- package/lib/types/client/look.js +64 -0
- package/lib/types/client/math.d.ts +24 -0
- package/lib/types/client/math.js +47 -0
- package/lib/types/client/mistakes.d.ts +21 -0
- package/lib/types/client/mistakes.js +28 -0
- package/lib/types/client/preset-visibility.d.ts +109 -0
- package/lib/types/client/preset-visibility.js +123 -0
- package/lib/types/client/quiz-drafts.d.ts +29 -0
- package/lib/types/client/quiz-drafts.js +50 -0
- package/lib/types/client/store.d.ts +109 -0
- package/lib/types/client/store.js +44 -0
- package/lib/types/client/titles.d.ts +17 -0
- package/lib/types/client/titles.js +23 -0
- package/lib/types/client/ui.d.ts +96 -0
- package/lib/types/client/ui.js +124 -0
- package/lib/types/client/views/book.d.ts +57 -0
- package/lib/types/client/views/book.js +164 -0
- package/lib/types/client/views/cards.d.ts +17 -0
- package/lib/types/client/views/cards.js +53 -0
- package/lib/types/client/views/home.d.ts +26 -0
- package/lib/types/client/views/home.js +263 -0
- package/lib/types/client/views/lesson.d.ts +42 -0
- package/lib/types/client/views/lesson.js +146 -0
- package/lib/types/client/views/path.d.ts +32 -0
- package/lib/types/client/views/path.js +64 -0
- package/lib/types/client/views/quiz.d.ts +42 -0
- package/lib/types/client/views/quiz.js +366 -0
- package/lib/types/client/views/station-quiz.d.ts +43 -0
- package/lib/types/client/views/station-quiz.js +203 -0
- package/lib/types/client/views/station.d.ts +18 -0
- package/lib/types/client/views/station.js +142 -0
- package/lib/types/image-refs.d.ts +35 -0
- package/lib/types/image-refs.js +71 -0
- package/lib/types/index.d.ts +51 -0
- package/lib/types/index.js +31 -0
- package/lib/types/mastery.d.ts +81 -0
- package/lib/types/mastery.js +110 -0
- package/lib/types/profiles.d.ts +31 -0
- package/lib/types/profiles.js +25 -0
- package/lib/types/remote.d.ts +355 -0
- package/lib/types/remote.js +332 -0
- package/lib/types/service/assess.d.ts +144 -0
- package/lib/types/service/assess.js +773 -0
- package/lib/types/service/bindings.d.ts +92 -0
- package/lib/types/service/bindings.js +198 -0
- package/lib/types/service/cards.d.ts +85 -0
- package/lib/types/service/cards.js +320 -0
- package/lib/types/service/core.d.ts +124 -0
- package/lib/types/service/core.js +169 -0
- package/lib/types/service/pages.d.ts +60 -0
- package/lib/types/service/pages.js +125 -0
- package/lib/types/service/roadmap.d.ts +99 -0
- package/lib/types/service/roadmap.js +284 -0
- package/lib/types/service/rules.d.ts +63 -0
- package/lib/types/service/rules.js +245 -0
- package/lib/types/service/shelf.d.ts +136 -0
- package/lib/types/service/shelf.js +422 -0
- package/lib/types/service.d.ts +42 -0
- package/lib/types/service.js +41 -0
- package/lib/types/store.d.ts +257 -0
- package/lib/types/store.js +1376 -0
- package/lib/types/tool-definition.d.ts +49 -0
- package/lib/types/tool-definition.js +49 -0
- package/lib/types/tool.d.ts +71 -0
- package/lib/types/tool.js +174 -0
- package/lib/types/tools/assess.d.ts +11 -0
- package/lib/types/tools/assess.js +82 -0
- package/lib/types/tools/binding.d.ts +11 -0
- package/lib/types/tools/binding.js +46 -0
- package/lib/types/tools/cards.d.ts +11 -0
- package/lib/types/tools/cards.js +100 -0
- package/lib/types/tools/kit.d.ts +215 -0
- package/lib/types/tools/kit.js +281 -0
- package/lib/types/tools/notes.d.ts +11 -0
- package/lib/types/tools/notes.js +40 -0
- package/lib/types/tools/page.d.ts +11 -0
- package/lib/types/tools/page.js +59 -0
- package/lib/types/tools/project.d.ts +11 -0
- package/lib/types/tools/project.js +67 -0
- package/lib/types/tools/quiz.d.ts +11 -0
- package/lib/types/tools/quiz.js +74 -0
- package/lib/types/tools/read.d.ts +11 -0
- package/lib/types/tools/read.js +127 -0
- package/lib/types/tools/resource.d.ts +11 -0
- package/lib/types/tools/resource.js +147 -0
- package/lib/types/tools/roadmap.d.ts +11 -0
- package/lib/types/tools/roadmap.js +102 -0
- package/lib/types/types.d.ts +752 -0
- package/lib/types/types.js +159 -0
- package/locale/en.json +6 -0
- package/locale/zh.json +6 -0
- package/package.json +135 -3
|
@@ -0,0 +1,109 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library pane's transient store: where the learner is in the sidebar —
|
|
3
|
+
* a stack of routes, so 「‹」 always returns to the screen they came from —
|
|
4
|
+
* and a revision counter the views reload on. Everything durable lives in the
|
|
5
|
+
* host's `state/dsh-library/`; each view re-reads it on mount and on `rev`.
|
|
6
|
+
*
|
|
7
|
+
* @module @khorsheed/dsh-library/client/store
|
|
8
|
+
*/
|
|
9
|
+
import { type EngineStoreHandle } from '@deepseek-ai/dsh-client-store';
|
|
10
|
+
import type { LibraryAssessResult, LibraryQuizFocus, LibraryQuizQuestion } from '../types.ts';
|
|
11
|
+
/** The card list's filters. */
|
|
12
|
+
export type LibraryCardFilter = 'all' | 'photo' | 'chat' | 'quiz' | 'due';
|
|
13
|
+
/** The node page's tabs. */
|
|
14
|
+
export type LibraryNodeTab = 'guide' | 'pages' | 'notes' | 'quiz';
|
|
15
|
+
/** One screen of the sidebar. */
|
|
16
|
+
export type LibraryRoute = {
|
|
17
|
+
readonly view: 'today';
|
|
18
|
+
} | {
|
|
19
|
+
readonly view: 'newProject';
|
|
20
|
+
} | {
|
|
21
|
+
readonly view: 'project';
|
|
22
|
+
readonly projectId: string;
|
|
23
|
+
} | {
|
|
24
|
+
readonly view: 'resource';
|
|
25
|
+
readonly projectId: string;
|
|
26
|
+
readonly resourceId: string;
|
|
27
|
+
} | {
|
|
28
|
+
readonly view: 'node';
|
|
29
|
+
readonly projectId: string;
|
|
30
|
+
readonly resourceId: string;
|
|
31
|
+
readonly nodeId: string;
|
|
32
|
+
readonly tab: LibraryNodeTab;
|
|
33
|
+
} | {
|
|
34
|
+
readonly view: 'lesson';
|
|
35
|
+
readonly projectId: string;
|
|
36
|
+
readonly resourceId: string;
|
|
37
|
+
readonly nodeId: string;
|
|
38
|
+
} | {
|
|
39
|
+
readonly view: 'cards';
|
|
40
|
+
readonly projectId: string;
|
|
41
|
+
readonly filter: LibraryCardFilter;
|
|
42
|
+
} | {
|
|
43
|
+
readonly view: 'quiz';
|
|
44
|
+
readonly projectId: string;
|
|
45
|
+
readonly resourceId?: string;
|
|
46
|
+
readonly quizId: string;
|
|
47
|
+
} | {
|
|
48
|
+
readonly view: 'result';
|
|
49
|
+
readonly projectId: string;
|
|
50
|
+
readonly resourceId?: string;
|
|
51
|
+
readonly result: LibraryAssessResult;
|
|
52
|
+
} | {
|
|
53
|
+
readonly view: 'review';
|
|
54
|
+
readonly projectId?: string;
|
|
55
|
+
};
|
|
56
|
+
/** A station's 章节考 settings, as the learner left them. */
|
|
57
|
+
export interface LibraryQuizSettings {
|
|
58
|
+
readonly focus: LibraryQuizFocus;
|
|
59
|
+
readonly kinds: readonly LibraryQuizQuestion['kind'][];
|
|
60
|
+
readonly countText: string;
|
|
61
|
+
/** The picked points (重点出这些) — empty means the coach picks by weakness. */
|
|
62
|
+
readonly picked: readonly string[];
|
|
63
|
+
}
|
|
64
|
+
/** An ask sent to the coach that has not landed yet: when, the paper count it must beat, and what was asked. */
|
|
65
|
+
export interface LibraryQuizAsk {
|
|
66
|
+
readonly since: number;
|
|
67
|
+
readonly count: number;
|
|
68
|
+
readonly summary: string;
|
|
69
|
+
}
|
|
70
|
+
/** One station's quiz state, kept across tab switches (keyed `project:resource:node`). */
|
|
71
|
+
export interface LibraryStationQuiz {
|
|
72
|
+
readonly settings?: LibraryQuizSettings;
|
|
73
|
+
readonly ask?: LibraryQuizAsk;
|
|
74
|
+
}
|
|
75
|
+
/** The pane's state. */
|
|
76
|
+
export interface LibraryState {
|
|
77
|
+
/** The route stack; the last entry is on screen. */
|
|
78
|
+
stack: LibraryRoute[];
|
|
79
|
+
/** Bumped to make the open view re-read the host. */
|
|
80
|
+
rev: number;
|
|
81
|
+
/** Per-station quiz settings and the pending ask — they outlive the tab that shows them. */
|
|
82
|
+
quiz: Record<string, LibraryStationQuiz>;
|
|
83
|
+
/** Stations whose 讲义 the learner asked for (开始学这一站), keyed `project:resource:node` → when. */
|
|
84
|
+
preparing: Record<string, number>;
|
|
85
|
+
}
|
|
86
|
+
/** Annotation twin of the actions literal below. */
|
|
87
|
+
export type LibraryActions = {
|
|
88
|
+
/** Open a screen on top of the current one. */
|
|
89
|
+
push: (draft: LibraryState, route: LibraryRoute) => void;
|
|
90
|
+
/** Replace the current screen (tab switches, filter changes). */
|
|
91
|
+
replace: (draft: LibraryState, route: LibraryRoute) => void;
|
|
92
|
+
/** Back one screen (never past today). */
|
|
93
|
+
pop: (draft: LibraryState) => void;
|
|
94
|
+
/** Back to today. */
|
|
95
|
+
home: (draft: LibraryState) => void;
|
|
96
|
+
refresh: (draft: LibraryState) => void;
|
|
97
|
+
/** Remember a station's 章节考 settings. */
|
|
98
|
+
setQuizSettings: (draft: LibraryState, key: string, settings: LibraryQuizSettings) => void;
|
|
99
|
+
/** Start (or clear, with undefined) a station's pending ask. */
|
|
100
|
+
setQuizAsk: (draft: LibraryState, key: string, ask: LibraryQuizAsk | undefined) => void;
|
|
101
|
+
/** Start (or clear) waiting for a station's 讲义. */
|
|
102
|
+
setPreparing: (draft: LibraryState, key: string, since: number | undefined) => void;
|
|
103
|
+
};
|
|
104
|
+
/**
|
|
105
|
+
* Create the library pane's store handle.
|
|
106
|
+
* @returns the store handle (spec, type, identity and factory in one).
|
|
107
|
+
*/
|
|
108
|
+
export declare function createLibraryStore(): EngineStoreHandle<LibraryState, LibraryActions>;
|
|
109
|
+
//# sourceMappingURL=store.d.ts.map
|
|
@@ -0,0 +1,44 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* The library pane's transient store: where the learner is in the sidebar —
|
|
3
|
+
* a stack of routes, so 「‹」 always returns to the screen they came from —
|
|
4
|
+
* and a revision counter the views reload on. Everything durable lives in the
|
|
5
|
+
* host's `state/dsh-library/`; each view re-reads it on mount and on `rev`.
|
|
6
|
+
*
|
|
7
|
+
* @module @khorsheed/dsh-library/client/store
|
|
8
|
+
*/
|
|
9
|
+
import { defineStore } from '@deepseek-ai/dsh-client-store';
|
|
10
|
+
const TODAY = { view: 'today' };
|
|
11
|
+
/**
|
|
12
|
+
* Create the library pane's store handle.
|
|
13
|
+
* @returns the store handle (spec, type, identity and factory in one).
|
|
14
|
+
*/
|
|
15
|
+
export function createLibraryStore() {
|
|
16
|
+
return defineStore({
|
|
17
|
+
init: () => ({ stack: [TODAY], rev: 0, quiz: {}, preparing: {} }),
|
|
18
|
+
actions: {
|
|
19
|
+
push: (d, route) => { d.stack.push(route); },
|
|
20
|
+
replace: (d, route) => { d.stack[d.stack.length - 1] = route; },
|
|
21
|
+
pop: (d) => {
|
|
22
|
+
if (d.stack.length > 1)
|
|
23
|
+
d.stack.pop();
|
|
24
|
+
d.rev += 1;
|
|
25
|
+
},
|
|
26
|
+
home: (d) => {
|
|
27
|
+
d.stack = [TODAY];
|
|
28
|
+
d.rev += 1;
|
|
29
|
+
},
|
|
30
|
+
refresh: (d) => { d.rev += 1; },
|
|
31
|
+
setQuizSettings: (d, key, settings) => { d.quiz[key] = { ...d.quiz[key], settings }; },
|
|
32
|
+
setQuizAsk: (d, key, ask) => {
|
|
33
|
+
const { ask: _previous, ...rest } = d.quiz[key] ?? {};
|
|
34
|
+
d.quiz[key] = ask === undefined ? rest : { ...rest, ask };
|
|
35
|
+
},
|
|
36
|
+
setPreparing: (d, key, since) => {
|
|
37
|
+
if (since === undefined)
|
|
38
|
+
delete d.preparing[key];
|
|
39
|
+
else
|
|
40
|
+
d.preparing[key] = since;
|
|
41
|
+
},
|
|
42
|
+
},
|
|
43
|
+
});
|
|
44
|
+
}
|
|
@@ -0,0 +1,17 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display forms of the titles the coach writes: a textbook's main title with
|
|
3
|
+
* its trailing parenthesis split off as a subtitle, and a station's title
|
|
4
|
+
* without the 「站 1:」 prefix (the disc carries the number) or a trailing
|
|
5
|
+
* 「(教材 A 第 1-3 章)」 (the meta line carries the rows). Stored titles are
|
|
6
|
+
* untouched.
|
|
7
|
+
*
|
|
8
|
+
* @module @khorsheed/dsh-library/client/titles
|
|
9
|
+
*/
|
|
10
|
+
/** 「Blender 4.5 手册(Getting Started / …)」 → main + sub; a title that is all parenthesis stays whole. */
|
|
11
|
+
export declare function splitTitle(title: string): {
|
|
12
|
+
main: string;
|
|
13
|
+
sub?: string;
|
|
14
|
+
};
|
|
15
|
+
/** A station title as the rail and cards show it. */
|
|
16
|
+
export declare function stationTitle(title: string): string;
|
|
17
|
+
//# sourceMappingURL=titles.d.ts.map
|
|
@@ -0,0 +1,23 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Display forms of the titles the coach writes: a textbook's main title with
|
|
3
|
+
* its trailing parenthesis split off as a subtitle, and a station's title
|
|
4
|
+
* without the 「站 1:」 prefix (the disc carries the number) or a trailing
|
|
5
|
+
* 「(教材 A 第 1-3 章)」 (the meta line carries the rows). Stored titles are
|
|
6
|
+
* untouched.
|
|
7
|
+
*
|
|
8
|
+
* @module @khorsheed/dsh-library/client/titles
|
|
9
|
+
*/
|
|
10
|
+
/** 「Blender 4.5 手册(Getting Started / …)」 → main + sub; a title that is all parenthesis stays whole. */
|
|
11
|
+
export function splitTitle(title) {
|
|
12
|
+
const match = /^(.+?)\s*[((]([^()()]+)[))]\s*$/.exec(title.trim());
|
|
13
|
+
if (match === null || match[1].trim() === '')
|
|
14
|
+
return { main: title.trim() };
|
|
15
|
+
return { main: match[1].trim(), sub: match[2].trim() };
|
|
16
|
+
}
|
|
17
|
+
/** A station title as the rail and cards show it. */
|
|
18
|
+
export function stationTitle(title) {
|
|
19
|
+
const cleaned = title.trim()
|
|
20
|
+
.replace(/^(?:站|第)\s*\d+\s*站?\s*[::.、\s-]\s*/, '')
|
|
21
|
+
.replace(/\s*[((][^()()]*(?:第\s*[\d\s,,、–—-]+\s*章|章节?\s*\d)[^()()]*[))]\s*$/, '');
|
|
22
|
+
return cleaned === '' ? title.trim() : cleaned;
|
|
23
|
+
}
|
|
@@ -0,0 +1,96 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* Small shared pieces of the library sidebar: the loading hook every view
|
|
3
|
+
* reads the host through, outcome unwrapping, and the few presentational
|
|
4
|
+
* atoms (state lines, weakness bars, review boxes, the term highlighter,
|
|
5
|
+
* the origin tag, the destructive-action confirm dialog).
|
|
6
|
+
*
|
|
7
|
+
* @module @khorsheed/dsh-library/client/ui
|
|
8
|
+
*/
|
|
9
|
+
import { type ReactNode } from 'react';
|
|
10
|
+
import type { RemoteResult } from '@deepseek-ai/dsh-typert-protocol';
|
|
11
|
+
import type { LibraryOutcome, LibraryResourceOrigin } from '../types.ts';
|
|
12
|
+
import type { LibraryPaneProps } from './contract.ts';
|
|
13
|
+
/** What every view receives. */
|
|
14
|
+
export interface ViewProps {
|
|
15
|
+
readonly p: LibraryPaneProps;
|
|
16
|
+
}
|
|
17
|
+
/** A load's state. */
|
|
18
|
+
export interface Loaded<T> {
|
|
19
|
+
readonly data: T | undefined;
|
|
20
|
+
readonly error: string | undefined;
|
|
21
|
+
readonly loading: boolean;
|
|
22
|
+
}
|
|
23
|
+
/**
|
|
24
|
+
* Read the host once per `deps` change (the store's `rev` is always one of
|
|
25
|
+
* them, so every mutation re-reads). A stale answer never lands.
|
|
26
|
+
*/
|
|
27
|
+
export declare function useLoad<T>(load: () => Promise<T>, deps: readonly unknown[]): Loaded<T>;
|
|
28
|
+
/** The value inside a Remote envelope, or a thrown Error with the wire's message. */
|
|
29
|
+
export declare function value<T>(answer: Promise<RemoteResult<T>>): Promise<T>;
|
|
30
|
+
/** The domain value inside an envelope + outcome, or a thrown Error with the refusal's lesson. */
|
|
31
|
+
export declare function outcome<T>(answer: Promise<RemoteResult<LibraryOutcome<T>>>): Promise<T>;
|
|
32
|
+
/** Loading / error / empty lines, in one place. */
|
|
33
|
+
export declare function StateLine({ loaded, t }: {
|
|
34
|
+
loaded: Loaded<unknown>;
|
|
35
|
+
t: LibraryPaneProps['t'];
|
|
36
|
+
}): ReactNode;
|
|
37
|
+
/** Weakness as three bars (filled = how stuck). */
|
|
38
|
+
export declare function WeaknessBars({ weakness, label }: {
|
|
39
|
+
weakness: number;
|
|
40
|
+
label: string;
|
|
41
|
+
}): ReactNode;
|
|
42
|
+
/** Review progress as five boxes. */
|
|
43
|
+
export declare function BoxDots({ box, label }: {
|
|
44
|
+
box: number;
|
|
45
|
+
label: string;
|
|
46
|
+
}): ReactNode;
|
|
47
|
+
/** A sentence with its term marked (case-insensitive, first occurrence). */
|
|
48
|
+
export declare function Highlight({ sentence, term }: {
|
|
49
|
+
sentence: string;
|
|
50
|
+
term: string;
|
|
51
|
+
}): ReactNode;
|
|
52
|
+
/** A relative time, coarse. */
|
|
53
|
+
export declare function when(at: string, t: LibraryPaneProps['t']): string;
|
|
54
|
+
/** A gloss short enough for a one-line row: up to its first dash or semicolon, then capped. */
|
|
55
|
+
export declare function shortGloss(gloss: string, max?: number): string;
|
|
56
|
+
/** Days from now until an ISO moment, at least 0. */
|
|
57
|
+
export declare function daysUntil(at: string): number;
|
|
58
|
+
/** A textbook's origin as a small colored tag (你的书 / 模型选的书 / 电子资料 / 模型自编). */
|
|
59
|
+
export declare function OriginTag({ origin, digital, t }: {
|
|
60
|
+
origin: LibraryResourceOrigin | undefined;
|
|
61
|
+
digital: boolean;
|
|
62
|
+
t: LibraryPaneProps['t'];
|
|
63
|
+
}): ReactNode;
|
|
64
|
+
/**
|
|
65
|
+
* The destructive-action confirm, on the host's own Modal (the blurred mask,
|
|
66
|
+
* the title bar with its close button, outline buttons — what the host's
|
|
67
|
+
* archive confirm uses): what goes as a grouped list with each count set
|
|
68
|
+
* off, what stays with a check, a red-text confirm. Esc, the mask and the
|
|
69
|
+
* close button cancel (not while busy); focus starts on 取消 so a stray
|
|
70
|
+
* Enter never deletes.
|
|
71
|
+
*/
|
|
72
|
+
export declare function ConfirmDialog({ title, lines, keep, note, confirmLabel, cancelLabel, busy, error, onConfirm, onCancel }: {
|
|
73
|
+
title: string;
|
|
74
|
+
/** What the action removes, one consequence per line (a leading count is set off). */
|
|
75
|
+
lines: readonly string[];
|
|
76
|
+
/** What survives it. */
|
|
77
|
+
keep?: string | undefined;
|
|
78
|
+
note?: string | undefined;
|
|
79
|
+
confirmLabel: string;
|
|
80
|
+
cancelLabel: string;
|
|
81
|
+
busy: boolean;
|
|
82
|
+
error?: string | undefined;
|
|
83
|
+
onConfirm: () => void;
|
|
84
|
+
onCancel: () => void;
|
|
85
|
+
}): ReactNode;
|
|
86
|
+
/**
|
|
87
|
+
* A progress bar cut into one segment per station: passed ones filled, the
|
|
88
|
+
* next one started — so an empty path still reads as progress, not a rule.
|
|
89
|
+
* Over 40 stations it collapses to one continuous bar.
|
|
90
|
+
*/
|
|
91
|
+
export declare function Segments({ done, total, label }: {
|
|
92
|
+
done: number;
|
|
93
|
+
total: number;
|
|
94
|
+
label?: string | undefined;
|
|
95
|
+
}): ReactNode;
|
|
96
|
+
//# sourceMappingURL=ui.d.ts.map
|
|
@@ -0,0 +1,124 @@
|
|
|
1
|
+
import { jsx as _jsx, Fragment as _Fragment, jsxs as _jsxs } from "react/jsx-runtime";
|
|
2
|
+
/**
|
|
3
|
+
* Small shared pieces of the library sidebar: the loading hook every view
|
|
4
|
+
* reads the host through, outcome unwrapping, and the few presentational
|
|
5
|
+
* atoms (state lines, weakness bars, review boxes, the term highlighter,
|
|
6
|
+
* the origin tag, the destructive-action confirm dialog).
|
|
7
|
+
*
|
|
8
|
+
* @module @khorsheed/dsh-library/client/ui
|
|
9
|
+
*/
|
|
10
|
+
import { useEffect, useRef, useState } from 'react';
|
|
11
|
+
import { Button, Modal } from '@deepseek-ai/dsh-client-ui-primitives';
|
|
12
|
+
import { IconCheckOutlineMedium } from "./icons.js";
|
|
13
|
+
import css from './LibraryPane.module.css';
|
|
14
|
+
/**
|
|
15
|
+
* Read the host once per `deps` change (the store's `rev` is always one of
|
|
16
|
+
* them, so every mutation re-reads). A stale answer never lands.
|
|
17
|
+
*/
|
|
18
|
+
export function useLoad(load, deps) {
|
|
19
|
+
const [state, setState] = useState({ data: undefined, error: undefined, loading: true });
|
|
20
|
+
useEffect(() => {
|
|
21
|
+
let cancelled = false;
|
|
22
|
+
setState(previous => ({ ...previous, loading: true }));
|
|
23
|
+
load().then(data => { if (!cancelled)
|
|
24
|
+
setState({ data, error: undefined, loading: false }); }, (error) => { if (!cancelled)
|
|
25
|
+
setState({ data: undefined, error: error instanceof Error ? error.message : String(error), loading: false }); });
|
|
26
|
+
return () => { cancelled = true; };
|
|
27
|
+
}, deps);
|
|
28
|
+
return state;
|
|
29
|
+
}
|
|
30
|
+
/** The value inside a Remote envelope, or a thrown Error with the wire's message. */
|
|
31
|
+
export async function value(answer) {
|
|
32
|
+
const result = await answer;
|
|
33
|
+
if (!result.ok)
|
|
34
|
+
throw new Error(result.error.message);
|
|
35
|
+
return result.value;
|
|
36
|
+
}
|
|
37
|
+
/** The domain value inside an envelope + outcome, or a thrown Error with the refusal's lesson. */
|
|
38
|
+
export async function outcome(answer) {
|
|
39
|
+
const inner = await value(answer);
|
|
40
|
+
if (!inner.ok)
|
|
41
|
+
throw new Error(inner.message);
|
|
42
|
+
return inner;
|
|
43
|
+
}
|
|
44
|
+
/** Loading / error / empty lines, in one place. */
|
|
45
|
+
export function StateLine({ loaded, t }) {
|
|
46
|
+
if (loaded.error !== undefined)
|
|
47
|
+
return _jsx("div", { className: css.error, children: t('state.error', { reason: loaded.error }) });
|
|
48
|
+
if (loaded.loading && loaded.data === undefined)
|
|
49
|
+
return _jsx("div", { className: css.muted, children: t('state.loading') });
|
|
50
|
+
return null;
|
|
51
|
+
}
|
|
52
|
+
/** Weakness as three bars (filled = how stuck). */
|
|
53
|
+
export function WeaknessBars({ weakness, label }) {
|
|
54
|
+
return (_jsx("span", { className: css.weak, role: "img", "aria-label": label, children: [1, 2, 3].map(level => _jsx("i", { "data-on": weakness >= level ? 'true' : 'false' }, level)) }));
|
|
55
|
+
}
|
|
56
|
+
/** Review progress as five boxes. */
|
|
57
|
+
export function BoxDots({ box, label }) {
|
|
58
|
+
return (_jsx("span", { className: css.boxes, role: "img", "aria-label": label, children: [1, 2, 3, 4, 5].map(level => _jsx("i", { "data-on": box >= level ? 'true' : 'false' }, level)) }));
|
|
59
|
+
}
|
|
60
|
+
/** A sentence with its term marked (case-insensitive, first occurrence). */
|
|
61
|
+
export function Highlight({ sentence, term }) {
|
|
62
|
+
const at = sentence.toLowerCase().indexOf(term.toLowerCase());
|
|
63
|
+
if (at < 0 || term === '')
|
|
64
|
+
return _jsx(_Fragment, { children: sentence });
|
|
65
|
+
return _jsxs(_Fragment, { children: [sentence.slice(0, at), _jsx("mark", { className: css.mark, children: sentence.slice(at, at + term.length) }), sentence.slice(at + term.length)] });
|
|
66
|
+
}
|
|
67
|
+
/** A relative time, coarse. */
|
|
68
|
+
export function when(at, t) {
|
|
69
|
+
const minutes = Math.floor((Date.now() - Date.parse(at)) / 60_000);
|
|
70
|
+
if (!Number.isFinite(minutes) || minutes < 1)
|
|
71
|
+
return t('when.justNow');
|
|
72
|
+
if (minutes < 60)
|
|
73
|
+
return t('when.minutes', { count: String(minutes) });
|
|
74
|
+
const hours = Math.floor(minutes / 60);
|
|
75
|
+
if (hours < 24)
|
|
76
|
+
return t('when.hours', { count: String(hours) });
|
|
77
|
+
const days = Math.floor(hours / 24);
|
|
78
|
+
return days === 1 ? t('when.yesterday') : t('when.days', { count: String(days) });
|
|
79
|
+
}
|
|
80
|
+
/** A gloss short enough for a one-line row: up to its first dash or semicolon, then capped. */
|
|
81
|
+
export function shortGloss(gloss, max = 18) {
|
|
82
|
+
const head = gloss.split(/\s*(?:——|—|;|;)\s*/)[0] ?? gloss;
|
|
83
|
+
return head.length > max ? `${head.slice(0, max)}…` : head;
|
|
84
|
+
}
|
|
85
|
+
/** Days from now until an ISO moment, at least 0. */
|
|
86
|
+
export function daysUntil(at) {
|
|
87
|
+
return Math.max(0, Math.ceil((Date.parse(at) - Date.now()) / 86_400_000));
|
|
88
|
+
}
|
|
89
|
+
/** A textbook's origin as a small colored tag (你的书 / 模型选的书 / 电子资料 / 模型自编). */
|
|
90
|
+
export function OriginTag({ origin, digital, t }) {
|
|
91
|
+
const key = origin === 'authored' ? 'authored' : origin === 'learner' ? 'learner' : digital ? 'digital' : 'picked';
|
|
92
|
+
return _jsx("span", { className: css.originTag, "data-origin": key, children: t(`origin.${key}`) });
|
|
93
|
+
}
|
|
94
|
+
/**
|
|
95
|
+
* The destructive-action confirm, on the host's own Modal (the blurred mask,
|
|
96
|
+
* the title bar with its close button, outline buttons — what the host's
|
|
97
|
+
* archive confirm uses): what goes as a grouped list with each count set
|
|
98
|
+
* off, what stays with a check, a red-text confirm. Esc, the mask and the
|
|
99
|
+
* close button cancel (not while busy); focus starts on 取消 so a stray
|
|
100
|
+
* Enter never deletes.
|
|
101
|
+
*/
|
|
102
|
+
export function ConfirmDialog({ title, lines, keep, note, confirmLabel, cancelLabel, busy, error, onConfirm, onCancel }) {
|
|
103
|
+
const cancelRef = useRef(null);
|
|
104
|
+
useEffect(() => { cancelRef.current?.focus(); }, []);
|
|
105
|
+
const close = () => { if (!busy)
|
|
106
|
+
onCancel(); };
|
|
107
|
+
return (_jsx(Modal, { open: true, onClose: close, closeLabel: cancelLabel, title: title, footer: (_jsxs(_Fragment, { children: [_jsx(Button, { ref: cancelRef, variant: "outline", disabled: busy, onClick: close, children: cancelLabel }), _jsx(Button, { variant: "outline", className: css.dialogDanger, disabled: busy, onClick: onConfirm, children: confirmLabel })] })), children: _jsxs("div", { className: css.dialogBody, children: [lines.length > 0 && (_jsx("ul", { className: css.dialogList, children: lines.map(line => {
|
|
108
|
+
const counted = /^(\d+)\s*(.*)$/.exec(line);
|
|
109
|
+
return (_jsx("li", { className: css.dialogRow, children: counted === null ? line : _jsxs(_Fragment, { children: [_jsx("b", { className: css.dialogCount, children: counted[1] }), _jsx("span", { children: counted[2] })] }) }, line));
|
|
110
|
+
}) })), keep !== undefined && _jsxs("div", { className: css.dialogKeep, children: [_jsx(IconCheckOutlineMedium, { size: 14 }), keep] }), note !== undefined && _jsx("div", { className: css.dialogNote, children: note }), error !== undefined && _jsx("div", { className: css.error, children: error })] }) }));
|
|
111
|
+
}
|
|
112
|
+
/**
|
|
113
|
+
* A progress bar cut into one segment per station: passed ones filled, the
|
|
114
|
+
* next one started — so an empty path still reads as progress, not a rule.
|
|
115
|
+
* Over 40 stations it collapses to one continuous bar.
|
|
116
|
+
*/
|
|
117
|
+
export function Segments({ done, total, label }) {
|
|
118
|
+
if (total <= 0)
|
|
119
|
+
return null;
|
|
120
|
+
if (total > 40) {
|
|
121
|
+
return _jsxs("span", { className: css.segs, role: "img", "aria-label": label, children: [_jsx("i", { "data-state": "done", style: { gridColumn: `span ${Math.max(1, done)}` } }), _jsx("i", { style: { gridColumn: `span ${Math.max(1, total - done)}` } })] });
|
|
122
|
+
}
|
|
123
|
+
return (_jsx("span", { className: css.segs, role: "img", "aria-label": label, children: Array.from({ length: total }, (_, index) => _jsx("i", { "data-state": index < done ? 'done' : index === done ? 'next' : undefined }, index)) }));
|
|
124
|
+
}
|
|
@@ -0,0 +1,57 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* One textbook in the sidebar (a book, a course, a coach-authored outline):
|
|
3
|
+
* its place in the study order and origin, its roadmap draft waiting for
|
|
4
|
+
* 采用, its adopted path as an S-shaped trail of nodes, the chapters the path
|
|
5
|
+
* skips, and one node's page. Every path belongs to a textbook — the
|
|
6
|
+
* project carries none. 「我的记录」 exists only when the station covers
|
|
7
|
+
* its own textbook. Every station carries the same four tabs —
|
|
8
|
+
* 导读 / 我的记录 / 讲义 / 章节考: the gate tab is the station's quiz on a
|
|
9
|
+
* quiz / read node, and its acceptance sheet (the coach judges the rubric
|
|
10
|
+
* with library_assess) on an acceptance node.
|
|
11
|
+
*
|
|
12
|
+
* @module @khorsheed/dsh-library/client/views/book
|
|
13
|
+
*/
|
|
14
|
+
import { type ReactNode } from 'react';
|
|
15
|
+
import { type LibraryAssessment, type LibraryResource, type LibraryRoadmap, type RoadmapNode } from '../../types.ts';
|
|
16
|
+
import { type CoverContext } from '../covers.tsx';
|
|
17
|
+
import { type ViewProps } from '../ui.tsx';
|
|
18
|
+
/** A node's contents rows of one thick resource, as titles. */
|
|
19
|
+
export declare function rowsOf(node: RoadmapNode, resource: LibraryResource): string[];
|
|
20
|
+
/** The kind line of a thick resource: a book's bookKind, else its kind label. */
|
|
21
|
+
export declare function kindLabelOf(resource: LibraryResource, t: ViewProps['p']['t']): string;
|
|
22
|
+
/** One resource: draft card, path, or the ask for one. */
|
|
23
|
+
export declare function BookView({ p, projectId, resourceId }: ViewProps & {
|
|
24
|
+
projectId: string;
|
|
25
|
+
resourceId: string;
|
|
26
|
+
}): ReactNode;
|
|
27
|
+
/** The draft card on a textbook's page: what the coach proposes, what it skips, and the learner's 采用 / 放弃. */
|
|
28
|
+
export declare function DraftCard({ p, draft, context, tocBy, skipped, replaces, busy, onAdopt, onDiscard }: {
|
|
29
|
+
p: ViewProps['p'];
|
|
30
|
+
draft: LibraryRoadmap;
|
|
31
|
+
/** The project's shelf, for labeling covers. */
|
|
32
|
+
context: CoverContext;
|
|
33
|
+
/** The textbook's contents-source badge. */
|
|
34
|
+
tocBy?: 'web' | 'model' | 'photo' | undefined;
|
|
35
|
+
/** The contents rows no station covers. */
|
|
36
|
+
skipped: readonly {
|
|
37
|
+
n: number;
|
|
38
|
+
title: string;
|
|
39
|
+
}[];
|
|
40
|
+
replaces: boolean;
|
|
41
|
+
busy: boolean;
|
|
42
|
+
onAdopt: () => void;
|
|
43
|
+
onDiscard: () => void;
|
|
44
|
+
}): ReactNode;
|
|
45
|
+
/**
|
|
46
|
+
* Rows of quizzes that gate no station (the resource page's 本书测验, the project
|
|
47
|
+
* page's 自由测验, a station's same-scope list) — each opens the paper. The
|
|
48
|
+
* route's resource comes from the quiz's own scope.
|
|
49
|
+
*/
|
|
50
|
+
export declare function QuizRows({ p, projectId, quizzes }: {
|
|
51
|
+
p: ViewProps['p'];
|
|
52
|
+
projectId: string;
|
|
53
|
+
quizzes: readonly LibraryAssessment[];
|
|
54
|
+
}): ReactNode;
|
|
55
|
+
/** 「照片 3 · 领域知识 7」 for a quiz row. */
|
|
56
|
+
export declare function sourceMix(kinds: readonly string[], t: ViewProps['p']['t']): string;
|
|
57
|
+
//# sourceMappingURL=book.d.ts.map
|
|
@@ -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
|
+
}
|