@relayfile/file-observer 0.1.0
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/next-env.d.ts +5 -0
- package/next.config.js +10 -0
- package/package.json +62 -0
- package/postcss.config.mjs +5 -0
- package/src/app/globals.css +45 -0
- package/src/app/layout.tsx +25 -0
- package/src/app/page.tsx +1520 -0
- package/src/components/FileDetails.tsx +442 -0
- package/src/components/FileTree.tsx +490 -0
- package/src/components/WorkspaceSelector.tsx +127 -0
- package/src/hooks/useFileEvents.ts +267 -0
- package/src/hooks/useFileTree.ts +124 -0
- package/src/lib/relayfile-client.ts +459 -0
- package/tsconfig.json +23 -0
- package/wrangler.file-observer.toml +10 -0
|
@@ -0,0 +1,442 @@
|
|
|
1
|
+
'use client';
|
|
2
|
+
|
|
3
|
+
import { AlertCircle, FileCode2, FileText, FolderOpen, GitBranch, Link2, ScrollText, Shield, Workflow } from 'lucide-react';
|
|
4
|
+
|
|
5
|
+
type FileNodeType = 'file' | 'dir';
|
|
6
|
+
|
|
7
|
+
export interface FileDetailsSemantics {
|
|
8
|
+
properties?: Record<string, string>;
|
|
9
|
+
relations?: string[];
|
|
10
|
+
permissions?: string[];
|
|
11
|
+
comments?: string[];
|
|
12
|
+
}
|
|
13
|
+
|
|
14
|
+
export interface FileDetailsEntry {
|
|
15
|
+
path: string;
|
|
16
|
+
type: FileNodeType;
|
|
17
|
+
revision?: string;
|
|
18
|
+
provider?: string;
|
|
19
|
+
providerObjectId?: string;
|
|
20
|
+
size?: number;
|
|
21
|
+
updatedAt?: string;
|
|
22
|
+
contentType?: string;
|
|
23
|
+
properties?: Record<string, string>;
|
|
24
|
+
relations?: string[];
|
|
25
|
+
permissions?: string[];
|
|
26
|
+
comments?: string[];
|
|
27
|
+
}
|
|
28
|
+
|
|
29
|
+
export interface FileDetailsContent {
|
|
30
|
+
path: string;
|
|
31
|
+
revision: string;
|
|
32
|
+
contentType: string;
|
|
33
|
+
provider?: string;
|
|
34
|
+
providerObjectId?: string;
|
|
35
|
+
lastEditedAt?: string;
|
|
36
|
+
semantics?: FileDetailsSemantics;
|
|
37
|
+
}
|
|
38
|
+
|
|
39
|
+
export interface FileDetailsProps {
|
|
40
|
+
selectedEntry: FileDetailsEntry | null;
|
|
41
|
+
fileDetails?: FileDetailsContent | null;
|
|
42
|
+
loading?: boolean;
|
|
43
|
+
error?: string | null;
|
|
44
|
+
childCount?: number;
|
|
45
|
+
className?: string;
|
|
46
|
+
onRetry?: () => void;
|
|
47
|
+
}
|
|
48
|
+
|
|
49
|
+
type StatusTone = 'neutral' | 'info' | 'success' | 'warning' | 'danger';
|
|
50
|
+
|
|
51
|
+
function joinClasses(...values: Array<string | false | null | undefined>): string {
|
|
52
|
+
return values.filter(Boolean).join(' ');
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
function formatDate(value?: string): string {
|
|
56
|
+
if (!value) {
|
|
57
|
+
return 'Unknown';
|
|
58
|
+
}
|
|
59
|
+
|
|
60
|
+
const date = new Date(value);
|
|
61
|
+
if (Number.isNaN(date.getTime())) {
|
|
62
|
+
return value;
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
return new Intl.DateTimeFormat(undefined, {
|
|
66
|
+
dateStyle: 'medium',
|
|
67
|
+
timeStyle: 'short'
|
|
68
|
+
}).format(date);
|
|
69
|
+
}
|
|
70
|
+
|
|
71
|
+
function formatBytes(value?: number): string {
|
|
72
|
+
if (value === undefined || Number.isNaN(value)) {
|
|
73
|
+
return 'Unknown';
|
|
74
|
+
}
|
|
75
|
+
|
|
76
|
+
if (value < 1024) {
|
|
77
|
+
return `${value} B`;
|
|
78
|
+
}
|
|
79
|
+
|
|
80
|
+
const units = ['KB', 'MB', 'GB', 'TB'];
|
|
81
|
+
let size = value / 1024;
|
|
82
|
+
let unitIndex = 0;
|
|
83
|
+
|
|
84
|
+
while (size >= 1024 && unitIndex < units.length - 1) {
|
|
85
|
+
size /= 1024;
|
|
86
|
+
unitIndex += 1;
|
|
87
|
+
}
|
|
88
|
+
|
|
89
|
+
return `${size.toFixed(size >= 10 ? 0 : 1)} ${units[unitIndex]}`;
|
|
90
|
+
}
|
|
91
|
+
|
|
92
|
+
function getDisplayName(path: string): string {
|
|
93
|
+
const parts = path.split('/').filter(Boolean);
|
|
94
|
+
return parts[parts.length - 1] ?? path;
|
|
95
|
+
}
|
|
96
|
+
|
|
97
|
+
function mergeProperties(selectedEntry: FileDetailsEntry, fileDetails?: FileDetailsContent | null): Record<string, string> {
|
|
98
|
+
return {
|
|
99
|
+
...(selectedEntry.properties ?? {}),
|
|
100
|
+
...(fileDetails?.semantics?.properties ?? {})
|
|
101
|
+
};
|
|
102
|
+
}
|
|
103
|
+
|
|
104
|
+
function getPropertyValue(properties: Record<string, string>, keys: string[]): string | null {
|
|
105
|
+
for (const key of keys) {
|
|
106
|
+
const value = properties[key];
|
|
107
|
+
if (value?.trim()) {
|
|
108
|
+
return value.trim();
|
|
109
|
+
}
|
|
110
|
+
}
|
|
111
|
+
|
|
112
|
+
return null;
|
|
113
|
+
}
|
|
114
|
+
|
|
115
|
+
function getStatusTone(status: string | null): StatusTone {
|
|
116
|
+
const normalized = status?.trim().toLowerCase() ?? '';
|
|
117
|
+
|
|
118
|
+
if (!normalized) {
|
|
119
|
+
return 'neutral';
|
|
120
|
+
}
|
|
121
|
+
|
|
122
|
+
if (['ready', 'healthy', 'stable', 'complete', 'completed', 'synced', 'approved', 'active'].includes(normalized)) {
|
|
123
|
+
return 'success';
|
|
124
|
+
}
|
|
125
|
+
|
|
126
|
+
if (['draft', 'queued', 'pending', 'review', 'in-review', 'in progress', 'in-progress', 'processing', 'staged'].includes(normalized)) {
|
|
127
|
+
return 'info';
|
|
128
|
+
}
|
|
129
|
+
|
|
130
|
+
if (['warning', 'stale', 'lagging', 'paused', 'blocked', 'hold', 'on-hold'].includes(normalized)) {
|
|
131
|
+
return 'warning';
|
|
132
|
+
}
|
|
133
|
+
|
|
134
|
+
if (['error', 'failed', 'rejected', 'archived', 'deleted'].includes(normalized)) {
|
|
135
|
+
return 'danger';
|
|
136
|
+
}
|
|
137
|
+
|
|
138
|
+
return 'neutral';
|
|
139
|
+
}
|
|
140
|
+
|
|
141
|
+
function getStatusClasses(tone: StatusTone): string {
|
|
142
|
+
switch (tone) {
|
|
143
|
+
case 'success':
|
|
144
|
+
return 'border-emerald-500/30 bg-emerald-500/10 text-emerald-200';
|
|
145
|
+
case 'info':
|
|
146
|
+
return 'border-sky-500/30 bg-sky-500/10 text-sky-200';
|
|
147
|
+
case 'warning':
|
|
148
|
+
return 'border-amber-500/30 bg-amber-500/10 text-amber-200';
|
|
149
|
+
case 'danger':
|
|
150
|
+
return 'border-rose-500/30 bg-rose-500/10 text-rose-200';
|
|
151
|
+
default:
|
|
152
|
+
return 'border-zinc-500/40 bg-zinc-500/10 text-zinc-200';
|
|
153
|
+
}
|
|
154
|
+
}
|
|
155
|
+
|
|
156
|
+
function DetailSkeleton() {
|
|
157
|
+
return (
|
|
158
|
+
<div className="space-y-4 p-5">
|
|
159
|
+
<div className="h-7 w-2/3 animate-pulse rounded bg-[#1f1f23]" />
|
|
160
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
161
|
+
{Array.from({ length: 4 }, (_, index) => (
|
|
162
|
+
<div
|
|
163
|
+
key={index}
|
|
164
|
+
className="h-20 animate-pulse rounded-2xl border border-[#27272a] bg-[#111113]"
|
|
165
|
+
/>
|
|
166
|
+
))}
|
|
167
|
+
</div>
|
|
168
|
+
<div className="h-56 animate-pulse rounded-2xl border border-[#27272a] bg-[#111113]" />
|
|
169
|
+
</div>
|
|
170
|
+
);
|
|
171
|
+
}
|
|
172
|
+
|
|
173
|
+
function ErrorState({ message, onRetry }: { message: string; onRetry?: () => void }) {
|
|
174
|
+
return (
|
|
175
|
+
<div className="p-5">
|
|
176
|
+
<div className="rounded-2xl border border-red-500/30 bg-red-500/10 p-4 text-sm text-red-100">
|
|
177
|
+
<div className="flex items-start gap-3">
|
|
178
|
+
<AlertCircle className="mt-0.5 h-5 w-5 shrink-0 text-red-300" />
|
|
179
|
+
<div className="min-w-0 flex-1">
|
|
180
|
+
<p className="font-medium text-red-50">File details failed to load</p>
|
|
181
|
+
<p className="mt-1 text-red-100/85">{message}</p>
|
|
182
|
+
{onRetry ? (
|
|
183
|
+
<button
|
|
184
|
+
type="button"
|
|
185
|
+
onClick={onRetry}
|
|
186
|
+
className="mt-3 inline-flex items-center rounded-full border border-red-300/40 px-3 py-1.5 text-xs font-medium text-red-50 transition hover:border-red-200/60 hover:bg-red-400/10"
|
|
187
|
+
>
|
|
188
|
+
Retry
|
|
189
|
+
</button>
|
|
190
|
+
) : null}
|
|
191
|
+
</div>
|
|
192
|
+
</div>
|
|
193
|
+
</div>
|
|
194
|
+
</div>
|
|
195
|
+
);
|
|
196
|
+
}
|
|
197
|
+
|
|
198
|
+
function EmptyState() {
|
|
199
|
+
return (
|
|
200
|
+
<div className="flex min-h-[520px] items-center justify-center p-6">
|
|
201
|
+
<div className="max-w-sm text-center">
|
|
202
|
+
<div className="mx-auto flex h-12 w-12 items-center justify-center rounded-2xl border border-[#27272a] bg-[#111113]">
|
|
203
|
+
<FileText className="h-5 w-5 text-[#a1a1aa]" />
|
|
204
|
+
</div>
|
|
205
|
+
<h4 className="mt-4 text-base font-medium text-white">Select a file or directory</h4>
|
|
206
|
+
<p className="mt-2 text-sm text-[#a1a1aa]">
|
|
207
|
+
Choose an item from the tree or search results to inspect relayfile metadata, revisions, and semantic relations.
|
|
208
|
+
</p>
|
|
209
|
+
</div>
|
|
210
|
+
</div>
|
|
211
|
+
);
|
|
212
|
+
}
|
|
213
|
+
|
|
214
|
+
function ChipList({
|
|
215
|
+
items,
|
|
216
|
+
emptyLabel,
|
|
217
|
+
tone = 'default'
|
|
218
|
+
}: {
|
|
219
|
+
items: string[];
|
|
220
|
+
emptyLabel: string;
|
|
221
|
+
tone?: 'default' | 'subtle';
|
|
222
|
+
}) {
|
|
223
|
+
if (!items.length) {
|
|
224
|
+
return <span className="text-[#a1a1aa]">{emptyLabel}</span>;
|
|
225
|
+
}
|
|
226
|
+
|
|
227
|
+
return (
|
|
228
|
+
<div className="mt-2 flex flex-wrap gap-2">
|
|
229
|
+
{items.map((item) => (
|
|
230
|
+
<span
|
|
231
|
+
key={item}
|
|
232
|
+
className={joinClasses(
|
|
233
|
+
'rounded-full border px-3 py-1 text-xs',
|
|
234
|
+
tone === 'subtle'
|
|
235
|
+
? 'border-[#2f2f35] bg-[#0d0d10] text-[#b4b4bb]'
|
|
236
|
+
: 'border-[#3f3f46] bg-[#111113] text-[#d4d4d8]'
|
|
237
|
+
)}
|
|
238
|
+
>
|
|
239
|
+
{item}
|
|
240
|
+
</span>
|
|
241
|
+
))}
|
|
242
|
+
</div>
|
|
243
|
+
);
|
|
244
|
+
}
|
|
245
|
+
|
|
246
|
+
function StatCard({
|
|
247
|
+
label,
|
|
248
|
+
value
|
|
249
|
+
}: {
|
|
250
|
+
label: string;
|
|
251
|
+
value: string;
|
|
252
|
+
}) {
|
|
253
|
+
return (
|
|
254
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
255
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">{label}</p>
|
|
256
|
+
<p className="mt-2 break-words text-sm font-medium text-white">{value}</p>
|
|
257
|
+
</div>
|
|
258
|
+
);
|
|
259
|
+
}
|
|
260
|
+
|
|
261
|
+
export function FileDetails({
|
|
262
|
+
selectedEntry,
|
|
263
|
+
fileDetails,
|
|
264
|
+
loading = false,
|
|
265
|
+
error,
|
|
266
|
+
childCount,
|
|
267
|
+
className,
|
|
268
|
+
onRetry
|
|
269
|
+
}: FileDetailsProps) {
|
|
270
|
+
const selectedIsDirectory = selectedEntry?.type === 'dir';
|
|
271
|
+
|
|
272
|
+
if (loading) {
|
|
273
|
+
return (
|
|
274
|
+
<aside className={joinClasses('brand-card overflow-hidden', className)}>
|
|
275
|
+
<div className="border-b border-[#27272a] px-5 py-4">
|
|
276
|
+
<h3 className="text-sm font-semibold text-white">File details</h3>
|
|
277
|
+
<p className="mt-1 text-xs text-[#71717a]">Metadata and revision details update when a file is selected.</p>
|
|
278
|
+
</div>
|
|
279
|
+
<DetailSkeleton />
|
|
280
|
+
</aside>
|
|
281
|
+
);
|
|
282
|
+
}
|
|
283
|
+
|
|
284
|
+
if (error) {
|
|
285
|
+
return (
|
|
286
|
+
<aside className={joinClasses('brand-card overflow-hidden', className)}>
|
|
287
|
+
<div className="border-b border-[#27272a] px-5 py-4">
|
|
288
|
+
<h3 className="text-sm font-semibold text-white">File details</h3>
|
|
289
|
+
<p className="mt-1 text-xs text-[#71717a]">Metadata and revision details update when a file is selected.</p>
|
|
290
|
+
</div>
|
|
291
|
+
<ErrorState message={error} onRetry={onRetry} />
|
|
292
|
+
</aside>
|
|
293
|
+
);
|
|
294
|
+
}
|
|
295
|
+
|
|
296
|
+
if (!selectedEntry) {
|
|
297
|
+
return (
|
|
298
|
+
<aside className={joinClasses('brand-card overflow-hidden', className)}>
|
|
299
|
+
<div className="border-b border-[#27272a] px-5 py-4">
|
|
300
|
+
<h3 className="text-sm font-semibold text-white">File details</h3>
|
|
301
|
+
<p className="mt-1 text-xs text-[#71717a]">Metadata and revision details update when a file is selected.</p>
|
|
302
|
+
</div>
|
|
303
|
+
<EmptyState />
|
|
304
|
+
</aside>
|
|
305
|
+
);
|
|
306
|
+
}
|
|
307
|
+
|
|
308
|
+
const properties = mergeProperties(selectedEntry, fileDetails);
|
|
309
|
+
const relations = fileDetails?.semantics?.relations ?? selectedEntry.relations ?? [];
|
|
310
|
+
const permissions = fileDetails?.semantics?.permissions ?? selectedEntry.permissions ?? [];
|
|
311
|
+
const comments = fileDetails?.semantics?.comments ?? selectedEntry.comments ?? [];
|
|
312
|
+
const author =
|
|
313
|
+
getPropertyValue(properties, ['author', 'owner', 'updatedBy', 'lastEditedBy', 'editor']) ??
|
|
314
|
+
fileDetails?.provider ??
|
|
315
|
+
'Unassigned';
|
|
316
|
+
const intent =
|
|
317
|
+
getPropertyValue(properties, ['intent', 'purpose', 'summary', 'description']) ??
|
|
318
|
+
(selectedIsDirectory ? 'Folder structure and child revisions' : 'File metadata and semantic context');
|
|
319
|
+
const status =
|
|
320
|
+
getPropertyValue(properties, ['status', 'state', 'lifecycle', 'reviewStatus', 'syncStatus']) ??
|
|
321
|
+
(selectedIsDirectory ? 'active' : 'draft');
|
|
322
|
+
const revision = fileDetails?.revision ?? selectedEntry.revision ?? 'Unknown';
|
|
323
|
+
const lastUpdated = formatDate(fileDetails?.lastEditedAt ?? selectedEntry.updatedAt);
|
|
324
|
+
const statusTone = getStatusTone(status);
|
|
325
|
+
const statusClasses = getStatusClasses(statusTone);
|
|
326
|
+
|
|
327
|
+
return (
|
|
328
|
+
<aside className={joinClasses('brand-card overflow-hidden', className)}>
|
|
329
|
+
<div className="border-b border-[#27272a] px-5 py-4">
|
|
330
|
+
<h3 className="text-sm font-semibold text-white">File details</h3>
|
|
331
|
+
<p className="mt-1 text-xs text-[#71717a]">Metadata, semantic intent, relations, and revision history for the current selection.</p>
|
|
332
|
+
</div>
|
|
333
|
+
|
|
334
|
+
<div className="space-y-5 p-5">
|
|
335
|
+
<div className="flex items-start gap-3">
|
|
336
|
+
<div className="mt-1 rounded-xl border border-[#27272a] bg-[#111113] p-2">
|
|
337
|
+
{selectedIsDirectory ? (
|
|
338
|
+
<FolderOpen className="h-5 w-5 text-sky-300" />
|
|
339
|
+
) : (
|
|
340
|
+
<FileCode2 className="h-5 w-5 text-[#c4b5fd]" />
|
|
341
|
+
)}
|
|
342
|
+
</div>
|
|
343
|
+
<div className="min-w-0 flex-1">
|
|
344
|
+
<h4 className="truncate text-lg font-semibold text-white">{getDisplayName(selectedEntry.path)}</h4>
|
|
345
|
+
<p className="mt-1 break-all font-mono text-xs text-[#71717a]">{selectedEntry.path}</p>
|
|
346
|
+
</div>
|
|
347
|
+
</div>
|
|
348
|
+
|
|
349
|
+
<div className="grid gap-3 sm:grid-cols-2">
|
|
350
|
+
<StatCard label="Author" value={author} />
|
|
351
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
352
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Status</p>
|
|
353
|
+
<div className="mt-2">
|
|
354
|
+
<span className={joinClasses('inline-flex rounded-full border px-2.5 py-1 text-[11px] font-medium uppercase tracking-[0.16em]', statusClasses)}>
|
|
355
|
+
{status}
|
|
356
|
+
</span>
|
|
357
|
+
</div>
|
|
358
|
+
</div>
|
|
359
|
+
<StatCard label="Intent" value={intent} />
|
|
360
|
+
<StatCard label={selectedIsDirectory ? 'Loaded children' : 'Size'} value={selectedIsDirectory ? String(childCount ?? 0) : formatBytes(selectedEntry.size)} />
|
|
361
|
+
</div>
|
|
362
|
+
|
|
363
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
364
|
+
<div className="flex items-center gap-2">
|
|
365
|
+
<GitBranch className="h-4 w-4 text-[#8b9bff]" />
|
|
366
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Revision info</p>
|
|
367
|
+
</div>
|
|
368
|
+
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
369
|
+
<div className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3">
|
|
370
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Revision</dt>
|
|
371
|
+
<dd className="mt-2 break-all font-mono text-sm text-white">{revision}</dd>
|
|
372
|
+
</div>
|
|
373
|
+
<div className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3">
|
|
374
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Last updated</dt>
|
|
375
|
+
<dd className="mt-2 text-sm text-white">{lastUpdated}</dd>
|
|
376
|
+
</div>
|
|
377
|
+
<div className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3">
|
|
378
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Provider</dt>
|
|
379
|
+
<dd className="mt-2 text-sm text-white">{fileDetails?.provider ?? selectedEntry.provider ?? 'Unspecified'}</dd>
|
|
380
|
+
</div>
|
|
381
|
+
<div className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3">
|
|
382
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Content type</dt>
|
|
383
|
+
<dd className="mt-2 text-sm text-white">{fileDetails?.contentType ?? selectedEntry.contentType ?? (selectedIsDirectory ? 'directory' : 'Unknown')}</dd>
|
|
384
|
+
</div>
|
|
385
|
+
<div className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3 sm:col-span-2">
|
|
386
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">Provider object</dt>
|
|
387
|
+
<dd className="mt-2 break-all font-mono text-sm text-white">
|
|
388
|
+
{fileDetails?.providerObjectId ?? selectedEntry.providerObjectId ?? 'Unavailable'}
|
|
389
|
+
</dd>
|
|
390
|
+
</div>
|
|
391
|
+
</dl>
|
|
392
|
+
</div>
|
|
393
|
+
|
|
394
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
395
|
+
<div className="flex items-center gap-2">
|
|
396
|
+
<Link2 className="h-4 w-4 text-[#8b9bff]" />
|
|
397
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Relations</p>
|
|
398
|
+
</div>
|
|
399
|
+
<ChipList items={relations} emptyLabel="No relations" />
|
|
400
|
+
</div>
|
|
401
|
+
|
|
402
|
+
<div className="grid gap-3 lg:grid-cols-2">
|
|
403
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
404
|
+
<div className="flex items-center gap-2">
|
|
405
|
+
<Shield className="h-4 w-4 text-[#8b9bff]" />
|
|
406
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Permissions</p>
|
|
407
|
+
</div>
|
|
408
|
+
<ChipList items={permissions} emptyLabel="No permissions" tone="subtle" />
|
|
409
|
+
</div>
|
|
410
|
+
|
|
411
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
412
|
+
<div className="flex items-center gap-2">
|
|
413
|
+
<ScrollText className="h-4 w-4 text-[#8b9bff]" />
|
|
414
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Comments</p>
|
|
415
|
+
</div>
|
|
416
|
+
<p className="mt-2 text-sm text-white">{comments.length}</p>
|
|
417
|
+
<p className="mt-1 text-xs text-[#71717a]">Linked comment threads or sync annotations recorded for this item.</p>
|
|
418
|
+
</div>
|
|
419
|
+
</div>
|
|
420
|
+
|
|
421
|
+
{Object.keys(properties).length ? (
|
|
422
|
+
<div className="rounded-2xl border border-[#27272a] bg-[#0f0f12] p-4">
|
|
423
|
+
<div className="flex items-center gap-2">
|
|
424
|
+
<Workflow className="h-4 w-4 text-[#8b9bff]" />
|
|
425
|
+
<p className="text-xs uppercase tracking-[0.16em] text-[#71717a]">Metadata</p>
|
|
426
|
+
</div>
|
|
427
|
+
<dl className="mt-4 grid gap-3 sm:grid-cols-2">
|
|
428
|
+
{Object.entries(properties).map(([key, value]) => (
|
|
429
|
+
<div key={key} className="rounded-xl border border-[#27272a] bg-[#111113] px-3 py-3">
|
|
430
|
+
<dt className="text-[11px] uppercase tracking-[0.16em] text-[#71717a]">{key}</dt>
|
|
431
|
+
<dd className="mt-2 break-words text-sm text-white">{value}</dd>
|
|
432
|
+
</div>
|
|
433
|
+
))}
|
|
434
|
+
</dl>
|
|
435
|
+
</div>
|
|
436
|
+
) : null}
|
|
437
|
+
</div>
|
|
438
|
+
</aside>
|
|
439
|
+
);
|
|
440
|
+
}
|
|
441
|
+
|
|
442
|
+
export default FileDetails;
|