token-larper 1.0.0 → 1.6.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.
- package/README.md +37 -10
- package/bin/cli.js +3 -1
- package/llms.txt +2 -2
- package/package.json +6 -4
- package/scripts/run-server.ps1 +6 -1
- package/scripts/tray-host.ps1 +158 -9
- package/src/ccusage.ts +40 -11
- package/src/client/App.tsx +53 -22
- package/src/client/components/Logo.tsx +19 -0
- package/src/client/components/SettingsDialog.tsx +21 -9
- package/src/client/components/ThemeSettings.tsx +116 -41
- package/src/client/components/UpdateBanner.tsx +42 -0
- package/src/client/components/UpdateSettings.tsx +65 -0
- package/src/client/index.html +19 -28
- package/src/client/lib/shareCard.ts +2 -6
- package/src/client/lib/trayTheme.ts +71 -0
- package/src/client/lib/updates.ts +94 -0
- package/src/client/logoMark.ts +103 -0
- package/src/client/main.tsx +3 -0
- package/src/client/styles.css +337 -77
- package/src/client/themes.ts +171 -34
- package/src/paths.ts +48 -0
- package/src/projects.ts +3 -2
- package/src/semver.ts +9 -0
- package/src/server.ts +118 -28
- package/src/startup.ts +36 -4
- package/src/tray.ts +226 -48
- package/src/trayStatus.ts +91 -0
- package/src/types.ts +10 -0
- package/src/updates.ts +62 -0
- package/src/client/logo.svg +0 -8
package/src/client/App.tsx
CHANGED
|
@@ -1,12 +1,15 @@
|
|
|
1
1
|
import React, { useCallback, useEffect, useMemo, useRef, useState } from "react";
|
|
2
|
-
import { AlertTriangle, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
|
|
2
|
+
import { AlertTriangle, Heart, Moon, Power, RefreshCw, Search, Settings, Sun } from "lucide-react";
|
|
3
3
|
import type { DashboardPayload, HarnessId, StartupConfig } from "../types.ts";
|
|
4
4
|
import { OTHER_SERIES, SERIES_SLOTS, seriesColor, type SeriesInfo } from "./charts.tsx";
|
|
5
5
|
import { DashboardProvider, type Dashboard } from "./context.tsx";
|
|
6
6
|
import { DateRangePicker } from "./components/DateRangePicker.tsx";
|
|
7
|
+
import { Logo } from "./components/Logo.tsx";
|
|
7
8
|
import { RankChip } from "./components/RankChip.tsx";
|
|
8
9
|
import { SettingsDialog } from "./components/SettingsDialog.tsx";
|
|
9
10
|
import { SelectMenu, type SelectMenuOption } from "./components/SelectMenu.tsx";
|
|
11
|
+
import { UpdateBanner } from "./components/UpdateBanner.tsx";
|
|
12
|
+
import { useUpdates } from "./lib/updates.ts";
|
|
10
13
|
import { daysInRange, sessionsInRange, type Bucket, type HarnessFilter } from "./lib/aggregate.ts";
|
|
11
14
|
import { RANGE_PRESETS, parseDay, presetRange, todayKey, type DateRange, type RangePreset } from "./lib/range.ts";
|
|
12
15
|
import { Models } from "./views/Models.tsx";
|
|
@@ -18,13 +21,13 @@ import { Tools } from "./views/Tools.tsx";
|
|
|
18
21
|
import {
|
|
19
22
|
applyTheme,
|
|
20
23
|
IMPORTED_THEME_STORAGE_KEY,
|
|
24
|
+
readAppearance,
|
|
21
25
|
readImportedTheme,
|
|
22
|
-
|
|
26
|
+
saveAppearance,
|
|
23
27
|
THEME_MODE_STORAGE_KEY,
|
|
24
|
-
|
|
28
|
+
type Appearance,
|
|
25
29
|
type ImportedTheme,
|
|
26
30
|
type ThemeMode,
|
|
27
|
-
type ThemePalette,
|
|
28
31
|
} from "./themes.ts";
|
|
29
32
|
import { localDateKey } from "./utils.ts";
|
|
30
33
|
|
|
@@ -50,10 +53,13 @@ interface Prefs {
|
|
|
50
53
|
customEnd?: string;
|
|
51
54
|
bucket: Bucket;
|
|
52
55
|
estimated: boolean;
|
|
56
|
+
checkUpdates: boolean;
|
|
57
|
+
/** The version whose banner was closed; a later version shows it again. */
|
|
58
|
+
dismissedUpdate?: string;
|
|
53
59
|
}
|
|
54
60
|
|
|
55
61
|
function loadPrefs(): Prefs {
|
|
56
|
-
const defaults: Prefs = { view: "overview", showRanks: true, preset: "30d", bucket: "daily", estimated: false };
|
|
62
|
+
const defaults: Prefs = { view: "overview", showRanks: true, preset: "30d", bucket: "daily", estimated: false, checkUpdates: true };
|
|
57
63
|
try {
|
|
58
64
|
const raw = JSON.parse(localStorage.getItem(PREFS_KEY) || "{}");
|
|
59
65
|
const saved: Partial<Prefs> = raw && typeof raw === "object" ? raw : {};
|
|
@@ -73,6 +79,8 @@ function loadPrefs(): Prefs {
|
|
|
73
79
|
bucket,
|
|
74
80
|
preset: preset === "custom" && !customDatesValid ? defaults.preset : preset,
|
|
75
81
|
estimated: saved.estimated === true,
|
|
82
|
+
checkUpdates: saved.checkUpdates !== false,
|
|
83
|
+
...(typeof saved.dismissedUpdate === "string" ? { dismissedUpdate: saved.dismissedUpdate } : {}),
|
|
76
84
|
...(customDatesValid ? { customStart: saved.customStart, customEnd: saved.customEnd } : {}),
|
|
77
85
|
};
|
|
78
86
|
} catch {
|
|
@@ -121,10 +129,13 @@ export function App() {
|
|
|
121
129
|
return true;
|
|
122
130
|
}
|
|
123
131
|
});
|
|
124
|
-
const [
|
|
132
|
+
const [appearance, setAppearance] = useState<Appearance>(readAppearance);
|
|
125
133
|
const [importedTheme, setImportedTheme] = useState<ImportedTheme | null>(readImportedTheme);
|
|
126
134
|
|
|
127
135
|
const updatePrefs = (patch: Partial<Prefs>) => setPrefs((p) => ({ ...p, ...patch }));
|
|
136
|
+
const updates = useUpdates(prefs.checkUpdates);
|
|
137
|
+
const showUpdateBanner = updates.phase !== "idle" || (prefs.checkUpdates && !!updates.status?.updateAvailable
|
|
138
|
+
&& updates.status.latest !== prefs.dismissedUpdate);
|
|
128
139
|
|
|
129
140
|
function setShowRanks(showRanks: boolean) {
|
|
130
141
|
setPrefs((p) => ({ ...p, showRanks, view: !showRanks && p.view === "rank" ? "overview" : p.view }));
|
|
@@ -151,8 +162,8 @@ export function App() {
|
|
|
151
162
|
}, [prefs.showRanks]);
|
|
152
163
|
|
|
153
164
|
useEffect(() => {
|
|
154
|
-
applyTheme(theme,
|
|
155
|
-
}, [theme,
|
|
165
|
+
applyTheme(theme, appearance, importedTheme);
|
|
166
|
+
}, [theme, appearance, importedTheme]);
|
|
156
167
|
|
|
157
168
|
useEffect(() => {
|
|
158
169
|
if (!followsSystemTheme) return;
|
|
@@ -170,25 +181,28 @@ export function App() {
|
|
|
170
181
|
try { localStorage.setItem(THEME_MODE_STORAGE_KEY, next); } catch { /* Storage may be disabled. */ }
|
|
171
182
|
}
|
|
172
183
|
|
|
173
|
-
function changeThemeMode(mode: ThemeMode) {
|
|
184
|
+
function changeThemeMode(mode: ThemeMode | "system") {
|
|
185
|
+
if (mode === "system") {
|
|
186
|
+
setFollowsSystemTheme(true);
|
|
187
|
+
setTheme(window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light");
|
|
188
|
+
try { localStorage.removeItem(THEME_MODE_STORAGE_KEY); } catch { /* Storage may be disabled. */ }
|
|
189
|
+
return;
|
|
190
|
+
}
|
|
174
191
|
setFollowsSystemTheme(false);
|
|
175
192
|
setTheme(mode);
|
|
176
193
|
try { localStorage.setItem(THEME_MODE_STORAGE_KEY, mode); } catch { /* Storage may be disabled. */ }
|
|
177
194
|
}
|
|
178
195
|
|
|
179
|
-
function
|
|
180
|
-
|
|
181
|
-
|
|
182
|
-
|
|
196
|
+
function changeAppearance(next: Appearance) {
|
|
197
|
+
const safe = next.imported && !importedTheme ? { ...next, imported: false } : next;
|
|
198
|
+
setAppearance(safe);
|
|
199
|
+
saveAppearance(safe);
|
|
183
200
|
}
|
|
184
201
|
|
|
185
202
|
function saveImportedTheme(next: ImportedTheme) {
|
|
186
203
|
setImportedTheme(next);
|
|
187
|
-
|
|
188
|
-
try {
|
|
189
|
-
localStorage.setItem(IMPORTED_THEME_STORAGE_KEY, JSON.stringify(next));
|
|
190
|
-
localStorage.setItem(THEME_PALETTE_STORAGE_KEY, "custom");
|
|
191
|
-
} catch { /* Storage may be disabled. */ }
|
|
204
|
+
changeAppearance({ ...appearance, imported: true });
|
|
205
|
+
try { localStorage.setItem(IMPORTED_THEME_STORAGE_KEY, JSON.stringify(next)); } catch { /* Storage may be disabled. */ }
|
|
192
206
|
}
|
|
193
207
|
|
|
194
208
|
async function fetchDashboard(opts?: { refresh?: boolean; forceDeepScan?: boolean }) {
|
|
@@ -369,7 +383,7 @@ export function App() {
|
|
|
369
383
|
return (
|
|
370
384
|
<div className="splash">
|
|
371
385
|
<div className="splash-card">
|
|
372
|
-
<
|
|
386
|
+
<Logo className="splash-logo" />
|
|
373
387
|
<h2>Summoning Token Larper…</h2>
|
|
374
388
|
<p>Reading local usage from your coding tools with ccusage.</p>
|
|
375
389
|
</div>
|
|
@@ -427,7 +441,7 @@ export function App() {
|
|
|
427
441
|
<div className="app">
|
|
428
442
|
<header className="topbar">
|
|
429
443
|
<div className="brand">
|
|
430
|
-
<
|
|
444
|
+
<Logo className="brand-logo" />
|
|
431
445
|
<h1>Token Larper</h1>
|
|
432
446
|
{data.syncingHarnesses.length > 0 && (
|
|
433
447
|
<span className="pill">
|
|
@@ -473,6 +487,10 @@ export function App() {
|
|
|
473
487
|
</div>
|
|
474
488
|
</header>
|
|
475
489
|
|
|
490
|
+
{showUpdateBanner && (
|
|
491
|
+
<UpdateBanner updates={updates} onDismiss={() => updatePrefs({ dismissedUpdate: updates.status?.latest ?? undefined })} />
|
|
492
|
+
)}
|
|
493
|
+
|
|
476
494
|
<nav className="tabs" aria-label="Dashboard views">
|
|
477
495
|
{VIEWS.filter((v) => v.id !== "rank" || prefs.showRanks).map((v) => (
|
|
478
496
|
<button
|
|
@@ -535,18 +553,31 @@ export function App() {
|
|
|
535
553
|
{view.id === "rank" && <Rank />}
|
|
536
554
|
</main>
|
|
537
555
|
|
|
556
|
+
<footer className="credit">
|
|
557
|
+
<span>
|
|
558
|
+
Made with <Heart size={12} aria-label="love" className="credit-heart" /> by{" "}
|
|
559
|
+
<a href="https://github.com/YarooqH" target="_blank" rel="noreferrer">qray</a>
|
|
560
|
+
</span>
|
|
561
|
+
<span aria-hidden="true">·</span>
|
|
562
|
+
<a href="https://github.com/YarooqH/token-larper" target="_blank" rel="noreferrer">Source on GitHub</a>
|
|
563
|
+
</footer>
|
|
564
|
+
|
|
538
565
|
{showSettings && (
|
|
539
566
|
<SettingsDialog
|
|
540
567
|
startup={startup}
|
|
541
568
|
startupError={startupError}
|
|
542
569
|
saving={savingStartup}
|
|
543
570
|
themeMode={theme}
|
|
544
|
-
|
|
571
|
+
followsSystemTheme={followsSystemTheme}
|
|
572
|
+
appearance={appearance}
|
|
545
573
|
importedTheme={importedTheme}
|
|
546
574
|
showRanks={prefs.showRanks}
|
|
575
|
+
updates={updates}
|
|
576
|
+
checkUpdates={prefs.checkUpdates}
|
|
577
|
+
onCheckUpdatesChange={(checkUpdates) => updatePrefs({ checkUpdates })}
|
|
547
578
|
onClose={closeSettings}
|
|
548
579
|
onThemeModeChange={changeThemeMode}
|
|
549
|
-
|
|
580
|
+
onAppearanceChange={changeAppearance}
|
|
550
581
|
onImportTheme={saveImportedTheme}
|
|
551
582
|
onShowRanksChange={setShowRanks}
|
|
552
583
|
onToggleStartup={(enabled, open) => void handleToggleStartup(enabled, open)}
|
|
@@ -0,0 +1,19 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { LOGO_PATHS, LOGO_VIEWBOX } from "../logoMark.ts";
|
|
3
|
+
|
|
4
|
+
/** The logo in the current text color, so it follows the dashboard theme. */
|
|
5
|
+
export function Logo({ className, label }: { className?: string; label?: string }) {
|
|
6
|
+
return (
|
|
7
|
+
<svg
|
|
8
|
+
className={className}
|
|
9
|
+
viewBox={LOGO_VIEWBOX}
|
|
10
|
+
fill="currentColor"
|
|
11
|
+
role={label ? "img" : undefined}
|
|
12
|
+
aria-label={label}
|
|
13
|
+
aria-hidden={label ? undefined : true}
|
|
14
|
+
>
|
|
15
|
+
<path d={LOGO_PATHS.t} />
|
|
16
|
+
<path d={LOGO_PATHS.flame} fillRule="evenodd" />
|
|
17
|
+
</svg>
|
|
18
|
+
);
|
|
19
|
+
}
|
|
@@ -2,19 +2,25 @@ import React, { useEffect, useRef } from "react";
|
|
|
2
2
|
import { ChevronDown, Power, X } from "lucide-react";
|
|
3
3
|
import type { StartupConfig } from "../../types.ts";
|
|
4
4
|
import { ThemeSettings } from "./ThemeSettings.tsx";
|
|
5
|
-
import
|
|
5
|
+
import { UpdateSettings } from "./UpdateSettings.tsx";
|
|
6
|
+
import type { Updates } from "../lib/updates.ts";
|
|
7
|
+
import type { Appearance, ImportedTheme, ThemeMode } from "../themes.ts";
|
|
6
8
|
|
|
7
9
|
interface Props {
|
|
8
10
|
startup: StartupConfig | null;
|
|
9
11
|
startupError: string | null;
|
|
10
12
|
saving: boolean;
|
|
11
13
|
themeMode: ThemeMode;
|
|
12
|
-
|
|
14
|
+
followsSystemTheme: boolean;
|
|
15
|
+
appearance: Appearance;
|
|
13
16
|
importedTheme: ImportedTheme | null;
|
|
14
17
|
showRanks: boolean;
|
|
18
|
+
updates: Updates;
|
|
19
|
+
checkUpdates: boolean;
|
|
20
|
+
onCheckUpdatesChange: (checkUpdates: boolean) => void;
|
|
15
21
|
onClose: () => void;
|
|
16
|
-
onThemeModeChange: (mode: ThemeMode) => void;
|
|
17
|
-
|
|
22
|
+
onThemeModeChange: (mode: ThemeMode | "system") => void;
|
|
23
|
+
onAppearanceChange: (appearance: Appearance) => void;
|
|
18
24
|
onImportTheme: (theme: ImportedTheme) => void;
|
|
19
25
|
onShowRanksChange: (showRanks: boolean) => void;
|
|
20
26
|
onToggleStartup: (enabled: boolean, openBrowserOnBoot?: boolean) => void;
|
|
@@ -27,12 +33,16 @@ export function SettingsDialog({
|
|
|
27
33
|
startupError,
|
|
28
34
|
saving,
|
|
29
35
|
themeMode,
|
|
30
|
-
|
|
36
|
+
followsSystemTheme,
|
|
37
|
+
appearance,
|
|
31
38
|
importedTheme,
|
|
32
39
|
showRanks,
|
|
40
|
+
updates,
|
|
41
|
+
checkUpdates,
|
|
42
|
+
onCheckUpdatesChange,
|
|
33
43
|
onClose,
|
|
34
44
|
onThemeModeChange,
|
|
35
|
-
|
|
45
|
+
onAppearanceChange,
|
|
36
46
|
onImportTheme,
|
|
37
47
|
onShowRanksChange,
|
|
38
48
|
onToggleStartup,
|
|
@@ -84,7 +94,7 @@ export function SettingsDialog({
|
|
|
84
94
|
<header className="modal-header">
|
|
85
95
|
<div>
|
|
86
96
|
<h3 id="settings-title">Settings</h3>
|
|
87
|
-
<p>Appearance, dashboard, and startup</p>
|
|
97
|
+
<p>Appearance, dashboard, updates, and startup</p>
|
|
88
98
|
</div>
|
|
89
99
|
<button ref={closeRef} className="icon-btn" aria-label="Close settings" onClick={onClose}>
|
|
90
100
|
<X size={17} />
|
|
@@ -95,10 +105,11 @@ export function SettingsDialog({
|
|
|
95
105
|
{startupError && <div className="inline-error" role="alert">{startupError}</div>}
|
|
96
106
|
<ThemeSettings
|
|
97
107
|
mode={themeMode}
|
|
98
|
-
|
|
108
|
+
followsSystem={followsSystemTheme}
|
|
109
|
+
appearance={appearance}
|
|
99
110
|
importedTheme={importedTheme}
|
|
100
111
|
onModeChange={onThemeModeChange}
|
|
101
|
-
|
|
112
|
+
onAppearanceChange={onAppearanceChange}
|
|
102
113
|
onImportTheme={onImportTheme}
|
|
103
114
|
/>
|
|
104
115
|
<section className="settings-section" aria-labelledby="settings-dashboard-heading">
|
|
@@ -122,6 +133,7 @@ export function SettingsDialog({
|
|
|
122
133
|
</button>
|
|
123
134
|
</div>
|
|
124
135
|
</section>
|
|
136
|
+
<UpdateSettings updates={updates} autoCheck={checkUpdates} onAutoCheckChange={onCheckUpdatesChange} />
|
|
125
137
|
{startup ? (
|
|
126
138
|
<>
|
|
127
139
|
<section className="settings-section" aria-labelledby="settings-startup-heading">
|
|
@@ -1,14 +1,21 @@
|
|
|
1
1
|
import React, { useState } from "react";
|
|
2
|
-
import { ChevronDown } from "lucide-react";
|
|
3
|
-
import {
|
|
4
|
-
|
|
2
|
+
import { Check, ChevronDown, Pipette } from "lucide-react";
|
|
3
|
+
import {
|
|
4
|
+
ACCENT_PRESETS,
|
|
5
|
+
importThemeCss,
|
|
6
|
+
STYLE_PRESETS,
|
|
7
|
+
type Appearance,
|
|
8
|
+
type ImportedTheme,
|
|
9
|
+
type ThemeMode,
|
|
10
|
+
} from "../themes.ts";
|
|
5
11
|
|
|
6
12
|
interface Props {
|
|
7
13
|
mode: ThemeMode;
|
|
8
|
-
|
|
14
|
+
followsSystem: boolean;
|
|
15
|
+
appearance: Appearance;
|
|
9
16
|
importedTheme: ImportedTheme | null;
|
|
10
|
-
onModeChange: (mode: ThemeMode) => void;
|
|
11
|
-
|
|
17
|
+
onModeChange: (mode: ThemeMode | "system") => void;
|
|
18
|
+
onAppearanceChange: (appearance: Appearance) => void;
|
|
12
19
|
onImportTheme: (theme: ImportedTheme) => void;
|
|
13
20
|
}
|
|
14
21
|
|
|
@@ -28,65 +35,132 @@ const SAMPLE_THEME = `:root {
|
|
|
28
35
|
--accent-ink: #11261a;
|
|
29
36
|
}`;
|
|
30
37
|
|
|
31
|
-
export function ThemeSettings({ mode,
|
|
38
|
+
export function ThemeSettings({ mode, followsSystem, appearance, importedTheme, onModeChange, onAppearanceChange, onImportTheme }: Props) {
|
|
32
39
|
const [css, setCss] = useState("");
|
|
33
40
|
const [error, setError] = useState<string | null>(null);
|
|
34
41
|
const [message, setMessage] = useState<string | null>(null);
|
|
42
|
+
const accent = appearance.accent;
|
|
43
|
+
const customColor = accent.kind === "custom" ? accent.color : "#6d5bd0";
|
|
35
44
|
|
|
36
45
|
function applyPastedTheme() {
|
|
37
46
|
try {
|
|
38
|
-
|
|
39
|
-
onImportTheme(imported);
|
|
47
|
+
onImportTheme(importThemeCss(css));
|
|
40
48
|
setError(null);
|
|
41
|
-
setMessage("Theme applied.");
|
|
49
|
+
setMessage("Theme applied. Its colors now sit on top of the selected style.");
|
|
42
50
|
} catch (cause) {
|
|
43
51
|
setMessage(null);
|
|
44
52
|
setError(cause instanceof Error ? cause.message : "Could not read this theme CSS.");
|
|
45
53
|
}
|
|
46
54
|
}
|
|
47
55
|
|
|
48
|
-
const
|
|
49
|
-
value: preset.id,
|
|
50
|
-
label: preset.name,
|
|
51
|
-
description: preset.description,
|
|
52
|
-
visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: mode === "dark" ? preset.darkAccent : preset.accent }} />,
|
|
53
|
-
}));
|
|
54
|
-
if (importedTheme) {
|
|
55
|
-
const colors = importedTheme[mode];
|
|
56
|
-
paletteOptions.push({
|
|
57
|
-
value: "custom",
|
|
58
|
-
label: "Imported theme",
|
|
59
|
-
description: "Your saved CSS colors",
|
|
60
|
-
visual: <span className="palette-color" aria-hidden="true" style={{ backgroundColor: colors["--accent"] }} />,
|
|
61
|
-
});
|
|
62
|
-
}
|
|
56
|
+
const modeValue = followsSystem ? "system" : mode;
|
|
63
57
|
|
|
64
58
|
return (
|
|
65
59
|
<section className="settings-section theme-settings" aria-labelledby="settings-appearance-heading">
|
|
66
60
|
<div className="settings-section-heading">
|
|
67
61
|
<h4 id="settings-appearance-heading">Appearance</h4>
|
|
68
|
-
<span>{mode === "dark" ? "Dark mode" : "Light mode"}</span>
|
|
69
62
|
</div>
|
|
70
63
|
|
|
71
|
-
<div className="theme-
|
|
72
|
-
<span
|
|
73
|
-
<div className="segmented" role="group" aria-
|
|
74
|
-
{(["light", "dark"] as const).map((option) => (
|
|
75
|
-
<button key={option} type="button" aria-pressed={
|
|
76
|
-
{option === "light" ? "Light" : "Dark"}
|
|
64
|
+
<div className="theme-row">
|
|
65
|
+
<span className="theme-row-label" id="theme-mode-label">Mode</span>
|
|
66
|
+
<div className="segmented" role="group" aria-labelledby="theme-mode-label">
|
|
67
|
+
{(["system", "light", "dark"] as const).map((option) => (
|
|
68
|
+
<button key={option} type="button" aria-pressed={modeValue === option} onClick={() => onModeChange(option)}>
|
|
69
|
+
{option === "system" ? "System" : option === "light" ? "Light" : "Dark"}
|
|
77
70
|
</button>
|
|
78
71
|
))}
|
|
79
72
|
</div>
|
|
80
73
|
</div>
|
|
81
74
|
|
|
82
|
-
<
|
|
83
|
-
|
|
84
|
-
|
|
85
|
-
|
|
86
|
-
|
|
87
|
-
|
|
88
|
-
|
|
89
|
-
|
|
75
|
+
<div className="theme-block">
|
|
76
|
+
<span className="theme-row-label" id="theme-style-label">Style</span>
|
|
77
|
+
<div className="style-grid" role="radiogroup" aria-labelledby="theme-style-label">
|
|
78
|
+
{STYLE_PRESETS.map((style) => {
|
|
79
|
+
const selected = appearance.style === style.id;
|
|
80
|
+
return (
|
|
81
|
+
<button
|
|
82
|
+
key={style.id}
|
|
83
|
+
type="button"
|
|
84
|
+
role="radio"
|
|
85
|
+
aria-checked={selected}
|
|
86
|
+
className="style-card"
|
|
87
|
+
onClick={() => onAppearanceChange({ ...appearance, style: style.id })}
|
|
88
|
+
>
|
|
89
|
+
{/* The preview renders with the style's own tokens, so it shows fonts, radius, borders and colors. */}
|
|
90
|
+
<span className="style-preview" data-style={style.id} aria-hidden="true">
|
|
91
|
+
<span className="sp-panel">
|
|
92
|
+
<span className="sp-title">Aa</span>
|
|
93
|
+
<span className="sp-bars">
|
|
94
|
+
<i style={{ height: "45%" }} />
|
|
95
|
+
<i style={{ height: "80%" }} />
|
|
96
|
+
<i style={{ height: "60%" }} />
|
|
97
|
+
</span>
|
|
98
|
+
<span className="sp-button" />
|
|
99
|
+
</span>
|
|
100
|
+
</span>
|
|
101
|
+
<span className="style-card-text">
|
|
102
|
+
<strong>{style.name}</strong>
|
|
103
|
+
<span>{style.description}</span>
|
|
104
|
+
</span>
|
|
105
|
+
{selected && <Check size={14} className="style-card-check" aria-hidden="true" />}
|
|
106
|
+
</button>
|
|
107
|
+
);
|
|
108
|
+
})}
|
|
109
|
+
</div>
|
|
110
|
+
</div>
|
|
111
|
+
|
|
112
|
+
<div className="theme-block">
|
|
113
|
+
<span className="theme-row-label" id="theme-accent-label">Accent</span>
|
|
114
|
+
<div className="accent-row" role="radiogroup" aria-labelledby="theme-accent-label">
|
|
115
|
+
<button
|
|
116
|
+
type="button"
|
|
117
|
+
role="radio"
|
|
118
|
+
aria-checked={accent.kind === "style"}
|
|
119
|
+
className="accent-default"
|
|
120
|
+
onClick={() => onAppearanceChange({ ...appearance, accent: { kind: "style" } })}
|
|
121
|
+
>
|
|
122
|
+
Style default
|
|
123
|
+
</button>
|
|
124
|
+
{ACCENT_PRESETS.map((preset) => (
|
|
125
|
+
<button
|
|
126
|
+
key={preset.id}
|
|
127
|
+
type="button"
|
|
128
|
+
role="radio"
|
|
129
|
+
aria-checked={accent.kind === "preset" && accent.id === preset.id}
|
|
130
|
+
aria-label={preset.name}
|
|
131
|
+
title={preset.name}
|
|
132
|
+
className="accent-swatch"
|
|
133
|
+
style={{ background: mode === "dark" ? preset.dark : preset.light }}
|
|
134
|
+
onClick={() => onAppearanceChange({ ...appearance, accent: { kind: "preset", id: preset.id } })}
|
|
135
|
+
/>
|
|
136
|
+
))}
|
|
137
|
+
<label className={`accent-custom ${accent.kind === "custom" ? "is-selected" : ""}`} title="Pick any color">
|
|
138
|
+
<Pipette size={14} aria-hidden="true" />
|
|
139
|
+
<span className="sr-only">Custom accent color</span>
|
|
140
|
+
<input
|
|
141
|
+
type="color"
|
|
142
|
+
value={customColor}
|
|
143
|
+
onChange={(event) => onAppearanceChange({ ...appearance, accent: { kind: "custom", color: event.target.value } })}
|
|
144
|
+
/>
|
|
145
|
+
</label>
|
|
146
|
+
</div>
|
|
147
|
+
</div>
|
|
148
|
+
|
|
149
|
+
{importedTheme && (
|
|
150
|
+
<div className="theme-row">
|
|
151
|
+
<span className="theme-row-label" id="theme-imported-label">Imported colors</span>
|
|
152
|
+
<button
|
|
153
|
+
type="button"
|
|
154
|
+
role="switch"
|
|
155
|
+
aria-checked={appearance.imported}
|
|
156
|
+
aria-labelledby="theme-imported-label"
|
|
157
|
+
className={`toggle ${appearance.imported ? "on" : ""}`}
|
|
158
|
+
onClick={() => onAppearanceChange({ ...appearance, imported: !appearance.imported })}
|
|
159
|
+
>
|
|
160
|
+
<span className="toggle-knob" />
|
|
161
|
+
</button>
|
|
162
|
+
</div>
|
|
163
|
+
)}
|
|
90
164
|
|
|
91
165
|
<details className="theme-import">
|
|
92
166
|
<summary>
|
|
@@ -95,7 +169,8 @@ export function ThemeSettings({ mode, palette, importedTheme, onModeChange, onPa
|
|
|
95
169
|
</summary>
|
|
96
170
|
<div className="theme-import-body">
|
|
97
171
|
<p>
|
|
98
|
-
Paste light and dark CSS variable blocks. Use Token Larper colors or
|
|
172
|
+
Paste light and dark CSS variable blocks, for example from tweakcn. Use Token Larper colors or
|
|
173
|
+
shadcn-style variables; the colors apply on top of the selected style, and extra CSS is ignored.
|
|
99
174
|
</p>
|
|
100
175
|
<details className="theme-format">
|
|
101
176
|
<summary>Show example CSS <ChevronDown size={14} aria-hidden="true" /></summary>
|
|
@@ -0,0 +1,42 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { ArrowUpCircle, RefreshCw, X } from "lucide-react";
|
|
3
|
+
import { RELEASES_URL, type Updates } from "../lib/updates.ts";
|
|
4
|
+
|
|
5
|
+
export function UpdateBanner({ updates, onDismiss }: { updates: Updates; onDismiss: () => void }) {
|
|
6
|
+
const { status, phase, error, update } = updates;
|
|
7
|
+
if (!status?.latest) return null;
|
|
8
|
+
const updating = phase === "updating";
|
|
9
|
+
|
|
10
|
+
return (
|
|
11
|
+
<div className="update-banner" role="status">
|
|
12
|
+
{updating ? <RefreshCw size={16} className="spin" aria-hidden="true" /> : <ArrowUpCircle size={16} aria-hidden="true" />}
|
|
13
|
+
<p>
|
|
14
|
+
{updating ? (
|
|
15
|
+
<>Updating to {status.latest}. The dashboard reloads when it's ready.</>
|
|
16
|
+
) : phase === "failed" ? (
|
|
17
|
+
<>{error}</>
|
|
18
|
+
) : (
|
|
19
|
+
<>
|
|
20
|
+
<strong>Token Larper {status.latest} is out.</strong>{" "}
|
|
21
|
+
<span>
|
|
22
|
+
{status.source === "source" ? "This copy runs from git; update it with git pull." : `You have ${status.current}.`}
|
|
23
|
+
</span>
|
|
24
|
+
</>
|
|
25
|
+
)}
|
|
26
|
+
</p>
|
|
27
|
+
<div className="update-banner-actions">
|
|
28
|
+
<a className="update-link" href={RELEASES_URL} target="_blank" rel="noreferrer">What's new</a>
|
|
29
|
+
{status.source === "npm" && (
|
|
30
|
+
<button className="btn btn-primary" disabled={updating} onClick={() => void update()}>
|
|
31
|
+
{phase === "failed" ? "Try again" : updating ? "Updating…" : "Update now"}
|
|
32
|
+
</button>
|
|
33
|
+
)}
|
|
34
|
+
{!updating && (
|
|
35
|
+
<button className="icon-btn" aria-label={`Hide the ${status.latest} update`} title="Not now" onClick={onDismiss}>
|
|
36
|
+
<X size={15} />
|
|
37
|
+
</button>
|
|
38
|
+
)}
|
|
39
|
+
</div>
|
|
40
|
+
</div>
|
|
41
|
+
);
|
|
42
|
+
}
|
|
@@ -0,0 +1,65 @@
|
|
|
1
|
+
import React from "react";
|
|
2
|
+
import { RefreshCw } from "lucide-react";
|
|
3
|
+
import { RELEASES_URL, UPDATE_COMMAND, type Updates } from "../lib/updates.ts";
|
|
4
|
+
|
|
5
|
+
interface Props {
|
|
6
|
+
updates: Updates;
|
|
7
|
+
autoCheck: boolean;
|
|
8
|
+
onAutoCheckChange: (autoCheck: boolean) => void;
|
|
9
|
+
}
|
|
10
|
+
|
|
11
|
+
function describe({ status, checking, phase }: Updates): string {
|
|
12
|
+
if (phase === "updating") return `Updating to ${status?.latest}…`;
|
|
13
|
+
if (checking) return "Checking…";
|
|
14
|
+
if (!status?.checkedAt) return "Not checked yet";
|
|
15
|
+
if (status.updateAvailable) return `${status.latest} is available`;
|
|
16
|
+
return "You're up to date";
|
|
17
|
+
}
|
|
18
|
+
|
|
19
|
+
export function UpdateSettings({ updates, autoCheck, onAutoCheckChange }: Props) {
|
|
20
|
+
const { status, checking, phase, error, check, update } = updates;
|
|
21
|
+
const canUpdate = status?.updateAvailable && status.source === "npm" && phase !== "updating";
|
|
22
|
+
|
|
23
|
+
return (
|
|
24
|
+
<section className="settings-section" aria-labelledby="settings-updates-heading">
|
|
25
|
+
<div className="settings-section-heading">
|
|
26
|
+
<h4 id="settings-updates-heading">Updates</h4>
|
|
27
|
+
<span>{status ? `Version ${status.current}` : ""}</span>
|
|
28
|
+
</div>
|
|
29
|
+
<div className="setting-row">
|
|
30
|
+
<div className="setting-copy">
|
|
31
|
+
<strong>{describe(updates)}</strong>
|
|
32
|
+
<p>
|
|
33
|
+
{error ? error : status?.source === "source"
|
|
34
|
+
? "This copy runs from a git checkout. Update it with git pull."
|
|
35
|
+
: <>Updates install with <code>{UPDATE_COMMAND}</code>. <a href={RELEASES_URL} target="_blank" rel="noreferrer">What's new</a></>}
|
|
36
|
+
</p>
|
|
37
|
+
</div>
|
|
38
|
+
{canUpdate ? (
|
|
39
|
+
<button className="btn btn-primary" onClick={() => void update()}>Update now</button>
|
|
40
|
+
) : (
|
|
41
|
+
<button className="btn" disabled={checking || phase === "updating"} onClick={() => void check(true)}>
|
|
42
|
+
<RefreshCw size={14} className={checking ? "spin" : ""} aria-hidden="true" />
|
|
43
|
+
<span>Check for updates</span>
|
|
44
|
+
</button>
|
|
45
|
+
)}
|
|
46
|
+
</div>
|
|
47
|
+
<div className="setting-row">
|
|
48
|
+
<div className="setting-copy">
|
|
49
|
+
<strong id="setting-autoupdate-label">Check automatically</strong>
|
|
50
|
+
<p>Ask npm for the latest version when the dashboard opens. Nothing else is sent.</p>
|
|
51
|
+
</div>
|
|
52
|
+
<button
|
|
53
|
+
type="button"
|
|
54
|
+
className={`toggle ${autoCheck ? "on" : ""}`}
|
|
55
|
+
role="switch"
|
|
56
|
+
aria-labelledby="setting-autoupdate-label"
|
|
57
|
+
aria-checked={autoCheck}
|
|
58
|
+
onClick={() => onAutoCheckChange(!autoCheck)}
|
|
59
|
+
>
|
|
60
|
+
<span className="toggle-knob" />
|
|
61
|
+
</button>
|
|
62
|
+
</div>
|
|
63
|
+
</section>
|
|
64
|
+
);
|
|
65
|
+
}
|
package/src/client/index.html
CHANGED
|
@@ -11,36 +11,27 @@
|
|
|
11
11
|
stored === "dark" || stored === "light"
|
|
12
12
|
? stored
|
|
13
13
|
: matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
|
|
14
|
-
|
|
15
|
-
|
|
16
|
-
var
|
|
17
|
-
var
|
|
18
|
-
|
|
19
|
-
|
|
20
|
-
|
|
21
|
-
|
|
22
|
-
|
|
23
|
-
|
|
24
|
-
|
|
25
|
-
|
|
26
|
-
|
|
27
|
-
|
|
28
|
-
|
|
29
|
-
|
|
30
|
-
|
|
31
|
-
var value = tokens && tokens[property];
|
|
32
|
-
if (typeof value === "string" && value.length <= 180 && !/[;{}]|\bvar\s*\(|url\s*\(/i.test(value) && CSS.supports("color", value)) {
|
|
33
|
-
document.documentElement.style.setProperty(property, value);
|
|
14
|
+
// Style and resolved theme variables were saved by the app (src/client/themes.ts);
|
|
15
|
+
// applying them here, before first paint, avoids a flash of the default theme.
|
|
16
|
+
var root = document.documentElement;
|
|
17
|
+
var appearance = null;
|
|
18
|
+
try { appearance = JSON.parse(localStorage.getItem("token-larper-appearance") || "null"); } catch {}
|
|
19
|
+
var styles = ["grove", "terminal", "paper", "brutal", "soft", "mono"];
|
|
20
|
+
root.dataset.style = appearance && styles.indexOf(appearance.style) >= 0 ? appearance.style : "grove";
|
|
21
|
+
root.dataset.accent = "style";
|
|
22
|
+
try {
|
|
23
|
+
var vars = JSON.parse(localStorage.getItem("token-larper-theme-vars") || "null");
|
|
24
|
+
var current = vars && vars[root.dataset.theme];
|
|
25
|
+
if (current && typeof current === "object") {
|
|
26
|
+
Object.keys(current).forEach(function (name) {
|
|
27
|
+
var value = current[name];
|
|
28
|
+
if (/^--[a-z0-9-]+$/.test(name) && typeof value === "string" && value.length <= 180 && !/[;{}]|url\s*\(/i.test(value)) {
|
|
29
|
+
root.style.setProperty(name, value);
|
|
30
|
+
root.dataset.accent = "custom";
|
|
34
31
|
}
|
|
35
32
|
});
|
|
36
|
-
|
|
37
|
-
|
|
38
|
-
if (typeof radius === "string" && /^\d+(?:\.\d+)?(?:px|rem|em)$/.test(radius)) {
|
|
39
|
-
document.documentElement.style.setProperty(property, radius);
|
|
40
|
-
}
|
|
41
|
-
});
|
|
42
|
-
} catch {}
|
|
43
|
-
}
|
|
33
|
+
}
|
|
34
|
+
} catch {}
|
|
44
35
|
</script>
|
|
45
36
|
<meta name="theme-color" content="#f8f9f7" />
|
|
46
37
|
<title>Token Larper — Usage overview</title>
|