Merge pull request #26 from StarLight-Server/feat/home
feat: 维基页支持搜索、分类浏览、关注与移动
This commit is contained in:
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 }
|
||||
|
||||
@@ -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 运行的朋友。"
|
||||
},
|
||||
|
||||
@@ -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 />
|
||||
|
||||
@@ -174,6 +174,7 @@
|
||||
"terracotta:default",
|
||||
"multiplayer:default",
|
||||
"servers:default",
|
||||
"planet-minecraft:default"
|
||||
"planet-minecraft:default",
|
||||
"fonts:default"
|
||||
]
|
||||
}
|
||||
@@ -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"
|
||||
}
|
||||
}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in new issue
Block a user