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 TranslationPanel = lazy(() => import('./components/TranslationPanel').then(m => ({ default: m.TranslationPanel })));
|
||||||
const AvatarToolkit = lazy(() => import('./components/AvatarToolkit'));
|
const AvatarToolkit = lazy(() => import('./components/AvatarToolkit'));
|
||||||
const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas'));
|
const VisualScriptingCanvas = lazy(() => import('./components/visual-scripting/VisualScriptingCanvas'));
|
||||||
|
const AssetLibrary = lazy(() => import('./components/assets/AssetLibrary'));
|
||||||
|
|
||||||
function App() {
|
function App() {
|
||||||
const [currentCode, setCurrentCode] = useState('');
|
const [currentCode, setCurrentCode] = useState('');
|
||||||
|
|
@ -46,6 +47,7 @@ function App() {
|
||||||
const [showTranslation, setShowTranslation] = useState(false);
|
const [showTranslation, setShowTranslation] = useState(false);
|
||||||
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
|
const [showAvatarToolkit, setShowAvatarToolkit] = useState(false);
|
||||||
const [showVisualScripting, setShowVisualScripting] = useState(false);
|
const [showVisualScripting, setShowVisualScripting] = useState(false);
|
||||||
|
const [showAssetLibrary, setShowAssetLibrary] = useState(false);
|
||||||
const [code, setCode] = useState('');
|
const [code, setCode] = useState('');
|
||||||
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
|
const [currentPlatform, setCurrentPlatform] = useState<PlatformId>('roblox');
|
||||||
const isMobile = useIsMobile();
|
const isMobile = useIsMobile();
|
||||||
|
|
@ -483,6 +485,7 @@ end)`,
|
||||||
onNewProjectClick={() => setShowNewProject(true)}
|
onNewProjectClick={() => setShowNewProject(true)}
|
||||||
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
onAvatarToolkitClick={() => setShowAvatarToolkit(true)}
|
||||||
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
onVisualScriptingClick={() => setShowVisualScripting(true)}
|
||||||
|
onAssetLibraryClick={() => setShowAssetLibrary(true)}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|
@ -628,6 +631,14 @@ end)`,
|
||||||
</Dialog>
|
</Dialog>
|
||||||
)}
|
)}
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
<Suspense fallback={null}>
|
||||||
|
{showAssetLibrary && (
|
||||||
|
<AssetLibrary
|
||||||
|
isOpen={showAssetLibrary}
|
||||||
|
onClose={() => setShowAssetLibrary(false)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
</Suspense>
|
||||||
<Suspense fallback={null}>
|
<Suspense fallback={null}>
|
||||||
<WelcomeDialog />
|
<WelcomeDialog />
|
||||||
</Suspense>
|
</Suspense>
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,7 @@ import {
|
||||||
DropdownMenuSeparator,
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} 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 { toast } from 'sonner';
|
||||||
import { useState, useEffect, useCallback, memo } from 'react';
|
import { useState, useEffect, useCallback, memo } from 'react';
|
||||||
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
import { Dialog, DialogContent, DialogDescription, DialogHeader, DialogTitle } from './ui/dialog';
|
||||||
|
|
@ -26,9 +26,10 @@ interface ToolbarProps {
|
||||||
onTranslateClick?: () => void;
|
onTranslateClick?: () => void;
|
||||||
onAvatarToolkitClick?: () => void;
|
onAvatarToolkitClick?: () => void;
|
||||||
onVisualScriptingClick?: () => 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 [showInfo, setShowInfo] = useState(false);
|
||||||
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
|
const [user, setUser] = useState<{ login: string; avatarUrl: string; email: string } | null>(null);
|
||||||
|
|
||||||
|
|
@ -138,6 +139,25 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
||||||
</Tooltip>
|
</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" />
|
<div className="h-6 w-px bg-border mx-1" />
|
||||||
|
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
|
|
@ -264,6 +284,12 @@ export function Toolbar({ code, onTemplatesClick, onPreviewClick, onNewProjectCl
|
||||||
<span>Visual Scripting</span>
|
<span>Visual Scripting</span>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
)}
|
)}
|
||||||
|
{onAssetLibraryClick && (
|
||||||
|
<DropdownMenuItem onClick={onAssetLibraryClick}>
|
||||||
|
<Package className="mr-2" size={16} />
|
||||||
|
<span>Asset Library</span>
|
||||||
|
</DropdownMenuItem>
|
||||||
|
)}
|
||||||
<DropdownMenuItem onClick={handleCopy}>
|
<DropdownMenuItem onClick={handleCopy}>
|
||||||
<Copy className="mr-2" size={16} />
|
<Copy className="mr-2" size={16} />
|
||||||
<span>Copy Code</span>
|
<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