@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,219 @@
1
+ import type { ReactNode } from 'react';
2
+ import type { StorageReference } from 'pyric/storage';
3
+ import { VirtualList } from '../../primitives/VirtualList.js';
4
+ import { useContainerSize } from '../../primitives/hooks/useContainerSize.js';
5
+ import type {
6
+ StorageListEntry,
7
+ StorageListStatus,
8
+ } from '../hooks/useStorageList.js';
9
+ import type { UseStorageRulesGateResult } from '../hooks/useStorageRulesGate.js';
10
+
11
+ export interface ObjectBrowserProps {
12
+ /** The folders-first row model from `useStorageList`. */
13
+ entries: StorageListEntry[];
14
+ /** Drives the loading state (`'loading'` with no rows yet) and the
15
+ * idle short-circuit. Default `'success'` for static usage. */
16
+ status?: StorageListStatus;
17
+ /** Renders a `role="alert"` container with the message. Pair with
18
+ * the hook's typed `StorageError` for code-driven copy. */
19
+ error?: Error;
20
+ /** Folder row click — fired with the prefix's `fullPath`. Wire to
21
+ * `usePathState.enter` (or `setPath`). */
22
+ onNavigate?: (path: string) => void;
23
+ /** Object row click — fired with the object's reference. */
24
+ onSelect?: (ref: StorageReference) => void;
25
+ /** Marks the matching object row `data-pyric-selected` +
26
+ * `aria-selected`. */
27
+ selectedPath?: string;
28
+ /**
29
+ * Rules-aware affordances — pass `useStorageRulesGate(storage)`.
30
+ * Rows whose READ verdict denies are stamped `data-pyric-denied`
31
+ * (with the evaluator's reason trace on
32
+ * `data-pyric-denied-reason`). A denied folder row would throw
33
+ * `storage/unauthorized` on `listAll`; the stamp warns BEFORE the
34
+ * click. Rows stay clickable — the affordance is advisory and the
35
+ * sandbox enforcement layer remains authoritative.
36
+ */
37
+ gate?: Pick<UseStorageRulesGateResult, 'verdictFor'>;
38
+ /**
39
+ * Row-label slot. Default renders the entry name. The row button,
40
+ * its click wiring, and the `data-*` states stay with the
41
+ * component — the slot only owns the label content.
42
+ */
43
+ renderEntry?: (entry: StorageListEntry) => ReactNode;
44
+ /** Optional per-row action rendered as a sibling of the navigation/select
45
+ * button. Use this for independent controls such as selection checkboxes. */
46
+ renderRowAction?: (entry: StorageListEntry) => ReactNode;
47
+ emptyState?: ReactNode;
48
+ className?: string;
49
+ /**
50
+ * Above this row count, the list switches to virtualization via
51
+ * `<VirtualList>` — `listAll` has no pagination, so a big prefix
52
+ * arrives as one flat result and virtualization is the only
53
+ * defense. Default 100 (same as `<DocumentList>`). `Infinity`
54
+ * disables.
55
+ */
56
+ virtualizeThreshold?: number;
57
+ /** Estimated row height when virtualizing. Default 36. */
58
+ rowHeight?: number | ((index: number) => number);
59
+ /** Scroll-container height when virtualized. Default `'60vh'`. */
60
+ virtualizedHeight?: number | string;
61
+ }
62
+
63
+ /**
64
+ * Headless storage browser shell — renders `useStorageList`'s merged
65
+ * folder/object rows. Folder rows navigate (`onNavigate` with the
66
+ * prefix path), object rows select (`onSelect` with the ref). Below
67
+ * `virtualizeThreshold` renders a plain `<ul>`; above it, composes
68
+ * the package's `<VirtualList>`.
69
+ *
70
+ * Ships no visual styling. Consumers style via:
71
+ * - `[data-pyric-ui="object-browser"]` — the root (stamps `data-size`)
72
+ * - `…[data-pyric-loading]` / `[data-pyric-empty]` / `[data-pyric-error]`
73
+ * - `…[data-pyric-virtualized]` — virtualized mode
74
+ * - `[data-pyric-object-browser-items]` — the `<ul>` in plain mode
75
+ * - `[data-pyric-storage-entry]` — each row
76
+ * - `[data-pyric-storage-entry][data-pyric-entry-kind="folder"|"object"]`
77
+ * - `[data-pyric-storage-entry][data-pyric-entry-path="docs/a.txt"]`
78
+ * - `[data-pyric-storage-entry][data-pyric-denied]` — read-denied row
79
+ * (rules gate; reason on `data-pyric-denied-reason`)
80
+ * - `[data-pyric-entry-select]` — the row button
81
+ * - `[data-pyric-entry-select][data-pyric-selected]` — the selected object
82
+ * - `[data-pyric-storage-action]` — optional sibling row action
83
+ */
84
+ export function ObjectBrowser({
85
+ entries,
86
+ status = 'success',
87
+ error,
88
+ onNavigate,
89
+ onSelect,
90
+ selectedPath,
91
+ gate,
92
+ renderEntry,
93
+ renderRowAction,
94
+ emptyState,
95
+ className,
96
+ virtualizeThreshold = 100,
97
+ rowHeight = 36,
98
+ virtualizedHeight = '60vh',
99
+ }: ObjectBrowserProps) {
100
+ const { ref: rootRef, size } = useContainerSize<HTMLDivElement>();
101
+
102
+ if (error) {
103
+ return (
104
+ <div
105
+ className={className}
106
+ data-pyric-ui="object-browser"
107
+ data-pyric-error=""
108
+ role="alert"
109
+ >
110
+ {error.message}
111
+ </div>
112
+ );
113
+ }
114
+ if ((status === 'loading' || status === 'idle') && entries.length === 0) {
115
+ return (
116
+ <div
117
+ className={className}
118
+ data-pyric-ui="object-browser"
119
+ data-pyric-loading={status === 'loading' ? '' : undefined}
120
+ data-pyric-idle={status === 'idle' ? '' : undefined}
121
+ />
122
+ );
123
+ }
124
+ if (entries.length === 0) {
125
+ return (
126
+ <div className={className} data-pyric-ui="object-browser" data-pyric-empty="">
127
+ {emptyState}
128
+ </div>
129
+ );
130
+ }
131
+
132
+ // Pre-flight read verdict per row (advisory — see the `gate` prop).
133
+ const denialFor = (
134
+ entry: StorageListEntry,
135
+ ): { denied: true; reason: string } | { denied: false; reason?: undefined } => {
136
+ if (!gate) return { denied: false };
137
+ const verdict = gate.verdictFor(entry.fullPath);
138
+ return verdict.read
139
+ ? { denied: false }
140
+ : { denied: true, reason: verdict.reasons.read.join('; ') };
141
+ };
142
+
143
+ const renderRow = (entry: StorageListEntry) => {
144
+ const selected = entry.kind === 'object' && entry.fullPath === selectedPath;
145
+ return (
146
+ <>
147
+ <button
148
+ type="button"
149
+ onClick={() =>
150
+ entry.kind === 'folder'
151
+ ? onNavigate?.(entry.fullPath)
152
+ : onSelect?.(entry.ref)
153
+ }
154
+ data-pyric-entry-select
155
+ data-pyric-selected={selected ? '' : undefined}
156
+ aria-selected={selected || undefined}
157
+ >
158
+ {renderEntry ? renderEntry(entry) : entry.name}
159
+ </button>
160
+ {renderRowAction ? (
161
+ <span data-pyric-storage-action>{renderRowAction(entry)}</span>
162
+ ) : null}
163
+ </>
164
+ );
165
+ };
166
+
167
+ const virtualized = entries.length > virtualizeThreshold;
168
+
169
+ return (
170
+ <div
171
+ ref={rootRef}
172
+ className={className}
173
+ data-pyric-ui="object-browser"
174
+ data-size={size}
175
+ data-pyric-virtualized={virtualized ? '' : undefined}
176
+ >
177
+ {virtualized ? (
178
+ <VirtualList
179
+ items={entries}
180
+ estimateSize={rowHeight}
181
+ height={virtualizedHeight}
182
+ getItemKey={(entry) => `${entry.kind}:${entry.fullPath}`}
183
+ renderItem={(entry) => {
184
+ const denial = denialFor(entry);
185
+ return (
186
+ <div
187
+ data-pyric-storage-entry
188
+ data-pyric-entry-kind={entry.kind}
189
+ data-pyric-entry-path={entry.fullPath}
190
+ data-pyric-denied={denial.denied ? '' : undefined}
191
+ data-pyric-denied-reason={denial.reason}
192
+ >
193
+ {renderRow(entry)}
194
+ </div>
195
+ );
196
+ }}
197
+ />
198
+ ) : (
199
+ <ul data-pyric-object-browser-items>
200
+ {entries.map((entry) => {
201
+ const denial = denialFor(entry);
202
+ return (
203
+ <li
204
+ key={`${entry.kind}:${entry.fullPath}`}
205
+ data-pyric-storage-entry
206
+ data-pyric-entry-kind={entry.kind}
207
+ data-pyric-entry-path={entry.fullPath}
208
+ data-pyric-denied={denial.denied ? '' : undefined}
209
+ data-pyric-denied-reason={denial.reason}
210
+ >
211
+ {renderRow(entry)}
212
+ </li>
213
+ );
214
+ })}
215
+ </ul>
216
+ )}
217
+ </div>
218
+ );
219
+ }
@@ -0,0 +1,169 @@
1
+ import { useEffect, type ReactNode } from 'react';
2
+ import type { FirebaseStorage, FullMetadata } from 'pyric/storage';
3
+ import { useContainerSize } from '../../primitives/hooks/useContainerSize.js';
4
+ import { useStorageObject } from '../hooks/useStorageObject.js';
5
+ import { selectStoragePreview, type StoragePreview } from '../previews.js';
6
+
7
+ export interface ObjectInspectorProps {
8
+ /** The package's sandbox Storage handle. */
9
+ storage: FirebaseStorage | null | undefined;
10
+ /** Object path to inspect. `null` renders the idle shell — keep
11
+ * the inspector mounted and swap paths as the user selects rows. */
12
+ path: string | null;
13
+ /** Consumer previews, tried BEFORE the built-ins (first match
14
+ * wins) — the extension channel of the preview registry. */
15
+ previews?: StoragePreview[];
16
+ /** Metadata-section slot. Default renders the standard field list.
17
+ * The header, preview, and state wiring stay with the component. */
18
+ renderMetadata?: (metadata: FullMetadata) => ReactNode;
19
+ /** Extra content below the preview (metadata editor, delete
20
+ * button, …). */
21
+ children?: ReactNode;
22
+ className?: string;
23
+ }
24
+
25
+ const METADATA_FIELDS: ReadonlyArray<{
26
+ key: string;
27
+ label: string;
28
+ value: (md: FullMetadata) => string | undefined;
29
+ }> = [
30
+ { key: 'fullPath', label: 'Path', value: (md) => md.fullPath },
31
+ { key: 'size', label: 'Size', value: (md) => `${md.size}` },
32
+ { key: 'contentType', label: 'Content type', value: (md) => md.contentType },
33
+ { key: 'cacheControl', label: 'Cache control', value: (md) => md.cacheControl },
34
+ { key: 'timeCreated', label: 'Created', value: (md) => md.timeCreated },
35
+ { key: 'updated', label: 'Updated', value: (md) => md.updated },
36
+ ];
37
+
38
+ function DefaultMetadata({ metadata }: { metadata: FullMetadata }) {
39
+ return (
40
+ <dl data-pyric-object-metadata>
41
+ {METADATA_FIELDS.map(({ key, label, value }) => {
42
+ const v = value(metadata);
43
+ if (v === undefined) return null;
44
+ return (
45
+ <div key={key} data-pyric-metadata-field={key}>
46
+ <dt>{label}</dt>
47
+ <dd>{v}</dd>
48
+ </div>
49
+ );
50
+ })}
51
+ {Object.entries(metadata.customMetadata ?? {}).map(([k, v]) => (
52
+ <div key={`custom:${k}`} data-pyric-metadata-field="customMetadata" data-pyric-metadata-key={k}>
53
+ <dt>{k}</dt>
54
+ <dd>{v}</dd>
55
+ </div>
56
+ ))}
57
+ </dl>
58
+ );
59
+ }
60
+
61
+ /**
62
+ * Headless inspector for one storage object: metadata + a
63
+ * content-type-driven preview. Previews come from the registry
64
+ * (`image/*` and `text/* + application/json` built in; extend via
65
+ * `previews`). Blob bytes load lazily and ONLY when the matched
66
+ * preview asks (`needsBlob`) and the object is within the preview's
67
+ * `maxBytes` cap; the blob URL is revoked on unmount/path change
68
+ * (see `useStorageObject`).
69
+ *
70
+ * Ships no visual styling. Consumers style via:
71
+ * - `[data-pyric-ui="object-inspector"]` — root (stamps `data-size`)
72
+ * - `…[data-pyric-idle]` / `[data-pyric-loading]` / `[data-pyric-error]`
73
+ * - `[data-pyric-object-name]` / `[data-pyric-object-metadata]`
74
+ * - `[data-pyric-metadata-field="<field>"]` — each metadata row
75
+ * - `[data-pyric-object-preview]` — the preview container, stamping
76
+ * `data-pyric-preview="<id>"` for the matched registry entry
77
+ * - `…[data-pyric-preview-loading]` — blob in flight
78
+ * - `…[data-pyric-preview-error]` — blob load failed
79
+ * - `…[data-pyric-preview-too-large]` — over the preview's cap
80
+ * - `…[data-pyric-preview-none]` — no registry match (metadata-only)
81
+ */
82
+ export function ObjectInspector({
83
+ storage,
84
+ path,
85
+ previews,
86
+ renderMetadata,
87
+ children,
88
+ className,
89
+ }: ObjectInspectorProps) {
90
+ const { ref: rootRef, size } = useContainerSize<HTMLDivElement>();
91
+ const object = useStorageObject(storage, path);
92
+ const { metadata, blobStatus, loadBlob } = object;
93
+
94
+ const preview = metadata ? selectStoragePreview(metadata, previews) : undefined;
95
+ const tooLarge =
96
+ preview?.maxBytes !== undefined &&
97
+ metadata !== undefined &&
98
+ metadata.size > preview.maxBytes;
99
+ const wantsBlob = preview?.needsBlob === true && !tooLarge;
100
+
101
+ // Auto-load the bytes when (and only when) the matched preview
102
+ // needs them. `blobStatus` resets to 'idle' on path change, which
103
+ // re-arms this effect for the next object.
104
+ useEffect(() => {
105
+ if (wantsBlob && blobStatus === 'idle') loadBlob();
106
+ }, [wantsBlob, blobStatus, loadBlob]);
107
+
108
+ if (object.status === 'idle') {
109
+ return (
110
+ <div className={className} data-pyric-ui="object-inspector" data-pyric-idle="" />
111
+ );
112
+ }
113
+ if (object.status === 'loading') {
114
+ return (
115
+ <div className={className} data-pyric-ui="object-inspector" data-pyric-loading="" />
116
+ );
117
+ }
118
+ if (object.status === 'error' || metadata === undefined) {
119
+ return (
120
+ <div
121
+ className={className}
122
+ data-pyric-ui="object-inspector"
123
+ data-pyric-error=""
124
+ role="alert"
125
+ >
126
+ {object.error?.message}
127
+ </div>
128
+ );
129
+ }
130
+
131
+ let previewContent: ReactNode;
132
+ let previewState: Record<string, ''> = {};
133
+ if (preview === undefined) {
134
+ previewState = { 'data-pyric-preview-none': '' };
135
+ } else if (tooLarge) {
136
+ previewState = { 'data-pyric-preview-too-large': '' };
137
+ } else if (wantsBlob && blobStatus === 'loading') {
138
+ previewState = { 'data-pyric-preview-loading': '' };
139
+ } else if (wantsBlob && blobStatus === 'error') {
140
+ previewState = { 'data-pyric-preview-error': '' };
141
+ previewContent = object.blobError?.message;
142
+ } else if (!wantsBlob || blobStatus === 'success') {
143
+ previewContent = preview.render({
144
+ metadata,
145
+ blob: object.blob,
146
+ blobUrl: object.blobUrl,
147
+ });
148
+ }
149
+
150
+ return (
151
+ <div
152
+ ref={rootRef}
153
+ className={className}
154
+ data-pyric-ui="object-inspector"
155
+ data-size={size}
156
+ >
157
+ <div data-pyric-object-name>{metadata.name}</div>
158
+ {renderMetadata ? renderMetadata(metadata) : <DefaultMetadata metadata={metadata} />}
159
+ <div
160
+ data-pyric-object-preview
161
+ data-pyric-preview={preview?.id}
162
+ {...previewState}
163
+ >
164
+ {previewContent}
165
+ </div>
166
+ {children}
167
+ </div>
168
+ );
169
+ }
@@ -0,0 +1,84 @@
1
+ import type { ReactNode } from 'react';
2
+ import { normalizeStoragePath } from '../hooks/usePathState.js';
3
+
4
+ export interface PathBreadcrumbProps {
5
+ /** Current path. `''` renders just the root crumb. */
6
+ path: string;
7
+ /** Fired with the absolute path of the clicked crumb (`''` for
8
+ * root). Wire to `usePathState.setPath`. */
9
+ onNavigate?: (path: string) => void;
10
+ /** Label for the root crumb. Default `'/'` — pass the bucket name
11
+ * for a `gs://bucket` feel. */
12
+ rootLabel?: ReactNode;
13
+ /** Rendered between crumbs. Default `'/'`. */
14
+ separator?: ReactNode;
15
+ className?: string;
16
+ }
17
+
18
+ /**
19
+ * Headless breadcrumb for storage paths. Every crumb (including the
20
+ * current one) is a real `<button>` — clicking the current crumb is
21
+ * a cheap "refresh this level" affordance for consumers that wire
22
+ * `onNavigate` to a path-keyed loader.
23
+ *
24
+ * Ships no visual styling. Consumers style via:
25
+ * - `[data-pyric-ui="path-breadcrumb"]` — the `<nav>` root
26
+ * - `[data-pyric-breadcrumb-item]` — each `<li>`
27
+ * - `[data-pyric-breadcrumb-link]` — each crumb button
28
+ * - `[data-pyric-breadcrumb-link][data-pyric-current]` — the current level
29
+ * - `[data-pyric-breadcrumb-root]` — the root crumb's button
30
+ * - `[data-pyric-breadcrumb-separator]` — the separators
31
+ */
32
+ export function PathBreadcrumb({
33
+ path,
34
+ onNavigate,
35
+ rootLabel = '/',
36
+ separator = '/',
37
+ className,
38
+ }: PathBreadcrumbProps) {
39
+ const normalized = normalizeStoragePath(path);
40
+ const segments = normalized === '' ? [] : normalized.split('/');
41
+
42
+ return (
43
+ <nav
44
+ className={className}
45
+ data-pyric-ui="path-breadcrumb"
46
+ aria-label="Storage path"
47
+ >
48
+ <ol data-pyric-breadcrumb-list>
49
+ <li data-pyric-breadcrumb-item>
50
+ <button
51
+ type="button"
52
+ onClick={() => onNavigate?.('')}
53
+ data-pyric-breadcrumb-link
54
+ data-pyric-breadcrumb-root
55
+ data-pyric-current={segments.length === 0 ? '' : undefined}
56
+ aria-current={segments.length === 0 ? 'page' : undefined}
57
+ >
58
+ {rootLabel}
59
+ </button>
60
+ </li>
61
+ {segments.map((segment, i) => {
62
+ const isCurrent = i === segments.length - 1;
63
+ const target = segments.slice(0, i + 1).join('/');
64
+ return (
65
+ <li key={target} data-pyric-breadcrumb-item>
66
+ <span aria-hidden data-pyric-breadcrumb-separator>
67
+ {separator}
68
+ </span>
69
+ <button
70
+ type="button"
71
+ onClick={() => onNavigate?.(target)}
72
+ data-pyric-breadcrumb-link
73
+ data-pyric-current={isCurrent ? '' : undefined}
74
+ aria-current={isCurrent ? 'page' : undefined}
75
+ >
76
+ {segment}
77
+ </button>
78
+ </li>
79
+ );
80
+ })}
81
+ </ol>
82
+ </nav>
83
+ );
84
+ }
@@ -0,0 +1,182 @@
1
+ import { useCallback, useRef, useState, type DragEvent, type ReactNode } from 'react';
2
+
3
+ /** One dropped file, flattened from the drop's file/folder tree. */
4
+ export interface DroppedFile {
5
+ file: File;
6
+ /**
7
+ * Path relative to the drop — `'a.txt'` for a plain file drop,
8
+ * `'photos/cat.png'` for a file inside a dropped folder. Feed
9
+ * straight into `useObjectUpload`:
10
+ * `upload(files.map((f) => ({ path: f.relativePath, data: f.file })))`.
11
+ */
12
+ relativePath: string;
13
+ }
14
+
15
+ export interface UploadDropzoneProps {
16
+ /**
17
+ * Fired once per drop with the flattened file list (folder drops
18
+ * are traversed recursively via `webkitGetAsEntry`; empty folders
19
+ * yield nothing — wire `useObjectUpload.createFolder` to your own
20
+ * "new folder" affordance instead). Not fired for empty drops.
21
+ */
22
+ onFiles: (files: DroppedFile[]) => void;
23
+ /** Slot — the dropzone chrome ("Drop files here…", a browse
24
+ * `<input type="file">`, anything). The component owns only the
25
+ * drag wiring + `data-*` states. */
26
+ children?: ReactNode;
27
+ /** Ignore drops + suppress the dragging state. */
28
+ disabled?: boolean;
29
+ /**
30
+ * Why the dropzone is disabled — stamped on
31
+ * `data-disabled-reason` (and only while `disabled`) so the
32
+ * chrome/styling can surface it. The canonical source is the rules
33
+ * gate: `disabled={!gate.verdictFor(path).upload}` +
34
+ * `disabledReason={gate.verdictFor(path).reasons.write.join('; ')}`.
35
+ */
36
+ disabledReason?: string;
37
+ className?: string;
38
+ }
39
+
40
+ /**
41
+ * Walk a `FileSystemEntry` tree depth-first, accumulating files with
42
+ * folder-relative paths. `readEntries` is batched per spec (Chrome
43
+ * caps a batch at 100) — loop until an empty batch.
44
+ */
45
+ async function walkEntry(
46
+ entry: FileSystemEntry,
47
+ prefix: string,
48
+ out: DroppedFile[],
49
+ ): Promise<void> {
50
+ const path = prefix === '' ? entry.name : `${prefix}/${entry.name}`;
51
+ if (entry.isFile) {
52
+ const file = await new Promise<File>((resolve, reject) =>
53
+ (entry as FileSystemFileEntry).file(resolve, reject),
54
+ );
55
+ out.push({ file, relativePath: path });
56
+ return;
57
+ }
58
+ if (entry.isDirectory) {
59
+ const reader = (entry as FileSystemDirectoryEntry).createReader();
60
+ for (;;) {
61
+ const batch = await new Promise<FileSystemEntry[]>((resolve, reject) =>
62
+ reader.readEntries(resolve, reject),
63
+ );
64
+ if (batch.length === 0) break;
65
+ for (const child of batch) {
66
+ await walkEntry(child, path, out);
67
+ }
68
+ }
69
+ }
70
+ }
71
+
72
+ /**
73
+ * Flatten a drop's `DataTransfer` into files. Prefers the
74
+ * `webkitGetAsEntry` tree (the only channel that exposes folder
75
+ * structure); items without it fall back to `getAsFile`, and a
76
+ * `DataTransfer` with no usable `items` falls back to `files`.
77
+ */
78
+ async function collectDroppedFiles(dt: DataTransfer): Promise<DroppedFile[]> {
79
+ const out: DroppedFile[] = [];
80
+ const items = dt.items ? Array.from(dt.items) : [];
81
+ for (const item of items) {
82
+ if (item.kind !== 'file') continue;
83
+ const getEntry = (
84
+ item as { webkitGetAsEntry?: () => FileSystemEntry | null }
85
+ ).webkitGetAsEntry;
86
+ const entry = typeof getEntry === 'function' ? getEntry.call(item) : null;
87
+ if (entry) {
88
+ await walkEntry(entry, '', out);
89
+ } else {
90
+ const file = item.getAsFile?.();
91
+ if (file) out.push({ file, relativePath: file.name });
92
+ }
93
+ }
94
+ if (out.length === 0) {
95
+ for (const file of Array.from(dt.files ?? [])) {
96
+ out.push({ file, relativePath: file.name });
97
+ }
98
+ }
99
+ return out;
100
+ }
101
+
102
+ /**
103
+ * Headless drop target for file + folder uploads. Slot-based: the
104
+ * children render the chrome; the component owns drag wiring and
105
+ * stamps `data-dragging` while a drag hovers (a counter tracks
106
+ * enter/leave pairs so crossing child elements doesn't flicker).
107
+ *
108
+ * Ships no visual styling. Consumers style via:
109
+ * - `[data-pyric-ui="upload-dropzone"]` — the root
110
+ * - `…[data-dragging]` — a drag is hovering
111
+ * - `…[data-disabled]`
112
+ * - `…[data-disabled-reason="…"]` — why (e.g. a denied write verdict)
113
+ */
114
+ export function UploadDropzone({
115
+ onFiles,
116
+ children,
117
+ disabled,
118
+ disabledReason,
119
+ className,
120
+ }: UploadDropzoneProps) {
121
+ const [dragging, setDragging] = useState(false);
122
+ // dragenter/dragleave fire for every child crossing — count the
123
+ // pairs and only clear at depth 0.
124
+ const depthRef = useRef(0);
125
+
126
+ const handleDragEnter = useCallback(
127
+ (e: DragEvent<HTMLDivElement>) => {
128
+ e.preventDefault();
129
+ if (disabled) return;
130
+ depthRef.current += 1;
131
+ setDragging(true);
132
+ },
133
+ [disabled],
134
+ );
135
+
136
+ const handleDragOver = useCallback((e: DragEvent<HTMLDivElement>) => {
137
+ // Required — without preventDefault the browser refuses the drop.
138
+ e.preventDefault();
139
+ }, []);
140
+
141
+ const handleDragLeave = useCallback(
142
+ (e: DragEvent<HTMLDivElement>) => {
143
+ e.preventDefault();
144
+ if (disabled) return;
145
+ depthRef.current = Math.max(0, depthRef.current - 1);
146
+ if (depthRef.current === 0) setDragging(false);
147
+ },
148
+ [disabled],
149
+ );
150
+
151
+ const handleDrop = useCallback(
152
+ (e: DragEvent<HTMLDivElement>) => {
153
+ e.preventDefault();
154
+ depthRef.current = 0;
155
+ setDragging(false);
156
+ if (disabled) return;
157
+ const dt = e.dataTransfer;
158
+ if (!dt) return;
159
+ void collectDroppedFiles(dt).then((files) => {
160
+ if (files.length > 0) onFiles(files);
161
+ });
162
+ },
163
+ [disabled, onFiles],
164
+ );
165
+
166
+ return (
167
+ <div
168
+ className={className}
169
+ data-pyric-ui="upload-dropzone"
170
+ data-dragging={dragging ? '' : undefined}
171
+ data-disabled={disabled ? '' : undefined}
172
+ data-disabled-reason={disabled ? disabledReason : undefined}
173
+ aria-disabled={disabled || undefined}
174
+ onDragEnter={handleDragEnter}
175
+ onDragOver={handleDragOver}
176
+ onDragLeave={handleDragLeave}
177
+ onDrop={handleDrop}
178
+ >
179
+ {children}
180
+ </div>
181
+ );
182
+ }
@@ -0,0 +1,40 @@
1
+ import { ref as refFn, type FirebaseStorage, type StorageReference } from 'pyric/storage';
2
+
3
+ /**
4
+ * Internal — build a reference to the trailing-slash folder
5
+ * placeholder (`<path>/`, the GCS create-folder convention).
6
+ *
7
+ * `StorageReference` is a documented structural value object (two
8
+ * refs with the same `(storage, fullPath)` are equal), so a plain
9
+ * object is a legal reference — the only way to express a `<path>/`
10
+ * name past `ref()`'s slash normalization. This helper is specific to
11
+ * the Pyric sandbox mirror.
12
+ */
13
+ export function folderPlaceholderRef(
14
+ storage: FirebaseStorage,
15
+ folderPath: string,
16
+ ): StorageReference {
17
+ const folder = refFn(storage, folderPath);
18
+ return {
19
+ storage,
20
+ bucket: folder.bucket,
21
+ fullPath: `${folder.fullPath}/`,
22
+ name: '',
23
+ parent: folder,
24
+ root: folder.root,
25
+ toString: () => `${folder.toString()}/`,
26
+ };
27
+ }
28
+
29
+ /** Preserve whichever backend identity a reference carries (in-process
30
+ * storage handle or worker MessagePort) while targeting its trailing-slash
31
+ * GCS folder placeholder. */
32
+ export function asFolderPlaceholder<T extends { fullPath: string; name: string }>(
33
+ folder: T,
34
+ ): T {
35
+ return {
36
+ ...folder,
37
+ fullPath: `${folder.fullPath}/`,
38
+ name: '',
39
+ };
40
+ }