@rohal12/spindle 0.16.1 → 0.18.1

This diff represents the content of publicly available package versions that have been released to one of the supported registries. The information contained in this diff is provided for informational purposes only and reflects changes between package versions as they appear in their respective public registries.
Files changed (60) hide show
  1. package/dist/pkg/format.js +1 -1
  2. package/package.json +2 -2
  3. package/src/action-registry.ts +2 -1
  4. package/src/components/Passage.tsx +69 -4
  5. package/src/components/PassageDialog.tsx +69 -0
  6. package/src/components/StoryInterface.tsx +2 -2
  7. package/src/components/macros/Button.tsx +33 -46
  8. package/src/components/macros/Checkbox.tsx +32 -35
  9. package/src/components/macros/Computed.tsx +49 -54
  10. package/src/components/macros/Cycle.tsx +43 -53
  11. package/src/components/macros/Dialog.tsx +40 -0
  12. package/src/components/macros/Do.tsx +15 -21
  13. package/src/components/macros/For.tsx +57 -75
  14. package/src/components/macros/Goto.tsx +18 -22
  15. package/src/components/macros/If.tsx +39 -53
  16. package/src/components/macros/Include.tsx +30 -46
  17. package/src/components/macros/Listbox.tsx +38 -48
  18. package/src/components/macros/MacroError.tsx +18 -0
  19. package/src/components/macros/MacroLink.tsx +38 -53
  20. package/src/components/macros/MenubarButtons.tsx +158 -0
  21. package/src/components/macros/Meter.tsx +35 -52
  22. package/src/components/macros/Numberbox.tsx +30 -47
  23. package/src/components/macros/PassageDisplay.tsx +37 -30
  24. package/src/components/macros/Print.tsx +19 -36
  25. package/src/components/macros/Radiobutton.tsx +33 -38
  26. package/src/components/macros/Repeat.tsx +49 -54
  27. package/src/components/macros/SaveManager.tsx +400 -0
  28. package/src/components/macros/Set.tsx +19 -24
  29. package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
  30. package/src/components/macros/Span.tsx +8 -0
  31. package/src/components/macros/Stop.tsx +11 -12
  32. package/src/components/macros/StoryTitle.tsx +16 -16
  33. package/src/components/macros/Switch.tsx +41 -59
  34. package/src/components/macros/Textarea.tsx +26 -45
  35. package/src/components/macros/Textbox.tsx +27 -44
  36. package/src/components/macros/Timed.tsx +54 -55
  37. package/src/components/macros/Type.tsx +61 -62
  38. package/src/components/macros/Unset.tsx +21 -22
  39. package/src/components/macros/Widget.tsx +11 -15
  40. package/src/components/macros/option-utils.ts +13 -0
  41. package/src/define-macro.ts +154 -0
  42. package/src/expression.ts +10 -8
  43. package/src/index.tsx +7 -0
  44. package/src/macros/register-builtins.ts +5 -7
  45. package/src/markup/ast.ts +11 -16
  46. package/src/markup/render.tsx +0 -13
  47. package/src/saves/save-manager.ts +43 -0
  48. package/src/saves/types.ts +9 -2
  49. package/src/store.ts +283 -52
  50. package/src/story-api.ts +33 -12
  51. package/src/styles.css +37 -68
  52. package/src/components/PassageLink.tsx +0 -49
  53. package/src/components/SaveLoadDialog.tsx +0 -408
  54. package/src/components/macros/Back.tsx +0 -32
  55. package/src/components/macros/Forward.tsx +0 -34
  56. package/src/components/macros/QuickLoad.tsx +0 -42
  57. package/src/components/macros/QuickSave.tsx +0 -30
  58. package/src/components/macros/Restart.tsx +0 -35
  59. package/src/components/macros/Saves.tsx +0 -24
  60. package/src/components/macros/SettingsButton.tsx +0 -28
