refactor(studio): modularize CloudFileBrowser with FileTable, FileGrid and FileToolbar

This commit is contained in:
senke 2026-02-05 19:04:00 +01:00
parent 7da61f9911
commit 6f8a16359a
17 changed files with 1463 additions and 0 deletions

View file

@ -0,0 +1,21 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CloudFileBrowser } from './CloudFileBrowser';
const meta: Meta<typeof CloudFileBrowser> = {
title: 'Components/Features/Studio/CloudFileBrowser',
component: CloudFileBrowser,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="bg-kodo-background min-h-layout-main p-4">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof CloudFileBrowser>;
export const Default: Story = {};

View file

@ -0,0 +1,188 @@
import React, { useState, useEffect, useMemo } from 'react';
import { useToast } from '@/components/feedback/ToastProvider';
import { FileDetailsView } from '@/components/views/FileDetailsView';
import { AutoMetadataDetectionModal } from '@/components/library/AutoMetadataDetectionModal';
import { WatermarkSettingsModal } from '@/components/library/WatermarkSettingsModal';
import { storageService } from '@/services/storageService';
import { logger } from '@/utils/logger';
import { FileToolbar } from './FileToolbar';
import { FileTable } from './FileTable';
import { FileGrid } from './FileGrid';
import { CloudFileBrowserSkeleton } from './CloudFileBrowserSkeleton';
import type { CloudFileNode, SortField, SortOrder, ViewMode } from './types';
import { FILE_BROWSER_DEFAULT_TAGS } from './types';
export const CloudFileBrowser: React.FC = () => {
const { addToast } = useToast();
const [viewMode, setViewMode] = useState<ViewMode>('list');
const [searchQuery, setSearchQuery] = useState('');
const [selectedFiles, setSelectedFiles] = useState<string[]>([]);
const [, setCurrentFolder] = useState('Root');
const [files, setFiles] = useState<CloudFileNode[]>([]);
const [loading, setLoading] = useState(true);
const [selectedFileId, setSelectedFileId] = useState<string | null>(null);
const [sortField, setSortField] = useState<SortField>('modified');
const [sortOrder, setSortOrder] = useState<SortOrder>('desc');
const [activeTags, setActiveTags] = useState<string[]>([]);
const [showMetadataModal, setShowMetadataModal] = useState(false);
const [showWatermarkModal, setShowWatermarkModal] = useState(false);
useEffect(() => {
const loadFiles = async () => {
setLoading(true);
try {
const data = await storageService.listFiles();
setFiles(data);
} catch (e) {
logger.error('Error loading files', {
error: e instanceof Error ? e.message : String(e),
stack: e instanceof Error ? e.stack : undefined,
});
} finally {
setLoading(false);
}
};
loadFiles();
}, []);
const filteredFiles = useMemo(() => {
return files
.filter((f) => f.name.toLowerCase().includes(searchQuery.toLowerCase()))
.filter(
(f) =>
activeTags.length === 0 || f.tags?.some((t) => activeTags.includes(t))
)
.sort((a, b) => {
let valA: string | number = a[sortField] ?? '';
let valB: string | number = b[sortField] ?? '';
if (sortField === 'size') {
valA = parseInt(String(a.size || '0'), 10) || 0;
valB = parseInt(String(b.size || '0'), 10) || 0;
}
if (valA < valB) return sortOrder === 'asc' ? -1 : 1;
if (valA > valB) return sortOrder === 'asc' ? 1 : -1;
return 0;
});
}, [files, searchQuery, activeTags, sortField, sortOrder]);
const handleSort = (field: SortField) => {
setSortField((prev) => {
if (prev === field) {
setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'));
return prev;
}
setSortOrder('asc');
return field;
});
};
const toggleTag = (tag: string) => {
setActiveTags((prev) =>
prev.includes(tag) ? prev.filter((t) => t !== tag) : [...prev, tag]
);
};
const toggleSelection = (id: string) => {
setSelectedFiles((prev) =>
prev.includes(id) ? prev.filter((f) => f !== id) : [...prev, id]
);
};
const selectAll = () => {
if (selectedFiles.length === filteredFiles.length) setSelectedFiles([]);
else setSelectedFiles(filteredFiles.map((f) => f.id));
};
const handleFileClick = (file: CloudFileNode) => {
if (file.type === 'folder') {
setCurrentFolder(file.name);
addToast(`Navigated to ${file.name}`, 'info');
} else {
setSelectedFileId(file.id);
}
};
const handleBulkAction = (action: string) => {
if (selectedFiles.length === 0) return;
addToast(`${action} ${selectedFiles.length} items`, 'success');
setSelectedFiles([]);
};
if (selectedFileId) {
return (
<FileDetailsView
fileId={selectedFileId}
onBack={() => setSelectedFileId(null)}
/>
);
}
if (loading) {
return <CloudFileBrowserSkeleton viewMode={viewMode} />;
}
return (
<div className="space-y-8 h-full flex flex-col">
<FileToolbar
searchQuery={searchQuery}
onSearchChange={setSearchQuery}
activeTags={activeTags}
onTagToggle={toggleTag}
sortField={sortField}
sortOrder={sortOrder}
onSortFieldChange={setSortField}
onSortOrderToggle={() =>
setSortOrder((o) => (o === 'asc' ? 'desc' : 'asc'))
}
viewMode={viewMode}
onViewModeChange={setViewMode}
selectedCount={selectedFiles.length}
onBulkDownload={() => handleBulkAction('Downloaded')}
onBulkTag={() => handleBulkAction('Tagged')}
onBulkDelete={() => handleBulkAction('Deleted')}
onMetadataClick={() => setShowMetadataModal(true)}
onWatermarkClick={() => setShowWatermarkModal(true)}
availableTags={FILE_BROWSER_DEFAULT_TAGS}
/>
<div className="flex-1 overflow-y-auto custom-scrollbar min-h-layout-page-sm">
{viewMode === 'list' ? (
<FileTable
files={filteredFiles}
selectedIds={selectedFiles}
onSelectAll={selectAll}
onToggleSelect={toggleSelection}
sortField={sortField}
onSort={handleSort}
onFileClick={handleFileClick}
onRowAction={(action) => {
if (action === 'ai') addToast('Sent to AI Tools');
}}
/>
) : (
<FileGrid
files={filteredFiles}
selectedIds={selectedFiles}
onFileClick={handleFileClick}
onToggleSelect={toggleSelection}
/>
)}
</div>
{showMetadataModal && (
<AutoMetadataDetectionModal
fileName={
selectedFileId
? files.find((f) => f.id === selectedFileId)?.name ??
'Selected File'
: 'Scan Library'
}
onClose={() => setShowMetadataModal(false)}
onApply={(data) => {
addToast(`Applied: ${data.genre} - ${data.bpm}BPM`, 'success');
setShowMetadataModal(false);
}}
/>
)}
{showWatermarkModal && (
<WatermarkSettingsModal
onClose={() => setShowWatermarkModal(false)}
onSave={() => addToast('Watermark settings updated', 'success')}
/>
)}
</div>
);
};

View file

@ -0,0 +1,31 @@
import type { Meta, StoryObj } from '@storybook/react';
import { CloudFileBrowserSkeleton } from './CloudFileBrowserSkeleton';
const meta: Meta<typeof CloudFileBrowserSkeleton> = {
title: 'Components/Features/Studio/CloudFileBrowser/CloudFileBrowserSkeleton',
component: CloudFileBrowserSkeleton,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4 min-h-layout-main">
<Story />
</div>
),
],
};
export default meta;
type Story = StoryObj<typeof CloudFileBrowserSkeleton>;
export const ListView: Story = {
args: {
viewMode: 'list',
},
};
export const GridView: Story = {
args: {
viewMode: 'grid',
},
};

