From 5feb186c05f3710d3cc994ebef173adef3af2edc Mon Sep 17 00:00:00 2001 From: Claude Date: Fri, 23 Jan 2026 22:58:30 +0000 Subject: [PATCH] feat: Add Asset Library system for models, textures, and audio management - Add asset types, categories, and file format mappings (src/lib/assets/types.ts) - Create Zustand store with filtering, sorting, favorites support (src/stores/asset-store.ts) - Build full Asset Library UI with grid/list views, drag-drop upload, details panel - Integrate Asset Library button into Toolbar (desktop and mobile menus) - Add lazy-loaded AssetLibrary modal to App.tsx --- src/App.tsx | 11 + src/components/Toolbar.tsx | 30 +- src/components/assets/AssetLibrary.tsx | 771 +++++++++++++++++++++++++ src/lib/assets/types.ts | 222 +++++++ src/stores/asset-store.ts | 404 +++++++++++++ 5 files changed, 1436 insertions(+), 2 deletions(-) create mode 100644 src/components/assets/AssetLibrary.tsx create mode 100644 src/lib/assets/types.ts create mode 100644 src/stores/asset-store.ts diff --git a/src/App.tsx b/src/App.tsx index 14dd03b..61f8e06 100644 --- a/src/App.tsx +++ b/src/App.tsx @@ -34,6 +34,7 @@ const PassportLogin = lazy(() => import('./components/PassportLogin').then(m => const TranslationPanel = lazy(() => import('./components/TranslationPanel').then(m => ({ default: m.TranslationPanel }))); const AvatarToolkit = lazy(() => import('./components/AvatarToolkit')); const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas')); +const AssetLibrary = lazy(() => import('./components/assets/AssetLibrary')); function App() { const [currentCode, setCurrentCode] = useState(''); @@ -46,6 +47,7 @@ function App() { const [showTranslation, setShowTranslation] = useState(false); const [showAvatarToolkit, setShowAvatarToolkit] = useState(false); const [showVisualScripting, setShowVisualScripting] = useState(false); + const [showAssetLibrary, setShowAssetLibrary] = useState(false); const [code, setCode] = useState(''); const [currentPlatform, setCurrentPlatform] = useState('roblox'); const isMobile = useIsMobile(); @@ -483,6 +485,7 @@ end)`, onNewProjectClick={() => setShowNewProject(true)} onAvatarToolkitClick={() => setShowAvatarToolkit(true)} onVisualScriptingClick={() => setShowVisualScripting(true)} + onAssetLibraryClick={() => setShowAssetLibrary(true)} /> @@ -628,6 +631,14 @@ end)`, )} + + {showAssetLibrary && ( + setShowAssetLibrary(false)} + /> + )} + diff --git a/src/components/Toolbar.tsx b/src/components/Toolbar.tsx index 85311ab..dad36e8 100644 --- a/src/components/Toolbar.tsx +++ b/src/components/Toolbar.tsx @@ -7,7 +7,7 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; -import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch } from '@phosphor-icons/react'; +import { Copy, FileCode, Download, Info, Play, FolderPlus, User, SignOut, List, ArrowsLeftRight, UserCircle, GitBranch, Package } from '@phosphor-icons/react'; import { toast } from 'sonner'; import { useState, useEffect, useCallback, memo } from 'react'; import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog'; @@ -26,9 +26,10 @@ interface ToolbarProps { onTranslateClick?: () => void; onAvatarToolkitClick?: () => void; onVisualScriptingClick?: () => void; + onAssetLibraryClick?: () => void; } -export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick }: ToolbarProps) { +export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectClick, currentPlatform, onPlatformChange, onTranslateClick, onAvatarToolkitClick, onVisualScriptingClick, onAssetLibraryClick }: ToolbarProps) { const [showInfo, setShowInfo] = useState(false); const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null); @@ -138,6 +139,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl )} + {/* Asset Library Button */} + {onAssetLibraryClick && ( + + + + + Asset Library (Models, Textures, Audio) + + )} +
@@ -264,6 +284,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl Visual Scripting )} + {onAssetLibraryClick && ( + + + Asset Library + + )} Copy Code diff --git a/src/components/assets/AssetLibrary.tsx b/src/components/assets/AssetLibrary.tsx new file mode 100644 index 0000000..70636b5 --- /dev/null +++ b/src/components/assets/AssetLibrary.tsx @@ -0,0 +1,771 @@ +'use client'; + +import { useState, useCallback, useRef } from 'react'; +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; +import { Button } from '@/components/ui/button'; +import { Input } from '@/components/ui/input'; +import { Badge } from '@/components/ui/badge'; +import { ScrollArea } from '@/components/ui/scroll-area'; +import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs'; +import { Separator } from '@/components/ui/separator'; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from '@/components/ui/select'; +import { + DropdownMenu, + DropdownMenuContent, + DropdownMenuItem, + DropdownMenuSeparator, + DropdownMenuTrigger, +} from '@/components/ui/dropdown-menu'; +import { + Upload, + Search, + Grid3X3, + List, + Folder, + FolderPlus, + Star, + Trash2, + Download, + Copy, + MoreVertical, + Image, + Volume2, + Box, + FileCode, + Sparkles, + Database, + Play, + Package, + Palette, + Layout, + Filter, + SortAsc, + SortDesc, + X, + Heart, + Tag, + Info, + Music, +} from 'lucide-react'; +import { toast } from 'sonner'; + +import { useAssetStore } from '@/stores/asset-store'; +import { + Asset, + AssetType, + AssetCategory, + ASSET_TYPE_ICONS, + ASSET_TYPE_COLORS, + CATEGORY_LABELS, + formatFileSize, + getAcceptedFileTypes, +} from '@/lib/assets/types'; + +interface AssetLibraryProps { + isOpen: boolean; + onClose: () => void; + onAssetSelect?: (asset: Asset) => void; +} + +// Asset type icons mapping +const TypeIcon: Record = { + model: , + texture: , + audio: , + animation: , + script: , + prefab: , + material: , + particle: , + ui: , + data: , +}; + +export default function AssetLibrary({ + isOpen, + onClose, + onAssetSelect, +}: AssetLibraryProps) { + const fileInputRef = useRef(null); + const [searchQuery, setSearchQuery] = useState(''); + const [selectedType, setSelectedType] = useState('all'); + const [isDragging, setIsDragging] = useState(false); + + const { + assets, + folders, + selectedAssetId, + viewMode, + sort, + addAsset, + removeAsset, + updateAsset, + setSelectedAsset, + setViewMode, + setSort, + setFilter, + toggleFavorite, + getFilteredAssets, + getFavorites, + } = useAssetStore(); + + // Handle file upload + const handleFileUpload = useCallback( + async (files: FileList | File[]) => { + const fileArray = Array.from(files); + + for (const file of fileArray) { + try { + await addAsset(file); + toast.success(`Uploaded ${file.name}`); + } catch (error) { + toast.error(`Failed to upload ${file.name}`); + } + } + }, + [addAsset] + ); + + // Handle drag and drop + const handleDragOver = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(true); + }, []); + + const handleDragLeave = useCallback((e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + }, []); + + const handleDrop = useCallback( + (e: React.DragEvent) => { + e.preventDefault(); + setIsDragging(false); + if (e.dataTransfer.files.length > 0) { + handleFileUpload(e.dataTransfer.files); + } + }, + [handleFileUpload] + ); + + // Filter assets + const filteredAssets = getFilteredAssets().filter((asset) => { + const matchesSearch = + !searchQuery || + asset.name.toLowerCase().includes(searchQuery.toLowerCase()) || + asset.tags.some((t) => t.toLowerCase().includes(searchQuery.toLowerCase())); + + const matchesType = selectedType === 'all' || asset.type === selectedType; + + return matchesSearch && matchesType; + }); + + const favorites = getFavorites(); + const selectedAsset = assets.find((a) => a.id === selectedAssetId); + + // Asset type counts + const typeCounts = assets.reduce((acc, asset) => { + acc[asset.type] = (acc[asset.type] || 0) + 1; + return acc; + }, {} as Record); + + return ( + !open && onClose()}> + + + + + Asset Library + + {assets.length} assets + + + + +
+ {/* Sidebar */} +
+ {/* Upload Button */} +
+ e.target.files && handleFileUpload(e.target.files)} + className="hidden" + /> + +
+ + + + {/* Quick Filters */} + +
+ + + + + + +

