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
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:
32 files changed
+11108
-491
No files matched your search
@@ -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
@@ -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"}
|
||||
}
|
||||
@@ -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": "返回"}
|
||||
}
|
||||
Generated
+8
@@ -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: {}
|
||||
|
||||
@@ -31,8 +31,11 @@ const allowedUntranslatedMessages = new Set([
|
||||
'LiteLoader',
|
||||
'LogShare.CN',
|
||||
'UUID',
|
||||
'ID',
|
||||
'QQ',
|
||||
'IP',
|
||||
'URL',
|
||||
'Mod ID',
|
||||
'LogShare AI',
|
||||
'NeoForge',
|
||||
'OptiFine',
|
||||
|
||||
Reference in new issue
Block a user