View file

@ -0,0 +1,89 @@
import React from 'react';
import { cn } from '@/lib/utils';
const ROW_COUNT = 6;
export interface CloudFileBrowserSkeletonProps {
viewMode?: 'list' | 'grid';
className?: string;
}
/**
* Skeleton that mirrors CloudFileBrowser layout (toolbar + content) to avoid layout shift.
* Uses min-h-layout-page-sm for content area.
*/
export function CloudFileBrowserSkeleton({
viewMode = 'list',
className,
}: CloudFileBrowserSkeletonProps) {
return (
<div className={cn('space-y-8 h-full flex flex-col', className)}>
{/* Toolbar skeleton */}
<div className="flex flex-col xl:flex-row gap-4 justify-between items-start xl:items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50 animate-pulse">
<div className="flex flex-col sm:flex-row gap-4 w-full xl:w-auto">
<div className="h-10 w-full sm:w-64 rounded-lg bg-kodo-steel/50" />
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-kodo-steel/50 shrink-0" />
<div className="flex gap-2">
{[1, 2, 3, 4, 5].map((i) => (
<div key={i} className="h-6 w-16 rounded bg-kodo-steel/50" />
))}
</div>
</div>
</div>
<div className="flex gap-2 w-full xl:w-auto justify-end">
<div className="h-10 w-24 rounded-lg bg-kodo-steel/50" />
<div className="h-10 w-32 rounded-lg bg-kodo-steel/50" />
<div className="h-10 w-20 rounded-lg bg-kodo-steel/50" />
</div>
</div>
{/* Content skeleton */}
<div className="flex-1 overflow-hidden min-h-layout-page-sm">
{viewMode === 'list' ? (
<div className="bg-kodo-graphite border border-kodo-steel rounded-xl overflow-hidden">
<div className="border-b border-kodo-steel/30 p-4 flex gap-4">
<div className="h-4 w-10 rounded bg-kodo-steel/50" />
<div className="h-4 flex-1 rounded bg-kodo-steel/50" />
<div className="h-4 w-16 rounded bg-kodo-steel/50" />
<div className="h-4 w-12 rounded bg-kodo-steel/50" />
<div className="h-4 w-20 rounded bg-kodo-steel/50" />
</div>
<div className="divide-y divide-kodo-steel/30">
{Array.from({ length: ROW_COUNT }).map((_, i) => (
<div key={i} className="p-4 flex gap-4 items-center">
<div className="h-4 w-4 rounded bg-kodo-steel/50 shrink-0" />
<div className="flex items-center gap-4 flex-1 min-w-0">
<div className="h-5 w-5 rounded bg-kodo-steel/50 shrink-0" />
<div className="h-4 w-40 rounded bg-kodo-steel/50" />
</div>
<div className="h-4 w-24 rounded bg-kodo-steel/50" />
<div className="h-4 w-12 rounded bg-kodo-steel/50" />
<div className="h-4 w-20 rounded bg-kodo-steel/50" />
</div>
))}
</div>
</div>
) : (
<div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4">
{Array.from({ length: 8 }).map((_, i) => (
<div
key={i}
className="p-4 flex flex-col items-center gap-4 rounded-xl border border-kodo-steel bg-kodo-graphite animate-pulse"
>
<div className="w-16 h-16 rounded-2xl bg-kodo-steel/50" />
<div className="w-full space-y-2">
<div className="h-4 w-full rounded bg-kodo-steel/50" />
<div className="flex justify-center gap-1">
<div className="h-3 w-12 rounded bg-kodo-steel/50" />
<div className="h-3 w-10 rounded bg-kodo-steel/50" />
</div>
</div>
</div>
))}
</div>
)}
</div>
</div>
);
}