@@ -0,0 +1,400 @@
1
+ import {
2
+ useState,
3
+ useEffect,
4
+ useCallback,
5
+ useRef,
6
+ useContext,
7
+ } from 'preact/hooks';
8
+ import { useStoryStore } from '../../store';
9
+ import { isSaveExport, type SaveRecord } from '../../saves/types';
10
+ import {
11
+ getSavesGrouped,
12
+ createSave,
13
+ overwriteSave,
14
+ deleteSaveById,
15
+ renameSave,
16
+ exportSave,
17
+ importSave,
18
+ type PlaythroughGroup,
19
+ } from '../../saves/save-manager';
20
+ import { DialogCloseContext } from '../PassageDialog';
21
+ import { defineMacro } from '../../define-macro';
22
+
23
+ function relativeTime(iso: string): string {
24
+ const diff = Date.now() - new Date(iso).getTime();
25
+ const secs = Math.floor(diff / 1000);
26
+ if (secs < 60) return 'just now';
27
+ const mins = Math.floor(secs / 60);
28
+ if (mins < 60) return `${mins}m ago`;
29
+ const hours = Math.floor(mins / 60);
30
+ if (hours < 24) return `${hours}h ago`;
31
+ const days = Math.floor(hours / 24);
32
+ if (days < 30) return `${days}d ago`;
33
+ return new Date(iso).toLocaleDateString();
34
+ }
35
+
36
+ function formatDate(iso: string): string {
37
+ return new Date(iso).toLocaleDateString(undefined, {
38
+ month: 'short',
39
+ day: 'numeric',
40
+ year: 'numeric',
41
+ });
42
+ }
43
+
44
+ export function SaveManagerContent() {
45
+ const [mode, setMode] = useState<'save' | 'load'>('load');
46
+ const [groups, setGroups] = useState<PlaythroughGroup[]>([]);
47
+ const [loading, setLoading] = useState(true);
48
+ const [status, setStatus] = useState<{
49
+ text: string;
50
+ type: 'success' | 'error';
51
+ } | null>(null);
52
+ const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
53
+ const [renamingId, setRenamingId] = useState<string | null>(null);
54
+ const [renameValue, setRenameValue] = useState('');
55
+ const renameInputRef = useRef<HTMLInputElement>(null);
56
+ const fileInputRef = useRef<HTMLInputElement>(null);
57
+
58
+ const closeDialog = useContext(DialogCloseContext);
59
+
60
+ const storyData = useStoryStore((s) => s.storyData);
61
+ const playthroughId = useStoryStore((s) => s.playthroughId);
62
+ const getSavePayload = useStoryStore((s) => s.getSavePayload);
63
+ const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
64
+ const ifid = storyData?.ifid ?? '';
65
+
66
+ const refresh = useCallback(async () => {
67
+ if (!ifid) return;
68
+ const data = await getSavesGrouped(ifid);
69
+ setGroups(data);
70
+ setLoading(false);
71
+ }, [ifid]);
72
+
73
+ useEffect(() => {
74
+ refresh();
75
+ }, [refresh]);
76
+
77
+ useEffect(() => {
78
+ if (renamingId && renameInputRef.current) {
79
+ renameInputRef.current.focus();
80
+ renameInputRef.current.select();
81
+ }
82
+ }, [renamingId]);
83
+
84
+ const statusTimer = useRef<number>();
85
+ const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
86
+ clearTimeout(statusTimer.current);
87
+ setStatus({ text, type });
88
+ statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
89
+ };
90
+
91
+ const toggleCollapse = (id: string) => {
92
+ setCollapsed((prev) => {
93
+ const next = new Set(prev);
94
+ if (next.has(id)) next.delete(id);
95
+ else next.add(id);
96
+ return next;
97
+ });
98
+ };
99
+
100
+ const handleNewSave = async () => {
101
+ if (!ifid || !playthroughId) return;
102
+ try {
103
+ const payload = getSavePayload();
104
+ await createSave(ifid, playthroughId, payload);
105
+ showStatus('Save created');
106
+ await refresh();
107
+ } catch {
108
+ showStatus('Failed to create save', 'error');
109
+ }
110
+ };
111
+
112
+ const handleOverwrite = async (saveId: string) => {
113
+ try {
114
+ const payload = getSavePayload();
115
+ await overwriteSave(saveId, payload);
116
+ showStatus('Save overwritten');
117
+ await refresh();
118
+ } catch {
119
+ showStatus('Failed to overwrite save', 'error');
120
+ }
121
+ };
122
+
123
+ const handleLoad = async (save: SaveRecord) => {
124
+ try {
125
+ loadFromPayload(save.payload);
126
+ showStatus('Game loaded');
127
+ if (closeDialog) setTimeout(closeDialog, 500);
128
+ } catch {
129
+ showStatus('Failed to load save', 'error');
130
+ }
131
+ };
132
+
133
+ const handleDelete = async (saveId: string) => {
134
+ if (!confirm('Delete this save?')) return;
135
+ try {
136
+ await deleteSaveById(saveId);
137
+ showStatus('Save deleted');
138
+ await refresh();
139
+ } catch {
140
+ showStatus('Failed to delete save', 'error');
141
+ }
142
+ };
143
+
144
+ const handleRenameStart = (save: SaveRecord) => {
145
+ setRenamingId(save.meta.id);
146
+ setRenameValue(save.meta.title);
147
+ };
148
+
149
+ const handleRenameConfirm = async () => {
150
+ if (!renamingId || !renameValue.trim()) return;
151
+ try {
152
+ await renameSave(renamingId, renameValue.trim());
153
+ setRenamingId(null);
154
+ showStatus('Save renamed');
155
+ await refresh();
156
+ } catch {
157
+ showStatus('Failed to rename', 'error');
158
+ }
159
+ };
160
+
161
+ const handleRenameKeyDown = (e: KeyboardEvent) => {
162
+ if (e.key === 'Enter') handleRenameConfirm();
163
+ else if (e.key === 'Escape') setRenamingId(null);
164
+ };
165
+
166
+ const handleExport = async (saveId: string) => {
167
+ try {
168
+ const data = await exportSave(saveId);
169
+ if (!data) return;
170
+ const blob = new Blob([JSON.stringify(data, null, 2)], {
171
+ type: 'application/json',
172
+ });
173
+ const url = URL.createObjectURL(blob);
174
+ const a = document.createElement('a');
175
+ a.href = url;
176
+ a.download = `save-${data.save.meta.title.replace(/[^a-z0-9]/gi, '_')}.json`;
177
+ a.click();
178
+ URL.revokeObjectURL(url);
179
+ showStatus('Save exported');
180
+ } catch {
181
+ showStatus('Failed to export save', 'error');
182
+ }
183
+ };
184
+
185
+ const handleImport = () => {
186
+ fileInputRef.current?.click();
187
+ };
188
+
189
+ const handleFileSelected = async (e: Event) => {
190
+ const input = e.target as HTMLInputElement;
191
+ const file = input.files?.[0];
192
+ if (!file) return;
193
+ input.value = '';
194
+
195
+ try {
196
+ const text = await file.text();
197
+ const data = JSON.parse(text);
198
+ if (!isSaveExport(data)) {
199
+ throw new Error('Invalid save file format');
200
+ }
201
+ await importSave(data, ifid);
202
+ showStatus('Save imported');
203
+ await refresh();
204
+ } catch (err) {
205
+ showStatus(
206
+ err instanceof Error ? err.message : 'Failed to import save',
207
+ 'error',
208
+ );
209
+ }
210
+ };
211
+
212
+ const totalSaves = groups.reduce((n, g) => n + g.saves.length, 0);
213
+
214
+ return (
215
+ <div class="save-manager">
216
+ <div class="saves-header">
217
+ <div class="saves-header-left">
218
+ <div class="saves-mode-toggle">
219
+ <button
220
+ class={mode === 'save' ? 'active' : ''}
221
+ onClick={() => setMode('save')}
222
+ >
223
+ Save
224
+ </button>
225
+ <button
226
+ class={mode === 'load' ? 'active' : ''}
227
+ onClick={() => setMode('load')}
228
+ >
229
+ Load
230
+ </button>
231
+ </div>
232
+ </div>
233
+ </div>
234
+
235
+ <div class="saves-toolbar">
236
+ <button
237
+ class="saves-toolbar-button"
238
+ onClick={handleImport}
239
+ >
240
+ Import
241
+ </button>
242
+ <input
243
+ ref={fileInputRef}
244
+ type="file"
245
+ accept=".json"
246
+ style="display:none"
247
+ onChange={handleFileSelected}
248
+ />
249
+ </div>
250
+
251
+ <div class="saves-body">
252
+ {loading ? (
253
+ <div class="saves-empty">Loading...</div>
254
+ ) : totalSaves === 0 && mode === 'load' ? (
255
+ <div class="saves-empty">No saves yet</div>
256
+ ) : (
257
+ groups.map((group) => {
258
+ const isCollapsed = collapsed.has(group.playthrough.id);
259
+ const isCurrentPt = group.playthrough.id === playthroughId;
260
+
261
+ if (mode === 'save' && !isCurrentPt) return null;
262
+ if (mode === 'load' && group.saves.length === 0 && !isCurrentPt)
263
+ return null;
264
+
265
+ return (
266
+ <div
267
+ class="playthrough-group"
268
+ key={group.playthrough.id}
269
+ >
270
+ <div
271
+ class="playthrough-header"
272
+ onClick={() => toggleCollapse(group.playthrough.id)}
273
+ >
274
+ <span
275
+ class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
276
+ >
277
+ ▶
278
+ </span>
279
+ <span class="playthrough-label">
280
+ {group.playthrough.label}
281
+ {isCurrentPt ? ' (current)' : ''}
282
+ </span>
283
+ <span class="playthrough-date">
284
+ {formatDate(group.playthrough.createdAt)}
285
+ </span>
286
+ </div>
287
+
288
+ {!isCollapsed && (
289
+ <div class="playthrough-saves">
290
+ {group.saves.map((save) => (
291
+ <div
292
+ class="save-slot"
293
+ key={save.meta.id}
294
+ >
295
+ <div class="save-slot-info">
296
+ {renamingId === save.meta.id ? (
297
+ <input
298
+ ref={renameInputRef}
299
+ class="save-rename-input"
300
+ value={renameValue}
301
+ onInput={(e) =>
302
+ setRenameValue(
303
+ (e.target as HTMLInputElement).value,
304
+ )
305
+ }
306
+ onKeyDown={handleRenameKeyDown}
307
+ onBlur={handleRenameConfirm}
308
+ />
309
+ ) : (
310
+ <div class="save-slot-title">{save.meta.title}</div>
311
+ )}
312
+ <div class="save-slot-meta">
313
+ <span>{save.meta.passage}</span>
314
+ <span>{relativeTime(save.meta.updatedAt)}</span>
315
+ </div>
316
+ </div>
317
+ <div class="save-slot-actions">
318
+ {mode === 'save' ? (
319
+ <button
320
+ class="save-slot-action primary"
321
+ onClick={() => handleOverwrite(save.meta.id)}
322
+ >
323
+ Save Here
324
+ </button>
325
+ ) : (
326
+ <button
327
+ class="save-slot-action primary"
328
+ onClick={() => handleLoad(save)}
329
+ >
330
+ Load
331
+ </button>
332
+ )}
333
+ <button
334
+ class="save-slot-action"
335
+ onClick={() => handleRenameStart(save)}
336
+ >
337
+ Rename
338
+ </button>
339
+ <button
340
+ class="save-slot-action"
341
+ onClick={() => handleExport(save.meta.id)}
342
+ >
343
+ Export
344
+ </button>
345
+ <button
346
+ class="save-slot-action danger"
347
+ onClick={() => handleDelete(save.meta.id)}
348
+ >
349
+ Delete
350
+ </button>
351
+ </div>
352
+ </div>
353
+ ))}
354
+
355
+ {mode === 'save' && isCurrentPt && (
356
+ <button
357
+ class="save-slot-new"
358
+ onClick={handleNewSave}
359
+ >
360
+ + New Save
361
+ </button>
362
+ )}
363
+ </div>
364
+ )}
365
+ </div>
366
+ );
367
+ })
368
+ )}
369
+
370
+ {mode === 'save' &&
371
+ !loading &&
372
+ !groups.some((g) => g.playthrough.id === playthroughId) && (
373
+ <div class="playthrough-group">
374
+ <div class="playthrough-saves">
375
+ <button
376
+ class="save-slot-new"
377
+ onClick={handleNewSave}
378
+ >
379
+ + New Save
380
+ </button>
381
+ </div>
382
+ </div>
383
+ )}
384
+ </div>
385
+
386
+ {status && (
387
+ <div class={`saves-status ${status.type === 'error' ? 'error' : ''}`}>
388
+ {status.text}
389
+ </div>
390
+ )}
391
+ </div>
392
+ );
393
+ }
394
+
395
+ defineMacro({
396
+ name: 'save-manager',
397
+ render() {
398
+ return <SaveManagerContent />;
399
+ },
400
+ });
@@ -1,29 +1,24 @@
1
- import { useRef, useContext } from 'preact/hooks';
2
- import { LocalsUpdateContext } from '../../markup/render';
3
- import { executeMutation } from '../../execute-mutation';
4
- import { currentSourceLocation } from '../../utils/source-location';
5
- import { registerMacro } from '../../registry';
6
- import type { MacroProps } from '../../registry';
1
+ import { defineMacro } from '../../define-macro';
7
2
 
