Add loading indicator, improve icons, improve layout

This commit is contained in:
Brian Beck 2024-10-23 14:02:29 -10:00
parent 41a3753623
commit fa0b047e19
19 changed files with 560 additions and 795 deletions

View file

@ -24,7 +24,9 @@ body {
flex-direction: column;
margin: 0;
padding: 0;
min-height: 100vh;
height: 100vh;
max-height: 100vh;
overflow: hidden;
background-color: #699697;
background-image: url("../public/noise.png"),
linear-gradient(to bottom, #5be9ee 0%, #1e4172 100%);

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

File diff suppressed because one or more lines are too long

View file

@ -1,6 +1,6 @@
2:I[9330,["51","static/chunks/795d4814-53044e1cf9e42373.js","212","static/chunks/59650de3-d12eb82ab0e85613.js","516","static/chunks/f7333993-8f32bf8e5c1e3f32.js","240","static/chunks/53c13509-17317f399c482fd7.js","699","static/chunks/8e1d74a4-2e60cc37d7b04363.js","634","static/chunks/634-d90cc75f8d4e4c06.js","931","static/chunks/app/page-65a64361088201e6.js"],"Forge"]
3:I[4707,[],""]
4:I[6423,[],""]
0:["mP5hGUGSmu-p0M7gsH5Gb",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{}],[["$","link","0",{"rel":"stylesheet","href":"/vl2-forge/_next/static/css/1febfadc1a5e3d46.css","precedence":"next","crossOrigin":"$undefined"}]]],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/vl2-forge/_next/static/css/9805353de4b13899.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","className":"__variable_6dd175 __variable_60c549 __variable_b97ccf","children":["$","body",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L5",null]]]]
0:["be_WUnb__0cQ-xWVpCmQ8",[[["",{"children":["__PAGE__",{}]},"$undefined","$undefined",true],["",{"children":["__PAGE__",{},[["$L1",["$","$L2",null,{}],[["$","link","0",{"rel":"stylesheet","href":"/vl2-forge/_next/static/css/1febfadc1a5e3d46.css","precedence":"next","crossOrigin":"$undefined"}]]],null],null]},[[[["$","link","0",{"rel":"stylesheet","href":"/vl2-forge/_next/static/css/9805353de4b13899.css","precedence":"next","crossOrigin":"$undefined"}]],["$","html",null,{"lang":"en","className":"__variable_6dd175 __variable_60c549 __variable_b97ccf","children":["$","body",null,{"children":["$","$L3",null,{"parallelRouterKey":"children","segmentPath":["children"],"error":"$undefined","errorStyles":"$undefined","errorScripts":"$undefined","template":["$","$L4",null,{}],"templateStyles":"$undefined","templateScripts":"$undefined","notFound":[["$","title",null,{"children":"404: This page could not be found."}],["$","div",null,{"style":{"fontFamily":"system-ui,\"Segoe UI\",Roboto,Helvetica,Arial,sans-serif,\"Apple Color Emoji\",\"Segoe UI Emoji\"","height":"100vh","textAlign":"center","display":"flex","flexDirection":"column","alignItems":"center","justifyContent":"center"},"children":["$","div",null,{"children":[["$","style",null,{"dangerouslySetInnerHTML":{"__html":"body{color:#000;background:#fff;margin:0}.next-error-h1{border-right:1px solid rgba(0,0,0,.3)}@media (prefers-color-scheme:dark){body{color:#fff;background:#000}.next-error-h1{border-right:1px solid rgba(255,255,255,.3)}}"}}],["$","h1",null,{"className":"next-error-h1","style":{"display":"inline-block","margin":"0 20px 0 0","padding":"0 23px 0 0","fontSize":24,"fontWeight":500,"verticalAlign":"top","lineHeight":"49px"},"children":"404"}],["$","div",null,{"style":{"display":"inline-block"},"children":["$","h2",null,{"style":{"fontSize":14,"fontWeight":400,"lineHeight":"49px","margin":0},"children":"This page could not be found."}]}]]}]}]],"notFoundStyles":[]}]}]}]],null],null],["$L5",null]]]]
5:[["$","meta","0",{"name":"viewport","content":"width=device-width, initial-scale=1"}],["$","meta","1",{"charSet":"utf-8"}],["$","title","2",{"children":"VL2 Forge"}],["$","meta","3",{"name":"description","content":"Create .vl2 files for Tribes 2"}],["$","meta","4",{"name":"next-size-adjust"}]]
1:null

323
package-lock.json generated
View file

