@spooky-sync/client-solid 0.0.1-canary.27 → 0.0.1-canary.270
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 +682 -158
- package/dist/index.cjs.map +1 -1
- package/dist/index.d.cts +393 -22
- package/dist/index.d.cts.map +1 -1
- package/dist/index.d.ts +393 -22
- package/dist/index.d.ts.map +1 -1
- package/dist/index.js +673 -160
- package/dist/index.js.map +1 -1
- package/package.json +7 -6
- package/skills/{spooky-solid → sp00ky-solid}/SKILL.md +85 -14
- package/skills/{spooky-solid → sp00ky-solid}/references/file-hooks.md +34 -5
- package/src/cache/index.ts +1 -1
- package/src/cache/surrealdb-wasm-factory.ts +4 -1
- package/src/index.ts +193 -61
- package/src/lib/Blurhash.ts +54 -0
- package/src/lib/BucketImage.ts +141 -0
- package/src/lib/Sp00kyProvider.ts +104 -0
- package/src/lib/context.ts +3 -3
- 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 +68 -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 +168 -28
- package/src/lib/use-storage-status.ts +44 -0
- package/src/lib/use-sync-status.ts +64 -0
- package/src/types/index.ts +3 -4
- package/src/lib/SpookyProvider.ts +0 -55
package/src/index.ts
CHANGED
|
@@ -1,14 +1,18 @@
|
|
|
1
1
|
import type { SyncedDbConfig } from './types';
|
|
2
2
|
import {
|
|
3
|
-
|
|
4
|
-
|
|
5
|
-
|
|
6
|
-
type
|
|
7
|
-
UpdateOptions,
|
|
8
|
-
RunOptions,
|
|
3
|
+
Sp00kyClient,
|
|
4
|
+
type Sp00kyQueryResultPromise,
|
|
5
|
+
type AuthService,
|
|
6
|
+
type BucketHandle,
|
|
7
|
+
type UpdateOptions,
|
|
8
|
+
type RunOptions,
|
|
9
|
+
type SyncHealth,
|
|
10
|
+
type StorageHealth,
|
|
11
|
+
type PreloadOptions,
|
|
12
|
+
type PreloadRefresh,
|
|
9
13
|
} from '@spooky-sync/core';
|
|
10
14
|
|
|
11
|
-
import {
|
|
15
|
+
import type {
|
|
12
16
|
GetTable,
|
|
13
17
|
QueryBuilder,
|
|
14
18
|
SchemaStructure,
|
|
@@ -19,25 +23,68 @@ import {
|
|
|
19
23
|
RelationshipFieldsFromSchema,
|
|
20
24
|
GetRelationship,
|
|
21
25
|
RelatedFieldMapEntry,
|
|
26
|
+
FinalQuery,
|
|
22
27
|
InnerQuery,
|
|
23
28
|
BackendNames,
|
|
24
29
|
BackendRoutes,
|
|
25
30
|
RoutePayload,
|
|
26
31
|
BucketNames,
|
|
27
32
|
BucketDefinitionSchema,
|
|
33
|
+
QueryModifier,
|
|
34
|
+
QueryModifierBuilder,
|
|
35
|
+
QueryInfo,
|
|
36
|
+
RelationshipsMetadata,
|
|
37
|
+
RelationshipDefinition,
|
|
38
|
+
InferRelatedModelFromMetadata,
|
|
39
|
+
GetCardinality,
|
|
28
40
|
} from '@spooky-sync/query-builder';
|
|
29
41
|
|
|
30
|
-
import { RecordId, Uuid, Surreal } from 'surrealdb';
|
|
42
|
+
import { RecordId, Uuid, type Surreal } from 'surrealdb';
|
|
31
43
|
export { RecordId, Uuid };
|
|
32
44
|
export type { Model, GenericModel, GenericSchema, ModelPayload } from './lib/models';
|
|
33
45
|
export { useQuery } from './lib/use-query';
|
|
46
|
+
export { createPreload } from './lib/create-preload';
|
|
47
|
+
export type { PreloadOptions, PreloadRefresh } from '@spooky-sync/core';
|
|
48
|
+
export { useSyncStatus, type UseSyncStatus } from './lib/use-sync-status';
|
|
49
|
+
export {
|
|
50
|
+
useImpersonation,
|
|
51
|
+
type UseImpersonation,
|
|
52
|
+
type UseImpersonationOptions,
|
|
53
|
+
} from './lib/use-impersonation';
|
|
54
|
+
export type {
|
|
55
|
+
SyncHealth,
|
|
56
|
+
SyncHealthStatus,
|
|
57
|
+
SyncHealthConfig,
|
|
58
|
+
ConnectionState,
|
|
59
|
+
ReconnectConfig,
|
|
60
|
+
} from '@spooky-sync/core';
|
|
61
|
+
export { useStorageStatus, type UseStorageStatus } from './lib/use-storage-status';
|
|
62
|
+
export type { StorageHealth, StorageHealthStatus } from '@spooky-sync/core';
|
|
63
|
+
export { useCrdtField } from './lib/use-crdt-field';
|
|
64
|
+
export { useFeatureFlag, type UseFeatureFlag } from './lib/use-feature-flag';
|
|
65
|
+
export {
|
|
66
|
+
useAppRelease,
|
|
67
|
+
type UseAppRelease,
|
|
68
|
+
type UseAppReleaseOptions,
|
|
69
|
+
} from './lib/use-app-release';
|
|
34
70
|
export { useFileUpload, type FileUploadResult } from './lib/use-file-upload';
|
|
35
|
-
export {
|
|
36
|
-
|
|
71
|
+
export {
|
|
72
|
+
useDownloadFile,
|
|
73
|
+
type UseDownloadFileOptions,
|
|
74
|
+
type UseDownloadFileResult,
|
|
75
|
+
} from './lib/use-download-file';
|
|
76
|
+
export { useBlurhash, type UseBlurhashResult } from './lib/use-blurhash';
|
|
77
|
+
export {
|
|
78
|
+
useBucketImage,
|
|
79
|
+
type UseBucketImageOptions,
|
|
80
|
+
type UseBucketImageResult,
|
|
81
|
+
} from './lib/use-bucket-image';
|
|
82
|
+
export { Blurhash, type BlurhashProps } from './lib/Blurhash';
|
|
83
|
+
export { BucketImage, type BucketImageProps } from './lib/BucketImage';
|
|
84
|
+
export { Sp00kyProvider, type Sp00kyProviderProps } from './lib/Sp00kyProvider';
|
|
37
85
|
export { useDb } from './lib/context';
|
|
38
86
|
|
|
39
87
|
// export { AuthEventTypes } from "@spooky-sync/core"; // TODO: Verify if AuthEventTypes exists in core
|
|
40
|
-
export type {};
|
|
41
88
|
|
|
42
89
|
// Re-export query builder types for convenience
|
|
43
90
|
export type {
|
|
@@ -52,7 +99,7 @@ export type {
|
|
|
52
99
|
TableModel,
|
|
53
100
|
TableNames,
|
|
54
101
|
QueryResult,
|
|
55
|
-
}
|
|
102
|
+
};
|
|
56
103
|
|
|
57
104
|
export type RelationshipField<
|
|
58
105
|
Schema extends SchemaStructure,
|
|
@@ -94,39 +141,52 @@ export type WithRelatedMany<Field extends string, RelatedFields extends RelatedF
|
|
|
94
141
|
};
|
|
95
142
|
|
|
96
143
|
/**
|
|
97
|
-
* SyncedDb - A thin wrapper around
|
|
98
|
-
* Delegates all logic to the underlying
|
|
144
|
+
* SyncedDb - A thin wrapper around sp00ky-ts for Solid.js integration
|
|
145
|
+
* Delegates all logic to the underlying sp00ky-ts instance
|
|
99
146
|
*/
|
|
100
147
|
export class SyncedDb<S extends SchemaStructure> {
|
|
101
148
|
private config: SyncedDbConfig<S>;
|
|
102
|
-
private
|
|
149
|
+
private sp00ky: Sp00kyClient<S> | null = null;
|
|
103
150
|
private _initialized = false;
|
|
104
151
|
|
|
105
152
|
constructor(config: SyncedDbConfig<S>) {
|
|
106
153
|
this.config = config;
|
|
107
154
|
}
|
|
108
155
|
|
|
109
|
-
public
|
|
110
|
-
if (!this.
|
|
111
|
-
return this.
|
|
156
|
+
public getSp00ky(): Sp00kyClient<S> {
|
|
157
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
158
|
+
return this.sp00ky;
|
|
112
159
|
}
|
|
113
160
|
|
|
114
161
|
/**
|
|
115
|
-
* Initialize the
|
|
162
|
+
* Initialize the sp00ky-ts instance
|
|
116
163
|
*/
|
|
117
164
|
async init(): Promise<void> {
|
|
118
165
|
if (this._initialized) return;
|
|
119
|
-
this.
|
|
120
|
-
await this.
|
|
166
|
+
this.sp00ky = new Sp00kyClient<S>(this.config);
|
|
167
|
+
await this.sp00ky.init();
|
|
121
168
|
this._initialized = true;
|
|
122
169
|
}
|
|
123
170
|
|
|
171
|
+
/**
|
|
172
|
+
* Tear down the client: leaves the tabs broker, closes the local store and
|
|
173
|
+
* remote socket, and frees the wasm circuit. Without this a remounted provider
|
|
174
|
+
* (or an HMR reload) strands a whole client, and the abandoned wasm heaps stay
|
|
175
|
+
* resident because V8 cannot see how much wasm memory a dropped wrapper holds.
|
|
176
|
+
*/
|
|
177
|
+
async close(): Promise<void> {
|
|
178
|
+
const instance = this.sp00ky;
|
|
179
|
+
this.sp00ky = null;
|
|
180
|
+
this._initialized = false;
|
|
181
|
+
if (instance) await instance.close();
|
|
182
|
+
}
|
|
183
|
+
|
|
124
184
|
/**
|
|
125
185
|
* Create a new record in the database
|
|
126
186
|
*/
|
|
127
187
|
async create(id: string, payload: Record<string, unknown>): Promise<void> {
|
|
128
|
-
if (!this.
|
|
129
|
-
await this.
|
|
188
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
189
|
+
await this.sp00ky.create(id, payload as Record<string, unknown>);
|
|
130
190
|
}
|
|
131
191
|
|
|
132
192
|
/**
|
|
@@ -138,8 +198,8 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
138
198
|
payload: Partial<TableModel<GetTable<S, TName>>>,
|
|
139
199
|
options?: UpdateOptions
|
|
140
200
|
): Promise<void> {
|
|
141
|
-
if (!this.
|
|
142
|
-
await this.
|
|
201
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
202
|
+
await this.sp00ky.update(
|
|
143
203
|
tableName as string,
|
|
144
204
|
recordId,
|
|
145
205
|
payload as Record<string, unknown>,
|
|
@@ -152,12 +212,39 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
152
212
|
*/
|
|
153
213
|
async delete<TName extends TableNames<S>>(
|
|
154
214
|
tableName: TName,
|
|
155
|
-
selector: string | InnerQuery<GetTable<S, TName>, boolean>
|
|
215
|
+
selector: string | RecordId | InnerQuery<GetTable<S, TName>, boolean>
|
|
156
216
|
): Promise<void> {
|
|
157
|
-
if (!this.
|
|
158
|
-
|
|
159
|
-
|
|
160
|
-
|
|
217
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
218
|
+
// Accept a `"table:id"` string OR a RecordId — live-query rows carry their
|
|
219
|
+
// `id` as a RecordId, so callers can pass `db.delete('game', row.id)`
|
|
220
|
+
// directly. Build the canonical string from the raw id part (not
|
|
221
|
+
// `RecordId.toString()`, which escapes special chars) so it round-trips
|
|
222
|
+
// through the engine's `parseRecordIdString`. InnerQuery selectors are not
|
|
223
|
+
// supported yet. (cross-package RecordId instances → match by constructor name.)
|
|
224
|
+
const isRecordId =
|
|
225
|
+
selector instanceof RecordId || (selector as any)?.constructor?.name === 'RecordId';
|
|
226
|
+
let id: string;
|
|
227
|
+
if (typeof selector === 'string') {
|
|
228
|
+
id = selector;
|
|
229
|
+
} else if (isRecordId) {
|
|
230
|
+
id = `${tableName as string}:${(selector as RecordId).id}`;
|
|
231
|
+
} else {
|
|
232
|
+
throw new Error('Only string ID or RecordId selectors are supported currently with core');
|
|
233
|
+
}
|
|
234
|
+
await this.sp00ky.delete(tableName as string, id);
|
|
235
|
+
}
|
|
236
|
+
|
|
237
|
+
/**
|
|
238
|
+
* Preload/prewarm a built query into the local cache without registering a
|
|
239
|
+
* live view. Fetches once and stores the rows (+ embedded related children)
|
|
240
|
+
* locally so a later `useQuery` for the same data paints instantly. Best-effort.
|
|
241
|
+
*/
|
|
242
|
+
public async preload(
|
|
243
|
+
finalQuery: FinalQuery<S, any, any, any, any, Sp00kyQueryResultPromise>,
|
|
244
|
+
options?: PreloadOptions
|
|
245
|
+
): Promise<void> {
|
|
246
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
247
|
+
await this.sp00ky.preload(finalQuery, options);
|
|
161
248
|
}
|
|
162
249
|
|
|
163
250
|
/**
|
|
@@ -165,34 +252,31 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
165
252
|
*/
|
|
166
253
|
public query<TName extends TableNames<S>>(
|
|
167
254
|
table: TName
|
|
168
|
-
): QueryBuilder<S, TName,
|
|
169
|
-
if (!this.
|
|
170
|
-
return this.
|
|
255
|
+
): QueryBuilder<S, TName, Sp00kyQueryResultPromise, {}, false> {
|
|
256
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
257
|
+
return this.sp00ky.query(table, {});
|
|
171
258
|
}
|
|
172
259
|
|
|
173
260
|
/**
|
|
174
261
|
* Run a backend operation
|
|
175
262
|
*/
|
|
176
|
-
public async run<
|
|
177
|
-
B extends BackendNames<S>,
|
|
178
|
-
R extends BackendRoutes<S, B>,
|
|
179
|
-
>(
|
|
263
|
+
public async run<B extends BackendNames<S>, R extends BackendRoutes<S, B>>(
|
|
180
264
|
backend: B,
|
|
181
265
|
path: R,
|
|
182
266
|
payload: RoutePayload<S, B, R>,
|
|
183
|
-
options?: RunOptions
|
|
267
|
+
options?: RunOptions
|
|
184
268
|
): Promise<void> {
|
|
185
|
-
if (!this.
|
|
186
|
-
await this.
|
|
269
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
270
|
+
await this.sp00ky.run(backend, path, payload, options);
|
|
187
271
|
}
|
|
188
272
|
|
|
189
273
|
/**
|
|
190
274
|
* Authenticate with the database
|
|
191
275
|
*/
|
|
192
276
|
public async authenticate(token: string): Promise<RecordId<string>> {
|
|
193
|
-
|
|
194
|
-
//
|
|
195
|
-
// Wait, checked
|
|
277
|
+
await this.sp00ky?.authenticate(token);
|
|
278
|
+
// Sp00kyClient.authenticate returns whatever remote.authenticate returns (boolean or token usually?)
|
|
279
|
+
// Wait, checked Sp00kyClient: return this.remote.getClient().authenticate(token);
|
|
196
280
|
// SurrealDB authenticate returns void? or token?
|
|
197
281
|
// Assuming void or token.
|
|
198
282
|
return new RecordId('user', 'me'); // Placeholder or actual?
|
|
@@ -206,58 +290,106 @@ export class SyncedDb<S extends SchemaStructure> {
|
|
|
206
290
|
await this.signOut();
|
|
207
291
|
}
|
|
208
292
|
|
|
293
|
+
/**
|
|
294
|
+
* Tell the client that this app's own impersonation banner is on screen.
|
|
295
|
+
* Only needed with `impersonationBanner: { mode: 'custom' }`; the
|
|
296
|
+
* `useImpersonation({ rendersBanner: true })` hook calls it for you.
|
|
297
|
+
*/
|
|
298
|
+
public acknowledgeImpersonationBanner(): void {
|
|
299
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
300
|
+
this.sp00ky.acknowledgeImpersonationBanner();
|
|
301
|
+
}
|
|
302
|
+
|
|
209
303
|
/**
|
|
210
304
|
* Sign out, clear session and local storage
|
|
211
305
|
*/
|
|
212
306
|
public async signOut(): Promise<void> {
|
|
213
|
-
if (!this.
|
|
214
|
-
await this.
|
|
307
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
308
|
+
await this.sp00ky.auth.signOut();
|
|
215
309
|
}
|
|
216
310
|
|
|
217
311
|
/**
|
|
218
312
|
* Execute a function with direct access to the remote database connection
|
|
219
313
|
*/
|
|
220
314
|
public async useRemote<T>(fn: (db: Surreal) => T | Promise<T>): Promise<T> {
|
|
221
|
-
if (!this.
|
|
222
|
-
return await this.
|
|
315
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
316
|
+
return await this.sp00ky.useRemote(fn);
|
|
223
317
|
}
|
|
224
318
|
/**
|
|
225
319
|
* Access the remote database service directly
|
|
226
320
|
*/
|
|
227
|
-
get remote():
|
|
228
|
-
if (!this.
|
|
229
|
-
return this.
|
|
321
|
+
get remote(): Sp00kyClient<S>['remoteClient'] {
|
|
322
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
323
|
+
return this.sp00ky.remoteClient;
|
|
230
324
|
}
|
|
231
325
|
|
|
232
326
|
/**
|
|
233
327
|
* Access the local database service directly
|
|
234
328
|
*/
|
|
235
|
-
get local():
|
|
236
|
-
if (!this.
|
|
237
|
-
return this.
|
|
329
|
+
get local(): Sp00kyClient<S>['localClient'] {
|
|
330
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
331
|
+
return this.sp00ky.localClient;
|
|
238
332
|
}
|
|
239
333
|
|
|
240
334
|
/**
|
|
241
335
|
* Access the auth service
|
|
242
336
|
*/
|
|
243
337
|
get auth(): AuthService<S> {
|
|
244
|
-
if (!this.
|
|
245
|
-
return this.
|
|
338
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
339
|
+
return this.sp00ky.auth;
|
|
246
340
|
}
|
|
247
341
|
|
|
248
342
|
get pendingMutationCount(): number {
|
|
249
|
-
if (!this.
|
|
250
|
-
return this.
|
|
343
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
344
|
+
return this.sp00ky.pendingMutationCount;
|
|
345
|
+
}
|
|
346
|
+
|
|
347
|
+
/** Diagnostic — see `Sp00kyClient.liveRetryCount`. */
|
|
348
|
+
get liveRetryCount(): number {
|
|
349
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
350
|
+
return this.sp00ky.liveRetryCount;
|
|
251
351
|
}
|
|
252
352
|
|
|
253
353
|
subscribeToPendingMutations(cb: (count: number) => void): () => void {
|
|
254
|
-
if (!this.
|
|
255
|
-
return this.
|
|
354
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
355
|
+
return this.sp00ky.subscribeToPendingMutations(cb);
|
|
356
|
+
}
|
|
357
|
+
|
|
358
|
+
/** Current sync-health snapshot. See {@link useSyncStatus}. */
|
|
359
|
+
get syncHealth(): SyncHealth {
|
|
360
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
361
|
+
return this.sp00ky.syncHealth;
|
|
362
|
+
}
|
|
363
|
+
|
|
364
|
+
/**
|
|
365
|
+
* Observe sync health. Fires immediately with the current status and again
|
|
366
|
+
* on every healthy↔degraded transition. Prefer the `useSyncStatus` hook in
|
|
367
|
+
* components; this is the imperative escape hatch.
|
|
368
|
+
*/
|
|
369
|
+
subscribeToSyncHealth(cb: (health: SyncHealth) => void): () => void {
|
|
370
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
371
|
+
return this.sp00ky.subscribeToSyncHealth(cb);
|
|
372
|
+
}
|
|
373
|
+
|
|
374
|
+
/** Current local-store durability snapshot. See {@link useStorageStatus}. */
|
|
375
|
+
get storageHealth(): StorageHealth {
|
|
376
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
377
|
+
return this.sp00ky.storageHealth;
|
|
378
|
+
}
|
|
379
|
+
|
|
380
|
+
/**
|
|
381
|
+
* Observe local-store durability. Fires immediately with the current snapshot
|
|
382
|
+
* and again on change. Prefer the `useStorageStatus` hook in components; this
|
|
383
|
+
* is the imperative escape hatch.
|
|
384
|
+
*/
|
|
385
|
+
subscribeToStorageHealth(cb: (health: StorageHealth) => void): () => void {
|
|
386
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
387
|
+
return this.sp00ky.subscribeToStorageHealth(cb);
|
|
256
388
|
}
|
|
257
389
|
|
|
258
390
|
bucket<B extends BucketNames<S>>(name: B): BucketHandle {
|
|
259
|
-
if (!this.
|
|
260
|
-
return this.
|
|
391
|
+
if (!this.sp00ky) throw new Error('SyncedDb not initialized');
|
|
392
|
+
return this.sp00ky.bucket(name);
|
|
261
393
|
}
|
|
262
394
|
|
|
263
395
|
getBucketConfig(name: string): BucketDefinitionSchema | undefined {
|
|
@@ -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
|
+
}
|