8
- export function Set({ rawArgs }: MacroProps) {
9
- const { update, getValues } = useContext(LocalsUpdateContext);
10
- const ran = useRef(false);
3
+ defineMacro({
4
+ name: 'set',
5
+ render({ rawArgs }, ctx) {
6
+ const ran = ctx.hooks.useRef(false);
11
7
 
12
- if (!ran.current) {
13
- ran.current = true;
8
+ if (!ran.current) {
9
+ ran.current = true;
14
10
 
15
- try {
16
- executeMutation(rawArgs, getValues(), update);
17
- } catch (err) {
18
- console.error(
19
- `spindle: Error in {set ${rawArgs}}${currentSourceLocation()}:`,
20
- err,
21
- );
22
- return null;
11
+ try {
12
+ ctx.mutate(rawArgs);
13
+ } catch (err) {
14
+ console.error(
15
+ `spindle: Error in {set ${rawArgs}}${ctx.sourceLocation()}:`,
16
+ err,
17
+ );
18
+ return null;
19
+ }
23
20
  }
24
- }
25
21
 
26
- return null;
27
- }
28
-
29
- registerMacro('set', Set);
22
+ return null;
23
+ },
24
+ });
@@ -1,9 +1,6 @@
1
1
  import { useState } from 'preact/hooks';
2
- import { settings, type SettingDef } from '../settings';
3
-
4
- interface SettingsDialogProps {
5
- onClose: () => void;
6
- }
2
+ import { settings, type SettingDef } from '../../settings';
3
+ import { defineMacro } from '../../define-macro';
7
4
 
8
5
  function SettingControl({ name, def }: { name: string; def: SettingDef }) {
9
6
  const [value, setValue] = useState(() => settings.get(name));
@@ -67,40 +64,25 @@ function SettingControl({ name, def }: { name: string; def: SettingDef }) {
67
64
  }
68
65
  }
69
66
 
70
- export function SettingsDialog({ onClose }: SettingsDialogProps) {
67
+ function SettingsControlsContent() {
71
68
  const defs = settings.getDefinitions();
72
69
 
73
- const handleBackdrop = (e: MouseEvent) => {
74
- if ((e.target as HTMLElement).classList.contains('settings-overlay')) {
75
- onClose();
76
- }
77
- };
78
-
79
70
  return (
80
- <div
81
- class="settings-overlay"
82
- onClick={handleBackdrop}
83
- >
84
- <div class="settings-panel">
85
- <div class="settings-header">
86
- <span>Settings</span>
87
- <button
88
- class="settings-close"
89
- onClick={onClose}
90
- >
91
- ✕
92
- </button>
93
- </div>
94
- <div class="settings-body">
95
- {Array.from(defs.entries()).map(([name, def]) => (
96
- <SettingControl
97
- key={name}
98
- name={name}
99
- def={def}
100
- />
101
- ))}
102
- </div>
103
- </div>
71
+ <div class="settings-controls">
72
+ {Array.from(defs.entries()).map(([name, def]) => (
73
+ <SettingControl
74
+ key={name}
75
+ name={name}
76
+ def={def}
77
+ />
78
+ ))}
104
79
  </div>
105
80
  );
106
81
  }
82
+
83
+ defineMacro({
84
+ name: 'settings-controls',
85
+ render() {
86
+ return <SettingsControlsContent />;
87
+ },
88
+ });
@@ -0,0 +1,8 @@
1
+ import { defineMacro } from '../../define-macro';
2
+
3
+ defineMacro({
4
+ name: 'span',
5
+ render({ children = [] }, ctx) {
6
+ return ctx.wrap(ctx.renderNodes(children));
7
+ },
8
+ });
@@ -1,16 +1,15 @@
1
- import { useLayoutEffect, useContext } from 'preact/hooks';
2
1
  import { RepeatContext } from './Repeat';
3
- import { registerMacro } from '../../registry';
4
- import type { MacroProps } from '../../registry';
2
+ import { defineMacro } from '../../define-macro';
5
3
 
6
- export function Stop(_props: MacroProps) {
7
- const { stop } = useContext(RepeatContext);
4
+ defineMacro({
5
+ name: 'stop',
6
+ render(_props, ctx) {
7
+ const { stop } = ctx.hooks.useContext(RepeatContext);
8
8
 
9
- useLayoutEffect(() => {
10
- stop();
11
- }, [stop]);
9
+ ctx.hooks.useLayoutEffect(() => {
10
+ stop();
11
+ }, [stop]);
12
12
 
13
- return null;
14
- }
15
-
16
- registerMacro('stop', Stop);
13
+ return null;
14
+ },
15
+ });
@@ -1,19 +1,19 @@
1
1
  import { useStoryStore } from '../../store';
2
- import { registerMacro } from '../../registry';
3
- import type { MacroProps } from '../../registry';
2
+ import { defineMacro } from '../../define-macro';
4
3
 
5
- export function StoryTitle({ className, id }: MacroProps) {
6
- const name = useStoryStore((s) => s.storyData?.name || '');
7
- const cls = className ? `story-title ${className}` : 'story-title';
4
+ defineMacro({
5
+ name: 'story-title',
6
+ render(_, ctx) {
7
+ const name = useStoryStore((s) => s.storyData?.name || '');
8
+ const cls = ctx.className ? `story-title ${ctx.className}` : 'story-title';
8
9
 
9
- return (
10
- <span
11
- id={id}
12
- class={cls}
13
- >
14
- {name}
15
- </span>
16
- );
17
- }
18
-
19
- registerMacro('story-title', StoryTitle);
10
+ return (
11
+ <span
12
+ id={ctx.id}
13
+ class={cls}
14
+ >
15
+ {name}
16
+ </span>
17
+ );
18
+ },
19
+ });