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.
@@ -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
- setSizes((prevSizes) => {
73
- if (prevSizes.length !== sections.length) {
74
- return defaultSizes;
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
- // eslint-disable-next-line react-hooks/exhaustive-deps
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
- dynamicColumns.push({
345
- id: "actions",
346
- cell: ({ row }) => (
347
- <DropdownMenu>
348
- <DropdownMenuTrigger asChild>
349
- <Button variant="ghost" className="flex size-8 text-muted-foreground data-[state=open]:bg-muted" size="icon">
350
- <Icon name="more-vertical" size="sm" />
351
- <span className="sr-only">Open menu</span>
352
- </Button>
353
- </DropdownMenuTrigger>
354
- <DropdownMenuContent align="end" className="w-40">
355
- <DropdownMenuItem
356
- onClick={() => {
357
- setEditingRowId(String(toRowId(row.original, tableSchema)))
358
- emitRowAction("edit", row.original)
359
- }}
360
- >
361
- Edit
362
- </DropdownMenuItem>
363
- <DropdownMenuItem onClick={() => emitRowAction("copy", row.original)}>Make a copy</DropdownMenuItem>
364
- <DropdownMenuItem onClick={() => emitRowAction("favorite", row.original)}>Favorite</DropdownMenuItem>
365
- <DropdownMenuSeparator />
366
- <DropdownMenuItem
367
- onClick={() => {
368
- if (editingRowId === String(toRowId(row.original, tableSchema))) {
369
- setEditingRowId(null)
370
- }
371
- emitRowAction("delete", row.original)
372
- }}
373
- >
374
- Delete
375
- </DropdownMenuItem>
376
- </DropdownMenuContent>
377
- </DropdownMenu>
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
+ }
@@ -0,0 +1,9 @@
1
+ export {
2
+ MediaPreview,
3
+ isVideoFile,
4
+ isAudioFile,
5
+ isImageFile,
6
+ isPdfFile,
7
+ isMediaFile,
8
+ } from './MediaPreview'
9
+ export type { MediaPreviewProps, MediaFileLike } from './MediaPreview'