View file

@ -0,0 +1,55 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FileGrid } from './FileGrid';
import { mockCloudFiles } from './mockFiles';
const meta: Meta<typeof FileGrid> = {
title: 'Components/Features/Studio/CloudFileBrowser/FileGrid',
component: FileGrid,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4">
<Story />
</div>
),
],
argTypes: {
onFileClick: { action: 'fileClick' },
onToggleSelect: { action: 'toggleSelect' },
},
};
export default meta;
type Story = StoryObj<typeof FileGrid>;
export const Default: Story = {
args: {
files: mockCloudFiles,
selectedIds: [],
onFileClick: () => {},
onToggleSelect: () => {},
},
};
export const WithSelection: Story = {
args: {
...Default.args,
selectedIds: ['1', '4'],
},
};
export const Loading: Story = {
args: {
...Default.args,
files: [],
isLoading: true,
},
};
export const Empty: Story = {
args: {
...Default.args,
files: [],
},
};

View file

@ -0,0 +1,78 @@
import React from 'react';
import { cn } from '@/lib/utils';
import { FileGridCard } from './FileGridCard';
import type { CloudFileNode } from './types';
export interface FileGridProps {
files: CloudFileNode[];
selectedIds: string[];
onFileClick: (file: CloudFileNode) => void;
onToggleSelect: (id: string) => void;
isLoading?: boolean;
emptyMessage?: string;
className?: string;
}
export function FileGrid({
files,
selectedIds,
onFileClick,
onToggleSelect,
isLoading = false,
emptyMessage = 'No files match your filters.',
className,
}: FileGridProps) {
if (isLoading) {
return (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4',
className
)}
>
{Array.from({ length: 8 }).map((_, i) => (
<FileGridCard
key={i}
file={{} as CloudFileNode}
selected={false}
onSelect={() => {}}
onClick={() => {}}
isLoading
/>
))}
</div>
);
}
if (files.length === 0) {
return (
<div
className={cn(
'min-h-layout-page-sm flex items-center justify-center text-kodo-content-dim text-sm',
className
)}
>
{emptyMessage}
</div>
);
}
return (
<div
className={cn(
'grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 gap-4',
className
)}
>
{files.map((file) => (
<FileGridCard
key={file.id}
file={file}
selected={selectedIds.includes(file.id)}
onSelect={onToggleSelect}
onClick={onFileClick}
/>
))}
</div>
);
}

View file

@ -0,0 +1,63 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FileGridCard } from './FileGridCard';
import { mockCloudFiles } from './mockFiles';
const meta: Meta<typeof FileGridCard> = {
title: 'Components/Features/Studio/CloudFileBrowser/FileGridCard',
component: FileGridCard,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4">
<div className="grid grid-cols-2 md:grid-cols-4 gap-4">
<Story />
</div>
</div>
),
],
argTypes: {
onSelect: { action: 'select' },
onClick: { action: 'click' },
},
};
export default meta;
type Story = StoryObj<typeof FileGridCard>;
const file = mockCloudFiles[0];
export const Default: Story = {
args: {
file,
selected: false,
onSelect: () => {},
onClick: () => {},
},
};
export const Selected: Story = {
args: {
...Default.args,
selected: true,
},
};
export const Folder: Story = {
args: {
file: mockCloudFiles[1],
selected: false,
onSelect: () => {},
onClick: () => {},
},
};
export const Loading: Story = {
args: {
file,
selected: false,
onSelect: () => {},
onClick: () => {},
isLoading: true,
},
};

