Align dashboard and installer flows with spec

This commit is contained in:
2026-05-08 19:02:50 +09:00
parent a10ca67210
commit 5ff4e20b5e
6 changed files with 117 additions and 17 deletions

View File

@@ -44,7 +44,7 @@ function renderPackList(packs) {
const label = document.createElement('label') const label = document.createElement('label')
label.className = 'packOption' label.className = 'packOption'
label.innerHTML = ` label.innerHTML = `
<input type="radio" name="packChoice" value="${pack.file}" /> <input type="radio" name="packChoice" value="${pack.file}" data-pack-name="${pack.name}" />
<div> <div>
<strong>${pack.name}</strong> <strong>${pack.name}</strong>
<span>${pack.file}</span> <span>${pack.file}</span>
@@ -52,15 +52,6 @@ function renderPackList(packs) {
` `
packList.appendChild(label) packList.appendChild(label)
}) })
packList.addEventListener('change', () => {
const checked = packList.querySelector('input[name="packChoice"]:checked')
if (checked == null) {
state.selectedPack = null
return
}
state.selectedPack = packs.find((pack) => pack.file === checked.value) ?? null
})
} }
async function bootstrap() { async function bootstrap() {
@@ -71,6 +62,18 @@ async function bootstrap() {
window.installerApi.onLog(appendLog) window.installerApi.onLog(appendLog)
packList.addEventListener('change', () => {
const checked = packList.querySelector('input[name="packChoice"]:checked')
if (checked == null) {
state.selectedPack = null
return
}
state.selectedPack = {
file: checked.value,
name: checked.dataset.packName ?? checked.value
}
})
document.querySelectorAll('[data-back]').forEach((button) => { document.querySelectorAll('[data-back]').forEach((button) => {
button.addEventListener('click', () => { button.addEventListener('click', () => {
setActiveStep(button.dataset.back) setActiveStep(button.dataset.back)

View File

@@ -191,21 +191,60 @@ button {
gap: 18px; gap: 18px;
} }
.deleteToolbar {
display: flex;
align-items: center;
gap: 12px;
}
.deleteActions {
display: flex;
align-items: center;
gap: 12px;
}
.selectableCard { .selectableCard {
position: relative; position: relative;
} }
.selectableCard input[type="checkbox"] { .selectionBox {
position: absolute; position: absolute;
top: 18px; top: 18px;
right: 18px; right: 18px;
} }
.ghostButton {
min-height: 46px;
padding: 0 20px;
border-radius: 999px;
cursor: pointer;
border: 1px solid var(--line);
background: transparent;
color: var(--text);
}
.selectionTitle { .selectionTitle {
font-size: 24px; font-size: 24px;
font-weight: 700; font-weight: 700;
} }
.warningBadge {
display: inline-flex;
align-items: center;
width: fit-content;
min-height: 30px;
padding: 0 12px;
border-radius: 999px;
background: rgba(240, 191, 87, 0.16);
color: var(--accent);
font-size: 13px;
font-weight: 700;
}
.hidden {
display: none !important;
}
.adminLoginBody { .adminLoginBody {
display: flex; display: flex;
align-items: center; align-items: center;

View File

@@ -3,6 +3,7 @@ import { fetchReleaseVersions } from '../../shared/mojang'
import { import {
createNewPack, createNewPack,
deletePacks, deletePacks,
listDashboardPacks,
loadAccounts, loadAccounts,
loadPackDefinition, loadPackDefinition,
loadRootManifest, loadRootManifest,
@@ -59,10 +60,10 @@ opRouter.post('/op/logout', requireAuth, (req, res) => {
opRouter.get('/op/dashboard', requireAuth, async (_req, res, next) => { opRouter.get('/op/dashboard', requireAuth, async (_req, res, next) => {
try { try {
const manifest = await loadRootManifest() const packs = await listDashboardPacks()
res.render('op/dashboard', { res.render('op/dashboard', {
userId: _req.session.userId, userId: _req.session.userId,
packs: manifest.packs packs
}) })
} catch (error) { } catch (error) {
next(error) next(error)

View File

@@ -2,7 +2,7 @@ import fs from 'node:fs'
import fsp from 'node:fs/promises' import fsp from 'node:fs/promises'
import path from 'node:path' import path from 'node:path'
import { accountPath, fileDir, manifestDir, manifestRootPath } from './paths' import { accountPath, fileDir, manifestDir, manifestRootPath } from './paths'
import { AccountEntry, PackDefinition, PackListEntry, RootManifest } from './types' import { AccountEntry, DashboardPackEntry, PackDefinition, PackListEntry, RootManifest } from './types'
const defaultRootManifest: RootManifest = { const defaultRootManifest: RootManifest = {
packs: [ packs: [
@@ -93,6 +93,22 @@ export async function listManifestFiles(): Promise<string[]> {
.sort((left, right) => left.localeCompare(right)) .sort((left, right) => left.localeCompare(right))
} }
export async function listDashboardPacks(): Promise<DashboardPackEntry[]> {
const [manifestFiles, rootManifest] = await Promise.all([
listManifestFiles(),
loadRootManifest()
])
return manifestFiles.map((file) => {
const registeredPack = rootManifest.packs.find((entry) => entry.file === file)
return {
file,
name: registeredPack?.name ?? file,
registered: registeredPack != null
}
})
}
export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> { export async function loadPackDefinition(packKey: string): Promise<PackDefinition | null> {
await ensureProjectFiles() await ensureProjectFiles()
const safeKey = sanitizePackKey(packKey) const safeKey = sanitizePackKey(packKey)

View File

@@ -3,6 +3,10 @@ export interface PackListEntry {
file: string file: string
} }
export interface DashboardPackEntry extends PackListEntry {
registered: boolean
}
export interface RootManifest { export interface RootManifest {
packs: PackListEntry[] packs: PackListEntry[]
} }

View File

@@ -14,14 +14,23 @@
<form method="post" action="/op/dashboard/packs"> <form method="post" action="/op/dashboard/packs">
<button class="primaryButton" type="submit">서버팩 추가</button> <button class="primaryButton" type="submit">서버팩 추가</button>
</form> </form>
<form method="post" action="/op/dashboard/packs/delete" class="deleteForm"> <form method="post" action="/op/dashboard/packs/delete" class="deleteForm" data-delete-form>
<button class="dangerButton" type="submit">선택 삭제</button> <div class="deleteToolbar">
<button class="dangerButton" type="button" data-delete-toggle>서버팩 삭제</button>
<div class="deleteActions hidden" data-delete-actions>
<button class="ghostButton" type="button" data-delete-cancel>취소</button>
<button class="dangerButton" type="submit">확인</button>
</div>
</div>
<div class="cardRow"> <div class="cardRow">
<% packs.forEach((pack) => { %> <% packs.forEach((pack) => { %>
<label class="packCard selectableCard"> <label class="packCard selectableCard">
<input type="checkbox" name="packKeys" value="<%= pack.file %>" /> <input class="selectionBox hidden" type="checkbox" name="packKeys" value="<%= pack.file %>" />
<span class="selectionTitle"><%= pack.name %></span> <span class="selectionTitle"><%= pack.name %></span>
<span><code><%= pack.file %></code></span> <span><code><%= pack.file %></code></span>
<% if (!pack.registered) { %>
<span class="warningBadge">manifest.json 미등록</span>
<% } %>
<a class="ghostLink" href="/op/dashboard/<%= pack.file %>">편집</a> <a class="ghostLink" href="/op/dashboard/<%= pack.file %>">편집</a>
</label> </label>
<% }) %> <% }) %>
@@ -29,5 +38,33 @@
</form> </form>
</section> </section>
</main> </main>
<script>
(() => {
const form = document.querySelector('[data-delete-form]')
if (form == null) {
return
}
const toggleButton = form.querySelector('[data-delete-toggle]')
const cancelButton = form.querySelector('[data-delete-cancel]')
const actions = form.querySelector('[data-delete-actions]')
const checkboxes = form.querySelectorAll('.selectionBox')
const setDeleteMode = (enabled) => {
form.classList.toggle('deleteMode', enabled)
actions.classList.toggle('hidden', !enabled)
checkboxes.forEach((checkbox) => {
checkbox.classList.toggle('hidden', !enabled)
if (!enabled) {
checkbox.checked = false
}
})
}
toggleButton?.addEventListener('click', () => setDeleteMode(true))
cancelButton?.addEventListener('click', () => setDeleteMode(false))
setDeleteMode(false)
})()
</script>
</body> </body>
</html> </html>