@omercnet/paseo-queens 0.1.0-next.126.2

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.
@@ -0,0 +1,349 @@
1
+ import type { PluginButtonContentProps } from "@getpaseo/plugin/client";
2
+ import { useEffect, useMemo } from "react";
3
+ import { ActivityIndicator, Pressable, StyleSheet, Text, View } from "react-native";
4
+ import { CompletionFeedback } from "./completion-feedback";
5
+ import { findConflicts } from "./game";
6
+ import type { CuratedPuzzleDeck } from "./game/curated";
7
+ import { GameControls } from "./game-controls";
8
+ import { GameMark } from "./game-mark";
9
+ import { PuzzleSelector } from "./puzzle-selector";
10
+ import { QueensBoard } from "./queens-board";
11
+ import { usePersistedGame } from "./use-persisted-game";
12
+ import { type PuzzleCatalogState, usePuzzleCatalog } from "./use-puzzle-catalog";
13
+
14
+ const IGNORE_GESTURE = () => {};
15
+
16
+ function formatElapsed(elapsedMs: number): string {
17
+ const totalSeconds = Math.max(0, Math.floor(elapsedMs / 1_000));
18
+ const minutes = Math.floor(totalSeconds / 60);
19
+ const seconds = totalSeconds % 60;
20
+ return `${String(minutes).padStart(2, "0")}:${String(seconds).padStart(2, "0")}`;
21
+ }
22
+
23
+ export function QueenPillIcon({ size, color }: { size: number; color: string }) {
24
+ return <GameMark size={size} color={color} />;
25
+ }
26
+
27
+ export function QueensPopover(props: PluginButtonContentProps) {
28
+ const catalog = usePuzzleCatalog();
29
+ if (!catalog.deck) {
30
+ return (
31
+ <View style={{ minHeight: 180, alignItems: "center", justifyContent: "center", gap: 10 }}>
32
+ {catalog.loading ? <ActivityIndicator color={props.theme.colors.accent} /> : null}
33
+ <Text
34
+ style={{
35
+ color: catalog.error
36
+ ? props.theme.colors.statusDanger
37
+ : props.theme.colors.foregroundMuted,
38
+ }}
39
+ >
40
+ {catalog.error ?? "Loading curated puzzles…"}
41
+ </Text>
42
+ {catalog.error ? (
43
+ <Pressable
44
+ accessibilityRole="button"
45
+ accessibilityLabel="Retry puzzle download"
46
+ onPress={catalog.retry}
47
+ style={{
48
+ minHeight: 40,
49
+ justifyContent: "center",
50
+ paddingHorizontal: 16,
51
+ borderWidth: StyleSheet.hairlineWidth,
52
+ borderColor: props.theme.colors.border,
53
+ borderRadius: 8,
54
+ backgroundColor: props.theme.colors.surface2,
55
+ }}
56
+ >
57
+ <Text style={{ color: props.theme.colors.foreground, fontWeight: "700" }}>Retry</Text>
58
+ </Pressable>
59
+ ) : null}
60
+ </View>
61
+ );
62
+ }
63
+ return (
64
+ <QueensPopoverGame
65
+ key={`${props.host.id}:${catalog.deck.key}`}
66
+ {...props}
67
+ deck={catalog.deck}
68
+ catalog={catalog}
69
+ />
70
+ );
71
+ }
72
+
73
+ function QueensPopoverGame({
74
+ theme,
75
+ host,
76
+ layout,
77
+ close,
78
+ deck,
79
+ catalog,
80
+ }: PluginButtonContentProps & {
81
+ readonly deck: CuratedPuzzleDeck;
82
+ readonly catalog: PuzzleCatalogState;
83
+ }) {
84
+ const session = usePersistedGame(`${host.id}:${deck.key}`, deck.puzzles, deck.solutions);
85
+ const styles = useMemo(() => createStyles(theme, layout.compact), [layout.compact, theme]);
86
+ const state = session.status === "ready" ? session.state : null;
87
+ const puzzle = state?.puzzles[state.activePuzzleIndex];
88
+ const progress = state?.progress[state.activePuzzleIndex];
89
+ const timerRunning = progress?.timer.startedAt !== null && progress?.timer.completedAt === null;
90
+
91
+ useEffect(() => {
92
+ if (session.status !== "ready" || !timerRunning) return;
93
+ const interval = setInterval(() => {
94
+ session.dispatch({ type: "tick", now: Date.now() });
95
+ }, 1_000);
96
+ return () => clearInterval(interval);
97
+ }, [session, timerRunning]);
98
+
99
+ if (session.status === "loading") {
100
+ return (
101
+ <View style={styles.stateCard}>
102
+ <ActivityIndicator color={theme.colors.accent} />
103
+ <Text style={styles.stateText}>Loading Queens…</Text>
104
+ </View>
105
+ );
106
+ }
107
+
108
+ if (session.status !== "ready" || !state || !puzzle || !progress) {
109
+ const message =
110
+ session.status === "error" || session.status === "invalid"
111
+ ? session.error
112
+ : "The selected puzzle is unavailable.";
113
+ return (
114
+ <View style={styles.stateCard}>
115
+ <Text accessibilityRole="alert" style={styles.errorText}>
116
+ {message}
117
+ </Text>
118
+ <Pressable
119
+ accessibilityRole="button"
120
+ onPress={() => void session.reload()}
121
+ style={styles.doneButton}
122
+ >
123
+ <Text style={styles.doneText}>Reload</Text>
124
+ </Pressable>
125
+ </View>
126
+ );
127
+ }
128
+
129
+ const conflicts = findConflicts(puzzle, progress.cells);
130
+ const markedCount = progress.cells.reduce(
131
+ (count, cell) => count + (cell === "marked" ? 1 : 0),
132
+ 0,
133
+ );
134
+ const hasProgress =
135
+ progress.timer.startedAt !== null || progress.cells.some((cell) => cell !== "empty");
136
+ const statusText = progress.solved
137
+ ? "Solved"
138
+ : conflicts.size > 0
139
+ ? `${conflicts.size} conflicting ${conflicts.size === 1 ? "square" : "squares"}`
140
+ : `${markedCount} of ${puzzle.size} queens`;
141
+ const statusColor = progress.solved
142
+ ? theme.colors.statusSuccess
143
+ : conflicts.size > 0
144
+ ? theme.colors.statusDanger
145
+ : theme.colors.foregroundMuted;
146
+ const puzzleNumber = state.activePuzzleIndex + 1;
147
+
148
+ return (
149
+ <View style={styles.root}>
150
+ {!layout.compact ? (
151
+ <View style={styles.header}>
152
+ <View style={styles.titleRow}>
153
+ <GameMark size={22} color={theme.colors.accent} />
154
+ <Text accessibilityRole="header" style={styles.title}>
155
+ Queens
156
+ </Text>
157
+ </View>
158
+ <Pressable
159
+ accessibilityRole="button"
160
+ accessibilityLabel="Close Queens"
161
+ onPress={close}
162
+ style={styles.doneButton}
163
+ >
164
+ <Text style={styles.doneText}>Done</Text>
165
+ </Pressable>
166
+ </View>
167
+ ) : null}
168
+
169
+ <View style={styles.metaRow}>
170
+ <Text style={styles.metaText}>
171
+ #{puzzleNumber}/{state.puzzles.length}
172
+ </Text>
173
+ <Text
174
+ accessibilityLabel={`Elapsed time ${formatElapsed(progress.timer.elapsedMs)}`}
175
+ style={styles.timerText}
176
+ >
177
+ {formatElapsed(progress.timer.elapsedMs)}
178
+ </Text>
179
+ <Text style={styles.metaText}>{session.saving ? "Saving…" : "Saved"}</Text>
180
+ </View>
181
+
182
+ <PuzzleSelector
183
+ size={catalog.size}
184
+ difficulty={catalog.difficulty}
185
+ disabled={catalog.loading || session.saving}
186
+ compact
187
+ theme={theme}
188
+ onChange={(size, difficulty) => {
189
+ void catalog.select(size, difficulty);
190
+ }}
191
+ />
192
+ {catalog.error ? (
193
+ <Text accessibilityRole="alert" style={styles.errorText}>
194
+ {catalog.error}
195
+ </Text>
196
+ ) : null}
197
+
198
+ <CompletionFeedback
199
+ solved={progress.solved}
200
+ color={theme.colors.statusSuccess}
201
+ style={styles.boardFrame}
202
+ >
203
+ <QueensBoard
204
+ key={`${host.id}:${puzzle.id}:popover`}
205
+ dragEnabled={!layout.compact}
206
+ maxSize={layout.compact ? 296 : 184}
207
+ puzzle={puzzle}
208
+ cells={progress.cells}
209
+ conflicts={conflicts}
210
+ solved={progress.solved}
211
+ disabled={progress.solved || session.saving}
212
+ compact
213
+ theme={theme}
214
+ onGestureActiveChange={IGNORE_GESTURE}
215
+ onSetCells={(indexes, cellState) => {
216
+ session.dispatch({ type: "set-cells", indexes, state: cellState, now: Date.now() });
217
+ }}
218
+ />
219
+ </CompletionFeedback>
220
+
221
+ <View style={styles.statusCard} accessibilityLiveRegion="polite">
222
+ <View style={[styles.statusDot, { backgroundColor: statusColor }]} />
223
+ <Text style={[styles.statusText, { color: statusColor }]}>{statusText}</Text>
224
+ </View>
225
+
226
+ <GameControls
227
+ canUndo={progress.history.length > 0}
228
+ canHint={!progress.solved}
229
+ canReset={hasProgress}
230
+ canGoPrevious={false}
231
+ canGoNext={false}
232
+ saving={session.saving}
233
+ disabled={session.saving}
234
+ dense
235
+ showNavigation={false}
236
+ showSavingStatus={false}
237
+ theme={theme}
238
+ onUndo={() => session.dispatch({ type: "undo", now: Date.now() })}
239
+ onHint={() => session.dispatch({ type: "hint", now: Date.now() })}
240
+ onReset={() => session.dispatch({ type: "reset", now: Date.now() })}
241
+ onPrevious={IGNORE_GESTURE}
242
+ onNext={IGNORE_GESTURE}
243
+ />
244
+ </View>
245
+ );
246
+ }
247
+
248
+ function createStyles(theme: PluginButtonContentProps["theme"], compact: boolean) {
249
+ return StyleSheet.create({
250
+ root: {
251
+ width: compact ? "100%" : 300,
252
+ alignSelf: "center",
253
+ alignItems: "center",
254
+ gap: compact ? 12 : 6,
255
+ },
256
+ header: {
257
+ width: "100%",
258
+ flexDirection: "row",
259
+ alignItems: "center",
260
+ justifyContent: "space-between",
261
+ gap: 12,
262
+ },
263
+ titleRow: {
264
+ flexDirection: "row",
265
+ alignItems: "center",
266
+ gap: 8,
267
+ },
268
+ title: {
269
+ color: theme.colors.foreground,
270
+ fontSize: 18,
271
+ fontWeight: "800",
272
+ },
273
+ doneButton: {
274
+ minHeight: 36,
275
+ justifyContent: "center",
276
+ paddingHorizontal: 12,
277
+ borderWidth: StyleSheet.hairlineWidth,
278
+ borderColor: theme.colors.border,
279
+ borderRadius: 8,
280
+ backgroundColor: theme.colors.surface2,
281
+ },
282
+ doneText: {
283
+ color: theme.colors.foreground,
284
+ fontSize: 12,
285
+ fontWeight: "700",
286
+ },
287
+ metaRow: {
288
+ flexDirection: "row",
289
+ alignItems: "center",
290
+ justifyContent: "center",
291
+ gap: 14,
292
+ },
293
+ metaText: {
294
+ color: theme.colors.foregroundMuted,
295
+ fontSize: 11,
296
+ fontWeight: "700",
297
+ },
298
+ timerText: {
299
+ color: theme.colors.foreground,
300
+ fontSize: 12,
301
+ fontWeight: "800",
302
+ fontVariant: ["tabular-nums"],
303
+ },
304
+ boardFrame: {
305
+ width: "100%",
306
+ maxWidth: compact ? 296 : 184,
307
+ },
308
+ statusCard: {
309
+ minHeight: 30,
310
+ flexDirection: "row",
311
+ alignItems: "center",
312
+ justifyContent: "center",
313
+ gap: 7,
314
+ paddingHorizontal: 12,
315
+ paddingVertical: 6,
316
+ borderWidth: StyleSheet.hairlineWidth,
317
+ borderColor: theme.colors.border,
318
+ borderRadius: 999,
319
+ backgroundColor: theme.colors.surface1,
320
+ },
321
+ statusDot: {
322
+ width: 7,
323
+ height: 7,
324
+ borderRadius: 4,
325
+ },
326
+ statusText: {
327
+ fontSize: 11,
328
+ fontWeight: "700",
329
+ },
330
+ stateCard: {
331
+ width: compact ? "100%" : 252,
332
+ minHeight: 180,
333
+ alignItems: "center",
334
+ justifyContent: "center",
335
+ gap: 12,
336
+ padding: 20,
337
+ },
338
+ stateText: {
339
+ color: theme.colors.foregroundMuted,
340
+ fontSize: 13,
341
+ },
342
+ errorText: {
343
+ color: theme.colors.statusDanger,
344
+ fontSize: 12,
345
+ lineHeight: 18,
346
+ textAlign: "center",
347
+ },
348
+ });
349
+ }