@ -7,7 +7,7 @@
"": {
"name": "vl2-forge",
"version": "1.0.0",
"license": "ISC",
"license": "MIT",
"dependencies": {
"file-saver": "^2.0.5",
"jszip": "^3.10.1",
@ -21,7 +21,7 @@
"devDependencies": {
"@types/node": "22.7.5",
"@types/react": "18.3.11",
"tshy": "^3.0.2",
"rimraf": "^6.0.1",
"typescript": "^5.6.3"
}
},
@ -263,20 +263,6 @@
"url": "https://github.com/chalk/ansi-styles?sponsor=1"
}
},
"node_modules/anymatch": {
"version": "3.1.3",
"resolved": "https://registry.npmjs.org/anymatch/-/anymatch-3.1.3.tgz",
"integrity": "sha512-KMReFUr0B4t+D+OBkjR3KYqvocp2XaSzO55UcB6mgQMd3KbcE+mWTyvVV7D/zsdEbNnV6acZUutkiHQXvTr1Rw==",
"dev": true,
"license": "ISC",
"dependencies": {
"normalize-path": "^3.0.0",
"picomatch": "^2.0.4"
},
"engines": {
"node": ">= 8"
}
},
"node_modules/attr-accept": {
"version": "2.2.4",
"resolved": "https://registry.npmjs.org/attr-accept/-/attr-accept-2.2.4.tgz",
@ -293,19 +279,6 @@
"dev": true,
"license": "MIT"
},
"node_modules/binary-extensions": {
"version": "2.3.0",
"resolved": "https://registry.npmjs.org/binary-extensions/-/binary-extensions-2.3.0.tgz",
"integrity": "sha512-Ceh+7ox5qe7LJuLHoY0feh3pHuUDHAcRUeyL2VYghZwfpkNIy/+8Ocg0a3UuSoYzavmylwuLWQOf3hl0jjMMIw==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8"
},
"funding": {
"url": "https://github.com/sponsors/sindresorhus"
}
},
"node_modules/brace-expansion": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/brace-expansion/-/brace-expansion-2.0.1.tgz",
@ -316,19 +289,6 @@
"balanced-match": "^1.0.0"
}
},
"node_modules/braces": {
"version": "3.0.3",
"resolved": "https://registry.npmjs.org/braces/-/braces-3.0.3.tgz",
"integrity": "sha512-yQbXgO/OSZVD2IsiLlro+7Hf6Q18EJrKSEsdoMzKePKXct3gvD8oLcOQdIzGupr5Fj+EDe8gO/lxc1BzfMpxvA==",
"dev": true,
"license": "MIT",
"dependencies": {
"fill-range": "^7.1.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/busboy": {
"version": "1.6.0",
"resolved": "https://registry.npmjs.org/busboy/-/busboy-1.6.0.tgz",
@ -360,44 +320,6 @@
],
"license": "CC-BY-4.0"
},
"node_modules/chalk": {
"version": "5.3.0",
"resolved": "https://registry.npmjs.org/chalk/-/chalk-5.3.0.tgz",
"integrity": "sha512-dLitG79d+GV1Nb/VYcCDFivJeK1hiukt9QjRNVOsUtTy1rR1YJsmpGGTZ3qJos+uw7WmWF4wUwBd9jxjocFC2w==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^12.17.0 || ^14.13 || >=16.0.0"
},
"funding": {
"url": "https://github.com/chalk/chalk?sponsor=1"
}
},
"node_modules/chokidar": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/chokidar/-/chokidar-3.6.0.tgz",
"integrity": "sha512-7VT13fmjotKpGipCW9JEQAusEPE+Ei8nl6/g4FBAmIm0GOOLMua9NDDo/DWp0ZAxCr3cPq5ZpBqmPAQgDda2Pw==",
"dev": true,
"license": "MIT",
"dependencies": {
"anymatch": "~3.1.2",
"braces": "~3.0.2",
"glob-parent": "~5.1.2",
"is-binary-path": "~2.1.0",
"is-glob": "~4.0.1",
"normalize-path": "~3.0.0",
"readdirp": "~3.6.0"
},
"engines": {
"node": ">= 8.10.0"
},
"funding": {
"url": "https://paulmillr.com/funding/"
},
"optionalDependencies": {
"fsevents": "~2.3.2"
}
},
"node_modules/client-only": {
"version": "0.0.1",
"resolved": "https://registry.npmjs.org/client-only/-/client-only-0.0.1.tgz",
@ -484,19 +406,6 @@
"node": ">= 12"
}
},
"node_modules/fill-range": {
"version": "7.1.1",
"resolved": "https://registry.npmjs.org/fill-range/-/fill-range-7.1.1.tgz",
"integrity": "sha512-YsGpe3WHLK8ZYi4tWDg2Jy3ebRz2rXowDxnld4bkQB00cc/1Zw9AWnC0i9ztDJitivtQvaI9KaLyKrc+hBW0yg==",
"dev": true,
"license": "MIT",
"dependencies": {
"to-regex-range": "^5.0.1"
},
"engines": {
"node": ">=8"
}
},
"node_modules/foreground-child": {
"version": "3.3.0",
"resolved": "https://registry.npmjs.org/foreground-child/-/foreground-child-3.3.0.tgz",
@ -514,21 +423,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/fsevents": {
"version": "2.3.3",
"resolved": "https://registry.npmjs.org/fsevents/-/fsevents-2.3.3.tgz",
"integrity": "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw==",
"dev": true,
"hasInstallScript": true,
"license": "MIT",
"optional": true,
"os": [
"darwin"
],
"engines": {
"node": "^8.16.0 || ^10.6.0 || >=11.0.0"
}
},
"node_modules/glob": {
"version": "11.0.0",
"resolved": "https://registry.npmjs.org/glob/-/glob-11.0.0.tgz",
@ -553,19 +447,6 @@
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/glob-parent": {
"version": "5.1.2",
"resolved": "https://registry.npmjs.org/glob-parent/-/glob-parent-5.1.2.tgz",
"integrity": "sha512-AOIgSQCepiJYwP3ARnGx+5VnTu2HBYdzbGP45eLw1vr3zB3vZLeyed1sC9hnbcOc9/SrMyM5RPQrkGz4aS9Zow==",
"dev": true,
"license": "ISC",
"dependencies": {
"is-glob": "^4.0.1"
},
"engines": {
"node": ">= 6"
}
},
"node_modules/graceful-fs": {
"version": "4.2.11",
"resolved": "https://registry.npmjs.org/graceful-fs/-/graceful-fs-4.2.11.tgz",
@ -584,29 +465,6 @@
"integrity": "sha512-k/vGaX4/Yla3WzyMCvTQOXYeIHvqOKtnqBduzTHpzpQZzAskKMhZ2K+EnBiSM9zGSoIFeMpXKxa4dYeZIQqewQ==",
"license": "ISC"
},
"node_modules/is-binary-path": {
"version": "2.1.0",
"resolved": "https://registry.npmjs.org/is-binary-path/-/is-binary-path-2.1.0.tgz",
"integrity": "sha512-ZMERYes6pDydyuGidse7OsHxtbI7WVeUEozgR/g7rd0xUimYNlvZRE/K2MgZTjWy725IfelLeVcEM97mmtRGXw==",
"dev": true,
"license": "MIT",
"dependencies": {
"binary-extensions": "^2.0.0"
},
"engines": {
"node": ">=8"
}
},
"node_modules/is-extglob": {
"version": "2.1.1",
"resolved": "https://registry.npmjs.org/is-extglob/-/is-extglob-2.1.1.tgz",
"integrity": "sha512-SbKbANkN603Vi4jEZv49LeVJMn4yGwsbzZworEoyEiutsN3nJYdbO36zfhGJ6QEDpOZIFkDtnq5JRxmvl3jsoQ==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-fullwidth-code-point": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/is-fullwidth-code-point/-/is-fullwidth-code-point-3.0.0.tgz",
@ -617,29 +475,6 @@
"node": ">=8"
}
},
"node_modules/is-glob": {
"version": "4.0.3",
"resolved": "https://registry.npmjs.org/is-glob/-/is-glob-4.0.3.tgz",
"integrity": "sha512-xelSayHH36ZgE7ZWhli7pW34hNbNl8Ojv5KVmkJD4hBdD3th8Tfk9vYasLM+mXWOZhFkgZfxhLSnrwRr4elSSg==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-extglob": "^2.1.1"
},
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/is-number": {
"version": "7.0.0",
"resolved": "https://registry.npmjs.org/is-number/-/is-number-7.0.0.tgz",
"integrity": "sha512-41Cifkg6e8TylSpdtTpeLVMqvSBEVzTttHvERD741+pnZ8ANv0004MRL43QKPDlK9cGvNp6NZWZUBlbGXYxxng==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.12.0"
}
},
"node_modules/isarray": {
"version": "1.0.0",
"resolved": "https://registry.npmjs.org/isarray/-/isarray-1.0.0.tgz",
@ -750,22 +585,6 @@
"node": ">=16 || 14 >=14.17"
}
},
"node_modules/mkdirp": {
"version": "3.0.1",
"resolved": "https://registry.npmjs.org/mkdirp/-/mkdirp-3.0.1.tgz",
"integrity": "sha512-+NsyUUAZDmo6YVHzL/stxSu3t9YS1iljliy3BSDrXJ/dkn1KYdmtZODGGjLcc9XLgVVpH4KshHB8XmZgMhaBXg==",
"dev": true,
"license": "MIT",
"bin": {
"mkdirp": "dist/cjs/src/bin.js"
},
"engines": {
"node": ">=10"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/nanoid": {
"version": "3.3.7",
"resolved": "https://registry.npmjs.org/nanoid/-/nanoid-3.3.7.tgz",
@ -834,16 +653,6 @@
}
}
},
"node_modules/normalize-path": {
"version": "3.0.0",
"resolved": "https://registry.npmjs.org/normalize-path/-/normalize-path-3.0.0.tgz",
"integrity": "sha512-6eZs5Ls3WtCisHWp9S2GUy8dqkpGi4BVSz3GaqiE6ezub0512ESztXUwUB6C6IKbQkY2Pnb/mD4WYojCRwcwLA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=0.10.0"
}
},
"node_modules/object-assign": {
"version": "4.1.1",
"resolved": "https://registry.npmjs.org/object-assign/-/object-assign-4.1.1.tgz",
@ -899,32 +708,6 @@
"integrity": "sha512-TQ92mBOW0l3LeMeyLV6mzy/kWr8lkd/hp3mTg7wYK7zJhuBStmGMBG0BdeDZS/dZx1IukaX6Bk11zcln25o1Aw==",
"license": "ISC"
},
"node_modules/picomatch": {
"version": "2.3.1",
"resolved": "https://registry.npmjs.org/picomatch/-/picomatch-2.3.1.tgz",
"integrity": "sha512-JU3teHTNjmE2VCGFzuY8EXzCDVwEqB2a8fsIvwaStHhAWJEeVd1o1QD80CU6+ZdEXXSLbSsuLwJjkCBWqRQUVA==",
"dev": true,
"license": "MIT",
"engines": {
"node": ">=8.6"
},
"funding": {
"url": "https://github.com/sponsors/jonschlinkert"
}
},
"node_modules/polite-json": {
"version": "5.0.0",
"resolved": "https://registry.npmjs.org/polite-json/-/polite-json-5.0.0.tgz",
"integrity": "sha512-OLS/0XeUAcE8a2fdwemNja+udKgXNnY6yKVIXqAD2zVRx1KvY6Ato/rZ2vdzbxqYwPW0u6SCNC/bAMPNzpzxbw==",
"dev": true,
"license": "MIT",
"engines": {
"node": "^14.17.0 || ^16.13.0 || >=18.0.0"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/postcss": {
"version": "8.4.31",
"resolved": "https://registry.npmjs.org/postcss/-/postcss-8.4.31.tgz",
@ -1042,36 +825,6 @@
"util-deprecate": "~1.0.1"
}
},
"node_modules/readdirp": {
"version": "3.6.0",
"resolved": "https://registry.npmjs.org/readdirp/-/readdirp-3.6.0.tgz",
"integrity": "sha512-hOS089on8RduqdbhvQ5Z37A0ESjsqz6qnRcffsMU3495FuTdqSm+7bhJ29JvIOsBDEEnan5DPu9t3To9VRlMzA==",
"dev": true,
"license": "MIT",
"dependencies": {
"picomatch": "^2.2.1"
},
"engines": {
"node": ">=8.10.0"
}
},
"node_modules/resolve-import": {
"version": "2.0.0",
"resolved": "https://registry.npmjs.org/resolve-import/-/resolve-import-2.0.0.tgz",
"integrity": "sha512-jpKjLibLuc8D1XEV2+7zb0aqN7I8d12u89g/v6IsgCzdVlccMQJq4TKkPw5fbhHdxhm7nbVtN+KvOTnjFf+nEA==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^11.0.0",
"walk-up-path": "^4.0.0"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/rimraf": {
"version": "6.0.1",
"resolved": "https://registry.npmjs.org/rimraf/-/rimraf-6.0.1.tgz",
@ -1302,68 +1055,6 @@
}
}
},
"node_modules/sync-content": {
"version": "2.0.1",
"resolved": "https://registry.npmjs.org/sync-content/-/sync-content-2.0.1.tgz",
"integrity": "sha512-NI1mo514yFhr8pV/5Etvgh+pSBUIpoAKoiBIUwALVlQQNAwb40bTw8hhPFaip/dvv0GhpHVOq0vq8iY02ppLTg==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"glob": "^11.0.0",
"mkdirp": "^3.0.1",
"path-scurry": "^2.0.0",
"rimraf": "^6.0.0",
"tshy": "^3.0.0"
},
"bin": {
"sync-content": "dist/esm/bin.mjs"
},
"engines": {
"node": "20 || >=22"
},
"funding": {
"url": "https://github.com/sponsors/isaacs"
}
},
"node_modules/to-regex-range": {
"version": "5.0.1",
"resolved": "https://registry.npmjs.org/to-regex-range/-/to-regex-range-5.0.1.tgz",
"integrity": "sha512-65P7iz6X5yEr1cwcgvQxbbIw7Uk3gOy5dIdtZ4rDveLqhrdJP+Li/Hx6tyK0NEb+2GCyneCMJiGqrADCSNk8sQ==",
"dev": true,
"license": "MIT",
"dependencies": {
"is-number": "^7.0.0"
},
"engines": {
"node": ">=8.0"
}
},
"node_modules/tshy": {
"version": "3.0.2",
"resolved": "https://registry.npmjs.org/tshy/-/tshy-3.0.2.tgz",
"integrity": "sha512-8GkWnAfmNXxl8iDTZ1o2H4jdaj9H7HeDKkr5qd0ZhQBCNA41D3xqTyg2Ycs51VCfmjJ5e+0v9AUmD6ylAI9Bgw==",
"dev": true,
"license": "BlueOak-1.0.0",
"dependencies": {
"chalk": "^5.3.0",
"chokidar": "^3.6.0",
"foreground-child": "^3.1.1",
"minimatch": "^10.0.0",
"mkdirp": "^3.0.1",
"polite-json": "^5.0.0",
"resolve-import": "^2.0.0",
"rimraf": "^6.0.0",
"sync-content": "^2.0.1",
"typescript": "^5.5.3",
"walk-up-path": "^4.0.0"
},
"bin": {
"tshy": "dist/esm/index.js"
},
"engines": {
"node": "20 || >=22"
}
},
"node_modules/tslib": {
"version": "2.7.0",
"resolved": "https://registry.npmjs.org/tslib/-/tslib-2.7.0.tgz",
@ -1397,16 +1088,6 @@
"integrity": "sha512-EPD5q1uXyFxJpCrLnCc1nHnq3gOa6DZBocAIiI2TaSCA7VCJ1UJDMagCzIkXNsUYfD1daK//LTEQ8xiIbrHtcw==",
"license": "MIT"
},
"node_modules/walk-up-path": {
"version": "4.0.0",
"resolved": "https://registry.npmjs.org/walk-up-path/-/walk-up-path-4.0.0.tgz",
"integrity": "sha512-3hu+tD8YzSLGuFYtPRb48vdhKMi0KQV5sn+uWr8+7dMEq/2G/dtLrdDinkLjqq5TIbIBjYJ4Ax/n3YiaW7QM8A==",
"dev": true,
"license": "ISC",
"engines": {
"node": "20 || >=22"
}
},
"node_modules/which": {
"version": "2.0.2",
"resolved": "https://registry.npmjs.org/which/-/which-2.0.2.tgz",

View file

@ -8,8 +8,11 @@
"main": "index.js",
"scripts": {
"dev": "next dev",
"build": "npm run build:pages",
"build:pages": "next build && touch docs/.nojekyll"
"build": "next build && touch docs/.nojekyll",
"clean": "rimraf .next docs",
"deploy": "npm run build && git add -f docs && git commit -m \"Deploy\" && git push",
"prebuild": "npm run clean",
"start": "next dev"
},
"dependencies": {
"file-saver": "^2.0.5",
@ -24,7 +27,7 @@
"devDependencies": {
"@types/node": "22.7.5",
"@types/react": "18.3.11",
"tshy": "^3.0.2",
"rimraf": "^6.0.1",
"typescript": "^5.6.3"
}
}

BIN
public/fire1.gif Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 119 KiB

View file

@ -0,0 +1,76 @@
.DownloadForm {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
margin: 0 auto;
}
.NameInput {
position: relative;
font-size: 18px;
font-family: var(--font-lora);
font-weight: 500;
font-style: italic;
line-height: 1.25;
}
.NameInput input {
--dark-bevel: rgb(37, 34, 29);
--light-bevel: rgb(108, 101, 88);
display: block;
min-width: 240px;
max-width: 100%;
min-height: 46px;
background: #fff;
border-top: 3px solid var(--dark-bevel);
border-left: 3px solid var(--dark-bevel);
border-bottom: 3px solid var(--light-bevel);
border-right: 3px solid var(--light-bevel);
color: #333;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
font-style: inherit;
line-height: inherit;
padding: 2px 50px 2px 0;
text-indent: 10px;
}
.NameInput::after {
display: grid;
place-content: center;
content: ".vl2";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 50px;
color: #999;
}
.DownloadButton {
border: 0;
margin: 0;
padding: 0 12px 0 12px;
min-width: 128px;
min-height: 46px;
font-family: var(--font-alagard), monospace;
font-size: 24px;
font-style: normal;
font-weight: normal;
line-height: 1;
border-radius: 2px;
background: url("../public/button.png") transparent;
background-repeat: no-repeat;
background-size: 150% 190%;
background-position: 50% 50%;
border-top: 3px solid rgba(255, 145, 105, 0.5);
border-left: 3px solid rgba(255, 178, 150, 0.5);
border-right: 3px solid rgba(36, 14, 14, 0.7);
border-bottom: 3px solid rgba(36, 14, 14, 0.7);
color: #ffffff;
text-shadow: 0 0 5px rgba(255, 226, 82, 0.358), 2px 3px 0 rgba(0, 0, 0, 0.5);
cursor: pointer;
}

41
src/DownloadForm.tsx Normal file
View file

@ -0,0 +1,41 @@
import { createZipFile, saveZipFile } from "./utils";
import styles from "./DownloadForm.module.css";
export function DownloadForm({ fileList }) {
return (
<form
className={styles.DownloadForm}
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>
);
}

50
src/FileItem.module.css Normal file
View file

@ -0,0 +1,50 @@
.File {
display: flex;
align-items: center;
gap: 12px;
}
.IconContainer {
flex: 0 0 auto;
display: grid;
place-content: center;
width: 24px;
font-size: 22px;
color: rgba(255, 255, 255, 0.9);
}
.PreviewIcon {
width: auto;
height: auto;
max-width: 100%;
max-height: 24px;
border: 1px solid black;
}
.Path {
background: rgba(0, 0, 0, 0.3);
padding: 3px 8px;
border-radius: 8px;
user-select: none;
}
.DeleteButton {
flex: 0 0 auto;
border: 0;
background: transparent;
display: grid;
place-content: center;
font-size: 24px;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.DeleteButton:hover {
color: rgb(255, 89, 89);
}
.DeleteButton svg {
pointer-events: none;
}

73
src/FileItem.tsx Normal file
View file

@ -0,0 +1,73 @@
import { useMemo } from "react";
import { FaTrashAlt } from "react-icons/fa";
import { MdTerrain } from "react-icons/md";
import { PiCastleTurretFill } from "react-icons/pi";
import { LuScrollText } from "react-icons/lu";
import { FaMapPin } from "react-icons/fa6";
import { HiMiniSpeakerWave } from "react-icons/hi2";
import { GrTask } from "react-icons/gr";
import styles from "./FileItem.module.css";
export function FileItem({ file, onDelete, onRename }) {
const icon = useMemo(() => {
if (file.dataUri && file.type?.genericType === "image") {
return (
<img
className={styles.PreviewIcon}
src={file.dataUri}
width={24}
alt=""
/>
);
} else if (file.type?.genericType === "audio") {
return <HiMiniSpeakerWave />;
} else if (/\.cs$/i.test(file.path)) {
return <LuScrollText />;
} else if (/\.mis$/i.test(file.path)) {
return <GrTask />;
} else if (/\.dif$/i.test(file.path)) {
return <PiCastleTurretFill />;
} else if (/\.ter$/i.test(file.path)) {
return <MdTerrain />;
} else if (/\.spn$/i.test(file.path)) {
return <FaMapPin />;
}
return null;
}, [file]);
return (
<div className={styles.File}>
<span className={styles.IconContainer}>{icon}</span>{" "}
<span
className={styles.Path}
onDoubleClick={() => {
let newPath = window.prompt(`Rename file (${file.path}):`, file.path);
if (newPath) {
newPath = newPath
.trim()
.replace(/\/+/g, "/")
.replace(/^\//, "")
.replace(/\/$/, "")
.trim();
if (newPath && newPath !== file.path) {
onRename(file.path, newPath);
}
}
}}
>
{file.path}
</span>
<button
className={styles.DeleteButton}
type="button"
aria-label="Delete"
title="Delete"
onClick={(event) => {
onDelete(file.path);
}}
>
<FaTrashAlt />
</button>
</div>
);
}

View file

@ -1,24 +1,25 @@
.Forge {
flex: 1 0 auto;
flex: 1 1 100%;
display: grid;
grid-template-columns: auto 1fr;
grid-template-rows: 1fr;
grid-template-rows: 1fr auto;
align-content: stretch;
font-family: var(--font-departure), monospace;
color: #fff;
text-shadow: 2px 1px 0 rgba(0, 0, 0, 1);
padding: 20px;
gap: 20px;
overflow: auto;
}
.Footer {
position: relative;
grid-column: span 2;
display: flex;
flex-wrap: wrap;
align-items: center;
justify-content: center;
gap: 12px;
padding: 40px;
padding: 32px 40px;
background: rgba(59, 55, 49, 0.8);
border-image-slice: 18 26 26 23;
@ -60,84 +61,15 @@
pointer-events: none;
}
.Footer form {
display: flex;
align-items: center;
justify-content: center;
flex-wrap: wrap;
gap: 12px;
margin: 0 auto;
}
.NameInput {
position: relative;
font-size: 18px;
font-family: var(--font-lora);
font-weight: 500;
font-style: italic;
line-height: 1.25;
}
.NameInput input {
display: block;
min-width: 240px;
max-width: 100%;
min-height: 46px;
background: #fff;
color: #333;
font-size: inherit;
font-family: inherit;
font-weight: inherit;
font-style: inherit;
line-height: inherit;
padding: 2px 50px 2px 0;
text-indent: 10px;
}
.NameInput::after {
display: grid;
place-content: center;
content: ".vl2";
position: absolute;
top: 0;
right: 0;
bottom: 0;
width: 50px;
color: #999;
}
.DownloadButton {
border: 0;
margin: 0;
padding: 0 12px 0 12px;
min-width: 128px;
min-height: 46px;
font-family: var(--font-alagard), monospace;
font-size: 24px;
font-style: normal;
font-weight: normal;
line-height: 1;
border-radius: 2px;
background: url("../public/button.png") transparent;
background-repeat: no-repeat;
background-size: 150% 190%;
background-position: 50% 50%;
border-top: 3px solid rgba(255, 145, 105, 0.5);
border-left: 3px solid rgba(255, 178, 150, 0.5);
border-right: 3px solid rgba(36, 14, 14, 0.7);
border-bottom: 3px solid rgba(36, 14, 14, 0.7);
color: #ffffff;
text-shadow: 0 0 5px rgba(255, 226, 82, 0.358), 2px 3px 0 rgba(0, 0, 0, 0.5);
cursor: pointer;
}
.ListArea {
display: flex;
position: relative;
display: grid;
align-items: center;
justify-content: center;
justify-content: stretch;
padding: 32px;
background: rgba(2, 2, 84, 0.252);
border-radius: 12px;
overflow: auto;
}
.FileList {
@ -148,10 +80,10 @@
margin: 0;
padding: 0;
list-style: none;
text-shadow: 2px 1px 0 rgba(0, 0, 0, 1);
}
.EmptyMessage {
align-self: center;
text-align: center;
text-wrap: balance;
line-height: 1.75;
@ -191,57 +123,6 @@
gap: 40px;
}
.File {
display: flex;
align-items: center;
gap: 12px;
}
.DeleteButton {
flex: 0 0 auto;
border: 0;
background: transparent;
display: grid;
place-content: center;
font-size: 24px;
margin: 0;
padding: 0;
color: rgba(0, 0, 0, 0.4);
cursor: pointer;
}
.DeleteButton:hover {
color: rgb(255, 89, 89);
}
.DeleteButton svg {
pointer-events: none;
}
.IconContainer {
flex: 0 0 auto;
display: grid;
place-content: center;
width: 24px;
font-size: 22px;
color: rgba(255, 255, 255, 0.9);
}
.PreviewIcon {
width: auto;
height: auto;
max-width: 100%;
max-height: 24px;
border: 1px solid black;
}
.Path {
background: rgba(0, 0, 0, 0.3);
padding: 3px 8px;
border-radius: 8px;
user-select: none;
}
.HeaderLink {
display: none;
margin-left: 0;
@ -268,7 +149,7 @@
@media (max-width: 767px) {
.Forge {
grid-template-columns: 1fr;
grid-template-rows: auto 1fr;
grid-template-rows: auto 1fr auto;
padding: 12px;
gap: 12px;
}
@ -286,6 +167,10 @@
display: grid;
}
.Footer {
grid-column: span 1;
}
.FooterLink {
display: none;
}

View file

@ -1,267 +1,22 @@
"use client";
import { useCallback, useMemo, useState } from "react";
import { useCallback, useMemo, useState, useTransition } from "react";
import { useDropzone } from "react-dropzone";
import JSZip from "jszip";
import { saveAs } from "file-saver";
import orderBy from "lodash.orderby";
import { FaTrashAlt, FaGithub } from "react-icons/fa";
import { MdCastle, MdTerrain } from "react-icons/md";
import { LuScrollText } from "react-icons/lu";
import { FaClipboardList, FaMapPin } from "react-icons/fa6";
import { HiMiniSpeakerWave } from "react-icons/hi2";
import { FaGithub } from "react-icons/fa";
import { Loading } from "./Loading";
import { FileItem } from "./FileItem";
import { DownloadForm } from "./DownloadForm";
import { handleInputFile } from "./utils";
import styles from "./Forge.module.css";
import { base64ArrayBuffer } from "./utils";
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];
}
if (folder) {
return `${folder}/${basename}`;
}
if (
/\.(l|m|h)(male|female|bioderm)\.png$/i.test(basename) ||
/^(vehicle|weapon)_.+png$/i.test(basename) ||
/^dcase\d\d\.png$/i.test(basename)
) {
folder = "textures/skins";
} 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";
}
if (folder) {
return `${folder}/${basename}`;
} else {
return basename;
}
}
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;
}
function FilePreview({ file, onDelete, onRename }) {
let icon = null;
if (file.dataUri && file.type?.genericType === "image") {
icon = (
<img
className={styles.PreviewIcon}
src={file.dataUri}
width={24}
alt=""
/>
);
} else if (file.type?.genericType === "audio") {
icon = <HiMiniSpeakerWave />;
} 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 />;
}
return (
<div className={styles.File}>
<span className={styles.IconContainer}>{icon}</span>{" "}
<span
className={styles.Path}
onDoubleClick={() => {
let newPath = window.prompt(`Rename file (${file.path}):`, file.path);
if (newPath) {
newPath = newPath
.trim()
.replace(/\/+/g, "/")
.replace(/^\//, "")
.replace(/\/$/, "")
.trim();
if (newPath && newPath !== file.path) {
onRename(file.path, newPath);
}
}
}}
>
{file.path}
</span>
<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>();
for (let path in zip.files) {
const fileObj = zip.files[path];
if (!fileObj.dir) {
path = detectBestPath(path);
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;
}
path = detectBestPath(path);
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 [isPending, startTransition] = useTransition();
const [loadingCount, setLoadingCount] = useState(0);
const [actionLog, setActionLog] = useState(() => []);
const [files, setFiles] = useState(() => new Map());
const onDrop = useCallback(async (acceptedFiles) => {
setLoadingCount((count) => count + 1);
const actionLog = [];
const finalMap = new Map();
const allFiles: Array<Map<string, any>> = await Promise.all(
@ -278,13 +33,16 @@ export function Forge() {
finalMap.set(path, file);
});
});
setFiles((prevMap) => {
return new Map([
...Array.from(prevMap.entries()),
...Array.from(finalMap.entries()),
]);
startTransition(() => {
setFiles((prevMap) => {
return new Map([
...Array.from(prevMap.entries()),
...Array.from(finalMap.entries()),
]);
});
setActionLog((prevLog) => [...prevLog, ...actionLog]);
});
setActionLog((prevLog) => [...prevLog, ...actionLog]);
setLoadingCount((count) => count - 1);
}, []);
const { getRootProps, getInputProps, open, isDragActive } = useDropzone({
noClick: true,
@ -331,49 +89,51 @@ export function Forge() {
});
}, []);
const showLoading = isPending || loadingCount > 0;
return (
<>
<section className={styles.Forge} {...getRootProps()}>
<header className={styles.Header}>
<a
className={styles.HeaderLink}
href="https://github.com/exogen/vl2-forge"
>
<FaGithub aria-label="GitHub" />
</a>
<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}>
<FilePreview
file={file}
onDelete={handleDelete}
onRename={handleRename}
/>
</li>
);
})}
</ul>
) : (
<div className={styles.EmptyMessage}>
Drop files onto the page or press the add button. No need to
extract existing .vl2 files first  just drop em in and itll do
that for you!
</div>
)}
</div>
</section>
<section className={styles.Forge} {...getRootProps()}>
<header className={styles.Header}>
<a
className={styles.HeaderLink}
href="https://github.com/exogen/vl2-forge"
>
<FaGithub aria-label="GitHub" />
</a>
<img
className={styles.Logo}
width={210}
height={188}
src="/vl2-forge/logo-md.png"
alt="VL2 Forge"
/>
{addButton}
</header>
<input {...getInputProps()} />
<div className={styles.ListArea}>
{showLoading ? <Loading /> : null}
{fileList.length ? (
<ul className={styles.FileList}>
{fileList.map((file) => {
return (
<li key={file.path}>
<FileItem
file={file}
onDelete={handleDelete}
onRename={handleRename}
/>
</li>
);
})}
</ul>
) : showLoading ? null : (
<div className={styles.EmptyMessage}>
Drop files onto the page or press the add button. No need to extract
existing .vl2 files first  just drop em in and itll take care of
that!
</div>
)}
</div>
<footer className={styles.Footer}>
<a
className={styles.FooterLink}
@ -381,40 +141,8 @@ export function Forge() {
>
<FaGithub aria-label="GitHub" />
</a>
<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>
<DownloadForm fileList={fileList} />
</footer>
</>
</section>
);
}

