Switch mods to per-folder auto-discovery and resourcepack to single zip
- PackDefinition: replace mods[]/resourcepacks[] with modsFolder (string) + resourcepackPath (string); drop PackAsset - Editor: replace dynamic add/remove lists with two single inputs; remove the now-dead JS for adding/removing rows - Server: expose GET /file/mods/<folder>/index.json that returns the list of .jar names; folder name restricted to [a-zA-Z0-9_-]+ - Installer: fetch the listing JSON and download each jar from /file/mods/<folder>/<file>.jar; download the single resourcepack from /file/resourcepacks/<file>.zip directly into resourcepacks/ Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
This commit is contained in:
@@ -5,18 +5,8 @@
|
|||||||
"type": "forge",
|
"type": "forge",
|
||||||
"downloadUrl": "/forge-installer.jar"
|
"downloadUrl": "/forge-installer.jar"
|
||||||
},
|
},
|
||||||
"mods": [
|
"modsFolder": "music-quiz",
|
||||||
{
|
"resourcepackPath": "music-quiz.zip",
|
||||||
"name": "ExampleMod",
|
|
||||||
"downloadUrl": "https://example.com/examplemod.jar"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"resourcepacks": [
|
|
||||||
{
|
|
||||||
"name": "ExampleResourcePack",
|
|
||||||
"downloadUrl": "https://example.com/resourcepack.zip"
|
|
||||||
}
|
|
||||||
],
|
|
||||||
"serverMinRam": 2048,
|
"serverMinRam": 2048,
|
||||||
"serverMaxRam": 8192,
|
"serverMaxRam": 8192,
|
||||||
"clientMinRam": 4096,
|
"clientMinRam": 4096,
|
||||||
|
|||||||
@@ -246,6 +246,43 @@ async function downloadMapZip(pack: PackDefinition, customRoot: string): Promise
|
|||||||
await downloadAndExtractZip(url, '맵', savesDir)
|
await downloadAndExtractZip(url, '맵', savesDir)
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function downloadModsFolder(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||||
|
if (!pack.modsFolder) {
|
||||||
|
sendLog('modsFolder가 비어 있어 모드 다운로드를 건너뜁니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const indexUrl = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/index.json`
|
||||||
|
sendLog(`모드 목록 조회: ${indexUrl}`)
|
||||||
|
const listing = await fetchJson<{ files?: unknown }>(indexUrl)
|
||||||
|
const files = Array.isArray(listing.files)
|
||||||
|
? listing.files.filter((name): name is string => typeof name === 'string' && /\.jar$/i.test(name))
|
||||||
|
: []
|
||||||
|
if (files.length === 0) {
|
||||||
|
sendLog(`/file/mods/${pack.modsFolder}/ 안에 .jar 파일이 없습니다.`)
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const modsDir = path.join(customRoot, 'mods')
|
||||||
|
await fsp.mkdir(modsDir, { recursive: true })
|
||||||
|
for (const fileName of files) {
|
||||||
|
const url = `${state.baseUrl}/file/mods/${encodeURIComponent(pack.modsFolder)}/${encodeURIComponent(fileName)}`
|
||||||
|
const target = path.join(modsDir, fileName)
|
||||||
|
sendLog(`모드 다운로드: ${fileName}`)
|
||||||
|
await downloadFile(url, target)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function downloadResourcepackZip(pack: PackDefinition, customRoot: string): Promise<void> {
|
||||||
|
if (!pack.resourcepackPath) {
|
||||||
|
sendLog('resourcepackPath가 비어 있어 리소스팩 다운로드를 건너뜁니다.')
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const url = `${state.baseUrl}/file/resourcepacks/${pack.resourcepackPath.replace(/^\/+/, '')}`
|
||||||
|
const target = path.join(customRoot, 'resourcepacks', pack.resourcepackPath.replace(/^\/+/, ''))
|
||||||
|
await fsp.mkdir(path.dirname(target), { recursive: true })
|
||||||
|
sendLog(`리소스팩 다운로드: ${url}`)
|
||||||
|
await downloadFile(url, target)
|
||||||
|
}
|
||||||
|
|
||||||
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
ipcMain.handle('server:install', async (_event, payload: ServerInstallPayload) => {
|
||||||
const pack = state.packs.get(payload.packKey)
|
const pack = state.packs.get(payload.packKey)
|
||||||
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
if (!pack) throw new Error('음악퀴즈를 찾을 수 없습니다.')
|
||||||
@@ -496,18 +533,8 @@ ipcMain.handle('client:install', async (_event, payload: ClientInstallPayload) =
|
|||||||
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
sendLog('플랫폼 설치 건너뜀. 바닐라로 진행합니다.')
|
||||||
}
|
}
|
||||||
|
|
||||||
for (const mod of pack.pack.mods) {
|
await downloadModsFolder(pack.pack, customRoot)
|
||||||
if (!mod.downloadUrl) continue
|
await downloadResourcepackZip(pack.pack, customRoot)
|
||||||
const target = path.join(customRoot, 'mods', deriveFileName(mod.downloadUrl) || `${mod.name}.jar`)
|
|
||||||
sendLog(`모드 다운로드: ${mod.name}`)
|
|
||||||
await downloadFile(mod.downloadUrl, target)
|
|
||||||
}
|
|
||||||
for (const resourcePack of pack.pack.resourcepacks) {
|
|
||||||
if (!resourcePack.downloadUrl) continue
|
|
||||||
const target = path.join(customRoot, 'resourcepacks', deriveFileName(resourcePack.downloadUrl) || `${resourcePack.name}.zip`)
|
|
||||||
sendLog(`리소스팩 다운로드: ${resourcePack.name}`)
|
|
||||||
await downloadFile(resourcePack.downloadUrl, target)
|
|
||||||
}
|
|
||||||
|
|
||||||
await downloadMapZip(pack.pack, customRoot)
|
await downloadMapZip(pack.pack, customRoot)
|
||||||
|
|
||||||
|
|||||||
@@ -1,6 +1,7 @@
|
|||||||
import express from 'express'
|
import express from 'express'
|
||||||
import session from 'express-session'
|
import session from 'express-session'
|
||||||
import path from 'node:path'
|
import path from 'node:path'
|
||||||
|
import fsp from 'node:fs/promises'
|
||||||
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
import { manifestRootPath, manifestDirPath, fileDirPath, viewsDirPath, publicDirPath } from '../shared/paths'
|
||||||
import { indexRouter } from './routes/index'
|
import { indexRouter } from './routes/index'
|
||||||
import { opRouter } from './routes/op'
|
import { opRouter } from './routes/op'
|
||||||
@@ -69,6 +70,30 @@ app.use((req, res, next) => {
|
|||||||
next()
|
next()
|
||||||
})
|
})
|
||||||
|
|
||||||
|
// 모드 폴더 안의 .jar 파일 목록을 JSON으로 반환. 설치기가 자동 다운로드용으로 사용.
|
||||||
|
app.get('/file/mods/:folder/index.json', async (req, res) => {
|
||||||
|
const folder = req.params.folder
|
||||||
|
if (!/^[a-zA-Z0-9_\-]+$/.test(folder)) {
|
||||||
|
res.status(404).json({ files: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
const dir = path.join(fileDirPath, 'mods', folder)
|
||||||
|
try {
|
||||||
|
const entries = await fsp.readdir(dir)
|
||||||
|
const files = entries
|
||||||
|
.filter((name) => /\.jar$/i.test(name))
|
||||||
|
.filter((name) => !name.includes('/') && !name.includes('\\'))
|
||||||
|
.sort()
|
||||||
|
res.json({ files })
|
||||||
|
} catch (error) {
|
||||||
|
if ((error as NodeJS.ErrnoException).code === 'ENOENT') {
|
||||||
|
res.status(404).json({ files: [] })
|
||||||
|
return
|
||||||
|
}
|
||||||
|
throw error
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
|
app.use('/file', express.static(fileDirPath, { fallthrough: true, index: false }))
|
||||||
|
|
||||||
app.use('/', indexRouter)
|
app.use('/', indexRouter)
|
||||||
|
|||||||
@@ -122,14 +122,6 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
|||||||
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
const packKey = sanitizePackKey(pickFirstValue(req.params.packName))
|
||||||
const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey
|
const requestedKey = sanitizePackKey(pickFirstValue(req.body.fileName)) || packKey
|
||||||
|
|
||||||
const modNames = pickStringArray(req.body['modName']).map((value) => value.trim())
|
|
||||||
const modUrls = pickStringArray(req.body['modUrl']).map((value) => value.trim())
|
|
||||||
const mods = modNames.map((name, index) => ({ name, downloadUrl: modUrls[index] ?? '' }))
|
|
||||||
|
|
||||||
const resourceNames = pickStringArray(req.body['resourceName']).map((value) => value.trim())
|
|
||||||
const resourceUrls = pickStringArray(req.body['resourceUrl']).map((value) => value.trim())
|
|
||||||
const resourcepacks = resourceNames.map((name, index) => ({ name, downloadUrl: resourceUrls[index] ?? '' }))
|
|
||||||
|
|
||||||
const platformType = pickFirstValue(req.body.platformType)
|
const platformType = pickFirstValue(req.body.platformType)
|
||||||
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
const platformDownloadUrl = pickFirstValue(req.body.platformDownloadUrl).trim()
|
||||||
|
|
||||||
@@ -140,8 +132,8 @@ opRouter.post('/op/dashboard/:packName', requireAuth, async (req, res, next) =>
|
|||||||
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
type: (platformType as PackDefinition['platform']['type']) || 'vanilla',
|
||||||
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
downloadUrl: platformDownloadUrl.length > 0 ? platformDownloadUrl : undefined
|
||||||
},
|
},
|
||||||
mods,
|
modsFolder: pickFirstValue(req.body.modsFolder),
|
||||||
resourcepacks,
|
resourcepackPath: pickFirstValue(req.body.resourcepackPath),
|
||||||
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
serverMinRam: Number(pickFirstValue(req.body.serverMinRam)),
|
||||||
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
serverMaxRam: Number(pickFirstValue(req.body.serverMaxRam)),
|
||||||
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
clientMinRam: Number(pickFirstValue(req.body.clientMinRam)),
|
||||||
|
|||||||
@@ -32,8 +32,8 @@ export function defaultPackDefinition(name: string): PackDefinition {
|
|||||||
name,
|
name,
|
||||||
mcVersion: '1.20.1',
|
mcVersion: '1.20.1',
|
||||||
platform: { type: 'vanilla' },
|
platform: { type: 'vanilla' },
|
||||||
mods: [],
|
modsFolder: '',
|
||||||
resourcepacks: [],
|
resourcepackPath: '',
|
||||||
serverMinRam: 2048,
|
serverMinRam: 2048,
|
||||||
serverMaxRam: 4096,
|
serverMaxRam: 4096,
|
||||||
clientMinRam: 2048,
|
clientMinRam: 2048,
|
||||||
@@ -53,6 +53,15 @@ function sanitizeZipFileName(input: unknown): string {
|
|||||||
return trimmed
|
return trimmed
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// 모드 폴더명: 영문/숫자/언더스코어/하이픈만 허용. 빈 값 허용.
|
||||||
|
function sanitizeFolderName(input: unknown): string {
|
||||||
|
if (typeof input !== 'string') return ''
|
||||||
|
const trimmed = input.trim().replace(/^\/+|\/+$/g, '')
|
||||||
|
if (trimmed.length === 0) return ''
|
||||||
|
if (!/^[a-zA-Z0-9_\-]+$/.test(trimmed)) return ''
|
||||||
|
return trimmed
|
||||||
|
}
|
||||||
|
|
||||||
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
const ALLOWED_LOADERS: LoaderType[] = ['vanilla', 'forge', 'fabric', 'neoforge']
|
||||||
|
|
||||||
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
export function normalizePackDefinition(input: Partial<PackDefinition> & Record<string, unknown>): PackDefinition {
|
||||||
@@ -62,28 +71,6 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
|||||||
? (platform.type as LoaderType)
|
? (platform.type as LoaderType)
|
||||||
: 'vanilla'
|
: 'vanilla'
|
||||||
|
|
||||||
const modsSource = Array.isArray(input.mods) ? input.mods : []
|
|
||||||
const mods = modsSource
|
|
||||||
.map((entry) => {
|
|
||||||
const value = entry as Partial<PackDefinition['mods'][number]>
|
|
||||||
return {
|
|
||||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
|
||||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
|
||||||
|
|
||||||
const resourcePacksSource = Array.isArray(input.resourcepacks) ? input.resourcepacks : []
|
|
||||||
const resourcepacks = resourcePacksSource
|
|
||||||
.map((entry) => {
|
|
||||||
const value = entry as Partial<PackDefinition['resourcepacks'][number]>
|
|
||||||
return {
|
|
||||||
name: typeof value?.name === 'string' ? value.name.trim() : '',
|
|
||||||
downloadUrl: typeof value?.downloadUrl === 'string' ? value.downloadUrl.trim() : ''
|
|
||||||
}
|
|
||||||
})
|
|
||||||
.filter((entry) => entry.name.length > 0 || entry.downloadUrl.length > 0)
|
|
||||||
|
|
||||||
return {
|
return {
|
||||||
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
name: typeof input.name === 'string' && input.name.trim().length > 0 ? input.name.trim() : fallback.name,
|
||||||
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
mcVersion: typeof input.mcVersion === 'string' && input.mcVersion.trim().length > 0
|
||||||
@@ -95,8 +82,8 @@ export function normalizePackDefinition(input: Partial<PackDefinition> & Record<
|
|||||||
? platform.downloadUrl.trim()
|
? platform.downloadUrl.trim()
|
||||||
: undefined
|
: undefined
|
||||||
},
|
},
|
||||||
mods,
|
modsFolder: sanitizeFolderName(input.modsFolder),
|
||||||
resourcepacks,
|
resourcepackPath: sanitizeZipFileName(input.resourcepackPath),
|
||||||
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
serverMinRam: clampNumber(input.serverMinRam, fallback.serverMinRam),
|
||||||
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
serverMaxRam: clampNumber(input.serverMaxRam, fallback.serverMaxRam),
|
||||||
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
clientMinRam: clampNumber(input.clientMinRam, fallback.clientMinRam),
|
||||||
|
|||||||
@@ -5,17 +5,14 @@ export interface PackPlatform {
|
|||||||
downloadUrl?: string
|
downloadUrl?: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface PackAsset {
|
|
||||||
name: string
|
|
||||||
downloadUrl: string
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface PackDefinition {
|
export interface PackDefinition {
|
||||||
name: string
|
name: string
|
||||||
mcVersion: string
|
mcVersion: string
|
||||||
platform: PackPlatform
|
platform: PackPlatform
|
||||||
mods: PackAsset[]
|
/** /file/mods/<modsFolder>/ 폴더 안의 모든 .jar을 자동 다운로드. */
|
||||||
resourcepacks: PackAsset[]
|
modsFolder: string
|
||||||
|
/** /file/resourcepacks/<resourcepackPath> 의 단일 .zip을 그대로 다운로드. */
|
||||||
|
resourcepackPath: string
|
||||||
serverMinRam: number
|
serverMinRam: number
|
||||||
serverMaxRam: number
|
serverMaxRam: number
|
||||||
clientMinRam: number
|
clientMinRam: number
|
||||||
|
|||||||
@@ -25,7 +25,7 @@
|
|||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
<li>마인크래프트 <strong><%= entry.definition.mcVersion %></strong></li>
|
||||||
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
<li>플랫폼 <strong><%= entry.definition.platform.type %></strong></li>
|
||||||
<li>모드 <%= entry.definition.mods.length %>개 / 리소스팩 <%= entry.definition.resourcepacks.length %>개</li>
|
<li>모드 폴더 <%= entry.definition.modsFolder || '없음' %> / 리소스팩 <%= entry.definition.resourcepackPath || '없음' %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</article>
|
</article>
|
||||||
|
|||||||
@@ -38,7 +38,7 @@
|
|||||||
<ul class="metaList">
|
<ul class="metaList">
|
||||||
<li>MC <%= item.definition.mcVersion %></li>
|
<li>MC <%= item.definition.mcVersion %></li>
|
||||||
<li>플랫폼 <%= item.definition.platform.type %></li>
|
<li>플랫폼 <%= item.definition.platform.type %></li>
|
||||||
<li>모드 <%= item.definition.mods.length %>개</li>
|
<li>모드 폴더 <%= item.definition.modsFolder || '없음' %></li>
|
||||||
</ul>
|
</ul>
|
||||||
<% } %>
|
<% } %>
|
||||||
</a>
|
</a>
|
||||||
|
|||||||
@@ -80,33 +80,18 @@
|
|||||||
</label>
|
</label>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<fieldset class="dynamicListFieldset">
|
<div class="gridTwo">
|
||||||
<legend>모드 (.jar)</legend>
|
<label>
|
||||||
<div class="dynamicList" data-list="mods">
|
<span>모드 폴더 이름</span>
|
||||||
<% pack.mods.forEach(function (mod) { %>
|
<input name="modsFolder" value="<%= pack.modsFolder %>" placeholder="my-pack" pattern="[a-zA-Z0-9_\-]*" />
|
||||||
<div class="dynamicRow">
|
<small class="muted">/file/mods/<폴더이름>/ 안의 모든 .jar을 자동으로 받습니다. 비워두면 모드를 받지 않습니다.</small>
|
||||||
<input name="modName" placeholder="모드 이름" value="<%= mod.name %>" />
|
</label>
|
||||||
<input name="modUrl" placeholder="다운로드 URL" value="<%= mod.downloadUrl %>" />
|
<label>
|
||||||
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
<span>리소스팩 (.zip)</span>
|
||||||
|
<input name="resourcepackPath" value="<%= pack.resourcepackPath %>" placeholder="my-pack.zip" pattern=".*\.zip|" />
|
||||||
|
<small class="muted">/file/resourcepacks/ 아래 .zip 파일 이름. 비워두면 리소스팩을 받지 않습니다.</small>
|
||||||
|
</label>
|
||||||
</div>
|
</div>
|
||||||
<% }) %>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="secondaryButton" data-add="mods">모드 추가</button>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<fieldset class="dynamicListFieldset">
|
|
||||||
<legend>리소스팩 (.zip)</legend>
|
|
||||||
<div class="dynamicList" data-list="resourcepacks">
|
|
||||||
<% pack.resourcepacks.forEach(function (resourcePack) { %>
|
|
||||||
<div class="dynamicRow">
|
|
||||||
<input name="resourceName" placeholder="리소스팩 이름" value="<%= resourcePack.name %>" />
|
|
||||||
<input name="resourceUrl" placeholder="다운로드 URL" value="<%= resourcePack.downloadUrl %>" />
|
|
||||||
<button type="button" class="dangerLink dynamicRemove">삭제</button>
|
|
||||||
</div>
|
|
||||||
<% }) %>
|
|
||||||
</div>
|
|
||||||
<button type="button" class="secondaryButton" data-add="resourcepacks">리소스팩 추가</button>
|
|
||||||
</fieldset>
|
|
||||||
|
|
||||||
<button class="primaryButton" type="submit">저장</button>
|
<button class="primaryButton" type="submit">저장</button>
|
||||||
</form>
|
</form>
|
||||||
@@ -129,32 +114,6 @@
|
|||||||
platformSelect.addEventListener('change', syncPlatformVisibility)
|
platformSelect.addEventListener('change', syncPlatformVisibility)
|
||||||
syncPlatformVisibility()
|
syncPlatformVisibility()
|
||||||
|
|
||||||
document.querySelectorAll('[data-add]').forEach(function (button) {
|
|
||||||
button.addEventListener('click', function () {
|
|
||||||
var listKey = button.getAttribute('data-add')
|
|
||||||
var list = document.querySelector('[data-list="' + listKey + '"]')
|
|
||||||
if (!list) return
|
|
||||||
var nameField = listKey === 'mods' ? 'modName' : 'resourceName'
|
|
||||||
var urlField = listKey === 'mods' ? 'modUrl' : 'resourceUrl'
|
|
||||||
var placeholder = listKey === 'mods' ? '모드 이름' : '리소스팩 이름'
|
|
||||||
var row = document.createElement('div')
|
|
||||||
row.className = 'dynamicRow'
|
|
||||||
row.innerHTML =
|
|
||||||
'<input name="' + nameField + '" placeholder="' + placeholder + '" />' +
|
|
||||||
'<input name="' + urlField + '" placeholder="다운로드 URL" />' +
|
|
||||||
'<button type="button" class="dangerLink dynamicRemove">삭제</button>'
|
|
||||||
list.appendChild(row)
|
|
||||||
})
|
|
||||||
})
|
|
||||||
|
|
||||||
document.addEventListener('click', function (event) {
|
|
||||||
var target = event.target
|
|
||||||
if (!(target instanceof HTMLElement)) return
|
|
||||||
if (!target.classList.contains('dynamicRemove')) return
|
|
||||||
var row = target.closest('.dynamicRow')
|
|
||||||
if (row) row.remove()
|
|
||||||
})
|
|
||||||
|
|
||||||
var form = document.getElementById('editorForm')
|
var form = document.getElementById('editorForm')
|
||||||
form.addEventListener('submit', function (event) {
|
form.addEventListener('submit', function (event) {
|
||||||
var clientMin = Number(form.clientMinRam.value)
|
var clientMin = Number(form.clientMinRam.value)
|
||||||
|
|||||||
Reference in New Issue
Block a user