feat: 补齐皮肤站管理台与维基阅读(用户、审核、Mod、聊天室、维基迁移)
Sync source to CNB / Sync Git ref (push) Skipped
Axolotl desktop CI / guardrails (push) Canceled after 0s
Axolotl desktop CI / desktop (macos-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (ubuntu-latest) (push) Canceled after 0s
Axolotl desktop CI / desktop (windows-latest) (push) Canceled after 0s
Axolotl desktop CI / website (push) Canceled after 0s
Repository checks / typos (push) Canceled after 0s
Repository checks / tombi (push) Canceled after 0s
Rust checks / shear (push) Canceled after 0s
Prepare pnpm cache / prepare (push) Canceled after 0s

feat: 新增皮肤站管理台资源下载、服务器、审计与捐赠页
This commit is contained in:
AxTps authored and GitHub committed 2026-10-18 13:15:01 +08:00
commit 3c8ac04269
32 files changed
+11108 -491

No files matched your search

+2 -1
View File
@@ -50,7 +50,8 @@
"vue": "^3.5.13",
"vue-i18n": "^10.0.0",
"vue-router": "^4.6.0",
"vuedraggable": "^4.1.0"
"vuedraggable": "^4.1.0",
"xxhash-wasm": "1.1.0"
},
"devDependencies": {
"@eslint/compat": "^1.1.1",
@@ -0,0 +1,290 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onBeforeUnmount, ref, watch } from 'vue'
import { type SkinSiteChatUserInfo, skinSiteChatUserSearch } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
label?: string
placeholder?: string
excludeUuids?: string[]
allowedUuids?: string[]
allowedHint?: string
}>(),
{
roomId: undefined,
label: undefined,
placeholder: undefined,
excludeUuids: () => [],
allowedUuids: undefined,
allowedHint: undefined,
},
)
const model = defineModel<SkinSiteChatUserInfo | null>({ required: true })
const messages = defineMessages({
label: { id: 'app.skin-site.chat.user-picker.label', defaultMessage: 'Pick a user' },
placeholder: {
id: 'app.skin-site.chat.user-picker.placeholder',
defaultMessage: 'Search username, email or account ID',
},
byUser: { id: 'app.skin-site.chat.user-picker.by-user', defaultMessage: 'By user' },
byPlayer: { id: 'app.skin-site.chat.user-picker.by-player', defaultMessage: 'By player' },
hint: {
id: 'app.skin-site.chat.user-picker.hint',
defaultMessage: 'Fuzzy search on username and email, or search by account ID.',
},
playerHint: {
id: 'app.skin-site.chat.user-picker.player-hint',
defaultMessage: 'Enter a player name to find the bound user.',
},
empty: {
id: 'app.skin-site.chat.user-picker.empty',
defaultMessage: 'No matching user.',
},
banned: {
id: 'app.skin-site.chat.user-picker.banned',
defaultMessage: 'This account is banned.',
},
guest: {
id: 'app.skin-site.chat.user-picker.guest',
defaultMessage: 'Guests cannot become a manager or settlement owner.',
},
alreadyIn: {
id: 'app.skin-site.chat.user-picker.already-in',
defaultMessage: 'This user is already in the room.',
},
notAllowed: {
id: 'app.skin-site.chat.user-picker.not-allowed',
defaultMessage: 'This user is outside the allowed range.',
},
select: { id: 'app.skin-site.chat.user-picker.select', defaultMessage: 'Select' },
selected: {
id: 'app.skin-site.chat.user-picker.selected',
defaultMessage: 'Selected',
},
idLabel: { id: 'app.skin-site.chat.user-picker.id', defaultMessage: 'ID' },
})
const keyword = ref('')
const searchMode = ref<'user' | 'player'>('user')
const results = ref<SkinSiteChatUserInfo[]>([])
const preview = ref<SkinSiteChatUserInfo | null>(null)
const loading = ref(false)
let timer: ReturnType<typeof setTimeout> | undefined
let requestId = 0
const currentLabel = computed(() => props.label ?? formatMessage(messages.label))
const currentPlaceholder = computed(
() => props.placeholder ?? formatMessage(messages.placeholder),
)
watch([keyword, searchMode], () => {
clearTimeout(timer)
const query = keyword.value.trim()
if (!query) {
results.value = []
preview.value = null
loading.value = false
return
}
timer = setTimeout(() => void search(query), 300)
})
watch(model, (value) => {
if (value) return
keyword.value = ''
preview.value = null
results.value = []
})
watch(
() => props.roomId,
() => {
model.value = null
},
)
onBeforeUnmount(() => clearTimeout(timer))
async function search(query: string) {
const current = ++requestId
loading.value = true
try {
const res = await skinSiteChatUserSearch(query, props.roomId, searchMode.value)
if (current !== requestId) return
if (!res.success) throw new Error(res.errorMessage)
results.value = res.payload ?? []
preview.value = results.value.length === 1 ? results.value[0] : null
} catch (error) {
if (current === requestId) {
results.value = []
handleError(error)
}
} finally {
if (current === requestId) loading.value = false
}
}
function unavailableReason(user: SkinSiteChatUserInfo): string | null {
if (user.banned) return formatMessage(messages.banned)
if (user.permissions.includes('GUEST')) return formatMessage(messages.guest)
if (props.excludeUuids.includes(user.uuid)) return formatMessage(messages.alreadyIn)
if (props.allowedUuids && !props.allowedUuids.includes(user.uuid)) {
return props.allowedHint ?? formatMessage(messages.notAllowed)
}
return null
}
function selectPreview() {
if (preview.value && !unavailableReason(preview.value)) {
model.value = preview.value
}
}
function formatTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
</script>
<template>
<div class="flex flex-col gap-3">
<div class="flex flex-col gap-1.5">
<span class="text-xs font-semibold text-secondary">{{ currentLabel }}</span>
<div class="flex gap-2">
<select
v-model="searchMode"
class="shrink-0 rounded-lg border border-solid border-surface-5 bg-bg-raised px-2 py-1.5 text-xs text-contrast outline-none focus:border-brand"
>
<option value="user">{{ formatMessage(messages.byUser) }}</option>
<option value="player">{{ formatMessage(messages.byPlayer) }}</option>
</select>
<input
v-model.trim="keyword"
type="text"
maxlength="100"
:placeholder="searchMode === 'player' ? formatMessage(messages.playerHint) : currentPlaceholder"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
autocomplete="off"
/>
</div>
<span class="text-[11px] text-secondary">
{{ searchMode === 'player' ? formatMessage(messages.playerHint) : formatMessage(messages.hint) }}
</span>
</div>
<div
v-if="keyword && !loading"
class="flex max-h-56 flex-col gap-1.5 overflow-y-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<button
v-for="user in results"
:key="user.uuid"
type="button"
class="flex w-full items-center justify-between gap-3 rounded-lg border border-solid p-2 text-left transition-colors"
:class="
preview?.uuid === user.uuid
? 'border-brand bg-brand/10'
: 'border-transparent bg-bg hover:border-surface-5'
"
@click="preview = user"
>
<span class="flex min-w-0 items-center gap-2">
<span class="grid size-8 shrink-0 place-items-center rounded-lg bg-brand/15 text-xs font-bold text-brand">
{{ user.username.slice(0, 1).toUpperCase() }}
</span>
<span class="min-w-0">
<strong class="block truncate text-xs text-contrast">{{ user.username }}</strong>
<span class="block truncate text-[10px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ user.id }} · {{ user.email }}
</span>
</span>
</span>
<span
v-if="unavailableReason(user)"
class="shrink-0 rounded-full bg-red/20 px-2 py-0.5 text-[10px] font-semibold text-red"
>
{{ unavailableReason(user) }}
</span>
<span
v-else-if="model?.uuid === user.uuid"
class="shrink-0 rounded-full bg-brand px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.selected) }}
</span>
</button>
<p v-if="!results.length" class="m-0 py-4 text-center text-xs text-secondary">
{{ formatMessage(messages.empty) }}
</p>
</div>
<section
v-if="preview"
class="flex flex-col gap-3 rounded-lg border border-solid border-surface-5 bg-bg-raised p-3"
>
<div class="flex items-start justify-between gap-3">
<div>
<h3 class="m-0 text-sm font-bold text-contrast">{{ preview.username }}</h3>
<p class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.idLabel) }} {{ preview.id }}
</p>
</div>
<span
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="preview.banned ? 'bg-red text-contrast' : 'bg-green text-contrast'"
>
{{ preview.banned ? 'Banned' : 'OK' }}
</span>
</div>
<div class="grid gap-2 text-[11px] sm:grid-cols-2">
<div class="min-w-0">
<span class="block text-secondary">UUID</span>
<span class="block break-all font-mono text-contrast">{{ preview.uuid }}</span>
</div>
<div>
<span class="block text-secondary">Email</span>
<span class="block break-all text-contrast">{{ preview.email }}</span>
</div>
<div>
<span class="block text-secondary">QQ</span>
<span class="block text-contrast">{{ preview.qq ?? '—' }}</span>
</div>
<div>
<span class="block text-secondary">Registered</span>
<span class="block text-contrast">{{ formatTime(preview.registerTime) }}</span>
</div>
</div>
<div class="flex flex-wrap gap-1.5">
<span
v-for="perm in preview.permissions"
:key="perm"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ perm }}
</span>
</div>
<p v-if="unavailableReason(preview)" class="m-0 text-[11px] text-red">
{{ unavailableReason(preview) }}
</p>
<button
type="button"
class="w-full rounded-lg bg-brand px-3 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="Boolean(unavailableReason(preview))"
@click="selectPreview"
>
{{ model?.uuid === preview.uuid ? formatMessage(messages.selected) : formatMessage(messages.select) }}
</button>
</section>
</div>
</template>
@@ -0,0 +1,63 @@
<script setup lang="ts">
import { computed, ref, watch } from 'vue'
import { loadCachedRoomAvatar } from './room-avatar-cache'
const props = withDefaults(
defineProps<{
roomId: number
avatarAttachmentId?: number | null
name: string
size?: 'sm' | 'md' | 'lg' | 'xl'
}>(),
{ avatarAttachmentId: null, size: 'md' },
)
const avatarUrl = ref<string | null>(null)
const loading = ref(false)
const sizeClass = computed(
() =>
({
sm: 'size-9 rounded-lg text-lg',
md: 'size-10 rounded-lg text-xl',
lg: 'size-12 rounded-lg text-2xl',
xl: 'size-20 rounded-xl text-3xl',
})[props.size],
)
watch(
() => [props.roomId, props.avatarAttachmentId] as const,
async ([roomId, attachmentId]) => {
avatarUrl.value = null
if (!attachmentId) {
loading.value = false
return
}
loading.value = true
const url = await loadCachedRoomAvatar(roomId, attachmentId)
if (props.roomId === roomId && props.avatarAttachmentId === attachmentId) {
avatarUrl.value = url
}
loading.value = false
},
{ immediate: true },
)
</script>
<template>
<div
class="grid shrink-0 place-items-center overflow-hidden border border-solid border-surface-5 bg-brand/10 font-bold text-brand"
:class="sizeClass"
>
<img
v-if="avatarUrl"
:src="avatarUrl"
:alt="name"
class="h-full w-full object-cover"
decoding="async"
/>
<span v-else-if="loading" class="text-[10px] text-secondary">…</span>
<span v-else class="select-none">#</span>
</div>
</template>
@@ -0,0 +1,161 @@
<script setup lang="ts">
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onBeforeUnmount, ref, watch } from 'vue'
import { skinSiteChatAttachmentUpload } from '@/helpers/skin-site-api'
import RoomAvatar from './RoomAvatar.vue'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(
defineProps<{
roomId?: number
avatarAttachmentId?: number | null
name: string
token?: string | null
remove?: boolean
}>(),
{ roomId: 0, avatarAttachmentId: null, token: null, remove: false },
)
const emit = defineEmits<{
(e: 'update:token', value: string | null): void
(e: 'update:remove', value: boolean): void
(e: 'update:uploading', value: boolean): void
}>()
const messages = defineMessages({
label: { id: 'app.skin-site.chat.avatar.label', defaultMessage: 'Room avatar' },
upload: { id: 'app.skin-site.chat.avatar.upload', defaultMessage: 'Upload avatar' },
replace: { id: 'app.skin-site.chat.avatar.replace', defaultMessage: 'Replace avatar' },
remove: { id: 'app.skin-site.chat.avatar.remove', defaultMessage: 'Remove avatar' },
hint: {
id: 'app.skin-site.chat.avatar.hint',
defaultMessage: 'PNG, JPEG or WebP, up to 8192 px per side and 5 MB.',
},
badFormat: {
id: 'app.skin-site.chat.avatar.bad-format',
defaultMessage: 'Only PNG, JPEG and WebP are supported.',
},
tooLarge: {
id: 'app.skin-site.chat.avatar.too-large',
defaultMessage: 'Avatar must not exceed 5 MB.',
},
notImage: {
id: 'app.skin-site.chat.avatar.not-image',
defaultMessage: 'Avatar must be an image.',
},
})
const MAX_BYTES = 5 * 1024 * 1024
const ALLOWED = ['png', 'jpg', 'jpeg', 'webp']
const uploading = ref(false)
const previewUrl = ref<string | null>(null)
watch(
() => props.token,
(token) => {
if (!token) revokePreview()
},
)
function revokePreview() {
if (previewUrl.value) URL.revokeObjectURL(previewUrl.value)
previewUrl.value = null
}
async function selectAvatar(event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
input.value = ''
if (!file) return
const ext = file.name.split('.').pop()?.toLowerCase()
if (!ext || !ALLOWED.includes(ext)) {
addNotification({ type: 'error', title: formatMessage(messages.badFormat) })
return
}
if (file.size > MAX_BYTES) {
addNotification({ type: 'error', title: formatMessage(messages.tooLarge) })
return
}
uploading.value = true
emit('update:uploading', true)
try {
const res = await skinSiteChatAttachmentUpload(file)
if (!res.success) throw new Error(res.errorMessage)
if (res.payload.attachment.kind !== 'IMAGE') {
addNotification({ type: 'error', title: formatMessage(messages.notImage) })
return
}
revokePreview()
previewUrl.value = URL.createObjectURL(file)
emit('update:token', res.payload.token)
emit('update:remove', false)
} catch (error) {
handleError(error)
} finally {
uploading.value = false
emit('update:uploading', false)
}
}
function removeAvatar() {
revokePreview()
emit('update:token', null)
emit('update:remove', true)
}
onBeforeUnmount(revokePreview)
</script>
<template>
<div class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.label) }}
</span>
<div class="flex flex-wrap items-center gap-3">
<div
v-if="previewUrl"
class="size-20 overflow-hidden rounded-xl border border-solid border-surface-5 bg-bg-raised"
>
<img :src="previewUrl" alt="" class="h-full w-full object-cover" />
</div>
<RoomAvatar
v-else
:room-id="roomId"
:avatar-attachment-id="remove ? null : avatarAttachmentId"
:name="name || 'Room'"
size="xl"
/>
<div class="flex flex-wrap gap-2">
<label
class="cursor-pointer rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
:class="uploading ? 'pointer-events-none opacity-50' : ''"
>
{{ uploading ? '…' : (avatarAttachmentId || previewUrl ? formatMessage(messages.replace) : formatMessage(messages.upload)) }}
<input
type="file"
class="hidden"
accept=".png,.jpg,.jpeg,.webp"
@change="selectAvatar"
/>
</label>
<button
v-if="previewUrl || (avatarAttachmentId && !remove)"
type="button"
class="rounded-lg bg-red/20 px-3 py-1.5 text-xs font-semibold text-red hover:brightness-110 disabled:opacity-50"
:disabled="uploading"
@click="removeAvatar"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
<p class="m-0 text-[11px] text-secondary">{{ formatMessage(messages.hint) }}</p>
</div>
</template>
@@ -4,12 +4,19 @@ import { computed, ref } from 'vue'
import { skinSiteUser } from '@/composables/skin-site-session'
import AdminAccessAuditPage from './admin/AdminAccessAuditPage.vue'
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
import AdminChatPage from './admin/AdminChatPage.vue'
import AdminDonationsPage from './admin/AdminDonationsPage.vue'
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
import AdminModsPage from './admin/AdminModsPage.vue'
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
import AdminReviewPage from './admin/AdminReviewPage.vue'
import AdminServersPage from './admin/AdminServersPage.vue'
import AdminSummaryPage from './admin/AdminSummaryPage.vue'
import AdminUsersPage from './admin/AdminUsersPage.vue'
import AdminWikiPage from './admin/AdminWikiPage.vue'
const emit = defineEmits<{ (e: 'back'): void }>()
@@ -72,7 +79,9 @@ const navGroups = computed<NavGroup[]>(() => [
id: 'security',
label: formatMessage(messages.groupSecurity),
items: [
{ key: 'servers', label: formatMessage(messages.itemServers) },
...(hasWebAdmin.value
? [{ key: 'servers' as AdminKey, label: formatMessage(messages.itemServers) }]
: []),
{ key: 'summary', label: formatMessage(messages.itemSummary) },
{ key: 'access-audit', label: formatMessage(messages.itemAccessAudit) },
],
@@ -93,7 +102,9 @@ const navGroups = computed<NavGroup[]>(() => [
{ key: 'broadcast', label: formatMessage(messages.itemBroadcast) },
{ key: 'chat', label: formatMessage(messages.itemChat) },
{ key: 'wiki', label: formatMessage(messages.itemWiki) },
{ key: 'donations', label: formatMessage(messages.itemDonations) },
...(hasWebAdmin.value
? [{ key: 'donations' as AdminKey, label: formatMessage(messages.itemDonations) }]
: []),
],
},
{
@@ -112,6 +123,11 @@ const hasAdminAccess = computed(() => {
return permissions.includes('WEB_ADMIN') || permissions.includes('SERVER_ADMIN')
})
const hasWebAdmin = computed(() => {
const permissions = skinSiteUser.value?.permissions ?? []
return permissions.includes('WEB_ADMIN')
})
const current = ref<AdminKey>('summary')
</script>
<template>
@@ -159,11 +175,18 @@ const current = ref<AdminKey>('summary')
class="min-w-0 flex-1 overflow-y-auto rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<AdminSummaryPage v-if="current === 'summary'" />
<AdminServersPage v-else-if="current === 'servers' && hasWebAdmin" />
<AdminAccessAuditPage v-else-if="current === 'access-audit'" />
<AdminBroadcastPage v-else-if="current === 'broadcast'" />
<AdminChatPage v-else-if="current === 'chat'" />
<AdminUsersPage v-else-if="current === 'users'" />
<AdminPlayersPage v-else-if="current === 'players'" />
<AdminReviewPage v-else-if="current === 'review'" />
<AdminLauncherPage v-else-if="current === 'launcher'" />
<AdminModsPage v-else-if="current === 'mods'" />
<AdminWikiPage v-else-if="current === 'wiki'" />
<AdminDownloadsPage v-else-if="current === 'downloads'" />
<AdminDonationsPage v-else-if="current === 'donations' && hasWebAdmin" />
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
</section>
</div>
@@ -3,7 +3,10 @@ import { HeartIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import { type SkinSiteDonationSummary, skinSiteDonationSummary } from '@/helpers/skin-site-api'
import {
type SkinSitePublicDonationSummary,
skinSitePublicDonationSummary,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
@@ -18,13 +21,13 @@ const messages = defineMessages({
loading: { id: 'app.skin-site.home.donation.loading', defaultMessage: 'Loading…' },
})
const summary = ref<SkinSiteDonationSummary | null>(null)
const summary = ref<SkinSitePublicDonationSummary | null>(null)
const loading = ref(true)
const failed = ref(false)
onMounted(async () => {
try {
const result = await skinSiteDonationSummary()
const result = await skinSitePublicDonationSummary()
if (result.success) summary.value = result.payload
else failed.value = true
} catch {
@@ -4,11 +4,11 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u
import { computed, onMounted, ref } from 'vue'
import {
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadList,
type SkinSiteDownloadStatus,
skinSitePublicDownloadCategories,
type SkinSitePublicDownloadCategory,
type SkinSitePublicDownloadItem,
skinSitePublicDownloadList,
type SkinSitePublicDownloadStatus,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
@@ -36,8 +36,8 @@ const messages = defineMessages({
const emit = defineEmits<{ (e: 'back'): void }>()
const items = ref<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
const items = ref<SkinSitePublicDownloadItem[]>([])
const categories = ref<SkinSitePublicDownloadCategory[]>([])
const selectedCategory = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
@@ -48,7 +48,7 @@ const filtered = computed(() =>
: items.value,
)
function statusLabel(status: SkinSiteDownloadStatus): string {
function statusLabel(status: SkinSitePublicDownloadStatus): string {
switch (status) {
case 'NEW':
return formatMessage(messages.statusNew)
@@ -68,7 +68,7 @@ async function openUrl(url: string) {
await open(url)
}
function download(item: SkinSiteDownloadItem) {
function download(item: SkinSitePublicDownloadItem) {
const route = item.routes?.[0]
void openUrl(route?.url ?? item.url)
}
@@ -78,8 +78,8 @@ async function load() {
failed.value = false
try {
const [listResult, categoryResult] = await Promise.all([
skinSiteDownloadList(),
skinSiteDownloadCategories(),
skinSitePublicDownloadList(),
skinSitePublicDownloadCategories(),
])
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
@@ -18,6 +18,7 @@ import SkinSiteNavCards from './SkinSiteNavCards.vue'
import SkinSiteProfileModal from './SkinSiteProfileModal.vue'
import SkinSiteProfileView from './SkinSiteProfileView.vue'
import SkinSiteReviewView from './SkinSiteReviewView.vue'
import WikiView from './WikiView.vue'
const { formatMessage } = useVIntl()
const { addNotification } = injectNotificationManager()
@@ -45,6 +46,7 @@ const view = ref<
| 'bug-detail'
| 'bug-notifications'
| 'admin'
| 'wiki'
>('home')
const selectedBugId = ref<number | null>(null)
@@ -98,6 +100,8 @@ async function copyAddress() {
@open="openBug"
/>
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
<WikiView v-else-if="view === 'wiki'" />
<SkinSiteBugDetailView
v-else-if="view === 'bug-detail' && selectedBugId !== null"
:issue-id="selectedBugId"
@@ -3,7 +3,7 @@ import { ExternalIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import { type SkinSiteMapServer, skinSiteMapServers } from '@/helpers/skin-site-api'
import { type SkinSitePublicMapServer, skinSitePublicMapServers } from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
@@ -23,7 +23,7 @@ const messages = defineMessages({
const emit = defineEmits<{ (e: 'back'): void }>()
const servers = ref<SkinSiteMapServer[]>([])
const servers = ref<SkinSitePublicMapServer[]>([])
const internal = ref(false)
const serverIndex = ref(0)
const mapIndex = ref(0)
@@ -52,7 +52,7 @@ async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
const result = await skinSitePublicMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
if (!servers.value.some((s) => !s.internal)) internal.value = true
@@ -12,7 +12,9 @@ import type { Component } from 'vue'
const { formatMessage } = useVIntl()
const emit = defineEmits<{ (e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map'): void }>()
const emit = defineEmits<{
(e: 'navigate', view: 'downloads' | 'review' | 'bugs' | 'map' | 'wiki'): void
}>()
const messages = defineMessages({
title: { id: 'app.skin-site.home.nav.title', defaultMessage: 'Skin site' },
@@ -104,8 +106,19 @@ const items: NavItem[] = [
]
function open(item: NavItem) {
if (item.id === 'download' || item.id === 'review' || item.id === 'bugs' || item.id === 'map') {
emit('navigate', item.id === 'download' ? 'downloads' : item.id)
if (
item.id === 'download' ||
item.id === 'review' ||
item.id === 'bugs' ||
item.id === 'map' ||
item.id === 'wiki'
) {
emit(
'navigate',
item.id === 'download'
? 'downloads'
: (item.id as 'review' | 'bugs' | 'map' | 'wiki'),
)
return
}
void import('@tauri-apps/plugin-opener').then(({ openUrl }) => openUrl(item.href))
@@ -5,8 +5,8 @@ import { computed, onMounted, ref } from 'vue'
import {
skinSiteReviewCatalog,
type SkinSiteReviewPaper,
type SkinSiteReviewQuestion,
type SkinSiteReviewPublicPaper,
type SkinSiteReviewPublicQuestion,
type SkinSiteReviewReceipt,
skinSiteSubmitReview,
} from '@/helpers/skin-site-api'
@@ -46,10 +46,10 @@ const messages = defineMessages({
const emit = defineEmits<{ (e: 'back'): void }>()
const papers = ref<SkinSiteReviewPaper[]>([])
const papers = ref<SkinSiteReviewPublicPaper[]>([])
const loading = ref(true)
const failed = ref(false)
const activePaper = ref<SkinSiteReviewPaper | null>(null)
const activePaper = ref<SkinSiteReviewPublicPaper | null>(null)
const email = ref('')
const inviteCode = ref('')
const answers = ref<Record<string, string | string[]>>({})
@@ -70,7 +70,7 @@ const requiredAnswered = computed(() => {
const canSubmit = computed(() => emailValid.value && requiredAnswered.value && !submitting.value)
function defaultAnswer(q: SkinSiteReviewQuestion): string | string[] {
function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
if (q.type === 'MULTIPLE' || q.type === 'SINGLE') {
const preset = (q.options ?? []).filter((o) => o.defaultSelected).map((o) => o.id)
return q.type === 'MULTIPLE' ? preset : (preset[0] ?? '')
@@ -78,7 +78,7 @@ function defaultAnswer(q: SkinSiteReviewQuestion): string | string[] {
return ''
}
function openPaper(paper: SkinSiteReviewPaper) {
function openPaper(paper: SkinSiteReviewPublicPaper) {
activePaper.value = paper
answers.value = {}
for (const q of paper.questions) answers.value[q.id] = defaultAnswer(q)
@@ -93,7 +93,7 @@ function closePaper() {
receipt.value = null
}
function toggleMultiple(q: SkinSiteReviewQuestion, optionId: string, checked: boolean) {
function toggleMultiple(q: SkinSiteReviewPublicQuestion, optionId: string, checked: boolean) {
const current = Array.isArray(answers.value[q.id]) ? (answers.value[q.id] as string[]) : []
answers.value[q.id] = checked
? [...current.filter((id) => id !== optionId), optionId]
@@ -0,0 +1,192 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiPageDetail,
skinSiteWikiPage,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
initialTitle: '',
})
const messages = defineMessages({
title: { id: 'app.skin-site.wiki.title', defaultMessage: 'StarLight Wiki' },
searchPlaceholder: {
id: 'app.skin-site.wiki.search-placeholder',
defaultMessage: 'Enter a page title',
},
open: { id: 'app.skin-site.wiki.open', defaultMessage: 'Open' },
loading: { id: 'app.skin-site.wiki.loading', defaultMessage: 'Loading…' },
notFound: {
id: 'app.skin-site.wiki.not-found',
defaultMessage: 'Page not found.',
},
empty: {
id: 'app.skin-site.wiki.empty',
defaultMessage: 'Enter a page title above to start reading.',
},
updatedAt: {
id: 'app.skin-site.wiki.updated-at',
defaultMessage: 'Last edited {date} by {author}',
},
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const history = ref<string[]>([])
const currentTitle = computed(() => page.value?.title ?? '')
async function openPage(title: string) {
const trimmed = title.trim()
if (!trimmed) return
titleInput.value = trimmed
loading.value = true
notFound.value = false
try {
const res = await skinSiteWikiPage(trimmed)
if (!res.success) {
notFound.value = true
page.value = null
return
}
page.value = res.payload
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
history.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
notFound.value = true
page.value = null
} finally {
loading.value = false
}
}
function goBack() {
const prev = history.value.pop()
if (prev) void openPage(prev)
}
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return '—'
}
}
function onNavigate(title: string) {
if (title) void openPage(title)
}
onMounted(() => {
if (props.initialTitle) void openPage(props.initialTitle)
})
</script>
<template>
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="history.length"
type="button"
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="goBack"
>
← {{ formatMessage(messages.back) }}
</button>
<h2 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h2>
<input
v-model="titleInput"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg-raised px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="openPage(titleInput)"
/>
<button
type="button"
class="rounded-lg bg-brand px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
@click="openPage(titleInput)"
>
{{ formatMessage(messages.open) }}
</button>
</div>
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p
v-else-if="notFound"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.notFound) }}
</p>
<p
v-else-if="!page"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<div class="flex flex-wrap items-baseline justify-between gap-2 border-b border-solid border-surface-5 pb-2">
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
</div>
<p class="m-0 text-[11px] text-secondary">
{{
formatMessage(messages.updatedAt, {
date: formatDate(page.updatedAt),
author: page.authorName,
})
}}
</p>
<div
v-if="page.redirectTarget"
class="rounded-lg border border-solid border-yellow/40 bg-yellow/10 p-3 text-xs text-yellow"
>
重定向到
<button
type="button"
class="font-semibold underline underline-offset-2"
@click="openPage(page.redirectTarget ?? '')"
>
{{ page.redirectTarget }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="page.nodes" @navigate="onNavigate" />
</article>
<div v-if="page.categories.length" class="flex flex-wrap items-center gap-2 pt-2">
<span class="text-[11px] text-secondary">{{ formatMessage(messages.categories) }}:</span>
<span
v-for="category in page.categories"
:key="category"
class="rounded bg-bg-raised px-2 py-0.5 text-[11px] text-secondary"
>
{{ category }}
</span>
</div>
</template>
</div>
</template>
@@ -0,0 +1,273 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
skinSiteAccessAudit,
type SkinSiteAccessAuditEntry,
skinSiteUpdateAccessAuditSettings,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.audit.title', defaultMessage: 'Access audit' },
loading: { id: 'app.skin-site.admin.audit.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.audit.failed', defaultMessage: 'Could not load audit log.' },
empty: { id: 'app.skin-site.admin.audit.empty', defaultMessage: 'No audit entries.' },
subject: { id: 'app.skin-site.admin.audit.subject', defaultMessage: 'Subject' },
action: { id: 'app.skin-site.admin.audit.action', defaultMessage: 'Action' },
sourceIp: { id: 'app.skin-site.admin.audit.source-ip', defaultMessage: 'Source IP' },
reportedIp: {
id: 'app.skin-site.admin.audit.reported-ip',
defaultMessage: 'Reported player IP',
},
count: { id: 'app.skin-site.admin.audit.count', defaultMessage: 'Count' },
firstAt: { id: 'app.skin-site.admin.audit.first-at', defaultMessage: 'First seen' },
lastAt: { id: 'app.skin-site.admin.audit.last-at', defaultMessage: 'Last seen' },
proxy: { id: 'app.skin-site.admin.audit.proxy', defaultMessage: 'Proxy' },
internal: { id: 'app.skin-site.admin.audit.internal', defaultMessage: 'Internal' },
searchSubject: {
id: 'app.skin-site.admin.audit.search-subject',
defaultMessage: 'Subject',
},
searchIp: { id: 'app.skin-site.admin.audit.search-ip', defaultMessage: 'IP' },
searchAction: { id: 'app.skin-site.admin.audit.search-action', defaultMessage: 'Action' },
search: { id: 'app.skin-site.admin.audit.search', defaultMessage: 'Search' },
clear: { id: 'app.skin-site.admin.audit.clear', defaultMessage: 'Clear' },
loadMore: { id: 'app.skin-site.admin.audit.load-more', defaultMessage: 'Load more' },
retention: {
id: 'app.skin-site.admin.audit.retention',
defaultMessage: 'Retention (days)',
},
recordLimit: { id: 'app.skin-site.admin.audit.record-limit', defaultMessage: 'Record limit' },
saveRetention: {
id: 'app.skin-site.admin.audit.save-retention',
defaultMessage: 'Save',
},
retentionSaved: {
id: 'app.skin-site.admin.audit.retention-saved',
defaultMessage: 'Retention updated.',
},
})
const items = ref<SkinSiteAccessAuditEntry[]>([])
const nextCursor = ref<number | null>(null)
const retentionDays = ref(0)
const recordLimit = ref(0)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const subjectFilter = ref('')
const ipFilter = ref('')
const actionFilter = ref('')
const retentionDraft = ref(0)
const savingRetention = ref(false)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
nextCursor.value = null
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAccessAudit({
before: reset ? undefined : (nextCursor.value ?? undefined),
subject: subjectFilter.value.trim() || undefined,
ip: ipFilter.value.trim() || undefined,
action: actionFilter.value.trim() || undefined,
})
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
nextCursor.value = page.nextCursor
retentionDays.value = page.retentionDays
recordLimit.value = page.recordLimit
retentionDraft.value = page.retentionDays
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
function clearFilters() {
subjectFilter.value = ''
ipFilter.value = ''
actionFilter.value = ''
void load(true)
}
async function saveRetention() {
if (savingRetention.value) return
savingRetention.value = true
try {
const result = await skinSiteUpdateAccessAuditSettings(Number(retentionDraft.value) || 0)
if (!result.success) throw new Error(result.errorMessage)
retentionDays.value = result.payload.retentionDays
retentionDraft.value = result.payload.retentionDays
addNotification({ type: 'success', title: formatMessage(messages.retentionSaved) })
} catch (error) {
handleError(error)
} finally {
savingRetention.value = false
}
}
onMounted(() => void load(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>
<span class="text-xs text-secondary">
{{ formatMessage(messages.recordLimit) }}: {{ recordLimit }}
</span>
</div>
<div class="flex flex-wrap items-end gap-2">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchSubject) }}</span>
<input
v-model="subjectFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchIp) }}</span>
<input
v-model="ipFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.searchAction) }}</span>
<input
v-model="actionFilter"
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"
@keyup.enter="load(true)"
/>
</label>
<button
type="button"
class="rounded-full bg-brand px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="load(true)"
>
{{ formatMessage(messages.search) }}
</button>
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="clearFilters"
>
{{ formatMessage(messages.clear) }}
</button>
</div>
<div class="flex flex-wrap items-end gap-2 rounded-xl bg-bg-raised p-3">
<label class="flex flex-col gap-1">
<span class="text-xs text-secondary">{{ formatMessage(messages.retention) }}</span>
<input
v-model.number="retentionDraft"
type="number"
min="1"
class="w-24 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
</label>
<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="savingRetention"
@click="saveRetention"
>
{{ formatMessage(messages.saveRetention) }}
</button>
<span class="text-xs text-secondary">
{{ formatMessage(messages.retention) }}: {{ retentionDays }}
</span>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.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="entry in items"
:key="entry.id"
class="flex flex-col gap-1.5 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ entry.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ entry.subject }}
</span>
<span
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ entry.action }}
</span>
<span class="shrink-0 text-xs text-secondary">×{{ entry.count }}</span>
<span
v-if="entry.proxy"
class="shrink-0 rounded-full bg-orange px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.proxy) }}
</span>
<span
v-if="entry.internalServer"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.internal) }}
</span>
</div>
<div class="flex flex-wrap items-center gap-3 text-xs text-secondary">
<span>{{ formatMessage(messages.sourceIp) }}: {{ entry.sourceIp }}</span>
<span v-if="entry.reportedPlayerIp">
{{ formatMessage(messages.reportedIp) }}: {{ entry.reportedPlayerIp }}
</span>
<span>{{ formatMessage(messages.firstAt) }}: {{ formatDateTime(entry.firstAt) }}</span>
<span>{{ formatMessage(messages.lastAt) }}: {{ formatDateTime(entry.lastAt) }}</span>
</div>
</div>
<button
v-if="nextCursor !== null"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="load(false)"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,366 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAdminDonations,
skinSiteCreateDonation,
skinSiteDeleteDonation,
type SkinSiteDonationAdminEntry,
type SkinSiteDonationSummary,
skinSiteUpdateDonation,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.donations.title', defaultMessage: 'Donations' },
loading: { id: 'app.skin-site.admin.donations.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.donations.failed',
defaultMessage: 'Could not load donations.',
},
empty: { id: 'app.skin-site.admin.donations.empty', defaultMessage: 'No donations yet.' },
totalAmount: { id: 'app.skin-site.admin.donations.total-amount', defaultMessage: 'Total' },
donorCount: { id: 'app.skin-site.admin.donations.donor-count', defaultMessage: 'Donors' },
donor: { id: 'app.skin-site.admin.donations.donor', defaultMessage: 'Donor' },
amount: { id: 'app.skin-site.admin.donations.amount', defaultMessage: 'Amount' },
updatedAt: { id: 'app.skin-site.admin.donations.updated-at', defaultMessage: 'Updated' },
external: { id: 'app.skin-site.admin.donations.external', defaultMessage: 'External' },
linked: { id: 'app.skin-site.admin.donations.linked', defaultMessage: 'Linked' },
create: { id: 'app.skin-site.admin.donations.create', defaultMessage: 'Add donation' },
save: { id: 'app.skin-site.admin.donations.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.donations.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.donations.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.donations.remove', defaultMessage: 'Delete' },
amountField: { id: 'app.skin-site.admin.donations.amount-field', defaultMessage: 'Amount' },
displayName: {
id: 'app.skin-site.admin.donations.display-name',
defaultMessage: 'Display name',
},
userUuid: { id: 'app.skin-site.admin.donations.user-uuid', defaultMessage: 'User UUID' },
qq: { id: 'app.skin-site.admin.donations.qq', defaultMessage: 'QQ' },
appendMode: {
id: 'app.skin-site.admin.donations.append-mode',
defaultMessage: 'Append to existing amount',
},
created: { id: 'app.skin-site.admin.donations.created', defaultMessage: 'Donation created.' },
updated: { id: 'app.skin-site.admin.donations.updated', defaultMessage: 'Donation updated.' },
deleted: { id: 'app.skin-site.admin.donations.deleted', defaultMessage: 'Donation deleted.' },
confirmDelete: {
id: 'app.skin-site.admin.donations.confirm-delete',
defaultMessage: 'Delete the donation from “{name}”? This cannot be undone.',
},
loadMore: { id: 'app.skin-site.admin.donations.load-more', defaultMessage: 'Load more' },
})
const items = ref<SkinSiteDonationAdminEntry[]>([])
const summary = ref<SkinSiteDonationSummary | null>(null)
const hasMore = ref(false)
const loading = ref(true)
const loadingMore = ref(false)
const failed = ref(false)
const offset = ref(0)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const editingVersion = ref(0)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
amount: '',
displayName: '',
userUuid: '',
qq: '',
appendMode: false,
})
const canSave = computed(() => form.value.amount.trim().length > 0 && !saving.value)
function formatDateTime(ts: number): string {
try {
return new Date(ts).toLocaleString()
} catch {
return ''
}
}
function resetForm() {
form.value = { amount: '', displayName: '', userUuid: '', qq: '', appendMode: false }
editingId.value = null
editingVersion.value = 0
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(entry: SkinSiteDonationAdminEntry) {
form.value = {
amount: entry.donor.amount,
displayName: entry.donor.username,
userUuid: entry.userUuid ?? '',
qq: entry.qq ?? '',
appendMode: false,
}
editingId.value = entry.donor.id
editingVersion.value = entry.version
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
async function load(reset = true) {
if (reset) {
loading.value = true
items.value = []
offset.value = 0
} else {
loadingMore.value = true
}
failed.value = false
try {
const result = await skinSiteAdminDonations(offset.value)
if (!result.success) throw new Error(result.errorMessage)
const page = result.payload
items.value = reset ? page.items : [...items.value, ...page.items]
summary.value = page.summary
hasMore.value = page.hasMore
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
loadingMore.value = false
}
}
async function loadMore() {
if (!hasMore.value) return
offset.value += 20
await load(false)
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
if (editingId.value === null) {
const payload: Parameters<typeof skinSiteCreateDonation>[0] = {
amount: form.value.amount.trim(),
}
if (form.value.userUuid.trim()) payload.userUuid = form.value.userUuid.trim()
if (form.value.qq.trim()) payload.qq = form.value.qq.trim()
if (form.value.displayName.trim()) payload.displayName = form.value.displayName.trim()
const result = await skinSiteCreateDonation(payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.created) })
} else {
const result = await skinSiteUpdateDonation(editingId.value, {
amount: form.value.amount.trim(),
version: editingVersion.value,
displayName: form.value.displayName.trim() || undefined,
mode: form.value.appendMode ? 'APPEND' : 'REPLACE',
})
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.updated) })
}
closeEditor()
await load(true)
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(entry: SkinSiteDonationAdminEntry) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDelete, { name: entry.donor.username }))) return
deletingId.value = entry.donor.id
try {
const result = await skinSiteDeleteDonation(entry.donor.id, entry.version)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await load(true)
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
onMounted(() => void load(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="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.create) }}
</button>
</div>
<div v-if="summary" class="grid grid-cols-1 gap-3 sm:grid-cols-2">
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.totalAmount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.totalAmount }}</span>
</div>
<div class="flex flex-col gap-1 rounded-xl bg-bg-raised p-4">
<span class="text-xs text-secondary">{{ formatMessage(messages.donorCount) }}</span>
<span class="text-xl font-bold text-contrast">{{ summary.donorCount }}</span>
</div>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.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="entry in items"
:key="entry.donor.id"
class="flex flex-wrap items-center gap-3 rounded-xl bg-bg-raised p-3"
>
<span class="shrink-0 text-xs text-secondary">#{{ entry.donor.rank }}</span>
<div class="flex min-w-0 flex-1 flex-col">
<span class="truncate text-sm font-semibold text-contrast">
{{ entry.donor.username }}
</span>
<span class="truncate text-xs text-secondary">
{{ formatMessage(messages.updatedAt) }}:
{{ formatDateTime(entry.updatedAt) }}
</span>
</div>
<span class="shrink-0 text-sm font-bold text-contrast">{{ entry.donor.amount }}</span>
<span
v-if="entry.external"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ formatMessage(messages.external) }}
</span>
<span
v-else
class="shrink-0 rounded-full bg-green px-2 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.linked) }}
</span>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(entry)"
>
{{ formatMessage(messages.edit) }}
</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="deletingId !== null"
@click="remove(entry)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
<button
v-if="hasMore"
type="button"
class="w-fit self-center rounded-full bg-button-bg px-4 py-2 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="loadingMore"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 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.amountField) }}
</span>
<input
v-model="form.amount"
type="text"
inputmode="decimal"
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.displayName) }}
</span>
<input
v-model="form.displayName"
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>
<template v-if="editingId === null">
<label class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.userUuid) }}
</span>
<input
v-model="form.userUuid"
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.qq) }}
</span>
<input
v-model="form.qq"
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>
</template>
<label v-else class="flex items-center gap-2">
<input v-model="form.appendMode" type="checkbox" />
<span class="text-sm text-contrast">{{ formatMessage(messages.appendMode) }}</span>
</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="closeEditor"
>
{{ 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="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,576 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import {
skinSiteAddDownloadCategory,
skinSiteAddDownloadItem,
skinSiteBindDownloadCategory,
skinSiteDeleteDownloadCategory,
skinSiteDeleteDownloadItem,
skinSiteDownloadCategories,
type SkinSiteDownloadCategory,
type SkinSiteDownloadItem,
skinSiteDownloadItemCount,
skinSiteDownloadItems,
type SkinSiteDownloadRoute,
type SkinSiteDownloadStatus,
skinSiteUnbindDownloadCategory,
skinSiteUpdateDownloadItem,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.downloads.title', defaultMessage: 'Downloads' },
loading: { id: 'app.skin-site.admin.downloads.loading', defaultMessage: 'Loading…' },
failed: {
id: 'app.skin-site.admin.downloads.failed',
defaultMessage: 'Could not load downloads.',
},
empty: { id: 'app.skin-site.admin.downloads.empty', defaultMessage: 'No download items.' },
categories: { id: 'app.skin-site.admin.downloads.categories', defaultMessage: 'Categories' },
all: { id: 'app.skin-site.admin.downloads.all', defaultMessage: 'All items' },
addCategory: { id: 'app.skin-site.admin.downloads.add-category', defaultMessage: 'Add' },
categoryName: {
id: 'app.skin-site.admin.downloads.category-name',
defaultMessage: 'Category name',
},
removeCategory: {
id: 'app.skin-site.admin.downloads.remove-category',
defaultMessage: 'Remove',
},
total: { id: 'app.skin-site.admin.downloads.total', defaultMessage: '{count} items' },
addItem: { id: 'app.skin-site.admin.downloads.add-item', defaultMessage: 'New item' },
save: { id: 'app.skin-site.admin.downloads.save', defaultMessage: 'Save' },
cancel: { id: 'app.skin-site.admin.downloads.cancel', defaultMessage: 'Cancel' },
edit: { id: 'app.skin-site.admin.downloads.edit', defaultMessage: 'Edit' },
remove: { id: 'app.skin-site.admin.downloads.remove', defaultMessage: 'Delete' },
filename: { id: 'app.skin-site.admin.downloads.filename', defaultMessage: 'File name' },
description: {
id: 'app.skin-site.admin.downloads.description',
defaultMessage: 'Description',
},
routes: { id: 'app.skin-site.admin.downloads.routes', defaultMessage: 'Download routes' },
routeName: { id: 'app.skin-site.admin.downloads.route-name', defaultMessage: 'Name' },
routeUrl: { id: 'app.skin-site.admin.downloads.route-url', defaultMessage: 'URL' },
addRoute: { id: 'app.skin-site.admin.downloads.add-route', defaultMessage: 'Add route' },
removeRoute: { id: 'app.skin-site.admin.downloads.remove-route', defaultMessage: 'Remove' },
statuses: { id: 'app.skin-site.admin.downloads.statuses', defaultMessage: 'Statuses' },
bindCategory: {
id: 'app.skin-site.admin.downloads.bind-category',
defaultMessage: 'Categories',
},
uploader: { id: 'app.skin-site.admin.downloads.uploader', defaultMessage: 'by {name}' },
created: { id: 'app.skin-site.admin.downloads.created', defaultMessage: 'Item created.' },
updated: { id: 'app.skin-site.admin.downloads.updated', defaultMessage: 'Item updated.' },
deleted: { id: 'app.skin-site.admin.downloads.deleted', defaultMessage: 'Item deleted.' },
categoryAdded: {
id: 'app.skin-site.admin.downloads.category-added',
defaultMessage: 'Category added.',
},
categoryDeleted: {
id: 'app.skin-site.admin.downloads.category-deleted',
defaultMessage: 'Category deleted.',
},
confirmDeleteItem: {
id: 'app.skin-site.admin.downloads.confirm-delete-item',
defaultMessage: 'Delete “{name}”? This cannot be undone.',
},
confirmDeleteCategory: {
id: 'app.skin-site.admin.downloads.confirm-delete-category',
defaultMessage: 'Delete category “{name}”?',
},
})
const STATUS_OPTIONS: SkinSiteDownloadStatus[] = ['OK', 'NEW', 'LEGACY', 'OUTDATED', 'REMOVAL']
const items = ref<SkinSiteDownloadItem[]>([])
const categories = ref<SkinSiteDownloadCategory[]>([])
const total = ref(0)
const loading = ref(true)
const failed = ref(false)
const activeCategory = ref<string>('')
const newCategoryName = ref('')
const categoryWorking = ref(false)
const editorOpen = ref(false)
const editingId = ref<number | null>(null)
const saving = ref(false)
const deletingId = ref<number | null>(null)
const form = ref({
filename: '',
description: '',
status: [] as SkinSiteDownloadStatus[],
routes: [{ name: '', url: '' }] as Array<{ name: string; url: string }>,
selectedCategories: [] as number[],
})
const canSave = computed(
() =>
form.value.filename.trim().length > 0 &&
form.value.routes.some((r) => r.url.trim().length > 0) &&
!saving.value,
)
function resetForm() {
form.value = {
filename: '',
description: '',
status: [],
routes: [{ name: '', url: '' }],
selectedCategories: [],
}
editingId.value = null
}
function openCreate() {
resetForm()
editorOpen.value = true
}
function openEdit(item: SkinSiteDownloadItem) {
form.value = {
filename: item.filename,
description: item.description,
status: [...item.status],
routes: item.routes.length
? item.routes.map((r) => ({ name: r.name ?? '', url: r.url }))
: [{ name: '', url: '' }],
selectedCategories: categories.value
.filter((c) => item.category.includes(c.name))
.map((c) => c.id),
}
editingId.value = item.id
editorOpen.value = true
}
function closeEditor() {
editorOpen.value = false
resetForm()
}
function addRouteRow() {
form.value.routes.push({ name: '', url: '' })
}
function removeRouteRow(index: number) {
if (form.value.routes.length <= 1) return
form.value.routes.splice(index, 1)
}
function toggleStatus(status: SkinSiteDownloadStatus) {
const i = form.value.status.indexOf(status)
if (i >= 0) form.value.status.splice(i, 1)
else form.value.status.push(status)
}
async function loadCategories() {
try {
const result = await skinSiteDownloadCategories()
if (result.success) categories.value = result.payload ?? []
} catch (error) {
handleError(error)
}
}
async function loadItems() {
loading.value = true
failed.value = false
try {
const listResult = await skinSiteDownloadItems({
limit: 200,
offset: 0,
category: activeCategory.value || undefined,
})
if (!listResult.success) throw new Error(listResult.errorMessage)
items.value = listResult.payload ?? []
const countResult = await skinSiteDownloadItemCount(activeCategory.value || undefined)
if (countResult.success && typeof countResult.payload === 'number') {
total.value = countResult.payload
}
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
async function selectCategory(name: string) {
activeCategory.value = name
await loadItems()
}
async function addCategory() {
const name = newCategoryName.value.trim()
if (!name || categoryWorking.value) return
categoryWorking.value = true
try {
const result = await skinSiteAddDownloadCategory(name)
if (!result.success) throw new Error(result.errorMessage)
newCategoryName.value = ''
addNotification({ type: 'success', title: formatMessage(messages.categoryAdded) })
await loadCategories()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function removeCategory(category: SkinSiteDownloadCategory) {
if (categoryWorking.value) return
if (!window.confirm(formatMessage(messages.confirmDeleteCategory, { name: category.name })))
return
categoryWorking.value = true
try {
const result = await skinSiteDeleteDownloadCategory(category.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.categoryDeleted) })
if (activeCategory.value === category.name) activeCategory.value = ''
await loadCategories()
await loadItems()
} catch (error) {
handleError(error)
} finally {
categoryWorking.value = false
}
}
async function save() {
if (!canSave.value) return
saving.value = true
try {
const routes: SkinSiteDownloadRoute[] = form.value.routes
.filter((r) => r.url.trim())
.map((r) => ({ name: r.name.trim() || null, url: r.url.trim() }))
const payload = {
routes,
filename: form.value.filename.trim(),
description: form.value.description,
status: form.value.status,
}
const result =
editingId.value === null
? await skinSiteAddDownloadItem(payload)
: await skinSiteUpdateDownloadItem(editingId.value, payload)
if (!result.success) throw new Error(result.errorMessage)
addNotification({
type: 'success',
title: formatMessage(editingId.value === null ? messages.created : messages.updated),
})
closeEditor()
await loadItems()
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
async function remove(item: SkinSiteDownloadItem) {
if (deletingId.value !== null) return
if (!window.confirm(formatMessage(messages.confirmDeleteItem, { name: item.filename }))) return
deletingId.value = item.id
try {
const result = await skinSiteDeleteDownloadItem(item.id)
if (!result.success) throw new Error(result.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
await loadItems()
} catch (error) {
handleError(error)
} finally {
deletingId.value = null
}
}
async function toggleItemCategory(item: SkinSiteDownloadItem, category: SkinSiteDownloadCategory) {
try {
const bound = item.category.includes(category.name)
const result = bound
? await skinSiteUnbindDownloadCategory(item.id, category.id)
: await skinSiteBindDownloadCategory(item.id, category.id)
if (!result.success) throw new Error(result.errorMessage)
await loadItems()
} catch (error) {
handleError(error)
}
}
onMounted(async () => {
await loadCategories()
await loadItems()
})
</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>
<span class="text-xs text-secondary">
{{ formatMessage(messages.total, { count: total }) }}
</span>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="openCreate"
>
{{ formatMessage(messages.addItem) }}
</button>
</div>
<div class="flex min-h-0 gap-4">
<aside class="flex w-56 shrink-0 flex-col gap-2">
<span class="text-xs font-bold tracking-wide text-secondary uppercase">
{{ formatMessage(messages.categories) }}
</span>
<button
type="button"
class="rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === ''
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory('')"
>
{{ formatMessage(messages.all) }}
</button>
<div v-for="category in categories" :key="category.id" class="flex items-center gap-1">
<button
type="button"
class="min-w-0 flex-1 truncate rounded-lg px-2 py-1.5 text-left text-xs font-semibold"
:class="
activeCategory === category.name
? 'bg-brand text-contrast'
: 'text-secondary hover:bg-button-bg hover:text-contrast'
"
@click="selectCategory(category.name)"
>
{{ category.name }}
</button>
<button
type="button"
class="shrink-0 rounded px-1 text-[10px] text-secondary hover:text-red"
:disabled="categoryWorking"
@click="removeCategory(category)"
>
×
</button>
</div>
<div class="flex items-center gap-1 pt-1">
<input
v-model="newCategoryName"
type="text"
:placeholder="formatMessage(messages.categoryName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="addCategory"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="categoryWorking || !newCategoryName.trim()"
@click="addCategory"
>
{{ formatMessage(messages.addCategory) }}
</button>
</div>
</aside>
<div class="flex min-w-0 flex-1 flex-col gap-2">
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!items.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="item in items"
:key="item.id"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<span class="shrink-0 text-xs text-secondary">#{{ item.id }}</span>
<span class="min-w-0 flex-1 truncate text-sm font-semibold text-contrast">
{{ item.filename }}
</span>
<span
v-for="s in item.status"
:key="s"
class="shrink-0 rounded-full bg-button-bg px-2 py-0.5 text-[10px] font-semibold text-secondary"
>
{{ s }}
</span>
<span class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
</span>
</div>
<div v-if="item.description" class="text-xs text-secondary">
{{ item.description }}
</div>
<div class="flex flex-wrap gap-1">
<span
v-for="route in item.routes"
:key="route.url"
class="rounded bg-bg px-2 py-0.5 font-mono text-[10px] text-secondary"
>
{{ route.name ? route.name + ': ' : '' }}{{ route.url }}
</span>
</div>
<div class="flex flex-wrap items-center gap-2">
<span class="text-xs text-secondary">
{{ formatMessage(messages.bindCategory) }}:
</span>
<button
v-for="category in categories"
:key="category.id"
type="button"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="
item.category.includes(category.name)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleItemCategory(item, category)"
>
{{ category.name }}
</button>
</div>
<div class="flex flex-wrap gap-2">
<button
type="button"
class="rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="openEdit(item)"
>
{{ 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="deletingId !== null"
@click="remove(item)"
>
{{ formatMessage(messages.remove) }}
</button>
</div>
</div>
</div>
</div>
</div>
<div
v-if="editorOpen"
class="flex flex-col gap-3 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.filename) }}
</span>
<input
v-model="form.filename"
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.description) }}
</span>
<textarea
v-model="form.description"
rows="3"
class="w-full resize-y rounded-lg border border-solid border-surface-5 bg-bg px-3 py-2 text-sm text-contrast outline-none focus:border-brand"
/>
</label>
<div class="flex flex-col gap-1">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.statuses) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="status in STATUS_OPTIONS"
:key="status"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold"
:class="
form.status.includes(status)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary'
"
@click="toggleStatus(status)"
>
{{ status }}
</button>
</div>
</div>
<div class="flex flex-col gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.routes) }}
</span>
<div
v-for="(route, idx) in form.routes"
:key="idx"
class="flex flex-wrap items-center gap-2"
>
<input
v-model="route.name"
type="text"
:placeholder="formatMessage(messages.routeName)"
class="w-32 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="route.url"
type="text"
:placeholder="formatMessage(messages.routeUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red disabled:opacity-50"
:disabled="form.routes.length <= 1"
@click="removeRouteRow(idx)"
>
{{ formatMessage(messages.removeRoute) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addRouteRow"
>
{{ formatMessage(messages.addRoute) }}
</button>
</div>
<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="closeEditor"
>
{{ 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="!canSave"
@click="save"
>
{{ formatMessage(messages.save) }}
</button>
</div>
</div>
</div>
</template>
@@ -0,0 +1,982 @@
<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>
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,188 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { onMounted, ref } from 'vue'
import {
type SkinSiteMapServer,
skinSiteMapServers,
skinSiteSaveMapServers,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.servers.title', defaultMessage: 'Map servers' },
loading: { id: 'app.skin-site.admin.servers.loading', defaultMessage: 'Loading…' },
failed: { id: 'app.skin-site.admin.servers.failed', defaultMessage: 'Could not load servers.' },
empty: { id: 'app.skin-site.admin.servers.empty', defaultMessage: 'No map servers configured.' },
serverName: { id: 'app.skin-site.admin.servers.server-name', defaultMessage: 'Server name' },
internal: { id: 'app.skin-site.admin.servers.internal', defaultMessage: 'Internal' },
maps: { id: 'app.skin-site.admin.servers.maps', defaultMessage: 'Maps' },
mapName: { id: 'app.skin-site.admin.servers.map-name', defaultMessage: 'Map name' },
mapUrl: { id: 'app.skin-site.admin.servers.map-url', defaultMessage: 'Map URL' },
addMap: { id: 'app.skin-site.admin.servers.add-map', defaultMessage: 'Add map' },
removeMap: { id: 'app.skin-site.admin.servers.remove-map', defaultMessage: 'Remove' },
addServer: { id: 'app.skin-site.admin.servers.add-server', defaultMessage: 'Add server' },
removeServer: {
id: 'app.skin-site.admin.servers.remove-server',
defaultMessage: 'Remove server',
},
save: { id: 'app.skin-site.admin.servers.save', defaultMessage: 'Save changes' },
saving: { id: 'app.skin-site.admin.servers.saving', defaultMessage: 'Saving…' },
saved: { id: 'app.skin-site.admin.servers.saved', defaultMessage: 'Map servers saved.' },
})
const servers = ref<SkinSiteMapServer[]>([])
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteMapServers()
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? []
} catch (error) {
failed.value = true
handleError(error)
} finally {
loading.value = false
}
}
function addServer() {
servers.value.push({ name: '', internal: false, maps: [{ name: '', url: '' }] })
}
function removeServer(index: number) {
servers.value.splice(index, 1)
}
function addMap(server: SkinSiteMapServer) {
server.maps.push({ name: '', url: '' })
}
function removeMap(server: SkinSiteMapServer, index: number) {
server.maps.splice(index, 1)
}
async function save() {
if (saving.value) return
saving.value = true
try {
const cleaned = servers.value.map((s) => ({
name: s.name.trim(),
internal: s.internal,
maps: s.maps
.filter((m) => m.name.trim() || m.url.trim())
.map((m) => ({ name: m.name.trim(), url: m.url.trim() })),
}))
const result = await skinSiteSaveMapServers(cleaned)
if (!result.success) throw new Error(result.errorMessage)
servers.value = result.payload ?? cleaned
addNotification({ type: 'success', title: formatMessage(messages.saved) })
} catch (error) {
handleError(error)
} finally {
saving.value = false
}
}
onMounted(() => void load())
</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 bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="addServer"
>
{{ formatMessage(messages.addServer) }}
</button>
<button
type="button"
class="ml-auto rounded-full bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="saving"
@click="save"
>
{{ saving ? formatMessage(messages.saving) : formatMessage(messages.save) }}
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<p v-else-if="failed" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.failed) }}
</p>
<p v-else-if="!servers.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-3">
<div
v-for="(server, si) in servers"
:key="si"
class="flex flex-col gap-2 rounded-xl bg-bg-raised p-3"
>
<div class="flex flex-wrap items-center gap-2">
<input
v-model="server.name"
type="text"
:placeholder="formatMessage(messages.serverName)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-sm text-contrast outline-none focus:border-brand"
/>
<label class="flex shrink-0 items-center gap-1.5 text-xs text-contrast">
<input v-model="server.internal" type="checkbox" />
{{ formatMessage(messages.internal) }}
</label>
<button
type="button"
class="shrink-0 rounded-full bg-red px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="removeServer(si)"
>
{{ formatMessage(messages.removeServer) }}
</button>
</div>
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.maps) }}
</span>
<div v-for="(map, mi) in server.maps" :key="mi" class="flex flex-wrap items-center gap-2">
<input
v-model="map.name"
type="text"
:placeholder="formatMessage(messages.mapName)"
class="w-40 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<input
v-model="map.url"
type="text"
:placeholder="formatMessage(messages.mapUrl)"
class="min-w-0 flex-1 rounded-lg border border-solid border-surface-5 bg-bg px-2 py-1 text-xs text-contrast outline-none focus:border-brand"
/>
<button
type="button"
class="shrink-0 rounded-full bg-button-bg px-2 py-1 text-[10px] font-semibold text-secondary hover:text-red"
@click="removeMap(server, mi)"
>
{{ formatMessage(messages.removeMap) }}
</button>
</div>
<button
type="button"
class="w-fit rounded-full bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="addMap(server)"
>
{{ formatMessage(messages.addMap) }}
</button>
</div>
</div>
</div>
</template>
File diff suppressed because it is too large. Load diff
File diff suppressed because it is too large. Load diff
@@ -0,0 +1,440 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, ref } from 'vue'
import {
type SkinSiteModRuleTag,
skinSiteModExistsByHash,
skinSiteModExistsByName,
skinSiteModHashFromFile,
skinSiteUploadMod,
} from '@/helpers/skin-site-api'
const { formatMessage } = useVIntl()
const { handleError, addNotification } = injectNotificationManager()
defineProps<{ ruleTags: SkinSiteModRuleTag[] }>()
const emit = defineEmits<{ (e: 'finished'): void }>()
const messages = defineMessages({
title: { id: 'app.skin-site.admin.mods.upload-title', defaultMessage: 'Upload mods' },
dropHint: {
id: 'app.skin-site.admin.mods.upload-drop',
defaultMessage: 'Drag .jar files here, or click to choose',
},
dropSub: {
id: 'app.skin-site.admin.mods.upload-sub',
defaultMessage: 'Multiple files supported, up to {size} MB each',
},
tagsLabel: {
id: 'app.skin-site.admin.mods.upload-tags',
defaultMessage: 'Attach rule tags',
},
queue: { id: 'app.skin-site.admin.mods.upload-queue', defaultMessage: 'Files' },
statusPending: {
id: 'app.skin-site.admin.mods.upload-pending',
defaultMessage: 'Pending',
},
statusChecking: {
id: 'app.skin-site.admin.mods.upload-checking',
defaultMessage: 'Checking…',
},
statusUploading: {
id: 'app.skin-site.admin.mods.upload-uploading',
defaultMessage: 'Uploading…',
},
statusConflict: {
id: 'app.skin-site.admin.mods.upload-conflict',
defaultMessage: 'Name exists',
},
statusSuccess: {
id: 'app.skin-site.admin.mods.upload-success',
defaultMessage: 'Done',
},
statusError: {
id: 'app.skin-site.admin.mods.upload-error',
defaultMessage: 'Failed',
},
existsAlready: {
id: 'app.skin-site.admin.mods.upload-exists',
defaultMessage: 'This file already exists on the server.',
},
nameConflictTitle: {
id: 'app.skin-site.admin.mods.upload-name-conflict',
defaultMessage: 'A mod with the same file name already exists',
},
nameConflictHint: {
id: 'app.skin-site.admin.mods.upload-name-conflict-hint',
defaultMessage: 'Overwrite the existing mod, or skip this file.',
},
overwrite: { id: 'app.skin-site.admin.mods.upload-overwrite', defaultMessage: 'Overwrite' },
skip: { id: 'app.skin-site.admin.mods.upload-skip', defaultMessage: 'Skip' },
close: { id: 'app.skin-site.admin.mods.upload-close', defaultMessage: 'Close' },
allDone: {
id: 'app.skin-site.admin.mods.upload-all-done',
defaultMessage: 'All files processed.',
},
tooLarge: {
id: 'app.skin-site.admin.mods.upload-too-large',
defaultMessage: 'File too large: {name}',
},
})
const MAX_MOD_SIZE_MB = 200
const MAX_RETRIES = 3
type TaskStatus = 'pending' | 'checking' | 'uploading' | 'conflict' | 'success' | 'error'
interface Task {
file: File
status: TaskStatus
message?: string
attempts: number
}
const queue = ref<Task[]>([])
const selectedTagIds = ref<Set<number>>(new Set())
const fileInput = ref<HTMLInputElement | null>(null)
const busy = ref(false)
const conflictTask = ref<Task | null>(null)
const finishedCount = computed(
() => queue.value.filter((t) => t.status === 'success' || t.status === 'error').length,
)
const allDone = computed(() => queue.value.length > 0 && finishedCount.value === queue.value.length)
const hasWork = computed(() => queue.value.length > 0)
function toggleTag(id: number) {
const next = new Set(selectedTagIds.value)
if (next.has(id)) next.delete(id)
else next.add(id)
selectedTagIds.value = next
}
function pickFiles() {
if (busy.value) return
fileInput.value?.click()
}
function onFileChange(event: Event) {
const input = event.target as HTMLInputElement
const files = Array.from(input.files ?? [])
input.value = ''
enqueue(files)
}
function onDrop(event: DragEvent) {
event.preventDefault()
if (busy.value) return
enqueue(Array.from(event.dataTransfer?.files ?? []))
}
function enqueue(files: File[]) {
const jars = files.filter((f) => f.name.toLowerCase().endsWith('.jar'))
if (!jars.length) return
const accepted: Task[] = []
for (const file of jars) {
if (file.size > MAX_MOD_SIZE_MB * 1024 * 1024) {
addNotification({
type: 'error',
title: formatMessage(messages.tooLarge, { name: file.name }),
})
continue
}
accepted.push({ file, status: 'pending', attempts: 0 })
}
if (!accepted.length) return
queue.value = [...queue.value, ...accepted]
void runQueue()
}
async function runQueue() {
if (busy.value) return
busy.value = true
try {
for (;;) {
const task = queue.value.find((t) => t.status === 'pending')
if (!task) break
await processTask(task)
if (task.status === 'conflict') break
}
} finally {
busy.value = false
}
if (allDone.value) {
addNotification({ type: 'success', title: formatMessage(messages.allDone) })
emit('finished')
}
}
async function processTask(task: Task) {
task.status = 'checking'
task.message = undefined
try {
const hash = await skinSiteModHashFromFile(task.file)
const exists = await skinSiteModExistsByHash(hash)
if (exists.success) {
task.status = 'error'
task.message = formatMessage(messages.existsAlready)
return
}
const nameExists = await skinSiteModExistsByName(task.file.name)
if (nameExists.success) {
task.status = 'conflict'
conflictTask.value = task
return
}
await upload(task, false)
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
handleError(error)
}
}
async function upload(task: Task, overwrite: boolean) {
task.status = 'uploading'
try {
const res = await skinSiteUploadMod(task.file, Array.from(selectedTagIds.value), overwrite)
if (res.success) {
task.status = 'success'
return
}
if (res.errorCode === 429) {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'rate limited, retrying'
setTimeout(() => void runQueue(), 1500 * task.attempts)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'HashDiff') {
task.attempts++
if (task.attempts < MAX_RETRIES) {
task.status = 'pending'
task.message = 'hash mismatch, retrying'
setTimeout(() => void runQueue(), 800)
} else {
task.status = 'error'
task.message = res.errorMessage
}
return
}
if (res.errorType === 'ModNameConflict') {
task.status = 'conflict'
conflictTask.value = task
return
}
task.status = 'error'
task.message = res.errorMessage
} catch (error) {
task.status = 'error'
task.message = error instanceof Error ? error.message : String(error)
}
}
async function resolveConflict(overwrite: boolean) {
const task = conflictTask.value
conflictTask.value = null
if (!task) return
if (overwrite) {
busy.value = true
try {
await upload(task, true)
} finally {
busy.value = false
}
} else {
task.status = 'error'
task.message = formatMessage(messages.skip)
}
void runQueue()
}
function close() {
if (busy.value) return
queue.value = []
conflictTask.value = null
emit('finished')
}
function statusLabel(status: TaskStatus): string {
switch (status) {
case 'pending':
return formatMessage(messages.statusPending)
case 'checking':
return formatMessage(messages.statusChecking)
case 'uploading':
return formatMessage(messages.statusUploading)
case 'conflict':
return formatMessage(messages.statusConflict)
case 'success':
return formatMessage(messages.statusSuccess)
default:
return formatMessage(messages.statusError)
}
}
function statusClass(status: TaskStatus): string {
switch (status) {
case 'success':
return 'bg-green text-contrast'
case 'error':
return 'bg-red text-contrast'
case 'conflict':
return 'bg-yellow text-contrast'
default:
return 'bg-button-bg text-secondary'
}
}
</script>
<template>
<div class="fixed inset-0 z-50 flex items-center justify-center bg-black/50 p-4" @click.self="close">
<div class="flex max-h-[88vh] w-full max-w-2xl flex-col gap-4 overflow-y-auto rounded-xl bg-bg p-6">
<div class="flex items-start justify-between gap-3">
<h3 class="m-0 text-lg font-bold text-contrast">{{ formatMessage(messages.title) }}</h3>
<button
type="button"
class="shrink-0 rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
✕
</button>
</div>
<div
class="flex min-h-40 cursor-pointer flex-col items-center justify-center gap-2 rounded-xl border-2 border-dashed border-surface-5 p-6 text-center transition-colors hover:bg-bg-raised"
:class="busy ? 'cursor-not-allowed opacity-60' : ''"
@dragover.prevent
@dragenter.prevent
@drop.prevent="onDrop"
@click="pickFiles"
>
<input
ref="fileInput"
type="file"
class="hidden"
multiple
accept=".jar"
@change="onFileChange"
/>
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.dropHint) }}
</span>
<span class="text-xs text-secondary">
{{ formatMessage(messages.dropSub, { size: MAX_MOD_SIZE_MB }) }}
</span>
</div>
<div v-if="ruleTags.length" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.tagsLabel) }}
</span>
<div class="flex flex-wrap gap-2">
<button
v-for="tag in ruleTags"
:key="tag.id"
type="button"
class="rounded-full px-3 py-1 text-xs font-semibold transition-colors"
:class="
selectedTagIds.has(tag.id)
? 'bg-brand text-contrast'
: 'bg-button-bg text-secondary hover:text-contrast'
"
@click="toggleTag(tag.id)"
>
{{ tag.name }}
</button>
</div>
</div>
<div v-if="hasWork" class="flex flex-col gap-2">
<span class="text-xs font-semibold text-secondary">
{{ formatMessage(messages.queue) }} ({{ finishedCount }}/{{ queue.length }})
</span>
<div class="flex max-h-64 flex-col gap-1.5 overflow-y-auto">
<div
v-for="(task, index) in queue"
:key="task.file.name + index"
class="flex flex-wrap items-center gap-2 rounded-lg bg-bg-raised px-3 py-2"
>
<span class="min-w-0 flex-1 truncate text-xs text-contrast">
{{ task.file.name }}
</span>
<span class="shrink-0 text-[10px] text-secondary">
{{ (task.file.size / 1024 / 1024).toFixed(2) }} MB
</span>
<span
class="shrink-0 rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="statusClass(task.status)"
>
{{ statusLabel(task.status) }}
</span>
<span
v-if="task.message"
class="w-full truncate text-[10px] text-secondary"
:title="task.message"
>
{{ task.message }}
</span>
</div>
</div>
</div>
<div
v-if="busy"
class="flex items-center gap-2 text-xs text-secondary"
>
<SpinnerIcon class="size-3.5 animate-spin" />
{{ formatMessage(messages.statusUploading) }}
</div>
<div class="flex justify-end gap-2 border-t border-solid border-surface-5 pt-3">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110 disabled:opacity-50"
:disabled="busy"
@click="close"
>
{{ formatMessage(messages.close) }}
</button>
</div>
</div>
<div
v-if="conflictTask"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/60 p-4"
>
<div class="flex w-full max-w-md flex-col gap-3 rounded-xl bg-bg p-6">
<h4 class="m-0 text-base font-bold text-contrast">
{{ formatMessage(messages.nameConflictTitle) }}
</h4>
<p class="m-0 text-xs break-all text-secondary">{{ conflictTask.file.name }}</p>
<p class="m-0 text-xs text-secondary">{{ formatMessage(messages.nameConflictHint) }}</p>
<div class="flex justify-end gap-2 pt-2">
<button
type="button"
class="rounded-lg bg-button-bg px-4 py-2 text-sm font-semibold text-secondary hover:brightness-110"
@click="resolveConflict(false)"
>
{{ formatMessage(messages.skip) }}
</button>
<button
type="button"
class="rounded-lg bg-brand px-4 py-2 text-sm font-semibold text-contrast hover:brightness-110"
@click="resolveConflict(true)"
>
{{ formatMessage(messages.overwrite) }}
</button>
</div>
</div>
</div>
</div>
</template>
@@ -0,0 +1,30 @@
import { skinSiteChatRoomAvatarFetch } from '@/helpers/skin-site-api'
const avatarUrls = new Map<number, string>()
const pending = new Map<number, Promise<string | null>>()
export function loadCachedRoomAvatar(
roomId: number,
attachmentId: number,
): Promise<string | null> {
const cached = avatarUrls.get(attachmentId)
if (cached) return Promise.resolve(cached)
const running = pending.get(attachmentId)
if (running) return running
const request = skinSiteChatRoomAvatarFetch(roomId).then((blob) => {
pending.delete(attachmentId)
if (!blob) return null
const url = URL.createObjectURL(blob)
avatarUrls.set(attachmentId, url)
return url
})
pending.set(attachmentId, request)
return request
}
export function forgetRoomAvatar(attachmentId: number) {
const url = avatarUrls.get(attachmentId)
if (url) URL.revokeObjectURL(url)
avatarUrls.delete(attachmentId)
}
@@ -0,0 +1,74 @@
<script setup lang="ts">
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiAttachmentInfo,
skinSiteWikiAttachment,
skinSiteWikiAttachmentUrl,
} from '@/helpers/skin-site-api'
const props = defineProps<{ name: string; caption?: string | null }>()
const attachment = ref<SkinSiteWikiAttachmentInfo | null>(null)
const loading = ref(true)
const viewerOpen = ref(false)
const contentUrl = computed(() =>
attachment.value ? skinSiteWikiAttachmentUrl(attachment.value.id) : '#',
)
const imageCaption = computed(() => {
const caption = props.caption?.trim() ?? ''
return caption === props.name || caption === attachment.value?.name ? '' : caption
})
onMounted(async () => {
try {
const res = await skinSiteWikiAttachment(props.name)
if (res.success) attachment.value = res.payload
} catch {
// ignore
} finally {
loading.value = false
}
})
</script>
<template>
<span
v-if="loading"
class="inline-flex min-h-20 min-w-32 items-center justify-center rounded-lg border border-solid border-surface-5"
>
<span class="text-xs text-secondary">…</span>
</span>
<figure v-else-if="attachment?.image" class="my-5 inline-block max-w-full">
<button type="button" class="block max-w-full cursor-zoom-in rounded-lg" @click="viewerOpen = true">
<img
class="max-h-[70vh] max-w-full rounded-lg border border-solid border-surface-5 object-contain shadow-md"
:src="contentUrl"
:alt="caption || attachment.name"
loading="lazy"
decoding="async"
/>
</button>
<figcaption v-if="imageCaption" class="mt-2 text-center text-sm text-secondary">
{{ imageCaption }}
</figcaption>
</figure>
<a
v-else-if="attachment"
class="inline-flex items-center gap-1 text-brand underline-offset-2 hover:underline"
:href="contentUrl"
download
>
{{ caption || attachment.name }}
</a>
<span v-else class="text-red">附件“{{ name }}”不存在</span>
<div
v-if="viewerOpen"
class="fixed inset-0 z-[60] flex items-center justify-center bg-black/80 p-4"
@click.self="viewerOpen = false"
>
<img :src="contentUrl" :alt="caption || attachment?.name" class="max-h-[92vh] max-w-[92vw] object-contain" />
</div>
</template>
@@ -0,0 +1,185 @@
<script setup lang="ts">
import { type SkinSiteWikiNode } from '@/helpers/skin-site-api'
import { safeExternalUrl, safeWikiColor } from './wiki-color'
defineOptions({ name: 'WikiNodeRenderer' })
defineProps<{ nodes: SkinSiteWikiNode[] }>()
const emit = defineEmits<{ (e: 'navigate', title: string): void }>()
function headingTag(level?: number | null): string {
return 'h' + Math.min(6, Math.max(1, level ?? 2))
}
function navigate(title?: string | null) {
emit('navigate', (title ?? '').replaceAll('_', ' '))
}
</script>
<template>
<template v-for="(node, index) in nodes" :key="node.type + '-' + index">
<component
:is="headingTag(node.level)"
v-if="node.type === 'heading'"
class="wiki-heading scroll-mt-24"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<p
v-else-if="node.type === 'paragraph'"
class="my-3 leading-7 break-words whitespace-pre-wrap"
:style="{ textAlign: node.align ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</p>
<strong v-else-if="node.type === 'strong'" class="font-bold">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</strong>
<small v-else-if="node.type === 'small'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</small>
<span
v-else-if="node.type === 'color'"
:style="{ color: safeWikiColor(node.color ?? '') ?? undefined }"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<em v-else-if="node.type === 'emphasis'" class="italic">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</em>
<u v-else-if="node.type === 'underline'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</u>
<s v-else-if="node.type === 'strike'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</s>
<mark v-else-if="node.type === 'highlight'" class="rounded bg-yellow/25 px-0.5 text-contrast">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</mark>
<code v-else-if="node.type === 'inlineCode'" class="rounded bg-button-bg px-1 font-mono text-sm">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</code>
<span
v-else-if="node.type === 'spoiler'"
class="cursor-help rounded bg-contrast text-contrast transition-colors hover:bg-transparent hover:text-inherit"
:title="node.tooltip || '我知道你在偷看'"
tabindex="0"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</span>
<br v-else-if="node.type === 'break'" />
<hr v-else-if="node.type === 'rule'" class="my-6 border-surface-5" />
<button
v-else-if="node.type === 'internalLink'"
type="button"
class="font-medium underline-offset-2 hover:underline"
:class="node.exists === false ? 'text-red' : 'text-brand'"
:title="node.exists === false ? '该词条尚未创建' : undefined"
@click="navigate(node.title)"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</button>
<a
v-else-if="node.type === 'externalLink'"
class="text-brand underline-offset-2 hover:underline"
:href="safeExternalUrl(node.href)"
target="_blank"
rel="noopener noreferrer nofollow"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<template v-else>{{ node.text }}</template>
</a>
<ul v-else-if="node.type === 'unorderedList'" class="my-3 list-disc pl-7">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ul>
<ol
v-else-if="node.type === 'orderedList'"
:start="node.start ?? undefined"
class="my-3 list-decimal pl-7"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</ol>
<li v-else-if="node.type === 'listItem'" class="my-1">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</li>
<blockquote
v-else-if="node.type === 'quote'"
class="my-4 border-l-4 border-brand/60 bg-brand/5 px-4 py-2"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</blockquote>
<pre
v-else-if="node.type === 'code'"
class="my-4 overflow-x-auto rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 font-mono text-sm"
><code>{{ node.text }}</code></pre>
<div v-else-if="node.type === 'table'" class="my-5 overflow-x-auto">
<table class="w-full border-collapse text-sm">
<tbody>
<WikiNodeRenderer
v-if="node.children.length"
:nodes="node.children"
@navigate="navigate"
/>
<tr v-for="(row, rowIndex) in node.rows" :key="rowIndex">
<td v-for="(cell, cellIndex) in row" :key="cellIndex" class="border border-solid border-surface-5 px-2 py-1">
<WikiNodeRenderer :nodes="cell" @navigate="navigate" />
</td>
</tr>
</tbody>
</table>
</div>
<tr v-else-if="node.type === 'tableRow'">
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</tr>
<component
:is="node.type === 'tableHeader' ? 'th' : 'td'"
v-else-if="node.type === 'tableCell' || node.type === 'tableHeader'"
class="border border-solid border-surface-5 px-2 py-1"
:colspan="node.colspan ?? 1"
:rowspan="node.rowspan ?? 1"
>
<WikiNodeRenderer :nodes="node.children" @navigate="navigate" />
</component>
<WikiAttachmentNode
v-else-if="node.type === 'attachment' && node.title"
:name="node.title"
:caption="node.text"
/>
<WikiNotice
v-else-if="node.type === 'notice' && node.title"
:title="node.title"
:description="node.text"
:variant="node.variant"
>
<WikiNodeRenderer v-if="node.children.length" :nodes="node.children" @navigate="navigate" />
<p v-else-if="node.text" class="whitespace-pre-wrap">{{ node.text }}</p>
</WikiNotice>
<span v-else>{{ node.text }}</span>
</template>
</template>
<style scoped>
.wiki-heading {
margin-top: 1.65rem;
margin-bottom: 0.7rem;
border-bottom: 1px solid color-mix(in oklab, currentColor 16%, transparent);
padding-bottom: 0.4rem;
font-weight: 750;
line-height: 1.25;
}
h1.wiki-heading {
font-size: 2rem;
}
h2.wiki-heading {
font-size: 1.55rem;
}
h3.wiki-heading {
font-size: 1.25rem;
}
</style>
@@ -0,0 +1,32 @@
<script setup lang="ts">
import { computed } from 'vue'
const props = defineProps<{
title: string
description?: string | null
variant?: string | null
}>()
const variantClass = computed(() => {
switch (props.variant) {
case 'warning':
return 'border-yellow/40 bg-yellow/10 text-yellow'
case 'error':
return 'border-red/40 bg-red/10 text-red'
case 'success':
return 'border-green/40 bg-green/10 text-green'
default:
return 'border-brand/40 bg-brand/10 text-brand'
}
})
</script>
<template>
<div class="my-4 rounded-lg border border-solid p-4" :class="variantClass">
<p class="m-0 text-sm font-bold">{{ title }}</p>
<p v-if="description" class="m-0 mt-1 text-xs whitespace-pre-wrap opacity-90">
{{ description }}
</p>
<slot />
</div>
</template>
@@ -0,0 +1,18 @@
export function safeWikiColor(value: unknown): string | null {
if (typeof value !== 'string') return null
const color = value.trim()
return color.length <= 80 &&
/^(?:[a-z]{1,20}|#[\da-f]{3}|#[\da-f]{4}|#[\da-f]{6}|#[\da-f]{8}|rgba?\([\d\s.,%]+\))$/i.test(color)
? color
: null
}
export function safeExternalUrl(url?: string | null): string {
if (!url) return '#'
try {
const parsed = new URL(url)
return parsed.protocol === 'http:' || parsed.protocol === 'https:' ? parsed.toString() : '#'
} catch {
return '#'
}
}
File diff suppressed because it is too large. Load diff
+483 -1
View File
@@ -11952,5 +11952,487 @@
"app.skin-site.admin.launcher.prev": {"message": "Previous"},
"app.skin-site.admin.launcher.next": {"message": "Next"},
"app.skin-site.admin.launcher.page": {"message": "Page {page}"},
"app.skin-site.admin.launcher.total": {"message": "{count} releases"}
"app.skin-site.admin.launcher.total": {"message": "{count} releases"},
"app.skin-site.admin.downloads.title": {"message": "Downloads"},
"app.skin-site.admin.downloads.loading": {"message": "Loading…"},
"app.skin-site.admin.downloads.failed": {"message": "Could not load downloads."},
"app.skin-site.admin.downloads.empty": {"message": "No download items."},
"app.skin-site.admin.downloads.categories": {"message": "Categories"},
"app.skin-site.admin.downloads.all": {"message": "All items"},
"app.skin-site.admin.downloads.add-category": {"message": "Add"},
"app.skin-site.admin.downloads.category-name": {"message": "Category name"},
"app.skin-site.admin.downloads.remove-category": {"message": "Remove"},
"app.skin-site.admin.downloads.total": {"message": "{count} items"},
"app.skin-site.admin.downloads.add-item": {"message": "New item"},
"app.skin-site.admin.downloads.save": {"message": "Save"},
"app.skin-site.admin.downloads.cancel": {"message": "Cancel"},
"app.skin-site.admin.downloads.edit": {"message": "Edit"},
"app.skin-site.admin.downloads.remove": {"message": "Delete"},
"app.skin-site.admin.downloads.filename": {"message": "File name"},
"app.skin-site.admin.downloads.description": {"message": "Description"},
"app.skin-site.admin.downloads.routes": {"message": "Download routes"},
"app.skin-site.admin.downloads.route-name": {"message": "Name"},
"app.skin-site.admin.downloads.route-url": {"message": "URL"},
"app.skin-site.admin.downloads.add-route": {"message": "Add route"},
"app.skin-site.admin.downloads.remove-route": {"message": "Remove"},
"app.skin-site.admin.downloads.statuses": {"message": "Statuses"},
"app.skin-site.admin.downloads.bind-category": {"message": "Categories"},
"app.skin-site.admin.downloads.uploader": {"message": "by {name}"},
"app.skin-site.admin.downloads.created": {"message": "Item created."},
"app.skin-site.admin.downloads.updated": {"message": "Item updated."},
"app.skin-site.admin.downloads.deleted": {"message": "Item deleted."},
"app.skin-site.admin.downloads.category-added": {"message": "Category added."},
"app.skin-site.admin.downloads.category-deleted": {"message": "Category deleted."},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "Delete “{name}”? This cannot be undone."},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "Delete category “{name}”?"},
"app.skin-site.admin.servers.title": {"message": "Map servers"},
"app.skin-site.admin.servers.loading": {"message": "Loading…"},
"app.skin-site.admin.servers.failed": {"message": "Could not load servers."},
"app.skin-site.admin.servers.empty": {"message": "No map servers configured."},
"app.skin-site.admin.servers.server-name": {"message": "Server name"},
"app.skin-site.admin.servers.internal": {"message": "Internal"},
"app.skin-site.admin.servers.maps": {"message": "Maps"},
"app.skin-site.admin.servers.map-name": {"message": "Map name"},
"app.skin-site.admin.servers.map-url": {"message": "Map URL"},
"app.skin-site.admin.servers.add-map": {"message": "Add map"},
"app.skin-site.admin.servers.remove-map": {"message": "Remove"},
"app.skin-site.admin.servers.add-server": {"message": "Add server"},
"app.skin-site.admin.servers.remove-server": {"message": "Remove server"},
"app.skin-site.admin.servers.save": {"message": "Save changes"},
"app.skin-site.admin.servers.saving": {"message": "Saving…"},
"app.skin-site.admin.servers.saved": {"message": "Map servers saved."},
"app.skin-site.admin.audit.title": {"message": "Access audit"},
"app.skin-site.admin.audit.loading": {"message": "Loading…"},
"app.skin-site.admin.audit.failed": {"message": "Could not load audit log."},
"app.skin-site.admin.audit.empty": {"message": "No audit entries."},
"app.skin-site.admin.audit.subject": {"message": "Subject"},
"app.skin-site.admin.audit.action": {"message": "Action"},
"app.skin-site.admin.audit.source-ip": {"message": "Source IP"},
"app.skin-site.admin.audit.reported-ip": {"message": "Reported player IP"},
"app.skin-site.admin.audit.count": {"message": "Count"},
"app.skin-site.admin.audit.first-at": {"message": "First seen"},
"app.skin-site.admin.audit.last-at": {"message": "Last seen"},
"app.skin-site.admin.audit.proxy": {"message": "Proxy"},
"app.skin-site.admin.audit.internal": {"message": "Internal"},
"app.skin-site.admin.audit.search-subject": {"message": "Subject"},
"app.skin-site.admin.audit.search-ip": {"message": "IP"},
"app.skin-site.admin.audit.search-action": {"message": "Action"},
"app.skin-site.admin.audit.search": {"message": "Search"},
"app.skin-site.admin.audit.clear": {"message": "Clear"},
"app.skin-site.admin.audit.load-more": {"message": "Load more"},
"app.skin-site.admin.audit.retention": {"message": "Retention (days)"},
"app.skin-site.admin.audit.record-limit": {"message": "Record limit"},
"app.skin-site.admin.audit.save-retention": {"message": "Save"},
"app.skin-site.admin.audit.retention-saved": {"message": "Retention updated."},
"app.skin-site.admin.donations.title": {"message": "Donations"},
"app.skin-site.admin.donations.loading": {"message": "Loading…"},
"app.skin-site.admin.donations.failed": {"message": "Could not load donations."},
"app.skin-site.admin.donations.empty": {"message": "No donations yet."},
"app.skin-site.admin.donations.total-amount": {"message": "Total"},
"app.skin-site.admin.donations.donor-count": {"message": "Donors"},
"app.skin-site.admin.donations.donor": {"message": "Donor"},
"app.skin-site.admin.donations.amount": {"message": "Amount"},
"app.skin-site.admin.donations.updated-at": {"message": "Updated"},
"app.skin-site.admin.donations.external": {"message": "External"},
"app.skin-site.admin.donations.linked": {"message": "Linked"},
"app.skin-site.admin.donations.create": {"message": "Add donation"},
"app.skin-site.admin.donations.save": {"message": "Save"},
"app.skin-site.admin.donations.cancel": {"message": "Cancel"},
"app.skin-site.admin.donations.edit": {"message": "Edit"},
"app.skin-site.admin.donations.remove": {"message": "Delete"},
"app.skin-site.admin.donations.amount-field": {"message": "Amount"},
"app.skin-site.admin.donations.display-name": {"message": "Display name"},
"app.skin-site.admin.donations.user-uuid": {"message": "User UUID"},
"app.skin-site.admin.donations.qq": {"message": "QQ"},
"app.skin-site.admin.donations.append-mode": {"message": "Append to existing amount"},
"app.skin-site.admin.donations.created": {"message": "Donation created."},
"app.skin-site.admin.donations.updated": {"message": "Donation updated."},
"app.skin-site.admin.donations.deleted": {"message": "Donation deleted."},
"app.skin-site.admin.donations.confirm-delete": {"message": "Delete the donation from “{name}”? This cannot be undone."},
"app.skin-site.admin.donations.load-more": {"message": "Load more"},
"app.skin-site.admin.mods.title": {"message": "Mods"},
"app.skin-site.admin.mods.tab-mods": {"message": "Mod files"},
"app.skin-site.admin.mods.tab-tags": {"message": "Rule tags"},
"app.skin-site.admin.mods.tab-runtime": {"message": "Runtime"},
"app.skin-site.admin.mods.loading": {"message": "Loading…"},
"app.skin-site.admin.mods.failed": {"message": "Could not load mods."},
"app.skin-site.admin.mods.empty": {"message": "No mods found."},
"app.skin-site.admin.mods.total": {"message": "{count} mods"},
"app.skin-site.admin.mods.search": {"message": "Search by name"},
"app.skin-site.admin.mods.filter-all": {"message": "All tags"},
"app.skin-site.admin.mods.prev": {"message": "Previous"},
"app.skin-site.admin.mods.next": {"message": "Next"},
"app.skin-site.admin.mods.page": {"message": "Page {page}"},
"app.skin-site.admin.mods.name": {"message": "Name"},
"app.skin-site.admin.mods.version": {"message": "Version"},
"app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
"app.skin-site.admin.mods.size": {"message": "Size"},
"app.skin-site.admin.mods.loaders": {"message": "Loaders"},
"app.skin-site.admin.mods.domain-rules": {"message": "Domain rules"},
"app.skin-site.admin.mods.edit": {"message": "Edit"},
"app.skin-site.admin.mods.save": {"message": "Save"},
"app.skin-site.admin.mods.cancel": {"message": "Cancel"},
"app.skin-site.admin.mods.remove": {"message": "Delete"},
"app.skin-site.admin.mods.updated": {"message": "Mod updated."},
"app.skin-site.admin.mods.deleted": {"message": "Mod deleted."},
"app.skin-site.admin.mods.confirm-delete": {"message": "Delete mod “{name}”? This cannot be undone."},
"app.skin-site.admin.mods.rule-tags": {"message": "Rule tags"},
"app.skin-site.admin.mods.tag-name": {"message": "Tag name"},
"app.skin-site.admin.mods.tag-rules": {"message": "Rules"},
"app.skin-site.admin.mods.tag-rules-hint": {"message": "One rule per line."},
"app.skin-site.admin.mods.new-tag": {"message": "New tag"},
"app.skin-site.admin.mods.tag-created": {"message": "Tag created."},
"app.skin-site.admin.mods.tag-updated": {"message": "Tag updated."},
"app.skin-site.admin.mods.tag-deleted": {"message": "Tag deleted."},
"app.skin-site.admin.mods.confirm-delete-tag": {"message": "Delete tag “{name}”?"},
"app.skin-site.admin.mods.runtime": {"message": "Runtime releases"},
"app.skin-site.admin.mods.manifest-url": {"message": "Manifest URL"},
"app.skin-site.admin.mods.upload-runtime": {"message": "Upload runtime"},
"app.skin-site.admin.mods.runtime-version": {"message": "Version"},
"app.skin-site.admin.mods.uploading": {"message": "Uploading…"},
"app.skin-site.admin.mods.runtime-uploaded": {"message": "Runtime uploaded."},
"app.skin-site.admin.mods.activate": {"message": "Activate"},
"app.skin-site.admin.mods.activated": {"message": "Runtime activated."},
"app.skin-site.admin.mods.available": {"message": "Available"},
"app.skin-site.admin.mods.unavailable": {"message": "Unavailable"},
"app.skin-site.admin.mods.active": {"message": "Active"},
"app.skin-site.admin.mods.uploader": {"message": "by {name}"},
"app.skin-site.home.nav.title": {"message": "Skin site"},
"app.skin-site.home.nav.download": {"message": "Downloads"},
"app.skin-site.home.nav.download-desc": {"message": "Browse files shared by the server."},
"app.skin-site.home.nav.review": {"message": "Player review"},
"app.skin-site.home.nav.review-desc": {"message": "Submit and review player applications."},
"app.skin-site.home.nav.wiki": {"message": "Server wiki"},
"app.skin-site.home.nav.wiki-desc": {"message": "Guides and documentation for the server."},
"app.skin-site.home.nav.bugs": {"message": "Bug tracker"},
"app.skin-site.home.nav.bugs-desc": {"message": "Report issues and follow their progress."},
"app.skin-site.home.nav.map": {"message": "Server map"},
"app.skin-site.home.nav.map-desc": {"message": "Explore the live world map."},
"app.skin-site.home.nav.profile": {"message": "Profile"},
"app.skin-site.home.nav.profile-desc": {"message": "Manage your account and textures."},
"app.skin-site.admin.wiki.title": {"message": "Wiki"},
"app.skin-site.admin.wiki.tab-page": {"message": "Page"},
"app.skin-site.admin.wiki.tab-audit": {"message": "Audit log"},
"app.skin-site.admin.wiki.tab-settings": {"message": "Settings"},
"app.skin-site.admin.wiki.loading": {"message": "Loading…"},
"app.skin-site.admin.wiki.page-path": {"message": "Page path"},
"app.skin-site.admin.wiki.load-page": {"message": "Load page"},
"app.skin-site.admin.wiki.page-not-found": {"message": "Page not found."},
"app.skin-site.admin.wiki.revision": {"message": "Revision"},
"app.skin-site.admin.wiki.author": {"message": "Author"},
"app.skin-site.admin.wiki.updated-at": {"message": "Updated"},
"app.skin-site.admin.wiki.categories": {"message": "Categories"},
"app.skin-site.admin.wiki.read-policy": {"message": "Read policy"},
"app.skin-site.admin.wiki.edit-policy": {"message": "Edit policy"},
"app.skin-site.admin.wiki.save-protect": {"message": "Save policies"},
"app.skin-site.admin.wiki.protect-saved": {"message": "Page policies updated."},
"app.skin-site.admin.wiki.set-home": {"message": "Set as home page"},
"app.skin-site.admin.wiki.home-set": {"message": "Home page updated."},
"app.skin-site.admin.wiki.set-deleted": {"message": "Mark as deleted"},
"app.skin-site.admin.wiki.set-restored": {"message": "Restore page"},
"app.skin-site.admin.wiki.deleted-updated": {"message": "Page deletion state updated."},
"app.skin-site.admin.wiki.audit-empty": {"message": "No audit entries."},
"app.skin-site.admin.wiki.audit-target": {"message": "Target"},
"app.skin-site.admin.wiki.load-more": {"message": "Load more"},
"app.skin-site.admin.wiki.settings-hint": {"message": "Comma separated domains."},
"app.skin-site.admin.wiki.domains": {"message": "Domains"},
"app.skin-site.admin.wiki.home-page": {"message": "Home page"},
"app.skin-site.admin.wiki.default-skin": {"message": "Default skin"},
"app.skin-site.admin.wiki.max-page-bytes": {"message": "Max page bytes"},
"app.skin-site.admin.wiki.max-attachment-bytes": {"message": "Max attachment bytes"},
"app.skin-site.admin.wiki.save-settings": {"message": "Save settings"},
"app.skin-site.admin.wiki.settings-saved": {"message": "Settings saved."},
"app.skin-site.admin.review.tab-list": {"message": "Submissions"},
"app.skin-site.admin.review.tab-papers": {"message": "Papers"},
"app.skin-site.admin.review.papers-empty": {"message": "No review papers yet."},
"app.skin-site.admin.review.new-paper": {"message": "New paper"},
"app.skin-site.admin.review.edit-paper": {"message": "Edit"},
"app.skin-site.admin.review.paper-title": {"message": "Title"},
"app.skin-site.admin.review.paper-annotation": {"message": "Description"},
"app.skin-site.admin.review.paper-enabled": {"message": "Enabled"},
"app.skin-site.admin.review.paper-mode": {"message": "Mode"},
"app.skin-site.admin.review.paper-pass-score": {"message": "Pass score"},
"app.skin-site.admin.review.paper-target-permissions": {"message": "Target permissions"},
"app.skin-site.admin.review.paper-questions": {"message": "Questions"},
"app.skin-site.admin.review.add-question": {"message": "Add question"},
"app.skin-site.admin.review.question-title": {"message": "Question title"},
"app.skin-site.admin.review.question-type": {"message": "Type"},
"app.skin-site.admin.review.question-score": {"message": "Score"},
"app.skin-site.admin.review.question-annotation": {"message": "Annotation"},
"app.skin-site.admin.review.add-option": {"message": "Add option"},
"app.skin-site.admin.review.option-description": {"message": "Option text"},
"app.skin-site.admin.review.option-correct": {"message": "Correct"},
"app.skin-site.admin.review.remove-question": {"message": "Remove"},
"app.skin-site.admin.review.question-no": {"message": "Question {n}"},
"app.skin-site.admin.review.paper-created": {"message": "Paper created."},
"app.skin-site.admin.review.paper-saved": {"message": "Paper saved."},
"app.skin-site.admin.review.mail-title": {"message": "Pass mail"},
"app.skin-site.admin.review.mail-enabled": {"message": "Send mail on pass"},
"app.skin-site.admin.review.mail-subject": {"message": "Subject"},
"app.skin-site.admin.review.mail-message": {"message": "Body"},
"app.skin-site.admin.review.mail-extra": {"message": "Extra text"},
"app.skin-site.admin.review.mail-include-invite": {"message": "Include invite code"},
"app.skin-site.admin.review.prevent-duplicate": {"message": "Reject duplicate submissions"},
"app.skin-site.admin.review.copy-link": {"message": "Copy link"},
"app.skin-site.admin.review.link-copied": {"message": "Link copied."},
"app.skin-site.admin.review.move-up": {"message": "Move up"},
"app.skin-site.admin.review.move-down": {"message": "Move down"},
"app.skin-site.admin.review.mail-subject-required": {"message": "Subject is required when mail is enabled."},
"app.skin-site.admin.review.mail-message-required": {"message": "Body is required when mail is enabled."},
"app.skin-site.admin.users.actions": {"message": "Actions"},
"app.skin-site.admin.users.audit": {"message": "Audit"},
"app.skin-site.admin.users.audit-empty": {"message": "No audit records yet."},
"app.skin-site.admin.users.audit-more": {"message": "Load earlier"},
"app.skin-site.admin.users.audit-title": {"message": "User management audit"},
"app.skin-site.admin.users.bot": {"message": "Bot"},
"app.skin-site.admin.users.bot-active": {"message": "Token valid"},
"app.skin-site.admin.users.bot-ban": {"message": "Ban bot creation"},
"app.skin-site.admin.users.bot-creation-banned": {"message": "Token creation banned"},
"app.skin-site.admin.users.bot-exists": {"message": "Bot created"},
"app.skin-site.admin.users.bot-inactive": {"message": "Token invalidated"},
"app.skin-site.admin.users.bot-invalidate": {"message": "Invalidate token"},
"app.skin-site.admin.users.bot-not-exists": {"message": "No bot yet"},
"app.skin-site.admin.users.bot-save-username": {"message": "Save bot username"},
"app.skin-site.admin.users.bot-title": {"message": "Bot management"},
"app.skin-site.admin.users.bot-token-hint": {"message": "Token hint"},
"app.skin-site.admin.users.bot-unban": {"message": "Unban bot creation"},
"app.skin-site.admin.users.bot-username": {"message": "Bot username"},
"app.skin-site.admin.users.bound-players-title": {"message": "Linked players"},
"app.skin-site.admin.users.frequency": {"message": "Rate limit"},
"app.skin-site.admin.users.frequency-clear": {"message": "Clear limit"},
"app.skin-site.admin.users.frequency-current": {"message": "Currently {percent}%"},
"app.skin-site.admin.users.frequency-hint": {"message": "Packets from this user will be dropped at the given rate."},
"app.skin-site.admin.users.frequency-none": {"message": "Not enabled"},
"app.skin-site.admin.users.frequency-range": {"message": "Only integer values from 10% to 100% are allowed."},
"app.skin-site.admin.users.frequency-save": {"message": "Save limit"},
"app.skin-site.admin.users.frequency-title": {"message": "Rate limit"},
"app.skin-site.admin.users.invite": {"message": "Invite user"},
"app.skin-site.admin.users.invite-copied": {"message": "Invite code copied."},
"app.skin-site.admin.users.invite-copy": {"message": "Copy"},
"app.skin-site.admin.users.invite-email": {"message": "Email"},
"app.skin-site.admin.users.invite-email-invalid": {"message": "Enter a valid email address."},
"app.skin-site.admin.users.invite-generate": {"message": "Generate invite code"},
"app.skin-site.admin.users.invite-title": {"message": "Invite user"},
"app.skin-site.admin.users.legacy-adapted": {"message": "Legacy internal adapted"},
"app.skin-site.admin.users.registered-at": {"message": "Registered at"},
"app.skin-site.admin.users.status": {"message": "Status"},
"app.skin-site.admin.users.status-banned": {"message": "Banned"},
"app.skin-site.admin.users.status-normal": {"message": "Normal"},
"app.skin-site.admin.mods.upload": {"message": "Upload"},
"app.skin-site.admin.mods.select-mode": {"message": "Select multiple"},
"app.skin-site.admin.mods.exit-select": {"message": "Done selecting"},
"app.skin-site.admin.mods.select-all": {"message": "Select all"},
"app.skin-site.admin.mods.clear-selection": {"message": "Clear selection"},
"app.skin-site.admin.mods.selected-count": {"message": "{count} selected"},
"app.skin-site.admin.mods.delete-selected": {"message": "Delete selected"},
"app.skin-site.admin.mods.confirm-delete-multi": {"message": "Delete {count} selected mods? This cannot be undone."},
"app.skin-site.admin.mods.deleted-multi": {"message": "Selected mods deleted."},
"app.skin-site.admin.mods.server-address": {"message": "Server address"},
"app.skin-site.admin.mods.server-address-hint": {"message": "Filter by server address"},
"app.skin-site.admin.mods.apply": {"message": "Apply"},
"app.skin-site.admin.mods.clear": {"message": "Clear"},
"app.skin-site.admin.mods.upload-title": {"message": "Upload mods"},
"app.skin-site.admin.mods.upload-drop": {"message": "Drag .jar files here, or click to choose"},
"app.skin-site.admin.mods.upload-sub": {"message": "Multiple files supported, up to {size} MB each"},
"app.skin-site.admin.mods.upload-tags": {"message": "Attach rule tags"},
"app.skin-site.admin.mods.upload-queue": {"message": "Files"},
"app.skin-site.admin.mods.upload-pending": {"message": "Pending"},
"app.skin-site.admin.mods.upload-checking": {"message": "Checking…"},
"app.skin-site.admin.mods.upload-uploading": {"message": "Uploading…"},
"app.skin-site.admin.mods.upload-conflict": {"message": "Name exists"},
"app.skin-site.admin.mods.upload-success": {"message": "Done"},
"app.skin-site.admin.mods.upload-error": {"message": "Failed"},
"app.skin-site.admin.mods.upload-exists": {"message": "This file already exists on the server."},
"app.skin-site.admin.mods.upload-name-conflict": {"message": "A mod with the same file name already exists"},
"app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "Overwrite the existing mod, or skip this file."},
"app.skin-site.admin.mods.upload-overwrite": {"message": "Overwrite"},
"app.skin-site.admin.mods.upload-skip": {"message": "Skip"},
"app.skin-site.admin.mods.upload-close": {"message": "Close"},
"app.skin-site.admin.mods.upload-all-done": {"message": "All files processed."},
"app.skin-site.admin.mods.upload-too-large": {"message": "File too large: {name}"},
"app.skin-site.chat.avatar.label": {"message": "Room avatar"},
"app.skin-site.chat.avatar.upload": {"message": "Upload avatar"},
"app.skin-site.chat.avatar.replace": {"message": "Replace avatar"},
"app.skin-site.chat.avatar.remove": {"message": "Remove avatar"},
"app.skin-site.chat.avatar.hint": {"message": "PNG, JPEG or WebP, up to 8192 px per side and 5 MB."},
"app.skin-site.chat.avatar.bad-format": {"message": "Only PNG, JPEG and WebP are supported."},
"app.skin-site.chat.avatar.too-large": {"message": "Avatar must not exceed 5 MB."},
"app.skin-site.chat.avatar.not-image": {"message": "Avatar must be an image."},
"app.skin-site.chat.admin.title": {"message": "Chat rooms"},
"app.skin-site.chat.admin.subtitle": {"message": "Create public or private rooms and hand daily moderation to trusted members."},
"app.skin-site.chat.admin.create-room": {"message": "Create room"},
"app.skin-site.chat.admin.loading": {"message": "Loading…"},
"app.skin-site.chat.admin.failed": {"message": "Could not load rooms."},
"app.skin-site.chat.admin.empty": {"message": "No chat rooms yet."},
"app.skin-site.chat.admin.edit": {"message": "Edit"},
"app.skin-site.chat.admin.remove": {"message": "Delete"},
"app.skin-site.chat.admin.save": {"message": "Save"},
"app.skin-site.chat.admin.cancel": {"message": "Cancel"},
"app.skin-site.chat.admin.name": {"message": "Name"},
"app.skin-site.chat.admin.description": {"message": "Description"},
"app.skin-site.chat.admin.visibility": {"message": "Visibility"},
"app.skin-site.chat.admin.visibility-public": {"message": "Public"},
"app.skin-site.chat.admin.visibility-private": {"message": "Private"},
"app.skin-site.chat.admin.kind": {"message": "Kind"},
"app.skin-site.chat.admin.kind-standard": {"message": "Standard"},
"app.skin-site.chat.admin.kind-settlement": {"message": "Settlement"},
"app.skin-site.chat.admin.prerequisite-mode": {"message": "Prerequisite mode"},
"app.skin-site.chat.admin.prerequisite-any": {"message": "Any"},
"app.skin-site.chat.admin.prerequisite-all": {"message": "All"},
"app.skin-site.chat.admin.prerequisite-permissions": {"message": "Required permissions"},
"app.skin-site.chat.admin.prerequisite-hint": {"message": "Comma separated. Leave empty for no restriction."},
"app.skin-site.chat.admin.created": {"message": "Room created."},
"app.skin-site.chat.admin.updated": {"message": "Room updated."},
"app.skin-site.chat.admin.deleted": {"message": "Room deleted."},
"app.skin-site.chat.admin.confirm-delete": {"message": "Permanently delete room “{name}” and all its messages?"},
"app.skin-site.chat.admin.manager-badge": {"message": "Manager"},
"app.skin-site.chat.admin.voice-badge": {"message": "Voice"},
"app.skin-site.chat.user-picker.label": {"message": "Pick a user"},
"app.skin-site.chat.user-picker.placeholder": {"message": "Search username, email or account ID"},
"app.skin-site.chat.user-picker.by-user": {"message": "By user"},
"app.skin-site.chat.user-picker.by-player": {"message": "By player"},
"app.skin-site.chat.user-picker.hint": {"message": "Fuzzy search on username and email, or search by account ID."},
"app.skin-site.chat.user-picker.player-hint": {"message": "Enter a player name to find the bound user."},
"app.skin-site.chat.user-picker.empty": {"message": "No matching user."},
"app.skin-site.chat.user-picker.banned": {"message": "This account is banned."},
"app.skin-site.chat.user-picker.guest": {"message": "Guests cannot become a manager or settlement owner."},
"app.skin-site.chat.user-picker.already-in": {"message": "This user is already in the room."},
"app.skin-site.chat.user-picker.not-allowed": {"message": "This user is outside the allowed range."},
"app.skin-site.chat.user-picker.select": {"message": "Select"},
"app.skin-site.chat.user-picker.selected": {"message": "Selected"},
"app.skin-site.chat.user-picker.id": {"message": "ID"},
"app.skin-site.chat.admin.managers": {"message": "Managers"},
"app.skin-site.chat.admin.manager-title": {"message": "Room manager"},
"app.skin-site.chat.admin.manager-hint": {"message": "Managers can moderate members, messages and files in this room."},
"app.skin-site.chat.admin.grant": {"message": "Grant"},
"app.skin-site.chat.admin.revoke": {"message": "Revoke"},
"app.skin-site.chat.admin.manager-granted": {"message": "Manager granted."},
"app.skin-site.chat.admin.manager-revoked": {"message": "Manager revoked."},
"app.skin-site.chat.admin.owner-title": {"message": "Settlement owner"},
"app.skin-site.chat.admin.owner-hint": {"message": "Assign the first settlement owner for this room."},
"app.skin-site.chat.admin.owner-assigned": {"message": "Settlement owner assigned."},
"app.skin-site.chat.admin.owner-current": {"message": "Current owner: {name}"},
"app.skin-site.chat.admin.owner-none": {"message": "No settlement owner yet."},
"app.skin-site.chat.admin.audit": {"message": "Audit"},
"app.skin-site.chat.admin.audit-title": {"message": "Room audit"},
"app.skin-site.chat.admin.audit-empty": {"message": "No audit records yet."},
"app.skin-site.chat.admin.audit-target": {"message": "Target: {name}"},
"app.skin-site.chat.admin.close": {"message": "Close"},
"app.skin-site.chat.voice-runtime-title": {"message": "Realtime voice runtime"},
"app.skin-site.chat.voice-logs": {"message": "View logs"},
"app.skin-site.chat.voice-config": {"message": "Configure runtime"},
"app.skin-site.chat.voice-retry": {"message": "Retry deployment"},
"app.skin-site.chat.voice-config-title": {"message": "Voice runtime configuration"},
"app.skin-site.chat.voice-logs-title": {"message": "LiveKit logs"},
"app.skin-site.chat.voice-capacity-title": {"message": "Voice capacity"},
"app.skin-site.chat.voice-capacity": {"message": "Voice capacity"},
"app.skin-site.chat.participant-limit": {"message": "Participant limit"},
"app.skin-site.chat.listener-limit": {"message": "Listener limit"},
"app.skin-site.chat.voice-capacity-updated": {"message": "Voice capacity updated."},
"app.skin-site.chat.voice-enabled": {"message": "Enable realtime voice"},
"app.skin-site.chat.voice-runtime-path": {"message": "Runtime directory"},
"app.skin-site.chat.voice-mirror": {"message": "Mirror base URL"},
"app.skin-site.chat.voice-public-url": {"message": "Public URL"},
"app.skin-site.chat.voice-bind": {"message": "Bind address"},
"app.skin-site.chat.voice-signal-port": {"message": "Signal port"},
"app.skin-site.chat.voice-rtc-tcp-port": {"message": "Media TCP port"},
"app.skin-site.chat.voice-rtc-udp-port": {"message": "Media UDP port"},
"app.skin-site.chat.voice-startup-timeout": {"message": "Startup timeout (s)"},
"app.skin-site.chat.voice-use-external-ip": {"message": "Use external IP"},
"app.skin-site.chat.voice-node-ip": {"message": "Node IP"},
"app.skin-site.chat.voice-saved": {"message": "Voice configuration saved."},
"app.skin-site.chat.voice-logs-auto-refresh": {"message": "Auto refresh"},
"app.skin-site.chat.voice-logs-refresh": {"message": "Refresh"},
"app.skin-site.chat.voice-logs-empty": {"message": "No logs yet."},
"app.skin-site.chat.admin.voice-runtime-title": {"message": "Realtime voice runtime"},
"app.skin-site.chat.admin.voice-logs": {"message": "View logs"},
"app.skin-site.chat.admin.voice-config": {"message": "Configure runtime"},
"app.skin-site.chat.admin.voice-retry": {"message": "Retry deployment"},
"app.skin-site.chat.admin.voice-config-title": {"message": "Voice runtime configuration"},
"app.skin-site.chat.admin.voice-logs-title": {"message": "LiveKit logs"},
"app.skin-site.chat.admin.voice-capacity-title": {"message": "Voice capacity"},
"app.skin-site.chat.admin.voice-capacity": {"message": "Voice capacity"},
"app.skin-site.chat.admin.participant-limit": {"message": "Participant limit"},
"app.skin-site.chat.admin.listener-limit": {"message": "Listener limit"},
"app.skin-site.chat.admin.voice-capacity-updated": {"message": "Voice capacity updated."},
"app.skin-site.chat.admin.voice-enabled": {"message": "Enable realtime voice"},
"app.skin-site.chat.admin.voice-runtime-path": {"message": "Runtime directory"},
"app.skin-site.chat.admin.voice-mirror": {"message": "Mirror base URL"},
"app.skin-site.chat.admin.voice-public-url": {"message": "Public URL"},
"app.skin-site.chat.admin.voice-bind": {"message": "Bind address"},
"app.skin-site.chat.admin.voice-signal-port": {"message": "Signal port"},
"app.skin-site.chat.admin.voice-rtc-tcp-port": {"message": "Media TCP port"},
"app.skin-site.chat.admin.voice-rtc-udp-port": {"message": "Media UDP port"},
"app.skin-site.chat.admin.voice-startup-timeout": {"message": "Startup timeout (s)"},
"app.skin-site.chat.admin.voice-use-external-ip": {"message": "Use external IP"},
"app.skin-site.chat.admin.voice-node-ip": {"message": "Node IP"},
"app.skin-site.chat.admin.voice-saved": {"message": "Voice configuration saved."},
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "Auto refresh"},
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "Refresh"},
"app.skin-site.chat.admin.voice-logs-empty": {"message": "No logs yet."},
"app.skin-site.admin.wiki.tab-migration": {"message": "Migration"},
"app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki migration"},
"app.skin-site.admin.wiki.migration-hint": {"message": "Migrated content lands in a hidden staging area and is published in one batch after every page is reviewed."},
"app.skin-site.admin.wiki.auto-title": {"message": "Read from MediaWiki API"},
"app.skin-site.admin.wiki.api-url": {"message": "api.php URL"},
"app.skin-site.admin.wiki.bot-username": {"message": "Bot username (optional)"},
"app.skin-site.admin.wiki.bot-password": {"message": "Bot password (optional)"},
"app.skin-site.admin.wiki.credentials-hint": {"message": "Credentials stay in memory for this job only. HTTPS is required when supplied."},
"app.skin-site.admin.wiki.allow-private": {"message": "Allow private network source"},
"app.skin-site.admin.wiki.allow-private-hint": {"message": "Private sources may reach services inside the server network."},
"app.skin-site.admin.wiki.start-auto": {"message": "Create automatic migration job"},
"app.skin-site.admin.wiki.xml-title": {"message": "Import MediaWiki XML"},
"app.skin-site.admin.wiki.xml-file": {"message": "Export file"},
"app.skin-site.admin.wiki.start-xml": {"message": "Upload and stage"},
"app.skin-site.admin.wiki.stale-rules": {"message": "Stale page detection rules"},
"app.skin-site.admin.wiki.stale-days": {"message": "Days without update"},
"app.skin-site.admin.wiki.maintainer-window": {"message": "Maintainer window (days)"},
"app.skin-site.admin.wiki.max-maintainers": {"message": "Max maintainers"},
"app.skin-site.admin.wiki.stale-markers": {"message": "Stale categories, templates or text"},
"app.skin-site.admin.wiki.stale-markers-hint": {"message": "Comma separated"},
"app.skin-site.admin.wiki.jobs": {"message": "Migration jobs"},
"app.skin-site.admin.wiki.refresh-jobs": {"message": "Refresh"},
"app.skin-site.admin.wiki.jobs-empty": {"message": "No migration jobs yet."},
"app.skin-site.admin.wiki.reviewed-count": {"message": "Reviewed {reviewed} / {total}"},
"app.skin-site.admin.wiki.imported-excluded": {"message": "Imported {imported} · Excluded {excluded}"},
"app.skin-site.admin.wiki.view-job": {"message": "View job"},
"app.skin-site.admin.wiki.delete-job": {"message": "Delete job record"},
"app.skin-site.admin.wiki.confirm-delete-job": {"message": "Delete migration job #{id}? This cannot be undone."},
"app.skin-site.admin.wiki.candidates": {"message": "Candidate pages"},
"app.skin-site.admin.wiki.candidates-empty": {"message": "Select a job to review its pages."},
"app.skin-site.admin.wiki.last-updated": {"message": "Updated {date}"},
"app.skin-site.admin.wiki.maintainer-count": {"message": "{count} maintainers"},
"app.skin-site.admin.wiki.conflict": {"message": "Title conflict"},
"app.skin-site.admin.wiki.target-title": {"message": "Target title"},
"app.skin-site.admin.wiki.overwrite": {"message": "Overwrite"},
"app.skin-site.admin.wiki.import": {"message": "Import"},
"app.skin-site.admin.wiki.exclude": {"message": "Exclude"},
"app.skin-site.admin.wiki.publish": {"message": "Publish batch"},
"app.skin-site.admin.wiki.rollback": {"message": "Roll back"},
"app.skin-site.admin.wiki.confirm-rollback": {"message": "Roll back this migration batch? Published pages will be reverted."},
"app.skin-site.admin.wiki.state-importing": {"message": "Importing"},
"app.skin-site.admin.wiki.state-review": {"message": "Waiting review"},
"app.skin-site.admin.wiki.state-publishing": {"message": "Publishing"},
"app.skin-site.admin.wiki.state-published": {"message": "Published"},
"app.skin-site.admin.wiki.state-failed": {"message": "Failed"},
"app.skin-site.admin.wiki.state-rolled-back": {"message": "Rolled back"},
"app.skin-site.admin.wiki.decision-pending": {"message": "Pending"},
"app.skin-site.admin.wiki.decision-import": {"message": "Import"},
"app.skin-site.admin.wiki.decision-exclude": {"message": "Exclude"},
"app.skin-site.admin.wiki.migration-started": {"message": "Migration job created."},
"app.skin-site.admin.wiki.xml-staged": {"message": "XML parsed and staged for review."},
"app.skin-site.admin.wiki.published": {"message": "Published {imported} pages, overwrote {overwritten}."},
"app.skin-site.admin.wiki.rolled-back": {"message": "Migration batch rolled back."},
"app.skin-site.admin.wiki.job-deleted": {"message": "Migration record deleted."},
"app.skin-site.admin.wiki.select-xml": {"message": "Choose a MediaWiki XML file first."},
"app.skin-site.wiki.title": {"message": "StarLight Wiki"},
"app.skin-site.wiki.search-placeholder": {"message": "Enter a page title"},
"app.skin-site.wiki.open": {"message": "Open"},
"app.skin-site.wiki.loading": {"message": "Loading…"},
"app.skin-site.wiki.not-found": {"message": "Page not found."},
"app.skin-site.wiki.empty": {"message": "Enter a page title above to start reading."},
"app.skin-site.wiki.updated-at": {"message": "Last edited {date} by {author}"},
"app.skin-site.wiki.revision": {"message": "Revision #{id}"},
"app.skin-site.wiki.categories": {"message": "Categories"},
"app.skin-site.wiki.back": {"message": "Back"}
}
+483 -1
View File
@@ -11783,5 +11783,487 @@
"app.skin-site.admin.launcher.prev": {"message": "上一页"},
"app.skin-site.admin.launcher.next": {"message": "下一页"},
"app.skin-site.admin.launcher.page": {"message": "第 {page} 页"},
"app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"}
"app.skin-site.admin.launcher.total": {"message": "共 {count} 个版本"},
"app.skin-site.admin.downloads.title": {"message": "资源下载"},
"app.skin-site.admin.downloads.loading": {"message": "加载中…"},
"app.skin-site.admin.downloads.failed": {"message": "无法加载资源列表。"},
"app.skin-site.admin.downloads.empty": {"message": "没有下载条目。"},
"app.skin-site.admin.downloads.categories": {"message": "分类"},
"app.skin-site.admin.downloads.all": {"message": "全部条目"},
"app.skin-site.admin.downloads.add-category": {"message": "添加"},
"app.skin-site.admin.downloads.category-name": {"message": "分类名称"},
"app.skin-site.admin.downloads.remove-category": {"message": "移除"},
"app.skin-site.admin.downloads.total": {"message": "共 {count} 个条目"},
"app.skin-site.admin.downloads.add-item": {"message": "新建条目"},
"app.skin-site.admin.downloads.save": {"message": "保存"},
"app.skin-site.admin.downloads.cancel": {"message": "取消"},
"app.skin-site.admin.downloads.edit": {"message": "编辑"},
"app.skin-site.admin.downloads.remove": {"message": "删除"},
"app.skin-site.admin.downloads.filename": {"message": "文件名"},
"app.skin-site.admin.downloads.description": {"message": "描述"},
"app.skin-site.admin.downloads.routes": {"message": "下载线路"},
"app.skin-site.admin.downloads.route-name": {"message": "名称"},
"app.skin-site.admin.downloads.route-url": {"message": "URL"},
"app.skin-site.admin.downloads.add-route": {"message": "添加线路"},
"app.skin-site.admin.downloads.remove-route": {"message": "移除"},
"app.skin-site.admin.downloads.statuses": {"message": "状态"},
"app.skin-site.admin.downloads.bind-category": {"message": "所属分类"},
"app.skin-site.admin.downloads.uploader": {"message": "由 {name} 上传"},
"app.skin-site.admin.downloads.created": {"message": "条目已创建。"},
"app.skin-site.admin.downloads.updated": {"message": "条目已更新。"},
"app.skin-site.admin.downloads.deleted": {"message": "条目已删除。"},
"app.skin-site.admin.downloads.category-added": {"message": "分类已添加。"},
"app.skin-site.admin.downloads.category-deleted": {"message": "分类已删除。"},
"app.skin-site.admin.downloads.confirm-delete-item": {"message": "确定删除「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.downloads.confirm-delete-category": {"message": "确定删除分类「{name}」吗?"},
"app.skin-site.admin.servers.title": {"message": "地图服务器"},
"app.skin-site.admin.servers.loading": {"message": "加载中…"},
"app.skin-site.admin.servers.failed": {"message": "无法加载服务器列表。"},
"app.skin-site.admin.servers.empty": {"message": "尚未配置地图服务器。"},
"app.skin-site.admin.servers.server-name": {"message": "服务器名称"},
"app.skin-site.admin.servers.internal": {"message": "内部"},
"app.skin-site.admin.servers.maps": {"message": "地图"},
"app.skin-site.admin.servers.map-name": {"message": "地图名称"},
"app.skin-site.admin.servers.map-url": {"message": "地图地址"},
"app.skin-site.admin.servers.add-map": {"message": "添加地图"},
"app.skin-site.admin.servers.remove-map": {"message": "移除"},
"app.skin-site.admin.servers.add-server": {"message": "添加服务器"},
"app.skin-site.admin.servers.remove-server": {"message": "删除服务器"},
"app.skin-site.admin.servers.save": {"message": "保存更改"},
"app.skin-site.admin.servers.saving": {"message": "保存中…"},
"app.skin-site.admin.servers.saved": {"message": "地图服务器已保存。"},
"app.skin-site.admin.audit.title": {"message": "登录与认证审计"},
"app.skin-site.admin.audit.loading": {"message": "加载中…"},
"app.skin-site.admin.audit.failed": {"message": "无法加载审计日志。"},
"app.skin-site.admin.audit.empty": {"message": "没有审计记录。"},
"app.skin-site.admin.audit.subject": {"message": "主体"},
"app.skin-site.admin.audit.action": {"message": "操作"},
"app.skin-site.admin.audit.source-ip": {"message": "来源 IP"},
"app.skin-site.admin.audit.reported-ip": {"message": "上报玩家 IP"},
"app.skin-site.admin.audit.count": {"message": "次数"},
"app.skin-site.admin.audit.first-at": {"message": "首次出现"},
"app.skin-site.admin.audit.last-at": {"message": "最近出现"},
"app.skin-site.admin.audit.proxy": {"message": "代理"},
"app.skin-site.admin.audit.internal": {"message": "内部"},
"app.skin-site.admin.audit.search-subject": {"message": "主体"},
"app.skin-site.admin.audit.search-ip": {"message": "IP"},
"app.skin-site.admin.audit.search-action": {"message": "操作"},
"app.skin-site.admin.audit.search": {"message": "搜索"},
"app.skin-site.admin.audit.clear": {"message": "清空"},
"app.skin-site.admin.audit.load-more": {"message": "加载更多"},
"app.skin-site.admin.audit.retention": {"message": "保留天数"},
"app.skin-site.admin.audit.record-limit": {"message": "记录上限"},
"app.skin-site.admin.audit.save-retention": {"message": "保存"},
"app.skin-site.admin.audit.retention-saved": {"message": "保留天数已更新。"},
"app.skin-site.admin.donations.title": {"message": "捐赠管理"},
"app.skin-site.admin.donations.loading": {"message": "加载中…"},
"app.skin-site.admin.donations.failed": {"message": "无法加载捐赠列表。"},
"app.skin-site.admin.donations.empty": {"message": "暂无捐赠记录。"},
"app.skin-site.admin.donations.total-amount": {"message": "总额"},
"app.skin-site.admin.donations.donor-count": {"message": "捐赠人数"},
"app.skin-site.admin.donations.donor": {"message": "捐赠者"},
"app.skin-site.admin.donations.amount": {"message": "金额"},
"app.skin-site.admin.donations.updated-at": {"message": "更新时间"},
"app.skin-site.admin.donations.external": {"message": "外部"},
"app.skin-site.admin.donations.linked": {"message": "已绑定"},
"app.skin-site.admin.donations.create": {"message": "新增捐赠"},
"app.skin-site.admin.donations.save": {"message": "保存"},
"app.skin-site.admin.donations.cancel": {"message": "取消"},
"app.skin-site.admin.donations.edit": {"message": "编辑"},
"app.skin-site.admin.donations.remove": {"message": "删除"},
"app.skin-site.admin.donations.amount-field": {"message": "金额"},
"app.skin-site.admin.donations.display-name": {"message": "显示名称"},
"app.skin-site.admin.donations.user-uuid": {"message": "用户 UUID"},
"app.skin-site.admin.donations.qq": {"message": "QQ"},
"app.skin-site.admin.donations.append-mode": {"message": "在原有金额上累加"},
"app.skin-site.admin.donations.created": {"message": "捐赠记录已创建。"},
"app.skin-site.admin.donations.updated": {"message": "捐赠记录已更新。"},
"app.skin-site.admin.donations.deleted": {"message": "捐赠记录已删除。"},
"app.skin-site.admin.donations.confirm-delete": {"message": "确定删除「{name}」的捐赠记录吗?此操作不可撤销。"},
"app.skin-site.admin.donations.load-more": {"message": "加载更多"},
"app.skin-site.admin.mods.title": {"message": "Mod 管理"},
"app.skin-site.admin.mods.tab-mods": {"message": "Mod 文件"},
"app.skin-site.admin.mods.tab-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tab-runtime": {"message": "运行时"},
"app.skin-site.admin.mods.loading": {"message": "加载中…"},
"app.skin-site.admin.mods.failed": {"message": "无法加载 Mod 列表。"},
"app.skin-site.admin.mods.empty": {"message": "没有找到 Mod。"},
"app.skin-site.admin.mods.total": {"message": "共 {count} 个 Mod"},
"app.skin-site.admin.mods.search": {"message": "按名称搜索"},
"app.skin-site.admin.mods.filter-all": {"message": "全部标签"},
"app.skin-site.admin.mods.prev": {"message": "上一页"},
"app.skin-site.admin.mods.next": {"message": "下一页"},
"app.skin-site.admin.mods.page": {"message": "第 {page} 页"},
"app.skin-site.admin.mods.name": {"message": "名称"},
"app.skin-site.admin.mods.version": {"message": "版本"},
"app.skin-site.admin.mods.mod-id": {"message": "Mod ID"},
"app.skin-site.admin.mods.size": {"message": "大小"},
"app.skin-site.admin.mods.loaders": {"message": "加载器"},
"app.skin-site.admin.mods.domain-rules": {"message": "域名规则"},
"app.skin-site.admin.mods.edit": {"message": "编辑"},
"app.skin-site.admin.mods.save": {"message": "保存"},
"app.skin-site.admin.mods.cancel": {"message": "取消"},
"app.skin-site.admin.mods.remove": {"message": "删除"},
"app.skin-site.admin.mods.updated": {"message": "Mod 已更新。"},
"app.skin-site.admin.mods.deleted": {"message": "Mod 已删除。"},
"app.skin-site.admin.mods.confirm-delete": {"message": "确定删除 Mod「{name}」吗?此操作不可撤销。"},
"app.skin-site.admin.mods.rule-tags": {"message": "规则标签"},
"app.skin-site.admin.mods.tag-name": {"message": "标签名称"},
"app.skin-site.admin.mods.tag-rules": {"message": "规则"},
"app.skin-site.admin.mods.tag-rules-hint": {"message": "每行一条规则。"},
"app.skin-site.admin.mods.new-tag": {"message": "新建标签"},
"app.skin-site.admin.mods.tag-created": {"message": "标签已创建。"},
"app.skin-site.admin.mods.tag-updated": {"message": "标签已更新。"},
"app.skin-site.admin.mods.tag-deleted": {"message": "标签已删除。"},
"app.skin-site.admin.mods.confirm-delete-tag": {"message": "确定删除标签「{name}」吗?"},
"app.skin-site.admin.mods.runtime": {"message": "运行时版本"},
"app.skin-site.admin.mods.manifest-url": {"message": "清单地址"},
"app.skin-site.admin.mods.upload-runtime": {"message": "上传运行时"},
"app.skin-site.admin.mods.runtime-version": {"message": "版本"},
"app.skin-site.admin.mods.uploading": {"message": "上传中…"},
"app.skin-site.admin.mods.runtime-uploaded": {"message": "运行时已上传。"},
"app.skin-site.admin.mods.activate": {"message": "设为当前"},
"app.skin-site.admin.mods.activated": {"message": "运行时已启用。"},
"app.skin-site.admin.mods.available": {"message": "可用"},
"app.skin-site.admin.mods.unavailable": {"message": "不可用"},
"app.skin-site.admin.mods.active": {"message": "当前版本"},
"app.skin-site.admin.mods.uploader": {"message": "由 {name} 上传"},
"app.skin-site.home.nav.title": {"message": "皮肤站"},
"app.skin-site.home.nav.download": {"message": "资源下载"},
"app.skin-site.home.nav.download-desc": {"message": "浏览服务器分享的文件。"},
"app.skin-site.home.nav.review": {"message": "玩家审核"},
"app.skin-site.home.nav.review-desc": {"message": "提交并审核玩家申请。"},
"app.skin-site.home.nav.wiki": {"message": "服务器维基"},
"app.skin-site.home.nav.wiki-desc": {"message": "服务器的指南与文档。"},
"app.skin-site.home.nav.bugs": {"message": "问题追踪"},
"app.skin-site.home.nav.bugs-desc": {"message": "反馈问题并跟踪处理进度。"},
"app.skin-site.home.nav.map": {"message": "服务器地图"},
"app.skin-site.home.nav.map-desc": {"message": "探索实时世界地图。"},
"app.skin-site.home.nav.profile": {"message": "个人资料"},
"app.skin-site.home.nav.profile-desc": {"message": "管理你的账号与皮肤。"},
"app.skin-site.admin.wiki.title": {"message": "维基管理"},
"app.skin-site.admin.wiki.tab-page": {"message": "页面"},
"app.skin-site.admin.wiki.tab-audit": {"message": "审计日志"},
"app.skin-site.admin.wiki.tab-settings": {"message": "设置"},
"app.skin-site.admin.wiki.loading": {"message": "加载中…"},
"app.skin-site.admin.wiki.page-path": {"message": "页面路径"},
"app.skin-site.admin.wiki.load-page": {"message": "加载页面"},
"app.skin-site.admin.wiki.page-not-found": {"message": "未找到该页面。"},
"app.skin-site.admin.wiki.revision": {"message": "版本"},
"app.skin-site.admin.wiki.author": {"message": "作者"},
"app.skin-site.admin.wiki.updated-at": {"message": "更新时间"},
"app.skin-site.admin.wiki.categories": {"message": "分类"},
"app.skin-site.admin.wiki.read-policy": {"message": "阅读权限"},
"app.skin-site.admin.wiki.edit-policy": {"message": "编辑权限"},
"app.skin-site.admin.wiki.save-protect": {"message": "保存权限"},
"app.skin-site.admin.wiki.protect-saved": {"message": "页面权限已更新。"},
"app.skin-site.admin.wiki.set-home": {"message": "设为首页"},
"app.skin-site.admin.wiki.home-set": {"message": "首页已更新。"},
"app.skin-site.admin.wiki.set-deleted": {"message": "标记为已删除"},
"app.skin-site.admin.wiki.set-restored": {"message": "恢复页面"},
"app.skin-site.admin.wiki.deleted-updated": {"message": "页面删除状态已更新。"},
"app.skin-site.admin.wiki.audit-empty": {"message": "没有审计记录。"},
"app.skin-site.admin.wiki.audit-target": {"message": "目标"},
"app.skin-site.admin.wiki.load-more": {"message": "加载更多"},
"app.skin-site.admin.wiki.settings-hint": {"message": "以逗号分隔域名。"},
"app.skin-site.admin.wiki.domains": {"message": "域名"},
"app.skin-site.admin.wiki.home-page": {"message": "首页"},
"app.skin-site.admin.wiki.default-skin": {"message": "默认皮肤"},
"app.skin-site.admin.wiki.max-page-bytes": {"message": "页面最大字节数"},
"app.skin-site.admin.wiki.max-attachment-bytes": {"message": "附件最大字节数"},
"app.skin-site.admin.wiki.save-settings": {"message": "保存设置"},
"app.skin-site.admin.wiki.settings-saved": {"message": "设置已保存。"},
"app.skin-site.admin.review.tab-list": {"message": "申请列表"},
"app.skin-site.admin.review.tab-papers": {"message": "试卷管理"},
"app.skin-site.admin.review.papers-empty": {"message": "还没有审核试卷。"},
"app.skin-site.admin.review.new-paper": {"message": "新建试卷"},
"app.skin-site.admin.review.edit-paper": {"message": "编辑"},
"app.skin-site.admin.review.paper-title": {"message": "试卷标题"},
"app.skin-site.admin.review.paper-annotation": {"message": "描述"},
"app.skin-site.admin.review.paper-enabled": {"message": "启用"},
"app.skin-site.admin.review.paper-mode": {"message": "阅卷模式"},
"app.skin-site.admin.review.paper-pass-score": {"message": "通过分数"},
"app.skin-site.admin.review.paper-target-permissions": {"message": "通过后获得的身份"},
"app.skin-site.admin.review.paper-questions": {"message": "题目"},
"app.skin-site.admin.review.add-question": {"message": "添加题目"},
"app.skin-site.admin.review.question-title": {"message": "题目"},
"app.skin-site.admin.review.question-type": {"message": "题型"},
"app.skin-site.admin.review.question-score": {"message": "分数"},
"app.skin-site.admin.review.question-annotation": {"message": "题目注解"},
"app.skin-site.admin.review.add-option": {"message": "添加选项"},
"app.skin-site.admin.review.option-description": {"message": "选项文本"},
"app.skin-site.admin.review.option-correct": {"message": "正确"},
"app.skin-site.admin.review.remove-question": {"message": "删除"},
"app.skin-site.admin.review.question-no": {"message": "第 {n} 题"},
"app.skin-site.admin.review.paper-created": {"message": "试卷已创建。"},
"app.skin-site.admin.review.paper-saved": {"message": "试卷已保存。"},
"app.skin-site.admin.review.mail-title": {"message": "通过邮件"},
"app.skin-site.admin.review.mail-enabled": {"message": "审核通过后发送邮件"},
"app.skin-site.admin.review.mail-subject": {"message": "邮件标题"},
"app.skin-site.admin.review.mail-message": {"message": "邮件正文"},
"app.skin-site.admin.review.mail-extra": {"message": "邮件补充说明"},
"app.skin-site.admin.review.mail-include-invite": {"message": "附带邀请码"},
"app.skin-site.admin.review.prevent-duplicate": {"message": "禁止重复作答"},
"app.skin-site.admin.review.copy-link": {"message": "复制直链"},
"app.skin-site.admin.review.link-copied": {"message": "直链已复制。"},
"app.skin-site.admin.review.move-up": {"message": "上移"},
"app.skin-site.admin.review.move-down": {"message": "下移"},
"app.skin-site.admin.review.mail-subject-required": {"message": "开启邮件后必须填写邮件标题。"},
"app.skin-site.admin.review.mail-message-required": {"message": "开启邮件后必须填写邮件正文。"},
"app.skin-site.admin.users.actions": {"message": "操作"},
"app.skin-site.admin.users.audit": {"message": "审计"},
"app.skin-site.admin.users.audit-empty": {"message": "暂无管理审计记录。"},
"app.skin-site.admin.users.audit-more": {"message": "加载更早记录"},
"app.skin-site.admin.users.audit-title": {"message": "用户管理审计"},
"app.skin-site.admin.users.bot": {"message": "机器人"},
"app.skin-site.admin.users.bot-active": {"message": "Token 有效"},
"app.skin-site.admin.users.bot-ban": {"message": "封禁 Bot 创建"},
"app.skin-site.admin.users.bot-creation-banned": {"message": "禁止创建 Token"},
"app.skin-site.admin.users.bot-exists": {"message": "已创建 Bot"},
"app.skin-site.admin.users.bot-inactive": {"message": "Token 已失效"},
"app.skin-site.admin.users.bot-invalidate": {"message": "使 Token 失效"},
"app.skin-site.admin.users.bot-not-exists": {"message": "尚未创建 Bot"},
"app.skin-site.admin.users.bot-save-username": {"message": "保存机器人用户名"},
"app.skin-site.admin.users.bot-title": {"message": "Bot 管理"},
"app.skin-site.admin.users.bot-token-hint": {"message": "Token 标识"},
"app.skin-site.admin.users.bot-unban": {"message": "解除 Bot 封禁"},
"app.skin-site.admin.users.bot-username": {"message": "机器人用户名"},
"app.skin-site.admin.users.bound-players-title": {"message": "绑定玩家"},
"app.skin-site.admin.users.frequency": {"message": "频率限制"},
"app.skin-site.admin.users.frequency-clear": {"message": "取消限制"},
"app.skin-site.admin.users.frequency-current": {"message": "当前 {percent}%"},
"app.skin-site.admin.users.frequency-hint": {"message": "以下列频率对指定用户进行丢包处理,限制其进入服务器的频率。"},
"app.skin-site.admin.users.frequency-none": {"message": "当前未启用"},
"app.skin-site.admin.users.frequency-range": {"message": "仅允许设置 10% 至 100% 的整数。"},
"app.skin-site.admin.users.frequency-save": {"message": "保存限制"},
"app.skin-site.admin.users.frequency-title": {"message": "频率限制"},
"app.skin-site.admin.users.invite": {"message": "邀请用户"},
"app.skin-site.admin.users.invite-copied": {"message": "邀请码已复制。"},
"app.skin-site.admin.users.invite-copy": {"message": "复制"},
"app.skin-site.admin.users.invite-email": {"message": "邮箱"},
"app.skin-site.admin.users.invite-email-invalid": {"message": "请输入正确格式的邮箱。"},
"app.skin-site.admin.users.invite-generate": {"message": "生成邀请码"},
"app.skin-site.admin.users.invite-title": {"message": "邀请用户"},
"app.skin-site.admin.users.legacy-adapted": {"message": "已适配旧内服"},
"app.skin-site.admin.users.registered-at": {"message": "注册时间"},
"app.skin-site.admin.users.status": {"message": "状态"},
"app.skin-site.admin.users.status-banned": {"message": "已封禁"},
"app.skin-site.admin.users.status-normal": {"message": "正常"},
"app.skin-site.admin.mods.upload": {"message": "上传"},
"app.skin-site.admin.mods.select-mode": {"message": "选择多个"},
"app.skin-site.admin.mods.exit-select": {"message": "完成选择"},
"app.skin-site.admin.mods.select-all": {"message": "全选本页"},
"app.skin-site.admin.mods.clear-selection": {"message": "取消全选"},
"app.skin-site.admin.mods.selected-count": {"message": "已选择 {count} 项"},
"app.skin-site.admin.mods.delete-selected": {"message": "删除所选"},
"app.skin-site.admin.mods.confirm-delete-multi": {"message": "确定删除选中的 {count} 个 Mod?此操作不可撤销。"},
"app.skin-site.admin.mods.deleted-multi": {"message": "已删除所选 Mod。"},
"app.skin-site.admin.mods.server-address": {"message": "服务器地址"},
"app.skin-site.admin.mods.server-address-hint": {"message": "按服务器地址过滤"},
"app.skin-site.admin.mods.apply": {"message": "应用"},
"app.skin-site.admin.mods.clear": {"message": "清除"},
"app.skin-site.admin.mods.upload-title": {"message": "上传 Mod"},
"app.skin-site.admin.mods.upload-drop": {"message": "拖拽 .jar 文件到此处,或点击选择"},
"app.skin-site.admin.mods.upload-sub": {"message": "支持多文件,单个不超过 {size} MB"},
"app.skin-site.admin.mods.upload-tags": {"message": "附加规则标签"},
"app.skin-site.admin.mods.upload-queue": {"message": "文件列表"},
"app.skin-site.admin.mods.upload-pending": {"message": "等待中"},
"app.skin-site.admin.mods.upload-checking": {"message": "检查中…"},
"app.skin-site.admin.mods.upload-uploading": {"message": "上传中…"},
"app.skin-site.admin.mods.upload-conflict": {"message": "名称已存在"},
"app.skin-site.admin.mods.upload-success": {"message": "完成"},
"app.skin-site.admin.mods.upload-error": {"message": "失败"},
"app.skin-site.admin.mods.upload-exists": {"message": "该文件已存在于服务器。"},
"app.skin-site.admin.mods.upload-name-conflict": {"message": "已存在同名 Mod"},
"app.skin-site.admin.mods.upload-name-conflict-hint": {"message": "覆盖已有 Mod,或跳过此文件。"},
"app.skin-site.admin.mods.upload-overwrite": {"message": "覆盖"},
"app.skin-site.admin.mods.upload-skip": {"message": "跳过"},
"app.skin-site.admin.mods.upload-close": {"message": "关闭"},
"app.skin-site.admin.mods.upload-all-done": {"message": "全部文件处理完成。"},
"app.skin-site.admin.mods.upload-too-large": {"message": "文件过大:{name}"},
"app.skin-site.chat.avatar.label": {"message": "房间头像"},
"app.skin-site.chat.avatar.upload": {"message": "上传头像"},
"app.skin-site.chat.avatar.replace": {"message": "更换头像"},
"app.skin-site.chat.avatar.remove": {"message": "移除头像"},
"app.skin-site.chat.avatar.hint": {"message": "支持 PNG、JPEG、WebP,单边不超过 8192 像素,文件不超过 5 MB。"},
"app.skin-site.chat.avatar.bad-format": {"message": "房间头像仅支持 PNG、JPEG 和 WebP 格式。"},
"app.skin-site.chat.avatar.too-large": {"message": "房间头像大小不能超过 5 MB。"},
"app.skin-site.chat.avatar.not-image": {"message": "房间头像必须是图片。"},
"app.skin-site.chat.admin.title": {"message": "聊天室管理"},
"app.skin-site.chat.admin.subtitle": {"message": "创建公开或私有房间,并将日常管理权限交给合适的成员。"},
"app.skin-site.chat.admin.create-room": {"message": "创建房间"},
"app.skin-site.chat.admin.loading": {"message": "加载中…"},
"app.skin-site.chat.admin.failed": {"message": "无法加载房间列表。"},
"app.skin-site.chat.admin.empty": {"message": "还没有聊天室。"},
"app.skin-site.chat.admin.edit": {"message": "编辑"},
"app.skin-site.chat.admin.remove": {"message": "删除"},
"app.skin-site.chat.admin.save": {"message": "保存"},
"app.skin-site.chat.admin.cancel": {"message": "取消"},
"app.skin-site.chat.admin.name": {"message": "房间名称"},
"app.skin-site.chat.admin.description": {"message": "房间简介"},
"app.skin-site.chat.admin.visibility": {"message": "可见性"},
"app.skin-site.chat.admin.visibility-public": {"message": "公开"},
"app.skin-site.chat.admin.visibility-private": {"message": "私有"},
"app.skin-site.chat.admin.kind": {"message": "房间类型"},
"app.skin-site.chat.admin.kind-standard": {"message": "普通房间"},
"app.skin-site.chat.admin.kind-settlement": {"message": "聚落房间"},
"app.skin-site.chat.admin.prerequisite-mode": {"message": "前置条件判定"},
"app.skin-site.chat.admin.prerequisite-any": {"message": "满足任一"},
"app.skin-site.chat.admin.prerequisite-all": {"message": "满足全部"},
"app.skin-site.chat.admin.prerequisite-permissions": {"message": "所需权限"},
"app.skin-site.chat.admin.prerequisite-hint": {"message": "以逗号分隔;留空表示无限制。"},
"app.skin-site.chat.admin.created": {"message": "房间已创建。"},
"app.skin-site.chat.admin.updated": {"message": "房间资料已更新。"},
"app.skin-site.chat.admin.deleted": {"message": "房间已删除。"},
"app.skin-site.chat.admin.confirm-delete": {"message": "确认永久删除房间“{name}”及其中全部消息吗?"},
"app.skin-site.chat.admin.manager-badge": {"message": "管理"},
"app.skin-site.chat.admin.voice-badge": {"message": "语音"},
"app.skin-site.chat.user-picker.label": {"message": "选择用户"},
"app.skin-site.chat.user-picker.placeholder": {"message": "搜索用户名、邮箱或账号 ID"},
"app.skin-site.chat.user-picker.by-user": {"message": "按用户信息"},
"app.skin-site.chat.user-picker.by-player": {"message": "按玩家 ID"},
"app.skin-site.chat.user-picker.hint": {"message": "支持按用户名、邮箱模糊搜索,也可输入账号 ID 搜索。"},
"app.skin-site.chat.user-picker.player-hint": {"message": "输入玩家名称,查找绑定该玩家 ID 的用户。"},
"app.skin-site.chat.user-picker.empty": {"message": "没有找到匹配的用户"},
"app.skin-site.chat.user-picker.banned": {"message": "该账号已被封禁,不能执行此操作。"},
"app.skin-site.chat.user-picker.guest": {"message": "游客不能成为房管、聚落主或聚落成员。"},
"app.skin-site.chat.user-picker.already-in": {"message": "该用户已经在当前房间中。"},
"app.skin-site.chat.user-picker.not-allowed": {"message": "该用户不在当前允许选择的范围内"},
"app.skin-site.chat.user-picker.select": {"message": "选择此用户"},
"app.skin-site.chat.user-picker.selected": {"message": "已选择此用户"},
"app.skin-site.chat.user-picker.id": {"message": "ID"},
"app.skin-site.chat.admin.managers": {"message": "房管"},
"app.skin-site.chat.admin.manager-title": {"message": "任命房管"},
"app.skin-site.chat.admin.manager-hint": {"message": "房管可以管理该房间的成员、消息与文件。"},
"app.skin-site.chat.admin.grant": {"message": "任命"},
"app.skin-site.chat.admin.revoke": {"message": "撤销"},
"app.skin-site.chat.admin.manager-granted": {"message": "已任命房管。"},
"app.skin-site.chat.admin.manager-revoked": {"message": "已撤销房管。"},
"app.skin-site.chat.admin.owner-title": {"message": "聚落主"},
"app.skin-site.chat.admin.owner-hint": {"message": "为该房间任命首任聚落主。"},
"app.skin-site.chat.admin.owner-assigned": {"message": "已任命聚落主。"},
"app.skin-site.chat.admin.owner-current": {"message": "当前聚落主:{name}"},
"app.skin-site.chat.admin.owner-none": {"message": "尚未设置聚落主。"},
"app.skin-site.chat.admin.audit": {"message": "审计"},
"app.skin-site.chat.admin.audit-title": {"message": "房间审计"},
"app.skin-site.chat.admin.audit-empty": {"message": "暂无审计记录。"},
"app.skin-site.chat.admin.audit-target": {"message": "对象:{name}"},
"app.skin-site.chat.admin.close": {"message": "关闭"},
"app.skin-site.chat.voice-runtime-title": {"message": "实时语音库"},
"app.skin-site.chat.voice-logs": {"message": "查看日志"},
"app.skin-site.chat.voice-config": {"message": "配置语音库"},
"app.skin-site.chat.voice-retry": {"message": "重新部署"},
"app.skin-site.chat.voice-config-title": {"message": "语音库配置"},
"app.skin-site.chat.voice-logs-title": {"message": "LiveKit 日志"},
"app.skin-site.chat.voice-capacity-title": {"message": "语音容量"},
"app.skin-site.chat.voice-capacity": {"message": "语音容量"},
"app.skin-site.chat.participant-limit": {"message": "参与人数上限"},
"app.skin-site.chat.listener-limit": {"message": "旁听人数上限"},
"app.skin-site.chat.voice-capacity-updated": {"message": "语音容量已更新。"},
"app.skin-site.chat.voice-enabled": {"message": "启用实时语音"},
"app.skin-site.chat.voice-runtime-path": {"message": "语音库目录"},
"app.skin-site.chat.voice-mirror": {"message": "镜像基础地址"},
"app.skin-site.chat.voice-public-url": {"message": "公网访问地址"},
"app.skin-site.chat.voice-bind": {"message": "监听地址"},
"app.skin-site.chat.voice-signal-port": {"message": "信令端口"},
"app.skin-site.chat.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
"app.skin-site.chat.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
"app.skin-site.chat.voice-startup-timeout": {"message": "启动等待时间(秒)"},
"app.skin-site.chat.voice-use-external-ip": {"message": "使用外部 IP"},
"app.skin-site.chat.voice-node-ip": {"message": "节点 IP"},
"app.skin-site.chat.voice-saved": {"message": "语音库配置已保存。"},
"app.skin-site.chat.voice-logs-auto-refresh": {"message": "自动刷新"},
"app.skin-site.chat.voice-logs-refresh": {"message": "刷新"},
"app.skin-site.chat.voice-logs-empty": {"message": "暂无日志。"},
"app.skin-site.chat.admin.voice-runtime-title": {"message": "实时语音库"},
"app.skin-site.chat.admin.voice-logs": {"message": "查看日志"},
"app.skin-site.chat.admin.voice-config": {"message": "配置语音库"},
"app.skin-site.chat.admin.voice-retry": {"message": "重新部署"},
"app.skin-site.chat.admin.voice-config-title": {"message": "语音库配置"},
"app.skin-site.chat.admin.voice-logs-title": {"message": "LiveKit 日志"},
"app.skin-site.chat.admin.voice-capacity-title": {"message": "语音容量"},
"app.skin-site.chat.admin.voice-capacity": {"message": "语音容量"},
"app.skin-site.chat.admin.participant-limit": {"message": "参与人数上限"},
"app.skin-site.chat.admin.listener-limit": {"message": "旁听人数上限"},
"app.skin-site.chat.admin.voice-capacity-updated": {"message": "语音容量已更新。"},
"app.skin-site.chat.admin.voice-enabled": {"message": "启用实时语音"},
"app.skin-site.chat.admin.voice-runtime-path": {"message": "语音库目录"},
"app.skin-site.chat.admin.voice-mirror": {"message": "镜像基础地址"},
"app.skin-site.chat.admin.voice-public-url": {"message": "公网访问地址"},
"app.skin-site.chat.admin.voice-bind": {"message": "监听地址"},
"app.skin-site.chat.admin.voice-signal-port": {"message": "信令端口"},
"app.skin-site.chat.admin.voice-rtc-tcp-port": {"message": "媒体 TCP 端口"},
"app.skin-site.chat.admin.voice-rtc-udp-port": {"message": "媒体 UDP 端口"},
"app.skin-site.chat.admin.voice-startup-timeout": {"message": "启动等待时间(秒)"},
"app.skin-site.chat.admin.voice-use-external-ip": {"message": "使用外部 IP"},
"app.skin-site.chat.admin.voice-node-ip": {"message": "节点 IP"},
"app.skin-site.chat.admin.voice-saved": {"message": "语音库配置已保存。"},
"app.skin-site.chat.admin.voice-logs-auto-refresh": {"message": "自动刷新"},
"app.skin-site.chat.admin.voice-logs-refresh": {"message": "刷新"},
"app.skin-site.chat.admin.voice-logs-empty": {"message": "暂无日志。"},
"app.skin-site.admin.wiki.tab-migration": {"message": "迁移中心"},
"app.skin-site.admin.wiki.migration-title": {"message": "MediaWiki 迁移"},
"app.skin-site.admin.wiki.migration-hint": {"message": "迁移内容会先进入不可见暂存区,所有页面完成导入或排除审核后才能整批发布。"},
"app.skin-site.admin.wiki.auto-title": {"message": "从 MediaWiki API 自动读取"},
"app.skin-site.admin.wiki.api-url": {"message": "api.php 地址"},
"app.skin-site.admin.wiki.bot-username": {"message": "Bot 用户名(选填)"},
"app.skin-site.admin.wiki.bot-password": {"message": "Bot 密码(选填)"},
"app.skin-site.admin.wiki.credentials-hint": {"message": "凭据只存在于本次后台任务的内存中,携带凭据时必须使用 HTTPS。"},
"app.skin-site.admin.wiki.allow-private": {"message": "明确允许访问内网来源"},
"app.skin-site.admin.wiki.allow-private-hint": {"message": "内网来源可能访问皮肤站所在网络中的服务,请只对可信地址启用。"},
"app.skin-site.admin.wiki.start-auto": {"message": "创建自动迁移任务"},
"app.skin-site.admin.wiki.xml-title": {"message": "导入 MediaWiki XML"},
"app.skin-site.admin.wiki.xml-file": {"message": "导出文件"},
"app.skin-site.admin.wiki.start-xml": {"message": "上传并建立暂存任务"},
"app.skin-site.admin.wiki.stale-rules": {"message": "疑似过时页面筛选规则"},
"app.skin-site.admin.wiki.stale-days": {"message": "未更新天数"},
"app.skin-site.admin.wiki.maintainer-window": {"message": "维护者统计天数"},
"app.skin-site.admin.wiki.max-maintainers": {"message": "维护者人数不超过"},
"app.skin-site.admin.wiki.stale-markers": {"message": "过时分类、模板或文字标记"},
"app.skin-site.admin.wiki.stale-markers-hint": {"message": "使用逗号分隔"},
"app.skin-site.admin.wiki.jobs": {"message": "迁移任务"},
"app.skin-site.admin.wiki.refresh-jobs": {"message": "刷新"},
"app.skin-site.admin.wiki.jobs-empty": {"message": "还没有迁移任务。"},
"app.skin-site.admin.wiki.reviewed-count": {"message": "已审核 {reviewed} / {total}"},
"app.skin-site.admin.wiki.imported-excluded": {"message": "导入 {imported} · 排除 {excluded}"},
"app.skin-site.admin.wiki.view-job": {"message": "查看任务"},
"app.skin-site.admin.wiki.delete-job": {"message": "删除迁移记录"},
"app.skin-site.admin.wiki.confirm-delete-job": {"message": "确认删除迁移任务 #{id}?此操作不可恢复。"},
"app.skin-site.admin.wiki.candidates": {"message": "候选页面"},
"app.skin-site.admin.wiki.candidates-empty": {"message": "选择任务后查看其页面。"},
"app.skin-site.admin.wiki.last-updated": {"message": "更新于 {date}"},
"app.skin-site.admin.wiki.maintainer-count": {"message": "{count} 位维护者"},
"app.skin-site.admin.wiki.conflict": {"message": "标题冲突"},
"app.skin-site.admin.wiki.target-title": {"message": "目标标题"},
"app.skin-site.admin.wiki.overwrite": {"message": "覆盖"},
"app.skin-site.admin.wiki.import": {"message": "导入"},
"app.skin-site.admin.wiki.exclude": {"message": "排除"},
"app.skin-site.admin.wiki.publish": {"message": "整批发布"},
"app.skin-site.admin.wiki.rollback": {"message": "回滚"},
"app.skin-site.admin.wiki.confirm-rollback": {"message": "确认回滚本批迁移?已发布的页面将被还原。"},
"app.skin-site.admin.wiki.state-importing": {"message": "正在读取"},
"app.skin-site.admin.wiki.state-review": {"message": "等待审核"},
"app.skin-site.admin.wiki.state-publishing": {"message": "正在发布"},
"app.skin-site.admin.wiki.state-published": {"message": "已发布"},
"app.skin-site.admin.wiki.state-failed": {"message": "失败"},
"app.skin-site.admin.wiki.state-rolled-back": {"message": "已回滚"},
"app.skin-site.admin.wiki.decision-pending": {"message": "待审核"},
"app.skin-site.admin.wiki.decision-import": {"message": "导入"},
"app.skin-site.admin.wiki.decision-exclude": {"message": "排除"},
"app.skin-site.admin.wiki.migration-started": {"message": "迁移任务已创建。"},
"app.skin-site.admin.wiki.xml-staged": {"message": "XML 已解析并进入逐页审核阶段。"},
"app.skin-site.admin.wiki.published": {"message": "已发布 {imported} 个页面,覆盖 {overwritten} 个。"},
"app.skin-site.admin.wiki.rolled-back": {"message": "迁移批次已安全回滚。"},
"app.skin-site.admin.wiki.job-deleted": {"message": "迁移记录已删除。"},
"app.skin-site.admin.wiki.select-xml": {"message": "请先选择 MediaWiki XML 文件。"},
"app.skin-site.wiki.title": {"message": "StarLight 维基"},
"app.skin-site.wiki.search-placeholder": {"message": "输入页面标题"},
"app.skin-site.wiki.open": {"message": "打开"},
"app.skin-site.wiki.loading": {"message": "加载中…"},
"app.skin-site.wiki.not-found": {"message": "页面不存在。"},
"app.skin-site.wiki.empty": {"message": "在上方输入页面标题开始阅读。"},
"app.skin-site.wiki.updated-at": {"message": "最后编辑于 {date},编辑者 {author}"},
"app.skin-site.wiki.revision": {"message": "修订 #{id}"},
"app.skin-site.wiki.categories": {"message": "分类"},
"app.skin-site.wiki.back": {"message": "返回"}
}
+8
View File
@@ -188,6 +188,9 @@ importers:
vuedraggable:
specifier: ^4.1.0
version: 4.1.0(vue@3.5.27(typescript@5.9.3))
xxhash-wasm:
specifier: 1.1.0
version: 1.1.0
devDependencies:
'@eslint/compat':
specifier: ^1.1.1
@@ -7103,6 +7106,9 @@ packages:
resolution: {integrity: sha512-LKYU1iAXJXUgAXn9URjiu+MWhyUXHsvfp7mcuYm9dSUKK0/CjtrUwFAxD82/mCWbtLsGjFIad0wIsod4zrTAEQ==}
engines: {node: '>=0.4'}
xxhash-wasm@1.1.0:
resolution: {integrity: sha512-147y/6YNh+tlp6nd/2pWq38i9h6mz/EuQ6njIrmW8D1BS5nCqs0P6DG+m6zTGnNz5I+uhZ0SHxBs9BsPrwcKDA==}
y18n@5.0.8:
resolution: {integrity: sha512-0pfFzegeDWJHJIAmTLRP2DwHjdF5s7jo9tuztdQxAhINCdvS+3nGINqPd00AphqJR/0LhANUS6/+7SCb98YOfA==}
engines: {node: '>=10'}
@@ -14160,6 +14166,8 @@ snapshots:
xtend@4.0.2: {}
xxhash-wasm@1.1.0: {}
y18n@5.0.8: {}
yallist@3.1.1: {}
+3
View File
@@ -31,8 +31,11 @@ const allowedUntranslatedMessages = new Set([
'LiteLoader',
'LogShare.CN',
'UUID',
'ID',
'QQ',
'IP',
'URL',
'Mod ID',
'LogShare AI',
'NeoForge',
'OptiFine',