30
src/Loading.module.css Normal file
View file

@ -0,0 +1,30 @@
.LoadingBackdrop {
display: flex;
align-items: stretch;
position: absolute;
top: 50%;
left: 50%;
width: 400px;
height: 120px;
transform: translate(-50%, -50%);
background: url("../public/fire1.gif");
background-repeat: no-repeat;
background-size: 100% 100%;
border-radius: 10px;
z-index: 2;
text-align: center;
overflow: hidden;
}
.LoadingText {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
color: rgb(232, 194, 154);
background: rgba(0, 0, 0, 0.95);
mix-blend-mode: hard-light;
font-family: var(--font-alagard);
font-size: 40px;
text-shadow: 0 -3px 10px rgb(251, 82, 30);
}

9
src/Loading.tsx Normal file
View file

@ -0,0 +1,9 @@
import styles from "./Loading.module.css";
export function Loading() {
return (
<div className={styles.LoadingBackdrop}>
<div className={styles.LoadingText}>Loading</div>
</div>
);
}

View file

@ -1,3 +1,21 @@
import JSZip from "jszip";
import { saveAs } from "file-saver";
export type FileType = {
mimeType: string;
genericType: string | null;
};
export type FileEntry = {
path: string;
buffer: ArrayBuffer;
dataUri: string | null;
date: Date | null;
unixPermissions: string | number | null;
dosPermissions: number | null;
type: FileType | null;
};
// Converts an ArrayBuffer directly to base64, without any intermediate 'convert to string then
// use window.btoa' step. According to my tests, this appears to be a faster approach:
// http://jsperf.com/encoding-xhr-image-data/5
@ -47,3 +65,172 @@ export function base64ArrayBuffer(arrayBuffer) {
}
return base64;
}
export 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];
}
if (folder) {
return `${folder}/${basename}`;
}
if (
/\.(l|m|h)(male|female|bioderm)\.png$/i.test(basename) ||
/^(vehicle|weapon)_.+png$/i.test(basename) ||
/^dcase\d\d\.png$/i.test(basename)
) {
folder = "textures/skins";
} 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";
}
if (folder) {
return `${folder}/${basename}`;
} else {
return basename;
}
}
export 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;
}
export async function handleZipFile(file) {
const zip = await JSZip.loadAsync(file);
const map = new Map<string, FileEntry>();
for (let path in zip.files) {
const fileObj = zip.files[path];
if (!fileObj.dir) {
path = detectBestPath(path);
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;
}
export 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;
}
path = detectBestPath(path);
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;
}
export 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);
}