diff --git a/src/components/views/ArcEditorView.tsx b/src/components/views/ArcEditorView.tsx index e016f38..dfe6990 100644 --- a/src/components/views/ArcEditorView.tsx +++ b/src/components/views/ArcEditorView.tsx @@ -1,89 +1,571 @@ -import { useState, useEffect, useRef } from "react"; -import { motion } from "framer-motion"; +import { useState, useRef, useMemo } from "react"; +import { motion, AnimatePresence } from "framer-motion"; import { useUI, useAudio, useConfig } from "../../context/LauncherContext"; - -export default function ArcEditorView() { +import { ArcService } from "../../services/ArcService"; +import { ArcFile, ArcEntry, LocFile, LocLanguage } from "../../types/arc"; +export const ArcEditorView: React.FC = () => { const { setActiveView } = useUI(); - const { playBackSound } = useAudio(); + const { playPressSound, playBackSound } = useAudio(); const { animationsEnabled } = useConfig(); - const [focusIndex, setFocusIndex] = useState(0); - const containerRef = useRef(null); + const [arc, setArc] = 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 fileInputRef = useRef(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]); - useEffect(() => { - const handleKeyDown = (e: KeyboardEvent) => { - if (e.key === "Escape" || e.key === "Backspace") { - playBackSound(); - setActiveView("devtools"); - return; - } - if (e.key === "Enter") { - if (focusIndex === 0) { - playBackSound(); - setActiveView("devtools"); + 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 (e: React.ChangeEvent) => { + const file = e.target.files?.[0]; + if (!file) return; + playPressSound(); + const buffer = await file.arrayBuffer(); + try { + const parsed = await ArcService.readARC(buffer); + parsed.name = file.name; + setArc(parsed); + 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); } - }; - window.addEventListener("keydown", handleKeyDown); - return () => window.removeEventListener("keydown", handleKeyDown); - }, [playBackSound, setActiveView, focusIndex]); + setSelectedEntryIdx(null); + showNotification(`Loaded ${file.name}`); + } catch (err) { + console.error("Failed to parse ARC", err); + showNotification("Failed to parse ARC", "error"); + } + }; - useEffect(() => { - const el = containerRef.current?.querySelector(`[data-index="${focusIndex}"]`) as HTMLElement; - if (el) el.focus(); - }, [focusIndex]); + const handleSaveArc = () => { + if (!arc) return; + playPressSound(); + const buffer = ArcService.serializeARC(arc); + const blob = new Blob([buffer]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = arc.name || "archive.arc"; + a.click(); + URL.revokeObjectURL(url); + showNotification("ARC Saved Successfully"); + }; + + const handleExtractEntry = (entry: ArcEntry) => { + playPressSound(); + const blob = new Blob([entry.data as any]); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = entry.filename.split("/").pop() || "asset"; + a.click(); + URL.revokeObjectURL(url); + showNotification(`Extracted: ${entry.filename}`); + }; + + 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(`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("languages.loc updated in archive"); + } else { + newEntries.push({ filename: "languages.loc", ptr: 0, size: data.length, isCompressed: false, data }); + showNotification("languages.loc added to archive"); + } + 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("Entry Added"); + 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("Entry Replaced"); + 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("Entry Renamed"); + }; + + 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 ? "String Added" : "String Updated"); + }; + + const handleLocStringDelete = (langIdx: number, strIdx: number) => { + if (!loc) return; + playBackSound(); + const newLoc = { ...loc }; + newLoc.languages[langIdx].strings.splice(strIdx, 1); + setLoc(newLoc); + showNotification("String Deleted"); + }; return ( -

- ARC Editor -

- -
- -

ARC Editor Coming Soon

-

- This tool will allow you to edit ARC files. -

+ + + +
+
+

ARC Editor

+ {arc && editing: {arc.name}} +
+
+ + +
- + {!arc ? ( +
+ +

Open an ARC file to begin editing

+
+ ) : ( +
+
+ + +
+ +
+ {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" + /> + +
+
+ + + + + + + + + + + + {filteredEntries.map((entry) => ( + + + + + + + + ))} + +
FilenameOffsetSizeFlagsActions
{entry.filename}0x{entry.ptr.toString(16).toUpperCase().padStart(8, '0')}{(entry.size / 1024).toFixed(1)} KB + {entry.isCompressed && ( + zlib + )} + + + + + +
+
+
+ ) : ( +
+ {!loc ? ( +
+

No languages.loc found in archive

+ +
+ ) : ( +
+
+ + 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) => ( + + + + + + ))} + +
Key / IndexValueActions
+ {currentLocLang?.isStatic ? str.originalIdx : str.key} + {str.value} +
+ + +
+
+
+
+ )} +
+ )} +
+
+ )} +
+ +
+ + {notification && ( + + + {notification.message} + + + )} + + {isAddModalOpen && ( +
+
+

Add File to Archive

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

Replace File Data

+

{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 [name, setName] = useState(initialName); + const [comp, setComp] = useState(initialCompressed); + return ( +
+
+

Rename Entry

+
+
+ + 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 [key, setKey] = useState(!data.isNew ? (lang.strings[data.strIdx].key || "") : ""); + const [val, setVal] = useState(!data.isNew ? lang.strings[data.strIdx].value : ""); + return ( +
+
+

{data.isNew ? "Add" : "Edit"} String

+
+ {!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" + /> +
+ ) : ( +
Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}
+ )} +
+ +