+ By Type +

+ + {(Object.keys(TypeIcon) as AssetType[]).map((type) => ( + + ))} +
+
+ + {/* Folders */} + +
+
+ + Folders + + +
+ {folders.map((folder) => ( + + ))} +
+
+ + {/* Main Content */} +
+ {/* Toolbar */} +
+
+ + setSearchQuery(e.target.value)} + className="pl-9" + /> +
+ + + + + + + + + +
+ + {/* Drop Zone / Asset Grid */} +
+ {isDragging && ( +
+
+ +

+ Drop files to upload +

+
+
+ )} + + {filteredAssets.length === 0 ? ( +
+ +

No assets found

+

+ {searchQuery + ? `No results for "${searchQuery}"` + : 'Upload some assets to get started'} +

+ +
+ ) : ( + + {viewMode === 'grid' ? ( +
+ {filteredAssets.map((asset) => ( + setSelectedAsset(asset.id)} + onDoubleClick={() => onAssetSelect?.(asset)} + onFavorite={() => toggleFavorite(asset.id)} + onDelete={() => removeAsset(asset.id)} + /> + ))} +
+ ) : ( +
+ {filteredAssets.map((asset) => ( + setSelectedAsset(asset.id)} + onDoubleClick={() => onAssetSelect?.(asset)} + onFavorite={() => toggleFavorite(asset.id)} + onDelete={() => removeAsset(asset.id)} + /> + ))} +
+ )} +
+ )} +
+
+ + {/* Details Panel */} + {selectedAsset && ( +
+
+
+

{selectedAsset.name}

+ +
+ + {selectedAsset.type} + +
+ + {/* Preview */} +
+
+ {selectedAsset.thumbnailUrl ? ( + {selectedAsset.name} + ) : selectedAsset.type === 'audio' ? ( +
+ + {selectedAsset.dataUrl && ( + + )} +
+ ) : ( +
+ {TypeIcon[selectedAsset.type]} +
+ )} +
+
+ + {/* Metadata */} + +
+
+

+ File Info +

+
+
+ Size + {formatFileSize(selectedAsset.metadata.fileSize)} +
+
+ Format + + {selectedAsset.metadata.extension} + +
+ {selectedAsset.metadata.width && ( +
+ Dimensions + + {selectedAsset.metadata.width}×{selectedAsset.metadata.height} + +
+ )} + {selectedAsset.metadata.duration && ( +
+ Duration + {selectedAsset.metadata.duration.toFixed(1)}s +
+ )} +
+
+ + {selectedAsset.tags.length > 0 && ( +
+

+ Tags +

+
+ {selectedAsset.tags.map((tag) => ( + + {tag} + + ))} +
+
+ )} + +
+

+ Actions +

+
+ + + +
+
+
+
+
+ )} +
+
+
+ ); +} + +// Asset Card Component (Grid View) +function AssetCard({ + asset, + isSelected, + onSelect, + onDoubleClick, + onFavorite, + onDelete, +}: { + asset: Asset; + isSelected: boolean; + onSelect: () => void; + onDoubleClick: () => void; + onFavorite: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Thumbnail */} +
+ {asset.thumbnailUrl ? ( + {asset.name} + ) : ( +
+ {TypeIcon[asset.type]} +
+ )} +
+ + {/* Info */} +
+

