@pyric/ui 0.1.0-alpha.10 → 0.1.0-alpha.12

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 (152) hide show
  1. package/package.json +5 -4
  2. package/src/agents/ContextWindowUsage.tsx +514 -0
  3. package/src/agents/EmptyState.tsx +27 -0
  4. package/src/agents/Fold.tsx +64 -0
  5. package/src/agents/Modal.tsx +65 -0
  6. package/src/agents/PulsingDot.tsx +28 -0
  7. package/src/agents/inbrowser-agent-usage.d.ts +141 -0
  8. package/src/agents/index.ts +28 -0
  9. package/src/auth/authApi.ts +72 -0
  10. package/src/auth/claims.ts +63 -0
  11. package/src/auth/components/AuthProviderToggles.tsx +147 -0
  12. package/src/auth/components/AuthSignInHelper.tsx +197 -0
  13. package/src/auth/components/AuthUserForm.tsx +328 -0
  14. package/src/auth/components/AuthUserList.tsx +219 -0
  15. package/src/auth/components/ClaimsField.tsx +50 -0
  16. package/src/auth/components/confirmActions.tsx +114 -0
  17. package/src/auth/controller.ts +173 -0
  18. package/src/auth/hooks/index.ts +36 -0
  19. package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
  20. package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
  21. package/src/auth/hooks/useAuthUserEditor.ts +76 -0
  22. package/src/auth/hooks/useAuthUsers.ts +154 -0
  23. package/src/auth/index.ts +42 -0
  24. package/src/auth/providers.ts +28 -0
  25. package/src/auth/reducers/userEditor.ts +186 -0
  26. package/src/events/components/ActivityActionItems.tsx +136 -0
  27. package/src/events/components/ActivityGrid.tsx +197 -0
  28. package/src/events/components/ActivityGridRow.tsx +65 -0
  29. package/src/events/components/ProposedChangeDiff.tsx +175 -0
  30. package/src/events/components/format.ts +21 -0
  31. package/src/events/components/index.ts +17 -0
  32. package/src/events/digest.ts +630 -0
  33. package/src/events/hooks/index.ts +9 -0
  34. package/src/events/hooks/useActivityDigest.ts +42 -0
  35. package/src/events/hooks/useActivityStream.ts +86 -0
  36. package/src/events/index.ts +39 -0
  37. package/src/events/types.ts +152 -0
  38. package/src/firestore/components/CollectionList.tsx +78 -0
  39. package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
  40. package/src/firestore/components/DocumentEditor.tsx +350 -0
  41. package/src/firestore/components/DocumentList.tsx +217 -0
  42. package/src/firestore/components/DocumentPreview.tsx +265 -0
  43. package/src/firestore/components/FieldRenderer.tsx +25 -0
  44. package/src/firestore/components/QueryBuilder.tsx +181 -0
  45. package/src/firestore/components/ReferencePicker.tsx +212 -0
  46. package/src/firestore/components/TreeEntry.tsx +58 -0
  47. package/src/firestore/components/context.ts +25 -0
  48. package/src/firestore/fieldEditors/array.tsx +30 -0
  49. package/src/firestore/fieldEditors/boolean.tsx +39 -0
  50. package/src/firestore/fieldEditors/bytes.tsx +53 -0
  51. package/src/firestore/fieldEditors/geopoint.tsx +71 -0
  52. package/src/firestore/fieldEditors/map.tsx +33 -0
  53. package/src/firestore/fieldEditors/null.tsx +27 -0
  54. package/src/firestore/fieldEditors/number.tsx +43 -0
  55. package/src/firestore/fieldEditors/reference.tsx +87 -0
  56. package/src/firestore/fieldEditors/registry.ts +45 -0
  57. package/src/firestore/fieldEditors/string.tsx +33 -0
  58. package/src/firestore/fieldEditors/timestamp.tsx +75 -0
  59. package/src/firestore/fieldEditors/types.ts +68 -0
  60. package/src/firestore/fieldEditors/vector.tsx +142 -0
  61. package/src/firestore/firestoreApi.ts +86 -0
  62. package/src/firestore/hooks/coerceError.ts +34 -0
  63. package/src/firestore/hooks/index.ts +46 -0
  64. package/src/firestore/hooks/useCollectionList.ts +102 -0
  65. package/src/firestore/hooks/useDocumentEditor.ts +161 -0
  66. package/src/firestore/hooks/useDocumentList.ts +242 -0
  67. package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
  68. package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
  69. package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
  70. package/src/firestore/hooks/useQueryBuilder.ts +188 -0
  71. package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
  72. package/src/firestore/hooks/useReferencePicker.ts +228 -0
  73. package/src/firestore/import/parseImport.ts +137 -0
  74. package/src/firestore/index.ts +87 -0
  75. package/src/firestore/reducers/defaults.ts +38 -0
  76. package/src/firestore/reducers/documentEditor.ts +239 -0
  77. package/src/firestore/reducers/tree.ts +140 -0
  78. package/src/firestore/reducers/types.ts +92 -0
  79. package/src/firestore/reducers/validation.ts +129 -0
  80. package/src/firestore/types.ts +231 -0
  81. package/src/firestore/validation/ids.ts +45 -0
  82. package/src/firestore/valueEquality.ts +43 -0
  83. package/src/index.ts +10 -0
  84. package/src/primitives/Badge.tsx +40 -0
  85. package/src/primitives/ConfirmDialog.tsx +137 -0
  86. package/src/primitives/CopyButton.tsx +62 -0
  87. package/src/primitives/JsonView.tsx +151 -0
  88. package/src/primitives/SegmentedControl.tsx +72 -0
  89. package/src/primitives/Toast.tsx +161 -0
  90. package/src/primitives/VirtualList.tsx +104 -0
  91. package/src/primitives/hooks/useContainerSize.ts +53 -0
  92. package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
  93. package/src/primitives/index.ts +39 -0
  94. package/src/primitives/useConfirm.tsx +113 -0
  95. package/src/rtdb/components/RtdbPathBar.tsx +135 -0
  96. package/src/rtdb/components/RtdbTree.tsx +409 -0
  97. package/src/rtdb/editor.ts +79 -0
  98. package/src/rtdb/hooks/useRtdbTree.ts +137 -0
  99. package/src/rtdb/index.ts +66 -0
  100. package/src/rtdb/pathInput.ts +47 -0
  101. package/src/rtdb/reducers/tree.ts +191 -0
  102. package/src/rtdb/rtdbApi.ts +23 -0
  103. package/src/rtdb/values.ts +188 -0
  104. package/src/rules/components/DenialInspector.tsx +227 -0
  105. package/src/rules/components/format.ts +171 -0
  106. package/src/rules/components/index.ts +12 -0
  107. package/src/rules/components/scope.ts +75 -0
  108. package/src/rules/hooks/index.ts +5 -0
  109. package/src/rules/hooks/useDenialTrace.ts +100 -0
  110. package/src/rules/index.ts +24 -0
  111. package/src/rules/types.ts +91 -0
  112. package/src/storage/collisionRename.ts +114 -0
  113. package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
  114. package/src/storage/components/ObjectBrowser.tsx +219 -0
  115. package/src/storage/components/ObjectInspector.tsx +169 -0
  116. package/src/storage/components/PathBreadcrumb.tsx +84 -0
  117. package/src/storage/components/UploadDropzone.tsx +182 -0
  118. package/src/storage/folderPlaceholder.ts +40 -0
  119. package/src/storage/hooks/index.ts +59 -0
  120. package/src/storage/hooks/useMetadataEditor.ts +329 -0
  121. package/src/storage/hooks/useObjectUpload.ts +262 -0
  122. package/src/storage/hooks/usePathState.ts +94 -0
  123. package/src/storage/hooks/useStorageDelete.ts +195 -0
  124. package/src/storage/hooks/useStorageList.ts +261 -0
  125. package/src/storage/hooks/useStorageObject.ts +162 -0
  126. package/src/storage/hooks/useStorageRulesGate.ts +270 -0
  127. package/src/storage/hooks/useStorageSelection.ts +90 -0
  128. package/src/storage/index.ts +59 -0
  129. package/src/storage/pendingPrefixes.ts +125 -0
  130. package/src/storage/previews.tsx +120 -0
  131. package/src/storage/storageApi.ts +54 -0
  132. package/src/traffic/components/RuleHeatmap.tsx +97 -0
  133. package/src/traffic/components/TrafficDetail.tsx +160 -0
  134. package/src/traffic/components/TrafficGroupRow.tsx +91 -0
  135. package/src/traffic/components/TrafficLineChart.tsx +139 -0
  136. package/src/traffic/components/TrafficLog.tsx +175 -0
  137. package/src/traffic/components/TrafficMetricCards.tsx +77 -0
  138. package/src/traffic/components/TrafficRow.tsx +69 -0
  139. package/src/traffic/components/TrafficStats.tsx +73 -0
  140. package/src/traffic/components/TrafficTimeline.tsx +289 -0
  141. package/src/traffic/components/format.ts +22 -0
  142. package/src/traffic/components/index.ts +22 -0
  143. package/src/traffic/hooks/index.ts +60 -0
  144. package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
  145. package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
  146. package/src/traffic/hooks/useTrafficFilter.ts +74 -0
  147. package/src/traffic/hooks/useTrafficGroups.ts +126 -0
  148. package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
  149. package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
  150. package/src/traffic/hooks/useTrafficStats.ts +77 -0
  151. package/src/traffic/index.ts +13 -0
  152. package/src/traffic/types.ts +85 -0
