@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,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
|
+
}
|