import { useState, useEffect, useRef, useMemo } from "react"; import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { PckService } from "../../services/PckService"; import { PCKFile, PCKAsset, PCKAssetType } from "../../types/pck"; import SkinPreview3D from "../common/SkinPreview3D"; export default function PckEditorView() { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); const [pck, setPck] = useState(null); const [selectedAssetId, setSelectedAssetId] = useState(null); const [searchTerm, setSearchTerm] = useState(""); const [isEditingProperty, setIsEditingProperty] = useState<{ idx: number, key: string, val: string } | null>(null); const [expandedFolders, setExpandedFolders] = useState>(new Set()); const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); const [showTypeModal, setShowTypeModal] = useState<{ file: File, data: Uint8Array } | null>(null); const [isChangingType, setIsChangingType] = useState(false); const containerRef = useRef(null); const fileInputRef = useRef(null); const replaceInputRef = useRef(null); const addAssetInputRef = useRef(null); const treeData = useMemo(() => { if (!pck) return []; interface TempNode { name: string; path: string; isFolder: boolean; asset?: PCKAsset; children: Record; } const root: Record = {}; pck.files.forEach(asset => { if (searchTerm && !asset.path.toLowerCase().includes(searchTerm.toLowerCase())) return; const parts = asset.path.split("/"); let currentLevel = root; let currentPath = ""; parts.forEach((part, index) => { currentPath = currentPath ? `${currentPath}/${part}` : part; const isLast = index === parts.length - 1; if (!currentLevel[part]) { currentLevel[part] = { name: part, path: currentPath, isFolder: !isLast, asset: isLast ? asset : undefined, children: {} }; } currentLevel = currentLevel[part].children; }); }); const convert = (nodes: Record): any[] => { return Object.values(nodes) .sort((a, b) => { if (a.isFolder && !b.isFolder) return -1; if (!a.isFolder && b.isFolder) return 1; return a.name.localeCompare(b.name); }) .map(node => ({ ...node, children: convert(node.children) })); }; return convert(root); }, [pck, searchTerm]); const selectedAsset = useMemo(() => { return pck?.files.find(f => f.id === selectedAssetId) || null; }, [pck, selectedAssetId]); const [assetPreview, setAssetPreview] = useState<{ id: string, url: string } | null>(null); useEffect(() => { if (!selectedAsset || ![PCKAssetType.SKIN, PCKAssetType.CAPE, PCKAssetType.TEXTURE, PCKAssetType.SKIN_DATA].includes(selectedAsset.type)) { setAssetPreview(null); return; } const blob = new Blob([selectedAsset.data as any], { type: "image/png" }); const url = URL.createObjectURL(blob); setAssetPreview({ id: selectedAsset.id, url }); return () => { URL.revokeObjectURL(url); }; }, [selectedAsset]); const assetPreviewUrl = (assetPreview && selectedAsset && assetPreview.id === selectedAsset.id) ? assetPreview.url : null; const toggleFolder = (path: string) => { const next = new Set(expandedFolders); if (next.has(path)) next.delete(path); else next.add(path); setExpandedFolders(next); }; const renderTree = (nodes: any[], depth = 0) => { return nodes.map(node => { const isExpanded = expandedFolders.has(node.path) || !!searchTerm; const isSelected = selectedAssetId === node.asset?.id; return (
{ if (node.isFolder) { toggleFolder(node.path); } else { playPressSound(); setSelectedAssetId(node.asset.id); } }} style={{ paddingLeft: `${depth * 16 + 12}px` }} className={`flex items-center gap-2 p-2 cursor-pointer transition-all border-l-2 ${isSelected ? "bg-[#FFFF55]/10 border-[#FFFF55] text-[#FFFF55]" : "border-transparent hover:bg-white/5 text-white" } ${node.isFolder ? "font-bold" : ""}`} > {node.isFolder ? ( ) : (
)} {node.name} {!node.isFolder && ( {(node.asset.size / 1024).toFixed(1)} KB )}
{node.isFolder && isExpanded && (
{renderTree(node.children, depth + 1)}
)}
); }); }; const handleFileLoad = async (e: React.ChangeEvent) => { const file = e.target.files?.[0]; if (!file) return; playPressSound(); const buffer = await file.arrayBuffer(); try { const parsed = await PckService.readPCK(buffer); setPck(parsed); setSelectedAssetId(parsed.files[0]?.id || null); setExpandedFolders(new Set()); } catch (err) { console.error("Failed to parse PCK", err); showNotification("Failed to parse PCK", "error"); } }; const handleNewPCK = () => { playPressSound(); const newPck: PCKFile = { version: 2, endianness: "little", xmlSupport: false, properties: ["ANIM", "BOX"], files: [] }; setPck(newPck); setSelectedAssetId(null); setExpandedFolders(new Set()); showNotification("New PCK Created"); }; const showNotification = (message: string, type: "success" | "error" = "success") => { setNotification({ message, type }); setTimeout(() => setNotification(null), 3000); }; const handleExportAsset = (asset: PCKAsset) => { playPressSound(); const blob = new Blob([asset.data as any]); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = asset.path.split("/").pop() || "asset"; a.click(); URL.revokeObjectURL(url); showNotification(`Exported: ${asset.path.split("/").pop()}`); }; const handleDeleteAsset = (id: string) => { if (!pck) return; playBackSound(); const newFiles = pck.files.filter(f => f.id !== id); const assetPath = pck.files.find(f => f.id === id)?.path; setPck({ ...pck, files: newFiles }); if (selectedAssetId === id) setSelectedAssetId(newFiles[0]?.id || null); showNotification(`Deleted: ${assetPath?.split("/").pop()}`); }; const handleReplaceAsset = async (e: React.ChangeEvent) => { if (!pck || !selectedAssetId) return; const file = e.target.files?.[0]; if (!file) return; playPressSound(); const buffer = await file.arrayBuffer(); const data = new Uint8Array(buffer); const newFiles = pck.files.map(f => f.id === selectedAssetId ? { ...f, data, size: data.length } : f); setPck({ ...pck, files: newFiles }); e.target.value = ""; showNotification("Asset Replaced"); }; const handleAddAsset = async (e: React.ChangeEvent) => { if (!pck) return; const file = e.target.files?.[0]; if (!file) return; playPressSound(); const buffer = await file.arrayBuffer(); const data = new Uint8Array(buffer); setShowTypeModal({ file, data }); e.target.value = ""; }; const confirmAddAsset = (type: PCKAssetType) => { if (!pck || !showTypeModal) return; const { file, data } = showTypeModal; const newAsset: PCKAsset = { id: Math.random().toString(36).substr(2, 9), path: file.name, type, size: data.length, data, properties: [] }; if (type === PCKAssetType.SKIN || type === PCKAssetType.CAPE) { newAsset.properties.push({ key: "ANIM", value: "0" }); } setPck({ ...pck, files: [...pck.files, newAsset] }); setSelectedAssetId(newAsset.id); setShowTypeModal(null); showNotification("Asset Added"); }; const handlePropertyEdit = (idx: number, newVal: string, isKey = false) => { if (!pck || !selectedAssetId) return; const newFiles = pck.files.map(f => { if (f.id === selectedAssetId) { const newProps = [...f.properties]; if (isKey) { newProps[idx] = { ...newProps[idx], key: newVal }; if (!pck.properties.includes(newVal)) { pck.properties.push(newVal); } } else { newProps[idx] = { ...newProps[idx], value: newVal }; } return { ...f, properties: newProps }; } return f; }); setPck({ ...pck, files: newFiles }); }; const handleAddProperty = () => { if (!pck || !selectedAssetId) return; playPressSound(); const newFiles = pck.files.map(f => { if (f.id === selectedAssetId) { return { ...f, properties: [...f.properties, { key: "NEW_PROPERTY", value: "0" }] }; } return f; }); setPck({ ...pck, files: newFiles }); }; const handleRemoveProperty = (idx: number) => { if (!pck || !selectedAssetId) return; playBackSound(); const newFiles = pck.files.map(f => { if (f.id === selectedAssetId) { const newProps = [...f.properties]; newProps.splice(idx, 1); return { ...f, properties: newProps }; } return f; }); setPck({ ...pck, files: newFiles }); }; const handleTypeChange = (newType: PCKAssetType) => { if (!pck || !selectedAssetId) return; playPressSound(); const newFiles = pck.files.map(f => { if (f.id === selectedAssetId) { return { ...f, type: newType }; } return f; }); setPck({ ...pck, files: newFiles }); }; const handleMoveAsset = (direction: 'up' | 'down') => { if (!pck || !selectedAssetId) return; const idx = pck.files.findIndex(f => f.id === selectedAssetId); if (idx === -1) return; const newIdx = direction === 'up' ? idx - 1 : idx + 1; if (newIdx < 0 || newIdx >= pck.files.length) return; playPressSound(); const newFiles = [...pck.files]; [newFiles[idx], newFiles[newIdx]] = [newFiles[newIdx], newFiles[idx]]; setPck({ ...pck, files: newFiles }); }; const handleSavePCK = () => { if (!pck) return; playPressSound(); const buffer = PckService.serializePCK(pck); const blob = new Blob([buffer]); const url = URL.createObjectURL(blob); const a = document.createElement("a"); a.href = url; a.download = pck.files.length > 0 ? "new.pck" : "empty.pck"; a.click(); URL.revokeObjectURL(url); showNotification("PCK Saved Successfully"); }; useEffect(() => { const handleKeyDown = (e: KeyboardEvent) => { if (document.activeElement?.tagName === "INPUT") return; if (e.key === "Escape" || e.key === "Backspace") { if (isEditingProperty) { setIsEditingProperty(null); return; } playBackSound(); setActiveView("devtools"); return; } }; window.addEventListener("keydown", handleKeyDown); return () => window.removeEventListener("keydown", handleKeyDown); }, [playBackSound, setActiveView, isEditingProperty]); const getTypeColor = (type: PCKAssetType) => { switch (type) { case PCKAssetType.SKIN: return "#FFFF55"; case PCKAssetType.SKIN_DATA: return "#FFFF55"; case PCKAssetType.CAPE: return "#AA00AA"; case PCKAssetType.TEXTURE: return "#55FFFF"; case PCKAssetType.AUDIO_DATA: return "#55FF55"; case PCKAssetType.UI_DATA: return "#FFAA00"; case PCKAssetType.LOCALISATION: return "#FF55FF"; case PCKAssetType.MODELS: return "#5555FF"; default: return "#AAAAAA"; } }; return (

PCK Editor

{!pck ? (

Open a PCK file to begin editing

) : (
setSearchTerm(e.target.value)} className="flex-1 bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors" />
{renderTree(treeData)}
{!selectedAsset ? (
Select an asset to view details
) : (

{selectedAsset.path.split("/").pop()}

{isChangingType && ( <> setIsChangingType(false)} /> {Object.keys(PCKAssetType) .filter(k => isNaN(Number(k))) .map((typeName) => { const typeVal = PCKAssetType[typeName as keyof typeof PCKAssetType]; const isActive = selectedAsset.type === typeVal; return ( ); })} )}
{assetPreviewUrl && (
{(selectedAsset.type === PCKAssetType.SKIN || selectedAsset.type === PCKAssetType.CAPE || selectedAsset.type === PCKAssetType.SKIN_DATA) ? ( ) : ( )}
{selectedAsset.type === PCKAssetType.SKIN ? "3D Skin View" : selectedAsset.type === PCKAssetType.CAPE ? "3D Cape View" : "Texture Preview"}
)}
Metadata Properties
{selectedAsset.properties.map((prop, idx) => (
handlePropertyEdit(idx, e.target.value, true)} className="bg-transparent text-white/40 text-[10px] outline-none hover:text-white/60 focus:text-[#FFFF55] w-2/3" />
handlePropertyEdit(idx, e.target.value)} className="w-full bg-black/40 p-2 text-white border border-[#373737] text-sm focus:border-[#FFFF55] outline-none transition-colors" />
{prop.key === "ANIM" && (
{[ { label: "Slim Format", flag: 1 << 19 }, { label: "Modern Wide", flag: 1 << 18 }, { label: "Hide Hat", flag: 1 << 16 }, { label: "Hide Head", flag: 1 << 10 }, { label: "Hide Body", flag: 1 << 13 }, { label: "Hide Right Arm", flag: 1 << 11 }, { label: "Hide Left Arm", flag: 1 << 12 }, { label: "Hide Right Leg", flag: 1 << 14 }, { label: "Hide Left Leg", flag: 1 << 15 }, { label: "Hide Jacket", flag: 1 << 24 }, { label: "Hide Right Sleeve", flag: 1 << 21 }, { label: "Hide Left Sleeve", flag: 1 << 20 }, { label: "Hide Right Pant", flag: 1 << 23 }, { label: "Hide Left Pant", flag: 1 << 22 }, { label: "Zombie Arms", flag: 1 << 1 }, { label: "Upside Down", flag: 1 << 31 }, ].map((item) => { const currentVal = parseInt(prop.value) || 0; const isChecked = (currentVal & item.flag) !== 0; return (
))} {selectedAsset.properties.length === 0 && (
No metadata properties
)}
)}
)} {notification && ( {notification.message} )} {showTypeModal && (
setShowTypeModal(null)} />

Select Asset Type

{Object.keys(PCKAssetType) .filter(k => isNaN(Number(k))) .map((typeName) => ( ))}
)}
); }