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
This commit is contained in:
parent
6aff5ac183
commit
5feb186c05
5 changed files with 1436 additions and 2 deletions
11
src/App.tsx
11
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<PlatformId>('roblox');
|
||||
const isMobile = useIsMobile();
|
||||
|
|
@ -483,6 +485,7 @@ end)`,
|
|||
onNewProjectClick={() => setShowNewProject(true)}
|
||||
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
||||
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
||||
onAssetLibraryClick={() => setShowAssetLibrary(true)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
@ -628,6 +631,14 @@ end)`,
|
|||
</Dialog>
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
{showAssetLibrary && (
|
||||
<AssetLibrary
|
||||
isOpen={showAssetLibrary}
|
||||
onClose={() => setShowAssetLibrary(false)}
|
||||
/>
|
||||
)}
|
||||
</Suspense>
|
||||
<Suspense fallback={null}>
|
||||
<WelcomeDialog />
|
||||
</Suspense>
|
||||
|
|
|
|||
|
|
@ -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
|
|||
</Tooltip>
|
||||
)}
|
||||
|
||||
{/* Asset Library Button */}
|
||||
{onAssetLibraryClick && (
|
||||
<Tooltip>
|
||||
<TooltipTrigger asChild>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={onAssetLibraryClick}
|
||||
className="h-8 px-3 text-xs gap-1"
|
||||
aria-label="Asset Library"
|
||||
>
|
||||
<Package size={14} />
|
||||
<span>Assets</span>
|
||||
</Button>
|
||||
</TooltipTrigger>
|
||||
<TooltipContent>Asset Library (Models, Textures, Audio)</TooltipContent>
|
||||
</Tooltip>
|
||||
)}
|
||||
|
||||
<div className="h-6 w-px bg-border mx-1" />
|
||||
|
||||
<Tooltip>
|
||||
|
|
@ -264,6 +284,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
|||
<span>Visual Scripting</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
{onAssetLibraryClick && (
|
||||
<DropdownMenuItem onClick={onAssetLibraryClick}>
|
||||
<Package className="mr-2" size={16} />
|
||||
<span>Asset Library</span>
|
||||
</DropdownMenuItem>
|
||||
)}
|
||||
<DropdownMenuItem onClick={handleCopy}>
|
||||
<Copy className="mr-2" size={16} />
|
||||
<span>Copy Code</span>
|
||||
|
|
|
|||
771
src/components/assets/AssetLibrary.tsx
Normal file
771
src/components/assets/AssetLibrary.tsx
Normal file
|
|
@ -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<AssetType, React.ReactNode> = {
|
||||
model: <Box className="h-4 w-4" />,
|
||||
texture: <Image className="h-4 w-4" />,
|
||||
audio: <Volume2 className="h-4 w-4" />,
|
||||
animation: <Play className="h-4 w-4" />,
|
||||
script: <FileCode className="h-4 w-4" />,
|
||||
prefab: <Package className="h-4 w-4" />,
|
||||
material: <Palette className="h-4 w-4" />,
|
||||
particle: <Sparkles className="h-4 w-4" />,
|
||||
ui: <Layout className="h-4 w-4" />,
|
||||
data: <Database className="h-4 w-4" />,
|
||||
};
|
||||
|
||||
export default function AssetLibrary({
|
||||
isOpen,
|
||||
onClose,
|
||||
onAssetSelect,
|
||||
}: AssetLibraryProps) {
|
||||
const fileInputRef = useRef<HTMLInputElement>(null);
|
||||
const [searchQuery, setSearchQuery] = useState('');
|
||||
const [selectedType, setSelectedType] = useState<AssetType | 'all'>('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<AssetType, number>);
|
||||
|
||||
return (
|
||||
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
|
||||
<DialogContent className="max-w-6xl max-h-[85vh] p-0 flex flex-col">
|
||||
<DialogHeader className="px-4 py-3 border-b">
|
||||
<DialogTitle className="flex items-center gap-2">
|
||||
<Package className="h-5 w-5 text-primary" />
|
||||
Asset Library
|
||||
<Badge variant="secondary" className="ml-2">
|
||||
{assets.length} assets
|
||||
</Badge>
|
||||
</DialogTitle>
|
||||
</DialogHeader>
|
||||
|
||||
<div className="flex flex-1 min-h-0">
|
||||
{/* Sidebar */}
|
||||
<div className="w-56 border-r bg-muted/30 flex flex-col">
|
||||
{/* Upload Button */}
|
||||
<div className="p-3">
|
||||
<input
|
||||
ref={fileInputRef}
|
||||
type="file"
|
||||
multiple
|
||||
accept={getAcceptedFileTypes()}
|
||||
onChange={(e) => e.target.files && handleFileUpload(e.target.files)}
|
||||
className="hidden"
|
||||
/>
|
||||
<Button
|
||||
className="w-full"
|
||||
onClick={() => fileInputRef.current?.click()}
|
||||
>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Assets
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
{/* Quick Filters */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-2 space-y-1">
|
||||
<button
|
||||
onClick={() => setSelectedType('all')}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
selectedType === 'all'
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
<span>All Assets</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{assets.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<button
|
||||
onClick={() => setFilter({ favorite: true })}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<Star className="h-4 w-4 text-yellow-500" />
|
||||
<span>Favorites</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{favorites.length}
|
||||
</span>
|
||||
</button>
|
||||
|
||||
<Separator className="my-2" />
|
||||
|
||||
<p className="px-3 py-1 text-xs font-medium text-muted-foreground uppercase">
|
||||
By Type
|
||||
</p>
|
||||
|
||||
{(Object.keys(TypeIcon) as AssetType[]).map((type) => (
|
||||
<button
|
||||
key={type}
|
||||
onClick={() => setSelectedType(type)}
|
||||
className={`w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm transition-colors ${
|
||||
selectedType === type
|
||||
? 'bg-primary/10 text-primary'
|
||||
: 'hover:bg-muted'
|
||||
}`}
|
||||
>
|
||||
<span style={{ color: ASSET_TYPE_COLORS[type] }}>
|
||||
{TypeIcon[type]}
|
||||
</span>
|
||||
<span className="capitalize">{type}</span>
|
||||
<span className="ml-auto text-xs text-muted-foreground">
|
||||
{typeCounts[type] || 0}
|
||||
</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</ScrollArea>
|
||||
|
||||
{/* Folders */}
|
||||
<Separator />
|
||||
<div className="p-2">
|
||||
<div className="flex items-center justify-between px-2 py-1">
|
||||
<span className="text-xs font-medium text-muted-foreground uppercase">
|
||||
Folders
|
||||
</span>
|
||||
<Button variant="ghost" size="icon" className="h-6 w-6">
|
||||
<FolderPlus className="h-3 w-3" />
|
||||
</Button>
|
||||
</div>
|
||||
{folders.map((folder) => (
|
||||
<button
|
||||
key={folder.id}
|
||||
className="w-full flex items-center gap-2 px-3 py-2 rounded-md text-sm hover:bg-muted transition-colors"
|
||||
>
|
||||
<Folder className="h-4 w-4 text-yellow-500" />
|
||||
<span>{folder.name}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Main Content */}
|
||||
<div className="flex-1 flex flex-col min-h-0">
|
||||
{/* Toolbar */}
|
||||
<div className="flex items-center gap-2 p-3 border-b">
|
||||
<div className="relative flex-1 max-w-md">
|
||||
<Search className="absolute left-2.5 top-2.5 h-4 w-4 text-muted-foreground" />
|
||||
<Input
|
||||
placeholder="Search assets..."
|
||||
value={searchQuery}
|
||||
onChange={(e) => setSearchQuery(e.target.value)}
|
||||
className="pl-9"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Select
|
||||
value={sort.field}
|
||||
onValueChange={(field: any) => setSort({ ...sort, field })}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="createdAt">Date Added</SelectItem>
|
||||
<SelectItem value="name">Name</SelectItem>
|
||||
<SelectItem value="fileSize">Size</SelectItem>
|
||||
<SelectItem value="type">Type</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
onClick={() =>
|
||||
setSort({
|
||||
...sort,
|
||||
direction: sort.direction === 'asc' ? 'desc' : 'asc',
|
||||
})
|
||||
}
|
||||
>
|
||||
{sort.direction === 'asc' ? (
|
||||
<SortAsc className="h-4 w-4" />
|
||||
) : (
|
||||
<SortDesc className="h-4 w-4" />
|
||||
)}
|
||||
</Button>
|
||||
|
||||
<Separator orientation="vertical" className="h-6" />
|
||||
|
||||
<Button
|
||||
variant={viewMode === 'grid' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('grid')}
|
||||
>
|
||||
<Grid3X3 className="h-4 w-4" />
|
||||
</Button>
|
||||
<Button
|
||||
variant={viewMode === 'list' ? 'secondary' : 'ghost'}
|
||||
size="icon"
|
||||
onClick={() => setViewMode('list')}
|
||||
>
|
||||
<List className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{/* Drop Zone / Asset Grid */}
|
||||
<div
|
||||
className={`flex-1 min-h-0 relative ${
|
||||
isDragging ? 'bg-primary/5' : ''
|
||||
}`}
|
||||
onDragOver={handleDragOver}
|
||||
onDragLeave={handleDragLeave}
|
||||
onDrop={handleDrop}
|
||||
>
|
||||
{isDragging && (
|
||||
<div className="absolute inset-0 flex items-center justify-center bg-primary/10 border-2 border-dashed border-primary z-10">
|
||||
<div className="text-center">
|
||||
<Upload className="h-12 w-12 mx-auto text-primary mb-2" />
|
||||
<p className="text-lg font-medium text-primary">
|
||||
Drop files to upload
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{filteredAssets.length === 0 ? (
|
||||
<div className="flex flex-col items-center justify-center h-full text-center p-8">
|
||||
<Package className="h-16 w-16 text-muted-foreground/50 mb-4" />
|
||||
<h3 className="text-lg font-medium mb-2">No assets found</h3>
|
||||
<p className="text-sm text-muted-foreground mb-4">
|
||||
{searchQuery
|
||||
? `No results for "${searchQuery}"`
|
||||
: 'Upload some assets to get started'}
|
||||
</p>
|
||||
<Button onClick={() => fileInputRef.current?.click()}>
|
||||
<Upload className="h-4 w-4 mr-2" />
|
||||
Upload Assets
|
||||
</Button>
|
||||
</div>
|
||||
) : (
|
||||
<ScrollArea className="h-full">
|
||||
{viewMode === 'grid' ? (
|
||||
<div className="grid grid-cols-4 gap-4 p-4">
|
||||
{filteredAssets.map((asset) => (
|
||||
<AssetCard
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedAssetId === asset.id}
|
||||
onSelect={() => setSelectedAsset(asset.id)}
|
||||
onDoubleClick={() => onAssetSelect?.(asset)}
|
||||
onFavorite={() => toggleFavorite(asset.id)}
|
||||
onDelete={() => removeAsset(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
) : (
|
||||
<div className="divide-y">
|
||||
{filteredAssets.map((asset) => (
|
||||
<AssetRow
|
||||
key={asset.id}
|
||||
asset={asset}
|
||||
isSelected={selectedAssetId === asset.id}
|
||||
onSelect={() => setSelectedAsset(asset.id)}
|
||||
onDoubleClick={() => onAssetSelect?.(asset)}
|
||||
onFavorite={() => toggleFavorite(asset.id)}
|
||||
onDelete={() => removeAsset(asset.id)}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</ScrollArea>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Details Panel */}
|
||||
{selectedAsset && (
|
||||
<div className="w-72 border-l bg-muted/30 flex flex-col">
|
||||
<div className="p-4 border-b">
|
||||
<div className="flex items-start justify-between">
|
||||
<h3 className="font-medium truncate pr-2">{selectedAsset.name}</h3>
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-6 w-6"
|
||||
onClick={() => setSelectedAsset(null)}
|
||||
>
|
||||
<X className="h-4 w-4" />
|
||||
</Button>
|
||||
</div>
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="mt-2"
|
||||
style={{ color: ASSET_TYPE_COLORS[selectedAsset.type] }}
|
||||
>
|
||||
{selectedAsset.type}
|
||||
</Badge>
|
||||
</div>
|
||||
|
||||
{/* Preview */}
|
||||
<div className="p-4 border-b">
|
||||
<div className="aspect-square bg-muted rounded-lg flex items-center justify-center overflow-hidden">
|
||||
{selectedAsset.thumbnailUrl ? (
|
||||
<img
|
||||
src={selectedAsset.thumbnailUrl}
|
||||
alt={selectedAsset.name}
|
||||
className="w-full h-full object-contain"
|
||||
/>
|
||||
) : selectedAsset.type === 'audio' ? (
|
||||
<div className="text-center p-4">
|
||||
<Music className="h-12 w-12 mx-auto text-muted-foreground mb-2" />
|
||||
{selectedAsset.dataUrl && (
|
||||
<audio controls className="w-full mt-2">
|
||||
<source src={selectedAsset.dataUrl} />
|
||||
</audio>
|
||||
)}
|
||||
</div>
|
||||
) : (
|
||||
<div
|
||||
className="text-muted-foreground"
|
||||
style={{ color: ASSET_TYPE_COLORS[selectedAsset.type] }}
|
||||
>
|
||||
{TypeIcon[selectedAsset.type]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Metadata */}
|
||||
<ScrollArea className="flex-1">
|
||||
<div className="p-4 space-y-4">
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase mb-1">
|
||||
File Info
|
||||
</p>
|
||||
<div className="space-y-1 text-sm">
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Size</span>
|
||||
<span>{formatFileSize(selectedAsset.metadata.fileSize)}</span>
|
||||
</div>
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Format</span>
|
||||
<span className="uppercase">
|
||||
{selectedAsset.metadata.extension}
|
||||
</span>
|
||||
</div>
|
||||
{selectedAsset.metadata.width && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Dimensions</span>
|
||||
<span>
|
||||
{selectedAsset.metadata.width}×{selectedAsset.metadata.height}
|
||||
</span>
|
||||
</div>
|
||||
)}
|
||||
{selectedAsset.metadata.duration && (
|
||||
<div className="flex justify-between">
|
||||
<span className="text-muted-foreground">Duration</span>
|
||||
<span>{selectedAsset.metadata.duration.toFixed(1)}s</span>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{selectedAsset.tags.length > 0 && (
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase mb-2">
|
||||
Tags
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-1">
|
||||
{selectedAsset.tags.map((tag) => (
|
||||
<Badge key={tag} variant="outline" className="text-xs">
|
||||
{tag}
|
||||
</Badge>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
<div>
|
||||
<p className="text-xs text-muted-foreground uppercase mb-2">
|
||||
Actions
|
||||
</p>
|
||||
<div className="flex flex-wrap gap-2">
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => toggleFavorite(selectedAsset.id)}
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 mr-1 ${
|
||||
selectedAsset.favorite ? 'fill-yellow-500 text-yellow-500' : ''
|
||||
}`}
|
||||
/>
|
||||
{selectedAsset.favorite ? 'Unfavorite' : 'Favorite'}
|
||||
</Button>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// Copy asset reference
|
||||
navigator.clipboard.writeText(selectedAsset.id);
|
||||
toast.success('Asset ID copied!');
|
||||
}}
|
||||
>
|
||||
<Copy className="h-4 w-4 mr-1" />
|
||||
Copy ID
|
||||
</Button>
|
||||
<Button
|
||||
variant="destructive"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
removeAsset(selectedAsset.id);
|
||||
toast.success('Asset deleted');
|
||||
}}
|
||||
>
|
||||
<Trash2 className="h-4 w-4 mr-1" />
|
||||
Delete
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</ScrollArea>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</DialogContent>
|
||||
</Dialog>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={`group relative rounded-lg border bg-card overflow-hidden cursor-pointer transition-all ${
|
||||
isSelected ? 'ring-2 ring-primary border-primary' : 'hover:border-primary/50'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="aspect-square bg-muted flex items-center justify-center overflow-hidden">
|
||||
{asset.thumbnailUrl ? (
|
||||
<img
|
||||
src={asset.thumbnailUrl}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: ASSET_TYPE_COLORS[asset.type] }}>
|
||||
{TypeIcon[asset.type]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Info */}
|
||||
<div className="p-2">
|
||||
<p className="text-sm font-medium truncate">{asset.name}</p>
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{formatFileSize(asset.metadata.fileSize)}
|
||||
</p>
|
||||
</div>
|
||||
|
||||
{/* Favorite Badge */}
|
||||
{asset.favorite && (
|
||||
<Star className="absolute top-2 right-2 h-4 w-4 fill-yellow-500 text-yellow-500" />
|
||||
)}
|
||||
|
||||
{/* Hover Actions */}
|
||||
<div className="absolute top-2 left-2 opacity-0 group-hover:opacity-100 transition-opacity">
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="secondary" size="icon" className="h-6 w-6">
|
||||
<MoreVertical className="h-3 w-3" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent>
|
||||
<DropdownMenuItem onClick={onFavorite}>
|
||||
<Star className="h-4 w-4 mr-2" />
|
||||
{asset.favorite ? 'Unfavorite' : 'Favorite'}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
|
||||
{/* Type Badge */}
|
||||
<Badge
|
||||
variant="secondary"
|
||||
className="absolute bottom-2 right-2 text-xs"
|
||||
style={{ color: ASSET_TYPE_COLORS[asset.type] }}
|
||||
>
|
||||
{asset.type}
|
||||
</Badge>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// 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 (
|
||||
<div
|
||||
className={`flex items-center gap-4 px-4 py-3 cursor-pointer transition-colors ${
|
||||
isSelected ? 'bg-primary/10' : 'hover:bg-muted/50'
|
||||
}`}
|
||||
onClick={onSelect}
|
||||
onDoubleClick={onDoubleClick}
|
||||
>
|
||||
{/* Thumbnail */}
|
||||
<div className="w-10 h-10 rounded bg-muted flex items-center justify-center overflow-hidden flex-shrink-0">
|
||||
{asset.thumbnailUrl ? (
|
||||
<img
|
||||
src={asset.thumbnailUrl}
|
||||
alt={asset.name}
|
||||
className="w-full h-full object-cover"
|
||||
/>
|
||||
) : (
|
||||
<div style={{ color: ASSET_TYPE_COLORS[asset.type] }}>
|
||||
{TypeIcon[asset.type]}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Name */}
|
||||
<div className="flex-1 min-w-0">
|
||||
<p className="text-sm font-medium truncate">{asset.name}</p>
|
||||
<p className="text-xs text-muted-foreground capitalize">{asset.type}</p>
|
||||
</div>
|
||||
|
||||
{/* Size */}
|
||||
<span className="text-sm text-muted-foreground w-20 text-right">
|
||||
{formatFileSize(asset.metadata.fileSize)}
|
||||
</span>
|
||||
|
||||
{/* Favorite */}
|
||||
<Button
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
className="h-8 w-8"
|
||||
onClick={(e) => {
|
||||
e.stopPropagation();
|
||||
onFavorite();
|
||||
}}
|
||||
>
|
||||
<Star
|
||||
className={`h-4 w-4 ${
|
||||
asset.favorite ? 'fill-yellow-500 text-yellow-500' : ''
|
||||
}`}
|
||||
/>
|
||||
</Button>
|
||||
|
||||
{/* Actions */}
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button variant="ghost" size="icon" className="h-8 w-8">
|
||||
<MoreVertical className="h-4 w-4" />
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="end">
|
||||
<DropdownMenuItem>
|
||||
<Download className="h-4 w-4 mr-2" />
|
||||
Download
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem>
|
||||
<Copy className="h-4 w-4 mr-2" />
|
||||
Copy ID
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuSeparator />
|
||||
<DropdownMenuItem className="text-destructive" onClick={onDelete}>
|
||||
<Trash2 className="h-4 w-4 mr-2" />
|
||||
Delete
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
222
src/lib/assets/types.ts
Normal file
222
src/lib/assets/types.ts
Normal file
|
|
@ -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<string, AssetType> = {
|
||||
// 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<AssetType, string> = {
|
||||
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<AssetType, string> = {
|
||||
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<AssetCategory, string> = {
|
||||
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)}`;
|
||||
}
|
||||
404
src/stores/asset-store.ts
Normal file
404
src/stores/asset-store.ts
Normal file
|
|
@ -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<Asset>;
|
||||
removeAsset: (id: string) => void;
|
||||
updateAsset: (id: string, updates: Partial<Asset>) => void;
|
||||
getAsset: (id: string) => Asset | undefined;
|
||||
|
||||
addFolder: (name: string, parentId?: string) => AssetFolder;
|
||||
removeFolder: (id: string) => void;
|
||||
updateFolder: (id: string, updates: Partial<AssetFolder>) => void;
|
||||
|
||||
setSelectedAsset: (id: string | null) => void;
|
||||
setSelectedFolder: (id: string | null) => void;
|
||||
setViewMode: (mode: 'grid' | 'list') => void;
|
||||
setFilter: (filter: Partial<AssetFilter>) => 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<string> {
|
||||
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<string> {
|
||||
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<AssetState>()(
|
||||
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<Asset> => {
|
||||
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<Asset>) => {
|
||||
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<AssetFolder>) => {
|
||||
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<AssetFilter>) => {
|
||||
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,
|
||||
}),
|
||||
}
|
||||
)
|
||||
);
|
||||
Loading…
Reference in a new issue