import { useState, useRef, useMemo } from "react"; import { useTranslation } from "react-i18next"; import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; import { ArcService } from "../../services/ArcService"; import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc"; import { TauriService } from "../../services/TauriService"; export const ArcEditorView: React.FC = () => { const { t } = useTranslation(); const { setActiveView } = useUI(); const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); const [arc, setArc] = useState(null); const [openedPath, setOpenedPath] = useState(null); const [loc, setLoc] = useState(null); const [activeTab, setActiveTab] = useState<"arc" | "loc">("arc"); const [searchTerm, setSearchTerm] = useState(""); const [selectedEntryIdx, setSelectedEntryIdx] = useState(null); const [selectedLocLangIdx, setSelectedLocLangIdx] = useState(0); const [notification, setNotification] = useState<{ message: string, type: "success" | "error" } | null>(null); const [isAddModalOpen, setIsAddModalOpen] = useState(false); const [isReplaceModalOpen, setIsReplaceModalOpen] = useState(false); const [isRenameModalOpen, setIsRenameModalOpen] = useState(false); const [isLocEditModalOpen, setIsLocEditModalOpen] = useState<{ langIdx: number, strIdx: number, isNew: boolean } | null>(null); const injectInputRef = useRef(null); const replaceInputRef = useRef(null); const filteredEntries = useMemo(() => { if (!arc) return []; return arc.entries.map((e, i) => ({ ...e, originalIdx: i })) .filter(e => e.filename.toLowerCase().includes(searchTerm.toLowerCase())); }, [arc, searchTerm]); const currentLocLang = useMemo(() => { if (!loc) return null; return loc.languages[selectedLocLangIdx] || null; }, [loc, selectedLocLangIdx]); const filteredLocStrings = useMemo(() => { if (!currentLocLang) return []; return currentLocLang.strings.map((s, i) => ({ ...s, originalIdx: i })) .filter(s => (s.key?.toLowerCase().includes(searchTerm.toLowerCase()) || s.value.toLowerCase().includes(searchTerm.toLowerCase()))); }, [currentLocLang, searchTerm]); const showNotification = (message: string, type: "success" | "error" = "success") => { setNotification({ message, type }); setTimeout(() => setNotification(null), 3000); }; const handleFileLoad = async () => { try { const path = await TauriService.pickFile(t("arcEditor.openArc"), ["arc"]); if (!path) return; playPressSound(); const bytes = await TauriService.readBinaryFile(path); const parsed = await ArcService.readARC(bytes.buffer as ArrayBuffer); parsed.name = path.split(/[\/\\]/).pop() || "archive.arc"; setArc(parsed); setOpenedPath(path); const locEntry = parsed.entries.find(entry => entry.filename.toLowerCase() === "languages.loc"); if (locEntry) { try { const parsedLoc = ArcService.parseLOC(locEntry.data); setLoc(parsedLoc); } catch (err) { console.warn("Could not parse languages.loc", err); setLoc(null); } } else { setLoc(null); } setSelectedEntryIdx(null); showNotification(t("arcEditor.loaded", { name: parsed.name })); } catch (err: unknown) { if (err !== "CANCELED") { console.error("Failed to parse ARC", err); showNotification(t("arcEditor.failedToParse"), "error"); } } }; const handleSaveArc = async () => { if (!arc) return; playPressSound(); const buffer = ArcService.serializeARC(arc); const data = new Uint8Array(buffer); try { let targetPath = openedPath; if (!targetPath) { targetPath = await TauriService.saveFileDialog(t("arcEditor.saveArcDialog"), arc.name || "archive.arc", ["arc"]); } if (targetPath) { await TauriService.writeBinaryFile(targetPath, data); setOpenedPath(targetPath); showNotification(t("arcEditor.arcSaved")); } } catch (err: unknown) { if (err !== "CANCELED") showNotification(t("arcEditor.saveFailed"), "error"); } }; const handleExtractEntry = async (entry: ArcEntry) => { try { const fileName = entry.filename.split("/").pop() || "asset"; const path = await TauriService.saveFileDialog(t("arcEditor.exportAssetDialog"), fileName, []); if (!path) return; playPressSound(); await TauriService.writeBinaryFile(path, entry.data); showNotification(t("arcEditor.extracted", { name: entry.filename })); } catch (err: unknown) { if (err !== "CANCELED") showNotification(t("arcEditor.extractionFailed"), "error"); } }; const handleDeleteEntry = (idx: number) => { if (!arc) return; playBackSound(); const name = arc.entries[idx].filename; const newEntries = [...arc.entries]; newEntries.splice(idx, 1); setArc({ ...arc, entries: newEntries }); setSelectedEntryIdx(null); showNotification(t("arcEditor.deleted", { name })); }; const handleSaveLocToArc = () => { if (!loc || !arc) return; playPressSound(); const data = ArcService.serializeLOC(loc); const locIdx = arc.entries.findIndex(e => e.filename.toLowerCase() === "languages.loc"); const newEntries = [...arc.entries]; if (locIdx >= 0) { newEntries[locIdx] = { ...newEntries[locIdx], data, size: data.length }; showNotification(t("arcEditor.locUpdatedInArchive")); } else { newEntries.push({ filename: "languages.loc", ptr: 0, size: data.length, isCompressed: false, data }); showNotification(t("arcEditor.locAddedToArchive")); } setArc({ ...arc, entries: newEntries }); }; const handleAddEntry = async (e: React.ChangeEvent) => { if (!arc) return; const file = e.target.files?.[0]; if (!file) return; playPressSound(); const buffer = await file.arrayBuffer(); const data = new Uint8Array(buffer); const newEntry: ArcEntry = { filename: file.name, ptr: 0, size: data.length, isCompressed: false, data }; setArc({ ...arc, entries: [...arc.entries, newEntry] }); e.target.value = ""; showNotification(t("arcEditor.entryAdded")); setIsAddModalOpen(false); }; const handleReplaceEntry = async (e: React.ChangeEvent) => { if (!arc || selectedEntryIdx === null) return; const file = e.target.files?.[0]; if (!file) return; playPressSound(); const buffer = await file.arrayBuffer(); const data = new Uint8Array(buffer); const newEntries = [...arc.entries]; newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], data, size: data.length }; setArc({ ...arc, entries: newEntries }); e.target.value = ""; showNotification(t("arcEditor.entryReplaced")); setIsReplaceModalOpen(false); }; const handleRenameEntry = (newPath: string, isCompressed: boolean) => { if (!arc || selectedEntryIdx === null) return; playPressSound(); const newEntries = [...arc.entries]; newEntries[selectedEntryIdx] = { ...newEntries[selectedEntryIdx], filename: newPath, isCompressed }; setArc({ ...arc, entries: newEntries }); setIsRenameModalOpen(false); showNotification(t("arcEditor.entryRenamed")); }; const handleLocStringEdit = (langIdx: number, strIdx: number, isNew: boolean, key: string, value: string) => { if (!loc) return; playPressSound(); const newLoc = { ...loc }; const lang = newLoc.languages[langIdx]; if (isNew) { lang.strings.push(lang.isStatic ? { value } : { key, value }); } else { if (!lang.isStatic) lang.strings[strIdx].key = key; lang.strings[strIdx].value = value; } setLoc(newLoc); setIsLocEditModalOpen(null); showNotification(isNew ? t("arcEditor.stringAdded") : t("arcEditor.stringUpdated")); }; const handleLocStringDelete = (langIdx: number, strIdx: number) => { if (!loc) return; playBackSound(); const newLoc = { ...loc }; newLoc.languages[langIdx].strings.splice(strIdx, 1); setLoc(newLoc); showNotification(t("arcEditor.stringDeleted")); }; const treeData = useMemo(() => { const root: Record = { name: "", children: {}, isFolder: true }; filteredEntries.forEach((entry) => { const parts = entry.filename.split(/\//); let current = root; parts.forEach((part, i) => { const isLast = i === parts.length - 1; if (!current.children[part]) { current.children[part] = isLast ? { ...entry, isFolder: false } : { name: part, children: {}, isFolder: true }; } current = current.children[part]; }); }); return root; }, [filteredEntries]); const [expandedNodes, setExpandedNodes] = useState>(new Set([""])); const toggleNode = (path: string) => { const newExpanded = new Set(expandedNodes); if (newExpanded.has(path)) newExpanded.delete(path); else newExpanded.add(path); setExpandedNodes(newExpanded); }; const renderTree = (node: Record, path: string = "") => { const nodePath = path ? `${path}/${node.name}` : node.name; const isExpanded = expandedNodes.has(nodePath); if (!node.isFolder) { const isSelected = selectedEntryIdx === node.originalIdx; return (
{ playPressSound(); setSelectedEntryIdx(node.originalIdx); }} className={`group flex items-center gap-2 px-2 py-1 cursor-pointer ${isSelected ? "bg-[#FFFF55]/20 text-[#FFFF55]" : "text-white/80"}`} > {node.filename.split("/").pop()} {node.isCompressed && ( {t("arcEditor.zlib")} )}
); } return (
{ playPressSound(); toggleNode(nodePath); }} className="flex items-center gap-2 px-2 py-1.5 cursor-pointer text-white/50 group" > ▶ {node.name}
{isExpanded && ( {Object.values(node.children as Record).map((child: Record) => renderTree(child, nodePath))} )}
); }; const handleExportAll = async () => { if (!arc || arc.entries.length === 0) return; try { const baseFolder = await TauriService.pickFolder(); if (!baseFolder) return; playPressSound(); showNotification(t("arcEditor.exportingAll")); for (const entry of arc.entries) { const fileName = entry.filename.replace(/\//g, "_"); await TauriService.writeBinaryFile(`${baseFolder}/${fileName}`, entry.data); } showNotification(t("arcEditor.allEntriesExported")); } catch (err: unknown) { if (err !== "CANCELED") showNotification(t("arcEditor.exportFailed"), "error"); } }; const selectedEntry = selectedEntryIdx !== null ? arc?.entries[selectedEntryIdx] : null; return (

{t("arcEditor.title")}

{!arc ? (

{t("arcEditor.openArcFile")}

) : (
{activeTab === "arc" ? (
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)}
{selectedEntry ? (

{t("arcEditor.entryDetails")}

{selectedEntry.filename}

{t("arcEditor.size")} {(selectedEntry.size / 1024).toFixed(1)} KB
{t("arcEditor.format")} {selectedEntry.isCompressed ? t("arcEditor.compressed") : t("arcEditor.raw")}
) : (

{t("arcEditor.selectEntry")}

)}
) : (
{!loc ? (

{t("arcEditor.noLocFound")}

) : (
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" />
{filteredLocStrings.map((str) => ( ))}
{t("arcEditor.keyOrIndex")} {t("arcEditor.value")} {t("arcEditor.actions")}
{currentLocLang?.isStatic ? str.originalIdx : str.key} {str.value}
)}
)}
)}
{notification && ( {notification.message} )} {isAddModalOpen && (

{t("arcEditor.addFileToArchive")}

)} {isReplaceModalOpen && selectedEntryIdx !== null && (

{t("arcEditor.replaceFileData")}

{arc?.entries[selectedEntryIdx].filename}

)} {isRenameModalOpen && selectedEntryIdx !== null && ( setIsRenameModalOpen(false)} onConfirm={handleRenameEntry} /> )} {isLocEditModalOpen && ( setIsLocEditModalOpen(null)} onConfirm={handleLocStringEdit} /> )}
); } function RenameModal({ initialName, initialCompressed, onClose, onConfirm }: { initialName: string, initialCompressed: boolean, onClose: () => void, onConfirm: (name: string, comp: boolean) => void }) { const { t } = useTranslation(); const [name, setName] = useState(initialName); const [comp, setComp] = useState(initialCompressed); return (

{t("arcEditor.renameEntry")}

setName(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" />
); } function LocEditModal({ data, lang, onClose, onConfirm }: { data: { langIdx: number, strIdx: number, isNew: boolean }, lang: LocLanguage, onClose: () => void, onConfirm: (langIdx: number, strIdx: number, isNew: boolean, key: string, val: string) => void }) { const { t } = useTranslation(); const [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : ""); const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : ""); return (

{data.isNew ? t("arcEditor.add") : t("arcEditor.edit")} {t("arcEditor.stringTitle")}

{!lang.isStatic ? (
setKey(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 font-mono" />
) : (
{t("arcEditor.staticEntry", { index: data.isNew ? lang.strings.length : data.strIdx })}
)}