Files
Starlight_Launcher/apps/app-frontend/src/components/ui/skin-site/admin/AdminModsPage.vue
T
AxTps 87de306245
Sync source to CNB / Sync Git ref (push) Skipped
Prepare pnpm cache / prepare (push) Canceled after 0s
feat: 补全皮肤站管理台 Mod 页上传与批量操作
- 新增 Mod 上传弹窗:拖拽或选择 .jar、多文件、大小限制、规则标签附加
- 上传流程:xxHash64 查重、同名检测、名称冲突确认(覆盖/跳过)、429 限流退避重试、HashDiff 重传
- Mod 列表支持多选模式:全选本页、取消选择、批量删除
- 新增服务器地址过滤,配合名称搜索与标签筛选
- 补充 Mod 相关 API:文件哈希、按哈希/名称查重、上传、批量更新
- 引入 xxhash-wasm 依赖
- 中英文各补 32 个文案
2026-10-18 12:02:39 +08:00

983 lines
33 KiB
Vue
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteActivateModRuntime,
skinSiteCreateModRuleTag,
skinSiteDeleteModFile,
skinSiteDeleteModRuleTag,
skinSiteDeleteModRuntime,
skinSiteDeleteMultiModFile,
skinSiteModCount,
type SkinSiteModInfo,
skinSiteModList,
type SkinSiteModRuleTag,
skinSiteModRuleTags,
type SkinSiteModRuntimeAdminState,
type SkinSiteModRuntimeRelease,
skinSiteModRuntimeReleases,
skinSiteModSearch,
skinSiteUpdateModInfo,
skinSiteUpdateModRuleTag,
skinSiteUpdateMultiModInfo,
skinSiteUploadModRuntime,
} from '@/helpers/skin-site-api'
import ModUploadModal from './ModUploadModal.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.title', defaultMessage: 'Mods' },
tabMods: { id: 'app.skin-site.admin.mods.tab-mods', defaultMessage: 'Mod files' },
tabTags: { id: 'app.skin-site.admin.mods.tab-tags', defaultMessage: 'Rule tags' },
tabRuntime: { id: 'app.skin-site.admin.mods.tab-runtime', defaultMessage: 'Runtime' },
loading: { id: 'app.skin-site.admin.mods.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.mods.failed', defaultMessage: 'Could not load mods.' },
empty: { id: 'app.skin-site.admin.mods.empty', defaultMessage: 'No mods found.' },
total: { id: 'app.skin-site.admin.mods.total', defaultMessage: '{count} mods' },
search: { id: 'app.skin-site.admin.mods.search', defaultMessage: 'Search by name' },
filterAll: { id: 'app.skin-site.admin.mods.filter-all', defaultMessage: 'All tags' },
prev: { id: 'app.skin-site.admin.mods.prev', defaultMessage: 'Previous' },
next: { id: 'app.skin-site.admin.mods.next', defaultMessage: 'Next' },
page: { id: 'app.skin-site.admin.mods.page', defaultMessage: 'Page {page}' },
name: { id: 'app.skin-site.admin.mods.name', defaultMessage: 'Name' },
version: { id: 'app.skin-site.admin.mods.version', defaultMessage: 'Version' },
modId: { id: 'app.skin-site.admin.mods.mod-id', defaultMessage: 'Mod ID' },
size: { id: 'app.skin-site.admin.mods.size', defaultMessage: 'Size' },
loaders: { id: 'app.skin-site.admin.mods.loaders', defaultMessage: 'Loaders' },
domainRules: { id: 'app.skin-site.admin.mods.domain-rules', defaultMessage: 'Domain rules' },
edit: { id: 'app.skin-site.admin.mods.edit', defaultMessage: 'Edit' },
save: { id: 'app.skin-site.admin.mods.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.mods.cancel', defaultMessage: 'Cancel' },
remove: { id: 'app.skin-site.admin.mods.remove', defaultMessage: 'Delete' },
updated: { id: 'app.skin-site.admin.mods.updated', defaultMessage: 'Mod updated.' },
deleted: { id: 'app.skin-site.admin.mods.deleted', defaultMessage: 'Mod deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.mods.confirm-delete',
defaultMessage: 'Delete mod “{name}”? This cannot be undone.',
},
ruleTags: { id: 'app.skin-site.admin.mods.rule-tags', defaultMessage: 'Rule tags' },
tagName: { id: 'app.skin-site.admin.mods.tag-name', defaultMessage: 'Tag name' },
tagRules: { id: 'app.skin-site.admin.mods.tag-rules', defaultMessage: 'Rules' },
tagRulesHint: {
id: 'app.skin-site.admin.mods.tag-rules-hint',
defaultMessage: 'One rule per line.',
},
newTag: { id: 'app.skin-site.admin.mods.new-tag', defaultMessage: 'New tag' },
tagCreated: { id: 'app.skin-site.admin.mods.tag-created', defaultMessage: 'Tag created.' },
tagUpdated: { id: 'app.skin-site.admin.mods.tag-updated', defaultMessage: 'Tag updated.' },
tagDeleted: { id: 'app.skin-site.admin.mods.tag-deleted', defaultMessage: 'Tag deleted.' },
confirmDeleteTag: {
id: 'app.skin-site.admin.mods.confirm-delete-tag',
defaultMessage: 'Delete tag “{name}”?',
},
runtime: { id: 'app.skin-site.admin.mods.runtime', defaultMessage: 'Runtime releases' },
manifestUrl: { id: 'app.skin-site.admin.mods.manifest-url', defaultMessage: 'Manifest URL' },
uploadRuntime: {
id: 'app.skin-site.admin.mods.upload-runtime',
defaultMessage: 'Upload runtime',
},
runtimeVersion: {
id: 'app.skin-site.admin.mods.runtime-version',
defaultMessage: 'Version',
},
uploading: { id: 'app.skin-site.admin.mods.uploading', defaultMessage: 'Uploading…' },
runtimeUploaded: {
id: 'app.skin-site.admin.mods.runtime-uploaded',
defaultMessage: 'Runtime uploaded.',
},
activate: { id: 'app.skin-site.admin.mods.activate', defaultMessage: 'Activate' },
activated: { id: 'app.skin-site.admin.mods.activated', defaultMessage: 'Runtime activated.' },
available: { id: 'app.skin-site.admin.mods.available', defaultMessage: 'Available' },
unavailable: { id: 'app.skin-site.admin.mods.unavailable', defaultMessage: 'Unavailable' },
active: { id: 'app.skin-site.admin.mods.active', defaultMessage: 'Active' },
uploader: { id: 'app.skin-site.admin.mods.uploader', defaultMessage: 'by {name}' },
upload: { id: 'app.skin-site.admin.mods.upload', defaultMessage: 'Upload' },
selectMode: {
id: 'app.skin-site.admin.mods.select-mode',
defaultMessage: 'Select multiple',
},
exitSelect: {
id: 'app.skin-site.admin.mods.exit-select',
defaultMessage: 'Done selecting',
},
selectAll: { id: 'app.skin-site.admin.mods.select-all', defaultMessage: 'Select all' },
clearSelection: {
id: 'app.skin-site.admin.mods.clear-selection',
defaultMessage: 'Clear selection',
},
selectedCount: {
id: 'app.skin-site.admin.mods.selected-count',
defaultMessage: '{count} selected',
},
deleteSelected: {
id: 'app.skin-site.admin.mods.delete-selected',
defaultMessage: 'Delete selected',
},
confirmDeleteMulti: {
id: 'app.skin-site.admin.mods.confirm-delete-multi',
defaultMessage: 'Delete {count} selected mods? This cannot be undone.',
},
deletedMulti: {
id: 'app.skin-site.admin.mods.deleted-multi',
defaultMessage: 'Selected mods deleted.',
},
serverAddress: {
id: 'app.skin-site.admin.mods.server-address',
defaultMessage: 'Server address',
},
serverAddressHint: {
id: 'app.skin-site.admin.mods.server-address-hint',
defaultMessage: 'Filter by server address',
},
apply: { id: 'app.skin-site.admin.mods.apply', defaultMessage: 'Apply' },
clear: { id: 'app.skin-site.admin.mods.clear', defaultMessage: 'Clear' },
})
type Tab = 'mods' | 'tags' | 'runtime'
const tab = ref<Tab>('mods')
const PAGE_SIZE = 20
const mods = ref<SkinSiteModInfo[]>([])
const modTotal = ref(0)
const modOffset = ref(0)
const modLoading = ref(true)
const modFailed = ref(false)
const searchKeyword = ref('')
const tagFilter = ref<number | null>(null)
const editingHash = ref<string | null>(null)
const editForm = ref({ name: '', modId: '', version: '', domainRules: '' })
const savingMod = ref(false)
const deletingHash = ref<string | null>(null)
const selectedHashes = ref<Set<string>>(new Set())
const selectionMode = ref(false)
const deletingMulti = ref(false)
const serverAddressInput = ref('')
const serverAddressFilter = ref('')
const uploadOpen = ref(false)
const tags = ref<SkinSiteModRuleTag[]>([])
const tagsLoading = ref(false)
const tagEditorOpen = ref(false)
const editingTagId = ref<number | null>(null)
const tagForm = ref({ name: '', rules: '' })
const savingTag = ref(false)
const runtimeState = ref<SkinSiteModRuntimeAdminState | null>(null)
const runtimeLoading = ref(false)
const runtimeVersion = ref('')
const runtimeFile = ref<HTMLInputElement | null>(null)
const runtimeUploading = ref(false)
const activatingRuntimeId = ref<number | null>(null)
const deletingRuntimeId = ref<number | null>(null)
const modPage = computed(() => Math.floor(modOffset.value / PAGE_SIZE) + 1)
const modPageCount = computed(() => Math.max(1, Math.ceil(modTotal.value / PAGE_SIZE)))
const hasPrev = computed(() => modOffset.value > 0)
const hasNext = computed(() => modOffset.value + PAGE_SIZE < modTotal.value)
function formatSize(bytes: number): string {
if (!Number.isFinite(bytes) || bytes < 0) return '—'
const units = ['B', 'KB', 'MB', 'GB']
let v = bytes
let i = 0
while (v >= 1024 && i < units.length - 1) {
v /= 1024
i++
}
return v.toFixed(i === 0 ? 0 : 2) + ' ' + units[i]
}
function formatDateTime(ts?: number | null): string {
if (!ts) return '—'
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
async function loadMods(reset = true) {
if (reset) {
modLoading.value = true
mods.value = []
modOffset.value = 0
}
modFailed.value = false
try {
const kw = searchKeyword.value.trim()
if (kw) {
const result = await skinSiteModSearch(kw, tagFilter.value)
if (!result.success) throw new Error(result.errorMessage)
mods.value = result.payload ?? []
modTotal.value = mods.value.length
} else {
const [listResult, countResult] = await Promise.all([
skinSiteModList({
limit: PAGE_SIZE,
offset: modOffset.value,
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
skinSiteModCount({
ruleTagId: tagFilter.value,
serverAddr: serverAddressFilter.value || undefined,
}),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
mods.value = listResult.payload ?? []
if (countResult.success && typeof countResult.payload === 'number') {
modTotal.value = countResult.payload
}
}
} catch (error) {
modFailed.value = true
handleError(error)
} finally {
modLoading.value = false
}
}
function prevPage() {
if (hasPrev.value) {
modOffset.value = Math.max(0, modOffset.value - PAGE_SIZE)
void loadMods(false)
}
}
function nextPage() {
if (hasNext.value) {
modOffset.value += PAGE_SIZE
void loadMods(false)
}
}
async function loadTags() {
tagsLoading.value = true
try {
const result = await skinSiteModRuleTags()
if (result.success) tags.value = result.payload ?? []
} catch (error) {
handleError(error)
} finally {
tagsLoading.value = false
}
}
async function loadRuntime() {
runtimeLoading.value = true
try {
const result = await skinSiteModRuntimeReleases()
if (result.success) runtimeState.value = result.payload
} catch (error) {
handleError(error)
} finally {
runtimeLoading.value = false
}
}
async function switchTab(next: Tab) {
tab.value = next
if (next === 'tags' && !tags.value.length) await loadTags()
if (next === 'runtime' && !runtimeState.value) await loadRuntime()
}
function startEdit(mod: SkinSiteModInfo) {
editingHash.value = mod.hash
editForm.value = {
name: mod.metadata.name ?? mod.name,
modId: mod.metadata.modId ?? '',
version: mod.metadata.version ?? '',
domainRules: mod.domainRules.join(', '),
}
}
function cancelEdit() {
editingHash.value = null
}
async function saveMod(mod: SkinSiteModInfo) {
if (savingMod.value) return
savingMod.value = true
try {
const payload: SkinSiteModInfo = {
...mod,
name: editForm.value.name.trim() || mod.name,
domainRules: editForm.value.domainRules
.split(',')
.map((r) => r.trim())
.filter(Boolean),
metadata: {
...mod.metadata,
name: editForm.value.name.trim() || undefined,
modId: editForm.value.modId.trim() || undefined,
version: editForm.value.version.trim() || undefined,
},
}
const result = await skinSiteUpdateModInfo(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
cancelEdit()
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
savingMod.value = false
}
}
async function removeMod(mod: SkinSiteModInfo) {
if (deletingHash.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: mod.name }))) return
deletingHash.value = mod.hash
try {
const result = await skinSiteDeleteModFile(mod.hash)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingHash.value = null
}
}
function toggleSelect(hash: string) {
const next = new Set(selectedHashes.value)
if (next.has(hash)) next.delete(hash)
else next.add(hash)
selectedHashes.value = next
}
function selectAllVisible() {
selectedHashes.value = new Set(mods.value.map((m) => m.hash))
}
function clearSelection() {
selectedHashes.value = new Set()
}
function exitSelectionMode() {
selectionMode.value = false
clearSelection()
}
async function deleteSelected() {
if (deletingMulti.value || !selectedHashes.value.size) return
const count = selectedHashes.value.size
if (!window.confirm(formatMessage(messages.confirmDeleteMulti, { count }))) return
deletingMulti.value = true
try {
const result = await skinSiteDeleteMultiModFile(Array.from(selectedHashes.value))
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deletedMulti) })
clearSelection()
selectionMode.value = false
await loadMods(false)
} catch (error) {
handleError(error)
} finally {
deletingMulti.value = false
}
}
function applyServerAddress() {
serverAddressFilter.value = serverAddressInput.value.trim()
void loadMods(true)
}
function clearServerAddress() {
serverAddressInput.value = ''
serverAddressFilter.value = ''
void loadMods(true)
}
function openNewTag() {
editingTagId.value = null
tagForm.value = { name: '', rules: '' }
tagEditorOpen.value = true
}
function openEditTag(tag: SkinSiteModRuleTag) {
editingTagId.value = tag.id
tagForm.value = { name: tag.name, rules: tag.rules.join('\n') }
tagEditorOpen.value = true
}
function closeTagEditor() {
tagEditorOpen.value = false
tagForm.value = { name: '', rules: '' }
editingTagId.value = null
}
async function saveTag() {
const name = tagForm.value.name.trim()
if (!name || savingTag.value) return
savingTag.value = true
try {
const rules = tagForm.value.rules
.split('\n')
.map((r) => r.trim())
.filter(Boolean)
const result =
editingTagId.value === null
? await skinSiteCreateModRuleTag(name, rules)
: await skinSiteUpdateModRuleTag(editingTagId.value, name, rules)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingTagId.value === null ? messages.tagCreated : messages.tagUpdated),
})
closeTagEditor()
await loadTags()
} catch (error) {
handleError(error)
} finally {
savingTag.value = false
}
}
async function removeTag(tag: SkinSiteModRuleTag) {
if (!window.confirm(formatMessage(messages.confirmDeleteTag, { name: tag.name }))) return
try {
const result = await skinSiteDeleteModRuleTag(tag.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.tagDeleted) })
if (tagFilter.value === tag.id) tagFilter.value = null
await loadTags()
} catch (error) {
handleError(error)
}
}
function pickRuntimeFile() {
runtimeFile.value?.click()
}
async function onRuntimeFile(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
const version = runtimeVersion.value.trim()
if (!file || !version || runtimeUploading.value) return
runtimeUploading.value = true
try {
const result = await skinSiteUploadModRuntime(version, file)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.runtimeUploaded) })
runtimeVersion.value = ''
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
runtimeUploading.value = false
}
}
async function activateRuntime(release: SkinSiteModRuntimeRelease) {
if (activatingRuntimeId.value !== null) return
activatingRuntimeId.value = release.id
try {
const result = await skinSiteActivateModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.activated) })
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
activatingRuntimeId.value = null
}
}
async function removeRuntime(release: SkinSiteModRuntimeRelease) {
if (deletingRuntimeId.value !== null) return
deletingRuntimeId.value = release.id
try {
const result = await skinSiteDeleteModRuntime(release.id)
if (!result.success) throw new Error(result.errorMessage)
await loadRuntime()
} catch (error) {
handleError(error)
} finally {
deletingRuntimeId.value = null
}
}
onMounted(async () => {
await loadTags()
await loadMods(true)
})
</script>
<template>
<div class="flex flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'mods' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('mods')"
>
{{ formatMessage(messages.tabMods) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'tags' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('tags')"
>
{{ formatMessage(messages.tabTags) }}
</button>
<button
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="tab === 'runtime' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab('runtime')"
>
{{ formatMessage(messages.tabRuntime) }}
</button>
</div>
<template v-if="tab === 'mods'">
<div class="flex flex-wrap items-center gap-2">
<input
v-model="searchKeyword"
type="text"
:placeholder="formatMessage(messages.search)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="loadMods(true)"
/>
<select
v-model="tagFilter"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast"
@change="loadMods(true)"
>
<option :value="null">{{ formatMessage(messages.filterAll) }}</option>
<option v-for="tag in tags" :key="tag.id" :value="tag.id">{{ tag.name }}</option>
</select>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="uploadOpen = true"
>
{{ formatMessage(messages.upload) }}
</button>
<button
v-if="!selectionMode"
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectionMode = true"
>
{{ formatMessage(messages.selectMode) }}
</button>
<template v-else>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="selectAllVisible"
>
{{ formatMessage(messages.selectAll) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size"
@click="clearSelection"
>
{{ formatMessage(messages.clearSelection) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!selectedHashes.size || deletingMulti"
@click="deleteSelected"
>
{{ formatMessage(messages.deleteSelected) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="exitSelectionMode"
>
{{ formatMessage(messages.exitSelect) }}
</button>
</template>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: modTotal }) }}
</span>
<span v-if="selectionMode && selectedHashes.size" class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.selectedCount, { count: selectedHashes.size }) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="serverAddressInput"
type="text"
:placeholder="formatMessage(messages.serverAddressHint)"
class="min-w-0 flex-1 rounded-full border border-solid border-surface-5 bg-bg px-3 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="applyServerAddress"
/>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="applyServerAddress"
>
{{ formatMessage(messages.apply) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!serverAddressFilter"
@click="clearServerAddress"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div v-if="modLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="modFailed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!mods.length" class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="mod in mods"
:key="mod.hash"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
:class="selectionMode && selectedHashes.has(mod.hash) ? 'ring-2 ring-brand' : ''"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-if="selectionMode"
type="checkbox"
class="size-3.5 shrink-0"
:checked="selectedHashes.has(mod.hash)"
@change="toggleSelect(mod.hash)"
/>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ mod.metadata.name || mod.name }}
</span>
<span class="shrink-0 text-xs text-secondary">{{ formatSize(mod.size) }}</span>
<span
v-for="loader in mod.metadata.loaderType"
:key="loader"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ loader }}
</span>
</div>
<span class="truncate font-mono text-[10px] text-secondary">{{ mod.hash }}</span>
<div v-if="editingHash === mod.hash" class="grid grid-cols-1 gap-2 sm:grid-cols-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.name) }}</span>
<input
v-model="editForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.modId) }}</span>
<input
v-model="editForm.modId"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.version) }}</span>
<input
v-model="editForm.version"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1 sm:col-span-3">
<span class="text-xs text-secondary">{{ formatMessage(messages.domainRules) }}</span>
<input
v-model="editForm.domainRules"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
</div>
<div class="flex flex-wrap gap-2">
<template v-if="editingHash === mod.hash">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="cancelEdit"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="savingMod"
@click="saveMod(mod)"
>
{{ formatMessage(messages.save) }}
</button>
</template>
<template v-else>
<span
v-for="rule in mod.domainRules"
:key="rule"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ rule }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="startEdit(mod)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingHash !== null"
@click="removeMod(mod)"
>
{{ formatMessage(messages.remove) }}
</button>
</template>
</div>
</div>
<div class="flex items-center justify-center gap-3 pt-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasPrev"
@click="prevPage"
>
{{ formatMessage(messages.prev) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.page, { page: modPage }) }} / {{ modPageCount }}
</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="!hasNext"
@click="nextPage"
>
{{ formatMessage(messages.next) }}
</button>
</div>
</div>
</template>
<template v-else-if="tab === 'tags'">
<div class="flex items-center gap-2">
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openNewTag"
>
{{ formatMessage(messages.newTag) }}
</button>
</div>
<div v-if="tagsLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<div v-else-if="!tags.length" class="rounded-xl bg-bg-raised p-4 text-sm text-secondary">
{{ formatMessage(messages.empty) }}
</div>
<div v-else class="flex flex-col gap-2">
<div
v-for="tag in tags"
:key="tag.id"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ tag.name }}
</span>
<span class="text-xs text-secondary">×{{ tag.rules.length }}</span>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEditTag(tag)"
>
{{ formatMessage(messages.edit) }}
</button>
<button
type="button"
class="rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeTag(tag)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<div
v-if="tagEditorOpen"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagName) }}
</span>
<input
v-model="tagForm.name"
type="text"
class="rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.tagRules) }}
</span>
<textarea
v-model="tagForm.rules"
rows="5"
:placeholder="formatMessage(messages.tagRulesHint)"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 font-mono text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex justify-end gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="closeTagEditor"
>
{{ formatMessage(messages.cancel) }}
</button>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast disabled:opacity-50"
:disabled="savingTag || !tagForm.name.trim()"
@click="saveTag"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</template>
<template v-else>
<div class="flex flex-wrap items-center gap-2">
<input ref="runtimeFile" type="file" class="hidden" @change="onRuntimeFile" />
<input
v-model="runtimeVersion"
type="text"
:placeholder="formatMessage(messages.runtimeVersion)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="runtimeUploading || !runtimeVersion.trim()"
@click="pickRuntimeFile"
>
{{
runtimeUploading
? formatMessage(messages.uploading)
: formatMessage(messages.uploadRuntime)
}}
</button>
</div>
<div
v-if="runtimeState && runtimeState.manifestUrl"
class="flex flex-wrap items-center gap-2 rounded-xl bg-bg-raised p-3"
>
<span class="text-xs font-semibold text-contrast">
{{ formatMessage(messages.manifestUrl) }}
</span>
<code class="min-w-0 flex-1 truncate text-xs text-secondary">
{{ runtimeState.manifestUrl }}
</code>
</div>
<div v-if="runtimeLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="!runtimeState || !runtimeState.releases.length"
class="m-0 rounded-xl bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<div v-else class="flex flex-col gap-2">
<div
v-for="release in runtimeState.releases"
:key="release.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ release.version }}
<span class="text-xs font-normal text-secondary">#{{ release.id }}</span>
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: release.uploadedBy }) }} ·
{{ formatDateTime(release.uploadedAt) }}
</span>
</div>
<span class="shrink-0 text-xs text-secondary">
{{ formatSize(release.fileSize) }}
</span>
<span
v-if="release.active"
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.active) }}
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="release.available ? 'bg-button-bg text-secondary' : 'bg-orange text-contrast'"
>
{{
release.available
? formatMessage(messages.available)
: formatMessage(messages.unavailable)
}}
</span>
<button
v-if="!release.active"
type="button"
class="shrink-0 rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="activatingRuntimeId !== null"
@click="activateRuntime(release)"
>
{{ formatMessage(messages.activate) }}
</button>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="deletingRuntimeId !== null || release.active"
@click="removeRuntime(release)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</template>
<ModUploadModal
v-if="uploadOpen"
:rule-tags="tags"
@finished="uploadOpen = false"
/>
</div>
</template>