1 parent
a85e88339f
commit
47c4a3973d
6 files changed
+746
-2
No files matched your search
@@ -5,6 +5,7 @@ import { computed, ref } from 'vue'
|
|||||||
import { skinSiteUser } from '@/composables/skin-site-session'
|
import { skinSiteUser } from '@/composables/skin-site-session'
|
||||||
|
|
||||||
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
import AdminBroadcastPage from './admin/AdminBroadcastPage.vue'
|
||||||
|
import AdminDownloadsPage from './admin/AdminDownloadsPage.vue'
|
||||||
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
|
import AdminLauncherPage from './admin/AdminLauncherPage.vue'
|
||||||
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
|
import AdminPlayersPage from './admin/AdminPlayersPage.vue'
|
||||||
import AdminReviewPage from './admin/AdminReviewPage.vue'
|
import AdminReviewPage from './admin/AdminReviewPage.vue'
|
||||||
@@ -164,6 +165,7 @@ const current = ref<AdminKey>('summary')
|
|||||||
<AdminPlayersPage v-else-if="current === 'players'" />
|
<AdminPlayersPage v-else-if="current === 'players'" />
|
||||||
<AdminReviewPage v-else-if="current === 'review'" />
|
<AdminReviewPage v-else-if="current === 'review'" />
|
||||||
<AdminLauncherPage v-else-if="current === 'launcher'" />
|
<AdminLauncherPage v-else-if="current === 'launcher'" />
|
||||||
|
<AdminDownloadsPage v-else-if="current === 'downloads'" />
|
||||||
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
<p v-else class="m-0 text-sm text-secondary">{{ current }}</p>
|
||||||
</section>
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -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>
|
||||||
@@ -1290,3 +1290,104 @@ export async function skinSiteUploadLauncherRelease(
|
|||||||
}
|
}
|
||||||
return launcherUploadError('上传超时,请刷新版本列表确认结果。')
|
return launcherUploadError('上传超时,请刷新版本列表确认结果。')
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export type SkinSiteDownloadStatus = 'OK' | 'NEW' | 'LEGACY' | 'OUTDATED' | 'REMOVAL'
|
||||||
|
|
||||||
|
export interface SkinSiteDownloadRoute {
|
||||||
|
name?: string | null
|
||||||
|
url: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteDownloadItem {
|
||||||
|
id: number
|
||||||
|
uploader: string
|
||||||
|
url: string
|
||||||
|
routes: SkinSiteDownloadRoute[]
|
||||||
|
filename: string
|
||||||
|
description: string
|
||||||
|
status: SkinSiteDownloadStatus[]
|
||||||
|
category: string[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteDownloadCategory {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface SkinSiteDownloadItemInput {
|
||||||
|
routes: SkinSiteDownloadRoute[]
|
||||||
|
filename: string
|
||||||
|
description: string
|
||||||
|
status: SkinSiteDownloadStatus[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDownloadItems(params?: {
|
||||||
|
limit?: number
|
||||||
|
offset?: number
|
||||||
|
category?: string
|
||||||
|
}): Promise<SkinSiteResponse<SkinSiteDownloadItem[]>> {
|
||||||
|
const query = new URLSearchParams({
|
||||||
|
limit: String(params?.limit ?? 200),
|
||||||
|
offset: String(params?.offset ?? 0),
|
||||||
|
})
|
||||||
|
if (params?.category?.trim()) query.set('category', params.category.trim())
|
||||||
|
return skinSiteRequest<SkinSiteDownloadItem[]>('download/list?' + query.toString())
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDownloadItemCount(category?: string): Promise<SkinSiteResponse<number>> {
|
||||||
|
const query = new URLSearchParams()
|
||||||
|
if (category?.trim()) query.set('category', category.trim())
|
||||||
|
const suffix = query.toString() ? '?' + query.toString() : ''
|
||||||
|
return skinSiteRequest<number>('download/count' + suffix)
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteAddDownloadItem(
|
||||||
|
data: SkinSiteDownloadItemInput,
|
||||||
|
): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/add', { method: 'POST', body: data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteUpdateDownloadItem(
|
||||||
|
id: number,
|
||||||
|
data: SkinSiteDownloadItemInput,
|
||||||
|
): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/update/' + id, { method: 'POST', body: data })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDeleteDownloadItem(id: number): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/' + id, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDownloadCategories(): Promise<
|
||||||
|
SkinSiteResponse<SkinSiteDownloadCategory[]>
|
||||||
|
> {
|
||||||
|
return skinSiteRequest<SkinSiteDownloadCategory[]>('download/category/list')
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteAddDownloadCategory(name: string): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/category/add/' + encodeURIComponent(name), {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteDeleteDownloadCategory(id: number): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/category/' + id, { method: 'DELETE' })
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteBindDownloadCategory(
|
||||||
|
infoId: number,
|
||||||
|
categoryId: number,
|
||||||
|
): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/category/bind/' + infoId + '/' + categoryId, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
export function skinSiteUnbindDownloadCategory(
|
||||||
|
infoId: number,
|
||||||
|
categoryId: number,
|
||||||
|
): Promise<SkinSiteResponse<string>> {
|
||||||
|
return skinSiteRequest<string>('download/category/unbind/' + infoId + '/' + categoryId, {
|
||||||
|
method: 'POST',
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -11952,5 +11952,37 @@
|
|||||||
"app.skin-site.admin.launcher.prev": {"message": "Previous"},
|
"app.skin-site.admin.launcher.prev": {"message": "Previous"},
|
||||||
"app.skin-site.admin.launcher.next": {"message": "Next"},
|
"app.skin-site.admin.launcher.next": {"message": "Next"},
|
||||||
"app.skin-site.admin.launcher.page": {"message": "Page {page}"},
|
"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}”?"}
|
||||||
}
|
}
|
||||||
@@ -11783,5 +11783,37 @@
|
|||||||
"app.skin-site.admin.launcher.prev": {"message": "上一页"},
|
"app.skin-site.admin.launcher.prev": {"message": "上一页"},
|
||||||
"app.skin-site.admin.launcher.next": {"message": "下一页"},
|
"app.skin-site.admin.launcher.next": {"message": "下一页"},
|
||||||
"app.skin-site.admin.launcher.page": {"message": "第 {page} 页"},
|
"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}」吗?"}
|
||||||
}
|
}
|
||||||
@@ -33,6 +33,7 @@ const allowedUntranslatedMessages = new Set([
|
|||||||
'UUID',
|
'UUID',
|
||||||
'QQ',
|
'QQ',
|
||||||
'IP',
|
'IP',
|
||||||
|
'URL',
|
||||||
'LogShare AI',
|
'LogShare AI',
|
||||||
'NeoForge',
|
'NeoForge',
|
||||||
'OptiFine',
|
'OptiFine',
|
||||||
|
|||||||
Reference in new issue
Block a user