@spooky-sync/client-solid 0.0.1-canary.33 → 0.0.1-canary.330
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/AGENTS.md +70 -0
- package/README.md +20 -0
- package/dist/index.cjs +685 -111
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +405 -9
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +405 -9
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +671 -114
- package/dist/index.js.map +1 -1
- package/package.json +6 -5
- package/skills/sp00ky-solid/SKILL.md +72 -1
- package/skills/sp00ky-solid/references/file-hooks.md +33 -4
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +200 -19
- package/src/lib/Blurhash.ts +54 -0
- package/src/lib/BucketImage.ts +141 -0
- package/src/lib/Sp00kyProvider.ts +50 -1
- package/src/lib/create-preload.ts +112 -0
- package/src/lib/models.ts +1 -1
- package/src/lib/use-app-release.ts +89 -0
- package/src/lib/use-blurhash.ts +77 -0
- package/src/lib/use-bucket-image.ts +100 -0
- package/src/lib/use-crdt-field.ts +65 -0
- package/src/lib/use-download-file.ts +66 -110
- package/src/lib/use-feature-flag.ts +50 -0
- package/src/lib/use-file-upload.ts +10 -4
- package/src/lib/use-impersonation.ts +44 -0
- package/src/lib/use-query.ts +184 -20
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +1 -2
package/src/index.ts
CHANGED
|
@@ -1,14 +1,19 @@
|
|
|
1
1
|
import type { SyncedDbConfig } from './types';
|
|
2
2
|
import {
|
|
3
3
|
Sp00kyClient,
|
|
4
|
-
AuthService,
|
|
5
|
-
BucketHandle,
|
|
6
4
|
type Sp00kyQueryResultPromise,
|
|
7
|
-
|
|
8
|
-
|
|
5
|
+
type AuthService,
|
|
6
|
+
type BucketHandle,
|
|
7
|
+
type UpdateOptions,
|
|
8
|
+
type RunOptions,
|
|
9
|
+
type SyncHealth,
|
|
10
|
+
type StorageHealth,
|
|
11
|
+
type PreloadOptions,
|
|
12
|
+
type PreloadRefresh,
|
|
13
|
+
type WebPushModule,
|
|
9
14
|
} from '@spooky-sync/core';
|
|
10
15
|
|
|
11
|
-
import {
|
|
16
|
+
import type {
|
|
12
17
|
GetTable,
|
|
13
18
|
QueryBuilder,
|
|
14
19
|
SchemaStructure,
|
|
@@ -19,25 +24,89 @@ import {
|
|
|
19
24
|
RelationshipFieldsFromSchema,
|
|
20
25
|
GetRelationship,
|
|
21
26
|
RelatedFieldMapEntry,
|
|
27
|
+
FinalQuery,
|
|
22
28
|
InnerQuery,
|
|
23
29
|
BackendNames,
|
|
24
30
|
BackendRoutes,
|
|
25
31
|
RoutePayload,
|
|
26
32
|
BucketNames,
|
|
27
33
|
BucketDefinitionSchema,
|
|
34
|
+
QueryModifier,
|
|
35
|
+
QueryModifierBuilder,
|
|
36
|
+
QueryInfo,
|
|
37
|
+
RelationshipsMetadata,
|
|
38
|
+
RelationshipDefinition,
|
|
39
|
+
InferRelatedModelFromMetadata,
|
|
40
|
+
GetCardinality,
|
|
28
41
|
} from '@spooky-sync/query-builder';
|
|
29
42
|
|
|
30
|
-
import { RecordId, Uuid, Surreal } from 'surrealdb';
|
|
43
|
+
import { RecordId, Uuid, type Surreal } from 'surrealdb';
|
|
31
44
|
export { RecordId, Uuid };
|
|
32
45
|
export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
|
|
33
46
|
export { useQuery } from './lib/use-query';
|
|
47
|
+
export { createPreload } from './lib/create-preload';
|
|
48
|
+
export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
|
|
49
|
+
export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
|
|
50
|
+
export {
|
|
51
|
+
useImpersonation,
|
|
52
|
+
type UseImpersonation,
|
|
53
|
+
type UseImpersonationOptions,
|
|
54
|
+
} from './lib/use-impersonation';
|
|
55
|
+
export type {
|
|
56
|
+
SyncHealth,
|
|
57
|
+
SyncHealthStatus,
|
|
58
|
+
SyncHealthConfig,
|
|
59
|
+
ConnectionState,
|
|
60
|
+
ReconnectConfig,
|
|
61
|
+
} from '@spooky-sync/core';
|
|
62
|
+
export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
|
|
63
|
+
export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
|
|
64
|
+
export {
|
|
65
|
+
WebPushError,
|
|
66
|
+
type WebPushModule,
|
|
67
|
+
type WebPushConfig,
|
|
68
|
+
type WebPushSupport,
|
|
69
|
+
type WebPushUnsupportedReason,
|
|
70
|
+
type WebPushInfo,
|
|
71
|
+
type WebPushDevice,
|
|
72
|
+
type PushDeviceKind,
|
|
73
|
+
type WebPushSubscribeOptions,
|
|
74
|
+
type WebPushUnsubscribeOptions,
|
|
75
|
+
type WebPushSyncOptions,
|
|
76
|
+
type WebPushSyncStatus,
|
|
77
|
+
type WebPushUpdateOptions,
|
|
78
|
+
type WebPushMessageInput,
|
|
79
|
+
type WebPushMessage,
|
|
80
|
+
type WebPushErrorCode,
|
|
81
|
+
type WebPushClientMessage,
|
|
82
|
+
type PushPayload,
|
|
83
|
+
type PushNotification,
|
|
84
|
+
} from '@spooky-sync/core';
|
|
85
|
+
export { useCrdtField } from './lib/use-crdt-field';
|
|
86
|
+
export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
|
|
87
|
+
export {
|
|
88
|
+
useAppRelease,
|
|
89
|
+
type UseAppRelease,
|
|
90
|
+
type UseAppReleaseOptions,
|
|
91
|
+
} from './lib/use-app-release';
|
|
34
92
|
export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
|
|
35
|
-
export {
|
|
93
|
+
export {
|
|
94
|
+
useDownloadFile,
|
|
95
|
+
type UseDownloadFileOptions,
|
|
96
|
+
type UseDownloadFileResult,
|
|
97
|
+
} from './lib/use-download-file';
|
|
98
|
+
export { useBlurhash, type UseBlurhashResult } from './lib/use-blurhash';
|
|
99
|
+
export {
|
|
100
|
+
useBucketImage,
|
|
101
|
+
type UseBucketImageOptions,
|
|
102
|
+
type UseBucketImageResult,
|
|
103
|
+
} from './lib/use-bucket-image';
|
|
104
|
+
export { Blurhash, type BlurhashProps } from './lib/Blurhash';
|
|
105
|
+
export { BucketImage, type BucketImageProps } from './lib/BucketImage';
|
|
36
106
|
export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
|
|
37
107
|
export { useDb } from './lib/context';
|
|
38
108
|
|
|
39
109
|
// export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
|
|
40
|
-
export type {};
|
|
41
110
|
|
|
42
111
|
// Re-export query builder types for convenience
|
|
43
112
|
export type {
|
|
@@ -52,7 +121,7 @@ export type {
|
|
|
52
121
|
TableModel,
|
|
53
122
|
TableNames,
|
|
54
123
|
QueryResult,
|
|
55
|
-
}
|
|
124
|
+
};
|
|
56
125
|
|
|
57
126
|
export type RelationshipField<
|
|
58
127
|
Schema extends SchemaStructure,
|
|
@@ -121,6 +190,19 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
121
190
|
this._initialized = true;
|
|
122
191
|
}
|
|
123
192
|
|
|
193
|
+
/**
|
|
194
|
+
* Tear down the client: leaves the tabs broker, closes the local store and
|
|
195
|
+
* remote socket, and frees the wasm circuit. Without this a remounted provider
|
|
196
|
+
* (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
|
|
197
|
+
* resident because V8 cannot see how much wasm memory a dropped wrapper holds.
|
|
198
|
+
*/
|
|
199
|
+
async close(): Promise<void> {
|
|
200
|
+
const instance = this.sp00ky;
|
|
201
|
+
this.sp00ky = null;
|
|
202
|
+
this._initialized = false;
|
|
203
|
+
if (instance) await instance.close();
|
|
204
|
+
}
|
|
205
|
+
|
|
124
206
|
/**
|
|
125
207
|
* Create a new record in the database
|
|
126
208
|
*/
|
|
@@ -152,12 +234,39 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
152
234
|
*/
|
|
153
235
|
async delete<TName extends TableNames<S>>(
|
|
154
236
|
tableName: TName,
|
|
155
|
-
selector: string | InnerQuery<GetTable<S, TName>, boolean>
|
|
237
|
+
selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
|
|
238
|
+
): Promise<void> {
|
|
239
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
240
|
+
// Accept a `"table:id"` string OR a RecordId — live-query rows carry their
|
|
241
|
+
// `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
|
|
242
|
+
// directly. Build the canonical string from the raw id part (not
|
|
243
|
+
// `RecordId.toString()`, which escapes special chars) so it round-trips
|
|
244
|
+
// through the engine's `parseRecordIdString`. InnerQuery selectors are not
|
|
245
|
+
// supported yet. (cross-package RecordId instances → match by constructor name.)
|
|
246
|
+
const isRecordId =
|
|
247
|
+
selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
|
|
248
|
+
let id: string;
|
|
249
|
+
if (typeof selector === 'string') {
|
|
250
|
+
id = selector;
|
|
251
|
+
} else if (isRecordId) {
|
|
252
|
+
id = `${tableName as string}:${(selector as RecordId).id}`;
|
|
253
|
+
} else {
|
|
254
|
+
throw new Error('Only string ID or RecordId selectors are supported currently with core');
|
|
255
|
+
}
|
|
256
|
+
await this.sp00ky.delete(tableName as string, id);
|
|
257
|
+
}
|
|
258
|
+
|
|
259
|
+
/**
|
|
260
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
261
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
262
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
263
|
+
*/
|
|
264
|
+
public async preload(
|
|
265
|
+
finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
|
|
266
|
+
options?: PreloadOptions
|
|
156
267
|
): Promise<void> {
|
|
157
268
|
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
158
|
-
|
|
159
|
-
throw new Error('Only string ID selectors are supported currently with core');
|
|
160
|
-
await this.sp00ky.delete(tableName as string, selector);
|
|
269
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
161
270
|
}
|
|
162
271
|
|
|
163
272
|
/**
|
|
@@ -173,14 +282,11 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
173
282
|
/**
|
|
174
283
|
* Run a backend operation
|
|
175
284
|
*/
|
|
176
|
-
public async run<
|
|
177
|
-
B extends BackendNames<S>,
|
|
178
|
-
R extends BackendRoutes<S, B>,
|
|
179
|
-
>(
|
|
285
|
+
public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
180
286
|
backend: B,
|
|
181
287
|
path: R,
|
|
182
288
|
payload: RoutePayload<S, B, R>,
|
|
183
|
-
options?: RunOptions
|
|
289
|
+
options?: RunOptions
|
|
184
290
|
): Promise<void> {
|
|
185
291
|
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
186
292
|
await this.sp00ky.run(backend, path, payload, options);
|
|
@@ -190,7 +296,7 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
190
296
|
* Authenticate with the database
|
|
191
297
|
*/
|
|
192
298
|
public async authenticate(token: string): Promise<RecordId<string>> {
|
|
193
|
-
|
|
299
|
+
await this.sp00ky?.authenticate(token);
|
|
194
300
|
// Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
|
|
195
301
|
// Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
|
|
196
302
|
// SurrealDB authenticate returns void? or token?
|
|
@@ -206,6 +312,16 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
206
312
|
await this.signOut();
|
|
207
313
|
}
|
|
208
314
|
|
|
315
|
+
/**
|
|
316
|
+
* Tell the client that this app's own impersonation banner is on screen.
|
|
317
|
+
* Only needed with `impersonationBanner: { mode: 'custom' }`; the
|
|
318
|
+
* `useImpersonation({ rendersBanner: true })` hook calls it for you.
|
|
319
|
+
*/
|
|
320
|
+
public acknowledgeImpersonationBanner(): void {
|
|
321
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
322
|
+
this.sp00ky.acknowledgeImpersonationBanner();
|
|
323
|
+
}
|
|
324
|
+
|
|
209
325
|
/**
|
|
210
326
|
* Sign out, clear session and local storage
|
|
211
327
|
*/
|
|
@@ -221,6 +337,23 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
221
337
|
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
222
338
|
return await this.sp00ky.useRemote(fn);
|
|
223
339
|
}
|
|
340
|
+
|
|
341
|
+
/**
|
|
342
|
+
* Run one SurrealQL statement on the server and return its result array.
|
|
343
|
+
* Nothing is cached locally. Prefer this over `useRemote(s => s.query(...))`
|
|
344
|
+
* for a plain read or write: the bare SDK client that callback receives
|
|
345
|
+
* skips the connect gate, so a statement issued while the socket is being
|
|
346
|
+
* rebuilt fails with "Specify a namespace to use" instead of waiting for
|
|
347
|
+
* the session. This one is also bounded by the remote service's
|
|
348
|
+
* concurrency limit and query timeout.
|
|
349
|
+
*/
|
|
350
|
+
public async remoteQuery<T extends unknown[] = unknown[]>(
|
|
351
|
+
sql: string,
|
|
352
|
+
vars?: Record<string, unknown>
|
|
353
|
+
): Promise<T> {
|
|
354
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
355
|
+
return await this.sp00ky.remoteQuery<T>(sql, vars);
|
|
356
|
+
}
|
|
224
357
|
/**
|
|
225
358
|
* Access the remote database service directly
|
|
226
359
|
*/
|
|
@@ -250,11 +383,59 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
250
383
|
return this.sp00ky.pendingMutationCount;
|
|
251
384
|
}
|
|
252
385
|
|
|
386
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
387
|
+
get liveRetryCount(): number {
|
|
388
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
389
|
+
return this.sp00ky.liveRetryCount;
|
|
390
|
+
}
|
|
391
|
+
|
|
253
392
|
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
254
393
|
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
255
394
|
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
256
395
|
}
|
|
257
396
|
|
|
397
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
398
|
+
get syncHealth(): SyncHealth {
|
|
399
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
400
|
+
return this.sp00ky.syncHealth;
|
|
401
|
+
}
|
|
402
|
+
|
|
403
|
+
/**
|
|
404
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
405
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
406
|
+
* components; this is the imperative escape hatch.
|
|
407
|
+
*/
|
|
408
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
409
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
410
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
411
|
+
}
|
|
412
|
+
|
|
413
|
+
/** Current local-store durability snapshot. See {@link useStorageStatus}. */
|
|
414
|
+
get storageHealth(): StorageHealth {
|
|
415
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
416
|
+
return this.sp00ky.storageHealth;
|
|
417
|
+
}
|
|
418
|
+
|
|
419
|
+
/**
|
|
420
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
421
|
+
* and again on change. Prefer the `useStorageStatus` hook in components; this
|
|
422
|
+
* is the imperative escape hatch.
|
|
423
|
+
*/
|
|
424
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
425
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
426
|
+
return this.sp00ky.subscribeToStorageHealth(cb);
|
|
427
|
+
}
|
|
428
|
+
|
|
429
|
+
/**
|
|
430
|
+
* Web Push for this browser: `support()`, `subscribe()` (from a click),
|
|
431
|
+
* `devices()`, `notify()` / `test()` (push to yourself), `bridge()` for the
|
|
432
|
+
* service worker. See the push-notifications docs page.
|
|
433
|
+
*/
|
|
434
|
+
get webPush(): WebPushModule {
|
|
435
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
436
|
+
return this.sp00ky.webPush;
|
|
437
|
+
}
|
|
438
|
+
|
|
258
439
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
259
440
|
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
260
441
|
return this.sp00ky.bucket(name);
|
|
@@ -0,0 +1,54 @@
|
|
|
1
|
+
import { createEffect } from 'solid-js';
|
|
2
|
+
import type { JSX } from 'solid-js';
|
|
3
|
+
import { decodeBlurhash } from '@spooky-sync/core';
|
|
4
|
+
|
|
5
|
+
export interface BlurhashProps {
|
|
6
|
+
/** The blurhash string. Nullish paints nothing (transparent canvas). */
|
|
7
|
+
hash: string | null | undefined;
|
|
8
|
+
/** Decode resolution. 32x32 is plenty: blurhash carries at most 9x9 DCT
|
|
9
|
+
* components, the canvas is meant to be CSS-scaled to fill. */
|
|
10
|
+
width?: number;
|
|
11
|
+
height?: number;
|
|
12
|
+
/** Contrast punch, see the blurhash reference decoder. Defaults to 1. */
|
|
13
|
+
punch?: number;
|
|
14
|
+
class?: string;
|
|
15
|
+
style?: string;
|
|
16
|
+
}
|
|
17
|
+
|
|
18
|
+
/**
|
|
19
|
+
* A blurhash painted onto a canvas, once per hash change. Size the canvas via
|
|
20
|
+
* `class`/`style` (e.g. `absolute inset-0 w-full h-full`); the internal decode
|
|
21
|
+
* resolution stays tiny regardless of the displayed size.
|
|
22
|
+
*/
|
|
23
|
+
export function Blurhash(props: BlurhashProps): JSX.Element {
|
|
24
|
+
if (typeof document === 'undefined') return null;
|
|
25
|
+
const canvas = document.createElement('canvas');
|
|
26
|
+
|
|
27
|
+
createEffect(() => {
|
|
28
|
+
canvas.className = props.class ?? '';
|
|
29
|
+
});
|
|
30
|
+
createEffect(() => {
|
|
31
|
+
canvas.style.cssText = props.style ?? '';
|
|
32
|
+
});
|
|
33
|
+
|
|
34
|
+
createEffect(() => {
|
|
35
|
+
const width = props.width ?? 32;
|
|
36
|
+
const height = props.height ?? 32;
|
|
37
|
+
canvas.width = width;
|
|
38
|
+
canvas.height = height;
|
|
39
|
+
const hash = props.hash;
|
|
40
|
+
if (!hash) return;
|
|
41
|
+
try {
|
|
42
|
+
const pixels = decodeBlurhash(hash, width, height, props.punch ?? 1);
|
|
43
|
+
const ctx = canvas.getContext('2d');
|
|
44
|
+
if (!ctx) return;
|
|
45
|
+
const imageData = ctx.createImageData(width, height);
|
|
46
|
+
imageData.data.set(pixels);
|
|
47
|
+
ctx.putImageData(imageData, 0, 0);
|
|
48
|
+
} catch {
|
|
49
|
+
// An invalid hash paints nothing; the layer below stays visible.
|
|
50
|
+
}
|
|
51
|
+
});
|
|
52
|
+
|
|
53
|
+
return canvas;
|
|
54
|
+
}
|
|
@@ -0,0 +1,141 @@
|
|
|
1
|
+
import { createSignal, createEffect, onMount, onCleanup, children } from 'solid-js';
|
|
2
|
+
import type { JSX } from 'solid-js';
|
|
3
|
+
import type { BucketNames, SchemaStructure } from '@spooky-sync/query-builder';
|
|
4
|
+
import { useBucketImage, type UseBucketImageOptions } from './use-bucket-image';
|
|
5
|
+
import { Blurhash } from './Blurhash';
|
|
6
|
+
|
|
7
|
+
export interface BucketImageProps {
|
|
8
|
+
/** Bucket name from the schema. */
|
|
9
|
+
bucket: string;
|
|
10
|
+
/** Path within the bucket. Nullish renders only the fallback layers. */
|
|
11
|
+
path: string | null | undefined;
|
|
12
|
+
alt?: string;
|
|
13
|
+
/** Classes for the container element (sizing/positioning). */
|
|
14
|
+
class?: string;
|
|
15
|
+
/** Classes for the inner `<img>` (the layout styles are inline). */
|
|
16
|
+
imgClass?: string;
|
|
17
|
+
/** `object-fit` for the image. Defaults to `cover`. */
|
|
18
|
+
fit?: 'cover' | 'contain' | 'fill' | 'none' | 'scale-down';
|
|
19
|
+
/**
|
|
20
|
+
* Bottom placeholder layer (your own plate/skeleton), shown until the image
|
|
21
|
+
* settles. The blurhash layer paints on top of it once the sidecar resolves.
|
|
22
|
+
*/
|
|
23
|
+
fallback?: JSX.Element;
|
|
24
|
+
/** Crossfade duration in ms. Defaults to 300. */
|
|
25
|
+
transition?: number;
|
|
26
|
+
/** Crossfade easing. Defaults to an ease-out-expo curve. */
|
|
27
|
+
easing?: string;
|
|
28
|
+
/** Resolve the blurhash sidecar. Defaults to true. */
|
|
29
|
+
blurhash?: boolean;
|
|
30
|
+
/** Download tuning, forwarded to the underlying `useDownloadFile`. */
|
|
31
|
+
options?: UseBucketImageOptions;
|
|
32
|
+
}
|
|
33
|
+
|
|
34
|
+
const LAYER_STYLE = 'position:absolute;inset:0;width:100%;height:100%;';
|
|
35
|
+
|
|
36
|
+
/**
|
|
37
|
+
* A bucket image that never pops in: it layers (bottom to top) your `fallback`
|
|
38
|
+
* plate, the automatically stored blurhash, and the real image, which stays
|
|
39
|
+
* transparent until the bitmap is DECODED and then crossfades over the
|
|
40
|
+
* placeholders. Placeholder layers unmount once the fade settles. Respects
|
|
41
|
+
* prefers-reduced-motion (instant swap). The container is made
|
|
42
|
+
* `position: relative` unless your `class` positions it already.
|
|
43
|
+
*
|
|
44
|
+
* ```tsx
|
|
45
|
+
* <BucketImage bucket="covers" path={row.cover_key} class="absolute inset-0"
|
|
46
|
+
* fallback={<MyPlate />} alt="" />
|
|
47
|
+
* ```
|
|
48
|
+
*/
|
|
49
|
+
export function BucketImage(props: BucketImageProps): JSX.Element {
|
|
50
|
+
if (typeof document === 'undefined') return null;
|
|
51
|
+
|
|
52
|
+
const image = useBucketImage(
|
|
53
|
+
props.bucket as BucketNames<SchemaStructure>,
|
|
54
|
+
() => props.path,
|
|
55
|
+
{ ...props.options, blurhash: props.blurhash !== false }
|
|
56
|
+
);
|
|
57
|
+
|
|
58
|
+
const reducedMotion =
|
|
59
|
+
typeof matchMedia === 'function' && matchMedia('(prefers-reduced-motion: reduce)').matches;
|
|
60
|
+
|
|
61
|
+
const root = document.createElement('div');
|
|
62
|
+
createEffect(() => {
|
|
63
|
+
root.className = props.class ?? '';
|
|
64
|
+
});
|
|
65
|
+
// Layers are absolutely positioned; give them an anchor without stomping on
|
|
66
|
+
// a caller class that already positions the container (inline would win).
|
|
67
|
+
onMount(() => {
|
|
68
|
+
if (getComputedStyle(root).position === 'static') root.style.position = 'relative';
|
|
69
|
+
});
|
|
70
|
+
|
|
71
|
+
const placeholder = document.createElement('div');
|
|
72
|
+
placeholder.style.cssText = LAYER_STYLE;
|
|
73
|
+
// Solid hands JSX props over as lazy thunks; `children` resolves them (and
|
|
74
|
+
// nested arrays/functions) to real nodes and keeps them alive reactively.
|
|
75
|
+
const fallbackHolder = document.createElement('div');
|
|
76
|
+
fallbackHolder.style.cssText = LAYER_STYLE;
|
|
77
|
+
placeholder.append(fallbackHolder);
|
|
78
|
+
const resolvedFallback = children(() => props.fallback);
|
|
79
|
+
createEffect(() => {
|
|
80
|
+
const nodes = resolvedFallback.toArray().filter((node) => node instanceof Node) as Node[];
|
|
81
|
+
fallbackHolder.replaceChildren(...nodes);
|
|
82
|
+
});
|
|
83
|
+
const hashCanvas = Blurhash({
|
|
84
|
+
get hash() {
|
|
85
|
+
return image.blurhash();
|
|
86
|
+
},
|
|
87
|
+
style: LAYER_STYLE,
|
|
88
|
+
});
|
|
89
|
+
if (hashCanvas instanceof Node) placeholder.append(hashCanvas);
|
|
90
|
+
|
|
91
|
+
const img = document.createElement('img');
|
|
92
|
+
img.decoding = 'async';
|
|
93
|
+
img.style.cssText = `${LAYER_STYLE}opacity:0;`;
|
|
94
|
+
createEffect(() => {
|
|
95
|
+
img.className = props.imgClass ?? '';
|
|
96
|
+
});
|
|
97
|
+
createEffect(() => {
|
|
98
|
+
img.style.objectFit = props.fit ?? 'cover';
|
|
99
|
+
});
|
|
100
|
+
createEffect(() => {
|
|
101
|
+
img.alt = props.alt ?? '';
|
|
102
|
+
});
|
|
103
|
+
createEffect(() => {
|
|
104
|
+
img.style.transition = reducedMotion
|
|
105
|
+
? 'none'
|
|
106
|
+
: `opacity ${props.transition ?? 300}ms ${props.easing ?? 'cubic-bezier(0.16, 1, 0.3, 1)'}`;
|
|
107
|
+
});
|
|
108
|
+
createEffect(() => {
|
|
109
|
+
const url = image.url();
|
|
110
|
+
if (!url) {
|
|
111
|
+
img.removeAttribute('src');
|
|
112
|
+
return;
|
|
113
|
+
}
|
|
114
|
+
img.src = url;
|
|
115
|
+
image.gate(img);
|
|
116
|
+
});
|
|
117
|
+
createEffect(() => {
|
|
118
|
+
img.style.opacity = image.ready() ? '1' : '0';
|
|
119
|
+
});
|
|
120
|
+
|
|
121
|
+
// Placeholders leave the DOM once the fade is over (a shelf of covers should
|
|
122
|
+
// not composite three layers each forever) and come back when the path
|
|
123
|
+
// changes mid-life (`ready` re-arms via the hook).
|
|
124
|
+
const [settled, setSettled] = createSignal(false);
|
|
125
|
+
createEffect(() => {
|
|
126
|
+
if (!image.ready()) {
|
|
127
|
+
setSettled(false);
|
|
128
|
+
return;
|
|
129
|
+
}
|
|
130
|
+
const wait = (reducedMotion ? 0 : (props.transition ?? 300)) + 120;
|
|
131
|
+
const timer = setTimeout(() => setSettled(true), wait);
|
|
132
|
+
onCleanup(() => clearTimeout(timer));
|
|
133
|
+
});
|
|
134
|
+
createEffect(() => {
|
|
135
|
+
if (settled()) placeholder.remove();
|
|
136
|
+
else if (!placeholder.isConnected) root.insertBefore(placeholder, img);
|
|
137
|
+
});
|
|
138
|
+
|
|
139
|
+
root.append(placeholder, img);
|
|
140
|
+
return root;
|
|
141
|
+
}
|
|
@@ -1,4 +1,12 @@
|
|
|
1
|
-
import {
|
|
1
|
+
import type { JSX } from 'solid-js';
|
|
2
|
+
import {
|
|
3
|
+
createSignal,
|
|
4
|
+
onMount,
|
|
5
|
+
onCleanup,
|
|
6
|
+
createComponent,
|
|
7
|
+
createMemo,
|
|
8
|
+
mergeProps,
|
|
9
|
+
} from 'solid-js';
|
|
2
10
|
import type { SchemaStructure } from '@spooky/query-builder';
|
|
3
11
|
import type { SyncedDbConfig } from '../types';
|
|
4
12
|
import { SyncedDb } from '../index';
|
|
@@ -9,6 +17,15 @@ export interface Sp00kyProviderProps<S extends SchemaStructure> {
|
|
|
9
17
|
fallback?: JSX.Element;
|
|
10
18
|
onError?: (error: Error) => void;
|
|
11
19
|
onReady?: (db: SyncedDb<S>) => void;
|
|
20
|
+
/**
|
|
21
|
+
* Prewarm data into the local cache before revealing the UI. Runs after
|
|
22
|
+
* `init()`; the `fallback` stays visible until it resolves. Use awaitable
|
|
23
|
+
* `db.preload(...)` calls here to gate first-load on essential data (e.g.
|
|
24
|
+
* config). On warm loads preload returns instantly, so there's no perceptible
|
|
25
|
+
* gate after the first run. Best-effort: a rejection is caught and the UI is
|
|
26
|
+
* revealed anyway.
|
|
27
|
+
*/
|
|
28
|
+
preload?: (db: SyncedDb<S>) => Promise<void>;
|
|
12
29
|
children: JSX.Element;
|
|
13
30
|
}
|
|
14
31
|
|
|
@@ -24,10 +41,41 @@ export function Sp00kyProvider<S extends SchemaStructure>(
|
|
|
24
41
|
|
|
25
42
|
const [db, setDb] = createSignal<SyncedDb<S> | undefined>(undefined);
|
|
26
43
|
|
|
44
|
+
// `onMount` is async, so a dispose can land mid-init. Only that narrow race is
|
|
45
|
+
// handled here: an instance whose init finished AFTER the provider was already
|
|
46
|
+
// gone is closed, because nothing will ever reference it.
|
|
47
|
+
//
|
|
48
|
+
// A live, mounted client is deliberately NOT closed on cleanup. Doing that
|
|
49
|
+
// nulls `SyncedDb.sp00ky`, so every later `create`/`update`/`delete` throws
|
|
50
|
+
// "SyncedDb not initialized" while reads keep rendering from state that is
|
|
51
|
+
// already subscribed — i.e. mutations die silently and the app looks fine. In
|
|
52
|
+
// a host app the provider wraps the whole tree and only unmounts with the
|
|
53
|
+
// page, where the browser reclaims the worker anyway, so the leak this was
|
|
54
|
+
// meant to fix is worth far less than that risk.
|
|
55
|
+
let disposed = false;
|
|
56
|
+
|
|
57
|
+
onCleanup(() => {
|
|
58
|
+
disposed = true;
|
|
59
|
+
});
|
|
60
|
+
|
|
27
61
|
onMount(async () => {
|
|
28
62
|
try {
|
|
29
63
|
const instance = new SyncedDb<S>(merged.config);
|
|
30
64
|
await instance.init();
|
|
65
|
+
if (disposed) {
|
|
66
|
+
await instance.close();
|
|
67
|
+
return;
|
|
68
|
+
}
|
|
69
|
+
// Gate first-load UI on prewarmed data. Best-effort: never let a preload
|
|
70
|
+
// failure keep the app stuck on the fallback.
|
|
71
|
+
if (merged.preload) {
|
|
72
|
+
try {
|
|
73
|
+
await merged.preload(instance);
|
|
74
|
+
} catch (e) {
|
|
75
|
+
// oxlint-disable-next-line no-console
|
|
76
|
+
console.error('Sp00kyProvider: preload failed; revealing UI anyway', e);
|
|
77
|
+
}
|
|
78
|
+
}
|
|
31
79
|
setDb(() => instance);
|
|
32
80
|
merged.onReady?.(instance);
|
|
33
81
|
} catch (e) {
|
|
@@ -35,6 +83,7 @@ export function Sp00kyProvider<S extends SchemaStructure>(
|
|
|
35
83
|
if (merged.onError) {
|
|
36
84
|
merged.onError(error);
|
|
37
85
|
} else {
|
|
86
|
+
// oxlint-disable-next-line no-console
|
|
38
87
|
console.error('Sp00kyProvider: Failed to initialize database', error);
|
|
39
88
|
}
|
|
40
89
|
}
|