@@ -0,0 +1,59 @@
1
+ export {
2
+ useStorageList,
3
+ type StorageListStatus,
4
+ type StorageListEntry,
5
+ type UseStorageListResult,
6
+ } from './useStorageList.js';
7
+ export {
8
+ usePathState,
9
+ normalizeStoragePath,
10
+ type UsePathStateOptions,
11
+ type UsePathStateResult,
12
+ } from './usePathState.js';
13
+ export {
14
+ useStorageObject,
15
+ type StorageObjectStatus,
16
+ type UseStorageObjectResult,
17
+ } from './useStorageObject.js';
18
+ export {
19
+ useMetadataEditor,
20
+ metadataEditorReducer,
21
+ initMetadataEditorState,
22
+ type CustomMetadataEntry,
23
+ type MetadataEditorState,
24
+ type MetadataEditorAction,
25
+ type UseMetadataEditorOptions,
26
+ type UseMetadataEditorResult,
27
+ } from './useMetadataEditor.js';
28
+ export {
29
+ useStorageSelection,
30
+ type StorageSelectionEntry,
31
+ type UseStorageSelectionResult,
32
+ } from './useStorageSelection.js';
33
+ export {
34
+ useStorageDelete,
35
+ createListAllDeleteImpl,
36
+ type StorageDeleteProgress,
37
+ type StorageRecursiveDeleteImpl,
38
+ type StorageDeleteFailure,
39
+ type StorageDeleteOutcome,
40
+ type UseStorageDeleteOptions,
41
+ type UseStorageDeleteResult,
42
+ } from './useStorageDelete.js';
43
+ export {
44
+ useStorageRulesGate,
45
+ type StorageRulesGateStatus,
46
+ type StorageRulesSource,
47
+ type StorageGateVerdict,
48
+ type UseStorageRulesGateOptions,
49
+ type UseStorageRulesGateResult,
50
+ } from './useStorageRulesGate.js';
51
+ export {
52
+ useObjectUpload,
53
+ type UploadTask,
54
+ type UploadTaskStatus,
55
+ type UploadEntry,
56
+ type UploadInput,
57
+ type UseObjectUploadOptions,
58
+ type UseObjectUploadResult,
59
+ } from './useObjectUpload.js';
@@ -0,0 +1,329 @@
1
+ import { useCallback, useMemo, useReducer, useRef, useState } from 'react';
2
+ import {
3
+ ref as refFn,
4
+ updateMetadata,
5
+ type FirebaseStorage,
6
+ type FullMetadata,
7
+ type SettableMetadata,
8
+ } from 'pyric/storage';
9
+ import { normalizeStoragePath } from './usePathState.js';
10
+
11
+ /** One `customMetadata` row. `id` is a stable render key — keys are
12
+ * user-editable, so they can't key the rows themselves. */
13
+ export interface CustomMetadataEntry {
14
+ id: string;
15
+ key: string;
16
+ value: string;
17
+ /** Validation error (`'Key is required'` / `'Duplicate key'`). */
18
+ error?: string;
19
+ }
20
+
21
+ interface MetadataDraft {
22
+ contentType: string;
23
+ cacheControl: string;
24
+ custom: CustomMetadataEntry[];
25
+ }
26
+
27
+ export interface MetadataEditorState {
28
+ draft: MetadataDraft;
29
+ /** Snapshot for `isDirty` / `reset` — same reference-compare
30
+ * semantics as the document editor's `tree !== initial`. */
31
+ initial: MetadataDraft;
32
+ errorCount: number;
33
+ }
34
+
35
+ export type MetadataEditorAction =
36
+ | { type: 'setContentType'; value: string }
37
+ | { type: 'setCacheControl'; value: string }
38
+ | { type: 'setCustomKey'; id: string; key: string }
39
+ | { type: 'setCustomValue'; id: string; value: string }
40
+ | { type: 'addCustomEntry'; key?: string; value?: string }
41
+ | { type: 'removeCustomEntry'; id: string }
42
+ | { type: 'reset' }
43
+ /** Internal — a successful save makes the draft the new baseline. */
44
+ | { type: 'commit' };
45
+
46
+ /** Re-validate the k/v rows: empty keys and duplicate keys error. */
47
+ function validateCustom(custom: CustomMetadataEntry[]): {
48
+ custom: CustomMetadataEntry[];
49
+ errorCount: number;
50
+ } {
51
+ const counts = new Map<string, number>();
52
+ for (const entry of custom) {
53
+ counts.set(entry.key, (counts.get(entry.key) ?? 0) + 1);
54
+ }
55
+ let errorCount = 0;
56
+ const next = custom.map((entry) => {
57
+ let error: string | undefined;
58
+ if (entry.key.trim() === '') error = 'Key is required';
59
+ else if ((counts.get(entry.key) ?? 0) > 1) error = 'Duplicate key';
60
+ if (error) errorCount++;
61
+ if (error === entry.error) return entry;
62
+ return { ...entry, error };
63
+ });
64
+ return { custom: next, errorCount };
65
+ }
66
+
67
+ function applyDraft(
68
+ state: MetadataEditorState,
69
+ mutate: (draft: MetadataDraft) => MetadataDraft,
70
+ ): MetadataEditorState {
71
+ const mutated = mutate(state.draft);
72
+ const { custom, errorCount } = validateCustom(mutated.custom);
73
+ return { ...state, draft: { ...mutated, custom }, errorCount };
74
+ }
75
+
76
+ /** Pure reducer — exported (with {@link initMetadataEditorState}) so
77
+ * the edit state is testable without React, mirroring the document
78
+ * editor's reducer/hook split. */
79
+ export function metadataEditorReducer(
80
+ state: MetadataEditorState,
81
+ action: MetadataEditorAction,
82
+ ): MetadataEditorState {
83
+ switch (action.type) {
84
+ case 'setContentType':
85
+ return applyDraft(state, (d) => ({ ...d, contentType: action.value }));
86
+ case 'setCacheControl':
87
+ return applyDraft(state, (d) => ({ ...d, cacheControl: action.value }));
88
+ case 'setCustomKey':
89
+ return applyDraft(state, (d) => ({
90
+ ...d,
91
+ custom: d.custom.map((e) => (e.id === action.id ? { ...e, key: action.key } : e)),
92
+ }));
93
+ case 'setCustomValue':
94
+ return applyDraft(state, (d) => ({
95
+ ...d,
96
+ custom: d.custom.map((e) =>
97
+ e.id === action.id ? { ...e, value: action.value } : e,
98
+ ),
99
+ }));
100
+ case 'addCustomEntry':
101
+ return applyDraft(state, (d) => ({
102
+ ...d,
103
+ custom: [
104
+ ...d.custom,
105
+ { id: crypto.randomUUID(), key: action.key ?? '', value: action.value ?? '' },
106
+ ],
107
+ }));
108
+ case 'removeCustomEntry':
109
+ return applyDraft(state, (d) => ({
110
+ ...d,
111
+ custom: d.custom.filter((e) => e.id !== action.id),
112
+ }));
113
+ case 'reset':
114
+ return {
115
+ draft: state.initial,
116
+ initial: state.initial,
117
+ errorCount: validateCustom(state.initial.custom).errorCount,
118
+ };
119
+ case 'commit':
120
+ return { ...state, initial: state.draft };
121
+ }
122
+ }
123
+
124
+ /** Build the edit state from the metadata a `getMetadata` /
125
+ * `useStorageObject` read returned. */
126
+ export function initMetadataEditorState(
127
+ initial: SettableMetadata | undefined,
128
+ ): MetadataEditorState {
129
+ const draft: MetadataDraft = {
130
+ contentType: initial?.contentType ?? '',
131
+ cacheControl: initial?.cacheControl ?? '',
132
+ custom: Object.entries(initial?.customMetadata ?? {}).map(([key, value]) => ({
133
+ id: crypto.randomUUID(),
134
+ key,
135
+ value,
136
+ })),
137
+ };
138
+ const { custom, errorCount } = validateCustom(draft.custom);
139
+ // One shared object: `isDirty` is `draft !== initial` by reference.
140
+ const validated: MetadataDraft = { ...draft, custom };
141
+ return { draft: validated, initial: validated, errorCount };
142
+ }
143
+
144
+ export interface UseMetadataEditorOptions {
145
+ /** The metadata being edited — the same shape `useStorageObject`'s
146
+ * `metadata` carries. Read once on mount (the editor is a
147
+ * stateful workspace, like the document editor); `reset()` +
148
+ * remount to re-initialize. */
149
+ initial?: SettableMetadata;
150
+ }
151
+
152
+ export interface UseMetadataEditorResult {
153
+ contentType: string;
154
+ cacheControl: string;
155
+ custom: CustomMetadataEntry[];
156
+ /** Convenience: `errorCount === 0`. */
157
+ isValid: boolean;
158
+ /** `true` once any modifying action fired since the last
159
+ * `reset`/successful `save`. Reference-compare semantics — manual
160
+ * re-entry of the original values does NOT clear it. */
161
+ isDirty: boolean;
162
+ errorCount: number;
163
+ /** Raw dispatch — prefer the named helpers. */
164
+ dispatch: (action: MetadataEditorAction) => void;
165
+ setContentType: (value: string) => void;
166
+ setCacheControl: (value: string) => void;
167
+ setCustomKey: (id: string, key: string) => void;
168
+ setCustomValue: (id: string, value: string) => void;
169
+ addCustomEntry: (key?: string, value?: string) => void;
170
+ removeCustomEntry: (id: string) => void;
171
+ /** Restore the initial values. Clears `isDirty`. */
172
+ reset: () => void;
173
+ /**
174
+ * Serialize the draft to an `updateMetadata` patch. Empty
175
+ * `contentType`/`cacheControl` become `undefined` — which LEAVES
176
+ * the previous value (the sandbox doesn't model null-clears; see
177
+ * `pyric/storage`'s `updateMetadata` doc). `customMetadata` is
178
+ * always included and replaces wholesale, so row removal works.
179
+ */
180
+ toPatch: () => SettableMetadata;
181
+ /**
182
+ * `updateMetadata(ref(storage, path), toPatch())`. Errors surface
183
+ * via `saveError` (typed `StorageError`), not throws — resolves
184
+ * `undefined` on failure or when the draft is invalid. On success
185
+ * the draft becomes the new baseline (`isDirty` clears) and the
186
+ * fresh `FullMetadata` is returned.
187
+ */
188
+ save: () => Promise<FullMetadata | undefined>;
189
+ isSaving: boolean;
190
+ saveError: Error | undefined;
191
+ }
192
+
193
+ /**
194
+ * Headless metadata editor — the `useDocumentEditor` reducer pattern
195
+ * over `updateMetadata`: a pure reducer owns the draft (contentType,
196
+ * cacheControl, customMetadata k/v rows with stable ids +
197
+ * empty/duplicate-key validation); the hook adds named dispatch
198
+ * helpers and the save half.
199
+ */
200
+ export function useMetadataEditor(
201
+ storage: FirebaseStorage | null | undefined,
202
+ path: string | null | undefined,
203
+ options: UseMetadataEditorOptions = {},
204
+ ): UseMetadataEditorResult {
205
+ // Computed once — the reducer owns the live draft from here on
206
+ // (same `useRef` seed as `useDocumentEditor`).
207
+ const initialRef = useRef<MetadataEditorState | null>(null);
208
+ if (initialRef.current == null) {
209
+ initialRef.current = initMetadataEditorState(options.initial);
210
+ }
211
+ const [state, dispatch] = useReducer(metadataEditorReducer, initialRef.current);
212
+ const [isSaving, setIsSaving] = useState(false);
213
+ const [saveError, setSaveError] = useState<Error | undefined>(undefined);
214
+ // Stale-run protection for overlapping saves (generation token,
215
+ // house style).
216
+ const generationRef = useRef(0);
217
+
218
+ const setContentType = useCallback(
219
+ (value: string) => dispatch({ type: 'setContentType', value }),
220
+ [],
221
+ );
222
+ const setCacheControl = useCallback(
223
+ (value: string) => dispatch({ type: 'setCacheControl', value }),
224
+ [],
225
+ );
226
+ const setCustomKey = useCallback(
227
+ (id: string, key: string) => dispatch({ type: 'setCustomKey', id, key }),
228
+ [],
229
+ );
230
+ const setCustomValue = useCallback(
231
+ (id: string, value: string) => dispatch({ type: 'setCustomValue', id, value }),
232
+ [],
233
+ );
234
+ const addCustomEntry = useCallback(
235
+ (key?: string, value?: string) => dispatch({ type: 'addCustomEntry', key, value }),
236
+ [],
237
+ );
238
+ const removeCustomEntry = useCallback(
239
+ (id: string) => dispatch({ type: 'removeCustomEntry', id }),
240
+ [],
241
+ );
242
+ const reset = useCallback(() => dispatch({ type: 'reset' }), []);
243
+
244
+ const { draft, initial, errorCount } = state;
245
+
246
+ const toPatch = useCallback((): SettableMetadata => {
247
+ return {
248
+ contentType: draft.contentType.trim() === '' ? undefined : draft.contentType,
249
+ cacheControl: draft.cacheControl.trim() === '' ? undefined : draft.cacheControl,
250
+ customMetadata: Object.fromEntries(
251
+ draft.custom.filter((e) => e.key.trim() !== '').map((e) => [e.key, e.value]),
252
+ ),
253
+ };
254
+ }, [draft]);
255
+
256
+ const save = useCallback(async (): Promise<FullMetadata | undefined> => {
257
+ const myGen = ++generationRef.current;
258
+ if (storage == null || path == null) {
259
+ setSaveError(new Error('useMetadataEditor: storage or path is null'));
260
+ return undefined;
261
+ }
262
+ if (errorCount > 0) {
263
+ setSaveError(new Error('useMetadataEditor: draft has validation errors'));
264
+ return undefined;
265
+ }
266
+ setSaveError(undefined);
267
+ setIsSaving(true);
268
+ try {
269
+ const next = await updateMetadata(
270
+ refFn(storage, normalizeStoragePath(path)),
271
+ toPatch(),
272
+ );
273
+ if (myGen === generationRef.current) {
274
+ dispatch({ type: 'commit' });
275
+ }
276
+ return next;
277
+ } catch (e) {
278
+ if (myGen === generationRef.current) {
279
+ setSaveError(e instanceof Error ? e : new Error(String(e)));
280
+ }
281
+ return undefined;
282
+ } finally {
283
+ if (myGen === generationRef.current) setIsSaving(false);
284
+ }
285
+ }, [storage, path, errorCount, toPatch]);
286
+
287
+ const isDirty = draft !== initial;
288
+ const isValid = errorCount === 0;
289
+
290
+ return useMemo<UseMetadataEditorResult>(
291
+ () => ({
292
+ contentType: draft.contentType,
293
+ cacheControl: draft.cacheControl,
294
+ custom: draft.custom,
295
+ isValid,
296
+ isDirty,
297
+ errorCount,
298
+ dispatch,
299
+ setContentType,
300
+ setCacheControl,
301
+ setCustomKey,
302
+ setCustomValue,
303
+ addCustomEntry,
304
+ removeCustomEntry,
305
+ reset,
306
+ toPatch,
307
+ save,
308
+ isSaving,
309
+ saveError,
310
+ }),
311
+ [
312
+ draft,
313
+ isValid,
314
+ isDirty,
315
+ errorCount,
316
+ setContentType,
317
+ setCacheControl,
318
+ setCustomKey,
319
+ setCustomValue,
320
+ addCustomEntry,
321
+ removeCustomEntry,
322
+ reset,
323
+ toPatch,
324
+ save,
325
+ isSaving,
326
+ saveError,
327
+ ],
328
+ );
329
+ }
@@ -0,0 +1,262 @@
1
+ import { useCallback, useRef, useState } from 'react';
2
+ import type {
3
+ FirebaseStorage,
4
+ FullMetadata,
5
+ SettableMetadata,
6
+ } from 'pyric/storage';
7
+ import { folderPlaceholderRef } from '../folderPlaceholder.js';
8
+ import { useStorageApi } from '../storageApi.js';
9
+ import { normalizeStoragePath } from './usePathState.js';
10
+ import type { UseStorageListResult } from './useStorageList.js';
11
+
12
+ export type UploadTaskStatus = 'running' | 'success' | 'error';
13
+
14
+ /**
15
+ * One file's upload, TASK-SHAPED for resumable forward-compat: the
16
+ * byte counters and the `onProgress` callback are in the type NOW so
17
+ * a future `uploadBytesResumable`-backed implementation emits real
18
+ * intermediate snapshots without a breaking change. Today
19
+ * (`pyric/storage` has no resumable uploads — COMPAT) a task
20
+ * completes in one tick: `onProgress` fires once at 0 bytes and once
21
+ * at `totalBytes`.
22
+ */
23
+ export interface UploadTask {
24
+ /** Stable id — key task rows on this, not on `fullPath` (two
25
+ * uploads can target the same path). */
26
+ id: string;
27
+ /** Bucket-rooted destination path. */
28
+ fullPath: string;
29
+ status: UploadTaskStatus;
30
+ bytesTransferred: number;
31
+ totalBytes: number;
32
+ /** Populated on `'success'`. */
33
+ metadata?: FullMetadata;
34
+ /** Populated on `'error'` — a typed `StorageError` from the
35
+ * sandbox (`.code` is `storage/<code>`, e.g.
36
+ * `storage/unauthorized` for a rules-denied write). */
37
+ error?: Error;
38
+ }
39
+
40
+ /** Explicit-path upload input. `path` is relative to the hook's
41
+ * `path` option (the destination folder). */
42
+ export interface UploadEntry {
43
+ path: string;
44
+ data: Blob | Uint8Array | ArrayBuffer;
45
+ metadata?: SettableMetadata;
46
+ }
47
+
48
+ /**
49
+ * `upload()` accepts plain `File`s (destination = the file's
50
+ * `webkitRelativePath` when present — folder drops keep their
51
+ * structure — else its `name`) or explicit {@link UploadEntry}s.
52
+ */
53
+ export type UploadInput = File | UploadEntry;
54
+
55
+ export interface UseObjectUploadOptions {
56
+ /** Destination folder, bucket-rooted. Default `''` (root). Wire to
57
+ * `usePathState().path` so uploads land in the browsed folder. */
58
+ path?: string;
59
+ /**
60
+ * Optimistic seam from `useStorageList`: each upload inserts its
61
+ * path immediately and rolls back via `removeItem` on failure.
62
+ * Caveat: rolling back an upload that was OVERWRITING an existing
63
+ * object drops that object's row locally (the seam can't tell an
64
+ * optimistic row from a listed one) — `refresh()` restores server
65
+ * truth.
66
+ */
67
+ list?: Pick<UseStorageListResult, 'insertItem' | 'removeItem'>;
68
+ /** Task-shaped progress callback (see {@link UploadTask}). */
69
+ onProgress?: (task: UploadTask) => void;
70
+ /** Fired once per task reaching `'success'`. */
71
+ onComplete?: (task: UploadTask) => void;
72
+ /** Fired once per task reaching `'error'`. */
73
+ onError?: (task: UploadTask) => void;
74
+ }
75
+
76
+ export interface UseObjectUploadResult {
77
+ /** Every task started by this hook instance, oldest first. */
78
+ tasks: UploadTask[];
79
+ /** `true` while any task is `'running'`. */
80
+ isUploading: boolean;
81
+ /**
82
+ * Upload one or many files. Tasks run concurrently; the promise
83
+ * resolves with the settled tasks once ALL finish and never
84
+ * rejects — per-file failures land on `task.error` (and
85
+ * `onError`), so one bad file doesn't mask the others.
86
+ */
87
+ upload: (input: UploadInput | UploadInput[]) => Promise<UploadTask[]>;
88
+ /**
89
+ * Create an empty folder under the hook's `path`: writes the GCS
90
+ * placeholder convention — a zero-byte object named `<path>/`
91
+ * (trailing slash). `listAll` hides the placeholder from `items`
92
+ * at every level (it only surfaces as a prefix), so the folder
93
+ * appears in the browser with no phantom file inside.
94
+ *
95
+ * `ref()` normalizes the trailing slash away, so the placeholder is
96
+ * written through a structural value-object reference the sandbox
97
+ * accepts. Throws the underlying error after rolling back the
98
+ * optimistic prefix insert.
99
+ *
100
+ * ALTERNATIVE: when the store must stay free of placeholder
101
+ * objects (Pyric Studio's choice), use the client-side
102
+ * pending-prefix mechanism instead — see `pendingPrefixes.ts`
103
+ * for the reducer and the recorded tradeoff.
104
+ */
105
+ createFolder: (name: string) => Promise<void>;
106
+ /** Drop settled (`success`/`error`) tasks from `tasks`. */
107
+ clearCompleted: () => void;
108
+ }
109
+
110
+ function joinPath(base: string, child: string): string {
111
+ if (base === '') return child;
112
+ if (child === '') return base;
113
+ return `${base}/${child}`;
114
+ }
115
+
116
+ function sizeOf(data: Blob | Uint8Array | ArrayBuffer): number {
117
+ if (data instanceof Blob) return data.size;
118
+ return data.byteLength;
119
+ }
120
+
121
+ function toEntry(input: UploadInput): UploadEntry {
122
+ if (input instanceof Blob) {
123
+ // File (the only Blob subtype `UploadInput` admits). Folder
124
+ // drops carry `webkitRelativePath`; plain picks carry `name`.
125
+ const file = input as File;
126
+ const rel = (file as { webkitRelativePath?: string }).webkitRelativePath;
127
+ return { path: rel || file.name, data: file };
128
+ }
129
+ return input;
130
+ }
131
+
132
+ /**
133
+ * Multi-file upload over the package's single Storage handle prop.
134
+ * Headless: returns task state; render it however you like (the
135
+ * `<UploadDropzone>` component is one producer of `upload()` calls).
136
+ *
137
+ * Optimistic-with-rollback: with the `list` seam wired, each upload's
138
+ * row appears in `useStorageList` immediately and disappears again if
139
+ * the write fails (typed `StorageError` on `task.error`).
140
+ */
141
+ export function useObjectUpload(
142
+ storage: FirebaseStorage | null | undefined,
143
+ options: UseObjectUploadOptions = {},
144
+ ): UseObjectUploadResult {
145
+ // Injected backend (in-process `pyric/storage` by default, the
146
+ // SharedWorker client bundle in Studio served mode) — uploads follow
147
+ // the same seam the browse hooks read through. The worker leg caps a
148
+ // payload at 8 MiB (base64 `storage.putBytes`); in-process writes are
149
+ // uncapped. An over-cap file fails as a normal per-file task error.
150
+ const { ref: refFn, uploadBytes } = useStorageApi();
151
+ const base = normalizeStoragePath(options.path ?? '');
152
+ const [tasks, setTasks] = useState<UploadTask[]>([]);
153
+
154
+ // Latest-value refs so `upload`/`createFolder` stay referentially
155
+ // stable across option changes (same pattern as the house hooks'
156
+ // generation tokens — the callbacks read current options at call
157
+ // time).
158
+ const optionsRef = useRef(options);
159
+ optionsRef.current = options;
160
+
161
+ const patchTask = useCallback((next: UploadTask) => {
162
+ setTasks((prev) => prev.map((t) => (t.id === next.id ? next : t)));
163
+ }, []);
164
+
165
+ const upload = useCallback(
166
+ async (input: UploadInput | UploadInput[]): Promise<UploadTask[]> => {
167
+ if (storage == null) {
168
+ throw new Error('useObjectUpload: storage handle is null');
169
+ }
170
+ const inputs = Array.isArray(input) ? input : [input];
171
+ const started = inputs.map((raw) => {
172
+ const entry = toEntry(raw);
173
+ const task: UploadTask = {
174
+ id: crypto.randomUUID(),
175
+ fullPath: joinPath(base, normalizeStoragePath(entry.path)),
176
+ status: 'running',
177
+ bytesTransferred: 0,
178
+ totalBytes: sizeOf(entry.data),
179
+ };
180
+ return { task, entry };
181
+ });
182
+
183
+ setTasks((prev) => [...prev, ...started.map((s) => s.task)]);
184
+ for (const { task } of started) {
185
+ optionsRef.current.list?.insertItem(task.fullPath);
186
+ optionsRef.current.onProgress?.(task);
187
+ }
188
+
189
+ return Promise.all(
190
+ started.map(async ({ task, entry }) => {
191
+ try {
192
+ const result = await uploadBytes(
193
+ refFn(storage, task.fullPath),
194
+ entry.data,
195
+ entry.metadata,
196
+ );
197
+ const done: UploadTask = {
198
+ ...task,
199
+ status: 'success',
200
+ bytesTransferred: task.totalBytes,
201
+ metadata: result.metadata,
202
+ };
203
+ patchTask(done);
204
+ optionsRef.current.onProgress?.(done);
205
+ optionsRef.current.onComplete?.(done);
206
+ return done;
207
+ } catch (e) {
208
+ const failed: UploadTask = {
209
+ ...task,
210
+ status: 'error',
211
+ error: e instanceof Error ? e : new Error(String(e)),
212
+ };
213
+ // Roll the optimistic row back before surfacing.
214
+ optionsRef.current.list?.removeItem(task.fullPath);
215
+ patchTask(failed);
216
+ optionsRef.current.onError?.(failed);
217
+ return failed;
218
+ }
219
+ }),
220
+ );
221
+ },
222
+ [storage, base, patchTask, refFn, uploadBytes],
223
+ );
224
+
225
+ const createFolder = useCallback(
226
+ async (name: string): Promise<void> => {
227
+ if (storage == null) {
228
+ throw new Error('useObjectUpload: storage handle is null');
229
+ }
230
+ const folderPath = joinPath(base, normalizeStoragePath(name));
231
+ if (folderPath === '') {
232
+ throw new Error('createFolder: folder name is empty');
233
+ }
234
+ // Trailing slash → the seam inserts a prefix, not an item.
235
+ optionsRef.current.list?.insertItem(`${folderPath}/`);
236
+ try {
237
+ await uploadBytes(
238
+ folderPlaceholderRef(storage, folderPath),
239
+ new Blob([]),
240
+ // Matches the emulator UI's placeholder content type.
241
+ { contentType: 'text/plain' },
242
+ );
243
+ } catch (e) {
244
+ optionsRef.current.list?.removeItem(folderPath);
245
+ throw e;
246
+ }
247
+ },
248
+ [storage, base, uploadBytes],
249
+ );
250
+
251
+ const clearCompleted = useCallback(() => {
252
+ setTasks((prev) => prev.filter((t) => t.status === 'running'));
253
+ }, []);
254
+
255
+ return {
256
+ tasks,
257
+ isUploading: tasks.some((t) => t.status === 'running'),
258
+ upload,
259
+ createFolder,
260
+ clearCompleted,
261
+ };
262
+ }