@deckbuilderkit/build 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/package.json +26 -4
- package/src/add-view.ts +105 -0
- package/src/app.ts +41 -0
- package/src/build.ts +123 -0
- package/src/cli.ts +135 -0
- package/src/dev.ts +42 -0
- package/src/icon.ts +26 -0
- package/src/keys.ts +46 -0
- package/src/new.ts +58 -0
- package/src/release.ts +182 -0
- package/template/app.ts +10 -0
- package/template/package.json +31 -0
- package/template/server/main.ts +95 -0
- package/template/server/routes.ts +10 -0
- package/template/tsconfig.json +5 -0
- package/template/vite.config.ts +20 -0
- package/template/web/index.html +13 -0
- package/template/web/public/favicon.svg +10 -0
- package/template/web/src/App.tsx +187 -0
- package/template/web/src/api.ts +38 -0
- package/template/web/src/i18n/en.ts +89 -0
- package/template/web/src/i18n/index.ts +8 -0
- package/template/web/src/i18n/pt-BR.ts +91 -0
- package/template/web/src/main.tsx +11 -0
- package/template/web/src/screens/Home.tsx +49 -0
- package/template/web/src/screens/SettingsModal.tsx +541 -0
- package/template/web/src/settings.ts +33 -0
- package/template/web/tsconfig.json +9 -0
- package/view-template/README.md +10 -0
- package/view-template/package.json +25 -0
- package/view-template/src/i18n.ts +14 -0
- package/view-template/src/server.ts +9 -0
- package/view-template/src/styles.css +1 -0
- package/view-template/src/view.tsx +22 -0
- package/view-template/src/web.ts +2 -0
- package/view-template/tsconfig.json +5 -0
- package/README.md +0 -3
|
@@ -0,0 +1,541 @@
|
|
|
1
|
+
import { type CSSProperties, type ReactNode, useMemo, useRef, useState } from 'react';
|
|
2
|
+
import { AppearanceFields, DockField, IconsField, LayoutIconField, LayoutField, MainMenuField, ZoomField, type ApiError, detectLanguage, ErrorBanner, errorMessage, errorOf, GLOBAL, isDesktop, KeybindingsEditor, LANGUAGES, isModified, resetPatch, Modal, parseSettingsQuery, settingDescriptionText, toggleModifiedFilter, SettingLinksProvider, type SettingLinks, SettingRow, Spinner, useRevealSetting, useRovingList, SETTING_ROW_CONTROLS, useAsync, useKeybindings, type Language, type SettingDef, type SettingsSectionNode, sectionKey } from '@deckbuilderkit/ui';
|
|
3
|
+
import { APP } from '../../../app';
|
|
4
|
+
import { api, type Settings } from '../api';
|
|
5
|
+
import { type MessageKey, t } from '../i18n';
|
|
6
|
+
import { en } from '../i18n/en';
|
|
7
|
+
import { settingsRegistry } from '../settings';
|
|
8
|
+
|
|
9
|
+
interface Props {
|
|
10
|
+
settings: Settings;
|
|
11
|
+
onChange: (patch: Partial<Settings>) => void;
|
|
12
|
+
/** Called once the server accepted the quit. */
|
|
13
|
+
onQuit: () => void;
|
|
14
|
+
}
|
|
15
|
+
|
|
16
|
+
type Change = (patch: Partial<Settings>) => void;
|
|
17
|
+
|
|
18
|
+
/** Translates `key` when it is a message, else falls back. */
|
|
19
|
+
const tr = (key: string | undefined, fallback?: string): string =>
|
|
20
|
+
!key ? (fallback ?? '') : Object.hasOwn(en, key) ? t(key as MessageKey) : (fallback ?? key);
|
|
21
|
+
|
|
22
|
+
const sectionLabel = (node: SettingsSectionNode): string => {
|
|
23
|
+
const key = sectionKey(node.path);
|
|
24
|
+
const last = node.path[node.path.length - 1] ?? key;
|
|
25
|
+
return tr(node.label, last.charAt(0).toUpperCase() + last.slice(1));
|
|
26
|
+
};
|
|
27
|
+
|
|
28
|
+
interface FlatNode {
|
|
29
|
+
node: SettingsSectionNode;
|
|
30
|
+
key: string;
|
|
31
|
+
depth: number;
|
|
32
|
+
/** Labels from the top level down to this node. */
|
|
33
|
+
trail: string[];
|
|
34
|
+
}
|
|
35
|
+
|
|
36
|
+
const flatten = (nodes: SettingsSectionNode[], depth = 0, trail: string[] = []): FlatNode[] =>
|
|
37
|
+
nodes.flatMap((node) => {
|
|
38
|
+
const here = [...trail, sectionLabel(node)];
|
|
39
|
+
return [{ node, key: sectionKey(node.path), depth, trail: here }, ...flatten(node.children, depth + 1, here)];
|
|
40
|
+
});
|
|
41
|
+
|
|
42
|
+
const isUnder = (key: string, parent: string) => key === parent || key.startsWith(`${parent}.`);
|
|
43
|
+
|
|
44
|
+
interface SectionHit {
|
|
45
|
+
/** The section label matched, so the whole section shows. */
|
|
46
|
+
whole: boolean;
|
|
47
|
+
/** Matching settings directly in the section. */
|
|
48
|
+
ids: string[];
|
|
49
|
+
}
|
|
50
|
+
|
|
51
|
+
/** Structural equality for JSON-like values (arrays, plain objects, primitives). */
|
|
52
|
+
function deepEqual(a: unknown, b: unknown): boolean {
|
|
53
|
+
if (a === b) return true;
|
|
54
|
+
if (typeof a !== 'object' || typeof b !== 'object' || a === null || b === null) return false;
|
|
55
|
+
if (Array.isArray(a) !== Array.isArray(b)) return false;
|
|
56
|
+
const ka = Object.keys(a);
|
|
57
|
+
const kb = Object.keys(b);
|
|
58
|
+
return ka.length === kb.length && ka.every((k) => Object.hasOwn(b, k) && deepEqual((a as Record<string, unknown>)[k], (b as Record<string, unknown>)[k]));
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
/** Whether the screen can read and reset the setting (custom ones have no generic getter). */
|
|
62
|
+
const handled = (def: SettingDef<Settings>): def is Exclude<SettingDef<Settings>, { kind: 'custom' }> => def.kind !== 'custom';
|
|
63
|
+
|
|
64
|
+
/** The patch that sets every one of `defs` back to its default, built on `s`. */
|
|
65
|
+
function defaultsPatch(defs: SettingDef<Settings>[], s: Settings): Partial<Settings> {
|
|
66
|
+
let acc = s;
|
|
67
|
+
let patch: Partial<Settings> = {};
|
|
68
|
+
for (const def of defs) {
|
|
69
|
+
const p = resetPatch(def, acc);
|
|
70
|
+
if (!p) continue;
|
|
71
|
+
acc = { ...acc, ...p };
|
|
72
|
+
patch = { ...patch, ...p };
|
|
73
|
+
}
|
|
74
|
+
return patch;
|
|
75
|
+
}
|
|
76
|
+
|
|
77
|
+
/** A setting's translated label, or the id when it has none. */
|
|
78
|
+
const labelOf = (id: string): string | undefined => {
|
|
79
|
+
const def = settingsRegistry.setting(id);
|
|
80
|
+
return def && tr(def.label, id);
|
|
81
|
+
};
|
|
82
|
+
|
|
83
|
+
/**
|
|
84
|
+
* Searches the schema like VS Code's settings search: a setting matches when its label, rendered description or id
|
|
85
|
+
* holds every word; a section whose label matches (or sits under one that does) shows whole. `@modified` and
|
|
86
|
+
* `@id:<id>` filters AND with the words; while one is on, a matching section shows only its filtered schema rows,
|
|
87
|
+
* since hand-written fields have no schema entry to filter. Returns the sections with hits.
|
|
88
|
+
*/
|
|
89
|
+
function searchSchema(flat: FlatNode[], query: string, settings: Settings): Map<string, SectionHit> {
|
|
90
|
+
const { text, modified, ids: only } = parseSettingsQuery(query);
|
|
91
|
+
const words = norm(text).trim().split(' ').filter(Boolean);
|
|
92
|
+
const filtered = modified || only.length > 0;
|
|
93
|
+
const found = new Map<string, SectionHit>();
|
|
94
|
+
if (words.length === 0 && !filtered) return found;
|
|
95
|
+
const labelKeys: string[] = [];
|
|
96
|
+
for (const { node, key } of flat) {
|
|
97
|
+
const labelHit = words.length > 0 && (words.every((w) => norm(sectionLabel(node)).includes(w)) || labelKeys.some((k) => isUnder(key, k)));
|
|
98
|
+
if (labelHit) labelKeys.push(key);
|
|
99
|
+
const whole = labelHit && !filtered;
|
|
100
|
+
const ids = settingsRegistry
|
|
101
|
+
.settingsIn(node.path)
|
|
102
|
+
.filter((s) => {
|
|
103
|
+
if (modified && !isModified(s, settings)) return false;
|
|
104
|
+
if (only.length > 0 && !only.includes(s.id)) return false;
|
|
105
|
+
if (labelHit) return true;
|
|
106
|
+
const hay = norm([tr(s.label), settingDescriptionText(tr(s.description), labelOf), s.id].join(' '));
|
|
107
|
+
return words.every((w) => hay.includes(w));
|
|
108
|
+
})
|
|
109
|
+
.map((s) => s.id);
|
|
110
|
+
if (whole || ids.length > 0) found.set(key, { whole, ids });
|
|
111
|
+
}
|
|
112
|
+
return found;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
/**
|
|
116
|
+
* Settings as VS Code opens them: a modal editor over the current screen, closed with Escape or a click outside.
|
|
117
|
+
* The nav is the schema's section tree; a parent shows its own settings and its children's. Keyboard › Shortcuts
|
|
118
|
+
* holds the keybindings editor, which has its own filter; `initialSection` opens a section directly.
|
|
119
|
+
* Changes stay a draft until Apply or Ok; closing with changes asks whether to save them. The Shortcuts page saves at once.
|
|
120
|
+
*/
|
|
121
|
+
export function SettingsModal({ settings, onChange, onQuit, onClose, initialSection }: Props & { onClose: () => void; initialSection?: string }) {
|
|
122
|
+
const flat = useMemo(() => flatten(settingsRegistry.sectionTree()), []);
|
|
123
|
+
const [section, setSection] = useState(initialSection ?? flat[0]?.key ?? 'general');
|
|
124
|
+
// The initial section's ancestors start expanded, so the nav shows it.
|
|
125
|
+
const [expanded, setExpanded] = useState<ReadonlySet<string>>(() => {
|
|
126
|
+
const path = initialSection?.split('.') ?? [];
|
|
127
|
+
return new Set(path.slice(0, -1).map((_, i) => path.slice(0, i + 1).join('.')));
|
|
128
|
+
});
|
|
129
|
+
const [query, setQuery] = useState('');
|
|
130
|
+
const [pending, setPending] = useState<Partial<Settings>>({});
|
|
131
|
+
const draft = useMemo<Settings>(() => ({ ...settings, ...pending }), [settings, pending]);
|
|
132
|
+
const change: Change = (patch) => setPending((p) => ({ ...p, ...patch }));
|
|
133
|
+
const dirty = Object.keys(pending).length > 0;
|
|
134
|
+
const apply = () => {
|
|
135
|
+
if (!dirty) return;
|
|
136
|
+
onChange(pending);
|
|
137
|
+
setPending({});
|
|
138
|
+
};
|
|
139
|
+
// Closing (Ok, X, Escape, backdrop) with unsaved changes asks first.
|
|
140
|
+
const [asking, setAsking] = useState(false);
|
|
141
|
+
const requestClose = () => (dirty ? setAsking(true) : onClose());
|
|
142
|
+
const body = useRef<HTMLDivElement>(null);
|
|
143
|
+
const searching = query.trim() !== '';
|
|
144
|
+
const hits = useMemo(() => searchSchema(flat, query, draft), [flat, query, draft]);
|
|
145
|
+
const modifiedOnly = parseSettingsQuery(query).modified;
|
|
146
|
+
const deepCount = (key: string) => [...hits].reduce((n, [k, h]) => (isUnder(k, key) ? n + h.ids.length : n), 0);
|
|
147
|
+
const total = [...hits.values()].reduce((n, h) => n + h.ids.length, 0);
|
|
148
|
+
|
|
149
|
+
const render = (key: string): ReactNode => {
|
|
150
|
+
switch (key) {
|
|
151
|
+
case 'general': return <GeneralSection settings={draft} onChange={change} onQuit={onQuit} />;
|
|
152
|
+
case 'appearance':
|
|
153
|
+
return (
|
|
154
|
+
<section className="section">
|
|
155
|
+
<div className="card form">
|
|
156
|
+
<AppearanceFields value={draft} onChange={change} />
|
|
157
|
+
<LayoutField value={draft} onChange={change} />
|
|
158
|
+
<MainMenuField value={draft} onChange={change} />
|
|
159
|
+
<DockField value={draft} onChange={change} />
|
|
160
|
+
<IconsField value={draft} onChange={change} />
|
|
161
|
+
<LayoutIconField value={draft} onChange={change} />
|
|
162
|
+
</div>
|
|
163
|
+
</section>
|
|
164
|
+
);
|
|
165
|
+
case 'window':
|
|
166
|
+
return (
|
|
167
|
+
<section className="section">
|
|
168
|
+
<div className="card form">
|
|
169
|
+
<ZoomField value={draft} onChange={change} />
|
|
170
|
+
</div>
|
|
171
|
+
</section>
|
|
172
|
+
);
|
|
173
|
+
case 'keyboard.shortcuts': return <ShortcutsSection settings={settings} onChange={onChange} />;
|
|
174
|
+
case 'system.updates': return <UpdatesCard folder={draft.updateFolder} onFolder={(updateFolder) => change({ updateFolder })} />;
|
|
175
|
+
default: {
|
|
176
|
+
// Sections other packages add: their schema rows.
|
|
177
|
+
const defs = settingsRegistry.settingsIn(key.split('.'));
|
|
178
|
+
return defs.length > 0 ? <SettingRows ids={defs.map((d) => d.id)} settings={draft} onChange={change} /> : null;
|
|
179
|
+
}
|
|
180
|
+
}
|
|
181
|
+
};
|
|
182
|
+
|
|
183
|
+
const open = (key: string) => {
|
|
184
|
+
setQuery('');
|
|
185
|
+
setSection(key);
|
|
186
|
+
body.current?.scrollTo({ top: 0 });
|
|
187
|
+
};
|
|
188
|
+
const toggle = (key: string) => setExpanded((prev) => {
|
|
189
|
+
const next = new Set(prev);
|
|
190
|
+
if (!next.delete(key)) next.add(key);
|
|
191
|
+
return next;
|
|
192
|
+
});
|
|
193
|
+
// A node shows when its ancestors are expanded; while searching, when it or a descendant has hits.
|
|
194
|
+
const visible = (f: FlatNode) =>
|
|
195
|
+
searching
|
|
196
|
+
? [...hits.keys()].some((k) => isUnder(k, f.key))
|
|
197
|
+
: f.node.path.slice(0, -1).every((_, i) => expanded.has(sectionKey(f.node.path.slice(0, i + 1))));
|
|
198
|
+
const selected = flat.find((f) => f.key === section) ?? flat[0];
|
|
199
|
+
// A node has unsaved changes when a setting in it (or under it) differs in the draft from the saved settings.
|
|
200
|
+
const unsaved = (path: readonly string[]) =>
|
|
201
|
+
dirty && settingsRegistry.settingsIn(path, true).some((s) => handled(s) && !deepEqual(s.get(draft), s.get(settings)));
|
|
202
|
+
// Reset restores the defaults of the page shown: the selected section and its children.
|
|
203
|
+
const pageDefs = selected && !searching ? settingsRegistry.settingsIn(selected.node.path, true).filter(handled) : [];
|
|
204
|
+
const canReset = pageDefs.some((d) => !deepEqual(d.get(draft), d.default));
|
|
205
|
+
const footer = selected?.key === 'keyboard.shortcuts' && !searching ? undefined : (
|
|
206
|
+
<>
|
|
207
|
+
<button className="btn btn-sm" disabled={!canReset} onClick={() => change(defaultsPatch(pageDefs, draft))}>{t('settings.footer.reset')}</button>
|
|
208
|
+
<button className="btn btn-sm" disabled={!dirty} onClick={apply}>{t('settings.footer.apply')}</button>
|
|
209
|
+
<button
|
|
210
|
+
className="btn btn-sm btn-primary"
|
|
211
|
+
onClick={requestClose}
|
|
212
|
+
>
|
|
213
|
+
{t('settings.footer.ok')}
|
|
214
|
+
</button>
|
|
215
|
+
</>
|
|
216
|
+
);
|
|
217
|
+
// `#setting.id#` links in descriptions: open the target's section (unless search already shows it), then its row.
|
|
218
|
+
const reveal = useRevealSetting(body);
|
|
219
|
+
// Up/Down/Home/End/PageUp/PageDown between rows; Enter or F2 into a row's control, Escape back to the row.
|
|
220
|
+
useRovingList(body, '[data-setting]', { control: SETTING_ROW_CONTROLS });
|
|
221
|
+
const links = useMemo<SettingLinks>(() => ({
|
|
222
|
+
labelOf,
|
|
223
|
+
onNavigate: (id) => {
|
|
224
|
+
const def = settingsRegistry.setting(id);
|
|
225
|
+
if (!def) return;
|
|
226
|
+
const hit = hits.get(sectionKey(def.section));
|
|
227
|
+
if (!hit || !(hit.whole || hit.ids.includes(id))) {
|
|
228
|
+
setQuery('');
|
|
229
|
+
setSection(sectionKey(def.section));
|
|
230
|
+
setExpanded((prev) => new Set([...prev, ...def.section.slice(0, -1).map((_, i) => sectionKey(def.section.slice(0, i + 1)))]));
|
|
231
|
+
}
|
|
232
|
+
reveal(id);
|
|
233
|
+
},
|
|
234
|
+
}), [hits, reveal]);
|
|
235
|
+
|
|
236
|
+
return (
|
|
237
|
+
<Modal title={t('settings.title')} onClose={requestClose} footer={footer} large>
|
|
238
|
+
<SettingLinksProvider value={links}>
|
|
239
|
+
<div className="settings-layout">
|
|
240
|
+
<nav className="settings-nav" aria-label={t('settings.title')}>
|
|
241
|
+
<div className="settings-nav-search-box">
|
|
242
|
+
<input
|
|
243
|
+
className="settings-search settings-nav-search"
|
|
244
|
+
type="search"
|
|
245
|
+
value={query}
|
|
246
|
+
placeholder={t('settings.search')}
|
|
247
|
+
aria-label={t('settings.search')}
|
|
248
|
+
onChange={(e) => setQuery(e.target.value)}
|
|
249
|
+
onKeyDown={(e) => {
|
|
250
|
+
if (e.key === 'Escape' && query) {
|
|
251
|
+
e.stopPropagation();
|
|
252
|
+
setQuery('');
|
|
253
|
+
} else if (e.key === 'ArrowDown') {
|
|
254
|
+
// Like VS Code: down from the search box goes into the settings.
|
|
255
|
+
const first = body.current?.querySelector<HTMLElement>(`.field :is(${CONTROLS})`);
|
|
256
|
+
if (first) {
|
|
257
|
+
e.preventDefault();
|
|
258
|
+
first.focus();
|
|
259
|
+
}
|
|
260
|
+
}
|
|
261
|
+
}}
|
|
262
|
+
/>
|
|
263
|
+
<button
|
|
264
|
+
type="button"
|
|
265
|
+
className="settings-nav-filter"
|
|
266
|
+
title={t('settings.search.modified')}
|
|
267
|
+
aria-label={t('settings.search.modified')}
|
|
268
|
+
aria-pressed={modifiedOnly}
|
|
269
|
+
onClick={() => setQuery(toggleModifiedFilter)}
|
|
270
|
+
>
|
|
271
|
+
<svg width="14" height="14" viewBox="0 0 16 16" fill="none" stroke="currentColor" strokeWidth="1.4" strokeLinejoin="round" aria-hidden="true">
|
|
272
|
+
<path d="M2 3h12l-4.5 5.5V13l-3 1.5V8.5z" />
|
|
273
|
+
</svg>
|
|
274
|
+
</button>
|
|
275
|
+
</div>
|
|
276
|
+
{searching && <span className="settings-count">{t(total === 1 ? 'settings.search.count.one' : 'settings.search.count', { n: total })}</span>}
|
|
277
|
+
{flat.filter(visible).map((f) => {
|
|
278
|
+
const parent = f.node.children.length > 0;
|
|
279
|
+
const isOpen = searching || expanded.has(f.key);
|
|
280
|
+
const active = !searching && section === f.key;
|
|
281
|
+
return (
|
|
282
|
+
<button
|
|
283
|
+
key={f.key}
|
|
284
|
+
className={`settings-nav-item${active ? ' active' : ''}`}
|
|
285
|
+
style={{ '--depth': f.depth } as CSSProperties}
|
|
286
|
+
aria-current={active}
|
|
287
|
+
aria-expanded={parent ? isOpen : undefined}
|
|
288
|
+
onClick={() => {
|
|
289
|
+
if (parent && (!expanded.has(f.key) || active)) toggle(f.key);
|
|
290
|
+
open(f.key);
|
|
291
|
+
}}
|
|
292
|
+
>
|
|
293
|
+
<span
|
|
294
|
+
className={`settings-nav-twisty${parent ? '' : ' leaf'}${isOpen ? ' open' : ''}`}
|
|
295
|
+
onClick={(e) => {
|
|
296
|
+
if (!parent || searching) return;
|
|
297
|
+
e.stopPropagation();
|
|
298
|
+
toggle(f.key);
|
|
299
|
+
}}
|
|
300
|
+
>
|
|
301
|
+
▶
|
|
302
|
+
</span>
|
|
303
|
+
{sectionLabel(f.node)}
|
|
304
|
+
{unsaved(f.node.path) && (
|
|
305
|
+
<span className="settings-nav-modified" title={t('settings.row.modified')} aria-label={t('settings.row.modified')} />
|
|
306
|
+
)}
|
|
307
|
+
{searching && <span className="settings-nav-count">{deepCount(f.key)}</span>}
|
|
308
|
+
</button>
|
|
309
|
+
);
|
|
310
|
+
})}
|
|
311
|
+
</nav>
|
|
312
|
+
<div className="scroll" ref={body}>
|
|
313
|
+
{searching
|
|
314
|
+
? flat.filter((f) => hits.has(f.key)).map((f) => {
|
|
315
|
+
const hit = hits.get(f.key)!;
|
|
316
|
+
return (
|
|
317
|
+
<div key={f.key} className="settings-group" data-section={f.key}>
|
|
318
|
+
<h3 className="settings-group-title">{f.trail.join(' › ')}</h3>
|
|
319
|
+
{hit.whole ? render(f.key) : <SettingRows ids={hit.ids} settings={draft} onChange={change} />}
|
|
320
|
+
</div>
|
|
321
|
+
);
|
|
322
|
+
})
|
|
323
|
+
: selected && (
|
|
324
|
+
<div key={`page-${selected.key}`} className="settings-page">
|
|
325
|
+
<h3 className="settings-group-title">{selected.trail.join(' › ')}</h3>
|
|
326
|
+
{render(selected.key)}
|
|
327
|
+
{flat.filter((f) => f.key !== selected.key && isUnder(f.key, selected.key)).map((f) => (
|
|
328
|
+
<div key={f.key} className="settings-group" data-section={f.key}>
|
|
329
|
+
<h3 className="settings-subgroup-title">{f.trail.slice(selected.trail.length).join(' › ')}</h3>
|
|
330
|
+
{render(f.key)}
|
|
331
|
+
</div>
|
|
332
|
+
))}
|
|
333
|
+
</div>
|
|
334
|
+
)}
|
|
335
|
+
{searching && hits.size === 0 && (
|
|
336
|
+
<div className="settings-empty">
|
|
337
|
+
<span className="muted">{t('settings.search.none')}</span>
|
|
338
|
+
<button className="btn btn-sm" onClick={() => setQuery('')}>{t('settings.search.clear')}</button>
|
|
339
|
+
</div>
|
|
340
|
+
)}
|
|
341
|
+
</div>
|
|
342
|
+
</div>
|
|
343
|
+
</SettingLinksProvider>
|
|
344
|
+
{asking && (
|
|
345
|
+
<Modal
|
|
346
|
+
title={t('settings.unsaved.title')}
|
|
347
|
+
onClose={() => setAsking(false)}
|
|
348
|
+
footer={
|
|
349
|
+
<>
|
|
350
|
+
<button className="btn" onClick={() => setAsking(false)} autoFocus>{t('settings.unsaved.cancel')}</button>
|
|
351
|
+
<button className="btn" onClick={onClose}>{t('settings.unsaved.discard')}</button>
|
|
352
|
+
<button
|
|
353
|
+
className="btn btn-primary"
|
|
354
|
+
onClick={() => {
|
|
355
|
+
apply();
|
|
356
|
+
onClose();
|
|
357
|
+
}}
|
|
358
|
+
>
|
|
359
|
+
{t('settings.unsaved.save')}
|
|
360
|
+
</button>
|
|
361
|
+
</>
|
|
362
|
+
}
|
|
363
|
+
>
|
|
364
|
+
<p>{t('settings.unsaved.message')}</p>
|
|
365
|
+
</Modal>
|
|
366
|
+
)}
|
|
367
|
+
</Modal>
|
|
368
|
+
);
|
|
369
|
+
}
|
|
370
|
+
|
|
371
|
+
/** Schema settings as one card of rows (search results, sections with no screen of their own). */
|
|
372
|
+
function SettingRows({ ids, settings, onChange }: { ids: string[]; settings: Settings; onChange: Change }) {
|
|
373
|
+
return (
|
|
374
|
+
<section className="section">
|
|
375
|
+
<div className="card form">
|
|
376
|
+
{ids.map((id) => <SettingRow key={id} def={settingsRegistry.setting(id)!} settings={settings} onChange={onChange} />)}
|
|
377
|
+
</div>
|
|
378
|
+
</section>
|
|
379
|
+
);
|
|
380
|
+
}
|
|
381
|
+
|
|
382
|
+
// What ArrowDown in the search box focuses first.
|
|
383
|
+
const CONTROLS = 'input, select, textarea, button';
|
|
384
|
+
|
|
385
|
+
const norm = (s: string | null | undefined) => (s ?? '').toLocaleLowerCase().replace(/\s+/g, ' ');
|
|
386
|
+
|
|
387
|
+
function GeneralSection({ settings, onChange, onQuit }: Props) {
|
|
388
|
+
return (
|
|
389
|
+
<>
|
|
390
|
+
<div className="card form">
|
|
391
|
+
<label className="field">
|
|
392
|
+
<span className="mono-label">{t('settings.language')}</span>
|
|
393
|
+
<select value={settings.language ?? detectLanguage()} onChange={(e) => onChange({ language: e.target.value as Language })}>
|
|
394
|
+
{LANGUAGES.map((l) => <option key={l.id} value={l.id}>{l.label}</option>)}
|
|
395
|
+
</select>
|
|
396
|
+
</label>
|
|
397
|
+
{settingsRegistry.settingsIn(['general']).map((def) => <SettingRow key={def.id} def={def} settings={settings} onChange={onChange} />)}
|
|
398
|
+
</div>
|
|
399
|
+
{(!isDesktop || settings.closeToTray) && <GeneralCard onQuit={onQuit} />}
|
|
400
|
+
</>
|
|
401
|
+
);
|
|
402
|
+
}
|
|
403
|
+
|
|
404
|
+
/** Every command of the app, with the keys the user can change; a change applies at once. It has its own filter. */
|
|
405
|
+
function ShortcutsSection({ settings, onChange }: { settings: Settings; onChange: Change }) {
|
|
406
|
+
const keys = useKeybindings();
|
|
407
|
+
if (!keys) return null;
|
|
408
|
+
const scopeLabel = (scope: string) => (scope === GLOBAL ? t('keys.global') : tr(`nav.${scope}`, scope));
|
|
409
|
+
return <KeybindingsEditor defs={keys.defs} keymap={settings.keybindings ?? {}} onChange={(keybindings) => onChange({ keybindings })} scopeLabel={scopeLabel} />;
|
|
410
|
+
}
|
|
411
|
+
|
|
412
|
+
|
|
413
|
+
/**
|
|
414
|
+
* Where closing the window doesn't stop the app: browser and app mode, and with the tray (the
|
|
415
|
+
* setting, which applies after a restart; until then the tray menu or the close button quits).
|
|
416
|
+
*/
|
|
417
|
+
function GeneralCard({ onQuit }: { onQuit: () => void }) {
|
|
418
|
+
const [busy, setBusy] = useState(false);
|
|
419
|
+
const [error, setError] = useState<ApiError | null>(null);
|
|
420
|
+
|
|
421
|
+
const quit = async () => {
|
|
422
|
+
setBusy(true);
|
|
423
|
+
setError(null);
|
|
424
|
+
try {
|
|
425
|
+
await api.quit();
|
|
426
|
+
onQuit();
|
|
427
|
+
} catch (err) {
|
|
428
|
+
setError(errorOf(err));
|
|
429
|
+
setBusy(false);
|
|
430
|
+
}
|
|
431
|
+
};
|
|
432
|
+
|
|
433
|
+
return (
|
|
434
|
+
<section className="section">
|
|
435
|
+
<div className="card form">
|
|
436
|
+
<div className="field">
|
|
437
|
+
<div className="row">
|
|
438
|
+
<button className="btn btn-sm btn-danger" disabled={busy} onClick={quit}>
|
|
439
|
+
{t('settings.quit')}
|
|
440
|
+
</button>
|
|
441
|
+
</div>
|
|
442
|
+
<span className="muted">{t(isDesktop ? 'settings.quit.hintTray' : 'settings.quit.hint', { name: APP.name })}</span>
|
|
443
|
+
</div>
|
|
444
|
+
{error && <ErrorBanner error={error} compact />}
|
|
445
|
+
</div>
|
|
446
|
+
</section>
|
|
447
|
+
);
|
|
448
|
+
}
|
|
449
|
+
|
|
450
|
+
function UpdatesCard({ folder, onFolder }: { folder: string; onFolder: (folder: string) => void }) {
|
|
451
|
+
const [busy, setBusy] = useState(false);
|
|
452
|
+
const [error, setError] = useState<ApiError | null>(null);
|
|
453
|
+
const [restarting, setRestarting] = useState<string | null>(null);
|
|
454
|
+
const state = useAsync(() => api.update(), [folder], 60_000);
|
|
455
|
+
|
|
456
|
+
const run = async (fn: () => Promise<unknown>) => {
|
|
457
|
+
setBusy(true);
|
|
458
|
+
setError(null);
|
|
459
|
+
try {
|
|
460
|
+
await fn();
|
|
461
|
+
} catch (err) {
|
|
462
|
+
setError(errorOf(err));
|
|
463
|
+
} finally {
|
|
464
|
+
setBusy(false);
|
|
465
|
+
}
|
|
466
|
+
};
|
|
467
|
+
|
|
468
|
+
const u = state.data;
|
|
469
|
+
const status = !u
|
|
470
|
+
? null
|
|
471
|
+
: u.code
|
|
472
|
+
? errorMessage({ kind: 'internal', code: u.code, message: u.message ?? '' })
|
|
473
|
+
: u.status === 'not-configured'
|
|
474
|
+
? t('settings.update.notConfigured')
|
|
475
|
+
: u.available
|
|
476
|
+
? t('settings.update.available', { version: u.version ?? '' })
|
|
477
|
+
: u.status === 'offline'
|
|
478
|
+
? t('settings.update.offline')
|
|
479
|
+
: u.status === 'error'
|
|
480
|
+
? (u.message ?? '')
|
|
481
|
+
: t('settings.update.latest');
|
|
482
|
+
|
|
483
|
+
return (
|
|
484
|
+
<section className="section">
|
|
485
|
+
<div className="card form">
|
|
486
|
+
<div className="field">
|
|
487
|
+
<label className="mono-label" htmlFor="update-folder">{t('settings.updateFolder')}</label>
|
|
488
|
+
<div className="row">
|
|
489
|
+
<input id="update-folder" value={folder} placeholder={t('settings.updateFolder.none')} readOnly />
|
|
490
|
+
<button
|
|
491
|
+
className="btn btn-sm"
|
|
492
|
+
disabled={busy}
|
|
493
|
+
onClick={() => run(async () => {
|
|
494
|
+
const next = (await api.pickUpdateFolder(t('settings.updateFolder.pick'))).updateFolder;
|
|
495
|
+
if (next !== folder) onFolder(next);
|
|
496
|
+
})}
|
|
497
|
+
>
|
|
498
|
+
{t('settings.updateFolder.browse')}
|
|
499
|
+
</button>
|
|
500
|
+
{folder && (
|
|
501
|
+
<button className="btn btn-sm" disabled={busy} onClick={() => onFolder('')}>
|
|
502
|
+
{t('settings.updateFolder.clear')}
|
|
503
|
+
</button>
|
|
504
|
+
)}
|
|
505
|
+
</div>
|
|
506
|
+
<span className="muted">{t('settings.updateFolder.hint')}</span>
|
|
507
|
+
</div>
|
|
508
|
+
{!u && !state.error && <Spinner small />}
|
|
509
|
+
{state.error && <ErrorBanner error={state.error} onRetry={state.reload} compact />}
|
|
510
|
+
{u && (
|
|
511
|
+
<div className="field">
|
|
512
|
+
<span>
|
|
513
|
+
{t('settings.update.current', { version: u.current })} {status}
|
|
514
|
+
</span>
|
|
515
|
+
{u.source && <span className="muted">{t('settings.update.source', { source: sourceLabel(u.source) })}</span>}
|
|
516
|
+
{u.available && u.notes && <pre className="mono muted">{u.notes}</pre>}
|
|
517
|
+
{u.available && !u.canApply && <span className="muted">{t('settings.update.fromSource')}</span>}
|
|
518
|
+
<div className="row">
|
|
519
|
+
<button className="btn btn-sm" disabled={busy || !u.source} onClick={() => run(() => api.checkUpdate().then(() => state.reload()))}>
|
|
520
|
+
{t('settings.update.check')}
|
|
521
|
+
</button>
|
|
522
|
+
{u.available && u.canApply && (
|
|
523
|
+
<button
|
|
524
|
+
className="btn btn-sm btn-primary"
|
|
525
|
+
disabled={busy || restarting !== null}
|
|
526
|
+
onClick={() => run(async () => setRestarting((await api.applyUpdate()).version))}
|
|
527
|
+
>
|
|
528
|
+
{t('settings.update.apply')}
|
|
529
|
+
</button>
|
|
530
|
+
)}
|
|
531
|
+
</div>
|
|
532
|
+
</div>
|
|
533
|
+
)}
|
|
534
|
+
{restarting && <span className="muted">{t('settings.update.restarting', { version: restarting })}</span>}
|
|
535
|
+
{error && <ErrorBanner error={error} compact />}
|
|
536
|
+
</div>
|
|
537
|
+
</section>
|
|
538
|
+
);
|
|
539
|
+
}
|
|
540
|
+
|
|
541
|
+
const sourceLabel = (id: string): string => (id.startsWith('github:') ? `GitHub ${id.slice(7)}` : `${t('settings.update.folder')} ${id.slice(7)}`);
|
|
@@ -0,0 +1,33 @@
|
|
|
1
|
+
import { createSettingsRegistry, DEFAULT_SECTIONS, sectionKey } from '@deckbuilderkit/ui';
|
|
2
|
+
import type { Settings } from './api';
|
|
3
|
+
|
|
4
|
+
/** The built-in sections this app has screens for; add more (e.g. `editor`, `terminal`) as the app grows. */
|
|
5
|
+
const SECTIONS = ['general', 'appearance', 'window', 'keyboard', 'keyboard.shortcuts', 'system', 'system.updates'];
|
|
6
|
+
|
|
7
|
+
export const settingsRegistry = createSettingsRegistry<Settings>();
|
|
8
|
+
for (const s of DEFAULT_SECTIONS) if (SECTIONS.includes(sectionKey(s.path))) settingsRegistry.registerSection(s);
|
|
9
|
+
|
|
10
|
+
settingsRegistry.registerSettings([
|
|
11
|
+
{
|
|
12
|
+
id: 'general.autostart',
|
|
13
|
+
kind: 'boolean',
|
|
14
|
+
section: ['general'],
|
|
15
|
+
order: 10,
|
|
16
|
+
label: 'settings.autostart',
|
|
17
|
+
description: 'settings.autostart.hint',
|
|
18
|
+
default: false,
|
|
19
|
+
get: (s) => s.autostart,
|
|
20
|
+
set: (_, autostart) => ({ autostart }),
|
|
21
|
+
},
|
|
22
|
+
{
|
|
23
|
+
id: 'general.closeToTray',
|
|
24
|
+
kind: 'boolean',
|
|
25
|
+
section: ['general'],
|
|
26
|
+
order: 20,
|
|
27
|
+
label: 'settings.closeToTray',
|
|
28
|
+
description: 'settings.closeToTray.hint',
|
|
29
|
+
default: false,
|
|
30
|
+
get: (s) => s.closeToTray,
|
|
31
|
+
set: (_, closeToTray) => ({ closeToTray }),
|
|
32
|
+
},
|
|
33
|
+
]);
|
|
@@ -0,0 +1,10 @@
|
|
|
1
|
+
# @deckbuilderkit/view-__VIEW_ID__
|
|
2
|
+
|
|
3
|
+
__VIEW_TITLE__ view for DeckBuilder.
|
|
4
|
+
|
|
5
|
+
- `src/view.tsx`: web entry (`__VIEW_CAMEL__View`, built with `defineView`).
|
|
6
|
+
- `src/server.ts`: server entry (`__VIEW_CAMEL__ServerView`, built with `defineServerView`).
|
|
7
|
+
- `src/i18n.ts`: messages (en, pt-BR) and the view's typed `t`.
|
|
8
|
+
- `src/styles.css`: styles; import `@deckbuilderkit/view-__VIEW_ID__/styles.css` in the app.
|
|
9
|
+
|
|
10
|
+
Settings cards: add `settingsCards` (see `ViewSettingsCards` in `@deckbuilderkit/ui`) to the view definition and put the components in `src/settings/cards.tsx`.
|
|
@@ -0,0 +1,25 @@
|
|
|
1
|
+
{
|
|
2
|
+
"name": "@deckbuilderkit/view-__VIEW_ID__",
|
|
3
|
+
"version": "0.0.0",
|
|
4
|
+
"private": true,
|
|
5
|
+
"type": "module",
|
|
6
|
+
"exports": {
|
|
7
|
+
"./web": "./src/web.ts",
|
|
8
|
+
"./server": "./src/server.ts",
|
|
9
|
+
"./styles.css": "./src/styles.css"
|
|
10
|
+
},
|
|
11
|
+
"dependencies": {
|
|
12
|
+
"@deckbuilderkit/server-core": "workspace:*",
|
|
13
|
+
"@deckbuilderkit/ui": "workspace:*"
|
|
14
|
+
},
|
|
15
|
+
"peerDependencies": {
|
|
16
|
+
"react": "^18.3.1",
|
|
17
|
+
"react-dom": "^18.3.1"
|
|
18
|
+
},
|
|
19
|
+
"devDependencies": {
|
|
20
|
+
"@types/react": "^18.3.12",
|
|
21
|
+
"react": "^18.3.1",
|
|
22
|
+
"react-dom": "^18.3.1",
|
|
23
|
+
"@types/react-dom": "^18.3.1"
|
|
24
|
+
}
|
|
25
|
+
}
|
|
@@ -0,0 +1,14 @@
|
|
|
1
|
+
import { createT, type Messages } from '@deckbuilderkit/ui';
|
|
2
|
+
|
|
3
|
+
export const en = {
|
|
4
|
+
'nav.__VIEW_ID__': '__VIEW_TITLE__',
|
|
5
|
+
'__VIEW_CAMEL__.heading': '__VIEW_TITLE__',
|
|
6
|
+
};
|
|
7
|
+
|
|
8
|
+
export const ptBR: Messages<typeof en> = {
|
|
9
|
+
'nav.__VIEW_ID__': '__VIEW_TITLE__',
|
|
10
|
+
'__VIEW_CAMEL__.heading': '__VIEW_TITLE__',
|
|
11
|
+
};
|
|
12
|
+
|
|
13
|
+
export type __VIEW_NAME__MessageKey = keyof typeof en;
|
|
14
|
+
export const t = createT<__VIEW_NAME__MessageKey>();
|
|
@@ -0,0 +1,9 @@
|
|
|
1
|
+
import { defineServerView } from '@deckbuilderkit/server-core';
|
|
2
|
+
|
|
3
|
+
/** Server half of the __VIEW_TITLE__ view: add routes, sockets and settings validators here. */
|
|
4
|
+
export function __VIEW_CAMEL__ServerView() {
|
|
5
|
+
return defineServerView({
|
|
6
|
+
id: '__VIEW_ID__',
|
|
7
|
+
routes: () => ({}),
|
|
8
|
+
});
|
|
9
|
+
}
|
|
@@ -0,0 +1 @@
|
|
|
1
|
+
.view-__VIEW_ID__ { display: flex; flex-direction: column; flex: 1; min-width: 0; min-height: 0; padding: 16px; }
|
|
@@ -0,0 +1,22 @@
|
|
|
1
|
+
import type { ReactNode } from 'react';
|
|
2
|
+
import { defineView } from '@deckbuilderkit/ui';
|
|
3
|
+
import { en, ptBR, t } from './i18n';
|
|
4
|
+
|
|
5
|
+
export interface __VIEW_NAME__ViewOptions {
|
|
6
|
+
/** i18n key of the nav section title. */
|
|
7
|
+
group: string;
|
|
8
|
+
order: number;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
export function __VIEW_CAMEL__View({ group, order }: __VIEW_NAME__ViewOptions) {
|
|
12
|
+
return defineView<'__VIEW_ID__', Record<string, never>, 'nav.__VIEW_ID__'>({
|
|
13
|
+
id: '__VIEW_ID__',
|
|
14
|
+
nav: { group, icon: 'home', label: 'nav.__VIEW_ID__', order },
|
|
15
|
+
render: (): ReactNode => (
|
|
16
|
+
<div className="view-__VIEW_ID__">
|
|
17
|
+
<h1>{t('__VIEW_CAMEL__.heading')}</h1>
|
|
18
|
+
</div>
|
|
19
|
+
),
|
|
20
|
+
messages: { en, 'pt-BR': ptBR },
|
|
21
|
+
});
|
|
22
|
+
}
|