{asset.name}

+

+ {formatFileSize(asset.metadata.fileSize)} +

+
+ + {/* Favorite Badge */} + {asset.favorite && ( + + )} + + {/* Hover Actions */} +
+ + + + + + + + {asset.favorite ? 'Unfavorite' : 'Favorite'} + + + + Download + + + + + Delete + + + +
+ + {/* Type Badge */} + + {asset.type} + +
+ ); +} + +// Asset Row Component (List View) +function AssetRow({ + asset, + isSelected, + onSelect, + onDoubleClick, + onFavorite, + onDelete, +}: { + asset: Asset; + isSelected: boolean; + onSelect: () => void; + onDoubleClick: () => void; + onFavorite: () => void; + onDelete: () => void; +}) { + return ( +
+ {/* Thumbnail */} +
+ {asset.thumbnailUrl ? ( + {asset.name} + ) : ( +
+ {TypeIcon[asset.type]} +
+ )} +
+ + {/* Name */} +
+

{asset.name}

+

{asset.type}

+
+ + {/* Size */} + + {formatFileSize(asset.metadata.fileSize)} + + + {/* Favorite */} + + + {/* Actions */} + + + + + + + + Download + + + + Copy ID + + + + + Delete + + + +
+ ); +} diff --git a/src/lib/assets/types.ts b/src/lib/assets/types.ts new file mode 100644 index 0000000..1250a5c --- /dev/null +++ b/src/lib/assets/types.ts @@ -0,0 +1,222 @@ +/** + * AeThex Asset Library - Type Definitions + * Types for managing game assets (models, textures, audio, etc.) + */ + +export type AssetType = + | 'model' // 3D models (GLB, FBX, OBJ) + | 'texture' // Images (PNG, JPG, WEBP) + | 'audio' // Sounds (MP3, WAV, OGG) + | 'animation' // Animation files + | 'script' // Code snippets + | 'prefab' // Pre-made game objects + | 'material' // Material definitions + | 'particle' // Particle effects + | 'ui' // UI elements + | 'data'; // Data files (JSON, CSV) + +export type AssetCategory = + | 'characters' + | 'environments' + | 'props' + | 'vehicles' + | 'weapons' + | 'effects' + | 'ui-elements' + | 'sounds' + | 'music' + | 'scripts' + | 'materials' + | 'uncategorized'; + +export interface AssetMetadata { + // File info + fileName: string; + fileSize: number; + mimeType: string; + extension: string; + + // Asset-specific metadata + width?: number; // For images + height?: number; // For images + duration?: number; // For audio/video + sampleRate?: number; // For audio + channels?: number; // For audio + polyCount?: number; // For 3D models + vertexCount?: number; // For 3D models + hasAnimations?: boolean; // For 3D models + boneCount?: number; // For 3D models + materialCount?: number; // For 3D models +} + +export interface Asset { + id: string; + name: string; + type: AssetType; + category: AssetCategory; + description?: string; + tags: string[]; + + // File data + file?: File; + dataUrl?: string; // Base64 for small files + blobUrl?: string; // Object URL for large files + thumbnailUrl?: string; // Preview image + + // Metadata + metadata: AssetMetadata; + + // Organization + folderId?: string; + favorite: boolean; + + // Timestamps + createdAt: Date; + updatedAt: Date; + + // Platform compatibility + platforms: ('roblox' | 'uefn' | 'spatial' | 'universal')[]; +} + +export interface AssetFolder { + id: string; + name: string; + parentId?: string; + color?: string; + icon?: string; + createdAt: Date; +} + +export interface AssetUploadOptions { + name?: string; + category?: AssetCategory; + tags?: string[]; + folderId?: string; + generateThumbnail?: boolean; +} + +export interface AssetFilter { + type?: AssetType | AssetType[]; + category?: AssetCategory | AssetCategory[]; + tags?: string[]; + folderId?: string; + favorite?: boolean; + search?: string; +} + +export interface AssetSortOptions { + field: 'name' | 'createdAt' | 'updatedAt' | 'fileSize' | 'type'; + direction: 'asc' | 'desc'; +} + +// File format mappings +export const FILE_TYPE_MAP: Record = { + // Models + '.glb': 'model', + '.gltf': 'model', + '.fbx': 'model', + '.obj': 'model', + '.dae': 'model', + + // Textures + '.png': 'texture', + '.jpg': 'texture', + '.jpeg': 'texture', + '.webp': 'texture', + '.svg': 'texture', + '.gif': 'texture', + '.bmp': 'texture', + '.tga': 'texture', + + // Audio + '.mp3': 'audio', + '.wav': 'audio', + '.ogg': 'audio', + '.m4a': 'audio', + '.flac': 'audio', + + // Animations + '.bvh': 'animation', + '.anim': 'animation', + + // Scripts + '.lua': 'script', + '.verse': 'script', + '.ts': 'script', + '.js': 'script', + + // Data + '.json': 'data', + '.csv': 'data', + '.xml': 'data', +}; + +export const ASSET_TYPE_ICONS: Record = { + model: 'Box', + texture: 'Image', + audio: 'Volume2', + animation: 'Play', + script: 'FileCode', + prefab: 'Package', + material: 'Palette', + particle: 'Sparkles', + ui: 'Layout', + data: 'Database', +}; + +export const ASSET_TYPE_COLORS: Record = { + model: '#3b82f6', // Blue + texture: '#22c55e', // Green + audio: '#f97316', // Orange + animation: '#a855f7', // Purple + script: '#eab308', // Yellow + prefab: '#ec4899', // Pink + material: '#14b8a6', // Teal + particle: '#f43f5e', // Rose + ui: '#8b5cf6', // Violet + data: '#6b7280', // Gray +}; + +export const CATEGORY_LABELS: Record = { + characters: 'Characters', + environments: 'Environments', + props: 'Props', + vehicles: 'Vehicles', + weapons: 'Weapons', + effects: 'Effects', + 'ui-elements': 'UI Elements', + sounds: 'Sound Effects', + music: 'Music', + scripts: 'Scripts', + materials: 'Materials', + uncategorized: 'Uncategorized', +}; + +// Helper functions +export function getAssetTypeFromFile(file: File): AssetType { + const extension = '.' + file.name.split('.').pop()?.toLowerCase(); + return FILE_TYPE_MAP[extension] || 'data'; +} + +export function formatFileSize(bytes: number): string { + if (bytes === 0) return '0 B'; + const k = 1024; + const sizes = ['B', 'KB', 'MB', 'GB']; + const i = Math.floor(Math.log(bytes) / Math.log(k)); + return parseFloat((bytes / Math.pow(k, i)).toFixed(1)) + ' ' + sizes[i]; +} + +export function getAcceptedFileTypes(type?: AssetType): string { + if (!type) { + return Object.keys(FILE_TYPE_MAP).join(','); + } + + return Object.entries(FILE_TYPE_MAP) + .filter(([_, t]) => t === type) + .map(([ext]) => ext) + .join(','); +} + +export function generateAssetId(): string { + return `asset-${Date.now()}-${Math.random().toString(36).substr(2, 9)}`; +} diff --git a/src/stores/asset-store.ts b/src/stores/asset-store.ts new file mode 100644 index 0000000..19599d0 --- /dev/null +++ b/src/stores/asset-store.ts @@ -0,0 +1,404 @@ +/** + * AeThex Asset Library - State Store + * Zustand store for managing game assets + */ + +import { create } from 'zustand'; +import { persist } from 'zustand/middleware'; +import { + Asset, + AssetFolder, + AssetFilter, + AssetSortOptions, + AssetType, + AssetCategory, + AssetUploadOptions, + getAssetTypeFromFile, + generateAssetId, +} from '../lib/assets/types'; + +interface AssetState { + // Asset data + assets: Asset[]; + folders: AssetFolder[]; + + // UI state + selectedAssetId: string | null; + selectedFolderId: string | null; + viewMode: 'grid' | 'list'; + filter: AssetFilter; + sort: AssetSortOptions; + + // Actions + addAsset: (file: File, options?: AssetUploadOptions) => Promise; + removeAsset: (id: string) => void; + updateAsset: (id: string, updates: Partial) => void; + getAsset: (id: string) => Asset | undefined; + + addFolder: (name: string, parentId?: string) => AssetFolder; + removeFolder: (id: string) => void; + updateFolder: (id: string, updates: Partial) => void; + + setSelectedAsset: (id: string | null) => void; + setSelectedFolder: (id: string | null) => void; + setViewMode: (mode: 'grid' | 'list') => void; + setFilter: (filter: Partial) => void; + setSort: (sort: AssetSortOptions) => void; + clearFilter: () => void; + + toggleFavorite: (id: string) => void; + addTag: (id: string, tag: string) => void; + removeTag: (id: string, tag: string) => void; + + getFilteredAssets: () => Asset[]; + getAssetsByFolder: (folderId?: string) => Asset[]; + getAssetsByType: (type: AssetType) => Asset[]; + getAssetsByCategory: (category: AssetCategory) => Asset[]; + getFavorites: () => Asset[]; + searchAssets: (query: string) => Asset[]; +} + +// Helper to create thumbnail from image +async function createImageThumbnail(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => { + const img = new Image(); + img.onload = () => { + const canvas = document.createElement('canvas'); + const ctx = canvas.getContext('2d')!; + const maxSize = 128; + let width = img.width; + let height = img.height; + + if (width > height) { + if (width > maxSize) { + height *= maxSize / width; + width = maxSize; + } + } else { + if (height > maxSize) { + width *= maxSize / height; + height = maxSize; + } + } + + canvas.width = width; + canvas.height = height; + ctx.drawImage(img, 0, 0, width, height); + resolve(canvas.toDataURL('image/jpeg', 0.7)); + }; + img.onerror = reject; + img.src = e.target?.result as string; + }; + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +// Helper to read file as data URL +async function readFileAsDataUrl(file: File): Promise { + return new Promise((resolve, reject) => { + const reader = new FileReader(); + reader.onload = (e) => resolve(e.target?.result as string); + reader.onerror = reject; + reader.readAsDataURL(file); + }); +} + +// Helper to get audio metadata +async function getAudioMetadata(file: File): Promise<{ duration: number }> { + return new Promise((resolve) => { + const audio = new Audio(); + audio.onloadedmetadata = () => { + resolve({ duration: audio.duration }); + }; + audio.onerror = () => resolve({ duration: 0 }); + audio.src = URL.createObjectURL(file); + }); +} + +// Helper to get image metadata +async function getImageMetadata(file: File): Promise<{ width: number; height: number }> { + return new Promise((resolve) => { + const img = new Image(); + img.onload = () => { + resolve({ width: img.width, height: img.height }); + URL.revokeObjectURL(img.src); + }; + img.onerror = () => resolve({ width: 0, height: 0 }); + img.src = URL.createObjectURL(file); + }); +} + +export const useAssetStore = create()( + persist( + (set, get) => ({ + assets: [], + folders: [ + { id: 'default', name: 'My Assets', createdAt: new Date() }, + ], + selectedAssetId: null, + selectedFolderId: null, + viewMode: 'grid', + filter: {}, + sort: { field: 'createdAt', direction: 'desc' }, + + addAsset: async (file: File, options?: AssetUploadOptions): Promise => { + const assetType = getAssetTypeFromFile(file); + const id = generateAssetId(); + + // Get metadata based on type + let metadata: any = { + fileName: file.name, + fileSize: file.size, + mimeType: file.type, + extension: file.name.split('.').pop()?.toLowerCase() || '', + }; + + let thumbnailUrl: string | undefined; + let dataUrl: string | undefined; + + // Process based on asset type + if (assetType === 'texture') { + const imgMeta = await getImageMetadata(file); + metadata = { ...metadata, ...imgMeta }; + thumbnailUrl = await createImageThumbnail(file); + if (file.size < 5 * 1024 * 1024) { // Under 5MB, store as data URL + dataUrl = await readFileAsDataUrl(file); + } + } else if (assetType === 'audio') { + const audioMeta = await getAudioMetadata(file); + metadata = { ...metadata, ...audioMeta }; + if (file.size < 10 * 1024 * 1024) { // Under 10MB + dataUrl = await readFileAsDataUrl(file); + } + } else if (file.size < 5 * 1024 * 1024) { + dataUrl = await readFileAsDataUrl(file); + } + + const asset: Asset = { + id, + name: options?.name || file.name.replace(/\.[^.]+$/, ''), + type: assetType, + category: options?.category || 'uncategorized', + tags: options?.tags || [], + file, + dataUrl, + thumbnailUrl, + metadata, + folderId: options?.folderId || 'default', + favorite: false, + createdAt: new Date(), + updatedAt: new Date(), + platforms: ['universal'], + }; + + set((state) => ({ + assets: [...state.assets, asset], + })); + + return asset; + }, + + removeAsset: (id: string) => { + const asset = get().assets.find((a) => a.id === id); + if (asset?.blobUrl) { + URL.revokeObjectURL(asset.blobUrl); + } + set((state) => ({ + assets: state.assets.filter((a) => a.id !== id), + selectedAssetId: state.selectedAssetId === id ? null : state.selectedAssetId, + })); + }, + + updateAsset: (id: string, updates: Partial) => { + set((state) => ({ + assets: state.assets.map((a) => + a.id === id ? { ...a, ...updates, updatedAt: new Date() } : a + ), + })); + }, + + getAsset: (id: string) => { + return get().assets.find((a) => a.id === id); + }, + + addFolder: (name: string, parentId?: string): AssetFolder => { + const folder: AssetFolder = { + id: `folder-${Date.now()}`, + name, + parentId, + createdAt: new Date(), + }; + set((state) => ({ + folders: [...state.folders, folder], + })); + return folder; + }, + + removeFolder: (id: string) => { + set((state) => ({ + folders: state.folders.filter((f) => f.id !== id), + assets: state.assets.map((a) => + a.folderId === id ? { ...a, folderId: 'default' } : a + ), + selectedFolderId: state.selectedFolderId === id ? null : state.selectedFolderId, + })); + }, + + updateFolder: (id: string, updates: Partial) => { + set((state) => ({ + folders: state.folders.map((f) => + f.id === id ? { ...f, ...updates } : f + ), + })); + }, + + setSelectedAsset: (id: string | null) => set({ selectedAssetId: id }), + setSelectedFolder: (id: string | null) => set({ selectedFolderId: id }), + setViewMode: (mode: 'grid' | 'list') => set({ viewMode: mode }), + + setFilter: (filter: Partial) => { + set((state) => ({ + filter: { ...state.filter, ...filter }, + })); + }, + + setSort: (sort: AssetSortOptions) => set({ sort }), + + clearFilter: () => set({ filter: {} }), + + toggleFavorite: (id: string) => { + set((state) => ({ + assets: state.assets.map((a) => + a.id === id ? { ...a, favorite: !a.favorite } : a + ), + })); + }, + + addTag: (id: string, tag: string) => { + set((state) => ({ + assets: state.assets.map((a) => + a.id === id && !a.tags.includes(tag) + ? { ...a, tags: [...a.tags, tag] } + : a + ), + })); + }, + + removeTag: (id: string, tag: string) => { + set((state) => ({ + assets: state.assets.map((a) => + a.id === id ? { ...a, tags: a.tags.filter((t) => t !== tag) } : a + ), + })); + }, + + getFilteredAssets: () => { + const { assets, filter, sort } = get(); + let filtered = [...assets]; + + // Apply filters + if (filter.type) { + const types = Array.isArray(filter.type) ? filter.type : [filter.type]; + filtered = filtered.filter((a) => types.includes(a.type)); + } + + if (filter.category) { + const categories = Array.isArray(filter.category) + ? filter.category + : [filter.category]; + filtered = filtered.filter((a) => categories.includes(a.category)); + } + + if (filter.tags && filter.tags.length > 0) { + filtered = filtered.filter((a) => + filter.tags!.some((tag) => a.tags.includes(tag)) + ); + } + + if (filter.folderId) { + filtered = filtered.filter((a) => a.folderId === filter.folderId); + } + + if (filter.favorite !== undefined) { + filtered = filtered.filter((a) => a.favorite === filter.favorite); + } + + if (filter.search) { + const query = filter.search.toLowerCase(); + filtered = filtered.filter( + (a) => + a.name.toLowerCase().includes(query) || + a.tags.some((t) => t.toLowerCase().includes(query)) || + a.description?.toLowerCase().includes(query) + ); + } + + // Apply sorting + filtered.sort((a, b) => { + let comparison = 0; + switch (sort.field) { + case 'name': + comparison = a.name.localeCompare(b.name); + break; + case 'createdAt': + comparison = new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime(); + break; + case 'updatedAt': + comparison = new Date(a.updatedAt).getTime() - new Date(b.updatedAt).getTime(); + break; + case 'fileSize': + comparison = a.metadata.fileSize - b.metadata.fileSize; + break; + case 'type': + comparison = a.type.localeCompare(b.type); + break; + } + return sort.direction === 'asc' ? comparison : -comparison; + }); + + return filtered; + }, + + getAssetsByFolder: (folderId?: string) => { + return get().assets.filter((a) => a.folderId === (folderId || 'default')); + }, + + getAssetsByType: (type: AssetType) => { + return get().assets.filter((a) => a.type === type); + }, + + getAssetsByCategory: (category: AssetCategory) => { + return get().assets.filter((a) => a.category === category); + }, + + getFavorites: () => { + return get().assets.filter((a) => a.favorite); + }, + + searchAssets: (query: string) => { + const lowerQuery = query.toLowerCase(); + return get().assets.filter( + (a) => + a.name.toLowerCase().includes(lowerQuery) || + a.tags.some((t) => t.toLowerCase().includes(lowerQuery)) + ); + }, + }), + { + name: 'aethex-assets', + partialize: (state) => ({ + assets: state.assets.map((a) => ({ + ...a, + file: undefined, // Don't persist File objects + blobUrl: undefined, // Don't persist blob URLs + })), + folders: state.folders, + viewMode: state.viewMode, + sort: state.sort, + }), + } + ) +);