View file

@ -0,0 +1,105 @@
import React from 'react';
import { Card } from '@/components/ui/card';
import { CheckSquare, Square, Folder, Music, Image as ImageIcon, File } from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CloudFileNode } from './types';
export interface FileGridCardProps {
file: CloudFileNode;
selected: boolean;
onSelect: (id: string) => void;
onClick: (file: CloudFileNode) => void;
isLoading?: boolean;
className?: string;
}
function FileTypeIcon({ type, size = 'md' }: { type: CloudFileNode['type']; size?: 'md' | 'lg' }) {
const cl = size === 'lg' ? 'w-8 h-8' : 'w-5 h-5';
if (type === 'folder') return <Folder className={cn(cl, 'text-kodo-gold')} />;
if (type === 'audio') return <Music className={cn(cl, 'text-kodo-steel')} />;
if (type === 'image') return <ImageIcon className={cn(cl, 'text-kodo-magenta')} />;
if (['document', 'archive', 'project'].includes(type)) {
return <File className={cn(cl, 'text-kodo-content-dim')} />;
}
return <File className={cn(cl, 'text-kodo-content-dim')} />;
}
export function FileGridCard({
file,
selected,
onSelect,
onClick,
isLoading = false,
className,
}: FileGridCardProps) {
if (isLoading) {
return (
<Card
variant="default"
className={cn(
'p-4 flex flex-col items-center text-center gap-4 animate-pulse',
className
)}
>
<div className="w-16 h-16 rounded-2xl bg-kodo-steel/50" />
<div className="w-full space-y-2">
<div className="h-4 w-full rounded bg-kodo-steel/50" />
<div className="flex justify-center gap-1">
<div className="h-3 w-12 rounded bg-kodo-steel/50" />
<div className="h-3 w-10 rounded bg-kodo-steel/50" />
</div>
</div>
</Card>
);
}
return (
<Card
variant="default"
className={cn(
'p-4 flex flex-col items-center text-center gap-4 cursor-pointer hover:border-kodo-steel/50 transition-all group relative',
selected && 'border-kodo-cyan bg-kodo-cyan/5',
className
)}
onClick={() => onClick(file)}
>
<button
type="button"
className="absolute top-2 right-2 opacity-0 group-hover:opacity-100 transition-opacity z-10"
onClick={(e) => {
e.stopPropagation();
onSelect(file.id);
}}
aria-label={selected ? 'Désélectionner' : 'Sélectionner'}
>
{selected ? (
<CheckSquare className="w-4 h-4 text-kodo-cyan" />
) : (
<Square className="w-4 h-4 text-kodo-content-dim hover:text-white" />
)}
</button>
<div className="w-16 h-16 rounded-2xl bg-kodo-ink flex items-center justify-center shadow-lg transition-opacity group-hover:opacity-80 duration-200">
<FileTypeIcon type={file.type} size="lg" />
</div>
<div className="w-full min-w-0">
<h4
className="font-bold text-white text-sm truncate w-full"
title={file.name}
>
{file.name}
</h4>
<div className="flex justify-center gap-1 mt-1 flex-wrap">
{file.tags?.slice(0, 2).map((t) => (
<span
key={t}
className="text-xs bg-white/10 px-1 rounded text-kodo-content-dim"
>
{t}
</span>
))}
</div>
</div>
</Card>
);
}

View file

@ -0,0 +1,61 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FileTable } from './FileTable';
import { mockCloudFiles } from './mockFiles';
const meta: Meta<typeof FileTable> = {
title: 'Components/Features/Studio/CloudFileBrowser/FileTable',
component: FileTable,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4">
<Story />
</div>
),
],
argTypes: {
onSelectAll: { action: 'selectAll' },
onToggleSelect: { action: 'toggleSelect' },
onSort: { action: 'sort' },
onFileClick: { action: 'fileClick' },
onRowAction: { action: 'rowAction' },
},
};
export default meta;
type Story = StoryObj<typeof FileTable>;
export const Default: Story = {
args: {
files: mockCloudFiles,
selectedIds: [],
sortField: 'modified',
onSelectAll: () => {},
onToggleSelect: () => {},
onSort: () => {},
onFileClick: () => {},
},
};
export const WithSelection: Story = {
args: {
...Default.args,
selectedIds: ['1', '3', '5'],
},
};
export const Loading: Story = {
args: {
...Default.args,
files: [],
isLoading: true,
},
};
export const Empty: Story = {
args: {
...Default.args,
files: [],
},
};

View file

