refactor(studio): modularize CloudFileBrowser with FileTable, FileGrid and FileToolbar
This commit is contained in:
parent
7da61f9911
commit
6f8a16359a
17 changed files with 1463 additions and 0 deletions
|
|
@ -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 = {};
|
||||
|
|
@ -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>
|
||||
);
|
||||
};
|
||||
|
|
@ -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',
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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: [],
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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} />,
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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,
|
||||
},
|
||||
};
|
||||
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
@ -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';
|
||||
|
|
@ -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'],
|
||||
},
|
||||
];
|
||||
|
|
@ -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;
|
||||
Loading…
Reference in a new issue