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"; import { TauriService } from "../../services/TauriService"; export default function PckEditorView() { const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); const [pck, setPck] = useState(null); const [openedPath, setOpenedPath] = 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: { name: string; data: Uint8Array }; } | null>(null); const [isChangingType, setIsChangingType] = useState(false); const containerRef = 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): TreeNode[] => { 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], { 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); }; type TreeNode = { name: string; path: string; isFolder: boolean; children: TreeNode[]; asset?: PCKAsset }; const renderTree = (nodes: TreeNode[], depth = 0) => { return nodes.map((node) => { const isExpanded = expandedFolders.has(node.path) || !!searchTerm; const isSelected = node.asset ? selectedAssetId === node.asset.id : false; return (
{ if (node.isFolder) { toggleFolder(node.path); } else if (node.asset) { 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 && ( {(node.asset.size / 1024).toFixed(1)} KB )}
{node.isFolder && isExpanded && (
{renderTree(node.children, depth + 1)}
)}
); }); }; const handleFileLoad = async () => { try { const path = await TauriService.pickFile("Open PCK", ["pck"]); if (!path) return; playPressSound(); const bytes = await TauriService.readBinaryFile(path); const parsed = await PckService.readPCK(bytes.buffer as ArrayBuffer); setPck(parsed); setOpenedPath(path); setSelectedAssetId(parsed.files[0]?.id || null); setExpandedFolders(new Set()); } catch (err: unknown) { if (err !== "CANCELED") { console.error("Failed to parse PCK", err); showNotification("Failed to parse PCK", "error"); } } }; const handleNewPCK = () => { playPressSound(); const newPck: PCKFile = { version: 3, endianness: "little", xmlSupport: false, properties: ["ANIM", "BOX"], files: [], }; setPck(newPck); setOpenedPath(null); 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 = async (asset: PCKAsset) => { try { const fileName = asset.path.split("/").pop() || "asset"; const path = await TauriService.saveFileDialog( "Export Asset", fileName, [], ); if (!path) return; playPressSound(); await TauriService.writeBinaryFile(path, asset.data); showNotification(`Exported: ${fileName}`); } catch (err: unknown) { if (err !== "CANCELED") showNotification("Export failed", "error"); } }; 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: { name: file.name, data } }); e.target.value = ""; }; const confirmAddAsset = (type: PCKAssetType) => { if (!pck || !showTypeModal) return; const { file } = showTypeModal; const newAsset: PCKAsset = { id: Math.random().toString(36).substring(2, 9), path: file.name, type, size: file.data.length, data: file.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 handleRenameAsset = (id: string, newPath: string) => { if (!pck) return; playPressSound(); const newFiles = pck.files.map((f) => { if (f.id === id) { return { ...f, path: newPath }; } return f; }); setPck({ ...pck, files: newFiles }); showNotification("Asset Renamed"); }; const handleExportAll = async () => { if (!pck || pck.files.length === 0) return; try { const baseFolder = await TauriService.pickFolder(); if (!baseFolder) return; playPressSound(); showNotification("Exporting all assets..."); for (const asset of pck.files) { const parts = asset.path.split("/"); const fileName = parts.join("_"); await TauriService.writeBinaryFile( `${baseFolder}/${fileName}`, asset.data, ); } showNotification("All Assets Exported"); } catch (err: unknown) { if (err !== "CANCELED") showNotification("Export failed", "error"); } }; const handleSavePCK = async () => { if (!pck) return; playPressSound(); const buffer = PckService.serializePCK(pck); const data = new Uint8Array(buffer); try { let targetPath = openedPath; if (!targetPath) { targetPath = await TauriService.saveFileDialog( "Save PCK", pck.files.length > 0 ? "new.pck" : "empty.pck", ["pck"], ); } if (targetPath) { await TauriService.writeBinaryFile(targetPath, data); setOpenedPath(targetPath); showNotification("PCK Saved Successfully"); } } catch (err: unknown) { if (err !== "CANCELED") showNotification("Export failed", "error"); } }; 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"; } }; const [isRenamingAsset, setIsRenamingAsset] = useState(null); return (

PCK Editor

{pck && (
Endianness:
XML Support:
Version: {pck.version}
)} {!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: "01: Static arms", flag: 0x1 }, { label: "01: Zombie arms", flag: 0x2 }, { label: "01: Static legs", flag: 0x4 }, { label: "01: Bad Santa", flag: 0x8 }, { label: "02: Unknown", flag: 0x10 }, { label: "02: Synced legs", flag: 0x20 }, { label: "02: Synced arms", flag: 0x40 }, { label: "02: Statue of Lib", flag: 0x80 }, { label: "03: No Armor", flag: 0x100 }, { label: "03: No Bobbing", flag: 0x200 }, { label: "03: No Head", flag: 0x400 }, { label: "03: No R. Arm", flag: 0x800 }, { label: "04: No L. Arm", flag: 0x1000 }, { label: "04: No Body", flag: 0x2000 }, { label: "04: No R. Leg", flag: 0x4000 }, { label: "04: No L. Leg", flag: 0x8000 }, { label: "05: No Head Overlay", flag: 0x10000, }, { label: "05: Back Crouch", flag: 0x20000 }, { label: "05: Modern Skin", flag: 0x40000 }, { label: "05: Slim Skin", flag: 0x80000 }, { label: "06: No L. Sleeve", flag: 0x100000 }, { label: "06: No R. Sleeve", flag: 0x200000 }, { label: "06: No L. Pant", flag: 0x400000 }, { label: "06: No R. Pant", flag: 0x800000 }, { label: "07: No Jacket", flag: 0x1000000 }, { label: "07: Rend Head Arm", flag: 0x2000000, }, { label: "07: Rend R.Arm Arm", flag: 0x4000000, }, { label: "07: Rend L.Arm Arm", flag: 0x8000000, }, { label: "08: Rend Body Arm", flag: 0x10000000, }, { label: "08: Rend R.Leg Arm", flag: 0x20000000, }, { label: "08: Rend L.Leg Arm", flag: 0x40000000, }, { label: "08: Dinnerbone", flag: 0x80000000 }, ].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) => ( ))}
)}
{isRenamingAsset && ( f.id === isRenamingAsset)?.path || "" } onClose={() => setIsRenamingAsset(null)} onConfirm={(newPath) => { handleRenameAsset(isRenamingAsset, newPath); setIsRenamingAsset(null); }} /> )} ); } function RenameAssetModal({ initialPath, onClose, onConfirm, }: { initialPath: string; onClose: () => void; onConfirm: (path: string) => void; }) { const [path, setPath] = useState(initialPath); return (

Rename Asset

setPath(e.target.value)} className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors" autoFocus />
); }