@ -0,0 +1,117 @@
import React from 'react';
import { CheckSquare, Square } from 'lucide-react';
import { cn } from '@/lib/utils';
import { FileTableRow } from './FileTableRow';
import type { CloudFileNode, SortField } from './types';
export interface FileTableProps {
files: CloudFileNode[];
selectedIds: string[];
onSelectAll: () => void;
onToggleSelect: (id: string) => void;
sortField: SortField;
onSort: (field: SortField) => void;
onFileClick: (file: CloudFileNode) => void;
onRowAction?: (action: string, file: CloudFileNode) => void;
isLoading?: boolean;
className?: string;
}
export function FileTable({
files,
selectedIds,
onSelectAll,
onToggleSelect,
sortField,
onSort,
onFileClick,
onRowAction,
isLoading = false,
className,
}: FileTableProps) {
const allSelected = files.length > 0 && selectedIds.length === files.length;
return (
<div
className={cn(
'bg-kodo-graphite border border-kodo-steel rounded-xl overflow-hidden',
className
)}
>
<table className="w-full text-left border-collapse">
<thead className="bg-kodo-ink text-xs font-mono text-kodo-content-dim uppercase tracking-wider sticky top-0 z-10">
<tr>
<th className="p-4 w-10">
<button
type="button"
onClick={onSelectAll}
className="cursor-pointer hover:text-white"
aria-label={allSelected ? 'Désélectionner tout' : 'Tout sélectionner'}
>
{allSelected ? (
<CheckSquare className="w-4 h-4 text-kodo-cyan" />
) : (
<Square className="w-4 h-4" />
)}
</button>
</th>
<th
className="p-4 cursor-pointer hover:text-white"
onClick={() => onSort('name')}
>
Name
</th>
<th className="p-4">Tags</th>
<th
className="p-4 cursor-pointer hover:text-white"
onClick={() => onSort('size')}
>
Size
</th>
<th
className="p-4 cursor-pointer hover:text-white"
onClick={() => onSort('modified')}
>
Modified
</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-kodo-steel/30 text-sm">
{isLoading
? Array.from({ length: 5 }).map((_, i) => (
<FileTableRow
key={i}
file={{} as CloudFileNode}
selected={false}
onToggleSelect={() => {}}
onFileClick={() => {}}
isLoading
/>
))
: files.length === 0
? (
<tr>
<td
colSpan={6}
className="p-8 text-center text-kodo-content-dim text-sm"
>
No files match your filters.
</td>
</tr>
)
: files.map((file) => (
<FileTableRow
key={file.id}
file={file}
selected={selectedIds.includes(file.id)}
onToggleSelect={onToggleSelect}
onFileClick={onFileClick}
onAction={onRowAction}
/>
))}
</tbody>
</table>
</div>
);
}

View file

@ -0,0 +1,80 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FileTableRow } from './FileTableRow';
import { mockCloudFiles } from './mockFiles';
const meta: Meta<typeof FileTableRow> = {
title: 'Components/Features/Studio/CloudFileBrowser/FileTableRow',
component: FileTableRow,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4">
<table className="w-full">
<thead className="text-xs text-kodo-content-dim uppercase">
<tr>
<th className="p-4 w-10" />
<th className="p-4">Name</th>
<th className="p-4">Tags</th>
<th className="p-4">Size</th>
<th className="p-4">Modified</th>
<th className="p-4 text-right">Actions</th>
</tr>
</thead>
<tbody className="divide-y divide-kodo-steel/30">
<Story />
</tbody>
</table>
</div>
),
],
argTypes: {
onToggleSelect: { action: 'toggleSelect' },
onFileClick: { action: 'fileClick' },
onAction: { action: 'action' },
},
};
export default meta;
type Story = StoryObj<typeof FileTableRow>;
const file = mockCloudFiles[0];
export const Default: Story = {
args: {
file,
selected: false,
onToggleSelect: () => {},
onFileClick: () => {},
},
render: (args) => <FileTableRow {...args} />,
};
export const Selected: Story = {
args: {
...Default.args,
selected: true,
},
render: (args) => <FileTableRow {...args} />,
};
export const Folder: Story = {
args: {
file: mockCloudFiles[1],
selected: false,
onToggleSelect: () => {},
onFileClick: () => {},
},
render: (args) => <FileTableRow {...args} />,
};
export const Loading: Story = {
args: {
file,
selected: false,
onToggleSelect: () => {},
onFileClick: () => {},
isLoading: true,
},
render: (args) => <FileTableRow {...args} />,
};

View file

