@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.
- package/package.json +5 -4
- package/src/agents/ContextWindowUsage.tsx +514 -0
- package/src/agents/EmptyState.tsx +27 -0
- package/src/agents/Fold.tsx +64 -0
- package/src/agents/Modal.tsx +65 -0
- package/src/agents/PulsingDot.tsx +28 -0
- package/src/agents/inbrowser-agent-usage.d.ts +141 -0
- package/src/agents/index.ts +28 -0
- package/src/auth/authApi.ts +72 -0
- package/src/auth/claims.ts +63 -0
- package/src/auth/components/AuthProviderToggles.tsx +147 -0
- package/src/auth/components/AuthSignInHelper.tsx +197 -0
- package/src/auth/components/AuthUserForm.tsx +328 -0
- package/src/auth/components/AuthUserList.tsx +219 -0
- package/src/auth/components/ClaimsField.tsx +50 -0
- package/src/auth/components/confirmActions.tsx +114 -0
- package/src/auth/controller.ts +173 -0
- package/src/auth/hooks/index.ts +36 -0
- package/src/auth/hooks/useAuthFlowHelper.ts +55 -0
- package/src/auth/hooks/useAuthProviderConfig.ts +139 -0
- package/src/auth/hooks/useAuthUserEditor.ts +76 -0
- package/src/auth/hooks/useAuthUsers.ts +154 -0
- package/src/auth/index.ts +42 -0
- package/src/auth/providers.ts +28 -0
- package/src/auth/reducers/userEditor.ts +186 -0
- package/src/events/components/ActivityActionItems.tsx +136 -0
- package/src/events/components/ActivityGrid.tsx +197 -0
- package/src/events/components/ActivityGridRow.tsx +65 -0
- package/src/events/components/ProposedChangeDiff.tsx +175 -0
- package/src/events/components/format.ts +21 -0
- package/src/events/components/index.ts +17 -0
- package/src/events/digest.ts +630 -0
- package/src/events/hooks/index.ts +9 -0
- package/src/events/hooks/useActivityDigest.ts +42 -0
- package/src/events/hooks/useActivityStream.ts +86 -0
- package/src/events/index.ts +39 -0
- package/src/events/types.ts +152 -0
- package/src/firestore/components/CollectionList.tsx +78 -0
- package/src/firestore/components/DeleteWithConfirm.tsx +88 -0
- package/src/firestore/components/DocumentEditor.tsx +350 -0
- package/src/firestore/components/DocumentList.tsx +217 -0
- package/src/firestore/components/DocumentPreview.tsx +265 -0
- package/src/firestore/components/FieldRenderer.tsx +25 -0
- package/src/firestore/components/QueryBuilder.tsx +181 -0
- package/src/firestore/components/ReferencePicker.tsx +212 -0
- package/src/firestore/components/TreeEntry.tsx +58 -0
- package/src/firestore/components/context.ts +25 -0
- package/src/firestore/fieldEditors/array.tsx +30 -0
- package/src/firestore/fieldEditors/boolean.tsx +39 -0
- package/src/firestore/fieldEditors/bytes.tsx +53 -0
- package/src/firestore/fieldEditors/geopoint.tsx +71 -0
- package/src/firestore/fieldEditors/map.tsx +33 -0
- package/src/firestore/fieldEditors/null.tsx +27 -0
- package/src/firestore/fieldEditors/number.tsx +43 -0
- package/src/firestore/fieldEditors/reference.tsx +87 -0
- package/src/firestore/fieldEditors/registry.ts +45 -0
- package/src/firestore/fieldEditors/string.tsx +33 -0
- package/src/firestore/fieldEditors/timestamp.tsx +75 -0
- package/src/firestore/fieldEditors/types.ts +68 -0
- package/src/firestore/fieldEditors/vector.tsx +142 -0
- package/src/firestore/firestoreApi.ts +86 -0
- package/src/firestore/hooks/coerceError.ts +34 -0
- package/src/firestore/hooks/index.ts +46 -0
- package/src/firestore/hooks/useCollectionList.ts +102 -0
- package/src/firestore/hooks/useDocumentEditor.ts +161 -0
- package/src/firestore/hooks/useDocumentList.ts +242 -0
- package/src/firestore/hooks/useDocumentSubcollections.ts +86 -0
- package/src/firestore/hooks/useFirestoreCollection.ts +51 -0
- package/src/firestore/hooks/useFirestoreDoc.ts +55 -0
- package/src/firestore/hooks/useQueryBuilder.ts +188 -0
- package/src/firestore/hooks/useRecursiveDelete.ts +77 -0
- package/src/firestore/hooks/useReferencePicker.ts +228 -0
- package/src/firestore/import/parseImport.ts +137 -0
- package/src/firestore/index.ts +87 -0
- package/src/firestore/reducers/defaults.ts +38 -0
- package/src/firestore/reducers/documentEditor.ts +239 -0
- package/src/firestore/reducers/tree.ts +140 -0
- package/src/firestore/reducers/types.ts +92 -0
- package/src/firestore/reducers/validation.ts +129 -0
- package/src/firestore/types.ts +231 -0
- package/src/firestore/validation/ids.ts +45 -0
- package/src/firestore/valueEquality.ts +43 -0
- package/src/index.ts +10 -0
- package/src/primitives/Badge.tsx +40 -0
- package/src/primitives/ConfirmDialog.tsx +137 -0
- package/src/primitives/CopyButton.tsx +62 -0
- package/src/primitives/JsonView.tsx +151 -0
- package/src/primitives/SegmentedControl.tsx +72 -0
- package/src/primitives/Toast.tsx +161 -0
- package/src/primitives/VirtualList.tsx +104 -0
- package/src/primitives/hooks/useContainerSize.ts +53 -0
- package/src/primitives/hooks/useUpdateHighlights.ts +109 -0
- package/src/primitives/index.ts +39 -0
- package/src/primitives/useConfirm.tsx +113 -0
- package/src/rtdb/components/RtdbPathBar.tsx +135 -0
- package/src/rtdb/components/RtdbTree.tsx +409 -0
- package/src/rtdb/editor.ts +79 -0
- package/src/rtdb/hooks/useRtdbTree.ts +137 -0
- package/src/rtdb/index.ts +66 -0
- package/src/rtdb/pathInput.ts +47 -0
- package/src/rtdb/reducers/tree.ts +191 -0
- package/src/rtdb/rtdbApi.ts +23 -0
- package/src/rtdb/values.ts +188 -0
- package/src/rules/components/DenialInspector.tsx +227 -0
- package/src/rules/components/format.ts +171 -0
- package/src/rules/components/index.ts +12 -0
- package/src/rules/components/scope.ts +75 -0
- package/src/rules/hooks/index.ts +5 -0
- package/src/rules/hooks/useDenialTrace.ts +100 -0
- package/src/rules/index.ts +24 -0
- package/src/rules/types.ts +91 -0
- package/src/storage/collisionRename.ts +114 -0
- package/src/storage/components/DeleteSelectionWithConfirm.tsx +193 -0
- package/src/storage/components/ObjectBrowser.tsx +219 -0
- package/src/storage/components/ObjectInspector.tsx +169 -0
- package/src/storage/components/PathBreadcrumb.tsx +84 -0
- package/src/storage/components/UploadDropzone.tsx +182 -0
- package/src/storage/folderPlaceholder.ts +40 -0
- package/src/storage/hooks/index.ts +59 -0
- package/src/storage/hooks/useMetadataEditor.ts +329 -0
- package/src/storage/hooks/useObjectUpload.ts +262 -0
- package/src/storage/hooks/usePathState.ts +94 -0
- package/src/storage/hooks/useStorageDelete.ts +195 -0
- package/src/storage/hooks/useStorageList.ts +261 -0
- package/src/storage/hooks/useStorageObject.ts +162 -0
- package/src/storage/hooks/useStorageRulesGate.ts +270 -0
- package/src/storage/hooks/useStorageSelection.ts +90 -0
- package/src/storage/index.ts +59 -0
- package/src/storage/pendingPrefixes.ts +125 -0
- package/src/storage/previews.tsx +120 -0
- package/src/storage/storageApi.ts +54 -0
- package/src/traffic/components/RuleHeatmap.tsx +97 -0
- package/src/traffic/components/TrafficDetail.tsx +160 -0
- package/src/traffic/components/TrafficGroupRow.tsx +91 -0
- package/src/traffic/components/TrafficLineChart.tsx +139 -0
- package/src/traffic/components/TrafficLog.tsx +175 -0
- package/src/traffic/components/TrafficMetricCards.tsx +77 -0
- package/src/traffic/components/TrafficRow.tsx +69 -0
- package/src/traffic/components/TrafficStats.tsx +73 -0
- package/src/traffic/components/TrafficTimeline.tsx +289 -0
- package/src/traffic/components/format.ts +22 -0
- package/src/traffic/components/index.ts +22 -0
- package/src/traffic/hooks/index.ts +60 -0
- package/src/traffic/hooks/useRuleHeatmap.ts +92 -0
- package/src/traffic/hooks/useTrafficBuckets.ts +146 -0
- package/src/traffic/hooks/useTrafficFilter.ts +74 -0
- package/src/traffic/hooks/useTrafficGroups.ts +126 -0
- package/src/traffic/hooks/useTrafficMetrics.ts +250 -0
- package/src/traffic/hooks/useTrafficMonitor.ts +111 -0
- package/src/traffic/hooks/useTrafficStats.ts +77 -0
- package/src/traffic/index.ts +13 -0
- 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
|
+
}
|