feat: ARC editor

This commit is contained in:
neoapps-dev 2026-04-10 22:06:15 +03:00
parent d808fbb8ae
commit 58361b15aa
4 changed files with 800 additions and 70 deletions

View file

@ -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<number>(0);
const containerRef = useRef<HTMLDivElement>(null);
const [arc, setArc] = useState<ArcFile | null>(null);
const [loc, setLoc] = useState<LocFile | null>(null);
const [activeTab, setActiveTab] = useState<"arc" | "loc">("arc");
const [searchTerm, setSearchTerm] = useState("");
const [selectedEntryIdx, setSelectedEntryIdx] = useState<number | null>(null);
const [selectedLocLangIdx, setSelectedLocLangIdx] = useState<number>(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<HTMLInputElement>(null);
const injectInputRef = useRef<HTMLInputElement>(null);
const replaceInputRef = useRef<HTMLInputElement>(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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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<HTMLInputElement>) => {
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 (
<motion.div
ref={containerRef}
initial={{ opacity: 0, scale: 0.95 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 0.95 }}
transition={{ duration: animationsEnabled ? 0.3 : 0 }}
className="flex flex-col items-center w-full max-w-3xl outline-none"
className="flex flex-col w-full h-[85vh] max-w-7xl relative"
>
<h2 className="text-2xl text-white mc-text-shadow mt-2 mb-4 border-b-2 border-[#373737] pb-2 w-[60%] max-w-75 text-center tracking-widest uppercase opacity-80 font-bold">
ARC Editor
</h2>
<div
className="w-full max-w-160 h-85 mb-4 p-8 shadow-2xl flex flex-col items-center justify-center p-12"
style={{
backgroundImage: "url('/images/frame_background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
<img
src="/images/tools/arc.png"
className="w-24 h-24 mb-6 opacity-20 grayscale"
style={{ imageRendering: "pixelated" }}
/>
<h3 className="text-xl text-[#FFFF55] mc-text-shadow mb-2">ARC Editor Coming Soon</h3>
<p className="text-center text-white/60 mc-text-shadow max-w-md">
This tool will allow you to edit ARC files.
</p>
<input type="file" ref={fileInputRef} onChange={handleFileLoad} className="hidden" accept=".arc" />
<input type="file" ref={injectInputRef} onChange={handleAddEntry} className="hidden" />
<input type="file" ref={replaceInputRef} onChange={handleReplaceEntry} className="hidden" />
<div className="flex items-center justify-between mb-6 px-4">
<div className="flex items-center gap-6">
<h2 className="text-3xl text-white mc-text-shadow tracking-widest uppercase font-bold">ARC Editor</h2>
{arc && <span className="text-white/40 mc-text-shadow italic">editing: <span className="text-[#FFFF55]">{arc.name}</span></span>}
</div>
<div className="flex gap-4">
<button
onClick={() => fileInputRef.current?.click()}
className="px-6 py-2 text-white mc-text-shadow text-lg"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Open ARC
</button>
<button
onClick={handleSaveArc}
disabled={!arc}
className={`px-6 py-2 text-white mc-text-shadow text-lg ${!arc ? "opacity-50 grayscale" : ""}`}
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Save ARC
</button>
</div>
</div>
<button
data-index="0"
onMouseEnter={() => setFocusIndex(0)}
onClick={() => {
playBackSound();
setActiveView("devtools");
}}
className={`w-72 h-14 flex items-center justify-center transition-colors text-2xl mc-text-shadow mt-2 outline-none border-none ${focusIndex === 0 ? "text-[#FFFF55]" : "text-white"
}`}
style={{
backgroundImage:
focusIndex === 0
? "url('/images/button_highlighted.png')"
: "url('/images/Button_Background.png')",
backgroundSize: "100% 100%",
imageRendering: "pixelated",
}}
>
Back
</button>
{!arc ? (
<div className="flex-1 w-full flex flex-col items-center justify-center p-12"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<img src="/images/tools/arc.png" className="w-32 h-32 mb-8 opacity-20 grayscale" style={{ imageRendering: "pixelated" }} />
<h3 className="text-2xl text-white/40 mc-text-shadow italic">Open an ARC file to begin editing</h3>
</div>
) : (
<div className="flex-1 w-full flex flex-col overflow-hidden" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<div className="flex gap-1 p-2 pt-4 border-b-2 border-[#373737]">
<button
onClick={() => { playPressSound(); setActiveTab("arc"); }}
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "arc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
>
<img src="/images/tools/arc.png" className={`w-5 h-5 object-contain ${activeTab === "arc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
<span className="text-lg">Archive</span>
</button>
<button
onClick={() => { playPressSound(); setActiveTab("loc"); }}
className={`flex items-center gap-3 px-6 py-2 transition-all mc-text-shadow ${activeTab === "loc" ? "text-[#FFFF55] opacity-100 scale-105" : "text-white opacity-40 hover:opacity-100"}`}
>
<img src="/images/tools/loc.png" className={`w-5 h-5 object-contain ${activeTab === "loc" ? "" : "grayscale opacity-50"}`} style={{ imageRendering: "pixelated" }} />
<span className="text-lg">Languages (LOC)</span>
</button>
</div>
<div className="flex-1 overflow-hidden flex flex-col">
{activeTab === "arc" ? (
<div className="flex-1 flex flex-col p-4 overflow-hidden">
<div className="mb-4 flex gap-4">
<input
type="text"
placeholder="Search entries..."
value={searchTerm}
onChange={(e) => 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"
/>
<button
onClick={() => setIsAddModalOpen(true)}
className="px-6 py-2 text-white mc-text-shadow text-sm"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Add Entry
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-[#252525] z-10">
<tr className="border-b-2 border-[#373737]">
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Filename</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Offset</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Size</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Flags</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
</tr>
</thead>
<tbody>
{filteredEntries.map((entry) => (
<tr key={entry.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
<td className="p-3 text-white truncate max-w-md font-medium">{entry.filename}</td>
<td className="p-3 text-white/60 text-right font-mono text-xs">0x{entry.ptr.toString(16).toUpperCase().padStart(8, '0')}</td>
<td className="p-3 text-white/60 text-right text-xs">{(entry.size / 1024).toFixed(1)} KB</td>
<td className="p-3">
{entry.isCompressed && (
<span className="bg-[#FFFF55]/10 text-[#FFFF55] border border-[#FFFF55]/20 px-2 py-0.5 text-[10px] uppercase font-bold">zlib</span>
)}
</td>
<td className="p-3 text-right flex justify-end gap-2 opacity-0 group-hover:opacity-100 transition-opacity">
<button onClick={() => handleExtractEntry(entry)} className="p-1 hover:text-[#FFFF55] transition-colors"><img src="/images/Download_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} /></button>
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsReplaceModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Replace</button>
<button onClick={() => { setSelectedEntryIdx(entry.originalIdx); setIsRenameModalOpen(true); }} className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase">Rename</button>
<button onClick={() => handleDeleteEntry(entry.originalIdx)} className="p-1 hover:text-red-500 transition-colors opacity-60 hover:opacity-100">
<img src="/images/Trash_Bin_Icon.png" className="w-5 h-5 object-contain" style={{ imageRendering: "pixelated" }} />
</button>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
) : (
<div className="flex-1 flex flex-col p-4 overflow-hidden">
{!loc ? (
<div className="flex-1 flex flex-col items-center justify-center p-12 text-center">
<h4 className="text-xl text-white/40 mc-text-shadow italic mb-4">No languages.loc found in archive</h4>
<button
onClick={() => {
setLoc({ version: 0, languages: [{ id: "en_US", version: 1, isStatic: false, langId: "en_US", strings: [] }] });
showNotification("Created new locale structure");
}}
className="px-6 py-2 text-white mc-text-shadow text-lg"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Create languages.loc
</button>
</div>
) : (
<div className="flex-1 flex flex-col overflow-hidden">
<div className="mb-4 flex gap-4 items-center">
<select
value={selectedLocLangIdx}
onChange={(e) => setSelectedLocLangIdx(parseInt(e.target.value))}
className="bg-black/40 border-2 border-[#373737] text-white px-4 py-2 outline-none focus:border-[#FFFF55] transition-colors"
>
{loc.languages.map((lang, idx) => (
<option key={idx} value={idx}>{lang.id} {lang.isStatic ? "[Static]" : "[Keyed]"}</option>
))}
</select>
<input
type="text"
placeholder="Search strings..."
value={searchTerm}
onChange={(e) => 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"
/>
<button
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: -1, isNew: true })}
className="px-6 py-2 text-white mc-text-shadow text-sm"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Add String
</button>
<button
onClick={handleSaveLocToArc}
className="px-6 py-2 text-[#FFFF55] mc-text-shadow text-sm"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Write to ARC
</button>
</div>
<div className="flex-1 overflow-y-auto pr-2 custom-scrollbar">
<table className="w-full text-left border-collapse">
<thead className="sticky top-0 bg-[#252525] z-10">
<tr className="border-b-2 border-[#373737]">
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Key / Index</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold">Value</th>
<th className="p-3 text-white/40 uppercase text-xs tracking-widest font-bold text-right">Actions</th>
</tr>
</thead>
<tbody>
{filteredLocStrings.map((str) => (
<tr key={str.originalIdx} className="border-b border-[#373737]/30 hover:bg-white/5 transition-colors group">
<td className="p-3 text-[#FFFF55] font-mono text-sm max-w-[200px] truncate">
{currentLocLang?.isStatic ? str.originalIdx : str.key}
</td>
<td className="p-3 text-white text-sm whitespace-pre-wrap">{str.value}</td>
<td className="p-3 text-right opacity-0 group-hover:opacity-100 transition-opacity">
<div className="flex justify-end gap-2">
<button
onClick={() => setIsLocEditModalOpen({ langIdx: selectedLocLangIdx, strIdx: str.originalIdx, isNew: false })}
className="px-2 py-1 text-[10px] bg-white/10 hover:bg-[#FFFF55]/20 hover:text-[#FFFF55] border border-white/20 transition-all uppercase"
>
Edit
</button>
<button onClick={() => handleLocStringDelete(selectedLocLangIdx, str.originalIdx)} className="p-1 hover:text-red-500 transition-colors opacity-60 hover:opacity-100">
<img src="/images/Trash_Bin_Icon.png" className="w-4 h-4 object-contain" style={{ imageRendering: "pixelated" }} />
</button>
</div>
</td>
</tr>
))}
</tbody>
</table>
</div>
</div>
)}
</div>
)}
</div>
</div>
)}
<div className="flex justify-center mt-6 h-14">
<button
onClick={() => { playBackSound(); setActiveView("devtools"); }}
className="w-72 h-full flex items-center justify-center transition-colors text-2xl mc-text-shadow outline-none border-none hover:text-[#FFFF55] text-white"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
Back
</button>
</div>
<AnimatePresence>
{notification && (
<motion.div
initial={{ opacity: 0, y: -50, scale: 0.9 }}
animate={{ opacity: 1, y: 0, scale: 1 }}
exit={{ opacity: 0, y: -50, scale: 0.9 }}
className="fixed top-12 right-12 z-[100] p-6 flex flex-col items-center justify-center min-w-[240px]"
style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}
>
<span className="text-white text-lg mc-text-shadow font-bold tracking-widest uppercase">
{notification.message}
</span>
</motion.div>
)}
</AnimatePresence>
{isAddModalOpen && (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6">Add File to Archive</h3>
<div className="flex flex-col gap-6">
<button
onClick={() => injectInputRef.current?.click()}
className="w-full py-3 text-white mc-text-shadow"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Select Source File
</button>
<div className="flex justify-end gap-4 mt-4">
<button onClick={() => setIsAddModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">Cancel</button>
</div>
</div>
</div>
</div>
)}
{isReplaceModalOpen && selectedEntryIdx !== null && (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-md p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-4">Replace File Data</h3>
<p className="text-white/60 mb-6 truncate">{arc?.entries[selectedEntryIdx].filename}</p>
<div className="flex flex-col gap-6">
<button
onClick={() => replaceInputRef.current?.click()}
className="w-full py-3 text-white mc-text-shadow"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Select New File
</button>
<div className="flex justify-end gap-4 mt-4">
<button onClick={() => setIsReplaceModalOpen(false)} className="px-6 py-2 text-white/60 hover:text-white transition-colors">Cancel</button>
</div>
</div>
</div>
</div>
)}
{isRenameModalOpen && selectedEntryIdx !== null && (
<RenameModal
initialName={arc?.entries[selectedEntryIdx].filename || ""}
initialCompressed={arc?.entries[selectedEntryIdx].isCompressed || false}
onClose={() => setIsRenameModalOpen(false)}
onConfirm={handleRenameEntry}
/>
)}
{isLocEditModalOpen && (
<LocEditModal
data={isLocEditModalOpen}
lang={loc?.languages[isLocEditModalOpen.langIdx]!}
onClose={() => setIsLocEditModalOpen(null)}
onConfirm={handleLocStringEdit}
/>
)}
</motion.div>
);
}
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 (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-lg p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">Rename Entry</h3>
<div className="flex flex-col gap-4">
<div>
<label className="text-white/40 text-xs uppercase mb-2 block">New Archive Path</label>
<input
type="text"
value={name}
onChange={(e) => 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"
/>
</div>
<label className="flex items-center gap-3 cursor-pointer group">
<input type="checkbox" checked={comp} onChange={(e) => setComp(e.target.checked)} className="w-5 h-5 accent-[#FFFF55]" />
<span className="text-white group-hover:text-[#FFFF55] transition-colors">Mark as compressed (zlib)</span>
</label>
<div className="flex justify-end gap-4 mt-6">
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">Cancel</button>
<button
onClick={() => onConfirm(name, comp)}
className="px-8 py-2 text-white mc-text-shadow"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
Rename
</button>
</div>
</div>
</div>
</div>
);
}
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 (
<div className="fixed inset-0 z-[110] flex items-center justify-center bg-black/60 backdrop-blur-sm">
<div className="w-full max-w-2xl p-8" style={{ backgroundImage: "url('/images/frame_background.png')", backgroundSize: "100% 100%", imageRendering: "pixelated" }}>
<h3 className="text-2xl text-[#FFFF55] mc-text-shadow mb-6 uppercase tracking-widest">{data.isNew ? "Add" : "Edit"} String</h3>
<div className="flex flex-col gap-4">
{!lang.isStatic ? (
<div>
<label className="text-white/40 text-xs uppercase mb-2 block">String Key</label>
<input
type="text"
value={key}
onChange={(e) => 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"
/>
</div>
) : (
<div className="text-white/40 italic mb-2">Static entry - Index: {data.isNew ? lang.strings.length : data.strIdx}</div>
)}
<div>
<label className="text-white/40 text-xs uppercase mb-2 block">String Value</label>
<textarea
value={val}
onChange={(e) => setVal(e.target.value)}
rows={6}
className="w-full bg-black/40 border-2 border-[#373737] text-white px-4 py-3 outline-none focus:border-[#FFFF55] transition-colors resize-none"
/>
</div>
<div className="flex justify-end gap-4 mt-6">
<button onClick={onClose} className="px-6 py-2 text-white/60 hover:text-white transition-colors uppercase tracking-widest">Cancel</button>
<button
onClick={() => onConfirm(data.langIdx, data.strIdx, data.isNew, key, val)}
className="px-8 py-2 text-white mc-text-shadow"
style={{ backgroundImage: "url('/images/Button_Background.png')", backgroundSize: "100% 100%" }}
>
{data.isNew ? "Add" : "Save"}
</button>
</div>
</div>
</div>
</div>
);
}

View file

@ -8,7 +8,7 @@ import SkinsView from "../components/views/SkinsView";
import WorkshopView from "../components/views/WorkshopView";
import SetupView from "../components/views/SetupView";
import PckEditorView from "../components/views/PckEditorView";
import ArcEditorView from "../components/views/ArcEditorView";
import { ArcEditorView } from "../components/views/ArcEditorView";
import SkinViewer from "../components/common/SkinViewer";
import TeamModal from "../components/modals/TeamModal";
import PanoramaBackground from "../components/common/PanoramaBackground";

218
src/services/ArcService.ts Normal file
View file

@ -0,0 +1,218 @@
import { ArcEntry, ArcFile, LocFile, LocLanguage, LocString } from "../types/arc";
export class ArcService {
private static enc = new TextEncoder();
private static dec = new TextDecoder('utf-8');
private static readUTF(view: DataView, off: number): { str: string; next: number } {
const len = view.getUint16(off, false);
const bytes = new Uint8Array(view.buffer, view.byteOffset + off + 2, len);
return { str: this.dec.decode(bytes), next: off + 2 + len };
}
private static writeUTF(str: string): Uint8Array {
const b = this.enc.encode(str);
const out = new Uint8Array(2 + b.length);
new DataView(out.buffer).setUint16(0, b.length, false);
out.set(b, 2);
return out;
}
static async readARC(buffer: ArrayBuffer): Promise<ArcFile> {
const view = new DataView(buffer);
const raw = new Uint8Array(buffer);
let off = 0;
const count = view.getInt32(off, false);
off += 4;
if (count < 0 || count > 200000) throw new Error("Invalid file count");
const entries: ArcEntry[] = [];
for (let i = 0; i < count; i++) {
const { str: rawName, next } = this.readUTF(view, off);
off = next;
const ptr = view.getInt32(off, false);
off += 4;
const size = view.getInt32(off, false);
off += 4;
let filename = rawName;
let isCompressed = false;
if (filename.charCodeAt(0) === 42) {
isCompressed = true;
filename = filename.slice(1);
}
const data = (ptr + size <= raw.length) ? raw.slice(ptr, ptr + size) : new Uint8Array(0);
entries.push({ filename, ptr, size, isCompressed, data });
}
return { name: "", entries };
}
static serializeARC(arc: ArcFile): ArrayBuffer {
let headerSize = 4;
arc.entries.forEach(e => {
const nameWithFlag = (e.isCompressed ? "*" : "") + e.filename;
headerSize += 2 + this.enc.encode(nameWithFlag).length + 8;
});
let currentOffset = headerSize;
const offsets = arc.entries.map(e => {
const o = currentOffset;
currentOffset += e.size;
return o;
});
const buffer = new ArrayBuffer(currentOffset);
const view = new DataView(buffer);
const out = new Uint8Array(buffer);
let pos = 0;
view.setInt32(pos, arc.entries.length, false);
pos += 4;
arc.entries.forEach((e, i) => {
const nb = this.enc.encode((e.isCompressed ? "*" : "") + e.filename);
view.setUint16(pos, nb.length, false);
pos += 2;
out.set(nb, pos);
pos += nb.length;
view.setInt32(pos, offsets[i], false);
pos += 4;
view.setInt32(pos, e.size, false);
pos += 4;
});
arc.entries.forEach((e, i) => {
if (e.data.length > 0) {
out.set(e.data, offsets[i]);
}
});
return buffer;
}
static parseLOC(data: Uint8Array): LocFile {
const buf = data.buffer.slice(data.byteOffset, data.byteOffset + data.byteLength);
const view = new DataView(buf);
let off = 0;
const version = view.getInt32(off, false);
off += 4;
const langCount = view.getInt32(off, false);
off += 4;
const langMeta: { id: string, size: number }[] = [];
for (let i = 0; i < langCount; i++) {
const { str: id, next } = this.readUTF(view, off);
off = next;
const size = view.getInt32(off, false);
off += 4;
langMeta.push({ id, size });
}
const languages: LocLanguage[] = [];
for (let i = 0; i < langMeta.length; i++) {
const blobView = new DataView(buf, off, langMeta[i].size);
let boff = 0;
const langVer = blobView.getInt32(boff, false);
boff += 4;
let isStatic = false;
if (langVer > 0) {
isStatic = blobView.getUint8(boff) !== 0;
boff += 1;
}
const { str: langId, next: n2 } = this.readUTF(blobView, boff);
boff = n2;
const total = blobView.getInt32(boff, false);
boff += 4;
const strings: LocString[] = [];
if (!isStatic) {
for (let j = 0; j < total; j++) {
const { str: key, next: nk } = this.readUTF(blobView, boff);
boff = nk;
const { str: val, next: nv } = this.readUTF(blobView, boff);
boff = nv;
strings.push({ key, value: val });
}
} else {
for (let j = 0; j < total; j++) {
const { str: val, next: nv } = this.readUTF(blobView, boff);
boff = nv;
strings.push({ value: val });
}
}
languages.push({ id: langMeta[i].id, version: langVer, isStatic, langId, strings });
off += langMeta[i].size;
}
return { version, languages };
}
static serializeLOC(loc: LocFile): Uint8Array {
const blobs = loc.languages.map(lang => {
const parts: Uint8Array[] = [];
const vh = new Uint8Array(4);
new DataView(vh.buffer).setInt32(0, lang.version, false);
parts.push(vh);
if (lang.version > 0) {
parts.push(new Uint8Array([lang.isStatic ? 1 : 0]));
}
parts.push(this.writeUTF(lang.langId));
const sc = new Uint8Array(4);
new DataView(sc.buffer).setInt32(0, lang.strings.length, false);
parts.push(sc);
lang.strings.forEach(s => {
if (!lang.isStatic && s.key) {
parts.push(this.writeUTF(s.key));
}
parts.push(this.writeUTF(s.value));
});
const total = parts.reduce((a, b) => a + b.length, 0);
const blob = new Uint8Array(total);
let p = 0;
parts.forEach(b => {
blob.set(b, p);
p += b.length;
});
return blob;
});
const headerParts: Uint8Array[] = [];
const vh = new Uint8Array(4);
new DataView(vh.buffer).setInt32(0, loc.version, false);
headerParts.push(vh);
const lc = new Uint8Array(4);
new DataView(lc.buffer).setInt32(0, loc.languages.length, false);
headerParts.push(lc);
loc.languages.forEach((lang, i) => {
headerParts.push(this.writeUTF(lang.id));
const sz = new Uint8Array(4);
new DataView(sz.buffer).setInt32(0, blobs[i].length, false);
headerParts.push(sz);
});
const allParts = [...headerParts, ...blobs];
const total = allParts.reduce((a, b) => a + b.length, 0);
const out = new Uint8Array(total);
let p = 0;
allParts.forEach(b => {
out.set(b, p);
p += b.length;
});
return out;
}
}

30
src/types/arc.ts Normal file
View file

@ -0,0 +1,30 @@
export interface ArcEntry {
filename: string;
ptr: number;
size: number;
isCompressed: boolean;
data: Uint8Array;
}
export interface ArcFile {
name: string;
entries: ArcEntry[];
}
export interface LocString {
key?: string;
value: string;
}
export interface LocLanguage {
id: string;
version: number;
isStatic: boolean;
langId: string;
strings: LocString[];
}
export interface LocFile {
version: number;
languages: LocLanguage[];
}