2024-10-15 18:48:11 -07:00
|
|
|
|
"use client";
|
|
|
|
|
|
import { useCallback, useMemo, useState } from "react";
|
|
|
|
|
|
import { useDropzone } from "react-dropzone";
|
|
|
|
|
|
import JSZip from "jszip";
|
|
|
|
|
|
import { saveAs } from "file-saver";
|
|
|
|
|
|
import orderBy from "lodash.orderby";
|
|
|
|
|
|
import { FaTrashAlt } from "react-icons/fa";
|
2024-10-15 20:15:45 -07:00
|
|
|
|
import { MdCastle, MdTerrain } from "react-icons/md";
|
|
|
|
|
|
import { LuScrollText } from "react-icons/lu";
|
|
|
|
|
|
import { FaClipboardList, FaMapPin } from "react-icons/fa6";
|
2024-10-15 20:20:06 -07:00
|
|
|
|
import { HiMiniSpeakerWave } from "react-icons/hi2";
|
2024-10-15 18:48:11 -07:00
|
|
|
|
import styles from "./Forge.module.css";
|
|
|
|
|
|
import { base64ArrayBuffer } from "./utils";
|
2024-10-15 19:25:08 -07:00
|
|
|
|
|
|
|
|
|
|
function detectBestPath(path) {
|
|
|
|
|
|
const parts = path.split("/");
|
|
|
|
|
|
let folder = "";
|
|
|
|
|
|
let basename = "";
|
|
|
|
|
|
if (parts.length > 1) {
|
|
|
|
|
|
folder = parts.slice(0, -1).join("/");
|
|
|
|
|
|
basename = parts[parts.length - 1];
|
|
|
|
|
|
} else {
|
|
|
|
|
|
folder = "";
|
|
|
|
|
|
basename = parts[0];
|
|
|
|
|
|
}
|
2024-10-15 20:15:45 -07:00
|
|
|
|
if (folder) {
|
|
|
|
|
|
return `${folder}/${basename}`;
|
|
|
|
|
|
}
|
2024-10-15 19:25:08 -07:00
|
|
|
|
if (
|
|
|
|
|
|
/\.(l|m|h)(male|female|bioderm)\.png$/i.test(basename) ||
|
2024-10-15 20:15:45 -07:00
|
|
|
|
/^(vehicle|weapon)_.+png$/i.test(basename) ||
|
|
|
|
|
|
/^dcase\d\d\.png$/i.test(basename)
|
2024-10-15 19:25:08 -07:00
|
|
|
|
) {
|
|
|
|
|
|
folder = "textures/skins";
|
2024-10-15 20:15:45 -07:00
|
|
|
|
} else if (/\.(ter|spn)$/i.test(basename)) {
|
|
|
|
|
|
folder = "terrains";
|
|
|
|
|
|
} else if (/\.mis$/i.test(basename)) {
|
|
|
|
|
|
folder = "missions";
|
|
|
|
|
|
} else if (/\.dif$/i.test(basename)) {
|
|
|
|
|
|
folder = "interiors";
|
2024-10-15 19:25:08 -07:00
|
|
|
|
}
|
|
|
|
|
|
if (folder) {
|
|
|
|
|
|
return `${folder}/${basename}`;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return basename;
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
2024-10-15 18:48:11 -07:00
|
|
|
|
|
|
|
|
|
|
function detectFileType(file): FileType | null {
|
|
|
|
|
|
if (file.type) {
|
|
|
|
|
|
if (/^image\//i.test(file.type)) {
|
|
|
|
|
|
return { mimeType: file.type, genericType: "image" };
|
|
|
|
|
|
} else if (/^audio\//i.test(file.type)) {
|
|
|
|
|
|
return { mimeType: file.type, genericType: "audio" };
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
if (/\.png$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/png", genericType: "image" };
|
|
|
|
|
|
} else if (/\.jpg$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/jpeg", genericType: "image" };
|
|
|
|
|
|
} else if (/\.bmp$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/bmp", genericType: "image" };
|
|
|
|
|
|
} else if (/\.webp$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/webp", genericType: "image" };
|
|
|
|
|
|
} else if (/\.gif$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/gif", genericType: "image" };
|
|
|
|
|
|
} else if (/\.tiff$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/tiff", genericType: "image" };
|
|
|
|
|
|
} else if (/\.svg$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "image/svg+xml", genericType: "image" };
|
|
|
|
|
|
} else if (/\.wav$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "audio/wav", genericType: "audio" };
|
|
|
|
|
|
} else if (/\.mp3$/i.test(file.name)) {
|
|
|
|
|
|
return { mimeType: "audio/mpeg", genericType: "audio" };
|
|
|
|
|
|
}
|
|
|
|
|
|
if (file.type) {
|
|
|
|
|
|
return {
|
|
|
|
|
|
mimeType: file.type,
|
|
|
|
|
|
genericType: null,
|
|
|
|
|
|
};
|
|
|
|
|
|
}
|
|
|
|
|
|
return null;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
2024-10-15 20:15:45 -07:00
|
|
|
|
function FilePreview({ file, onDelete, onRename }) {
|
2024-10-15 18:48:11 -07:00
|
|
|
|
let icon = null;
|
|
|
|
|
|
if (file.dataUri && file.type?.genericType === "image") {
|
|
|
|
|
|
icon = (
|
|
|
|
|
|
<img
|
|
|
|
|
|
className={styles.PreviewIcon}
|
|
|
|
|
|
src={file.dataUri}
|
|
|
|
|
|
width={24}
|
|
|
|
|
|
alt=""
|
|
|
|
|
|
/>
|
|
|
|
|
|
);
|
2024-10-15 20:20:06 -07:00
|
|
|
|
} else if (file.type?.genericType === "audio") {
|
|
|
|
|
|
icon = <HiMiniSpeakerWave />;
|
2024-10-15 20:15:45 -07:00
|
|
|
|
} else if (/\.cs$/i.test(file.path)) {
|
|
|
|
|
|
icon = <LuScrollText />;
|
|
|
|
|
|
} else if (/\.mis$/i.test(file.path)) {
|
|
|
|
|
|
icon = <FaClipboardList />;
|
|
|
|
|
|
} else if (/\.dif$/i.test(file.path)) {
|
|
|
|
|
|
icon = <MdCastle />;
|
|
|
|
|
|
} else if (/\.ter$/i.test(file.path)) {
|
|
|
|
|
|
icon = <MdTerrain />;
|
|
|
|
|
|
} else if (/\.spn$/i.test(file.path)) {
|
|
|
|
|
|
icon = <FaMapPin />;
|
2024-10-15 18:48:11 -07:00
|
|
|
|
}
|
|
|
|
|
|
return (
|
|
|
|
|
|
<div className={styles.File}>
|
|
|
|
|
|
<span className={styles.IconContainer}>{icon}</span>{" "}
|
2024-10-15 20:15:45 -07:00
|
|
|
|
<span
|
|
|
|
|
|
className={styles.Path}
|
|
|
|
|
|
onDoubleClick={() => {
|
|
|
|
|
|
let newPath = window.prompt(`Rename file (${file.path}):`, file.path);
|
2024-10-15 20:20:06 -07:00
|
|
|
|
if (newPath) {
|
|
|
|
|
|
newPath = newPath
|
|
|
|
|
|
.trim()
|
|
|
|
|
|
.replace(/\/+/g, "/")
|
|
|
|
|
|
.replace(/^\//, "")
|
|
|
|
|
|
.replace(/\/$/, "")
|
|
|
|
|
|
.trim();
|
|
|
|
|
|
if (newPath && newPath !== file.path) {
|
|
|
|
|
|
onRename(file.path, newPath);
|
|
|
|
|
|
}
|
2024-10-15 20:15:45 -07:00
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
{file.path}
|
|
|
|
|
|
</span>
|
2024-10-15 18:48:11 -07:00
|
|
|
|
<button
|
|
|
|
|
|
className={styles.DeleteButton}
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
aria-label="Delete"
|
|
|
|
|
|
title="Delete"
|
|
|
|
|
|
onClick={(event) => {
|
|
|
|
|
|
onDelete(file.path);
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<FaTrashAlt />
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
type FileType = {
|
|
|
|
|
|
mimeType: string;
|
|
|
|
|
|
genericType: string | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
type FileEntry = {
|
|
|
|
|
|
path: string;
|
|
|
|
|
|
buffer: ArrayBuffer;
|
|
|
|
|
|
dataUri: string | null;
|
|
|
|
|
|
date: Date | null;
|
|
|
|
|
|
unixPermissions: string | number | null;
|
|
|
|
|
|
dosPermissions: number | null;
|
|
|
|
|
|
type: FileType | null;
|
|
|
|
|
|
};
|
|
|
|
|
|
|
|
|
|
|
|
async function handleZipFile(file) {
|
|
|
|
|
|
const zip = await JSZip.loadAsync(file);
|
|
|
|
|
|
const map = new Map<string, FileEntry>();
|
2024-10-15 19:25:08 -07:00
|
|
|
|
for (let path in zip.files) {
|
2024-10-15 18:48:11 -07:00
|
|
|
|
const fileObj = zip.files[path];
|
|
|
|
|
|
if (!fileObj.dir) {
|
2024-10-15 19:25:08 -07:00
|
|
|
|
path = detectBestPath(path);
|
2024-10-15 18:48:11 -07:00
|
|
|
|
const buffer = await fileObj.async("arraybuffer");
|
|
|
|
|
|
const fileEntry = {
|
|
|
|
|
|
path,
|
|
|
|
|
|
buffer: buffer,
|
|
|
|
|
|
dataUri: null,
|
|
|
|
|
|
date: fileObj.date,
|
|
|
|
|
|
unixPermissions: fileObj.unixPermissions,
|
|
|
|
|
|
dosPermissions: fileObj.dosPermissions,
|
|
|
|
|
|
type: detectFileType(fileObj),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (
|
|
|
|
|
|
fileEntry.type?.genericType === "image" ||
|
|
|
|
|
|
fileEntry.type?.genericType === "audio"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const base64String = await fileObj.async("base64");
|
|
|
|
|
|
fileEntry.dataUri = `data:${fileEntry.type.mimeType};base64,${base64String}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
map.set(path, fileEntry);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleOtherFile(file) {
|
|
|
|
|
|
const map = new Map();
|
|
|
|
|
|
let path;
|
|
|
|
|
|
if (file.path) {
|
|
|
|
|
|
path = file.path;
|
|
|
|
|
|
if (path.startsWith("/")) {
|
|
|
|
|
|
path = path.slice(1);
|
|
|
|
|
|
}
|
|
|
|
|
|
} else if (file.name) {
|
|
|
|
|
|
path = file.name;
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}
|
2024-10-15 19:25:08 -07:00
|
|
|
|
path = detectBestPath(path);
|
2024-10-15 18:48:11 -07:00
|
|
|
|
const buffer = await new Promise<ArrayBuffer>((resolve, reject) => {
|
|
|
|
|
|
const reader = new FileReader();
|
|
|
|
|
|
reader.addEventListener("load", (event) => {
|
|
|
|
|
|
resolve(event.target.result as ArrayBuffer);
|
|
|
|
|
|
});
|
|
|
|
|
|
reader.readAsArrayBuffer(file);
|
|
|
|
|
|
});
|
|
|
|
|
|
const fileEntry = {
|
|
|
|
|
|
path,
|
|
|
|
|
|
buffer,
|
|
|
|
|
|
dataUri: null,
|
|
|
|
|
|
date: null,
|
|
|
|
|
|
unixPermissions: null,
|
|
|
|
|
|
dosPermissions: null,
|
|
|
|
|
|
type: detectFileType(file),
|
|
|
|
|
|
};
|
|
|
|
|
|
if (
|
|
|
|
|
|
fileEntry.type?.genericType === "image" ||
|
|
|
|
|
|
fileEntry.type?.genericType === "audio"
|
|
|
|
|
|
) {
|
|
|
|
|
|
const base64String = base64ArrayBuffer(buffer);
|
|
|
|
|
|
fileEntry.dataUri = `data:${fileEntry.type.mimeType};base64,${base64String}`;
|
|
|
|
|
|
}
|
|
|
|
|
|
map.set(path, fileEntry);
|
|
|
|
|
|
return map;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
async function handleInputFile(file) {
|
|
|
|
|
|
if (/\.(zip|vl2)$/i.test(file.name)) {
|
|
|
|
|
|
return handleZipFile(file);
|
|
|
|
|
|
} else {
|
|
|
|
|
|
return handleOtherFile(file);
|
|
|
|
|
|
}
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function createZipFile(files: Array<FileEntry>) {
|
|
|
|
|
|
const zip = new JSZip();
|
|
|
|
|
|
for (const file of files) {
|
|
|
|
|
|
zip.file(file.path, file.buffer, {
|
|
|
|
|
|
date: file.date,
|
|
|
|
|
|
dosPermissions: file.dosPermissions,
|
|
|
|
|
|
unixPermissions: file.unixPermissions,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
return zip;
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export async function saveZipFile(zip: JSZip, name: string) {
|
|
|
|
|
|
const blob = await zip.generateAsync({
|
|
|
|
|
|
type: "blob",
|
|
|
|
|
|
mimeType: "application/octet-stream",
|
|
|
|
|
|
});
|
|
|
|
|
|
saveAs(blob, name);
|
|
|
|
|
|
}
|
|
|
|
|
|
|
|
|
|
|
|
export function Forge() {
|
|
|
|
|
|
const [actionLog, setActionLog] = useState(() => []);
|
|
|
|
|
|
const [files, setFiles] = useState(() => new Map());
|
|
|
|
|
|
|
|
|
|
|
|
const onDrop = useCallback(async (acceptedFiles) => {
|
|
|
|
|
|
const actionLog = [];
|
|
|
|
|
|
const finalMap = new Map();
|
|
|
|
|
|
const allFiles: Array<Map<string, any>> = await Promise.all(
|
|
|
|
|
|
acceptedFiles.map((file) => handleInputFile(file))
|
|
|
|
|
|
);
|
|
|
|
|
|
allFiles.forEach((map) => {
|
|
|
|
|
|
map.forEach((file, path) => {
|
|
|
|
|
|
if (finalMap.has(path)) {
|
|
|
|
|
|
actionLog.push({
|
|
|
|
|
|
type: "overwrite",
|
|
|
|
|
|
path,
|
|
|
|
|
|
});
|
|
|
|
|
|
}
|
|
|
|
|
|
finalMap.set(path, file);
|
|
|
|
|
|
});
|
|
|
|
|
|
});
|
|
|
|
|
|
setFiles((prevMap) => {
|
|
|
|
|
|
return new Map([
|
|
|
|
|
|
...Array.from(prevMap.entries()),
|
|
|
|
|
|
...Array.from(finalMap.entries()),
|
|
|
|
|
|
]);
|
|
|
|
|
|
});
|
|
|
|
|
|
setActionLog((prevLog) => [...prevLog, ...actionLog]);
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
const { getRootProps, getInputProps, open, isDragActive } = useDropzone({
|
|
|
|
|
|
noClick: true,
|
|
|
|
|
|
onDrop,
|
|
|
|
|
|
});
|
|
|
|
|
|
|
|
|
|
|
|
const fileList = useMemo(() => {
|
|
|
|
|
|
const paths = orderBy(
|
|
|
|
|
|
Array.from(files.keys()),
|
|
|
|
|
|
[(path) => path.toLowerCase()],
|
|
|
|
|
|
["asc"]
|
|
|
|
|
|
);
|
|
|
|
|
|
return paths.map((path) => files.get(path));
|
|
|
|
|
|
}, [files]);
|
|
|
|
|
|
|
|
|
|
|
|
const addButton = (
|
|
|
|
|
|
<button
|
|
|
|
|
|
type="button"
|
|
|
|
|
|
className={styles.AddButton}
|
|
|
|
|
|
aria-label="Add files"
|
|
|
|
|
|
title="Add files"
|
|
|
|
|
|
onClick={open}
|
|
|
|
|
|
>
|
|
|
|
|
|
+
|
|
|
|
|
|
</button>
|
|
|
|
|
|
);
|
|
|
|
|
|
|
|
|
|
|
|
const handleDelete = useCallback((path) => {
|
|
|
|
|
|
setFiles((files) => {
|
|
|
|
|
|
const newFiles = new Map(files);
|
|
|
|
|
|
newFiles.delete(path);
|
|
|
|
|
|
return newFiles;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2024-10-15 20:15:45 -07:00
|
|
|
|
const handleRename = useCallback((oldPath, newPath) => {
|
|
|
|
|
|
setFiles((files) => {
|
|
|
|
|
|
const file = files.get(oldPath);
|
|
|
|
|
|
const newFile = { ...file, path: newPath };
|
|
|
|
|
|
const newFiles = new Map(files);
|
|
|
|
|
|
newFiles.delete(oldPath);
|
|
|
|
|
|
newFiles.set(newPath, newFile);
|
|
|
|
|
|
return newFiles;
|
|
|
|
|
|
});
|
|
|
|
|
|
}, []);
|
|
|
|
|
|
|
2024-10-15 18:48:11 -07:00
|
|
|
|
return (
|
|
|
|
|
|
<>
|
|
|
|
|
|
<section className={styles.Forge} {...getRootProps()}>
|
|
|
|
|
|
<header className={styles.Header}>
|
|
|
|
|
|
<img
|
|
|
|
|
|
width={210}
|
|
|
|
|
|
height={188}
|
|
|
|
|
|
src="/vl2-forge/logo-md.png"
|
|
|
|
|
|
alt="VL2 Forge"
|
|
|
|
|
|
/>
|
|
|
|
|
|
{addButton}
|
|
|
|
|
|
</header>
|
|
|
|
|
|
<input {...getInputProps()} />
|
|
|
|
|
|
<div className={styles.ListArea}>
|
|
|
|
|
|
{fileList.length ? (
|
|
|
|
|
|
<ul className={styles.FileList}>
|
|
|
|
|
|
{fileList.map((file) => {
|
|
|
|
|
|
return (
|
|
|
|
|
|
<li key={file.path}>
|
2024-10-15 20:15:45 -07:00
|
|
|
|
<FilePreview
|
|
|
|
|
|
file={file}
|
|
|
|
|
|
onDelete={handleDelete}
|
|
|
|
|
|
onRename={handleRename}
|
|
|
|
|
|
/>
|
2024-10-15 18:48:11 -07:00
|
|
|
|
</li>
|
|
|
|
|
|
);
|
|
|
|
|
|
})}
|
|
|
|
|
|
</ul>
|
|
|
|
|
|
) : (
|
|
|
|
|
|
<div className={styles.EmptyMessage}>
|
2024-10-15 20:31:45 -07:00
|
|
|
|
Drop files onto the page or press the add button. No need to
|
|
|
|
|
|
extract existing .vl2 files first – just drop ‘em in and it’ll do
|
|
|
|
|
|
that for you!
|
2024-10-15 18:48:11 -07:00
|
|
|
|
</div>
|
|
|
|
|
|
)}
|
|
|
|
|
|
</div>
|
|
|
|
|
|
</section>
|
|
|
|
|
|
<footer className={styles.Footer}>
|
|
|
|
|
|
<form
|
|
|
|
|
|
onSubmit={async (event) => {
|
|
|
|
|
|
event.preventDefault();
|
|
|
|
|
|
const form = event.target as HTMLFormElement;
|
|
|
|
|
|
const fileName = form.elements["fileName"] as HTMLInputElement;
|
|
|
|
|
|
const name = fileName.value.trim();
|
|
|
|
|
|
if (!name) {
|
|
|
|
|
|
window.alert("Name thy file.");
|
|
|
|
|
|
fileName.focus();
|
|
|
|
|
|
} else if (!fileList.length) {
|
|
|
|
|
|
window.alert("Add some files!");
|
|
|
|
|
|
} else {
|
|
|
|
|
|
const zip = createZipFile(fileList);
|
|
|
|
|
|
await saveZipFile(zip, `${name}.vl2`);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
>
|
|
|
|
|
|
<div className={styles.NameInput}>
|
|
|
|
|
|
<input
|
|
|
|
|
|
name="fileName"
|
|
|
|
|
|
type="text"
|
|
|
|
|
|
placeholder="name thy file"
|
|
|
|
|
|
onChange={(event) => {
|
|
|
|
|
|
if (/\.vl2$/i.test(event.target.value)) {
|
|
|
|
|
|
event.target.value = event.target.value.slice(0, -4);
|
|
|
|
|
|
}
|
|
|
|
|
|
}}
|
|
|
|
|
|
/>
|
|
|
|
|
|
</div>
|
|
|
|
|
|
<button type="submit" className={styles.DownloadButton}>
|
|
|
|
|
|
Download
|
|
|
|
|
|
</button>
|
|
|
|
|
|
</form>
|
|
|
|
|
|
</footer>
|
|
|
|
|
|
</>
|
|
|
|
|
|
);
|
|
|
|
|
|
}
|