Merge pull request #26 from StarLight-Server/feat/home
feat: 维基页支持搜索、分类浏览、关注与移动
This commit is contained in:
24 files changed
+1618
-94
No files matched your search
@@ -236,6 +236,7 @@ async function refresh() {
|
|||||||
if (parsed && !disposed) {
|
if (parsed && !disposed) {
|
||||||
sync(parsed, true)
|
sync(parsed, true)
|
||||||
saveCache(parsed)
|
saveCache(parsed)
|
||||||
|
for (const item of parsed) void loadImage(item, token)
|
||||||
}
|
}
|
||||||
} catch {
|
} catch {
|
||||||
// Remote fetch failed; previously cached items remain displayed
|
// Remote fetch failed; previously cached items remain displayed
|
||||||
@@ -316,6 +317,7 @@ onUnmounted(() => {
|
|||||||
if (interval) clearInterval(interval)
|
if (interval) clearInterval(interval)
|
||||||
if (advanceTimer) clearTimeout(advanceTimer)
|
if (advanceTimer) clearTimeout(advanceTimer)
|
||||||
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
window.removeEventListener(OPEN_REMOTE_ANNOUNCEMENT_CENTER_EVENT, handleOpenCenter)
|
||||||
|
revokeImages()
|
||||||
})
|
})
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -378,6 +380,12 @@ onUnmounted(() => {
|
|||||||
class="mt-1.5 size-2 shrink-0 rounded-full"
|
class="mt-1.5 size-2 shrink-0 rounded-full"
|
||||||
:class="read.has(announcementKey(item)) ? 'opacity-0' : 'bg-red'"
|
: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="min-w-0 flex-1">
|
||||||
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
|
<span class="block truncate font-medium text-[var(--color-text-primary)]">{{
|
||||||
item.title
|
item.title
|
||||||
@@ -393,6 +401,12 @@ onUnmounted(() => {
|
|||||||
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
|
<h2 class="mb-4 text-xl font-semibold text-[var(--color-text-primary)]">
|
||||||
{{ selected.title }}
|
{{ selected.title }}
|
||||||
</h2>
|
</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
|
<div
|
||||||
class="markdown-body break-words"
|
class="markdown-body break-words"
|
||||||
@click="contentClick"
|
@click="contentClick"
|
||||||
|
|||||||
@@ -1,11 +1,12 @@
|
|||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { SpinnerIcon } from '@modrinth/assets'
|
import { SpinnerIcon } from '@modrinth/assets'
|
||||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, onUnmounted, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
type SkinSiteBugComment,
|
type SkinSiteBugComment,
|
||||||
skinSiteBugComments,
|
skinSiteBugComments,
|
||||||
|
skinSiteBugImageFetch,
|
||||||
type SkinSiteBugIssue,
|
type SkinSiteBugIssue,
|
||||||
skinSiteBugIssue,
|
skinSiteBugIssue,
|
||||||
type SkinSiteBugIssueType,
|
type SkinSiteBugIssueType,
|
||||||
@@ -169,6 +170,26 @@ function formatDateTime(ts: number): string {
|
|||||||
|
|
||||||
const canSubmit = computed(() => !commentSending.value && commentDraft.value.trim().length > 0)
|
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() {
|
async function loadIssue() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
failed.value = false
|
failed.value = false
|
||||||
@@ -176,6 +197,7 @@ async function loadIssue() {
|
|||||||
const result = await skinSiteBugIssue(props.issueId)
|
const result = await skinSiteBugIssue(props.issueId)
|
||||||
if (!result.success) throw new Error(result.errorMessage)
|
if (!result.success) throw new Error(result.errorMessage)
|
||||||
issue.value = result.payload
|
issue.value = result.payload
|
||||||
|
void loadIssueImages(result.payload)
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
failed.value = true
|
failed.value = true
|
||||||
handleError(error)
|
handleError(error)
|
||||||
@@ -241,6 +263,8 @@ onMounted(() => {
|
|||||||
void loadIssue()
|
void loadIssue()
|
||||||
void loadComments(true)
|
void loadComments(true)
|
||||||
})
|
})
|
||||||
|
|
||||||
|
onUnmounted(revokeImageUrls)
|
||||||
</script>
|
</script>
|
||||||
<template>
|
<template>
|
||||||
<div class="flex flex-col gap-4">
|
<div class="flex flex-col gap-4">
|
||||||
@@ -328,13 +352,27 @@ onMounted(() => {
|
|||||||
{{ formatMessage(messages.descriptionEmpty) }}
|
{{ formatMessage(messages.descriptionEmpty) }}
|
||||||
</p>
|
</p>
|
||||||
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
|
<div v-if="issue.images.length" class="mt-3 flex flex-wrap gap-2">
|
||||||
<span
|
<a
|
||||||
v-for="image in issue.images"
|
v-for="image in issue.images"
|
||||||
:key="image.hash"
|
: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"
|
||||||
|
>
|
||||||
|
<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 }}
|
{{ image.hash }}
|
||||||
</span>
|
</span>
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -100,16 +100,19 @@ defineExpose({ show })
|
|||||||
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
<p class="m-0 text-secondary">{{ formatMessage(messages.subtitle) }}</p>
|
||||||
|
|
||||||
<div
|
<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
|
<img
|
||||||
v-if="image"
|
v-if="image"
|
||||||
:src="image"
|
:src="image"
|
||||||
alt=""
|
alt=""
|
||||||
class="size-full object-cover transition-transform duration-75"
|
class="size-full object-contain transition-transform duration-75"
|
||||||
:style="{ transform: `rotate(${previewRotation})` }"
|
:style="{ transform: `rotate(${previewRotation})` }"
|
||||||
draggable="false"
|
draggable="false"
|
||||||
/>
|
/>
|
||||||
|
<div v-else class="flex size-full items-center justify-center text-sm text-secondary">
|
||||||
|
{{ formatMessage(messages.loading) }}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="w-full max-w-sm">
|
<div class="w-full max-w-sm">
|
||||||
@@ -122,11 +125,9 @@ defineExpose({ show })
|
|||||||
class="w-full accent-brand"
|
class="w-full accent-brand"
|
||||||
:disabled="loading || submitting"
|
: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>0°</span>
|
||||||
<span>90°</span>
|
<span class="font-semibold text-contrast">{{ degree }}°</span>
|
||||||
<span>180°</span>
|
|
||||||
<span>270°</span>
|
|
||||||
<span>360°</span>
|
<span>360°</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -3,6 +3,7 @@ import { DownloadIcon, SpinnerIcon } from '@modrinth/assets'
|
|||||||
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/ui'
|
||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
|
import { skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
||||||
import {
|
import {
|
||||||
skinSitePublicDownloadCategories,
|
skinSitePublicDownloadCategories,
|
||||||
type SkinSitePublicDownloadCategory,
|
type SkinSitePublicDownloadCategory,
|
||||||
@@ -32,6 +33,10 @@ const messages = defineMessages({
|
|||||||
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
|
statusLegacy: { id: 'app.skin-site.downloads.status.legacy', defaultMessage: 'Legacy' },
|
||||||
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
|
statusOutdated: { id: 'app.skin-site.downloads.status.outdated', defaultMessage: 'Outdated' },
|
||||||
statusRemoval: { id: 'app.skin-site.downloads.status.removal', defaultMessage: 'Removal' },
|
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 }>()
|
const emit = defineEmits<{ (e: 'back'): void }>()
|
||||||
@@ -42,6 +47,8 @@ const selectedCategory = ref<string | null>(null)
|
|||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
|
|
||||||
|
const signedIn = computed(() => skinSiteStatus.value === 'signed-in' && !!skinSiteUser.value)
|
||||||
|
|
||||||
const filtered = computed(() =>
|
const filtered = computed(() =>
|
||||||
selectedCategory.value
|
selectedCategory.value
|
||||||
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
|
? items.value.filter((item) => item.category.includes(selectedCategory.value!))
|
||||||
@@ -76,6 +83,10 @@ function download(item: SkinSitePublicDownloadItem) {
|
|||||||
async function load() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
failed.value = false
|
failed.value = false
|
||||||
|
if (!signedIn.value) {
|
||||||
|
loading.value = false
|
||||||
|
return
|
||||||
|
}
|
||||||
try {
|
try {
|
||||||
const [listResult, categoryResult] = await Promise.all([
|
const [listResult, categoryResult] = await Promise.all([
|
||||||
skinSitePublicDownloadList(),
|
skinSitePublicDownloadList(),
|
||||||
@@ -138,7 +149,10 @@ onMounted(load)
|
|||||||
</button>
|
</button>
|
||||||
</div>
|
</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" />
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
{{ formatMessage(messages.loading) }}
|
{{ formatMessage(messages.loading) }}
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
@@ -101,7 +101,7 @@ function onBugCreated(id: number) {
|
|||||||
@open="openBug"
|
@open="openBug"
|
||||||
/>
|
/>
|
||||||
<SkinSiteAdminView v-else-if="view === 'admin'" @back="view = 'home'" />
|
<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
|
<SkinSiteBugDetailView
|
||||||
v-else-if="view === 'bug-detail' && selectedBugId !== null"
|
v-else-if="view === 'bug-detail' && selectedBugId !== null"
|
||||||
|
|||||||
@@ -12,9 +12,16 @@ import {
|
|||||||
skinSiteUser,
|
skinSiteUser,
|
||||||
} from '@/composables/skin-site-session'
|
} from '@/composables/skin-site-session'
|
||||||
import {
|
import {
|
||||||
|
skinSiteAddPlayer,
|
||||||
|
skinSiteClearDefaultPlayer,
|
||||||
|
skinSiteDefaultPlayer,
|
||||||
|
skinSiteDeletePlayer,
|
||||||
skinSiteDeleteTexture,
|
skinSiteDeleteTexture,
|
||||||
type SkinSitePlayerSkin,
|
|
||||||
skinSitePlayerSkin,
|
skinSitePlayerSkin,
|
||||||
|
type SkinSitePlayerSkin,
|
||||||
|
skinSiteRenamePlayer,
|
||||||
|
skinSiteSetDefaultPlayer,
|
||||||
|
skinSiteSwitchMojang,
|
||||||
skinSiteUploadTexture,
|
skinSiteUploadTexture,
|
||||||
type SkinTextureType,
|
type SkinTextureType,
|
||||||
} from '@/helpers/skin-site-api'
|
} from '@/helpers/skin-site-api'
|
||||||
@@ -51,6 +58,48 @@ const messages = defineMessages({
|
|||||||
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
|
uploaded: { id: 'app.skin-site.profile-view.uploaded', defaultMessage: 'Texture updated.' },
|
||||||
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
|
removed: { id: 'app.skin-site.profile-view.removed', defaultMessage: 'Texture removed.' },
|
||||||
back: { id: 'app.skin-site.profile-view.back', defaultMessage: 'Back to home' },
|
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 selectedId = ref<string | null>(null)
|
||||||
const texture = ref<SkinSitePlayerSkin>({})
|
const texture = ref<SkinSitePlayerSkin>({})
|
||||||
@@ -93,6 +142,174 @@ watch(selectedId, (id) => {
|
|||||||
else texture.value = {}
|
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) {
|
async function onUpload(type: SkinTextureType, event: Event) {
|
||||||
const input = event.target as HTMLInputElement
|
const input = event.target as HTMLInputElement
|
||||||
const file = input.files?.[0]
|
const file = input.files?.[0]
|
||||||
@@ -135,7 +352,10 @@ async function removeTexture(type: SkinTextureType) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
onMounted(() => {
|
onMounted(() => {
|
||||||
if (signedIn.value) void requestSkinSitePlayers().catch(() => {})
|
if (signedIn.value) {
|
||||||
|
void requestSkinSitePlayers().catch(() => {})
|
||||||
|
void loadDefaultPlayer()
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|
||||||
watch(signedIn, (value) => {
|
watch(signedIn, (value) => {
|
||||||
@@ -161,9 +381,79 @@ watch(signedIn, (value) => {
|
|||||||
<div
|
<div
|
||||||
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
|
class="flex flex-col gap-2 rounded-2xl border border-solid border-surface-5 bg-bg-raised p-3"
|
||||||
>
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
<h3 class="m-0 text-base font-semibold text-contrast">
|
<h3 class="m-0 text-base font-semibold text-contrast">
|
||||||
{{ formatMessage(messages.title) }}
|
{{ formatMessage(messages.title) }}
|
||||||
</h3>
|
</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">
|
<div v-if="playersLoading" class="flex items-center gap-2 text-sm text-secondary">
|
||||||
<SpinnerIcon class="size-4 animate-spin" />
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
@@ -172,11 +462,10 @@ watch(signedIn, (value) => {
|
|||||||
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
|
<p v-else-if="!players.length" class="m-0 text-sm text-secondary">
|
||||||
{{ formatMessage(messages.noPlayers) }}
|
{{ formatMessage(messages.noPlayers) }}
|
||||||
</p>
|
</p>
|
||||||
<button
|
<div
|
||||||
v-for="player in players"
|
v-for="player in players"
|
||||||
:key="player.uuid"
|
:key="player.uuid"
|
||||||
type="button"
|
class="flex items-center gap-2 rounded-xl px-2 py-1.5 transition-colors"
|
||||||
class="flex items-center gap-2 rounded-xl px-2 py-1.5 text-left transition-colors"
|
|
||||||
:class="
|
:class="
|
||||||
player.uuid === selectedId
|
player.uuid === selectedId
|
||||||
? 'bg-brand-highlight text-contrast'
|
? 'bg-brand-highlight text-contrast'
|
||||||
@@ -192,10 +481,98 @@ watch(signedIn, (value) => {
|
|||||||
class="!rounded-md"
|
class="!rounded-md"
|
||||||
/>
|
/>
|
||||||
<span class="min-w-0 flex-1 truncate text-sm font-medium">{{ player.name }}</span>
|
<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"
|
<span v-if="player.isMojang" class="shrink-0 text-[10px] uppercase text-secondary"
|
||||||
>Mojang</span
|
>Mojang</span
|
||||||
>
|
>
|
||||||
|
<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>
|
||||||
|
<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>
|
</div>
|
||||||
|
|
||||||
<!-- Preview + texture actions -->
|
<!-- Preview + texture actions -->
|
||||||
@@ -207,10 +584,37 @@ watch(signedIn, (value) => {
|
|||||||
</p>
|
</p>
|
||||||
|
|
||||||
<template v-else>
|
<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>
|
<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>
|
<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>
|
</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 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">
|
<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(() => {
|
const requiredAnswered = computed(() => {
|
||||||
if (!activePaper.value) return false
|
if (!activePaper.value) return false
|
||||||
return activePaper.value.questions.every((q) => {
|
return (activePaper.value.questions ?? []).every((q) => {
|
||||||
if (q.informationQuestion || q.optional) return true
|
if (q.informationQuestion || q.optional) return true
|
||||||
const value = answers.value[q.id]
|
const value = answers.value[q.id]
|
||||||
if (Array.isArray(value)) return value.length > 0
|
if (Array.isArray(value)) return value.length > 0
|
||||||
@@ -81,7 +81,7 @@ function defaultAnswer(q: SkinSiteReviewPublicQuestion): string | string[] {
|
|||||||
function openPaper(paper: SkinSiteReviewPublicPaper) {
|
function openPaper(paper: SkinSiteReviewPublicPaper) {
|
||||||
activePaper.value = paper
|
activePaper.value = paper
|
||||||
answers.value = {}
|
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 = ''
|
email.value = ''
|
||||||
inviteCode.value = ''
|
inviteCode.value = ''
|
||||||
receipt.value = null
|
receipt.value = null
|
||||||
@@ -119,7 +119,7 @@ async function submitApplication() {
|
|||||||
submitting.value = true
|
submitting.value = true
|
||||||
try {
|
try {
|
||||||
const payloadAnswers: Record<string, unknown> = {}
|
const payloadAnswers: Record<string, unknown> = {}
|
||||||
for (const q of activePaper.value.questions) {
|
for (const q of activePaper.value.questions ?? []) {
|
||||||
if (q.informationQuestion) continue
|
if (q.informationQuestion) continue
|
||||||
payloadAnswers[q.id] = answers.value[q.id] ?? ''
|
payloadAnswers[q.id] = answers.value[q.id] ?? ''
|
||||||
}
|
}
|
||||||
@@ -226,7 +226,7 @@ onMounted(loadCatalog)
|
|||||||
</label>
|
</label>
|
||||||
|
|
||||||
<div
|
<div
|
||||||
v-for="question in activePaper.questions"
|
v-for="question in activePaper.questions ?? []"
|
||||||
:key="question.id"
|
:key="question.id"
|
||||||
class="flex flex-col gap-2 rounded-xl border border-solid border-surface-5 bg-bg-raised p-3"
|
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 {
|
import {
|
||||||
type SkinSiteWikiPageDetail,
|
type SkinSiteWikiPageDetail,
|
||||||
skinSiteWikiPage,
|
skinSiteWikiPage,
|
||||||
|
skinSiteWikiDiff,
|
||||||
|
skinSiteWikiHistory,
|
||||||
|
skinSiteWikiRevert,
|
||||||
|
skinSiteWikiRevision,
|
||||||
|
type SkinSiteWikiDiffLine,
|
||||||
|
type SkinSiteWikiNode,
|
||||||
|
type SkinSiteWikiRevisionInfo,
|
||||||
} from '@/helpers/skin-site-api'
|
} from '@/helpers/skin-site-api'
|
||||||
|
|
||||||
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
import WikiNodeRenderer from './wiki/WikiNodeRenderer.vue'
|
||||||
|
import WikiBrowser from './wiki/WikiBrowser.vue'
|
||||||
|
|
||||||
const { formatMessage } = useVIntl()
|
const { formatMessage } = useVIntl()
|
||||||
const { handleError } = injectNotificationManager()
|
const { handleError, addNotification } = injectNotificationManager()
|
||||||
|
|
||||||
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
|
const props = withDefaults(defineProps<{ initialTitle?: string }>(), {
|
||||||
initialTitle: '',
|
initialTitle: '',
|
||||||
@@ -40,13 +48,50 @@ const messages = defineMessages({
|
|||||||
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
|
revision: { id: 'app.skin-site.wiki.revision', defaultMessage: 'Revision #{id}' },
|
||||||
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
|
categories: { id: 'app.skin-site.wiki.categories', defaultMessage: 'Categories' },
|
||||||
back: { id: 'app.skin-site.wiki.back', defaultMessage: 'Back' },
|
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 titleInput = ref(props.initialTitle)
|
||||||
const page = ref<SkinSiteWikiPageDetail | null>(null)
|
const page = ref<SkinSiteWikiPageDetail | null>(null)
|
||||||
const loading = ref(false)
|
const loading = ref(false)
|
||||||
const notFound = ref(false)
|
const notFound = ref(false)
|
||||||
const history = ref<string[]>([])
|
const navStack = ref<string[]>([])
|
||||||
|
|
||||||
const currentTitle = computed(() => page.value?.title ?? '')
|
const currentTitle = computed(() => page.value?.title ?? '')
|
||||||
|
|
||||||
@@ -64,8 +109,8 @@ async function openPage(title: string) {
|
|||||||
return
|
return
|
||||||
}
|
}
|
||||||
page.value = res.payload
|
page.value = res.payload
|
||||||
if (currentTitle.value && history.value[history.value.length - 1] !== currentTitle.value) {
|
if (currentTitle.value && navStack.value[navStack.value.length - 1] !== currentTitle.value) {
|
||||||
history.value.push(currentTitle.value)
|
navStack.value.push(currentTitle.value)
|
||||||
}
|
}
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
handleError(error)
|
handleError(error)
|
||||||
@@ -77,7 +122,7 @@ async function openPage(title: string) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
function goBack() {
|
function goBack() {
|
||||||
const prev = history.value.pop()
|
const prev = navStack.value.pop()
|
||||||
if (prev) void openPage(prev)
|
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) {
|
function onNavigate(title: string) {
|
||||||
if (title) void openPage(title)
|
if (title) void openPage(title)
|
||||||
}
|
}
|
||||||
@@ -102,7 +218,7 @@ onMounted(() => {
|
|||||||
<div class="flex w-full flex-col gap-4">
|
<div class="flex w-full flex-col gap-4">
|
||||||
<div class="flex flex-wrap items-center gap-2">
|
<div class="flex flex-wrap items-center gap-2">
|
||||||
<button
|
<button
|
||||||
v-if="history.length"
|
v-if="navStack.length"
|
||||||
type="button"
|
type="button"
|
||||||
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
class="rounded-lg bg-button-bg px-3 py-1.5 text-xs font-semibold text-contrast hover:brightness-110"
|
||||||
@click="goBack"
|
@click="goBack"
|
||||||
@@ -124,8 +240,28 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ formatMessage(messages.open) }}
|
{{ formatMessage(messages.open) }}
|
||||||
</button>
|
</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>
|
</div>
|
||||||
|
|
||||||
|
<WikiBrowser @navigate="onNavigate" />
|
||||||
|
|
||||||
<div v-if="loading" class="flex min-h-48 items-center justify-center gap-2 text-sm text-secondary">
|
<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" />
|
<SpinnerIcon class="size-4 animate-spin" />
|
||||||
{{ formatMessage(messages.loading) }}
|
{{ formatMessage(messages.loading) }}
|
||||||
@@ -142,12 +278,59 @@ onMounted(() => {
|
|||||||
>
|
>
|
||||||
{{ formatMessage(messages.empty) }}
|
{{ formatMessage(messages.empty) }}
|
||||||
</p>
|
</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">
|
<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>
|
<h1 class="m-0 text-2xl font-bold text-contrast">{{ page.title }}</h1>
|
||||||
|
<div class="flex flex-wrap items-center gap-1.5">
|
||||||
<span class="text-[11px] text-secondary">
|
<span class="text-[11px] text-secondary">
|
||||||
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
|
{{ formatMessage(messages.revision, { id: page.revisionId }) }}
|
||||||
</span>
|
</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>
|
</div>
|
||||||
|
|
||||||
<p class="m-0 text-[11px] text-secondary">
|
<p class="m-0 text-[11px] text-secondary">
|
||||||
@@ -188,5 +371,123 @@ onMounted(() => {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</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>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
@@ -4,8 +4,10 @@ import { defineMessages, injectNotificationManager, useVIntl } from '@modrinth/u
|
|||||||
import { computed, onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
|
|
||||||
import {
|
import {
|
||||||
|
type SkinSiteAdminUser,
|
||||||
type SkinSiteBroadcast,
|
type SkinSiteBroadcast,
|
||||||
type SkinSiteBroadcastInput,
|
type SkinSiteBroadcastInput,
|
||||||
|
skinSiteAdminUserList,
|
||||||
skinSiteBroadcasts,
|
skinSiteBroadcasts,
|
||||||
skinSiteCreateBroadcast,
|
skinSiteCreateBroadcast,
|
||||||
skinSiteDeleteBroadcast,
|
skinSiteDeleteBroadcast,
|
||||||
@@ -44,6 +46,7 @@ const messages = defineMessages({
|
|||||||
})
|
})
|
||||||
|
|
||||||
const items = ref<SkinSiteBroadcast[]>([])
|
const items = ref<SkinSiteBroadcast[]>([])
|
||||||
|
const uploaderNames = ref<Record<string, string>>({})
|
||||||
const loading = ref(true)
|
const loading = ref(true)
|
||||||
const failed = ref(false)
|
const failed = ref(false)
|
||||||
const saving = 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,
|
() => 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() {
|
async function load() {
|
||||||
loading.value = true
|
loading.value = true
|
||||||
failed.value = false
|
failed.value = false
|
||||||
try {
|
try {
|
||||||
const result = await skinSiteBroadcasts()
|
const [result] = await Promise.all([skinSiteBroadcasts(), loadUploaderNames()])
|
||||||
if (!result.success) throw new Error(result.errorMessage)
|
if (!result.success) throw new Error(result.errorMessage)
|
||||||
items.value = result.payload ?? []
|
items.value = result.payload ?? []
|
||||||
} catch (error) {
|
} catch (error) {
|
||||||
@@ -248,7 +268,7 @@ onMounted(() => void load())
|
|||||||
</div>
|
</div>
|
||||||
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
|
<span class="shrink-0 text-xs text-secondary">{{ formatDate(item.updateAt) }}</span>
|
||||||
<span v-if="item.uploader" class="shrink-0 text-xs text-secondary">
|
<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>
|
</span>
|
||||||
<button
|
<button
|
||||||
type="button"
|
type="button"
|
||||||
|
|||||||
@@ -270,9 +270,18 @@ function questionFor(submission: SkinSiteReviewSubmission, questionId: string) {
|
|||||||
return submission.paper?.questions?.find((q) => q.id === questionId)
|
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 (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 '—'
|
return '—'
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1046,7 +1055,7 @@ onMounted(() => void load())
|
|||||||
<span
|
<span
|
||||||
class="mt-1 rounded-lg bg-bg px-3 py-2 text-sm break-words whitespace-pre-wrap text-contrast"
|
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>
|
||||||
<span
|
<span
|
||||||
v-if="answer.awardedScore !== null && answer.awardedScore !== undefined"
|
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' },
|
banned: { id: 'app.skin-site.admin.users.banned', defaultMessage: 'Banned' },
|
||||||
permissions: { id: 'app.skin-site.admin.users.permissions', defaultMessage: 'Permissions' },
|
permissions: { id: 'app.skin-site.admin.users.permissions', defaultMessage: 'Permissions' },
|
||||||
|
permissionGroups: {
|
||||||
|
id: 'app.skin-site.admin.users.permission-groups',
|
||||||
|
defaultMessage: 'Groups',
|
||||||
|
},
|
||||||
permissionsHint: {
|
permissionsHint: {
|
||||||
id: 'app.skin-site.admin.users.permissions-hint',
|
id: 'app.skin-site.admin.users.permissions-hint',
|
||||||
defaultMessage: 'Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN.',
|
defaultMessage: 'Comma separated, e.g. WEB_ADMIN, SERVER_ADMIN.',
|
||||||
@@ -514,6 +518,21 @@ async function nextPage() {
|
|||||||
await load()
|
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) {
|
function startEdit(user: SkinSiteAdminUser) {
|
||||||
editingUuid.value = user.uuid
|
editingUuid.value = user.uuid
|
||||||
editForm.value = {
|
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.email) }}</th>
|
||||||
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.qq) }}</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.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.registeredAt) }}</th>
|
||||||
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.actions) }}</th>
|
<th class="px-3 py-2 font-semibold">{{ formatMessage(messages.actions) }}</th>
|
||||||
</tr>
|
</tr>
|
||||||
@@ -704,6 +726,24 @@ onMounted(() => void load())
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
</td>
|
</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">
|
<td class="px-3 py-2 text-xs text-secondary">
|
||||||
{{ formatDateTime(user.registerTime) }}
|
{{ formatDateTime(user.registerTime) }}
|
||||||
</td>
|
</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) {
|
function navigate(title?: string | null) {
|
||||||
emit('navigate', (title ?? '').replaceAll('_', ' '))
|
emit('navigate', title ?? '')
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
|
|||||||
@@ -34,6 +34,7 @@ import {
|
|||||||
useVIntl,
|
useVIntl,
|
||||||
} from '@modrinth/ui'
|
} from '@modrinth/ui'
|
||||||
import { getPingLevel } from '@modrinth/utils'
|
import { getPingLevel } from '@modrinth/utils'
|
||||||
|
import { autoToHTML } from '@sfirew/minecraft-motd-parser'
|
||||||
import dayjs from 'dayjs'
|
import dayjs from 'dayjs'
|
||||||
import { Tooltip } from 'floating-vue'
|
import { Tooltip } from 'floating-vue'
|
||||||
import type { Component } from 'vue'
|
import type { Component } from 'vue'
|
||||||
@@ -360,10 +361,12 @@ const messages = defineMessages({
|
|||||||
}}
|
}}
|
||||||
</span>
|
</span>
|
||||||
<template #popper>
|
<template #popper>
|
||||||
<div class="flex flex-col gap-1">
|
<div class="flex flex-col gap-1 font-minecraft">
|
||||||
<span v-for="player in serverStatus.players?.sample" :key="player.name">
|
<span
|
||||||
{{ player.name }}
|
v-for="player in serverStatus.players?.sample"
|
||||||
</span>
|
:key="player.name"
|
||||||
|
v-html="autoToHTML(player.name)"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
|
|||||||
@@ -292,6 +292,9 @@ async function buildPlayer(entry: {
|
|||||||
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
|
export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
|
||||||
if (playersRequest) return playersRequest
|
if (playersRequest) return playersRequest
|
||||||
if (status.value !== 'signed-in' || !user.value) {
|
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 皮肤站。'))
|
return Promise.reject(new Error('请先登录 StarLight 皮肤站。'))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -317,6 +320,16 @@ export function requestSkinSitePlayers(): Promise<SkinSitePlayer[]> {
|
|||||||
return playersRequest
|
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) {
|
export function selectSkinSitePlayer(playerId: string | null) {
|
||||||
if (playerId === null) {
|
if (playerId === null) {
|
||||||
selectedPlayerId.value = null
|
selectedPlayerId.value = null
|
||||||
|
|||||||
@@ -11,6 +11,7 @@ export type RemoteAnnouncement = {
|
|||||||
published_at: string
|
published_at: string
|
||||||
action_url: string | null
|
action_url: string | null
|
||||||
action_label: string | null
|
action_label: string | null
|
||||||
|
image_name: string | null
|
||||||
}
|
}
|
||||||
|
|
||||||
export function safeAnnouncementUrl(value: unknown): 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,
|
ends_at: item.ends_at ?? null,
|
||||||
action_label: item.action_label ?? null,
|
action_label: item.action_label ?? null,
|
||||||
action_url: item.action_url ?? null,
|
action_url: item.action_url ?? null,
|
||||||
|
image_name:
|
||||||
|
typeof item.image_name === 'string' && item.image_name.trim()
|
||||||
|
? item.image_name
|
||||||
|
: null,
|
||||||
} as RemoteAnnouncement)
|
} as RemoteAnnouncement)
|
||||||
}
|
}
|
||||||
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
|
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
|
||||||
@@ -143,6 +148,10 @@ export function mapBroadcastAnnouncements(payload: unknown): RemoteAnnouncement[
|
|||||||
published_at: published,
|
published_at: published,
|
||||||
action_url: null,
|
action_url: null,
|
||||||
action_label: 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 }
|
const rank = { low: 0, normal: 1, high: 2, critical: 3 }
|
||||||
|
|||||||
@@ -336,6 +336,40 @@ export function skinSiteRenamePlayer(
|
|||||||
{ method: 'POST', type: 'skin' },
|
{ 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
|
// Registration & password reset
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
@@ -414,8 +448,8 @@ export function skinSitePublicDonationSummary(): Promise<
|
|||||||
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
|
return skinSiteRequest<SkinSitePublicDonationSummary>('donation/summary', { auth: false })
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSitePublicDonationPage>> {
|
export function skinSiteDonationList(offset = 0): Promise<SkinSiteResponse<SkinSiteDonationPage>> {
|
||||||
return skinSiteRequest<SkinSitePublicDonationPage>(`donation/list?limit=20&offset=${offset}`, {
|
return skinSiteRequest<SkinSiteDonationPage>(`donation/list?limit=20&offset=${offset}`, {
|
||||||
auth: false,
|
auth: false,
|
||||||
})
|
})
|
||||||
}
|
}
|
||||||
@@ -522,22 +556,18 @@ export function skinSitePublicDownloadList(options?: {
|
|||||||
offset: String(options?.offset ?? 0),
|
offset: String(options?.offset ?? 0),
|
||||||
})
|
})
|
||||||
if (options?.category) query.set('category', options.category)
|
if (options?.category) query.set('category', options.category)
|
||||||
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`, {
|
return skinSiteRequest<SkinSitePublicDownloadItem[]>(`download/list?${query.toString()}`)
|
||||||
auth: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSitePublicDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
|
export function skinSitePublicDownloadCount(category?: string): Promise<SkinSiteResponse<number>> {
|
||||||
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
|
const suffix = category ? `?category=${encodeURIComponent(category)}` : ''
|
||||||
return skinSiteRequest<number>(`download/count${suffix}`, { auth: false })
|
return skinSiteRequest<number>(`download/count${suffix}`)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSitePublicDownloadCategories(): Promise<
|
export function skinSitePublicDownloadCategories(): Promise<
|
||||||
SkinSiteResponse<SkinSitePublicDownloadCategory[]>
|
SkinSiteResponse<SkinSitePublicDownloadCategory[]>
|
||||||
> {
|
> {
|
||||||
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list', {
|
return skinSiteRequest<SkinSitePublicDownloadCategory[]>('download/category/list')
|
||||||
auth: false,
|
|
||||||
})
|
|
||||||
}
|
}
|
||||||
// ---------------------------------------------------------------------------
|
// ---------------------------------------------------------------------------
|
||||||
// Maps
|
// Maps
|
||||||
@@ -662,6 +692,24 @@ export interface SkinSiteBugImage {
|
|||||||
[key: string]: unknown
|
[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 {
|
export interface SkinSiteBugIssue {
|
||||||
id: number
|
id: number
|
||||||
type: SkinSiteBugIssueType
|
type: SkinSiteBugIssueType
|
||||||
@@ -1217,7 +1265,6 @@ export function skinSiteActivateLauncherRelease(
|
|||||||
export function skinSiteDeleteLauncherRelease(id: number): Promise<SkinSiteResponse<boolean>> {
|
export function skinSiteDeleteLauncherRelease(id: number): Promise<SkinSiteResponse<boolean>> {
|
||||||
return skinSiteRequest<boolean>('launcher/admin/' + id, { method: 'DELETE' })
|
return skinSiteRequest<boolean>('launcher/admin/' + id, { method: 'DELETE' })
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface SkinSiteLauncherUploadStatus {
|
export interface SkinSiteLauncherUploadStatus {
|
||||||
id: string
|
id: string
|
||||||
chunkSize: number
|
chunkSize: number
|
||||||
@@ -1865,7 +1912,6 @@ export function skinSiteWikiUpdateSettings(
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|
||||||
export interface SkinSiteReviewPaperSummary {
|
export interface SkinSiteReviewPaperSummary {
|
||||||
id: string
|
id: string
|
||||||
enabled: boolean
|
enabled: boolean
|
||||||
@@ -1908,9 +1954,7 @@ export interface SkinSiteReviewConfigData {
|
|||||||
prerequisitePermissions: string[]
|
prerequisitePermissions: string[]
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSiteReviewPaperList(): Promise<
|
export function skinSiteReviewPaperList(): Promise<SkinSiteResponse<SkinSiteReviewPaperSummary[]>> {
|
||||||
SkinSiteResponse<SkinSiteReviewPaperSummary[]>
|
|
||||||
> {
|
|
||||||
return skinSiteRequest<SkinSiteReviewPaperSummary[]>('review/admin/papers')
|
return skinSiteRequest<SkinSiteReviewPaperSummary[]>('review/admin/papers')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -1991,9 +2035,7 @@ export interface SkinSiteInviteCode {
|
|||||||
code: string
|
code: string
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSiteAdminBotInfo(
|
export function skinSiteAdminBotInfo(userUuid: string): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
|
||||||
userUuid: string,
|
|
||||||
): Promise<SkinSiteResponse<SkinSiteBotInfo>> {
|
|
||||||
return skinSiteRequest<SkinSiteBotInfo>('admin/users/' + encodeURIComponent(userUuid) + '/bot')
|
return skinSiteRequest<SkinSiteBotInfo>('admin/users/' + encodeURIComponent(userUuid) + '/bot')
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2047,7 +2089,9 @@ export function skinSiteAdminUserAudit(params?: {
|
|||||||
return skinSiteRequest<SkinSiteUserAdminAuditPage>('admin/users/audit' + suffix)
|
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', {
|
return skinSiteRequest<SkinSiteInviteCode>('admin/inviteCode', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
body: { email },
|
body: { email },
|
||||||
@@ -2381,10 +2425,10 @@ export function skinSiteChatVoiceConfig(): Promise<
|
|||||||
export function skinSiteChatVoiceConfigUpdate(
|
export function skinSiteChatVoiceConfigUpdate(
|
||||||
data: SkinSiteChatVoiceGlobalConfig,
|
data: SkinSiteChatVoiceGlobalConfig,
|
||||||
): Promise<SkinSiteResponse<SkinSiteChatVoiceGlobalConfigUpdateResult>> {
|
): Promise<SkinSiteResponse<SkinSiteChatVoiceGlobalConfigUpdateResult>> {
|
||||||
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>(
|
return skinSiteRequest<SkinSiteChatVoiceGlobalConfigUpdateResult>('chat/voice/admin/config', {
|
||||||
'chat/voice/admin/config',
|
method: 'PUT',
|
||||||
{ method: 'PUT', body: data },
|
body: data,
|
||||||
)
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSiteChatVoiceLogs(
|
export function skinSiteChatVoiceLogs(
|
||||||
@@ -2503,7 +2547,11 @@ export function skinSiteWikiMigrationCandidates(
|
|||||||
|
|
||||||
export function skinSiteWikiMigrationDecide(
|
export function skinSiteWikiMigrationDecide(
|
||||||
candidateId: number,
|
candidateId: number,
|
||||||
data: { decision: SkinSiteWikiMigrationDecision; targetTitle?: string | null; overwrite?: boolean },
|
data: {
|
||||||
|
decision: SkinSiteWikiMigrationDecision
|
||||||
|
targetTitle?: string | null
|
||||||
|
overwrite?: boolean
|
||||||
|
},
|
||||||
): Promise<SkinSiteResponse<void>> {
|
): Promise<SkinSiteResponse<void>> {
|
||||||
return skinSiteRequest<void>('wiki/admin/migration/candidates/' + candidateId, {
|
return skinSiteRequest<void>('wiki/admin/migration/candidates/' + candidateId, {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
@@ -2520,9 +2568,7 @@ export function skinSiteWikiMigrationPublish(
|
|||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|
||||||
export function skinSiteWikiMigrationRollback(
|
export function skinSiteWikiMigrationRollback(jobId: number): Promise<SkinSiteResponse<void>> {
|
||||||
jobId: number,
|
|
||||||
): Promise<SkinSiteResponse<void>> {
|
|
||||||
return skinSiteRequest<void>('wiki/admin/migration/' + jobId + '/rollback', {
|
return skinSiteRequest<void>('wiki/admin/migration/' + jobId + '/rollback', {
|
||||||
method: 'POST',
|
method: 'POST',
|
||||||
})
|
})
|
||||||
@@ -2543,3 +2589,170 @@ export function skinSiteWikiAttachment(
|
|||||||
export function skinSiteWikiAttachmentUrl(id: number): string {
|
export function skinSiteWikiAttachmentUrl(id: number): string {
|
||||||
return `${SKIN_SITE_ORIGIN}${REQUEST_TYPE_PATH.starlight}/wiki/attachment/${id}/content`
|
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.title": { "message": "Downloads" },
|
||||||
"app.skin-site.downloads.uploader": { "message": "by {name}" },
|
"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": {
|
"app.skin-site.home.announcements.empty": {
|
||||||
"message": "No announcements right now."
|
"message": "No announcements right now."
|
||||||
},
|
},
|
||||||
@@ -65,24 +66,42 @@
|
|||||||
"app.skin-site.map.open": { "message": "Open in browser" },
|
"app.skin-site.map.open": { "message": "Open in browser" },
|
||||||
"app.skin-site.map.server": { "message": "Server" },
|
"app.skin-site.map.server": { "message": "Server" },
|
||||||
"app.skin-site.map.title": { "message": "Server map" },
|
"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.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.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.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.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.no-players": { "message": "No players yet." },
|
||||||
|
"app.skin-site.profile-view.player-name-placeholder": { "message": "Player name" },
|
||||||
"app.skin-site.profile-view.readonly": {
|
"app.skin-site.profile-view.readonly": {
|
||||||
"message": "Mojang accounts are managed by Mojang; textures cannot be edited here."
|
"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.remove": { "message": "Remove" },
|
||||||
"app.skin-site.profile-view.removed": { "message": "Texture removed." },
|
"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": {
|
"app.skin-site.profile-view.select-hint": {
|
||||||
"message": "Select a player to preview and edit its textures."
|
"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": {
|
"app.skin-site.profile-view.signed-out": {
|
||||||
"message": "Sign in to manage your players."
|
"message": "Sign in to manage your players."
|
||||||
},
|
},
|
||||||
"app.skin-site.profile-view.skin": { "message": "Skin" },
|
"app.skin-site.profile-view.skin": { "message": "Skin" },
|
||||||
"app.skin-site.profile-view.title": { "message": "Your players" },
|
"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.upload": { "message": "Upload" },
|
||||||
"app.skin-site.profile-view.uploaded": { "message": "Texture updated." },
|
"app.skin-site.profile-view.uploaded": { "message": "Texture updated." },
|
||||||
"app.skin-site.profile.avatar": { "message": "Avatar" },
|
"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": { "message": "Status" },
|
||||||
"app.skin-site.admin.users.status-banned": { "message": "Banned" },
|
"app.skin-site.admin.users.status-banned": { "message": "Banned" },
|
||||||
"app.skin-site.admin.users.status-normal": { "message": "Normal" },
|
"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.upload": { "message": "Upload" },
|
||||||
"app.skin-site.admin.mods.select-mode": { "message": "Select multiple" },
|
"app.skin-site.admin.mods.select-mode": { "message": "Select multiple" },
|
||||||
"app.skin-site.admin.mods.exit-select": { "message": "Done selecting" },
|
"app.skin-site.admin.mods.exit-select": { "message": "Done selecting" },
|
||||||
@@ -13076,6 +13096,30 @@
|
|||||||
"app.skin-site.wiki.revision": { "message": "Revision #{id}" },
|
"app.skin-site.wiki.revision": { "message": "Revision #{id}" },
|
||||||
"app.skin-site.wiki.categories": { "message": "Categories" },
|
"app.skin-site.wiki.categories": { "message": "Categories" },
|
||||||
"app.skin-site.wiki.back": { "message": "Back" },
|
"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": {
|
"app.skin-site.home.donation.thanks": {
|
||||||
"message": "Thank you to everyone who helps keep StarLight running."
|
"message": "Thank you to everyone who helps keep StarLight running."
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@
|
|||||||
},
|
},
|
||||||
"app.skin-site.downloads.title": { "message": "资源下载" },
|
"app.skin-site.downloads.title": { "message": "资源下载" },
|
||||||
"app.skin-site.downloads.uploader": { "message": "上传者:{name}" },
|
"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.empty": { "message": "暂无公告。" },
|
||||||
"app.skin-site.home.announcements.failed": { "message": "加载公告失败。" },
|
"app.skin-site.home.announcements.failed": { "message": "加载公告失败。" },
|
||||||
"app.skin-site.home.announcements.loading": { "message": "正在加载…" },
|
"app.skin-site.home.announcements.loading": { "message": "正在加载…" },
|
||||||
@@ -61,24 +62,42 @@
|
|||||||
"app.skin-site.map.open": { "message": "在浏览器中打开" },
|
"app.skin-site.map.open": { "message": "在浏览器中打开" },
|
||||||
"app.skin-site.map.server": { "message": "服务器" },
|
"app.skin-site.map.server": { "message": "服务器" },
|
||||||
"app.skin-site.map.title": { "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.back": { "message": "返回主页" },
|
||||||
|
"app.skin-site.profile-view.cancel": { "message": "取消" },
|
||||||
"app.skin-site.profile-view.cape": { "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.elytra": { "message": "鞘翅" },
|
||||||
|
"app.skin-site.profile-view.is-default": { "message": "默认" },
|
||||||
"app.skin-site.profile-view.loading": { "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.no-players": { "message": "暂无角色。" },
|
||||||
|
"app.skin-site.profile-view.player-name-placeholder": { "message": "角色名" },
|
||||||
"app.skin-site.profile-view.readonly": {
|
"app.skin-site.profile-view.readonly": {
|
||||||
"message": "Mojang 账号由 Mojang 管理,无法在此修改材质。"
|
"message": "Mojang 账号由 Mojang 管理,无法在此修改材质。"
|
||||||
},
|
},
|
||||||
"app.skin-site.profile-view.remove": { "message": "移除" },
|
"app.skin-site.profile-view.remove": { "message": "移除" },
|
||||||
"app.skin-site.profile-view.removed": { "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": {
|
"app.skin-site.profile-view.select-hint": {
|
||||||
"message": "选择角色以预览和修改其材质。"
|
"message": "选择角色以预览和修改其材质。"
|
||||||
},
|
},
|
||||||
|
"app.skin-site.profile-view.set-default": { "message": "设为默认" },
|
||||||
"app.skin-site.profile-view.signed-out": {
|
"app.skin-site.profile-view.signed-out": {
|
||||||
"message": "登录后管理你的角色。"
|
"message": "登录后管理你的角色。"
|
||||||
},
|
},
|
||||||
"app.skin-site.profile-view.skin": { "message": "皮肤" },
|
"app.skin-site.profile-view.skin": { "message": "皮肤" },
|
||||||
"app.skin-site.profile-view.title": { "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.upload": { "message": "上传" },
|
||||||
"app.skin-site.profile-view.uploaded": { "message": "材质已更新。" },
|
"app.skin-site.profile-view.uploaded": { "message": "材质已更新。" },
|
||||||
"app.skin-site.profile.avatar": { "message": "头像" },
|
"app.skin-site.profile.avatar": { "message": "头像" },
|
||||||
@@ -13026,6 +13045,7 @@
|
|||||||
"app.skin-site.admin.users.status": { "message": "状态" },
|
"app.skin-site.admin.users.status": { "message": "状态" },
|
||||||
"app.skin-site.admin.users.status-banned": { "message": "已封禁" },
|
"app.skin-site.admin.users.status-banned": { "message": "已封禁" },
|
||||||
"app.skin-site.admin.users.status-normal": { "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.upload": { "message": "上传" },
|
||||||
"app.skin-site.admin.mods.select-mode": { "message": "选择多个" },
|
"app.skin-site.admin.mods.select-mode": { "message": "选择多个" },
|
||||||
"app.skin-site.admin.mods.exit-select": { "message": "完成选择" },
|
"app.skin-site.admin.mods.exit-select": { "message": "完成选择" },
|
||||||
@@ -13337,6 +13357,30 @@
|
|||||||
"app.skin-site.wiki.revision": { "message": "修订 #{id}" },
|
"app.skin-site.wiki.revision": { "message": "修订 #{id}" },
|
||||||
"app.skin-site.wiki.categories": { "message": "分类" },
|
"app.skin-site.wiki.categories": { "message": "分类" },
|
||||||
"app.skin-site.wiki.back": { "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": {
|
"app.skin-site.home.donation.thanks": {
|
||||||
"message": "感谢每一位支持 StarLight 运行的朋友。"
|
"message": "感谢每一位支持 StarLight 运行的朋友。"
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -39,6 +39,7 @@ import SkinListSkeleton from '@/components/ui/skin/SkinListSkeleton.vue'
|
|||||||
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
import VirtualSkinSectionList from '@/components/ui/skin/VirtualSkinSectionList.vue'
|
||||||
import {
|
import {
|
||||||
openSkinSiteLogin,
|
openSkinSiteLogin,
|
||||||
|
reloadSkinSitePlayers,
|
||||||
requestSkinSitePlayers,
|
requestSkinSitePlayers,
|
||||||
requestSkinSiteSkinUpdate,
|
requestSkinSiteSkinUpdate,
|
||||||
selectedSkinSitePlayerId,
|
selectedSkinSitePlayerId,
|
||||||
@@ -214,6 +215,18 @@ const messages = defineMessages({
|
|||||||
id: 'app.skins.skin-site-account.empty-description',
|
id: 'app.skins.skin-site-account.empty-description',
|
||||||
defaultMessage: 'This skin site account does not have an available player profile yet.',
|
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: {
|
offlineCompatibility: {
|
||||||
id: 'app.skins.offline-account.compatibility',
|
id: 'app.skins.offline-account.compatibility',
|
||||||
defaultMessage:
|
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 {
|
function getBakedSkinTextures(skin: Skin): RenderResult | undefined {
|
||||||
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
const key = `${skin.texture_key}+${skin.variant}+${skin.cape_id ?? 'no-cape'}`
|
||||||
return skinBlobUrlMap.get(key)
|
return skinBlobUrlMap.get(key)
|
||||||
@@ -1436,22 +1480,26 @@ await loadSkins()
|
|||||||
<h1 class="text-3xl font-extrabold m-0">
|
<h1 class="text-3xl font-extrabold m-0">
|
||||||
{{
|
{{
|
||||||
formatMessage(
|
formatMessage(
|
||||||
skinSiteUser
|
!skinSiteUser
|
||||||
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
? messages.signInTitle
|
||||||
|
: skinSitePlayersStatus === 'error'
|
||||||
|
? messages.errorSkinSitePlayersTitle
|
||||||
|
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||||
? messages.loadingSkinSitePlayersTitle
|
? messages.loadingSkinSitePlayersTitle
|
||||||
: messages.noSkinSitePlayersTitle
|
: messages.noSkinSitePlayersTitle,
|
||||||
: messages.signInTitle,
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</h1>
|
</h1>
|
||||||
<p class="text-lg m-0">
|
<p class="text-lg m-0">
|
||||||
{{
|
{{
|
||||||
formatMessage(
|
formatMessage(
|
||||||
skinSiteUser
|
!skinSiteUser
|
||||||
? skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
? messages.signInDescription
|
||||||
|
: skinSitePlayersStatus === 'error'
|
||||||
|
? messages.errorSkinSitePlayersDescription
|
||||||
|
: skinSitePlayersStatus === 'checking' || skinSitePlayersStatus === 'idle'
|
||||||
? messages.loadingSkinSitePlayersDescription
|
? messages.loadingSkinSitePlayersDescription
|
||||||
: messages.noSkinSitePlayersDescription
|
: messages.noSkinSitePlayersDescription,
|
||||||
: messages.signInDescription,
|
|
||||||
)
|
)
|
||||||
}}
|
}}
|
||||||
</p>
|
</p>
|
||||||
@@ -1462,6 +1510,11 @@ await loadSkins()
|
|||||||
"
|
"
|
||||||
class="h-8 w-8 animate-spin text-brand"
|
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">
|
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||||
<button @click="loginToSkinSite">
|
<button @click="loginToSkinSite">
|
||||||
<LogInIcon />
|
<LogInIcon />
|
||||||
|
|||||||
@@ -174,6 +174,7 @@
|
|||||||
"terracotta:default",
|
"terracotta:default",
|
||||||
"multiplayer:default",
|
"multiplayer:default",
|
||||||
"servers:default",
|
"servers:default",
|
||||||
"planet-minecraft:default"
|
"planet-minecraft:default",
|
||||||
|
"fonts:default"
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
@@ -126,7 +126,7 @@
|
|||||||
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
|
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
|
||||||
"worker-src": "'self' blob:",
|
"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"
|
"media-src": "'self' data: https://*.githubusercontent.com"
|
||||||
},
|
},
|
||||||
"csp": {
|
"csp": {
|
||||||
@@ -136,7 +136,7 @@
|
|||||||
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
"img-src": "https: 'unsafe-inline' 'self' asset: http://asset.localhost http://textures.minecraft.net blob: data:",
|
||||||
"style-src": "'unsafe-inline' 'self'",
|
"style-src": "'unsafe-inline' 'self'",
|
||||||
"script-src": "'self' 'unsafe-eval' 'wasm-unsafe-eval'",
|
"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"
|
"media-src": "'self' data: https://*.githubusercontent.com"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -728,7 +728,23 @@ async fn instance_and_root(
|
|||||||
.ok_or_else(|| {
|
.ok_or_else(|| {
|
||||||
crate::ErrorKind::InputError("Unknown instance".to_string())
|
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))
|
Ok((instance, root))
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -746,13 +762,6 @@ async fn eligibility_for(
|
|||||||
if instance.is_direct_linked() {
|
if instance.is_direct_linked() {
|
||||||
return Ok(InstanceBackupEligibility::DirectLinkedInstance);
|
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 {
|
let metadata = match tokio::fs::symlink_metadata(&root).await {
|
||||||
Ok(metadata) => metadata,
|
Ok(metadata) => metadata,
|
||||||
Err(error) if error.kind() == std::io::ErrorKind::NotFound => {
|
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(
|
async fn scan_snapshot_attempt(
|
||||||
root: &Path,
|
root: &Path,
|
||||||
repository: &Path,
|
repository: &Path,
|
||||||
@@ -1680,6 +1725,8 @@ async fn scan_snapshot_attempt(
|
|||||||
None,
|
None,
|
||||||
)
|
)
|
||||||
.await;
|
.await;
|
||||||
|
let exclusions = with_shared_root_exclusions(root, exclusions);
|
||||||
|
let exclusions = exclusions.as_slice();
|
||||||
let total_bytes = measure_snapshot(root, exclusions, cancellation).await?;
|
let total_bytes = measure_snapshot(root, exclusions, cancellation).await?;
|
||||||
emit_backup_progress(
|
emit_backup_progress(
|
||||||
operation_id,
|
operation_id,
|
||||||
@@ -2642,13 +2689,10 @@ pub(crate) async fn ensure_backup_eligible_edit(
|
|||||||
instance_id: &str,
|
instance_id: &str,
|
||||||
patch: &crate::state::EditInstance,
|
patch: &crate::state::EditInstance,
|
||||||
) -> crate::Result<()> {
|
) -> crate::Result<()> {
|
||||||
|
// Only symlink instances stay ineligible; switching to an external game
|
||||||
|
// directory is fine because the backup root follows it.
|
||||||
let becomes_ineligible =
|
let becomes_ineligible =
|
||||||
patch.symlink_target.as_ref().is_some_and(Option::is_some)
|
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());
|
|
||||||
if !becomes_ineligible {
|
if !becomes_ineligible {
|
||||||
return Ok(());
|
return Ok(());
|
||||||
}
|
}
|
||||||
@@ -3020,11 +3064,16 @@ async fn build_restore_plan(
|
|||||||
.into());
|
.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(
|
let current = scan_restore_entries(
|
||||||
root,
|
root,
|
||||||
whole_root,
|
whole_root,
|
||||||
&selected_roots,
|
&selected_roots,
|
||||||
&exclusions,
|
&scan_exclusions,
|
||||||
progress,
|
progress,
|
||||||
)
|
)
|
||||||
.await?;
|
.await?;
|
||||||
|
|||||||
@@ -629,10 +629,26 @@ pub async fn login_skin_site_player(
|
|||||||
.send()
|
.send()
|
||||||
.await?;
|
.await?;
|
||||||
if !response.status().is_success() {
|
if !response.status().is_success() {
|
||||||
return Err(ErrorKind::InputError(format!(
|
let status = response.status().as_u16();
|
||||||
"皮肤站玩家登录失败(HTTP {}),请检查登录状态后重试",
|
// 皮肤站的错误响应体形如 {"error": "...", "errorMessage": "..."},
|
||||||
response.status().as_u16()
|
// 优先透出服务端给出的具体原因(例如正版玩家需用正版账号登录)。
|
||||||
))
|
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());
|
.as_error());
|
||||||
}
|
}
|
||||||
let login = response.json::<Envelope>().await?.payload;
|
let login = response.json::<Envelope>().await?.payload;
|
||||||
|
|||||||
Reference in new issue
Block a user