@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.
- package/dist/pkg/format.js +1 -1
- package/package.json +2 -2
- package/src/action-registry.ts +2 -1
- package/src/components/Passage.tsx +69 -4
- package/src/components/PassageDialog.tsx +69 -0
- package/src/components/StoryInterface.tsx +2 -2
- package/src/components/macros/Button.tsx +33 -46
- package/src/components/macros/Checkbox.tsx +32 -35
- package/src/components/macros/Computed.tsx +49 -54
- package/src/components/macros/Cycle.tsx +43 -53
- package/src/components/macros/Dialog.tsx +40 -0
- package/src/components/macros/Do.tsx +15 -21
- package/src/components/macros/For.tsx +57 -75
- package/src/components/macros/Goto.tsx +18 -22
- package/src/components/macros/If.tsx +39 -53
- package/src/components/macros/Include.tsx +30 -46
- package/src/components/macros/Listbox.tsx +38 -48
- package/src/components/macros/MacroError.tsx +18 -0
- package/src/components/macros/MacroLink.tsx +38 -53
- package/src/components/macros/MenubarButtons.tsx +158 -0
- package/src/components/macros/Meter.tsx +35 -52
- package/src/components/macros/Numberbox.tsx +30 -47
- package/src/components/macros/PassageDisplay.tsx +37 -30
- package/src/components/macros/Print.tsx +19 -36
- package/src/components/macros/Radiobutton.tsx +33 -38
- package/src/components/macros/Repeat.tsx +49 -54
- package/src/components/macros/SaveManager.tsx +400 -0
- package/src/components/macros/Set.tsx +19 -24
- package/src/components/{SettingsDialog.tsx → macros/SettingsControls.tsx} +18 -36
- package/src/components/macros/Span.tsx +8 -0
- package/src/components/macros/Stop.tsx +11 -12
- package/src/components/macros/StoryTitle.tsx +16 -16
- package/src/components/macros/Switch.tsx +41 -59
- package/src/components/macros/Textarea.tsx +26 -45
- package/src/components/macros/Textbox.tsx +27 -44
- package/src/components/macros/Timed.tsx +54 -55
- package/src/components/macros/Type.tsx +61 -62
- package/src/components/macros/Unset.tsx +21 -22
- package/src/components/macros/Widget.tsx +11 -15
- package/src/components/macros/option-utils.ts +13 -0
- package/src/define-macro.ts +154 -0
- package/src/expression.ts +10 -8
- package/src/index.tsx +7 -0
- package/src/macros/register-builtins.ts +5 -7
- package/src/markup/ast.ts +11 -16
- package/src/markup/render.tsx +0 -13
- package/src/saves/save-manager.ts +43 -0
- package/src/saves/types.ts +9 -2
- package/src/store.ts +283 -52
- package/src/story-api.ts +33 -12
- package/src/styles.css +37 -68
- package/src/components/PassageLink.tsx +0 -49
- package/src/components/SaveLoadDialog.tsx +0 -408
- package/src/components/macros/Back.tsx +0 -32
- package/src/components/macros/Forward.tsx +0 -34
- package/src/components/macros/QuickLoad.tsx +0 -42
- package/src/components/macros/QuickSave.tsx +0 -30
- package/src/components/macros/Restart.tsx +0 -35
- package/src/components/macros/Saves.tsx +0 -24
- package/src/components/macros/SettingsButton.tsx +0 -28
package/src/styles.css
CHANGED
|
@@ -44,25 +44,6 @@ tw-storydata {
|
|
|
44
44
|
}
|
|
45
45
|
}
|
|
46
46
|
|
|
47
|
-
.passage-link {
|
|
48
|
-
color: #64b5f6;
|
|
49
|
-
text-decoration: none;
|
|
50
|
-
border-bottom: 1px solid rgba(100, 181, 246, 0.3);
|
|
51
|
-
transition:
|
|
52
|
-
color 0.2s,
|
|
53
|
-
border-color 0.2s;
|
|
54
|
-
cursor: pointer;
|
|
55
|
-
}
|
|
56
|
-
|
|
57
|
-
.passage-link:hover {
|
|
58
|
-
color: #90caf9;
|
|
59
|
-
border-bottom-color: #90caf9;
|
|
60
|
-
}
|
|
61
|
-
|
|
62
|
-
.passage-link:active {
|
|
63
|
-
color: #42a5f5;
|
|
64
|
-
}
|
|
65
|
-
|
|
66
47
|
.macro-button {
|
|
67
48
|
background: rgba(100, 181, 246, 0.15);
|
|
68
49
|
color: #64b5f6;
|
|
@@ -154,9 +135,9 @@ tw-storydata {
|
|
|
154
135
|
background: transparent;
|
|
155
136
|
}
|
|
156
137
|
|
|
157
|
-
/*
|
|
138
|
+
/* Generic dialog */
|
|
158
139
|
|
|
159
|
-
.
|
|
140
|
+
.dialog-overlay {
|
|
160
141
|
position: fixed;
|
|
161
142
|
inset: 0;
|
|
162
143
|
z-index: 200;
|
|
@@ -166,28 +147,33 @@ tw-storydata {
|
|
|
166
147
|
background: rgba(0, 0, 0, 0.6);
|
|
167
148
|
}
|
|
168
149
|
|
|
169
|
-
.
|
|
150
|
+
.dialog-panel {
|
|
170
151
|
background: #16213e;
|
|
171
152
|
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
172
153
|
border-radius: 8px;
|
|
173
154
|
width: 90%;
|
|
174
|
-
max-width:
|
|
155
|
+
max-width: 500px;
|
|
175
156
|
max-height: 80vh;
|
|
176
|
-
|
|
157
|
+
display: flex;
|
|
158
|
+
flex-direction: column;
|
|
177
159
|
color: #e0e0e0;
|
|
178
160
|
font-family: system-ui, sans-serif;
|
|
161
|
+
position: relative;
|
|
179
162
|
}
|
|
180
163
|
|
|
181
|
-
.
|
|
182
|
-
|
|
183
|
-
align-items: center;
|
|
184
|
-
justify-content: space-between;
|
|
185
|
-
padding: 1em 1.25em;
|
|
186
|
-
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
187
|
-
font-weight: 600;
|
|
164
|
+
.dialog-saves {
|
|
165
|
+
max-width: 550px;
|
|
188
166
|
}
|
|
189
167
|
|
|
190
|
-
.settings
|
|
168
|
+
.dialog-settings {
|
|
169
|
+
max-width: 400px;
|
|
170
|
+
}
|
|
171
|
+
|
|
172
|
+
.dialog-close {
|
|
173
|
+
position: absolute;
|
|
174
|
+
top: 0.75em;
|
|
175
|
+
right: 0.75em;
|
|
176
|
+
z-index: 1;
|
|
191
177
|
background: transparent;
|
|
192
178
|
border: none;
|
|
193
179
|
color: #9e9e9e;
|
|
@@ -197,11 +183,19 @@ tw-storydata {
|
|
|
197
183
|
line-height: 1;
|
|
198
184
|
}
|
|
199
185
|
|
|
200
|
-
.
|
|
186
|
+
.dialog-close:hover {
|
|
201
187
|
color: #e0e0e0;
|
|
202
188
|
}
|
|
203
189
|
|
|
204
|
-
.
|
|
190
|
+
.dialog-body {
|
|
191
|
+
overflow-y: auto;
|
|
192
|
+
flex: 1;
|
|
193
|
+
min-height: 0;
|
|
194
|
+
}
|
|
195
|
+
|
|
196
|
+
/* Settings controls */
|
|
197
|
+
|
|
198
|
+
.settings-controls {
|
|
205
199
|
padding: 1em 1.25em;
|
|
206
200
|
display: flex;
|
|
207
201
|
flex-direction: column;
|
|
@@ -235,29 +229,13 @@ tw-storydata {
|
|
|
235
229
|
height: 1.1em;
|
|
236
230
|
}
|
|
237
231
|
|
|
238
|
-
/*
|
|
239
|
-
|
|
240
|
-
.saves-overlay {
|
|
241
|
-
position: fixed;
|
|
242
|
-
inset: 0;
|
|
243
|
-
z-index: 200;
|
|
244
|
-
display: flex;
|
|
245
|
-
align-items: center;
|
|
246
|
-
justify-content: center;
|
|
247
|
-
background: rgba(0, 0, 0, 0.6);
|
|
248
|
-
}
|
|
232
|
+
/* Save manager */
|
|
249
233
|
|
|
250
|
-
.
|
|
251
|
-
background: #16213e;
|
|
252
|
-
border: 1px solid rgba(255, 255, 255, 0.1);
|
|
253
|
-
border-radius: 8px;
|
|
254
|
-
width: 90%;
|
|
255
|
-
max-width: 550px;
|
|
256
|
-
max-height: 80vh;
|
|
234
|
+
.save-manager {
|
|
257
235
|
display: flex;
|
|
258
236
|
flex-direction: column;
|
|
259
|
-
|
|
260
|
-
|
|
237
|
+
flex: 1;
|
|
238
|
+
min-height: 0;
|
|
261
239
|
}
|
|
262
240
|
|
|
263
241
|
.saves-header {
|
|
@@ -265,6 +243,7 @@ tw-storydata {
|
|
|
265
243
|
align-items: center;
|
|
266
244
|
justify-content: space-between;
|
|
267
245
|
padding: 1em 1.25em;
|
|
246
|
+
padding-right: 3em;
|
|
268
247
|
border-bottom: 1px solid rgba(255, 255, 255, 0.08);
|
|
269
248
|
font-weight: 600;
|
|
270
249
|
flex-shrink: 0;
|
|
@@ -307,20 +286,6 @@ tw-storydata {
|
|
|
307
286
|
color: #e0e0e0;
|
|
308
287
|
}
|
|
309
288
|
|
|
310
|
-
.saves-close {
|
|
311
|
-
background: transparent;
|
|
312
|
-
border: none;
|
|
313
|
-
color: #9e9e9e;
|
|
314
|
-
font-size: 1.1em;
|
|
315
|
-
cursor: pointer;
|
|
316
|
-
padding: 0.25em;
|
|
317
|
-
line-height: 1;
|
|
318
|
-
}
|
|
319
|
-
|
|
320
|
-
.saves-close:hover {
|
|
321
|
-
color: #e0e0e0;
|
|
322
|
-
}
|
|
323
|
-
|
|
324
289
|
.saves-body {
|
|
325
290
|
padding: 0.75em 1.25em;
|
|
326
291
|
overflow-y: auto;
|
|
@@ -560,6 +525,10 @@ tw-storydata {
|
|
|
560
525
|
border-bottom-color: #90caf9;
|
|
561
526
|
}
|
|
562
527
|
|
|
528
|
+
.macro-link:active {
|
|
529
|
+
color: #42a5f5;
|
|
530
|
+
}
|
|
531
|
+
|
|
563
532
|
/* Macro: form inputs */
|
|
564
533
|
|
|
565
534
|
.macro-textbox,
|
|
@@ -1,49 +0,0 @@
|
|
|
1
|
-
import { useStoryStore } from '../store';
|
|
2
|
-
import { useAction } from '../hooks/use-action';
|
|
3
|
-
import { useInterpolate } from '../hooks/use-interpolate';
|
|
4
|
-
|
|
5
|
-
interface PassageLinkProps {
|
|
6
|
-
target: string;
|
|
7
|
-
className?: string;
|
|
8
|
-
id?: string;
|
|
9
|
-
children: preact.ComponentChildren;
|
|
10
|
-
}
|
|
11
|
-
|
|
12
|
-
export function PassageLink({
|
|
13
|
-
target,
|
|
14
|
-
className,
|
|
15
|
-
id,
|
|
16
|
-
children,
|
|
17
|
-
}: PassageLinkProps) {
|
|
18
|
-
const navigate = useStoryStore((s) => s.navigate);
|
|
19
|
-
const resolve = useInterpolate();
|
|
20
|
-
className = resolve(className);
|
|
21
|
-
id = resolve(id);
|
|
22
|
-
|
|
23
|
-
const handleClick = (e: MouseEvent) => {
|
|
24
|
-
e.preventDefault();
|
|
25
|
-
navigate(target);
|
|
26
|
-
};
|
|
27
|
-
|
|
28
|
-
useAction({
|
|
29
|
-
type: 'link',
|
|
30
|
-
key: target,
|
|
31
|
-
authorId: id,
|
|
32
|
-
label: typeof children === 'string' ? children : target,
|
|
33
|
-
target,
|
|
34
|
-
perform: () => navigate(target),
|
|
35
|
-
});
|
|
36
|
-
|
|
37
|
-
const cls = className ? `passage-link ${className}` : 'passage-link';
|
|
38
|
-
|
|
39
|
-
return (
|
|
40
|
-
<a
|
|
41
|
-
href="#"
|
|
42
|
-
id={id}
|
|
43
|
-
class={cls}
|
|
44
|
-
onClick={handleClick}
|
|
45
|
-
>
|
|
46
|
-
{children}
|
|
47
|
-
</a>
|
|
48
|
-
);
|
|
49
|
-
}
|
|
@@ -1,408 +0,0 @@
|
|
|
1
|
-
import { useState, useEffect, useCallback, useRef } from 'preact/hooks';
|
|
2
|
-
import { useStoryStore } from '../store';
|
|
3
|
-
import { isSaveExport, type SaveRecord } from '../saves/types';
|
|
4
|
-
import {
|
|
5
|
-
getSavesGrouped,
|
|
6
|
-
createSave,
|
|
7
|
-
overwriteSave,
|
|
8
|
-
deleteSaveById,
|
|
9
|
-
renameSave,
|
|
10
|
-
exportSave,
|
|
11
|
-
importSave,
|
|
12
|
-
type PlaythroughGroup,
|
|
13
|
-
} from '../saves/save-manager';
|
|
14
|
-
|
|
15
|
-
interface SaveLoadDialogProps {
|
|
16
|
-
onClose: () => void;
|
|
17
|
-
}
|
|
18
|
-
|
|
19
|
-
function relativeTime(iso: string): string {
|
|
20
|
-
const diff = Date.now() - new Date(iso).getTime();
|
|
21
|
-
const secs = Math.floor(diff / 1000);
|
|
22
|
-
if (secs < 60) return 'just now';
|
|
23
|
-
const mins = Math.floor(secs / 60);
|
|
24
|
-
if (mins < 60) return `${mins}m ago`;
|
|
25
|
-
const hours = Math.floor(mins / 60);
|
|
26
|
-
if (hours < 24) return `${hours}h ago`;
|
|
27
|
-
const days = Math.floor(hours / 24);
|
|
28
|
-
if (days < 30) return `${days}d ago`;
|
|
29
|
-
return new Date(iso).toLocaleDateString();
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
function formatDate(iso: string): string {
|
|
33
|
-
return new Date(iso).toLocaleDateString(undefined, {
|
|
34
|
-
month: 'short',
|
|
35
|
-
day: 'numeric',
|
|
36
|
-
year: 'numeric',
|
|
37
|
-
});
|
|
38
|
-
}
|
|
39
|
-
|
|
40
|
-
export function SaveLoadDialog({ onClose }: SaveLoadDialogProps) {
|
|
41
|
-
const [mode, setMode] = useState<'save' | 'load'>('load');
|
|
42
|
-
const [groups, setGroups] = useState<PlaythroughGroup[]>([]);
|
|
43
|
-
const [loading, setLoading] = useState(true);
|
|
44
|
-
const [status, setStatus] = useState<{
|
|
45
|
-
text: string;
|
|
46
|
-
type: 'success' | 'error';
|
|
47
|
-
} | null>(null);
|
|
48
|
-
const [collapsed, setCollapsed] = useState<Set<string>>(() => new Set());
|
|
49
|
-
const [renamingId, setRenamingId] = useState<string | null>(null);
|
|
50
|
-
const [renameValue, setRenameValue] = useState('');
|
|
51
|
-
const renameInputRef = useRef<HTMLInputElement>(null);
|
|
52
|
-
const fileInputRef = useRef<HTMLInputElement>(null);
|
|
53
|
-
|
|
54
|
-
const storyData = useStoryStore((s) => s.storyData);
|
|
55
|
-
const playthroughId = useStoryStore((s) => s.playthroughId);
|
|
56
|
-
const getSavePayload = useStoryStore((s) => s.getSavePayload);
|
|
57
|
-
const loadFromPayload = useStoryStore((s) => s.loadFromPayload);
|
|
58
|
-
const ifid = storyData?.ifid ?? '';
|
|
59
|
-
|
|
60
|
-
const refresh = useCallback(async () => {
|
|
61
|
-
if (!ifid) return;
|
|
62
|
-
const data = await getSavesGrouped(ifid);
|
|
63
|
-
setGroups(data);
|
|
64
|
-
setLoading(false);
|
|
65
|
-
}, [ifid]);
|
|
66
|
-
|
|
67
|
-
useEffect(() => {
|
|
68
|
-
refresh();
|
|
69
|
-
}, [refresh]);
|
|
70
|
-
|
|
71
|
-
useEffect(() => {
|
|
72
|
-
if (renamingId && renameInputRef.current) {
|
|
73
|
-
renameInputRef.current.focus();
|
|
74
|
-
renameInputRef.current.select();
|
|
75
|
-
}
|
|
76
|
-
}, [renamingId]);
|
|
77
|
-
|
|
78
|
-
const statusTimer = useRef<number>();
|
|
79
|
-
const showStatus = (text: string, type: 'success' | 'error' = 'success') => {
|
|
80
|
-
clearTimeout(statusTimer.current);
|
|
81
|
-
setStatus({ text, type });
|
|
82
|
-
statusTimer.current = window.setTimeout(() => setStatus(null), 3000);
|
|
83
|
-
};
|
|
84
|
-
|
|
85
|
-
const toggleCollapse = (id: string) => {
|
|
86
|
-
setCollapsed((prev) => {
|
|
87
|
-
const next = new Set(prev);
|
|
88
|
-
if (next.has(id)) next.delete(id);
|
|
89
|
-
else next.add(id);
|
|
90
|
-
return next;
|
|
91
|
-
});
|
|
92
|
-
};
|
|
93
|
-
|
|
94
|
-
const handleNewSave = async () => {
|
|
95
|
-
if (!ifid || !playthroughId) return;
|
|
96
|
-
try {
|
|
97
|
-
const payload = getSavePayload();
|
|
98
|
-
await createSave(ifid, playthroughId, payload);
|
|
99
|
-
showStatus('Save created');
|
|
100
|
-
await refresh();
|
|
101
|
-
} catch {
|
|
102
|
-
showStatus('Failed to create save', 'error');
|
|
103
|
-
}
|
|
104
|
-
};
|
|
105
|
-
|
|
106
|
-
const handleOverwrite = async (saveId: string) => {
|
|
107
|
-
try {
|
|
108
|
-
const payload = getSavePayload();
|
|
109
|
-
await overwriteSave(saveId, payload);
|
|
110
|
-
showStatus('Save overwritten');
|
|
111
|
-
await refresh();
|
|
112
|
-
} catch {
|
|
113
|
-
showStatus('Failed to overwrite save', 'error');
|
|
114
|
-
}
|
|
115
|
-
};
|
|
116
|
-
|
|
117
|
-
const handleLoad = async (save: SaveRecord) => {
|
|
118
|
-
try {
|
|
119
|
-
loadFromPayload(save.payload);
|
|
120
|
-
showStatus('Game loaded');
|
|
121
|
-
setTimeout(onClose, 500);
|
|
122
|
-
} catch {
|
|
123
|
-
showStatus('Failed to load save', 'error');
|
|
124
|
-
}
|
|
125
|
-
};
|
|
126
|
-
|
|
127
|
-
const handleDelete = async (saveId: string) => {
|
|
128
|
-
if (!confirm('Delete this save?')) return;
|
|
129
|
-
try {
|
|
130
|
-
await deleteSaveById(saveId);
|
|
131
|
-
showStatus('Save deleted');
|
|
132
|
-
await refresh();
|
|
133
|
-
} catch {
|
|
134
|
-
showStatus('Failed to delete save', 'error');
|
|
135
|
-
}
|
|
136
|
-
};
|
|
137
|
-
|
|
138
|
-
const handleRenameStart = (save: SaveRecord) => {
|
|
139
|
-
setRenamingId(save.meta.id);
|
|
140
|
-
setRenameValue(save.meta.title);
|
|
141
|
-
};
|
|
142
|
-
|
|
143
|
-
const handleRenameConfirm = async () => {
|
|
144
|
-
if (!renamingId || !renameValue.trim()) return;
|
|
145
|
-
try {
|
|
146
|
-
await renameSave(renamingId, renameValue.trim());
|
|
147
|
-
setRenamingId(null);
|
|
148
|
-
showStatus('Save renamed');
|
|
149
|
-
await refresh();
|
|
150
|
-
} catch {
|
|
151
|
-
showStatus('Failed to rename', 'error');
|
|
152
|
-
}
|
|
153
|
-
};
|
|
154
|
-
|
|
155
|
-
const handleRenameKeyDown = (e: KeyboardEvent) => {
|
|
156
|
-
if (e.key === 'Enter') handleRenameConfirm();
|
|
157
|
-
else if (e.key === 'Escape') setRenamingId(null);
|
|
158
|
-
};
|
|
159
|
-
|
|
160
|
-
const handleExport = async (saveId: string) => {
|
|
161
|
-
try {
|
|
162
|
-
const data = await exportSave(saveId);
|
|
163
|
-
if (!data) return;
|
|
164
|
-
const blob = new Blob([JSON.stringify(data, null, 2)], {
|
|
165
|
-
type: 'application/json',
|
|
166
|
-
});
|
|
167
|
-
const url = URL.createObjectURL(blob);
|
|
168
|
-
const a = document.createElement('a');
|
|
169
|
-
a.href = url;
|
|
170
|
-
a.download = `save-${data.save.meta.title.replace(/[^a-z0-9]/gi, '_')}.json`;
|
|
171
|
-
a.click();
|
|
172
|
-
URL.revokeObjectURL(url);
|
|
173
|
-
showStatus('Save exported');
|
|
174
|
-
} catch {
|
|
175
|
-
showStatus('Failed to export save', 'error');
|
|
176
|
-
}
|
|
177
|
-
};
|
|
178
|
-
|
|
179
|
-
const handleImport = () => {
|
|
180
|
-
fileInputRef.current?.click();
|
|
181
|
-
};
|
|
182
|
-
|
|
183
|
-
const handleFileSelected = async (e: Event) => {
|
|
184
|
-
const input = e.target as HTMLInputElement;
|
|
185
|
-
const file = input.files?.[0];
|
|
186
|
-
if (!file) return;
|
|
187
|
-
input.value = '';
|
|
188
|
-
|
|
189
|
-
try {
|
|
190
|
-
const text = await file.text();
|
|
191
|
-
const data = JSON.parse(text);
|
|
192
|
-
if (!isSaveExport(data)) {
|
|
193
|
-
throw new Error('Invalid save file format');
|
|
194
|
-
}
|
|
195
|
-
await importSave(data, ifid);
|
|
196
|
-
showStatus('Save imported');
|
|
197
|
-
await refresh();
|
|
198
|
-
} catch (err) {
|
|
199
|
-
showStatus(
|
|
200
|
-
err instanceof Error ? err.message : 'Failed to import save',
|
|
201
|
-
'error',
|
|
202
|
-
);
|
|
203
|
-
}
|
|
204
|
-
};
|
|
205
|
-
|
|
206
|
-
const handleBackdrop = (e: MouseEvent) => {
|
|
207
|
-
if ((e.target as HTMLElement).classList.contains('saves-overlay')) {
|
|
208
|
-
onClose();
|
|
209
|
-
}
|
|
210
|
-
};
|
|
211
|
-
|
|
212
|
-
const totalSaves = groups.reduce((n, g) => n + g.saves.length, 0);
|
|
213
|
-
|
|
214
|
-
return (
|
|
215
|
-
<div
|
|
216
|
-
class="saves-overlay"
|
|
217
|
-
onClick={handleBackdrop}
|
|
218
|
-
>
|
|
219
|
-
<div class="saves-panel">
|
|
220
|
-
<div class="saves-header">
|
|
221
|
-
<div class="saves-header-left">
|
|
222
|
-
<div class="saves-mode-toggle">
|
|
223
|
-
<button
|
|
224
|
-
class={mode === 'save' ? 'active' : ''}
|
|
225
|
-
onClick={() => setMode('save')}
|
|
226
|
-
>
|
|
227
|
-
Save
|
|
228
|
-
</button>
|
|
229
|
-
<button
|
|
230
|
-
class={mode === 'load' ? 'active' : ''}
|
|
231
|
-
onClick={() => setMode('load')}
|
|
232
|
-
>
|
|
233
|
-
Load
|
|
234
|
-
</button>
|
|
235
|
-
</div>
|
|
236
|
-
</div>
|
|
237
|
-
<button
|
|
238
|
-
class="saves-close"
|
|
239
|
-
onClick={onClose}
|
|
240
|
-
>
|
|
241
|
-
✕
|
|
242
|
-
</button>
|
|
243
|
-
</div>
|
|
244
|
-
|
|
245
|
-
<div class="saves-toolbar">
|
|
246
|
-
<button
|
|
247
|
-
class="saves-toolbar-button"
|
|
248
|
-
onClick={handleImport}
|
|
249
|
-
>
|
|
250
|
-
Import
|
|
251
|
-
</button>
|
|
252
|
-
<input
|
|
253
|
-
ref={fileInputRef}
|
|
254
|
-
type="file"
|
|
255
|
-
accept=".json"
|
|
256
|
-
style="display:none"
|
|
257
|
-
onChange={handleFileSelected}
|
|
258
|
-
/>
|
|
259
|
-
</div>
|
|
260
|
-
|
|
261
|
-
<div class="saves-body">
|
|
262
|
-
{loading ? (
|
|
263
|
-
<div class="saves-empty">Loading...</div>
|
|
264
|
-
) : totalSaves === 0 && mode === 'load' ? (
|
|
265
|
-
<div class="saves-empty">No saves yet</div>
|
|
266
|
-
) : (
|
|
267
|
-
groups.map((group) => {
|
|
268
|
-
const isCollapsed = collapsed.has(group.playthrough.id);
|
|
269
|
-
const isCurrentPt = group.playthrough.id === playthroughId;
|
|
270
|
-
|
|
271
|
-
// Save mode: only show current playthrough
|
|
272
|
-
// Load mode: hide empty non-current playthroughs (irrecoverable)
|
|
273
|
-
if (mode === 'save' && !isCurrentPt) return null;
|
|
274
|
-
if (mode === 'load' && group.saves.length === 0 && !isCurrentPt)
|
|
275
|
-
return null;
|
|
276
|
-
|
|
277
|
-
return (
|
|
278
|
-
<div
|
|
279
|
-
class="playthrough-group"
|
|
280
|
-
key={group.playthrough.id}
|
|
281
|
-
>
|
|
282
|
-
<div
|
|
283
|
-
class="playthrough-header"
|
|
284
|
-
onClick={() => toggleCollapse(group.playthrough.id)}
|
|
285
|
-
>
|
|
286
|
-
<span
|
|
287
|
-
class={`playthrough-chevron ${isCollapsed ? '' : 'open'}`}
|
|
288
|
-
>
|
|
289
|
-
▶
|
|
290
|
-
</span>
|
|
291
|
-
<span class="playthrough-label">
|
|
292
|
-
{group.playthrough.label}
|
|
293
|
-
{isCurrentPt ? ' (current)' : ''}
|
|
294
|
-
</span>
|
|
295
|
-
<span class="playthrough-date">
|
|
296
|
-
{formatDate(group.playthrough.createdAt)}
|
|
297
|
-
</span>
|
|
298
|
-
</div>
|
|
299
|
-
|
|
300
|
-
{!isCollapsed && (
|
|
301
|
-
<div class="playthrough-saves">
|
|
302
|
-
{group.saves.map((save) => (
|
|
303
|
-
<div
|
|
304
|
-
class="save-slot"
|
|
305
|
-
key={save.meta.id}
|
|
306
|
-
>
|
|
307
|
-
<div class="save-slot-info">
|
|
308
|
-
{renamingId === save.meta.id ? (
|
|
309
|
-
<input
|
|
310
|
-
ref={renameInputRef}
|
|
311
|
-
class="save-rename-input"
|
|
312
|
-
value={renameValue}
|
|
313
|
-
onInput={(e) =>
|
|
314
|
-
setRenameValue(
|
|
315
|
-
(e.target as HTMLInputElement).value,
|
|
316
|
-
)
|
|
317
|
-
}
|
|
318
|
-
onKeyDown={handleRenameKeyDown}
|
|
319
|
-
onBlur={handleRenameConfirm}
|
|
320
|
-
/>
|
|
321
|
-
) : (
|
|
322
|
-
<div class="save-slot-title">
|
|
323
|
-
{save.meta.title}
|
|
324
|
-
</div>
|
|
325
|
-
)}
|
|
326
|
-
<div class="save-slot-meta">
|
|
327
|
-
<span>{save.meta.passage}</span>
|
|
328
|
-
<span>{relativeTime(save.meta.updatedAt)}</span>
|
|
329
|
-
</div>
|
|
330
|
-
</div>
|
|
331
|
-
<div class="save-slot-actions">
|
|
332
|
-
{mode === 'save' ? (
|
|
333
|
-
<button
|
|
334
|
-
class="save-slot-action primary"
|
|
335
|
-
onClick={() => handleOverwrite(save.meta.id)}
|
|
336
|
-
>
|
|
337
|
-
Save Here
|
|
338
|
-
</button>
|
|
339
|
-
) : (
|
|
340
|
-
<button
|
|
341
|
-
class="save-slot-action primary"
|
|
342
|
-
onClick={() => handleLoad(save)}
|
|
343
|
-
>
|
|
344
|
-
Load
|
|
345
|
-
</button>
|
|
346
|
-
)}
|
|
347
|
-
<button
|
|
348
|
-
class="save-slot-action"
|
|
349
|
-
onClick={() => handleRenameStart(save)}
|
|
350
|
-
>
|
|
351
|
-
Rename
|
|
352
|
-
</button>
|
|
353
|
-
<button
|
|
354
|
-
class="save-slot-action"
|
|
355
|
-
onClick={() => handleExport(save.meta.id)}
|
|
356
|
-
>
|
|
357
|
-
Export
|
|
358
|
-
</button>
|
|
359
|
-
<button
|
|
360
|
-
class="save-slot-action danger"
|
|
361
|
-
onClick={() => handleDelete(save.meta.id)}
|
|
362
|
-
>
|
|
363
|
-
Delete
|
|
364
|
-
</button>
|
|
365
|
-
</div>
|
|
366
|
-
</div>
|
|
367
|
-
))}
|
|
368
|
-
|
|
369
|
-
{mode === 'save' && isCurrentPt && (
|
|
370
|
-
<button
|
|
371
|
-
class="save-slot-new"
|
|
372
|
-
onClick={handleNewSave}
|
|
373
|
-
>
|
|
374
|
-
+ New Save
|
|
375
|
-
</button>
|
|
376
|
-
)}
|
|
377
|
-
</div>
|
|
378
|
-
)}
|
|
379
|
-
</div>
|
|
380
|
-
);
|
|
381
|
-
})
|
|
382
|
-
)}
|
|
383
|
-
|
|
384
|
-
{mode === 'save' &&
|
|
385
|
-
!loading &&
|
|
386
|
-
!groups.some((g) => g.playthrough.id === playthroughId) && (
|
|
387
|
-
<div class="playthrough-group">
|
|
388
|
-
<div class="playthrough-saves">
|
|
389
|
-
<button
|
|
390
|
-
class="save-slot-new"
|
|
391
|
-
onClick={handleNewSave}
|
|
392
|
-
>
|
|
393
|
-
+ New Save
|
|
394
|
-
</button>
|
|
395
|
-
</div>
|
|
396
|
-
</div>
|
|
397
|
-
)}
|
|
398
|
-
</div>
|
|
399
|
-
|
|
400
|
-
{status && (
|
|
401
|
-
<div class={`saves-status ${status.type === 'error' ? 'error' : ''}`}>
|
|
402
|
-
{status.text}
|
|
403
|
-
</div>
|
|
404
|
-
)}
|
|
405
|
-
</div>
|
|
406
|
-
</div>
|
|
407
|
-
);
|
|
408
|
-
}
|
|
@@ -1,32 +0,0 @@
|
|
|
1
|
-
import { useStoryStore } from '../../store';
|
|
2
|
-
import { useAction } from '../../hooks/use-action';
|
|
3
|
-
import { registerMacro } from '../../registry';
|
|
4
|
-
import type { MacroProps } from '../../registry';
|
|
5
|
-
|
|
6
|
-
export function Back({ className, id }: MacroProps) {
|
|
7
|
-
const goBack = useStoryStore((s) => s.goBack);
|
|
8
|
-
const canGoBack = useStoryStore((s) => s.historyIndex > 0);
|
|
9
|
-
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
10
|
-
|
|
11
|
-
useAction({
|
|
12
|
-
type: 'back',
|
|
13
|
-
key: 'back',
|
|
14
|
-
authorId: id,
|
|
15
|
-
label: 'Back',
|
|
16
|
-
disabled: !canGoBack,
|
|
17
|
-
perform: () => goBack(),
|
|
18
|
-
});
|
|
19
|
-
|
|
20
|
-
return (
|
|
21
|
-
<button
|
|
22
|
-
id={id}
|
|
23
|
-
class={cls}
|
|
24
|
-
onClick={goBack}
|
|
25
|
-
disabled={!canGoBack}
|
|
26
|
-
>
|
|
27
|
-
← Back
|
|
28
|
-
</button>
|
|
29
|
-
);
|
|
30
|
-
}
|
|
31
|
-
|
|
32
|
-
registerMacro('back', Back);
|
|
@@ -1,34 +0,0 @@
|
|
|
1
|
-
import { useStoryStore } from '../../store';
|
|
2
|
-
import { useAction } from '../../hooks/use-action';
|
|
3
|
-
import { registerMacro } from '../../registry';
|
|
4
|
-
import type { MacroProps } from '../../registry';
|
|
5
|
-
|
|
6
|
-
export function Forward({ className, id }: MacroProps) {
|
|
7
|
-
const goForward = useStoryStore((s) => s.goForward);
|
|
8
|
-
const canGoForward = useStoryStore(
|
|
9
|
-
(s) => s.historyIndex < s.history.length - 1,
|
|
10
|
-
);
|
|
11
|
-
const cls = className ? `menubar-button ${className}` : 'menubar-button';
|
|
12
|
-
|
|
13
|
-
useAction({
|
|
14
|
-
type: 'forward',
|
|
15
|
-
key: 'forward',
|
|
16
|
-
authorId: id,
|
|
17
|
-
label: 'Forward',
|
|
18
|
-
disabled: !canGoForward,
|
|
19
|
-
perform: () => goForward(),
|
|
20
|
-
});
|
|
21
|
-
|
|
22
|
-
return (
|
|
23
|
-
<button
|
|
24
|
-
id={id}
|
|
25
|
-
class={cls}
|
|
26
|
-
onClick={goForward}
|
|
27
|
-
disabled={!canGoForward}
|
|
28
|
-
>
|
|
29
|
-
Forward →
|
|
30
|
-
</button>
|
|
31
|
-
);
|
|
32
|
-
}
|
|
33
|
-
|
|
34
|
-
registerMacro('forward', Forward);
|