@ -0,0 +1,155 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import {
CheckSquare,
Square,
Folder,
Music,
Image as ImageIcon,
File,
Share2,
MoreVertical,
Wand2,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { CloudFileNode } from './types';
export interface FileTableRowProps {
file: CloudFileNode;
selected: boolean;
onToggleSelect: (id: string) => void;
onFileClick: (file: CloudFileNode) => void;
onAction?: (action: string, file: CloudFileNode) => void;
isLoading?: boolean;
}
function FileTypeIcon({ type }: { type: CloudFileNode['type'] }) {
if (type === 'folder') return <Folder className="w-5 h-5 text-kodo-gold" />;
if (type === 'audio') return <Music className="w-5 h-5 text-kodo-steel" />;
if (type === 'image') return <ImageIcon className="w-5 h-5 text-kodo-magenta" />;
if (['document', 'archive', 'project'].includes(type)) {
return <File className="w-5 h-5 text-kodo-content-dim" />;
}
return <File className="w-5 h-5 text-kodo-content-dim" />;
}
export function FileTableRow({
file,
selected,
onToggleSelect,
onFileClick,
onAction,
isLoading = false,
}: FileTableRowProps) {
if (isLoading) {
return (
<tr className="animate-pulse">
<td className="p-4 w-10">
<div className="h-4 w-4 rounded bg-kodo-steel/50" />
</td>
<td className="p-4">
<div className="flex items-center gap-4">
<div className="h-5 w-5 rounded bg-kodo-steel/50 shrink-0" />
<div className="h-4 w-32 rounded bg-kodo-steel/50" />
</div>
</td>
<td className="p-4">
<div className="flex gap-1">
<div className="h-4 w-12 rounded bg-kodo-steel/50" />
<div className="h-4 w-10 rounded bg-kodo-steel/50" />
</div>
</td>
<td className="p-4">
<div className="h-4 w-12 rounded bg-kodo-steel/50 font-mono text-xs" />
</td>
<td className="p-4">
<div className="h-4 w-20 rounded bg-kodo-steel/50 text-xs" />
</td>
<td className="p-4 text-right">
<div className="h-8 w-8 rounded bg-kodo-steel/50 ml-auto" />
</td>
</tr>
);
}
return (
<tr
className={cn(
'group hover:bg-white/5 transition-colors',
selected && 'bg-kodo-cyan/5'
)}
>
<td className="p-4">
<button
type="button"
onClick={() => onToggleSelect(file.id)}
className="cursor-pointer text-kodo-content-dim hover:text-white"
aria-label={selected ? 'Désélectionner' : 'Sélectionner'}
>
{selected ? (
<CheckSquare className="w-4 h-4 text-kodo-cyan" />
) : (
<Square className="w-4 h-4" />
)}
</button>
</td>
<td className="p-4">
<button
type="button"
className="flex items-center gap-4 cursor-pointer text-left w-full"
onClick={() => onFileClick(file)}
>
<FileTypeIcon type={file.type} />
<span className="font-medium text-kodo-text-main group-hover:text-white transition-colors">
{file.name}
</span>
</button>
</td>
<td className="p-4">
<div className="flex gap-1 flex-wrap">
{file.tags?.map((t) => (
<span
key={t}
className="text-xs bg-kodo-slate px-1.5 py-0.5 rounded text-kodo-content-dim border border-kodo-steel"
>
{t}
</span>
))}
</div>
</td>
<td className="p-4 text-kodo-content-dim font-mono text-xs">{file.size}</td>
<td className="p-4 text-kodo-content-dim text-xs">{file.modified}</td>
<td className="p-4 text-right">
<div className="flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
{file.type === 'audio' && (
<Button
variant="ghost"
size="icon"
className="p-1.5 text-kodo-steel"
title="Process with AI"
onClick={() => onAction?.('ai', file)}
>
<Wand2 className="w-4 h-4" />
</Button>
)}
<Button
variant="ghost"
size="icon"
className="p-1.5"
onClick={() => onAction?.('share', file)}
>
<Share2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
className="p-1.5"
onClick={() => onAction?.('more', file)}
>
<MoreVertical className="w-4 h-4" />
</Button>
</div>
</td>
</tr>
);
}

View file

@ -0,0 +1,75 @@
import type { Meta, StoryObj } from '@storybook/react';
import { FileToolbar } from './FileToolbar';
import { FILE_BROWSER_DEFAULT_TAGS } from './types';
const meta: Meta<typeof FileToolbar> = {
title: 'Components/Features/Studio/CloudFileBrowser/FileToolbar',
component: FileToolbar,
tags: ['autodocs'],
decorators: [
(Story) => (
<div className="max-w-layout-content bg-kodo-background p-4">
<Story />
</div>
),
],
argTypes: {
onSearchChange: { action: 'search' },
onTagToggle: { action: 'tagToggle' },
onSortFieldChange: { action: 'sortField' },
onSortOrderToggle: { action: 'sortOrder' },
onViewModeChange: { action: 'viewMode' },
onBulkDownload: { action: 'bulkDownload' },
onBulkTag: { action: 'bulkTag' },
onBulkDelete: { action: 'bulkDelete' },
onMetadataClick: { action: 'metadata' },
onWatermarkClick: { action: 'watermark' },
},
};
export default meta;
type Story = StoryObj<typeof FileToolbar>;
export const Default: Story = {
args: {
searchQuery: '',
activeTags: [],
sortField: 'modified',
sortOrder: 'desc',
viewMode: 'list',
selectedCount: 0,
availableTags: FILE_BROWSER_DEFAULT_TAGS,
},
};
export const WithSearchAndTags: Story = {
args: {
...Default.args,
searchQuery: 'Vocals',
activeTags: ['Vocals', 'Stem'],
},
};
export const WithSelection: Story = {
args: {
...Default.args,
selectedCount: 3,
},
};
export const Loading: Story = {
args: {
...Default.args,
isLoading: true,
},
};
export const Empty: Story = {
args: {
...Default.args,
searchQuery: '',
activeTags: [],
selectedCount: 0,
},
};

View file

@ -0,0 +1,237 @@
import React from 'react';
import { Button } from '@/components/ui/button';
import { SearchInput } from '@/components/ui/input';
import {
LayoutGrid,
List,
Filter,
Download,
Trash2,
Tag,
ArrowUp,
ArrowDown,
Wand2,
Stamp,
} from 'lucide-react';
import { cn } from '@/lib/utils';
import type { SortField, SortOrder, ViewMode } from './types';
import { FILE_BROWSER_DEFAULT_TAGS } from './types';
const TAG_SLICE = 5;
export interface FileToolbarProps {
searchQuery: string;
onSearchChange: (value: string) => void;
activeTags: string[];
onTagToggle: (tag: string) => void;
sortField: SortField;
sortOrder: SortOrder;
onSortFieldChange: (field: SortField) => void;
onSortOrderToggle: () => void;
viewMode: ViewMode;
onViewModeChange: (mode: ViewMode) => void;
selectedCount: number;
onBulkDownload?: () => void;
onBulkTag?: () => void;
onBulkDelete?: () => void;
onMetadataClick?: () => void;
onWatermarkClick?: () => void;
availableTags?: readonly string[];
isLoading?: boolean;
className?: string;
}
export function FileToolbar({
searchQuery,
onSearchChange,
activeTags,
onTagToggle,
sortField,
sortOrder,
onSortFieldChange,
onSortOrderToggle,
viewMode,
onViewModeChange,
selectedCount,
onBulkDownload,
onBulkTag,
onBulkDelete,
onMetadataClick,
onWatermarkClick,
availableTags = FILE_BROWSER_DEFAULT_TAGS,
isLoading = false,
className,
}: FileToolbarProps) {
const tagsToShow = availableTags.slice(0, TAG_SLICE);
if (isLoading) {
return (
<div
className={cn(
'flex flex-col xl:flex-row gap-4 justify-between items-start xl:items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50 animate-pulse',
className
)}
>
<div className="flex flex-col sm:flex-row gap-4 w-full xl:w-auto">
<div className="h-10 w-full sm:w-64 rounded-lg bg-kodo-steel/50" />
<div className="flex items-center gap-2">
<div className="h-4 w-4 rounded bg-kodo-steel/50 shrink-0" />
<div className="flex gap-2">
{tagsToShow.map((_, i) => (
<div key={i} className="h-6 w-16 rounded bg-kodo-steel/50" />
))}
</div>
</div>
</div>
<div className="flex gap-2 w-full xl:w-auto">
<div className="h-10 w-24 rounded-lg bg-kodo-steel/50" />
<div className="h-10 w-32 rounded-lg bg-kodo-steel/50" />
<div className="h-10 w-20 rounded-lg bg-kodo-steel/50" />
</div>
</div>
);
}
return (
<div
className={cn(
'flex flex-col xl:flex-row gap-4 justify-between items-start xl:items-center bg-kodo-ink/50 p-4 rounded-xl border border-kodo-steel/50',
className
)}
>
<div className="flex flex-col sm:flex-row gap-4 w-full xl:w-auto">
<div className="w-full sm:w-64">
<SearchInput
placeholder="Search files..."
value={searchQuery}
onChange={(e) => onSearchChange(e.target.value)}
/>
</div>
<div className="flex items-center gap-2 overflow-x-auto no-scrollbar pb-2 sm:pb-0">
<Filter className="w-4 h-4 text-kodo-content-dim shrink-0" />
{tagsToShow.map((tag) => (
<button
key={tag}
type="button"
onClick={() => onTagToggle(tag)}
className={cn(
'px-2 py-1 rounded text-xs border transition-colors whitespace-nowrap',
activeTags.includes(tag)
? 'bg-kodo-cyan/20 border-kodo-cyan text-kodo-cyan'
: 'border-kodo-steel text-kodo-content-dim hover:border-kodo-steel'
)}
>
{tag}
</button>
))}
</div>
</div>
<div className="flex gap-2 w-full xl:w-auto justify-between xl:justify-end">
{selectedCount > 0 && (
<div className="flex gap-2 mr-2 animate-fadeIn">
<Button
variant="ghost"
size="icon"
onClick={onBulkDownload}
title="Download"
aria-label="Télécharger"
>
<Download className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onBulkTag}
title="Add Tag"
aria-label="Ajouter un tag"
>
<Tag className="w-4 h-4" />
</Button>
<Button
variant="ghost"
size="icon"
onClick={onBulkDelete}
className="text-kodo-red hover:bg-kodo-red/10"
aria-label="Supprimer"
>
<Trash2 className="w-4 h-4" />
</Button>
</div>
)}
<div className="flex gap-2">
<Button
variant="ghost"
onClick={onMetadataClick}
title="AI Auto-Tag"
aria-label="AI Auto-Tag"
>
<Wand2 className="w-4 h-4" />
</Button>
<Button
variant="ghost"
onClick={onWatermarkClick}
title="Watermark Settings"
aria-label="Paramètres de filigrane"
>
<Stamp className="w-4 h-4" />
</Button>
<div className="bg-kodo-void rounded-lg p-1 border border-kodo-steel flex items-center">
<span className="text-xs text-kodo-content-dim px-2 uppercase font-bold">
Sort:
</span>
<select
className="bg-transparent text-xs text-white outline-none"
value={sortField}
onChange={(e) => onSortFieldChange(e.target.value as SortField)}
aria-label="Sort by"
>
<option value="modified">Date</option>
<option value="name">Name</option>
<option value="size">Size</option>
<option value="type">Type</option>
</select>
<button
type="button"
onClick={onSortOrderToggle}
className="ml-2 p-1 hover:text-white text-kodo-content-dim"
aria-label="Toggle sort order"
>
{sortOrder === 'asc' ? (
<ArrowUp className="w-3 h-3" />
) : (
<ArrowDown className="w-3 h-3" />
)}
</button>
</div>
<div className="bg-kodo-void p-1 rounded-lg border border-kodo-steel flex">
<button
type="button"
onClick={() => onViewModeChange('list')}
className={cn(
'p-2 rounded',
viewMode === 'list' ? 'bg-kodo-slate text-white' : 'text-kodo-content-dim hover:text-white'
)}
aria-label="List view"
>
<List className="w-4 h-4" />
</button>
<button
type="button"
onClick={() => onViewModeChange('grid')}
className={cn(
'p-2 rounded',
viewMode === 'grid' ? 'bg-kodo-slate text-white' : 'text-kodo-content-dim hover:text-white'
)}
aria-label="Grid view"
>
<LayoutGrid className="w-4 h-4" />
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -0,0 +1,21 @@
export { CloudFileBrowser } from './CloudFileBrowser';
export { FileToolbar } from './FileToolbar';
export { FileTable } from './FileTable';
export { FileTableRow } from './FileTableRow';
export { FileGrid } from './FileGrid';
export { FileGridCard } from './FileGridCard';
export { CloudFileBrowserSkeleton } from './CloudFileBrowserSkeleton';
export { mockCloudFiles } from './mockFiles';
export {
FILE_BROWSER_DEFAULT_TAGS,
type CloudFileNode,
type SortField,
type SortOrder,
type ViewMode,
} from './types';
export type { FileToolbarProps } from './FileToolbar';
export type { FileTableProps } from './FileTable';
export type { FileTableRowProps } from './FileTableRow';
export type { FileGridProps } from './FileGrid';
export type { FileGridCardProps } from './FileGridCard';
export type { CloudFileBrowserSkeletonProps } from './CloudFileBrowserSkeleton';

View file

@ -0,0 +1,67 @@
import type { CloudFileNode } from './types';
export const mockCloudFiles: CloudFileNode[] = [
{
id: '1',
name: 'Vocals_Main.wav',
type: 'audio',
size: '24 MB',
modified: '2023-10-25',
status: 'ready',
tags: ['Vocals', 'Raw'],
},
{
id: '2',
name: 'Project_Alpha_v3',
type: 'folder',
size: '-',
modified: '2023-10-24',
status: 'ready',
tags: ['Project'],
},
{
id: '3',
name: 'Bass_Drop_F#m.wav',
type: 'audio',
size: '12 MB',
modified: '2023-10-22',
status: 'processing',
tags: ['Bass', 'One-Shot'],
},
{
id: '4',
name: 'Album_Cover_Art.png',
type: 'image',
size: '4 MB',
modified: '2023-10-15',
status: 'ready',
tags: ['Art', 'Final'],
},
{
id: '5',
name: 'Reference_Track_01.mp3',
type: 'audio',
size: '8 MB',
modified: '2023-10-10',
status: 'ready',
tags: ['Reference'],
},
{
id: '6',
name: 'Legal_Contracts.pdf',
type: 'document',
size: '2 MB',
modified: '2023-09-01',
status: 'archived',
tags: ['Legal'],
},
{
id: '7',
name: 'Drums_Stem_02.wav',
type: 'audio',
size: '45 MB',
modified: '2023-10-25',
status: 'ready',
tags: ['Drums', 'Stem'],
},
];

View file

@ -0,0 +1,20 @@
import type { FileNode } from '@/types';
/** File node with optional tags for cloud browser */
export type CloudFileNode = FileNode & { tags?: string[] };
export type SortField = 'name' | 'size' | 'modified' | 'type';
export type SortOrder = 'asc' | 'desc';
export type ViewMode = 'list' | 'grid';
export const FILE_BROWSER_DEFAULT_TAGS = [
'Vocals',
'Bass',
'Drums',
'Project',
'Art',
'Legal',
'Reference',
'Stem',
'Raw',
] as const;