feat: 嵌入 StarLight 皮肤站并同步登录状态
This commit is contained in:
@ -758,9 +758,9 @@ const messages = defineMessages({
|
||||
id: 'app.account.signed-in-as',
|
||||
defaultMessage: 'Signed in as',
|
||||
},
|
||||
playingAs: {
|
||||
id: 'app.minecraft.playing-as',
|
||||
defaultMessage: 'Playing as',
|
||||
userInformation: {
|
||||
id: 'app.minecraft.user-information',
|
||||
defaultMessage: 'User information',
|
||||
},
|
||||
collapseSidebar: {
|
||||
id: 'app.sidebar.collapse',
|
||||
@ -2469,7 +2469,7 @@ provideAppUpdateDownloadProgress(appUpdateDownload)
|
||||
<div class="sidebar-default-content hidden" :class="{ 'sidebar-enabled': sidebarVisible }">
|
||||
<div class="p-4 border-0 border-b-[1px] border-[--brand-gradient-border] border-solid">
|
||||
<h3 class="text-base text-primary font-medium m-0">
|
||||
{{ formatMessage(messages.playingAs) }}
|
||||
{{ formatMessage(messages.userInformation) }}
|
||||
</h3>
|
||||
<suspense>
|
||||
<AccountsCard ref="accounts" />
|
||||
|
||||
@ -1,4 +1,23 @@
|
||||
<template>
|
||||
<div v-if="skinSiteUser" class="flex items-center gap-3 mt-2 p-3 rounded-xl bg-button-bg">
|
||||
<Avatar :src="axolotlLogo" size="36px" />
|
||||
<div class="flex min-w-0 flex-col">
|
||||
<span class="truncate font-semibold text-contrast">{{ skinSiteUser.username }}</span>
|
||||
<span class="text-secondary text-xs">{{ formatMessage(messages.skinSiteSignedIn) }}</span>
|
||||
</div>
|
||||
</div>
|
||||
<p v-else-if="skinSiteStatus === 'checking'" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.skinSiteChecking) }}
|
||||
</p>
|
||||
<p v-else-if="skinSiteStatus === 'error'" class="text-sm text-secondary">
|
||||
{{ formatMessage(messages.skinSiteSyncError) }}
|
||||
</p>
|
||||
<ButtonStyled v-if="accounts.length > 0 && !offline && !skinSiteUser" color="brand">
|
||||
<button class="mt-2 w-full" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||
<LogInIcon />
|
||||
{{ formatMessage(messages.signInToStarlight) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<div
|
||||
v-if="offline"
|
||||
class="flex flex-col gap-1 bg-highlight-orange border border-solid border-orange rounded-xl p-3 mt-2"
|
||||
@ -17,18 +36,24 @@
|
||||
v-if="accounts.length === 0"
|
||||
class="flex flex-col gap-3 bg-button-bg border border-solid border-surface-5 rounded-xl p-3 mt-2"
|
||||
>
|
||||
<span>{{ formatMessage(messages.notSignedIn) }}</span>
|
||||
<ButtonStyled v-if="!offline" color="brand">
|
||||
<button color="primary" :disabled="loginDisabled" @click="login()">
|
||||
<span v-if="skinSiteStatus === 'signed-out'">{{ formatMessage(messages.notSignedIn) }}</span>
|
||||
<ButtonStyled v-if="!offline && !skinSiteUser" color="brand">
|
||||
<button color="primary" :disabled="loginDisabled" @click="goToSkinSiteLogin()">
|
||||
<LogInIcon v-if="!loginDisabled" />
|
||||
<SpinnerIcon v-else class="animate-spin" />
|
||||
{{ formatMessage(messages.signInToMinecraft) }}
|
||||
{{ formatMessage(messages.signInToStarlight) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="!offline && skinSiteUser">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="!offline">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<button :disabled="loginDisabled" @click="login()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@ -137,15 +162,15 @@
|
||||
</template>
|
||||
<div class="flex flex-col gap-2 px-2 pt-2">
|
||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||
<button :disabled="loginDisabled" @click="login()">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||
{{ formatMessage(messages.addSkinGameAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
<ButtonStyled v-if="accounts.length > 0 && !offline" class="w-full">
|
||||
<button :disabled="loginDisabled" @click="showYggdrasilAccountModal()">
|
||||
<button :disabled="loginDisabled" @click="login()">
|
||||
<PlusIcon />
|
||||
{{ formatMessage(messages.addThirdPartyAccount) }}
|
||||
{{ formatMessage(messages.addMicrosoftAccount) }}
|
||||
</button>
|
||||
</ButtonStyled>
|
||||
</div>
|
||||
@ -262,6 +287,7 @@ import {
|
||||
Accordion,
|
||||
Avatar,
|
||||
ButtonStyled,
|
||||
Checkbox,
|
||||
commonMessages,
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
@ -272,12 +298,13 @@ import { useQueryClient } from '@tanstack/vue-query'
|
||||
import { listen } from '@tauri-apps/api/event'
|
||||
import type { Ref } from 'vue'
|
||||
import { computed, nextTick, onUnmounted, ref, watch } from 'vue'
|
||||
import { useRoute } from 'vue-router'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import axolotlLogo from '@/assets/netherstar.png'
|
||||
import steveSkinTexture from '@/assets/skins/steve.png?inline'
|
||||
import MinecraftLoginModal from '@/components/ui/MinecraftLoginModal.vue'
|
||||
import ModalWrapper from '@/components/ui/modal/ModalWrapper.vue'
|
||||
import { openSkinSiteLogin, skinSiteStatus, skinSiteUser } from '@/composables/skin-site-session'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { compareMinecraftAccounts } from '@/helpers/accounts'
|
||||
import {
|
||||
@ -298,12 +325,22 @@ import { getPlayerHeadUrl } from '@/helpers/rendering/batch-skin-renderer.ts'
|
||||
import type { Skin } from '@/helpers/skins'
|
||||
import { get_available_skins } from '@/helpers/skins'
|
||||
import { handleSevereError } from '@/store/error.js'
|
||||
import { useTheming } from '@/store/state'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const { handleError } = injectNotificationManager()
|
||||
const { offline, refreshBrowserOffline } = useNetworkStatus()
|
||||
const queryClient = useQueryClient()
|
||||
const route = useRoute()
|
||||
const router = useRouter()
|
||||
const themeStore = useTheming()
|
||||
|
||||
async function goToSkinSiteLogin() {
|
||||
openSkinSiteLogin()
|
||||
// The login action must reveal the iframe even if Minimal Home was selected.
|
||||
themeStore.homeLayout = 'standard'
|
||||
await router.push('/').catch(handleError)
|
||||
}
|
||||
const refreshingNetwork = ref(false)
|
||||
|
||||
/**
|
||||
@ -897,6 +934,22 @@ onUnmounted(() => {
|
||||
})
|
||||
|
||||
const messages = defineMessages({
|
||||
skinSiteSignedIn: {
|
||||
id: 'minecraft-account.skin-site.signed-in',
|
||||
defaultMessage: 'Signed in to StarLight Skin Site',
|
||||
},
|
||||
skinSiteChecking: {
|
||||
id: 'minecraft-account.skin-site.checking',
|
||||
defaultMessage: 'Checking skin site session…',
|
||||
},
|
||||
skinSiteSyncError: {
|
||||
id: 'minecraft-account.skin-site.sync-error',
|
||||
defaultMessage: 'Could not verify the skin site session. Retrying automatically.',
|
||||
},
|
||||
addSkinGameAccount: {
|
||||
id: 'minecraft-account.add-skin-game-account',
|
||||
defaultMessage: 'Add skin site game account',
|
||||
},
|
||||
offlineMode: {
|
||||
id: 'minecraft-account.offline-mode',
|
||||
defaultMessage: 'Offline mode',
|
||||
@ -916,11 +969,11 @@ const messages = defineMessages({
|
||||
},
|
||||
addMicrosoftAccount: {
|
||||
id: 'minecraft-account.add-microsoft-account',
|
||||
defaultMessage: 'Add Microsoft account',
|
||||
defaultMessage: 'Add your own Minecraft account',
|
||||
},
|
||||
addThirdPartyAccount: {
|
||||
id: 'minecraft-account.add-third-party-account',
|
||||
defaultMessage: 'Add third-party account',
|
||||
signInToStarlight: {
|
||||
id: 'minecraft-account.sign-in-starlight',
|
||||
defaultMessage: 'Sign in to StarLight Skin Site',
|
||||
},
|
||||
thirdPartyAccount: {
|
||||
id: 'minecraft-account.third-party-account',
|
||||
@ -1064,10 +1117,6 @@ const messages = defineMessages({
|
||||
id: 'minecraft-account.label',
|
||||
defaultMessage: 'Minecraft account',
|
||||
},
|
||||
signInToMinecraft: {
|
||||
id: 'minecraft-account.sign-in',
|
||||
defaultMessage: 'Sign in to Minecraft',
|
||||
},
|
||||
loginTrouble: {
|
||||
id: 'minecraft-login.trouble',
|
||||
defaultMessage: 'Having trouble?',
|
||||
|
||||
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
58
apps/app-frontend/src/components/ui/SkinSiteFrame.vue
Normal file
@ -0,0 +1,58 @@
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { onMounted, onUnmounted, ref } from 'vue'
|
||||
|
||||
import {
|
||||
receiveSkinSiteSession,
|
||||
resetSkinSiteSession,
|
||||
SKIN_SITE_ORIGIN,
|
||||
skinSiteFrameUrl,
|
||||
} from '@/composables/skin-site-session'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
const messages = defineMessages({
|
||||
frameTitle: {
|
||||
id: 'app.starlight-skin.frame-title',
|
||||
defaultMessage: 'StarLight Skin Site',
|
||||
},
|
||||
})
|
||||
|
||||
const frame = ref<HTMLIFrameElement>()
|
||||
let lastMessage = 0
|
||||
let expiryTimer: ReturnType<typeof setInterval> | undefined
|
||||
|
||||
function connect() {
|
||||
frame.value?.contentWindow?.postMessage(
|
||||
{ type: 'starlight-skin-session-connect' },
|
||||
SKIN_SITE_ORIGIN,
|
||||
)
|
||||
}
|
||||
|
||||
function receive(event: MessageEvent) {
|
||||
if (receiveSkinSiteSession(event, frame.value?.contentWindow ?? null)) lastMessage = Date.now()
|
||||
}
|
||||
|
||||
onMounted(() => {
|
||||
window.addEventListener('message', receive)
|
||||
connect()
|
||||
expiryTimer = setInterval(() => {
|
||||
if (lastMessage && Date.now() - lastMessage > 90_000) resetSkinSiteSession()
|
||||
}, 10_000)
|
||||
})
|
||||
|
||||
onUnmounted(() => {
|
||||
window.removeEventListener('message', receive)
|
||||
clearInterval(expiryTimer)
|
||||
resetSkinSiteSession()
|
||||
})
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<iframe
|
||||
ref="frame"
|
||||
:src="skinSiteFrameUrl"
|
||||
:title="formatMessage(messages.frameTitle)"
|
||||
class="block h-full min-h-0 w-full border-0"
|
||||
@load="connect"
|
||||
/>
|
||||
</template>
|
||||
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
51
apps/app-frontend/src/composables/skin-site-session.test.ts
Normal file
@ -0,0 +1,51 @@
|
||||
import assert from 'node:assert/strict'
|
||||
import test from 'node:test'
|
||||
|
||||
import {
|
||||
openSkinSiteLogin,
|
||||
receiveSkinSiteSession,
|
||||
resetSkinSiteSession,
|
||||
SKIN_SITE_ORIGIN,
|
||||
skinSiteFrameUrl,
|
||||
skinSiteStatus,
|
||||
skinSiteUser,
|
||||
} from './skin-site-session.ts'
|
||||
|
||||
test('skin site session accepts only the embedded origin and window, and redirects after verification', () => {
|
||||
const frame = {} as Window
|
||||
const message = (status: string, user?: unknown) =>
|
||||
({
|
||||
origin: SKIN_SITE_ORIGIN,
|
||||
source: frame,
|
||||
data: { type: 'starlight-skin-session', status, user },
|
||||
}) as MessageEvent
|
||||
resetSkinSiteSession()
|
||||
openSkinSiteLogin()
|
||||
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/login`)
|
||||
const signedIn = message('signed-in', {
|
||||
uuid: 'test-user',
|
||||
username: '测试用户',
|
||||
jwt: 'must-not-copy',
|
||||
})
|
||||
assert.equal(
|
||||
receiveSkinSiteSession({ ...signedIn, origin: 'https://evil.example' } as MessageEvent, frame),
|
||||
false,
|
||||
)
|
||||
assert.equal(receiveSkinSiteSession(signedIn, {} as Window), false)
|
||||
assert.equal(receiveSkinSiteSession(message('signed-in', {}), frame), false)
|
||||
assert.equal(skinSiteUser.value, null)
|
||||
assert.equal(receiveSkinSiteSession(signedIn, frame), true)
|
||||
assert.deepEqual(skinSiteUser.value, { uuid: 'test-user', username: '测试用户' })
|
||||
assert.equal(skinSiteStatus.value, 'signed-in')
|
||||
assert.equal(skinSiteFrameUrl.value, `${SKIN_SITE_ORIGIN}/profile`)
|
||||
assert.equal(receiveSkinSiteSession(message('checking'), frame), true)
|
||||
assert.equal(skinSiteUser.value, null)
|
||||
receiveSkinSiteSession(message('signed-in', { uuid: 'second', username: '另一个账号' }), frame)
|
||||
assert.deepEqual(skinSiteUser.value, { uuid: 'second', username: '另一个账号' })
|
||||
receiveSkinSiteSession(message('signed-out'), frame)
|
||||
assert.equal(skinSiteUser.value, null)
|
||||
assert.equal(skinSiteStatus.value, 'signed-out')
|
||||
receiveSkinSiteSession(message('error'), frame)
|
||||
assert.equal(skinSiteStatus.value, 'error')
|
||||
resetSkinSiteSession()
|
||||
})
|
||||
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
48
apps/app-frontend/src/composables/skin-site-session.ts
Normal file
@ -0,0 +1,48 @@
|
||||
import { readonly, ref } from 'vue'
|
||||
|
||||
export const SKIN_SITE_ORIGIN = 'https://skin.starlight.cool'
|
||||
export type SkinSiteUser = { uuid: string; username: string }
|
||||
export type SkinSiteStatus = 'checking' | 'signed-out' | 'signed-in' | 'error'
|
||||
|
||||
const user = ref<SkinSiteUser | null>(null)
|
||||
const status = ref<SkinSiteStatus>('signed-out')
|
||||
const frameUrl = ref(`${SKIN_SITE_ORIGIN}/`)
|
||||
|
||||
export const skinSiteUser = readonly(user)
|
||||
export const skinSiteStatus = readonly(status)
|
||||
export const skinSiteFrameUrl = readonly(frameUrl)
|
||||
|
||||
export function openSkinSiteLogin() {
|
||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/login`
|
||||
}
|
||||
|
||||
export function resetSkinSiteSession() {
|
||||
user.value = null
|
||||
status.value = 'signed-out'
|
||||
}
|
||||
|
||||
export function receiveSkinSiteSession(event: MessageEvent, frame: Window | null) {
|
||||
if (!frame || event.source !== frame || event.origin !== SKIN_SITE_ORIGIN) return false
|
||||
const data = event.data
|
||||
if (!data || data.type !== 'starlight-skin-session') return false
|
||||
if (!['checking', 'signed-out', 'signed-in', 'error'].includes(data.status)) return false
|
||||
if (data.status === 'signed-in') {
|
||||
if (
|
||||
!data.user ||
|
||||
typeof data.user.uuid !== 'string' ||
|
||||
typeof data.user.username !== 'string' ||
|
||||
!data.user.uuid ||
|
||||
!data.user.username ||
|
||||
data.user.username.length > 256
|
||||
)
|
||||
return false
|
||||
user.value = { uuid: data.user.uuid, username: data.user.username }
|
||||
if ([`${SKIN_SITE_ORIGIN}/`, `${SKIN_SITE_ORIGIN}/login`].includes(frameUrl.value)) {
|
||||
frameUrl.value = `${SKIN_SITE_ORIGIN}/profile`
|
||||
}
|
||||
} else {
|
||||
user.value = null
|
||||
}
|
||||
status.value = data.status
|
||||
return true
|
||||
}
|
||||
@ -1,4 +1,8 @@
|
||||
{
|
||||
"minecraft-account.skin-site.signed-in": { "message": "Signed in to StarLight Skin Site" },
|
||||
"minecraft-account.skin-site.checking": { "message": "Checking skin site session…" },
|
||||
"minecraft-account.skin-site.sync-error": { "message": "Could not verify the skin site session. Retrying automatically." },
|
||||
"minecraft-account.add-skin-game-account": { "message": "Add skin site game account" },
|
||||
"app.easteregg.color-mine.title": {
|
||||
"message": "Starlight Mine: Chromatic Realms"
|
||||
},
|
||||
@ -5279,8 +5283,8 @@
|
||||
"app.minecraft-crash.view-mod-changes": {
|
||||
"message": "View Mod changes"
|
||||
},
|
||||
"app.minecraft.playing-as": {
|
||||
"message": "Playing as"
|
||||
"app.minecraft.user-information": {
|
||||
"message": "User information"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "Content required"
|
||||
@ -10074,13 +10078,13 @@
|
||||
"message": "World is in use"
|
||||
},
|
||||
"minecraft-account.add-microsoft-account": {
|
||||
"message": "Add Microsoft account"
|
||||
"message": "Add your own Minecraft account"
|
||||
},
|
||||
"minecraft-account.add-offline-account": {
|
||||
"message": "Add offline account"
|
||||
},
|
||||
"minecraft-account.add-third-party-account": {
|
||||
"message": "Add third-party account"
|
||||
"minecraft-account.sign-in-starlight": {
|
||||
"message": "Sign in to StarLight Skin Site"
|
||||
},
|
||||
"minecraft-account.copy-uuid": {
|
||||
"message": "Copy UUID"
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
{
|
||||
"minecraft-account.skin-site.signed-in": { "message": "已登录 StarLight皮肤站" },
|
||||
"minecraft-account.skin-site.checking": { "message": "正在验证皮肤站登录状态…" },
|
||||
"minecraft-account.skin-site.sync-error": { "message": "暂时无法验证皮肤站登录状态,将自动重试。" },
|
||||
"minecraft-account.add-skin-game-account": { "message": "添加皮肤站游戏账号" },
|
||||
"app.easteregg.color-mine.title": {
|
||||
"message": "星光矿井:彩域"
|
||||
},
|
||||
@ -5369,8 +5373,8 @@
|
||||
"app.minecraft-crash.view-mod-changes": {
|
||||
"message": "查看 Mod 变化"
|
||||
},
|
||||
"app.minecraft.playing-as": {
|
||||
"message": "当前游玩账号:"
|
||||
"app.minecraft.user-information": {
|
||||
"message": "用户信息"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "需求内容"
|
||||
@ -10155,13 +10159,13 @@
|
||||
"message": "世界正在使用中"
|
||||
},
|
||||
"minecraft-account.add-microsoft-account": {
|
||||
"message": "添加 Microsoft 账户"
|
||||
"message": "添加自己的正版账号"
|
||||
},
|
||||
"minecraft-account.add-offline-account": {
|
||||
"message": "添加离线账户"
|
||||
},
|
||||
"minecraft-account.add-third-party-account": {
|
||||
"message": "添加第三方账号"
|
||||
"minecraft-account.sign-in-starlight": {
|
||||
"message": "登录 StarLight皮肤站"
|
||||
},
|
||||
"minecraft-account.copy-uuid": {
|
||||
"message": "复制 UUID"
|
||||
|
||||
@ -1,4 +1,8 @@
|
||||
{
|
||||
"minecraft-account.skin-site.signed-in": { "message": "已登入 StarLight皮膚站" },
|
||||
"minecraft-account.skin-site.checking": { "message": "正在驗證皮膚站登入狀態…" },
|
||||
"minecraft-account.skin-site.sync-error": { "message": "暫時無法驗證皮膚站登入狀態,將自動重試。" },
|
||||
"minecraft-account.add-skin-game-account": { "message": "新增皮膚站遊戲帳號" },
|
||||
"app.easteregg.color-mine.title": {
|
||||
"message": "星光礦井:彩域"
|
||||
},
|
||||
@ -5120,8 +5124,8 @@
|
||||
"app.minecraft-crash.view-mod-changes": {
|
||||
"message": "查看 Mod 變更"
|
||||
},
|
||||
"app.minecraft.playing-as": {
|
||||
"message": "當前遊玩帳號:"
|
||||
"app.minecraft.user-information": {
|
||||
"message": "使用者資訊"
|
||||
},
|
||||
"app.modal.install-to-play.content-required": {
|
||||
"message": "所需內容"
|
||||
@ -9801,13 +9805,13 @@
|
||||
"message": "世界正在使用中"
|
||||
},
|
||||
"minecraft-account.add-microsoft-account": {
|
||||
"message": "新增 Microsoft 帳號"
|
||||
"message": "新增自己的正版帳號"
|
||||
},
|
||||
"minecraft-account.add-offline-account": {
|
||||
"message": "新增離線帳號"
|
||||
},
|
||||
"minecraft-account.add-third-party-account": {
|
||||
"message": "新增第三方帳號"
|
||||
"minecraft-account.sign-in-starlight": {
|
||||
"message": "登入 StarLight皮膚站"
|
||||
},
|
||||
"minecraft-account.copy-uuid": {
|
||||
"message": "複製 UUID"
|
||||
|
||||
@ -1,14 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import {
|
||||
CheckIcon,
|
||||
GridIcon,
|
||||
LayoutTemplateIcon,
|
||||
MinimizeIcon,
|
||||
MoveIcon,
|
||||
PencilIcon,
|
||||
PlusIcon,
|
||||
RotateCounterClockwiseIcon,
|
||||
} from '@modrinth/assets'
|
||||
import { LayoutTemplateIcon, MinimizeIcon } from '@modrinth/assets'
|
||||
import {
|
||||
defineMessages,
|
||||
injectNotificationManager,
|
||||
@ -18,20 +9,14 @@ import {
|
||||
import { computed, onUnmounted, ref } from 'vue'
|
||||
import { useRoute, useRouter } from 'vue-router'
|
||||
|
||||
import {
|
||||
createDefaultHomeDashboard,
|
||||
createHomeDashboardSaveQueue,
|
||||
type HomeDashboardConfig,
|
||||
normalizeHomeDashboard,
|
||||
} from '@/components/home/home-dashboard'
|
||||
import { getActivePlayerName } from '@/components/home/home-utils'
|
||||
import HomeDailyChallenge from '@/components/home/HomeDailyChallenge.vue'
|
||||
import HomeDashboard from '@/components/home/HomeDashboard.vue'
|
||||
import HomeInstancePickerModal from '@/components/home/HomeInstancePickerModal.vue'
|
||||
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
||||
import HomeMinecraftNews from '@/components/home/HomeMinecraftNews.vue'
|
||||
import HomeMinimal from '@/components/home/HomeMinimal.vue'
|
||||
import HomeLaunchProgress from '@/components/home/HomeLaunchProgress.vue'
|
||||
import HomePlayInsights from '@/components/home/HomePlayInsights.vue'
|
||||
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||
import { useNetworkStatus } from '@/composables/useNetworkStatus'
|
||||
import { get_default_user, users } from '@/helpers/auth'
|
||||
import { DIRECT_LINKS_SYNCED_EVENT } from '@/helpers/direct-link-sync'
|
||||
@ -41,7 +26,7 @@ import { get as getSettings, set as setSettings } from '@/helpers/settings'
|
||||
import type { GameInstance } from '@/helpers/types'
|
||||
import { useBreadcrumbs } from '@/store/breadcrumbs'
|
||||
import { useTheming } from '@/store/state'
|
||||
import type { FeatureFlag, HomeLayout } from '@/store/theme'
|
||||
import type { HomeLayout } from '@/store/theme'
|
||||
|
||||
const { handleError } = injectNotificationManager()
|
||||
const route = useRoute()
|
||||
@ -66,58 +51,15 @@ const messages = defineMessages({
|
||||
id: 'app.home.layout.toggle',
|
||||
defaultMessage: 'Minimal Home',
|
||||
},
|
||||
switchToGridWidgetLayout: {
|
||||
id: 'app.home.widgets.layout.switch-to-grid',
|
||||
defaultMessage: 'Switch to grid widget layout',
|
||||
},
|
||||
switchToFreeWidgetLayout: {
|
||||
id: 'app.home.widgets.layout.switch-to-free',
|
||||
defaultMessage: 'Switch to free widget layout',
|
||||
},
|
||||
widgetLayoutToggle: {
|
||||
id: 'app.home.widgets.layout.toggle',
|
||||
defaultMessage: 'Widget layout mode',
|
||||
},
|
||||
resetWidgets: {
|
||||
id: 'app.home.widgets.reset-confirm',
|
||||
defaultMessage: 'Restore the default widget layout?',
|
||||
},
|
||||
customizeWidgets: {
|
||||
id: 'app.home.widgets.customize',
|
||||
defaultMessage: 'Customize widgets',
|
||||
},
|
||||
doneEditing: { id: 'app.home.widgets.done', defaultMessage: 'Finish editing' },
|
||||
addWidget: { id: 'app.home.widgets.add', defaultMessage: 'Add widget' },
|
||||
resetWidgetLayout: {
|
||||
id: 'app.home.widgets.reset',
|
||||
defaultMessage: 'Restore default widgets',
|
||||
},
|
||||
})
|
||||
|
||||
const recentProjectsInHomeFlag: FeatureFlag = 'worlds_in_home'
|
||||
|
||||
breadcrumbs.setRootContext({ name: formatMessage(messages.home), link: route.path })
|
||||
|
||||
const instances = ref<GameInstance[]>([])
|
||||
const playerName = ref<string | null>(null)
|
||||
const dashboardConfig = ref<HomeDashboardConfig | null>(null)
|
||||
const dashboard = ref<InstanceType<typeof HomeDashboard>>()
|
||||
const dashboardEditing = ref(false)
|
||||
const instancePicker = ref<InstanceType<typeof HomeInstancePickerModal>>()
|
||||
const isMinimal = computed(() => themeStore.homeLayout === 'minimal')
|
||||
const isFreeWidgetLayout = computed(() => dashboardConfig.value?.layout === 'free')
|
||||
const switchingLayout = ref(false)
|
||||
const dashboardSaveQueue = createHomeDashboardSaveQueue(
|
||||
async (config) => {
|
||||
const settings = await getSettings()
|
||||
settings.home_widgets = config
|
||||
await setSettings(settings)
|
||||
},
|
||||
(config) => {
|
||||
dashboardConfig.value = config
|
||||
},
|
||||
handleError,
|
||||
)
|
||||
const floatingControlsStyle = computed(() => ({
|
||||
bottom: themeStore.getFeatureFlag('page_path') ? '3.5rem' : '1rem',
|
||||
right: `calc(${pageContext.floatingActionBarOffsets?.right.value ?? '0px'} + 1rem)`,
|
||||
@ -162,38 +104,6 @@ async function fetchPlayerName() {
|
||||
playerName.value = getActivePlayerName(selectedUser, accounts)
|
||||
}
|
||||
|
||||
async function loadDashboardConfig() {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
const normalized = normalizeHomeDashboard(settings.home_widgets)
|
||||
if (normalized) {
|
||||
dashboardConfig.value = normalized
|
||||
return
|
||||
}
|
||||
|
||||
const config = createDefaultHomeDashboard(themeStore.getFeatureFlag(recentProjectsInHomeFlag))
|
||||
dashboardConfig.value = config
|
||||
settings.home_widgets = config
|
||||
await setSettings(settings)
|
||||
} catch (error) {
|
||||
dashboardConfig.value = createDefaultHomeDashboard(
|
||||
themeStore.getFeatureFlag(recentProjectsInHomeFlag),
|
||||
)
|
||||
handleError(error)
|
||||
}
|
||||
}
|
||||
|
||||
function updateDashboardConfig(config: HomeDashboardConfig) {
|
||||
const previous = dashboardConfig.value ?? config
|
||||
dashboardConfig.value = config
|
||||
void dashboardSaveQueue.enqueue(config, previous)
|
||||
}
|
||||
|
||||
function resetDashboardConfig() {
|
||||
if (!window.confirm(formatMessage(messages.resetWidgets))) return
|
||||
updateDashboardConfig(createDefaultHomeDashboard())
|
||||
}
|
||||
|
||||
async function selectMinimalInstance(instance: GameInstance) {
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
@ -214,10 +124,8 @@ async function toggleHomeLayout() {
|
||||
|
||||
const previousLayout = themeStore.homeLayout
|
||||
const nextLayout: HomeLayout = previousLayout === 'minimal' ? 'standard' : 'minimal'
|
||||
const previousEditing = dashboardEditing.value
|
||||
switchingLayout.value = true
|
||||
themeStore.homeLayout = nextLayout
|
||||
if (nextLayout === 'minimal') dashboardEditing.value = false
|
||||
|
||||
try {
|
||||
const settings = await getSettings()
|
||||
@ -225,28 +133,14 @@ async function toggleHomeLayout() {
|
||||
await setSettings(settings)
|
||||
} catch (error) {
|
||||
themeStore.homeLayout = previousLayout
|
||||
dashboardEditing.value = previousEditing
|
||||
handleError(error)
|
||||
} finally {
|
||||
switchingLayout.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function toggleDashboardEditing() {
|
||||
dashboardEditing.value = !dashboardEditing.value
|
||||
}
|
||||
|
||||
function toggleWidgetLayout() {
|
||||
dashboard.value?.setLayout(isFreeWidgetLayout.value ? 'grid' : 'free')
|
||||
}
|
||||
|
||||
function openWidgetPicker() {
|
||||
dashboard.value?.openWidgetPicker()
|
||||
}
|
||||
|
||||
const instancesLoaded = await fetchInstances()
|
||||
if (!instancesLoaded || instances.value.length > 0) void fetchPlayerName()
|
||||
await loadDashboardConfig()
|
||||
|
||||
window.addEventListener(DIRECT_LINKS_SYNCED_EVENT, fetchInstances)
|
||||
|
||||
@ -267,16 +161,8 @@ onUnmounted(() => {
|
||||
:selected-instance-id="themeStore.minimalHomeInstanceId"
|
||||
@select="selectMinimalInstance"
|
||||
/>
|
||||
<div class="min-h-full">
|
||||
<HomeDashboard
|
||||
v-if="!isMinimal && dashboardConfig"
|
||||
ref="dashboard"
|
||||
:config="dashboardConfig"
|
||||
:instances="instances"
|
||||
:player-name="playerName"
|
||||
:editing="dashboardEditing"
|
||||
@change="updateDashboardConfig"
|
||||
/>
|
||||
<div class="home-content" :class="{ 'is-minimal': isMinimal }">
|
||||
<SkinSiteFrame v-if="!isMinimal" />
|
||||
|
||||
<HomeMinimal
|
||||
v-else
|
||||
@ -288,71 +174,6 @@ onUnmounted(() => {
|
||||
/>
|
||||
</div>
|
||||
<div class="home-floating-controls" :style="floatingControlsStyle">
|
||||
<template v-if="!isMinimal">
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="formatMessage(messages.addWidget)"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:aria-label="formatMessage(messages.addWidget)"
|
||||
@click="openWidgetPicker"
|
||||
>
|
||||
<PlusIcon />
|
||||
</button>
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="formatMessage(messages.resetWidgetLayout)"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:aria-label="formatMessage(messages.resetWidgetLayout)"
|
||||
@click="resetDashboardConfig"
|
||||
>
|
||||
<RotateCounterClockwiseIcon />
|
||||
</button>
|
||||
<button
|
||||
v-tooltip="
|
||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
||||
"
|
||||
data-onboarding-id="home-widget-customize"
|
||||
type="button"
|
||||
class="home-floating-action"
|
||||
:class="{ 'is-active': dashboardEditing }"
|
||||
:aria-label="
|
||||
formatMessage(dashboardEditing ? messages.doneEditing : messages.customizeWidgets)
|
||||
"
|
||||
:aria-pressed="dashboardEditing"
|
||||
@click="toggleDashboardEditing"
|
||||
>
|
||||
<CheckIcon v-if="dashboardEditing" />
|
||||
<PencilIcon v-else />
|
||||
</button>
|
||||
<button
|
||||
v-if="dashboardEditing"
|
||||
v-tooltip="
|
||||
formatMessage(
|
||||
isFreeWidgetLayout
|
||||
? messages.switchToGridWidgetLayout
|
||||
: messages.switchToFreeWidgetLayout,
|
||||
)
|
||||
"
|
||||
type="button"
|
||||
role="switch"
|
||||
class="home-layout-switch home-widget-layout-switch"
|
||||
:class="{ 'is-free': isFreeWidgetLayout }"
|
||||
:aria-checked="isFreeWidgetLayout"
|
||||
:aria-label="formatMessage(messages.widgetLayoutToggle)"
|
||||
@click="toggleWidgetLayout"
|
||||
>
|
||||
<span class="home-layout-switch-option home-widget-layout-grid" aria-hidden="true">
|
||||
<GridIcon />
|
||||
</span>
|
||||
<span class="home-layout-switch-thumb" aria-hidden="true" />
|
||||
<span class="home-layout-switch-option home-widget-layout-free" aria-hidden="true">
|
||||
<MoveIcon />
|
||||
</span>
|
||||
</button>
|
||||
<span class="home-floating-divider" aria-hidden="true" />
|
||||
</template>
|
||||
<button
|
||||
v-tooltip="formatMessage(isMinimal ? messages.switchToInformation : messages.switchToMinimal)"
|
||||
data-onboarding-id="home-layout-switch"
|
||||
@ -388,6 +209,17 @@ onUnmounted(() => {
|
||||
</template>
|
||||
|
||||
<style scoped>
|
||||
.home-content {
|
||||
/* The embedded site owns scrolling within the launcher viewport. */
|
||||
height: calc(100dvh - var(--top-bar-height));
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.home-content.is-minimal {
|
||||
height: auto;
|
||||
min-height: calc(100dvh - var(--top-bar-height));
|
||||
}
|
||||
|
||||
.home-floating-controls {
|
||||
position: fixed;
|
||||
z-index: 40;
|
||||
@ -406,57 +238,11 @@ onUnmounted(() => {
|
||||
isolation: isolate;
|
||||
}
|
||||
|
||||
.home-floating-action {
|
||||
display: flex;
|
||||
width: 2rem;
|
||||
height: 2rem;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-radius: 9999px;
|
||||
background: transparent;
|
||||
color: var(--color-secondary);
|
||||
cursor: pointer;
|
||||
transition:
|
||||
background-color 120ms ease,
|
||||
color 120ms ease,
|
||||
filter 150ms ease,
|
||||
transform 150ms ease;
|
||||
}
|
||||
|
||||
.home-floating-action:hover {
|
||||
background: var(--color-button-bg);
|
||||
color: var(--color-contrast);
|
||||
}
|
||||
|
||||
.home-floating-action:active {
|
||||
transform: scale(0.96);
|
||||
}
|
||||
|
||||
.home-floating-action:focus-visible,
|
||||
.home-layout-switch:focus-visible {
|
||||
outline: none;
|
||||
box-shadow: 0 0 0 4px var(--color-brand-shadow);
|
||||
}
|
||||
|
||||
.home-floating-action.is-active {
|
||||
background: var(--color-brand);
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
|
||||
.home-floating-action :deep(svg) {
|
||||
width: 1rem;
|
||||
height: 1rem;
|
||||
}
|
||||
|
||||
.home-floating-divider {
|
||||
width: 1px;
|
||||
height: 1.25rem;
|
||||
margin: 0 0.125rem;
|
||||
background: var(--color-divider);
|
||||
}
|
||||
|
||||
.home-layout-switch {
|
||||
position: relative;
|
||||
display: grid;
|
||||
@ -505,10 +291,6 @@ onUnmounted(() => {
|
||||
transform: translateX(2rem);
|
||||
}
|
||||
|
||||
.home-widget-layout-switch.is-free .home-layout-switch-thumb {
|
||||
transform: translateX(2rem);
|
||||
}
|
||||
|
||||
.home-layout-switch-option {
|
||||
position: relative;
|
||||
z-index: 1;
|
||||
@ -527,9 +309,7 @@ onUnmounted(() => {
|
||||
}
|
||||
|
||||
.home-layout-switch:not(.is-minimal) .home-layout-switch-information,
|
||||
.home-layout-switch.is-minimal .home-layout-switch-minimal,
|
||||
.home-widget-layout-switch:not(.is-free) .home-widget-layout-grid,
|
||||
.home-widget-layout-switch.is-free .home-widget-layout-free {
|
||||
.home-layout-switch.is-minimal .home-layout-switch-minimal {
|
||||
color: var(--color-accent-contrast);
|
||||
}
|
||||
</style>
|
||||
|
||||
@ -1,41 +1,9 @@
|
||||
|
||||
<script setup lang="ts">
|
||||
import { defineMessages, useVIntl } from '@modrinth/ui'
|
||||
import { ref } from 'vue'
|
||||
|
||||
const { formatMessage } = useVIntl()
|
||||
|
||||
const messages = defineMessages({
|
||||
title: {
|
||||
id: 'app.starlight-skin.title',
|
||||
defaultMessage: '斯达莱特',
|
||||
},
|
||||
frameTitle: {
|
||||
id: 'app.starlight-skin.frame-title',
|
||||
defaultMessage: 'StarLight Skin Site',
|
||||
},
|
||||
})
|
||||
|
||||
const SKIN_SITE_URL = 'https://skin.starlight.cool/'
|
||||
|
||||
const loading = ref(true)
|
||||
|
||||
function onLoad() {
|
||||
loading.value = false
|
||||
}
|
||||
import SkinSiteFrame from '@/components/ui/SkinSiteFrame.vue'
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<div class="flex h-full min-h-0 flex-col">
|
||||
<div v-if="loading" class="flex flex-1 items-center justify-center">
|
||||
<span class="text-secondary">{{ formatMessage(messages.title) }}…</span>
|
||||
</div>
|
||||
<iframe
|
||||
:src="SKIN_SITE_URL"
|
||||
:title="formatMessage(messages.frameTitle)"
|
||||
class="h-full min-h-0 w-full flex-1 border-0"
|
||||
:class="loading ? 'hidden' : ''"
|
||||
@load="onLoad"
|
||||
/>
|
||||
<div class="h-full min-h-0">
|
||||
<SkinSiteFrame />
|
||||
</div>
|
||||
</template>
|
||||
|
||||
Reference in New Issue
Block a user