Merge pull request #26 from StarLight-Server/feat/home
Axolotl desktop CI / guardrails (push) Waiting to run
Repository checks / typos (push) Waiting to run
Repository checks / tombi (push) Waiting to run
Rust checks / shear (push) Waiting to run
Sync source to CNB / Sync Git ref (push) Skipped

feat: 维基页支持搜索、分类浏览、关注与移动
This commit is contained in:
AxTps authored and GitHub committed 2026-10-23 22:18:57 +08:00
commit 78e90876a6
24 files changed
+1638 -114

No files matched your search

@@ -236,6 +236,7 @@ async function refresh() {
if (parsed && !disposed) {
sync(parsed, true)
saveCache(parsed)
for (const item of parsed) void loadImage(item, token)
}
} catch {
// Remote fetch failed; previously cached items remain displayed
@@ -316,6 +317,7 @@ onUnmounted(() => {
if (interval) clearInterval(interval)
if (advanceTimer) clearTimeout(advanceTimer)
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
revokeImages()
})
</script>
@@ -378,6 +380,12 @@ onUnmounted(() => {
class="mt-1.5 size-2 shrink-0 rounded-full"
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
/>
<img
v-if="imageUrls[item.id]"
:src="imageUrls[item.id]"
alt=""
class="size-10 shrink-0 rounded object-cover"
/>
<span class="min-w-0 flex-1">
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
item.title
@@ -393,6 +401,12 @@ onUnmounted(() => {
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
{{ selected.title }}
</h2>
<img
v-if="imageUrls[selected.id]"
:src="imageUrls[selected.id]"
alt=""
class="mb-4 max-h-80 w-full rounded-lg object-contain"
/>
<div
class="markdown-body break-words"
@click="contentClick"
@@ -1,11 +1,12 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { computed, onMounted, onUnmounted, ref } from 'vue'
import {
type SkinSiteBugComment,
skinSiteBugComments,
skinSiteBugImageFetch,
type SkinSiteBugIssue,
skinSiteBugIssue,
type SkinSiteBugIssueType,
@@ -169,6 +170,26 @@ function formatDateTime(ts: number): string {
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
const imageUrls = ref<Record<string, string>>({})
function revokeImageUrls() {
for (const url of Object.values(imageUrls.value)) URL.revokeObjectURL(url)
imageUrls.value = {}
}
async function loadIssueImages(issue: SkinSiteBugIssue) {
revokeImageUrls()
const entries = await Promise.all(
(issue.images ?? []).map(async (image) => {
const blob = await skinSiteBugImageFetch(image.hash)
return blob ? [image.hash, URL.createObjectURL(blob)] : null
}),
)
const map: Record<string, string> = {}
for (const entry of entries) if (entry) map[entry[0]] = entry[1]
imageUrls.value = map
}
async function loadIssue() {
loading.value = true
failed.value = false
@@ -176,6 +197,7 @@ async function loadIssue() {
const result = await skinSiteBugIssue(props.issueId)
if (!result.success) throw new Error(result.errorMessage)
issue.value = result.payload
void loadIssueImages(result.payload)
} catch (error) {
failed.value = true
handleError(error)
@@ -241,6 +263,8 @@ onMounted(() => {
void loadIssue()
void loadComments(true)
})
onUnmounted(revokeImageUrls)
</script>
<template>
<div class="flex flex-col gap-4">
@@ -328,13 +352,27 @@ onMounted(() => {
{{ formatMessage(messages.descriptionEmpty) }}
</p>
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
<span
<a
v-for="image in issue.images"
:key="image.hash"
class="rounded-lg bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
:href="imageUrls[image.hash]"
target="_blank"
rel="noopener noreferrer"
class="block overflow-hidden rounded-lg border border-solid border-surface-5"
>
{{ image.hash }}
</span>
<img
v-if="imageUrls[image.hash]"
:src="imageUrls[image.hash]"
alt="attachment"
class="max-h-48 max-w-full object-contain"
/>
<span
v-else
class="block bg-surface-5 px-2 py-1 text-[10px] font-mono text-secondary"
>
{{ image.hash }}
</span>
</a>
</div>
</div>
@@ -100,16 +100,19 @@ defineExpose({ show })
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
<div
class="size-44 overflow-hidden rounded-full border-4 border-solid border-bg-raised bg-button-bg"
class="relative size-64 overflow-hidden rounded-2xl border-2 border-solid border-surface-5 bg-bg"
>
<img
v-if="image"
:src="image"
alt=""
class="size-full object-cover transition-transform duration-75"
class="size-full object-contain transition-transform duration-75"
:style="{ transform: `rotate(${previewRotation})` }"
draggable="false"
/>
<div v-else class="flex size-full items-center justify-center text-sm text-secondary">
{{ formatMessage(messages.loading) }}
</div>
</div>
<div class="w-full max-w-sm">
@@ -122,11 +125,9 @@ defineExpose({ show })
class="w-full accent-brand"
:disabled="loading || submitting"
/>
<div class="mt-1 flex justify-between text-xs text-secondary">
<div class="mt-1 flex items-center justify-between text-xs text-secondary">
<span>0°</span>
<span>90°</span>
<span>180°</span>
<span>270°</span>
<span class="font-semibold text-contrast">{{ degree }}°</span>
<span>360°</span>
</div>
</div>
@@ -3,6 +3,7 @@ import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref } from 'vue'
import { skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
import {
skinSitePublicDownloadCategories,
type SkinSitePublicDownloadCategory,
@@ -32,6 +33,10 @@ const messages = defineMessages({
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
signedOut: {
id: 'app.skin-site.downloads.signed-out',
defaultMessage: 'Sign in to browse downloads.',
},
})
const emit = defineEmits<{ (e: 'back'): void }>()
@@ -42,6 +47,8 @@ const selectedCategory = ref<string | null>(null)
const loading = ref(true)
const failed = ref(false)
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
const filtered = computed(() =>
selectedCategory.value
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
@@ -76,6 +83,10 @@ function download(item: SkinSitePublicDownloadItem) {
async function load() {
loading.value = true
failed.value = false
if (!signedIn.value) {
loading.value = false
return
}
try {
const [listResult, categoryResult] = await Promise.all([
skinSitePublicDownloadList(),
@@ -138,7 +149,10 @@ onMounted(load)
</button>
</div>
<div v-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<p v-if="!signedIn" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.signedOut) }}
</p>
<div v-else-if="loading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
@@ -101,7 +101,7 @@ function onBugCreated(id: number) {
@open="openBug"
/>
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
<WikiView v-else-if="view === 'wiki'" />
<WikiView v-else-if="view === 'wiki'" initial-title="The Land Of StarLight" />
<SkinSiteBugDetailView
v-else-if="view === 'bug-detail' && selectedBugId !== null"
@@ -12,9 +12,16 @@ import {
skinSiteUser,
} from '@/composables/skin-site-session'
import {
skinSiteAddPlayer,
skinSiteClearDefaultPlayer,
skinSiteDefaultPlayer,
skinSiteDeletePlayer,
skinSiteDeleteTexture,
type SkinSitePlayerSkin,
skinSitePlayerSkin,
type SkinSitePlayerSkin,
skinSiteRenamePlayer,
skinSiteSetDefaultPlayer,
skinSiteSwitchMojang,
skinSiteUploadTexture,
type SkinTextureType,
} from '@/helpers/skin-site-api'
@@ -51,6 +58,48 @@ const messages = defineMessages({
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
back: { id: 'app.skin-site.profile-view.back', defaultMessage: 'Back to home' },
addPlayer: { id: 'app.skin-site.profile-view.add-player', defaultMessage: 'New player' },
playerNamePlaceholder: {
id: 'app.skin-site.profile-view.player-name-placeholder',
defaultMessage: 'Player name',
},
confirm: { id: 'app.skin-site.profile-view.confirm', defaultMessage: 'Confirm' },
cancel: { id: 'app.skin-site.profile-view.cancel', defaultMessage: 'Cancel' },
rename: { id: 'app.skin-site.profile-view.rename', defaultMessage: 'Rename' },
delete: { id: 'app.skin-site.profile-view.delete', defaultMessage: 'Delete' },
setDefault: { id: 'app.skin-site.profile-view.set-default', defaultMessage: 'Set as default' },
clearDefault: {
id: 'app.skin-site.profile-view.clear-default',
defaultMessage: 'Clear default',
},
toggleMojang: {
id: 'app.skin-site.profile-view.toggle-mojang',
defaultMessage: 'Toggle Mojang',
},
isDefault: { id: 'app.skin-site.profile-view.is-default', defaultMessage: 'Default' },
added: { id: 'app.skin-site.profile-view.added', defaultMessage: 'Player added.' },
renamed: { id: 'app.skin-site.profile-view.renamed', defaultMessage: 'Player renamed.' },
deleted: { id: 'app.skin-site.profile-view.deleted', defaultMessage: 'Player deleted.' },
defaultSet: {
id: 'app.skin-site.profile-view.default-set',
defaultMessage: 'Default player updated.',
},
mojangToggled: {
id: 'app.skin-site.profile-view.mojang-toggled',
defaultMessage: 'Mojang status updated.',
},
deleteConfirm: {
id: 'app.skin-site.profile-view.delete-confirm',
defaultMessage: 'Delete this player? This cannot be undone.',
},
mojangConfirm: {
id: 'app.skin-site.profile-view.mojang-confirm',
defaultMessage: 'Toggle Mojang status for this player?',
},
renameConfirm: {
id: 'app.skin-site.profile-view.rename-confirm',
defaultMessage: 'Rename this player?',
},
})
const selectedId = ref<string | null>(null)
const texture = ref<SkinSitePlayerSkin>({})
@@ -93,6 +142,174 @@ watch(selectedId, (id) => {
else texture.value = {}
})
// --- Player management ---
const defaultUuid = ref<string | null>(null)
const busy = ref<string | null>(null)
async function loadDefaultPlayer() {
if (!signedIn.value) {
defaultUuid.value = null
return
}
try {
const res = await skinSiteDefaultPlayer()
defaultUuid.value = res.success ? (res.payload?.uuid ?? null) : null
} catch {
defaultUuid.value = null
}
}
const showAdd = ref(false)
const newName = ref('')
const renamingId = ref<string | null>(null)
const renameValue = ref('')
const confirmingRename = ref(false)
const confirmingDeleteId = ref<string | null>(null)
const confirmingMojangId = ref<string | null>(null)
function resetDialogs() {
showAdd.value = false
newName.value = ''
renamingId.value = null
renameValue.value = ''
confirmingRename.value = false
confirmingDeleteId.value = null
confirmingMojangId.value = null
}
async function submitAdd() {
const name = newName.value.trim()
if (!name || busy.value) return
busy.value = 'add'
try {
const res = await skinSiteAddPlayer(name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.added) })
resetDialogs()
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function startRename(player: { uuid: string; name: string }) {
renamingId.value = player.uuid
renameValue.value = player.name
confirmingRename.value = false
}
function requestRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name) return
confirmingRename.value = true
}
async function submitRename() {
const name = renameValue.value.trim()
if (!renamingId.value || !name || busy.value) return
if (!confirmingRename.value) {
confirmingRename.value = true
return
}
busy.value = 'rename'
try {
const res = await skinSiteRenamePlayer(renamingId.value, name)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.renamed) })
resetDialogs()
await requestSkinSitePlayers()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestDelete(uuid: string) {
if (busy.value) return
confirmingDeleteId.value = uuid
}
async function submitDelete(uuid: string) {
if (busy.value) return
if (confirmingDeleteId.value !== uuid) {
confirmingDeleteId.value = uuid
return
}
busy.value = 'delete'
try {
const res = await skinSiteDeletePlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.deleted) })
resetDialogs()
if (selectedId.value === uuid) selectedId.value = null
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function setDefaultPlayer(uuid: string) {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteSetDefaultPlayer(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function clearDefaultPlayer() {
if (busy.value) return
busy.value = 'default'
try {
const res = await skinSiteClearDefaultPlayer()
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.defaultSet) })
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
function requestToggleMojang(uuid: string) {
if (busy.value) return
confirmingMojangId.value = uuid
}
async function toggleMojang(uuid: string) {
if (busy.value) return
if (confirmingMojangId.value !== uuid) {
confirmingMojangId.value = uuid
return
}
busy.value = 'mojang'
try {
const res = await skinSiteSwitchMojang(uuid)
if (!res.success) throw new Error(res.errorMessage)
addNotification({ type: 'success', title: formatMessage(messages.mojangToggled) })
await requestSkinSitePlayers()
await loadDefaultPlayer()
} catch (error) {
handleError(error)
} finally {
busy.value = null
}
}
async function onUpload(type: SkinTextureType, event: Event) {
const input = event.target as HTMLInputElement
const file = input.files?.[0]
@@ -135,7 +352,10 @@ async function removeTexture(type: SkinTextureType) {
}
onMounted(() => {
if (signedIn.value) void requestSkinSitePlayers().catch(() => {})
if (signedIn.value) {
void requestSkinSitePlayers().catch(() => {})
void loadDefaultPlayer()
}
})
watch(signedIn, (value) => {
@@ -161,9 +381,79 @@ watch(signedIn, (value) => {
<div
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
>
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<div class="flex items-center justify-between gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">
{{ formatMessage(messages.title) }}
</h3>
<button
v-if="!showAdd"
type="button"
class="shrink-0 rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="showAdd = true"
>
+ {{ formatMessage(messages.addPlayer) }}
</button>
</div>
<div v-if="showAdd" class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2">
<input
v-model="newName"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full 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="submitAdd"
/>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'add' || !newName.trim()"
@click="submitAdd"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="renamingId"
class="flex flex-col gap-1.5 rounded-xl bg-button-bg p-2"
>
<input
v-model="renameValue"
type="text"
:placeholder="formatMessage(messages.playerNamePlaceholder)"
class="w-full 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="submitRename"
/>
<p v-if="confirmingRename" class="m-0 text-[11px] text-secondary">
{{ formatMessage(messages.renameConfirm) }}
</p>
<div class="flex gap-1.5">
<button
type="button"
class="flex-1 rounded-lg bg-brand px-2 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="busy === 'rename' || !renameValue.trim()"
@click="submitRename"
>
{{ formatMessage(confirmingRename ? messages.confirm : messages.rename) }}
</button>
<button
type="button"
class="rounded-lg bg-bg-raised px-2 py-1 text-xs font-semibold text-secondary"
@click="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
@@ -172,18 +462,17 @@ watch(signedIn, (value) => {
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
{{ formatMessage(messages.noPlayers) }}
</p>
<button
v-for="player in players"
:key="player.uuid"
type="button"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 text-left transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<div
v-for="player in players"
:key="player.uuid"
class="flex items-center gap-2 rounded-xl px-2 py-1.5 transition-colors"
:class="
player.uuid === selectedId
? 'bg-brand-highlight text-contrast'
: 'bg-button-bg hover:brightness-110'
"
@click="selectPlayer(player.uuid)"
>
<Avatar
:src="player.headDataUrl"
:alt="player.name"
@@ -192,10 +481,98 @@ watch(signedIn, (value) => {
class="!rounded-md"
/>
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ player.name }}</span>
<span
v-if="defaultUuid === player.uuid"
class="shrink-0 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
>Mojang</span
>
</button>
<div class="flex shrink-0 items-center gap-1">
<button
v-if="defaultUuid === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-brand"
:disabled="busy !== null"
@click.stop="clearDefaultPlayer"
>
{{ formatMessage(messages.clearDefault) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="setDefaultPlayer(player.uuid)"
>
{{ formatMessage(messages.setDefault) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="startRename(player)"
>
{{ formatMessage(messages.rename) }}
</button>
<button
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestToggleMojang(player.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid"
type="button"
class="rounded bg-red px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="submitDelete(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-red hover:bg-bg"
:disabled="busy !== null"
@click.stop="requestDelete(player.uuid)"
>
{{ formatMessage(messages.delete) }}
</button>
<button
v-if="confirmingDeleteId === player.uuid || confirmingMojangId === player.uuid"
type="button"
class="rounded px-1.5 py-0.5 text-[10px] font-semibold text-secondary hover:bg-bg"
@click.stop="resetDialogs"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
</div>
<div
v-if="confirmingDeleteId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-red"
>
{{ formatMessage(messages.deleteConfirm) }}
</div>
<div
v-if="confirmingMojangId === player.uuid"
class="rounded-lg bg-button-bg px-2 py-1 text-[11px] text-secondary"
>
{{ formatMessage(messages.mojangConfirm) }}
<button
type="button"
class="ml-1 rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
:disabled="busy !== null"
@click.stop="toggleMojang(player.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
</div>
</div>
<!-- Preview + texture actions -->
@@ -207,10 +584,37 @@ watch(signedIn, (value) => {
</p>
<template v-else>
<div class="flex items-center gap-2">
<div class="flex flex-wrap items-center gap-2">
<h3 class="m-0 text-base font-semibold text-contrast">{{ selectedPlayer.name }}</h3>
<span
v-if="defaultUuid === selectedPlayer.uuid"
class="rounded bg-brand px-1.5 py-0.5 text-[10px] font-semibold text-contrast"
>
{{ formatMessage(messages.isDefault) }}
</span>
<span v-if="selectedPlayer.isMojang" class="text-xs text-secondary">Mojang</span>
<button
v-if="confirmingMojangId === selectedPlayer.uuid"
type="button"
class="rounded-full bg-brand px-2.5 py-1 text-xs font-semibold text-contrast"
:disabled="busy !== null"
@click="toggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.confirm) }}
</button>
<button
v-else
type="button"
class="rounded-full bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
:disabled="busy !== null"
@click="requestToggleMojang(selectedPlayer.uuid)"
>
{{ formatMessage(messages.toggleMojang) }}
</button>
</div>
<p v-if="confirmingMojangId === selectedPlayer.uuid" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.mojangConfirm) }}
</p>
<div class="relative h-80 w-full overflow-hidden rounded-xl bg-button-bg">
<div v-if="loadingTexture" class="absolute inset-0 flex items-center justify-center">
@@ -60,7 +60,7 @@ const emailValid = computed(() => /^[^\s@]+@[^\s@]+\.[^\s@]+$/.test(email.value.
const requiredAnswered = computed(() => {
if (!activePaper.value) return false
return activePaper.value.questions.every((q) => {
return (activePaper.value.questions ?? []).every((q) => {
if (q.informationQuestion || q.optional) return true
const value = answers.value[q.id]
if (Array.isArray(value)) return value.length > 0
@@ -81,7 +81,7 @@ function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
function openPaper(paper: SkinSiteReviewPublicPaper) {
activePaper.value = paper
answers.value = {}
for (const q of paper.questions) answers.value[q.id] = defaultAnswer(q)
for (const q of paper.questions ?? []) answers.value[q.id] = defaultAnswer(q)
email.value = ''
inviteCode.value = ''
receipt.value = null
@@ -119,7 +119,7 @@ async function submitApplication() {
submitting.value = true
try {
const payloadAnswers: Record<string, unknown> = {}
for (const q of activePaper.value.questions) {
for (const q of activePaper.value.questions ?? []) {
if (q.informationQuestion) continue
payloadAnswers[q.id] = answers.value[q.id] ?? ''
}
@@ -226,7 +226,7 @@ onMounted(loadCatalog)
</label>
<div
v-for="question in activePaper.questions"
v-for="question in activePaper.questions ?? []"
:key="question.id"
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
>
@@ -6,12 +6,20 @@ import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteWikiPageDetail,
skinSiteWikiPage,
skinSiteWikiDiff,
skinSiteWikiHistory,
skinSiteWikiRevert,
skinSiteWikiRevision,
type SkinSiteWikiDiffLine,
type SkinSiteWikiNode,
type SkinSiteWikiRevisionInfo,
} from '@/helpers/skin-site-api'
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
import WikiBrowser from './wiki/WikiBrowser.vue'
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const { handleError, addNotification } = injectNotificationManager()
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
initialTitle: '',
@@ -40,13 +48,50 @@ const messages = defineMessages({
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' },
history: { id: 'app.skin-site.wiki.history', defaultMessage: 'History' },
read: { id: 'app.skin-site.wiki.read', defaultMessage: 'Read' },
historyEmpty: {
id: 'app.skin-site.wiki.history-empty',
defaultMessage: 'No revisions yet.',
},
noSummary: {
id: 'app.skin-site.wiki.no-summary',
defaultMessage: 'No edit summary',
},
minor: { id: 'app.skin-site.wiki.minor', defaultMessage: 'Minor' },
viewRevision: { id: 'app.skin-site.wiki.view-revision', defaultMessage: 'View' },
revertTo: {
id: 'app.skin-site.wiki.revert-to',
defaultMessage: 'Revert to this',
},
confirmRevert: {
id: 'app.skin-site.wiki.confirm-revert',
defaultMessage: 'Confirm revert',
},
revertDone: {
id: 'app.skin-site.wiki.revert-done',
defaultMessage: 'Page reverted to the selected revision.',
},
revisionPreview: {
id: 'app.skin-site.wiki.revision-preview',
defaultMessage: 'Preview of revision #{id}',
},
closePreview: {
id: 'app.skin-site.wiki.close-preview',
defaultMessage: 'Close preview',
},
diffTitle: {
id: 'app.skin-site.wiki.diff-title',
defaultMessage: 'Line diff against the current revision',
},
cancel: { id: 'app.skin-site.wiki.cancel', defaultMessage: 'Cancel' },
})
const titleInput = ref(props.initialTitle)
const page = ref<SkinSiteWikiPageDetail | null>(null)
const loading = ref(false)
const notFound = ref(false)
const history = ref<string[]>([])
const navStack = ref<string[]>([])
const currentTitle = computed(() => page.value?.title ?? '')
@@ -64,8 +109,8 @@ async function openPage(title: string) {
return
}
page.value = res.payload
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
history.value.push(currentTitle.value)
if (currentTitle.value && navStack.value[navStack.value.length - 1] !== currentTitle.value) {
navStack.value.push(currentTitle.value)
}
} catch (error) {
handleError(error)
@@ -77,7 +122,7 @@ async function openPage(title: string) {
}
function goBack() {
const prev = history.value.pop()
const prev = navStack.value.pop()
if (prev) void openPage(prev)
}
@@ -89,6 +134,77 @@ function formatDate(ts: number): string {
}
}
const mode = ref<'read' | 'history'>('read')
const history = ref<SkinSiteWikiRevisionInfo[]>([])
const historyLoading = ref(false)
const selectedRevisionId = ref<number | null>(null)
const selectedRevisionNodes = ref<SkinSiteWikiNode[] | null>(null)
const diffLines = ref<SkinSiteWikiDiffLine[]>([])
const pendingRevertId = ref<number | null>(null)
const reverting = ref(false)
async function showHistory() {
if (!page.value) return
mode.value = 'history'
historyLoading.value = true
try {
const res = await skinSiteWikiHistory(page.value.title)
if (!res.success) throw new Error(res.errorMessage)
history.value = res.payload.items ?? []
} catch (error) {
handleError(error)
history.value = []
} finally {
historyLoading.value = false
}
}
function backToRead() {
mode.value = 'read'
selectedRevisionNodes.value = null
diffLines.value = []
pendingRevertId.value = null
}
async function viewRevision(revisionId: number) {
if (!page.value) return
try {
const res = await skinSiteWikiRevision(page.value.title, revisionId)
if (!res.success) throw new Error(res.errorMessage)
selectedRevisionId.value = revisionId
selectedRevisionNodes.value = res.payload.nodes ?? []
diffLines.value = []
if (revisionId !== page.value.revisionId) {
const diff = await skinSiteWikiDiff(page.value.title, revisionId, page.value.revisionId)
if (diff.success) diffLines.value = diff.payload ?? []
}
} catch (error) {
handleError(error)
}
}
async function revertToRevision(revisionId: number) {
if (!page.value || reverting.value) return
reverting.value = true
try {
const res = await skinSiteWikiRevert({
title: page.value.title,
revisionId,
baseRevisionId: page.value.revisionId,
})
if (!res.success) throw new Error(res.errorMessage)
page.value = res.payload
pendingRevertId.value = null
backToRead()
addNotification({ type: 'success', title: formatMessage(messages.revertDone) })
} catch (error) {
handleError(error)
} finally {
reverting.value = false
}
}
function onNavigate(title: string) {
if (title) void openPage(title)
}
@@ -102,7 +218,7 @@ onMounted(() => {
<div class="flex w-full flex-col gap-4">
<div class="flex flex-wrap items-center gap-2">
<button
v-if="history.length"
v-if="navStack.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"
@@ -124,8 +240,28 @@ onMounted(() => {
>
{{ formatMessage(messages.open) }}
</button>
<template v-if="page">
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'read' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="backToRead"
>
{{ formatMessage(messages.read) }}
</button>
<button
type="button"
class="rounded-lg px-3 py-1.5 text-xs font-semibold"
:class="mode === 'history' ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="showHistory"
>
{{ formatMessage(messages.history) }}
</button>
</template>
</div>
<WikiBrowser @navigate="onNavigate" />
<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) }}
@@ -142,12 +278,59 @@ onMounted(() => {
>
{{ formatMessage(messages.empty) }}
</p>
<template v-else>
<template v-else-if="mode === 'read'">
<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 class="flex flex-wrap items-center gap-1.5">
<span class="text-[11px] text-secondary">
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
</span>
<button
type="button"
class="rounded-lg px-2.5 py-1 text-xs font-semibold disabled:opacity-50"
:class="watching ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
:disabled="watchBusy"
@click="toggleWatch"
>
{{ formatMessage(watching ? messages.unwatch : messages.watch) }}
</button>
<button
v-if="page.canEdit"
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="openMove"
>
{{ formatMessage(messages.move) }}
</button>
</div>
</div>
<div
v-if="moving"
class="flex flex-wrap items-center gap-2 rounded-lg border border-solid border-surface-5 bg-bg-raised p-2"
>
<input
v-model="moveTarget"
type="text"
:placeholder="formatMessage(messages.moveTarget)"
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="confirmMove"
/>
<button
type="button"
class="rounded-lg bg-brand px-2.5 py-1 text-xs font-semibold text-contrast disabled:opacity-50"
:disabled="moveBusy"
@click="confirmMove"
>
{{ formatMessage(messages.moveConfirm) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-2.5 py-1 text-xs font-semibold text-secondary"
@click="moving = false"
>
{{ formatMessage(messages.cancel) }}
</button>
</div>
<p class="m-0 text-[11px] text-secondary">
@@ -188,5 +371,123 @@ onMounted(() => {
</span>
</div>
</template>
<template v-else>
<div v-if="historyLoading" class="flex min-h-32 items-center justify-center gap-2 text-sm text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
{{ formatMessage(messages.loading) }}
</div>
<template v-else>
<div
v-if="selectedRevisionNodes"
class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-4"
>
<div class="flex flex-wrap items-center justify-between gap-2">
<span class="text-sm font-semibold text-contrast">
{{ formatMessage(messages.revisionPreview, { id: selectedRevisionId }) }}
</span>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="selectedRevisionNodes = null; diffLines = []"
>
{{ formatMessage(messages.closePreview) }}
</button>
</div>
<article class="min-w-0 text-sm leading-7 text-contrast">
<WikiNodeRenderer :nodes="selectedRevisionNodes" @navigate="onNavigate" />
</article>
<details v-if="diffLines.length" class="rounded-lg border border-solid border-surface-5 p-3">
<summary class="cursor-pointer text-xs font-semibold text-contrast">
{{ formatMessage(messages.diffTitle) }}
</summary>
<div class="mt-2 max-h-96 overflow-auto font-mono text-[11px]">
<div
v-for="(line, idx) in diffLines"
:key="idx"
class="px-2 py-0.5 whitespace-pre-wrap"
:class="
line.type === 'ADDED'
? 'bg-green/15 text-green'
: line.type === 'REMOVED'
? 'bg-red/15 text-red'
: 'text-secondary'
"
>
{{ line.type === 'ADDED' ? '+' : line.type === 'REMOVED' ? '-' : ' ' }}{{ line.text }}
</div>
</div>
</details>
</div>
<p
v-if="!history.length"
class="m-0 rounded-lg border border-solid border-surface-5 bg-bg-raised p-4 text-sm text-secondary"
>
{{ formatMessage(messages.historyEmpty) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<div
v-for="revision in history"
:key="revision.id"
class="flex flex-wrap items-start justify-between gap-3 py-3"
>
<div class="min-w-0 flex-1">
<div class="flex flex-wrap items-center gap-2">
<span class="text-sm font-semibold text-contrast">{{ revision.authorName }}</span>
<span
v-if="revision.minor"
class="rounded bg-button-bg px-2 py-0.5 text-[10px] text-secondary"
>
{{ formatMessage(messages.minor) }}
</span>
<span class="text-[10px] text-secondary">#{{ revision.id }}</span>
</div>
<p class="m-0 mt-1 text-xs text-secondary">
{{ revision.summary || formatMessage(messages.noSummary) }}
</p>
<p class="m-0 mt-0.5 text-[10px] text-secondary">{{ formatDate(revision.createdAt) }}</p>
</div>
<div class="flex flex-wrap gap-1.5">
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110"
@click="viewRevision(revision.id)"
>
{{ formatMessage(messages.viewRevision) }}
</button>
<template v-if="page && revision.id !== page.revisionId">
<button
v-if="pendingRevertId !== revision.id"
type="button"
class="rounded-lg bg-yellow/20 px-3 py-1 text-xs font-semibold text-yellow hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="pendingRevertId = revision.id"
>
{{ formatMessage(messages.revertTo) }}
</button>
<template v-else>
<button
type="button"
class="rounded-lg bg-yellow px-3 py-1 text-xs font-semibold text-contrast hover:brightness-110 disabled:opacity-50"
:disabled="reverting"
@click="revertToRevision(revision.id)"
>
{{ formatMessage(messages.confirmRevert) }}
</button>
<button
type="button"
class="rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary"
@click="pendingRevertId = null"
>
{{ formatMessage(messages.cancel) }}
</button>
</template>
</template>
</div>
</div>
</div>
</template>
</template>
</div>
</template>
@@ -4,8 +4,10 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u
import { computed, onMounted, ref } from 'vue'
import {
type SkinSiteAdminUser,
type SkinSiteBroadcast,
type SkinSiteBroadcastInput,
skinSiteAdminUserList,
skinSiteBroadcasts,
skinSiteCreateBroadcast,
skinSiteDeleteBroadcast,
@@ -44,6 +46,7 @@ const messages = defineMessages({
})
const items = ref<SkinSiteBroadcast[]>([])
const uploaderNames = ref<Record<string, string>>({})
const loading = ref(true)
const failed = ref(false)
const saving = ref(false)
@@ -154,11 +157,28 @@ const canSave = computed(
() => form.value.title.trim().length > 0 && form.value.content.length > 0 && !saving.value,
)
function uploaderName(uuid: string): string {
return uploaderNames.value[uuid] ?? uuid
}
async function loadUploaderNames() {
try {
const res = await skinSiteAdminUserList({ limit: 200, offset: 0 })
if (!res.success) return
const users: SkinSiteAdminUser[] = res.payload ?? []
const map: Record<string, string> = {}
for (const u of users) map[u.uuid] = u.username
uploaderNames.value = map
} catch {
// 名称映射失败不影响公告列表展示
}
}
async function load() {
loading.value = true
failed.value = false
try {
const result = await skinSiteBroadcasts()
const [result] = await Promise.all([skinSiteBroadcasts(), loadUploaderNames()])
if (!result.success) throw new Error(result.errorMessage)
items.value = result.payload ?? []
} catch (error) {
@@ -248,7 +268,7 @@ onMounted(() => void load())
</div>
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
<span v-if="item.uploader" class="shrink-0 text-xs text-secondary">
{{ formatMessage(messages.uploader, { name: item.uploader }) }}
{{ formatMessage(messages.uploader, { name: uploaderName(item.uploader) }) }}
</span>
<button
type="button"
@@ -270,9 +270,18 @@ function questionFor(submission: SkinSiteReviewSubmission, questionId: string) {
return submission.paper?.questions?.find((q) => q.id === questionId)
}
function answerText(answer: SkinSiteReviewAnswer): string {
function answerText(
submission: SkinSiteReviewSubmission,
answer: SkinSiteReviewAnswer,
): string {
if (answer.text) return answer.text
if (Array.isArray(answer.values) && answer.values.length) return answer.values.join(', ')
if (Array.isArray(answer.values) && answer.values.length) {
const question = answer.questionId ? questionFor(submission, answer.questionId) : undefined
const options = question?.options ?? []
return answer.values
.map((value) => options.find((o) => o.id === value)?.description ?? value)
.join(', ')
}
return '—'
}
@@ -1046,7 +1055,7 @@ onMounted(() => void load())
<span
class="mt-1 rounded-lg bg-bg px-3 py-2 text-sm break-words whitespace-pre-wrap text-contrast"
>
{{ answerText(answer) }}
{{ answerText(detailSubmission, answer) }}
</span>
<span
v-if="answer.awardedScore !== null && answer.awardedScore !== undefined"
@@ -57,6 +57,10 @@ const messages = defineMessages({
},
banned: { id: 'app.skin-site.admin.users.banned', defaultMessage: 'Banned' },
permissions: { id: 'app.skin-site.admin.users.permissions', defaultMessage: 'Permissions' },
permissionGroups: {
id: 'app.skin-site.admin.users.permission-groups',
defaultMessage: 'Groups',
},
permissionsHint: {
id: 'app.skin-site.admin.users.permissions-hint',
defaultMessage: 'Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN.',
@@ -514,6 +518,21 @@ async function nextPage() {
await load()
}
const PERMISSION_META: Record<string, { label: string; color: string }> = {
GUEST: { label: '游客', color: 'bg-button-bg text-secondary' },
EXTERNAL_MEMBER: { label: 'Mod服玩家', color: 'bg-blue text-contrast' },
INTERNAL_MEMBER: { label: '原版服玩家', color: 'bg-orange text-contrast' },
SENIOR_MEMBER: { label: '老资历', color: 'bg-purple text-contrast' },
SPONSOR: { label: '捐赠者', color: 'bg-yellow text-contrast' },
SERVER_ADMIN: { label: '服务器管理', color: 'bg-green text-contrast' },
WEB_ADMIN: { label: 'Web 管理', color: 'bg-brand text-contrast' },
DEVELOPER: { label: '开发者', color: 'bg-red text-contrast' },
}
function permissionMeta(perm: string) {
return PERMISSION_META[perm] ?? { label: perm, color: 'bg-button-bg text-secondary' }
}
function startEdit(user: SkinSiteAdminUser) {
editingUuid.value = user.uuid
editForm.value = {
@@ -676,6 +695,9 @@ onMounted(() => void load())
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.email) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.qq) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.status) }}</th>
<th class="px-3 py-2 font-semibold">
{{ formatMessage(messages.permissionGroups) }}
</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.registeredAt) }}</th>
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.actions) }}</th>
</tr>
@@ -704,6 +726,24 @@ onMounted(() => void load())
}}
</span>
</td>
<td class="px-3 py-2">
<div class="flex flex-wrap gap-1">
<span
v-for="perm in user.permissions ?? []"
:key="perm"
class="rounded-full px-2 py-0.5 text-[10px] font-semibold"
:class="permissionMeta(perm).color"
>
{{ permissionMeta(perm).label }}
</span>
<span
v-if="!user.permissions?.length"
class="text-[11px] text-secondary"
>
—
</span>
</div>
</td>
<td class="px-3 py-2 text-xs text-secondary">
{{ formatDateTime(user.registerTime) }}
</td>
@@ -0,0 +1,238 @@
<script setup lang="ts">
import { SpinnerIcon } from '@modrinth/assets'
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
import { computed, onMounted, ref, watch } from 'vue'
import {
type SkinSiteWikiPageSummary,
type SkinSiteWikiSearchPage,
skinSiteWikiCategory,
skinSiteWikiRecent,
skinSiteWikiSearch,
} from '@/helpers/skin-site-api'
const emit = defineEmits<{ navigate: [title: string] }>()
const { formatMessage } = useVIntl()
const { handleError } = injectNotificationManager()
const messages = defineMessages({
searchPlaceholder: {
id: 'app.skin-site.wiki.browser.search-placeholder',
defaultMessage: 'Search wiki pages…',
},
tabSearch: { id: 'app.skin-site.wiki.browser.tab-search', defaultMessage: 'Search' },
tabRecent: { id: 'app.skin-site.wiki.browser.tab-recent', defaultMessage: 'Recent' },
tabCategory: { id: 'app.skin-site.wiki.browser.tab-category', defaultMessage: 'Category' },
categoryPlaceholder: {
id: 'app.skin-site.wiki.browser.category-placeholder',
defaultMessage: 'Category name',
},
loadMore: { id: 'app.skin-site.wiki.browser.load-more', defaultMessage: 'Load more' },
emptySearch: {
id: 'app.skin-site.wiki.browser.empty-search',
defaultMessage: 'No pages match your search.',
},
emptyList: {
id: 'app.skin-site.wiki.browser.empty-list',
defaultMessage: 'Nothing here yet.',
},
hintSearch: {
id: 'app.skin-site.wiki.browser.hint-search',
defaultMessage: 'Type a keyword to search.',
},
hintCategory: {
id: 'app.skin-site.wiki.browser.hint-category',
defaultMessage: 'Enter a category name to browse.',
},
byAuthor: {
id: 'app.skin-site.wiki.browser.by-author',
defaultMessage: 'by {author}',
},
})
type Tab = 'search' | 'recent' | 'category'
const tab = ref<Tab>('recent')
const keyword = ref('')
const categoryName = ref('')
const items = ref<SkinSiteWikiPageSummary[]>([])
const nextCursor = ref<number | null>(null)
const loading = ref(false)
const loaded = ref(false)
const canLoadMore = computed(() => nextCursor.value != null)
function formatDate(ts: number): string {
try {
return new Date(ts).toLocaleDateString()
} catch {
return '—'
}
}
function resetState() {
items.value = []
nextCursor.value = null
loaded.value = false
}
async function runSearch() {
const q = keyword.value.trim()
if (!q) {
resetState()
return
}
loading.value = true
try {
const res = await skinSiteWikiSearch(q)
if (!res.success) throw new Error(res.errorMessage)
items.value = res.payload ?? []
nextCursor.value = null
loaded.value = true
} catch (error) {
handleError(error)
resetState()
} finally {
loading.value = false
}
}
async function fetchPage(mode: 'recent' | 'category', append: boolean) {
if (mode === 'category' && !categoryName.value.trim()) {
resetState()
return
}
loading.value = true
try {
const before = append ? (nextCursor.value ?? undefined) : undefined
const res: { success: boolean; payload?: SkinSiteWikiSearchPage; errorMessage?: string } =
mode === 'recent'
? await skinSiteWikiRecent(before)
: await skinSiteWikiCategory(categoryName.value.trim(), before)
if (!res.success || !res.payload) throw new Error(res.errorMessage ?? 'request failed')
const page = res.payload
items.value = append ? [...items.value, ...page.items] : page.items
nextCursor.value = page.nextCursor ?? null
loaded.value = true
} catch (error) {
handleError(error)
if (!append) resetState()
} finally {
loading.value = false
}
}
function switchTab(next: Tab) {
if (tab.value === next) return
tab.value = next
resetState()
if (next === 'recent') void fetchPage('recent', false)
}
function loadMore() {
if (tab.value === 'recent') void fetchPage('recent', true)
else if (tab.value === 'category') void fetchPage('category', true)
}
watch(categoryName, () => {
if (tab.value === 'category') {
resetState()
}
})
let searchTimer: ReturnType<typeof setTimeout> | null = null
watch(keyword, () => {
if (tab.value !== 'search') return
if (searchTimer) clearTimeout(searchTimer)
searchTimer = setTimeout(() => {
void runSearch()
}, 300)
})
onMounted(() => {
void fetchPage('recent', false)
})
</script>
<template>
<div class="flex flex-col gap-3 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3">
<div class="flex flex-wrap items-center gap-2">
<button
v-for="t in (['search', 'recent', 'category'] as Tab[])"
:key="t"
type="button"
class="rounded-lg px-3 py-1 text-xs font-semibold"
:class="tab === t ? 'bg-brand text-contrast' : 'bg-button-bg text-secondary'"
@click="switchTab(t)"
>
{{
formatMessage(
t === 'search'
? messages.tabSearch
: t === 'recent'
? messages.tabRecent
: messages.tabCategory,
)
}}
</button>
</div>
<input
v-if="tab === 'search'"
v-model="keyword"
type="text"
:placeholder="formatMessage(messages.searchPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="runSearch"
/>
<input
v-else-if="tab === 'category'"
v-model="categoryName"
type="text"
:placeholder="formatMessage(messages.categoryPlaceholder)"
class="w-full rounded-lg border border-solid border-surface-5 bg-bg px-3 py-1.5 text-xs text-contrast outline-none focus:border-brand"
@keyup.enter="fetchPage('category', false)"
/>
<div v-if="loading && !items.length" class="flex min-h-16 items-center justify-center gap-2 text-xs text-secondary">
<SpinnerIcon class="size-4 animate-spin" />
</div>
<p v-else-if="tab === 'search' && !keyword.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintSearch) }}
</p>
<p v-else-if="tab === 'category' && !categoryName.trim()" class="m-0 text-xs text-secondary">
{{ formatMessage(messages.hintCategory) }}
</p>
<p v-else-if="loaded && !items.length" class="m-0 text-xs text-secondary">
{{ formatMessage(tab === 'search' ? messages.emptySearch : messages.emptyList) }}
</p>
<div v-else class="flex flex-col divide-y divide-surface-5">
<button
v-for="item in items"
:key="item.title"
type="button"
class="flex flex-col items-start gap-0.5 py-2 text-left hover:brightness-110"
@click="emit('navigate', item.title)"
>
<span class="text-sm font-semibold text-contrast">{{ item.title }}</span>
<span class="text-[10px] text-secondary">
{{ formatMessage(messages.byAuthor, { author: item.authorName }) }} ·
{{ formatDate(item.updatedAt) }}
</span>
</button>
</div>
<button
v-if="canLoadMore && !loading"
type="button"
class="self-start rounded-lg bg-button-bg px-3 py-1 text-xs font-semibold text-secondary hover:brightness-110"
@click="loadMore"
>
{{ formatMessage(messages.loadMore) }}
</button>
</div>
</template>
@@ -13,7 +13,7 @@ function headingTag(level?: number | null): string {
}
function navigate(title?: string | null) {
emit('navigate', (title ?? '').replaceAll('_', ' '))
emit('navigate', title ?? '')
}
</script>
@@ -34,6 +34,7 @@ import {
useVIntl,
} from '@modrinth/ui'
import { getPingLevel } from '@modrinth/utils'
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
import dayjs from 'dayjs'
import { Tooltip } from 'floating-vue'
import type { Component } from 'vue'
@@ -360,10 +361,12 @@ const messages = defineMessages({
}}
</span>
<template #popper>
<div class="flex flex-col gap-1">
<span v-for="player in serverStatus.players?.sample" :key="player.name">
{{ player.name }}
</span>
<div class="flex flex-col gap-1 font-minecraft">
<span
v-for="player in serverStatus.players?.sample"
:key="player.name"
v-html="autoToHTML(player.name)"
/>
</div>
</template>
</Tooltip>
@@ -292,6 +292,9 @@ async function buildPlayer(entry: {
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
if (playersRequest) return playersRequest
if (status.value !== 'signed-in' || !user.value) {
// Keep the status meaningful so the UI does not spin forever: an
// unauthenticated request means "not loaded yet", not "loading".
playersStatus.value = 'idle'
return Promise.reject(new Error('请先登录 StarLight 皮肤站。'))
}
@@ -317,6 +320,16 @@ export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
return playersRequest
}
/**
* Drop any in-flight or cached player request and fetch again. Used by views
* that need to recover after a failed load (e.g. signing in from the skin
* page while the first attempt raced the session check).
*/
export function reloadSkinSitePlayers(): Promise<SkinSitePlayer[]> {
playersRequest = null
return requestSkinSitePlayers()
}
export function selectSkinSitePlayer(playerId: string | null) {
if (playerId === null) {
selectedPlayerId.value = null
@@ -11,6 +11,7 @@ export type RemoteAnnouncement = {
published_at: string
action_url: string | null
action_label: string | null
image_name: string | null
}
export function safeAnnouncementUrl(value: unknown): string | null {
@@ -62,6 +63,10 @@ export function parseAnnouncements(value: unknown): RemoteAnnouncement[] | null
ends_at: item.ends_at ?? null,
action_label: item.action_label ?? null,
action_url: item.action_url ?? null,
image_name:
typeof item.image_name === 'string' && item.image_name.trim()
? item.image_name
: null,
} as RemoteAnnouncement)
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
@@ -143,6 +148,10 @@ export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[
published_at: published,
action_url: null,
action_label: null,
image_name:
typeof item.imageName === 'string' && item.imageName.trim()
? item.imageName
: null,
})
}
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
+239 -26
View File
@@ -336,6 +336,40 @@ export function skinSiteRenamePlayer(
{ method: 'POST', type: 'skin' },
)
}
/** Mark a player as the account default (used by launcher login). */
export function skinSiteSetDefaultPlayer(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/default/set?uuid=${encodeURIComponent(uuid)}`, {
method: 'POST',
type: 'skin',
})
}
/** Clear the account default player. */
export function skinSiteClearDefaultPlayer(): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>('player/default', { method: 'DELETE', type: 'skin' })
}
/** Fetch the account default player, if one is set. */
export function skinSiteDefaultPlayer(): Promise<SkinSiteResponse<SkinSitePlayerInfo | null>> {
return skinSiteRequest<SkinSitePlayerInfo | null>('player/default', { type: 'skin' })
}
/** Toggle the Mojang (official) status of a player and resync its profile. */
export function skinSiteSwitchMojang(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}/switchMojang`, {
method: 'GET',
type: 'skin',
})
}
/** Convert a legacy internal player so it can log in through the modern flow. */
export function skinSiteAdaptLegacyInternal(uuid: string): Promise<SkinSiteResponse<string>> {
return skinSiteRequest<string>(`player/${encodeURIComponent(uuid)}/adaptLegacyInternal`, {
method: 'POST',
type: 'skin',
})
}
// ---------------------------------------------------------------------------
// Registration & password reset
// ---------------------------------------------------------------------------
@@ -414,8 +448,8 @@ export function skinSitePublicDonationSummary(): Promise<
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
}
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSitePublicDonationPage>> {
return skinSiteRequest<SkinSitePublicDonationPage>(`donation/list?limit=20&offset=${offset}`, {
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> {
return skinSiteRequest<SkinSiteDonationPage>(`donation/list?limit=20&offset=${offset}`, {
auth: false,
})
}
@@ -522,22 +556,18 @@ export function skinSitePublicDownloadList(options?: {
offset: String(options?.offset ?? 0),
})
if (options?.category) query.set('category', options.category)
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`, {
auth: false,
})
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`)
}
export function skinSitePublicDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
return skinSiteRequest<number>(`download/count${suffix}`, { auth: false })
return skinSiteRequest<number>(`download/count${suffix}`)
}
export function skinSitePublicDownloadCategories(): Promise<
SkinSiteResponse<SkinSitePublicDownloadCategory[]>
> {
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list', {
auth: false,
})
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list')
}
// ---------------------------------------------------------------------------
// Maps
@@ -662,6 +692,24 @@ export interface SkinSiteBugImage {
[key: string]: unknown
}
/** Download a bug attachment image. The endpoint requires auth, so the image
* must be fetched as a blob and rendered through an object URL. */
export async function skinSiteBugImageFetch(hash: string): Promise<Blob | null> {
const url = `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/bug/images/${encodeURIComponent(hash)}`
const headers: Record<string, string> = { Accept: 'image/*' }
const token = getSkinSiteToken()
if (token) headers['Authorization'] = `Bearer ${token}`
try {
const response = await tauriFetch(url, { method: 'GET', headers })
if (!response.ok) return null
const type = response.headers.get('content-type') ?? ''
if (type.includes('application/json') || type.includes('text/')) return null
return await response.blob()
} catch {
return null
}
}
export interface SkinSiteBugIssue {
id: number
type: SkinSiteBugIssueType
@@ -1217,7 +1265,6 @@ export function skinSiteActivateLauncherRelease(
export function skinSiteDeleteLauncherRelease(id: number): Promise<SkinSiteResponse<boolean>> {
return skinSiteRequest<boolean>('launcher/admin/' + id, { method: 'DELETE' })
}
export interface SkinSiteLauncherUploadStatus {
id: string
chunkSize: number
@@ -1865,7 +1912,6 @@ export function skinSiteWikiUpdateSettings(
})
}
export interface SkinSiteReviewPaperSummary {
id: string
enabled: boolean
@@ -1908,9 +1954,7 @@ export interface SkinSiteReviewConfigData {
prerequisitePermissions: string[]
}
export function skinSiteReviewPaperList(): Promise<
SkinSiteResponse<SkinSiteReviewPaperSummary[]>
> {
export function skinSiteReviewPaperList(): Promise<SkinSiteResponse<SkinSiteReviewPaperSummary[]>> {
return skinSiteRequest<SkinSiteReviewPaperSummary[]>('review/admin/papers')
}
@@ -1991,9 +2035,7 @@ export interface SkinSiteInviteCode {
code: string
}
export function skinSiteAdminBotInfo(
userUuid: string,
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
export function skinSiteAdminBotInfo(userUuid: string): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
return skinSiteRequest<SkinSiteBotInfo>('admin/users/' + encodeURIComponent(userUuid) + '/bot')
}
@@ -2047,7 +2089,9 @@ export function skinSiteAdminUserAudit(params?: {
return skinSiteRequest<SkinSiteUserAdminAuditPage>('admin/users/audit' + suffix)
}
export function skinSiteAdminInviteUser(email: string): Promise<SkinSiteResponse<SkinSiteInviteCode>> {
export function skinSiteAdminInviteUser(
email: string,
): Promise<SkinSiteResponse<SkinSiteInviteCode>> {
return skinSiteRequest<SkinSiteInviteCode>('admin/inviteCode', {
method: 'POST',
body: { email },
@@ -2381,10 +2425,10 @@ export function skinSiteChatVoiceConfig(): Promise<
export function skinSiteChatVoiceConfigUpdate(
data: SkinSiteChatVoiceGlobalConfig,
): Promise<SkinSiteResponse<SkinSiteChatVoiceGlobalConfigUpdateResult>> {
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>(
'chat/voice/admin/config',
{ method: 'PUT', body: data },
)
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>('chat/voice/admin/config', {
method: 'PUT',
body: data,
})
}
export function skinSiteChatVoiceLogs(
@@ -2503,7 +2547,11 @@ export function skinSiteWikiMigrationCandidates(
export function skinSiteWikiMigrationDecide(
candidateId: number,
data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean },
data: {
decision: SkinSiteWikiMigrationDecision
targetTitle?: string | null
overwrite?: boolean
},
): Promise<SkinSiteResponse<void>> {
return skinSiteRequest<void>('wiki/admin/migration/candidates/' + candidateId, {
method: 'POST',
@@ -2520,9 +2568,7 @@ export function skinSiteWikiMigrationPublish(
)
}
export function skinSiteWikiMigrationRollback(
jobId: number,
): Promise<SkinSiteResponse<void>> {
export function skinSiteWikiMigrationRollback(jobId: number): Promise<SkinSiteResponse<void>> {
return skinSiteRequest<void>('wiki/admin/migration/' + jobId + '/rollback', {
method: 'POST',
})
@@ -2543,3 +2589,170 @@ export function skinSiteWikiAttachment(
export function skinSiteWikiAttachmentUrl(id: number): string {
return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
}
export interface SkinSiteWikiRevisionInfo {
id: number
authorName: string
summary: string
minor: boolean
createdAt: number
sourceWiki?: string | null
sourceRevisionId?: number | null
}
export interface SkinSiteWikiRevisionPage {
items: SkinSiteWikiRevisionInfo[]
nextCursor?: number | null
}
export interface SkinSiteWikiRevisionDetail {
revision: SkinSiteWikiRevisionInfo
source: string
nodes: SkinSiteWikiNode[]
}
export interface SkinSiteWikiDiffLine {
type: 'UNCHANGED' | 'REMOVED' | 'ADDED'
oldLine?: number | null
newLine?: number | null
text: string
}
export function skinSiteWikiHistory(
title: string,
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiRevisionPage>> {
const query = new URLSearchParams({ title })
if (before) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiRevisionPage>('wiki/history?' + query.toString())
}
export function skinSiteWikiRevision(
title: string,
revisionId: number,
): Promise<SkinSiteResponse<SkinSiteWikiRevisionDetail>> {
return skinSiteRequest<SkinSiteWikiRevisionDetail>(
'wiki/revision/' + revisionId + '?title=' + encodeURIComponent(title),
)
}
export function skinSiteWikiDiff(
title: string,
older: number,
newer: number,
): Promise<SkinSiteResponse<SkinSiteWikiDiffLine[]>> {
const query = new URLSearchParams({ title, older: String(older), newer: String(newer) })
return skinSiteRequest<SkinSiteWikiDiffLine[]>('wiki/diff?' + query.toString())
}
export function skinSiteWikiRevert(data: {
title: string
revisionId: number
baseRevisionId: number
}): Promise<SkinSiteResponse<SkinSiteWikiPageDetail>> {
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/revert', {
method: 'POST',
body: data,
})
}
// ─── Wiki: 搜索 / 列表 / 最近 / 分类 ───────────────────────────────────────
export interface SkinSiteWikiPageSummary {
title: string
updatedAt: number
authorName: string
readPolicy: SkinSiteWikiReadPolicy
editPolicy: SkinSiteWikiEditPolicy
redirectTarget?: string | null
}
export interface SkinSiteWikiPageListItem {
id: number
title: string
namespace: string
revisionId: number
updatedAt: number
authorName: string
readPolicy: SkinSiteWikiReadPolicy
editPolicy: SkinSiteWikiEditPolicy
redirectTarget?: string | null
}
export interface SkinSiteWikiPageList {
items: SkinSiteWikiPageListItem[]
nextCursor?: string | null
}
export interface SkinSiteWikiSearchPage {
items: SkinSiteWikiPageSummary[]
nextCursor?: number | null
}
export function skinSiteWikiSearch(
query: string,
): Promise<SkinSiteResponse<SkinSiteWikiPageSummary[]>> {
const q = query.trim()
if (!q) {
return Promise.resolve({
success: true,
payload: [],
timestamp: Date.now(),
} as SkinSiteResponse<SkinSiteWikiPageSummary[]>)
}
return skinSiteRequest<SkinSiteWikiPageSummary[]>('wiki/search?q=' + encodeURIComponent(q))
}
export function skinSiteWikiPages(
params: {
prefix?: string
namespace?: string
limit?: number
cursor?: string
} = {},
): Promise<SkinSiteResponse<SkinSiteWikiPageList>> {
const query = new URLSearchParams()
if (params.prefix) query.set('prefix', params.prefix)
if (params.namespace != null) query.set('namespace', params.namespace)
if (params.limit != null) query.set('limit', String(params.limit))
if (params.cursor) query.set('cursor', params.cursor)
return skinSiteRequest<SkinSiteWikiPageList>('wiki/pages?' + query.toString())
}
export function skinSiteWikiRecent(
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
const query = new URLSearchParams()
if (before != null) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/recent?' + query.toString())
}
export function skinSiteWikiCategory(
name: string,
before?: number,
): Promise<SkinSiteResponse<SkinSiteWikiSearchPage>> {
const query = new URLSearchParams({ name })
if (before != null) query.set('before', String(before))
return skinSiteRequest<SkinSiteWikiSearchPage>('wiki/category?' + query.toString())
}
// ─── Wiki: 移动 / 关注 / 设首页 ─────────────────────────────────────────
export function skinSiteWikiMove(data: {
title: string
targetTitle: string
leaveRedirect?: boolean
}): Promise<SkinSiteResponse<SkinSiteWikiPageDetail>> {
return skinSiteRequest<SkinSiteWikiPageDetail>('wiki/move', {
method: 'POST',
body: data,
})
}
export function skinSiteWikiWatch(
title: string,
watched: boolean,
): Promise<SkinSiteResponse<boolean>> {
return skinSiteRequest<boolean>('wiki/watch', {
method: 'POST',
body: { title, watched },
})
}
@@ -26,6 +26,7 @@
},
"app.skin-site.downloads.title": { "message": "Downloads" },
"app.skin-site.downloads.uploader": { "message": "by {name}" },
"app.skin-site.downloads.signed-out": { "message": "Sign in to browse downloads." },
"app.skin-site.home.announcements.empty": {
"message": "No announcements right now."
},
@@ -65,24 +66,42 @@
"app.skin-site.map.open": { "message": "Open in browser" },
"app.skin-site.map.server": { "message": "Server" },
"app.skin-site.map.title": { "message": "Server map" },
"app.skin-site.profile-view.add-player": { "message": "New player" },
"app.skin-site.profile-view.added": { "message": "Player added." },
"app.skin-site.profile-view.back": { "message": "Back to home" },
"app.skin-site.profile-view.cancel": { "message": "Cancel" },
"app.skin-site.profile-view.cape": { "message": "Cape" },
"app.skin-site.profile-view.clear-default": { "message": "Clear default" },
"app.skin-site.profile-view.confirm": { "message": "Confirm" },
"app.skin-site.profile-view.default-set": { "message": "Default player updated." },
"app.skin-site.profile-view.delete": { "message": "Delete" },
"app.skin-site.profile-view.delete-confirm": { "message": "Delete this player? This cannot be undone." },
"app.skin-site.profile-view.deleted": { "message": "Player deleted." },
"app.skin-site.profile-view.elytra": { "message": "Elytra" },
"app.skin-site.profile-view.is-default": { "message": "Default" },
"app.skin-site.profile-view.loading": { "message": "Loading players…" },
"app.skin-site.profile-view.mojang-confirm": { "message": "Toggle Mojang status for this player?" },
"app.skin-site.profile-view.mojang-toggled": { "message": "Mojang status updated." },
"app.skin-site.profile-view.no-players": { "message": "No players yet." },
"app.skin-site.profile-view.player-name-placeholder": { "message": "Player name" },
"app.skin-site.profile-view.readonly": {
"message": "Mojang accounts are managed by Mojang; textures cannot be edited here."
},
"app.skin-site.profile-view.remove": { "message": "Remove" },
"app.skin-site.profile-view.removed": { "message": "Texture removed." },
"app.skin-site.profile-view.rename": { "message": "Rename" },
"app.skin-site.profile-view.rename-confirm": { "message": "Rename this player?" },
"app.skin-site.profile-view.renamed": { "message": "Player renamed." },
"app.skin-site.profile-view.select-hint": {
"message": "Select a player to preview and edit its textures."
},
"app.skin-site.profile-view.set-default": { "message": "Set as default" },
"app.skin-site.profile-view.signed-out": {
"message": "Sign in to manage your players."
},
"app.skin-site.profile-view.skin": { "message": "Skin" },
"app.skin-site.profile-view.title": { "message": "Your players" },
"app.skin-site.profile-view.toggle-mojang": { "message": "Toggle Mojang" },
"app.skin-site.profile-view.upload": { "message": "Upload" },
"app.skin-site.profile-view.uploaded": { "message": "Texture updated." },
"app.skin-site.profile.avatar": { "message": "Avatar" },
@@ -12705,6 +12724,7 @@
"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.users.permission-groups": { "message": "Groups" },
"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" },
@@ -13076,6 +13096,30 @@
"app.skin-site.wiki.revision": { "message": "Revision #{id}" },
"app.skin-site.wiki.categories": { "message": "Categories" },
"app.skin-site.wiki.back": { "message": "Back" },
"app.skin-site.wiki.history": { "message": "History" },
"app.skin-site.wiki.read": { "message": "Read" },
"app.skin-site.wiki.history-empty": { "message": "No revisions yet." },
"app.skin-site.wiki.no-summary": { "message": "No edit summary" },
"app.skin-site.wiki.minor": { "message": "Minor" },
"app.skin-site.wiki.view-revision": { "message": "View" },
"app.skin-site.wiki.revert-to": { "message": "Revert to this" },
"app.skin-site.wiki.confirm-revert": { "message": "Confirm revert" },
"app.skin-site.wiki.revert-done": { "message": "Page reverted to the selected revision." },
"app.skin-site.wiki.revision-preview": { "message": "Preview of revision #{id}" },
"app.skin-site.wiki.close-preview": { "message": "Close preview" },
"app.skin-site.wiki.diff-title": { "message": "Line diff against the current revision" },
"app.skin-site.wiki.cancel": { "message": "Cancel" },
"app.skin-site.wiki.browser.search-placeholder": { "message": "Search wiki pages…" },
"app.skin-site.wiki.browser.tab-search": { "message": "Search" },
"app.skin-site.wiki.browser.tab-recent": { "message": "Recent" },
"app.skin-site.wiki.browser.tab-category": { "message": "Category" },
"app.skin-site.wiki.browser.category-placeholder": { "message": "Category name" },
"app.skin-site.wiki.browser.load-more": { "message": "Load more" },
"app.skin-site.wiki.browser.empty-search": { "message": "No pages match your search." },
"app.skin-site.wiki.browser.empty-list": { "message": "Nothing here yet." },
"app.skin-site.wiki.browser.hint-search": { "message": "Type a keyword to search." },
"app.skin-site.wiki.browser.hint-category": { "message": "Enter a category name to browse." },
"app.skin-site.wiki.browser.by-author": { "message": "by {author}" },
"app.skin-site.home.donation.thanks": {
"message": "Thank you to everyone who helps keep StarLight running."
},
@@ -26,6 +26,7 @@
},
"app.skin-site.downloads.title": { "message": "资源下载" },
"app.skin-site.downloads.uploader": { "message": "上传者:{name}" },
"app.skin-site.downloads.signed-out": { "message": "登录后浏览资源下载。" },
"app.skin-site.home.announcements.empty": { "message": "暂无公告。" },
"app.skin-site.home.announcements.failed": { "message": "加载公告失败。" },
"app.skin-site.home.announcements.loading": { "message": "正在加载…" },
@@ -61,24 +62,42 @@
"app.skin-site.map.open": { "message": "在浏览器中打开" },
"app.skin-site.map.server": { "message": "服务器" },
"app.skin-site.map.title": { "message": "服务器地图" },
"app.skin-site.profile-view.add-player": { "message": "新建角色" },
"app.skin-site.profile-view.added": { "message": "角色已添加。" },
"app.skin-site.profile-view.back": { "message": "返回主页" },
"app.skin-site.profile-view.cancel": { "message": "取消" },
"app.skin-site.profile-view.cape": { "message": "披风" },
"app.skin-site.profile-view.clear-default": { "message": "取消默认" },
"app.skin-site.profile-view.confirm": { "message": "确认" },
"app.skin-site.profile-view.default-set": { "message": "默认角色已更新。" },
"app.skin-site.profile-view.delete": { "message": "删除" },
"app.skin-site.profile-view.delete-confirm": { "message": "删除此角色?此操作不可撤销。" },
"app.skin-site.profile-view.deleted": { "message": "角色已删除。" },
"app.skin-site.profile-view.elytra": { "message": "鞘翅" },
"app.skin-site.profile-view.is-default": { "message": "默认" },
"app.skin-site.profile-view.loading": { "message": "正在加载角色…" },
"app.skin-site.profile-view.mojang-confirm": { "message": "切换此角色的正版状态?" },
"app.skin-site.profile-view.mojang-toggled": { "message": "正版状态已更新。" },
"app.skin-site.profile-view.no-players": { "message": "暂无角色。" },
"app.skin-site.profile-view.player-name-placeholder": { "message": "角色名" },
"app.skin-site.profile-view.readonly": {
"message": "Mojang 账号由 Mojang 管理,无法在此修改材质。"
},
"app.skin-site.profile-view.remove": { "message": "移除" },
"app.skin-site.profile-view.removed": { "message": "材质已移除。" },
"app.skin-site.profile-view.rename": { "message": "重命名" },
"app.skin-site.profile-view.rename-confirm": { "message": "重命名此角色?" },
"app.skin-site.profile-view.renamed": { "message": "角色已重命名。" },
"app.skin-site.profile-view.select-hint": {
"message": "选择角色以预览和修改其材质。"
},
"app.skin-site.profile-view.set-default": { "message": "设为默认" },
"app.skin-site.profile-view.signed-out": {
"message": "登录后管理你的角色。"
},
"app.skin-site.profile-view.skin": { "message": "皮肤" },
"app.skin-site.profile-view.title": { "message": "我的角色" },
"app.skin-site.profile-view.toggle-mojang": { "message": "切换正版" },
"app.skin-site.profile-view.upload": { "message": "上传" },
"app.skin-site.profile-view.uploaded": { "message": "材质已更新。" },
"app.skin-site.profile.avatar": { "message": "头像" },
@@ -13026,6 +13045,7 @@
"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.users.permission-groups": { "message": "权限组" },
"app.skin-site.admin.mods.upload": { "message": "上传" },
"app.skin-site.admin.mods.select-mode": { "message": "选择多个" },
"app.skin-site.admin.mods.exit-select": { "message": "完成选择" },
@@ -13337,6 +13357,30 @@
"app.skin-site.wiki.revision": { "message": "修订 #{id}" },
"app.skin-site.wiki.categories": { "message": "分类" },
"app.skin-site.wiki.back": { "message": "返回" },
"app.skin-site.wiki.history": { "message": "历史" },
"app.skin-site.wiki.read": { "message": "阅读" },
"app.skin-site.wiki.history-empty": { "message": "暂无修订。" },
"app.skin-site.wiki.no-summary": { "message": "无编辑摘要" },
"app.skin-site.wiki.minor": { "message": "小修改" },
"app.skin-site.wiki.view-revision": { "message": "查看" },
"app.skin-site.wiki.revert-to": { "message": "撤销到此版本" },
"app.skin-site.wiki.confirm-revert": { "message": "确认撤销" },
"app.skin-site.wiki.revert-done": { "message": "页面已撤销到所选修订。" },
"app.skin-site.wiki.revision-preview": { "message": "修订 #{id} 预览" },
"app.skin-site.wiki.close-preview": { "message": "关闭预览" },
"app.skin-site.wiki.diff-title": { "message": "与当前版本的逐行差异" },
"app.skin-site.wiki.cancel": { "message": "取消" },
"app.skin-site.wiki.browser.search-placeholder": { "message": "搜索维基页面…" },
"app.skin-site.wiki.browser.tab-search": { "message": "搜索" },
"app.skin-site.wiki.browser.tab-recent": { "message": "最近" },
"app.skin-site.wiki.browser.tab-category": { "message": "分类" },
"app.skin-site.wiki.browser.category-placeholder": { "message": "分类名" },
"app.skin-site.wiki.browser.load-more": { "message": "加载更多" },
"app.skin-site.wiki.browser.empty-search": { "message": "没有匹配的页面。" },
"app.skin-site.wiki.browser.empty-list": { "message": "这里还没有内容。" },
"app.skin-site.wiki.browser.hint-search": { "message": "输入关键词开始搜索。" },
"app.skin-site.wiki.browser.hint-category": { "message": "输入分类名开始浏览。" },
"app.skin-site.wiki.browser.by-author": { "message": "作者 {author}" },
"app.skin-site.home.donation.thanks": {
"message": "感谢每一位支持 StarLight 运行的朋友。"
},
+63 -10
View File
@@ -39,6 +39,7 @@ import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
import {
openSkinSiteLogin,
reloadSkinSitePlayers,
requestSkinSitePlayers,
requestSkinSiteSkinUpdate,
selectedSkinSitePlayerId,
@@ -214,6 +215,18 @@ const messages = defineMessages({
id: 'app.skins.skin-site-account.empty-description',
defaultMessage: 'This skin site account does not have an available player profile yet.',
},
errorSkinSitePlayersTitle: {
id: 'app.skins.skin-site-account.error-title',
defaultMessage: 'Could not load your players',
},
errorSkinSitePlayersDescription: {
id: 'app.skins.skin-site-account.error-description',
defaultMessage: 'Check your connection and try again.',
},
retryButton: {
id: 'app.skins.skin-site-account.retry',
defaultMessage: 'Retry',
},
offlineCompatibility: {
id: 'app.skins.offline-account.compatibility',
defaultMessage:
@@ -982,6 +995,37 @@ watch(
},
)
// Load the player list whenever the session becomes available or the view is
// opened while the list never started loading. Without this the skin page can
// sit on "loading" forever when the first request raced the session check.
function ensureSkinSitePlayersLoaded() {
if (!skinSiteUser.value) return
if (skinSitePlayersStatus.value === 'checking') return
if (skinSitePlayersStatus.value === 'ready' && skinSitePlayers.value.length) return
if (skinSitePlayersStatus.value === 'error') return
void reloadSkinSitePlayers().catch((error) => {
console.warn('Failed to load skin site players:', error)
})
}
watch(
() => [skinSiteUser.value?.uuid, skinSiteStatus.value] as const,
([uuid, sessionStatus]) => {
if (uuid && sessionStatus === 'signed-in') ensureSkinSitePlayersLoaded()
},
{ immediate: true },
)
watch(skinSitePlayersStatus, (value) => {
if (value === 'idle' && skinSiteUser.value) ensureSkinSitePlayersLoaded()
})
function retrySkinSitePlayers() {
void reloadSkinSitePlayers().catch((error) => {
console.warn('Failed to reload skin site players:', error)
})
}
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
return skinBlobUrlMap.get(key)
@@ -1436,22 +1480,26 @@ await loadSkins()
<h1 class="text-3xl font-extrabold m-0">
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle
: messages.signInTitle,
!skinSiteUser
? messages.signInTitle
: skinSitePlayersStatus === 'error'
? messages.errorSkinSitePlayersTitle
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersTitle
: messages.noSkinSitePlayersTitle,
)
}}
</h1>
<p class="text-lg m-0">
{{
formatMessage(
skinSiteUser
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription
: messages.signInDescription,
!skinSiteUser
? messages.signInDescription
: skinSitePlayersStatus === 'error'
? messages.errorSkinSitePlayersDescription
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
? messages.loadingSkinSitePlayersDescription
: messages.noSkinSitePlayersDescription,
)
}}
</p>
@@ -1462,6 +1510,11 @@ await loadSkins()
"
class="h-8 w-8 animate-spin text-brand"
/>
<ButtonStyled v-if="skinSiteUser && skinSitePlayersStatus === 'error'" color="brand">
<button @click="retrySkinSitePlayers">
{{ formatMessage(messages.retryButton) }}
</button>
</ButtonStyled>
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
<button @click="loginToSkinSite">
<LogInIcon />
+2 -1
View File
@@ -174,6 +174,7 @@
"terracotta:default",
"multiplayer:default",
"servers:default",
"planet-minecraft:default"
"planet-minecraft:default",
"fonts:default"
]
}
+2 -2
View File
@@ -126,7 +126,7 @@
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
"worker-src": "'self' blob:",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' http://axolotl-skin.localhost axolotl-skin://localhost https://skin.starlight.cool",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' http://axolotl-skin.localhost axolotl-skin://localhost https://skin.starlight.cool https://map.starlight.cool",
"media-src": "'self' data: https://*.githubusercontent.com"
},
"csp": {
@@ -136,7 +136,7 @@
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
"style-src": "'unsafe-inline' 'self'",
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' axolotl-skin://localhost http://axolotl-skin.localhost https://skin.starlight.cool",
"frame-src": "https://www.youtube.com https://www.youtube-nocookie.com https://discord.com 'self' axolotl-skin://localhost http://axolotl-skin.localhost https://skin.starlight.cool https://map.starlight.cool",
"media-src": "'self' data: https://*.githubusercontent.com"
}
}
+64 -15
View File
@@ -728,7 +728,23 @@ async fn instance_and_root(
.ok_or_else(|| {
crate::ErrorKind::InputError("Unknown instance".to_string())
})?;
let root = state.directories.instances_dir().join(&instance.path);
// Back up the directory the game actually writes to. The runtime adapter
// is the authoritative resolver used by `instance_get_full_path` and the
// launch path, so backups follow the same rules: a per-instance
// `game_dir_override` (hosted/SLS packs, external folders) resolves through
// its launcher dialect and version isolation, and direct-linked
// installations resolve into the linked `.minecraft`.
let root = crate::launcher::instance_runtime::InstanceRuntimeAdapter::
for_instance(&instance, &state.directories)
.map(|adapter| adapter.game_dir())
.unwrap_or_else(|error| {
tracing::warn!(
%error,
instance_id = %instance.id,
"Falling back to the unresolved game directory for backup root"
);
state.directories.instance_game_dir(&instance)
});
Ok((instance, root))
}
@@ -746,13 +762,6 @@ async fn eligibility_for(
if instance.is_direct_linked() {
return Ok(InstanceBackupEligibility::DirectLinkedInstance);
}
if instance
.game_dir_override
.as_deref()
.is_some_and(|path| !path.trim().is_empty())
{
return Ok(InstanceBackupEligibility::ExternalGameDirectory);
}
let metadata = match tokio::fs::symlink_metadata(&root).await {
Ok(metadata) => metadata,
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
@@ -1658,6 +1667,42 @@ async fn create_snapshot_inner(
})
}
/// Game-body directories that live at a shared `.minecraft` root but do not
/// belong to any single instance. Excluding them keeps backups of externally
/// managed instances to their own saves, configs and mods instead of
/// re-copying several gigabytes of libraries and assets.
const SHARED_ROOT_EXCLUSIONS: [&str; 5] =
["libraries", "assets", "versions", "logs", "crash-reports"];
/// True when `path` looks like a shared `.minecraft` root rather than a
/// per-instance folder (mirrors the removal heuristic used elsewhere).
fn is_shared_minecraft_root(path: &Path) -> bool {
path.join("libraries").is_dir() || path.join("assets").is_dir()
}
/// Adds the shared game-body exclusions when the backup root is a shared
/// `.minecraft` root. Per-instance folders (hosted packs, isolated profiles)
/// are left untouched so their own `logs` or `versions` stay backed up.
fn with_shared_root_exclusions(
root: &Path,
exclusions: &[BackupExclusion],
) -> Vec<BackupExclusion> {
let mut merged = exclusions.to_vec();
if !is_shared_minecraft_root(root) {
return merged;
}
for name in SHARED_ROOT_EXCLUSIONS {
if merged.iter().any(|existing| existing.path == name) {
continue;
}
merged.push(BackupExclusion {
path: name.to_string(),
kind: BackupExclusionKind::Directory,
});
}
merged
}
async fn scan_snapshot_attempt(
root: &Path,
repository: &Path,
@@ -1680,6 +1725,8 @@ async fn scan_snapshot_attempt(
None,
)
.await;
let exclusions = with_shared_root_exclusions(root, exclusions);
let exclusions = exclusions.as_slice();
let total_bytes = measure_snapshot(root, exclusions, cancellation).await?;
emit_backup_progress(
operation_id,
@@ -2642,13 +2689,10 @@ pub(crate) async fn ensure_backup_eligible_edit(
instance_id: &str,
patch: &crate::state::EditInstance,
) -> crate::Result<()> {
// Only symlink instances stay ineligible; switching to an external game
// directory is fine because the backup root follows it.
let becomes_ineligible =
patch.symlink_target.as_ref().is_some_and(Option::is_some)
|| patch
.game_dir_override
.as_ref()
.and_then(|path| path.as_deref())
.is_some_and(|path| !path.trim().is_empty());
patch.symlink_target.as_ref().is_some_and(Option::is_some);
if !becomes_ineligible {
return Ok(());
}
@@ -3020,11 +3064,16 @@ async fn build_restore_plan(
.into());
}
}
// The snapshot never captured the shared game body (see
// `with_shared_root_exclusions`), so the current tree must be compared with
// the same exclusions or those directories would look like files the
// snapshot dropped and get deleted on restore.
let scan_exclusions = with_shared_root_exclusions(root, &exclusions);
let current = scan_restore_entries(
root,
whole_root,
&selected_roots,
&exclusions,
&scan_exclusions,
progress,
)
.await?;
@@ -629,10 +629,26 @@ pub async fn login_skin_site_player(
.send()
.await?;
if !response.status().is_success() {
return Err(ErrorKind::InputError(format!(
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试",
response.status().as_u16()
))
let status = response.status().as_u16();
// 皮肤站的错误响应体形如 {"error": "...", "errorMessage": "..."},
// 优先透出服务端给出的具体原因(例如正版玩家需用正版账号登录)。
let detail = response
.json::<serde_json::Value>()
.await
.ok()
.and_then(|value| {
value
.get("errorMessage")
.and_then(|msg| msg.as_str())
.map(str::to_string)
})
.filter(|msg| !msg.trim().is_empty());
return Err(ErrorKind::InputError(match detail {
Some(msg) => format!("皮肤站玩家登录失败:{msg}(HTTP {status})"),
None => format!(
"皮肤站玩家登录失败(HTTP {status}),请检查登录状态后重试"
),
})
.as_error());
}
let login = response.json::<Envelope>().await?.payload;