ai-design-system 0.1.77 → 0.1.79
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/components/blocks/AIConversation/AIConversation.stories.tsx +86 -0
- package/components/blocks/AIConversation/AIConversation.tsx +17 -0
- package/components/composites/AdjustableLayout/AdjustableLayout.tsx +8 -10
- package/components/composites/AgentIndicator/AgentIndicator.tsx +1 -0
- package/components/composites/DataTable/EnhancedDataTable.tsx +94 -37
- package/components/composites/DataTable/table-types.ts +1 -1
- package/components/composites/FormReports/FormReportsTable.tsx +3 -1
- package/components/composites/MediaPreview/MediaPreview.stories.tsx +60 -0
- package/components/composites/MediaPreview/MediaPreview.tsx +186 -0
- package/components/composites/MediaPreview/index.ts +9 -0
- package/components/composites/SpecialistMessage/SpecialistMessage.tsx +37 -1
- package/components/composites/StreamingMarkdown.tsx +11 -5
- package/components/composites/SystemMessage/SystemMessage.tsx +49 -1
- package/components/composites/index.ts +3 -1
- package/components/features/TextEditor/TextEditor.tsx +25 -23
- package/components/features/TextEditor/index.ts +3 -0
- package/components/features/TextEditor/useTextEditor.mock.ts +1 -14
- package/dist/index.cjs +368 -69
- package/dist/index.cjs.map +1 -1
- package/dist/index.css +38 -0
- package/dist/index.d.ts +24 -2
- package/dist/index.js +368 -69
- package/dist/index.js.map +1 -1
- package/package.json +1 -1
|
@@ -127,6 +127,92 @@ export const MultipleAgents: Story = {
|
|
|
127
127
|
},
|
|
128
128
|
}
|
|
129
129
|
|
|
130
|
+
export const NestedSubAgents: Story = {
|
|
131
|
+
args: {
|
|
132
|
+
messages: [
|
|
133
|
+
{
|
|
134
|
+
id: '1',
|
|
135
|
+
type: 'human',
|
|
136
|
+
role: 'user',
|
|
137
|
+
content: 'Generate a 30 second product demo video with motion graphics overlays',
|
|
138
|
+
avatarSrc: 'https://images.unsplash.com/photo-1494790108755-2616b612b786?w=32&h=32&fit=crop&crop=face',
|
|
139
|
+
avatarName: 'User',
|
|
140
|
+
},
|
|
141
|
+
{
|
|
142
|
+
id: '2',
|
|
143
|
+
type: 'ai',
|
|
144
|
+
role: 'orchestrator',
|
|
145
|
+
content: '',
|
|
146
|
+
avatarSrc: 'https://images.unsplash.com/photo-1472099645785-5658abf4ff4e?w=32&h=32&fit=crop&crop=face',
|
|
147
|
+
avatarName: 'Coordinator',
|
|
148
|
+
subAgents: [
|
|
149
|
+
{
|
|
150
|
+
id: 'call_00_soED4oref3iyY11T',
|
|
151
|
+
name: 'task',
|
|
152
|
+
subAgentName: 'media-generator-agent',
|
|
153
|
+
input: 'Generate a 30 second product demo video with motion graphics overlays',
|
|
154
|
+
status: 'active',
|
|
155
|
+
// Nested children — video-sequencer and motion-graphics are both
|
|
156
|
+
// dispatched by media-generator-agent (task() calls), and stream
|
|
157
|
+
// their own tool-call-started/tool-output-delta/tool-finished
|
|
158
|
+
// events. This is what AIConversation must render one level deep.
|
|
159
|
+
subAgents: [
|
|
160
|
+
{
|
|
161
|
+
id: 'call_00_9LyCOvjRGrApV6II',
|
|
162
|
+
name: 'task',
|
|
163
|
+
subAgentName: 'video-sequencer',
|
|
164
|
+
input: 'Sequence the raw clips into a 30s cut with transitions',
|
|
165
|
+
output: 'Sequenced 12 clips into a 30s timeline. Exported sequence.json (30069 bytes).',
|
|
166
|
+
status: 'completed',
|
|
167
|
+
},
|
|
168
|
+
{
|
|
169
|
+
id: 'call_00_pQZBX2VUDWwmKQUz',
|
|
170
|
+
name: 'task',
|
|
171
|
+
subAgentName: 'motion-graphics',
|
|
172
|
+
input: 'Add lower-thirds, logo animation, and title card overlays',
|
|
173
|
+
output: 'Rendering title card overlay (2 of 4 assets)...',
|
|
174
|
+
status: 'active',
|
|
175
|
+
},
|
|
176
|
+
],
|
|
177
|
+
},
|
|
178
|
+
],
|
|
179
|
+
blocks: [
|
|
180
|
+
{ type: 'text', id: 'text-1', text: "I'll generate this using two specialized sub-agents." },
|
|
181
|
+
{
|
|
182
|
+
type: 'subAgent',
|
|
183
|
+
id: 'call_00_soED4oref3iyY11T',
|
|
184
|
+
subAgent: {
|
|
185
|
+
id: 'call_00_soED4oref3iyY11T',
|
|
186
|
+
name: 'task',
|
|
187
|
+
subAgentName: 'media-generator-agent',
|
|
188
|
+
input: 'Generate a 30 second product demo video with motion graphics overlays',
|
|
189
|
+
status: 'active',
|
|
190
|
+
subAgents: [
|
|
191
|
+
{
|
|
192
|
+
id: 'call_00_9LyCOvjRGrApV6II',
|
|
193
|
+
name: 'task',
|
|
194
|
+
subAgentName: 'video-sequencer',
|
|
195
|
+
input: 'Sequence the raw clips into a 30s cut with transitions',
|
|
196
|
+
output: 'Sequenced 12 clips into a 30s timeline. Exported sequence.json (30069 bytes).',
|
|
197
|
+
status: 'completed',
|
|
198
|
+
},
|
|
199
|
+
{
|
|
200
|
+
id: 'call_00_pQZBX2VUDWwmKQUz',
|
|
201
|
+
name: 'task',
|
|
202
|
+
subAgentName: 'motion-graphics',
|
|
203
|
+
input: 'Add lower-thirds, logo animation, and title card overlays',
|
|
204
|
+
output: 'Rendering title card overlay (2 of 4 assets)...',
|
|
205
|
+
status: 'active',
|
|
206
|
+
},
|
|
207
|
+
],
|
|
208
|
+
},
|
|
209
|
+
},
|
|
210
|
+
],
|
|
211
|
+
},
|
|
212
|
+
],
|
|
213
|
+
},
|
|
214
|
+
}
|
|
215
|
+
|
|
130
216
|
export const Empty: Story = {
|
|
131
217
|
args: {
|
|
132
218
|
messages: [],
|
|
@@ -34,6 +34,21 @@ type AIMessageBlock =
|
|
|
34
34
|
| { type: 'toolCall'; id: string; toolCall: ToolCall }
|
|
35
35
|
| { type: 'subAgent'; id: string; subAgent: SubAgent }
|
|
36
36
|
|
|
37
|
+
// SpecialistMessage's `subAgents` prop expects string-only `output`, while
|
|
38
|
+
// SubAgent allows structured output — normalize recursively when nesting.
|
|
39
|
+
function toNestedSubAgents(
|
|
40
|
+
agents?: SubAgent[]
|
|
41
|
+
): { id: string; subAgentName: string; status: SubAgent['status']; input?: string | Record<string, unknown>; output?: string; subAgents?: ReturnType<typeof toNestedSubAgents> }[] | undefined {
|
|
42
|
+
return agents?.map((a) => ({
|
|
43
|
+
id: a.id,
|
|
44
|
+
subAgentName: a.subAgentName,
|
|
45
|
+
status: a.status,
|
|
46
|
+
input: a.input,
|
|
47
|
+
output: typeof a.output === 'string' ? a.output : a.output ? JSON.stringify(a.output) : undefined,
|
|
48
|
+
subAgents: toNestedSubAgents(a.subAgents),
|
|
49
|
+
}));
|
|
50
|
+
}
|
|
51
|
+
|
|
37
52
|
interface AIMessage {
|
|
38
53
|
id: string;
|
|
39
54
|
type: 'human' | 'ai';
|
|
@@ -354,6 +369,7 @@ export const AIConversation = React.memo<AIConversationProps>(
|
|
|
354
369
|
content: typeof subAgent.output === 'string' ? subAgent.output : (subAgent.output ? JSON.stringify(subAgent.output) : ''),
|
|
355
370
|
status: subAgent.status,
|
|
356
371
|
toolCalls: [],
|
|
372
|
+
subAgents: toNestedSubAgents(subAgent.subAgents),
|
|
357
373
|
}}
|
|
358
374
|
isNested={true}
|
|
359
375
|
/>
|
|
@@ -380,6 +396,7 @@ export const AIConversation = React.memo<AIConversationProps>(
|
|
|
380
396
|
content: typeof subAgent.output === 'string' ? subAgent.output : (subAgent.output ? JSON.stringify(subAgent.output) : ''),
|
|
381
397
|
status: subAgent.status,
|
|
382
398
|
toolCalls: [],
|
|
399
|
+
subAgents: toNestedSubAgents(subAgent.subAgents),
|
|
383
400
|
}}
|
|
384
401
|
isNested={true}
|
|
385
402
|
/>
|
|
@@ -60,21 +60,20 @@ export const AdjustableLayout = React.memo<AdjustableLayoutProps>(
|
|
|
60
60
|
|
|
61
61
|
// Compute default sizes (server-safe — no localStorage access)
|
|
62
62
|
const defaultSizes = React.useMemo(() => {
|
|
63
|
-
const raw = sections.map(section => section.defaultSize ?? (100 / sections.length))
|
|
63
|
+
const raw = sections.map((section) => section.defaultSize ?? (100 / sections.length))
|
|
64
64
|
const total = raw.reduce((sum, size) => sum + size, 0)
|
|
65
|
-
return raw.map(size => (size / total) * 100)
|
|
65
|
+
return raw.map((size) => (size / total) * 100)
|
|
66
66
|
}, [sections])
|
|
67
67
|
|
|
68
68
|
const [sizes, setSizes] = React.useState<number[]>(defaultSizes)
|
|
69
69
|
|
|
70
70
|
// Sync sizes if the number of sections changes (e.g. data loads and new section appears)
|
|
71
|
+
const prevSectionsLengthRef = React.useRef(sections.length)
|
|
71
72
|
React.useEffect(() => {
|
|
72
|
-
|
|
73
|
-
|
|
74
|
-
|
|
75
|
-
|
|
76
|
-
return prevSizes;
|
|
77
|
-
});
|
|
73
|
+
if (prevSectionsLengthRef.current !== sections.length) {
|
|
74
|
+
prevSectionsLengthRef.current = sections.length
|
|
75
|
+
setSizes(defaultSizes)
|
|
76
|
+
}
|
|
78
77
|
}, [sections.length, defaultSizes])
|
|
79
78
|
|
|
80
79
|
// After hydration, overwrite with persisted sizes if available
|
|
@@ -90,8 +89,7 @@ export const AdjustableLayout = React.memo<AdjustableLayoutProps>(
|
|
|
90
89
|
} catch {
|
|
91
90
|
// ignore malformed storage
|
|
92
91
|
}
|
|
93
|
-
|
|
94
|
-
}, [storageKey])
|
|
92
|
+
}, [storageKey, sections.length])
|
|
95
93
|
|
|
96
94
|
const [draggingIndex, setDraggingIndex] = React.useState<number | null>(null)
|
|
97
95
|
const [startX, setStartX] = React.useState(0)
|
|
@@ -17,6 +17,7 @@ export interface SubAgent {
|
|
|
17
17
|
input: string | Record<string, unknown>
|
|
18
18
|
output?: string | Record<string, unknown>
|
|
19
19
|
status: "pending" | "active" | "completed" | "error" | "running" | "in_progress"
|
|
20
|
+
subAgents?: SubAgent[]
|
|
20
21
|
}
|
|
21
22
|
|
|
22
23
|
export interface AgentIndicatorProps {
|
|
@@ -55,6 +55,7 @@ export interface EnhancedDataTableProps {
|
|
|
55
55
|
rightActions?: React.ReactNode
|
|
56
56
|
onCreateClick?: () => void
|
|
57
57
|
createButtonLabel?: string
|
|
58
|
+
rowActions?: Array<{ key: string; label: string; icon?: string }>
|
|
58
59
|
}
|
|
59
60
|
|
|
60
61
|
function toRowId(row: DashboardRow, schema: DynamicTableSchema): number | string {
|
|
@@ -96,6 +97,7 @@ export function EnhancedDataTable({
|
|
|
96
97
|
rightActions,
|
|
97
98
|
onCreateClick,
|
|
98
99
|
createButtonLabel = "Create",
|
|
100
|
+
rowActions,
|
|
99
101
|
}: EnhancedDataTableProps) {
|
|
100
102
|
const [searchQuery, setSearchQuery] = React.useState("")
|
|
101
103
|
|
|
@@ -341,45 +343,100 @@ export function EnhancedDataTable({
|
|
|
341
343
|
}))
|
|
342
344
|
)
|
|
343
345
|
|
|
344
|
-
|
|
345
|
-
|
|
346
|
-
|
|
347
|
-
|
|
348
|
-
|
|
349
|
-
|
|
350
|
-
|
|
351
|
-
|
|
352
|
-
|
|
353
|
-
|
|
354
|
-
|
|
355
|
-
|
|
356
|
-
|
|
357
|
-
|
|
358
|
-
|
|
359
|
-
|
|
360
|
-
|
|
361
|
-
|
|
362
|
-
|
|
363
|
-
<
|
|
364
|
-
|
|
365
|
-
|
|
366
|
-
|
|
367
|
-
|
|
368
|
-
|
|
369
|
-
|
|
370
|
-
|
|
371
|
-
|
|
372
|
-
|
|
373
|
-
|
|
374
|
-
|
|
375
|
-
|
|
376
|
-
|
|
377
|
-
|
|
378
|
-
|
|
379
|
-
|
|
346
|
+
if (rowActions && rowActions.length === 0) {
|
|
347
|
+
// No actions column if rowActions is explicitly empty
|
|
348
|
+
} else if (rowActions && rowActions.length === 1) {
|
|
349
|
+
const singleAction = rowActions[0]
|
|
350
|
+
const iconName = singleAction.icon || (singleAction.key === "retry" ? "refresh-cw" : "more-vertical")
|
|
351
|
+
dynamicColumns.push({
|
|
352
|
+
id: "actions",
|
|
353
|
+
cell: ({ row }) => (
|
|
354
|
+
<Button
|
|
355
|
+
variant="ghost"
|
|
356
|
+
size="icon"
|
|
357
|
+
className="flex size-8 text-muted-foreground hover:text-foreground"
|
|
358
|
+
onClick={(e) => {
|
|
359
|
+
e.stopPropagation()
|
|
360
|
+
emitRowAction(singleAction.key, row.original)
|
|
361
|
+
}}
|
|
362
|
+
title={singleAction.label}
|
|
363
|
+
aria-label={singleAction.label}
|
|
364
|
+
>
|
|
365
|
+
<Icon name={iconName} size="sm" />
|
|
366
|
+
</Button>
|
|
367
|
+
),
|
|
368
|
+
})
|
|
369
|
+
} else if (rowActions && rowActions.length > 1) {
|
|
370
|
+
dynamicColumns.push({
|
|
371
|
+
id: "actions",
|
|
372
|
+
cell: ({ row }) => (
|
|
373
|
+
<DropdownMenu>
|
|
374
|
+
<DropdownMenuTrigger asChild>
|
|
375
|
+
<Button variant="ghost" className="flex size-8 text-muted-foreground data-[state=open]:bg-muted" size="icon">
|
|
376
|
+
<Icon name="more-vertical" size="sm" />
|
|
377
|
+
<span className="sr-only">Open menu</span>
|
|
378
|
+
</Button>
|
|
379
|
+
</DropdownMenuTrigger>
|
|
380
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
381
|
+
{rowActions.map((action) => (
|
|
382
|
+
<DropdownMenuItem
|
|
383
|
+
key={action.key}
|
|
384
|
+
onClick={() => {
|
|
385
|
+
if (action.key === "edit") {
|
|
386
|
+
setEditingRowId(String(toRowId(row.original, tableSchema)))
|
|
387
|
+
}
|
|
388
|
+
emitRowAction(action.key, row.original)
|
|
389
|
+
}}
|
|
390
|
+
>
|
|
391
|
+
{action.icon && <Icon name={action.icon} size="xs" className="mr-2 inline" />}
|
|
392
|
+
{action.label}
|
|
393
|
+
</DropdownMenuItem>
|
|
394
|
+
))}
|
|
395
|
+
</DropdownMenuContent>
|
|
396
|
+
</DropdownMenu>
|
|
397
|
+
),
|
|
398
|
+
})
|
|
399
|
+
} else {
|
|
400
|
+
dynamicColumns.push({
|
|
401
|
+
id: "actions",
|
|
402
|
+
cell: ({ row }) => (
|
|
403
|
+
<DropdownMenu>
|
|
404
|
+
<DropdownMenuTrigger asChild>
|
|
405
|
+
<Button variant="ghost" className="flex size-8 text-muted-foreground data-[state=open]:bg-muted" size="icon">
|
|
406
|
+
<Icon name="more-vertical" size="sm" />
|
|
407
|
+
<span className="sr-only">Open menu</span>
|
|
408
|
+
</Button>
|
|
409
|
+
</DropdownMenuTrigger>
|
|
410
|
+
<DropdownMenuContent align="end" className="w-40">
|
|
411
|
+
<DropdownMenuItem
|
|
412
|
+
onClick={() => {
|
|
413
|
+
setEditingRowId(String(toRowId(row.original, tableSchema)))
|
|
414
|
+
emitRowAction("edit", row.original)
|
|
415
|
+
}}
|
|
416
|
+
>
|
|
417
|
+
Edit
|
|
418
|
+
</DropdownMenuItem>
|
|
419
|
+
<DropdownMenuItem onClick={() => emitRowAction("copy", row.original)}>Make a copy</DropdownMenuItem>
|
|
420
|
+
<DropdownMenuItem onClick={() => emitRowAction("favorite", row.original)}>Favorite</DropdownMenuItem>
|
|
421
|
+
<DropdownMenuSeparator />
|
|
422
|
+
<DropdownMenuItem
|
|
423
|
+
onClick={() => {
|
|
424
|
+
if (editingRowId === String(toRowId(row.original, tableSchema))) {
|
|
425
|
+
setEditingRowId(null)
|
|
426
|
+
}
|
|
427
|
+
emitRowAction("delete", row.original)
|
|
428
|
+
}}
|
|
429
|
+
>
|
|
430
|
+
Delete
|
|
431
|
+
</DropdownMenuItem>
|
|
432
|
+
</DropdownMenuContent>
|
|
433
|
+
</DropdownMenu>
|
|
434
|
+
),
|
|
435
|
+
})
|
|
436
|
+
}
|
|
380
437
|
|
|
381
438
|
return dynamicColumns
|
|
382
|
-
}, [editingRowId, emitRowAction, renderEditableCell, renderReadonlyCell, tableSchema])
|
|
439
|
+
}, [editingRowId, emitRowAction, renderEditableCell, renderReadonlyCell, rowActions, tableSchema])
|
|
383
440
|
|
|
384
441
|
const filterKeys = React.useMemo(() => tableSchema.columns.map((column) => column.key), [tableSchema.columns])
|
|
385
442
|
|
|
@@ -8,7 +8,7 @@ export type DashboardRow = z.infer<typeof dashboardRowSchema>
|
|
|
8
8
|
|
|
9
9
|
export type DashboardInlineEditableField = "target" | "limit"
|
|
10
10
|
|
|
11
|
-
export type DashboardRowAction = "edit" | "copy" | "favorite" | "delete"
|
|
11
|
+
export type DashboardRowAction = "edit" | "copy" | "favorite" | "delete" | string
|
|
12
12
|
|
|
13
13
|
export interface DashboardPaginationState {
|
|
14
14
|
pageIndex: number
|
|
@@ -18,6 +18,7 @@ export type FormReportsColumn = TableColumn
|
|
|
18
18
|
export interface FormReportsRowAction {
|
|
19
19
|
key: string
|
|
20
20
|
label: string
|
|
21
|
+
icon?: string
|
|
21
22
|
}
|
|
22
23
|
|
|
23
24
|
export interface FormReportsTableHandlers {
|
|
@@ -64,7 +65,7 @@ const dashboardToFormReportsActionMap: Record<DashboardRowAction, string> = {
|
|
|
64
65
|
export type { DashboardPaginationState }
|
|
65
66
|
|
|
66
67
|
export const FormReportsTable = React.memo<FormReportsTableProps>(
|
|
67
|
-
({ items, columns, pagination, handlers, leftActions, rightActions, onCreateClick, createButtonLabel, enableRowSelection }) => {
|
|
68
|
+
({ items, columns, rowActions, pagination, handlers, leftActions, rightActions, onCreateClick, createButtonLabel, enableRowSelection }) => {
|
|
68
69
|
const { rows, originalById, tableSchema } = React.useMemo(() => {
|
|
69
70
|
const byId = new Map<string, FormReportsEntity>()
|
|
70
71
|
const tableColumns: DynamicTableSchema["columns"] = columns
|
|
@@ -185,6 +186,7 @@ export const FormReportsTable = React.memo<FormReportsTableProps>(
|
|
|
185
186
|
rightActions={rightActions}
|
|
186
187
|
onCreateClick={onCreateClick}
|
|
187
188
|
createButtonLabel={createButtonLabel}
|
|
189
|
+
rowActions={rowActions}
|
|
188
190
|
/>
|
|
189
191
|
)
|
|
190
192
|
}
|
|
@@ -0,0 +1,60 @@
|
|
|
1
|
+
import type { Meta, StoryObj } from '@storybook/react'
|
|
2
|
+
import { MediaPreview } from './MediaPreview'
|
|
3
|
+
|
|
4
|
+
const meta = {
|
|
5
|
+
title: 'Composites/MediaPreview',
|
|
6
|
+
component: MediaPreview,
|
|
7
|
+
tags: ['autodocs'],
|
|
8
|
+
parameters: {
|
|
9
|
+
layout: 'centered',
|
|
10
|
+
},
|
|
11
|
+
} satisfies Meta<typeof MediaPreview>
|
|
12
|
+
|
|
13
|
+
export default meta
|
|
14
|
+
type Story = StoryObj<typeof meta>
|
|
15
|
+
|
|
16
|
+
export const Video: Story = {
|
|
17
|
+
args: {
|
|
18
|
+
file: {
|
|
19
|
+
id: 'video-1',
|
|
20
|
+
name: 'sample-video.mp4',
|
|
21
|
+
format: 'mp4',
|
|
22
|
+
mediaType: 'video/mp4',
|
|
23
|
+
url: 'https://commondatastorage.googleapis.com/gtv-videos-bucket/sample/BigBuckBunny.mp4',
|
|
24
|
+
},
|
|
25
|
+
},
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export const Audio: Story = {
|
|
29
|
+
args: {
|
|
30
|
+
file: {
|
|
31
|
+
id: 'audio-1',
|
|
32
|
+
name: 'sample-audio.mp3',
|
|
33
|
+
format: 'mp3',
|
|
34
|
+
mediaType: 'audio/mp3',
|
|
35
|
+
url: 'https://www.soundhelix.com/examples/mp3/SoundHelix-Song-1.mp3',
|
|
36
|
+
},
|
|
37
|
+
},
|
|
38
|
+
}
|
|
39
|
+
|
|
40
|
+
export const Image: Story = {
|
|
41
|
+
args: {
|
|
42
|
+
file: {
|
|
43
|
+
id: 'image-1',
|
|
44
|
+
name: 'sample-image.png',
|
|
45
|
+
format: 'png',
|
|
46
|
+
mediaType: 'image/png',
|
|
47
|
+
url: 'https://picsum.photos/800/600',
|
|
48
|
+
},
|
|
49
|
+
},
|
|
50
|
+
}
|
|
51
|
+
|
|
52
|
+
export const NoUrl: Story = {
|
|
53
|
+
args: {
|
|
54
|
+
file: {
|
|
55
|
+
id: 'file-1',
|
|
56
|
+
name: 'unknown-media.bin',
|
|
57
|
+
format: 'bin',
|
|
58
|
+
},
|
|
59
|
+
},
|
|
60
|
+
}
|
|
@@ -0,0 +1,186 @@
|
|
|
1
|
+
/**
|
|
2
|
+
* MediaPreview Composite Component
|
|
3
|
+
*
|
|
4
|
+
* Renders native preview players and viewers for binary media files
|
|
5
|
+
* (video, audio, images, documents) within the TextEditor pane.
|
|
6
|
+
*/
|
|
7
|
+
|
|
8
|
+
import React from 'react'
|
|
9
|
+
import type { DocumentFile } from '@/types/ai-editor/editor'
|
|
10
|
+
import { cn } from '@/lib/utils'
|
|
11
|
+
import { Icon } from '@/components/primitives/Icon'
|
|
12
|
+
|
|
13
|
+
export interface MediaFileLike {
|
|
14
|
+
id?: string
|
|
15
|
+
name?: string
|
|
16
|
+
format?: 'json' | 'markdown' | string
|
|
17
|
+
url?: string
|
|
18
|
+
mediaType?: string
|
|
19
|
+
isDirty?: boolean
|
|
20
|
+
lastModified?: number
|
|
21
|
+
}
|
|
22
|
+
|
|
23
|
+
export interface MediaPreviewProps {
|
|
24
|
+
file: MediaFileLike
|
|
25
|
+
className?: string
|
|
26
|
+
}
|
|
27
|
+
|
|
28
|
+
export function isVideoFile(file: MediaFileLike): boolean {
|
|
29
|
+
const format = file.format?.toLowerCase() || ''
|
|
30
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || ''
|
|
31
|
+
if (['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(format) || ['mp4', 'webm', 'mov', 'mkv', 'avi'].includes(ext)) return true
|
|
32
|
+
if (file.mediaType?.startsWith('video/')) return true
|
|
33
|
+
if (file.url && /\.(mp4|webm|mov|mkv)(\?.*)?$/i.test(file.url)) return true
|
|
34
|
+
return false
|
|
35
|
+
}
|
|
36
|
+
|
|
37
|
+
export function isAudioFile(file: MediaFileLike): boolean {
|
|
38
|
+
const format = file.format?.toLowerCase() || ''
|
|
39
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || ''
|
|
40
|
+
if (['wav', 'mp3', 'ogg', 'm4a', 'flac', 'aac', 'wma'].includes(format) || ['wav', 'mp3', 'ogg', 'm4a', 'flac', 'aac', 'wma'].includes(ext)) return true
|
|
41
|
+
if (file.mediaType?.startsWith('audio/')) return true
|
|
42
|
+
if (file.url && /\.(wav|mp3|ogg|m4a|flac|aac)(\?.*)?$/i.test(file.url)) return true
|
|
43
|
+
return false
|
|
44
|
+
}
|
|
45
|
+
|
|
46
|
+
export function isImageFile(file: MediaFileLike): boolean {
|
|
47
|
+
const format = file.format?.toLowerCase() || ''
|
|
48
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || ''
|
|
49
|
+
if (['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico', 'bmp'].includes(format) || ['png', 'jpg', 'jpeg', 'webp', 'gif', 'svg', 'ico', 'bmp'].includes(ext)) return true
|
|
50
|
+
if (file.mediaType?.startsWith('image/')) return true
|
|
51
|
+
if (file.url && /\.(png|jpg|jpeg|webp|gif|svg)(\?.*)?$/i.test(file.url)) return true
|
|
52
|
+
return false
|
|
53
|
+
}
|
|
54
|
+
|
|
55
|
+
export function isPdfFile(file: MediaFileLike): boolean {
|
|
56
|
+
const format = file.format?.toLowerCase() || ''
|
|
57
|
+
const ext = file.name?.split('.').pop()?.toLowerCase() || ''
|
|
58
|
+
return format === 'pdf' || ext === 'pdf' || file.mediaType === 'application/pdf'
|
|
59
|
+
}
|
|
60
|
+
|
|
61
|
+
export function isMediaFile(file: MediaFileLike): boolean {
|
|
62
|
+
return isVideoFile(file) || isAudioFile(file) || isImageFile(file) || isPdfFile(file)
|
|
63
|
+
}
|
|
64
|
+
|
|
65
|
+
export const MediaPreview: React.FC<MediaPreviewProps> = ({ file, className }) => {
|
|
66
|
+
const isVideo = isVideoFile(file)
|
|
67
|
+
const isAudio = isAudioFile(file)
|
|
68
|
+
const isImage = isImageFile(file)
|
|
69
|
+
const isPdf = isPdfFile(file)
|
|
70
|
+
|
|
71
|
+
const mediaUrl = file.url
|
|
72
|
+
|
|
73
|
+
if (!mediaUrl) {
|
|
74
|
+
return (
|
|
75
|
+
<div className={cn('flex flex-col items-center justify-center h-full p-8 text-center', className)}>
|
|
76
|
+
<div className="flex h-16 w-16 items-center justify-center rounded-2xl bg-muted/60 mb-4 text-muted-foreground">
|
|
77
|
+
{isVideo && <Icon name="film" className="h-8 w-8" />}
|
|
78
|
+
{isAudio && <Icon name="music" className="h-8 w-8" />}
|
|
79
|
+
{isImage && <Icon name="image" className="h-8 w-8" />}
|
|
80
|
+
{isPdf && <Icon name="file-text" className="h-8 w-8" />}
|
|
81
|
+
{!isVideo && !isAudio && !isImage && !isPdf && <Icon name="alert-circle" className="h-8 w-8" />}
|
|
82
|
+
</div>
|
|
83
|
+
<h3 className="text-base font-medium text-foreground mb-1">{file.name || 'Media File'}</h3>
|
|
84
|
+
<p className="text-sm text-muted-foreground max-w-sm">
|
|
85
|
+
No direct stream URL is available for this file yet.
|
|
86
|
+
</p>
|
|
87
|
+
</div>
|
|
88
|
+
)
|
|
89
|
+
}
|
|
90
|
+
|
|
91
|
+
return (
|
|
92
|
+
<div className={cn('flex flex-col h-full w-full overflow-y-auto p-6 bg-background/50', className)}>
|
|
93
|
+
{/* Header Info */}
|
|
94
|
+
<div className="flex items-center justify-between pb-4 mb-6 border-b border-border">
|
|
95
|
+
<div className="flex items-center gap-3">
|
|
96
|
+
<div className="flex h-10 w-10 items-center justify-center rounded-xl bg-primary/10 text-primary">
|
|
97
|
+
{isVideo && <Icon name="film" className="h-5 w-5" />}
|
|
98
|
+
{isAudio && <Icon name="music" className="h-5 w-5" />}
|
|
99
|
+
{isImage && <Icon name="image" className="h-5 w-5" />}
|
|
100
|
+
{isPdf && <Icon name="file-text" className="h-5 w-5" />}
|
|
101
|
+
</div>
|
|
102
|
+
<div>
|
|
103
|
+
<h2 className="text-sm font-semibold text-foreground tracking-tight">{file.name}</h2>
|
|
104
|
+
<p className="text-xs text-muted-foreground">
|
|
105
|
+
{file.mediaType || file.format?.toUpperCase() || 'Binary Media'}
|
|
106
|
+
</p>
|
|
107
|
+
</div>
|
|
108
|
+
</div>
|
|
109
|
+
|
|
110
|
+
<div className="flex items-center gap-2">
|
|
111
|
+
<a
|
|
112
|
+
href={mediaUrl}
|
|
113
|
+
target="_blank"
|
|
114
|
+
rel="noopener noreferrer"
|
|
115
|
+
download={file.name}
|
|
116
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border bg-background hover:bg-muted text-foreground transition-colors shadow-sm"
|
|
117
|
+
>
|
|
118
|
+
<Icon name="download" className="h-3.5 w-3.5" />
|
|
119
|
+
Download
|
|
120
|
+
</a>
|
|
121
|
+
<a
|
|
122
|
+
href={mediaUrl}
|
|
123
|
+
target="_blank"
|
|
124
|
+
rel="noopener noreferrer"
|
|
125
|
+
className="inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium rounded-lg border border-border bg-background hover:bg-muted text-foreground transition-colors shadow-sm"
|
|
126
|
+
>
|
|
127
|
+
<Icon name="external-link" className="h-3.5 w-3.5" />
|
|
128
|
+
Open
|
|
129
|
+
</a>
|
|
130
|
+
</div>
|
|
131
|
+
</div>
|
|
132
|
+
|
|
133
|
+
{/* Main Media Player / Viewer */}
|
|
134
|
+
<div className="flex-1 flex flex-col items-center justify-center min-h-[300px]">
|
|
135
|
+
{isVideo && (
|
|
136
|
+
<div className="w-full max-w-4xl flex flex-col items-center justify-center bg-black/95 rounded-2xl overflow-hidden shadow-lg border border-border/40">
|
|
137
|
+
<video
|
|
138
|
+
controls
|
|
139
|
+
playsInline
|
|
140
|
+
preload="metadata"
|
|
141
|
+
src={mediaUrl}
|
|
142
|
+
className="max-h-[65vh] w-auto max-w-full rounded-2xl"
|
|
143
|
+
>
|
|
144
|
+
Your browser does not support the video tag.
|
|
145
|
+
</video>
|
|
146
|
+
</div>
|
|
147
|
+
)}
|
|
148
|
+
|
|
149
|
+
{isAudio && (
|
|
150
|
+
<div className="w-full max-w-xl p-8 rounded-2xl border border-border bg-card shadow-sm flex flex-col items-center gap-6">
|
|
151
|
+
<div className="flex h-20 w-20 items-center justify-center rounded-full bg-primary/10 text-primary">
|
|
152
|
+
<Icon name="music" className="h-10 w-10 animate-pulse" />
|
|
153
|
+
</div>
|
|
154
|
+
<div className="text-center">
|
|
155
|
+
<h3 className="text-base font-semibold text-foreground mb-1">{file.name}</h3>
|
|
156
|
+
<span className="text-xs text-muted-foreground uppercase tracking-wider">Audio Playback</span>
|
|
157
|
+
</div>
|
|
158
|
+
<audio controls preload="metadata" src={mediaUrl} className="w-full h-12">
|
|
159
|
+
Your browser does not support the audio element.
|
|
160
|
+
</audio>
|
|
161
|
+
</div>
|
|
162
|
+
)}
|
|
163
|
+
|
|
164
|
+
{isImage && (
|
|
165
|
+
<div className="flex items-center justify-center w-full max-w-4xl rounded-2xl border border-border bg-muted/20 p-4 shadow-sm">
|
|
166
|
+
<img
|
|
167
|
+
src={mediaUrl}
|
|
168
|
+
alt={file.name}
|
|
169
|
+
className="max-h-[68vh] w-auto max-w-full rounded-xl object-contain shadow-md"
|
|
170
|
+
/>
|
|
171
|
+
</div>
|
|
172
|
+
)}
|
|
173
|
+
|
|
174
|
+
{isPdf && (
|
|
175
|
+
<div className="w-full h-full min-h-[500px] rounded-2xl overflow-hidden border border-border shadow-sm">
|
|
176
|
+
<iframe
|
|
177
|
+
src={mediaUrl}
|
|
178
|
+
title={file.name}
|
|
179
|
+
className="w-full h-full min-h-[500px]"
|
|
180
|
+
/>
|
|
181
|
+
</div>
|
|
182
|
+
)}
|
|
183
|
+
</div>
|
|
184
|
+
</div>
|
|
185
|
+
)
|
|
186
|
+
}
|