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:
Claude 2026-01-23 22:58:30 +00:00
parent 6aff5ac183
commit 5feb186c05
No known key found for this signature in database
5 changed files with 1436 additions and 2 deletions

View file

@ -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>

View file

@ -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>

View 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
View 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
View 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,
}),
}
)
);