You can not select more than 25 topics
Topics must start with a letter or number, can include dashes ('-') and can be up to 35 characters long.
182 lines
5.8 KiB
182 lines
5.8 KiB
<script setup lang="ts">
|
|
import { useAuthSession } from '../composables/useAuthSession'
|
|
import {
|
|
publicHomeLayoutModeKey,
|
|
type PublicHomeLayoutMode,
|
|
} from '../composables/usePublicHomeLayout'
|
|
import { unwrapApiBody, type ApiResponse } from '../utils/http/factory'
|
|
|
|
const route = useRoute()
|
|
const { loggedIn, user, refresh, initialized } = useAuthSession()
|
|
const { siteName } = useGlobalConfig()
|
|
|
|
const showPublicLayoutToggle = computed(() => /^\/@[^/]+$/.test(route.path))
|
|
const profileSlug = computed(() => {
|
|
const s = route.params.publicSlug
|
|
return typeof s === 'string' && s.length ? s : ''
|
|
})
|
|
|
|
/** 顶栏「控制台」仅在看自己的 /@slug 时显示;浏览他人主页时不显示控制台也不显示登录 */
|
|
const showPublicConsoleLink = computed(() => {
|
|
if (!loggedIn.value || !user.value) {
|
|
return false
|
|
}
|
|
const slug = profileSlug.value.trim().toLowerCase()
|
|
if (!slug) {
|
|
return true
|
|
}
|
|
const mine = user.value.publicSlug?.trim().toLowerCase() ?? ''
|
|
return mine !== '' && mine === slug
|
|
})
|
|
|
|
type PublicHomeHeaderPayload = { title: string; iconUrl: string | null }
|
|
|
|
const { data: publicHomeHeader } = await useAsyncData(
|
|
() => `public-home-header:${profileSlug.value || 'none'}`,
|
|
async () => {
|
|
const slug = profileSlug.value
|
|
if (!slug) {
|
|
return null
|
|
}
|
|
try {
|
|
const res = await $fetch<ApiResponse<PublicHomeHeaderPayload>>(
|
|
`/api/public/profile/${encodeURIComponent(slug)}/home-header`,
|
|
)
|
|
return unwrapApiBody(res)
|
|
}
|
|
catch {
|
|
return null
|
|
}
|
|
},
|
|
{ watch: [profileSlug] },
|
|
)
|
|
|
|
const headerBrandTo = computed(() => (profileSlug.value ? `/@${profileSlug.value}` : '/'))
|
|
const headerBrandTitle = computed(() => {
|
|
if (!profileSlug.value) {
|
|
return siteName.value
|
|
}
|
|
const t = publicHomeHeader.value?.title
|
|
if (typeof t === 'string' && t.trim().length) {
|
|
return t.trim()
|
|
}
|
|
return siteName.value
|
|
})
|
|
const headerBrandIconUrl = computed(() => {
|
|
if (!profileSlug.value) {
|
|
return null
|
|
}
|
|
const u = publicHomeHeader.value?.iconUrl
|
|
if (typeof u !== 'string' || !u.trim()) {
|
|
return null
|
|
}
|
|
return u.trim()
|
|
})
|
|
|
|
type LayoutBySlug = Record<string, PublicHomeLayoutMode>
|
|
|
|
const layoutBySlug = useCookie<LayoutBySlug>('public_home_layout', {
|
|
default: () => ({}),
|
|
maxAge: 60 * 60 * 24 * 400,
|
|
sameSite: 'lax',
|
|
})
|
|
|
|
function cookieModeForSlug(slug: string): PublicHomeLayoutMode {
|
|
const m = layoutBySlug.value[slug]
|
|
return m === 'detailed' || m === 'showcase' ? m : 'showcase'
|
|
}
|
|
|
|
const publicLayoutMode = ref<PublicHomeLayoutMode>('showcase')
|
|
provide(publicHomeLayoutModeKey, publicLayoutMode)
|
|
|
|
function syncModeFromCookie() {
|
|
if (!showPublicLayoutToggle.value || !profileSlug.value) {
|
|
return
|
|
}
|
|
publicLayoutMode.value = cookieModeForSlug(profileSlug.value)
|
|
}
|
|
|
|
syncModeFromCookie()
|
|
watch([profileSlug, showPublicLayoutToggle, layoutBySlug], syncModeFromCookie)
|
|
|
|
watch(publicLayoutMode, (m) => {
|
|
if (!showPublicLayoutToggle.value || !profileSlug.value) {
|
|
return
|
|
}
|
|
layoutBySlug.value = { ...layoutBySlug.value, [profileSlug.value]: m }
|
|
})
|
|
|
|
onMounted(() => {
|
|
refresh().catch(() => {})
|
|
})
|
|
</script>
|
|
|
|
<template>
|
|
<UApp>
|
|
<div class="min-h-screen bg-default text-default">
|
|
<header class="sticky top-0 z-40 border-b border-default/80 bg-default/95 backdrop-blur">
|
|
<UContainer class="flex h-14 items-center justify-between gap-3 sm:gap-4">
|
|
<NuxtLink
|
|
:to="headerBrandTo"
|
|
class="flex min-w-0 items-center gap-2 font-semibold tracking-tight text-highlighted"
|
|
>
|
|
<span class="flex h-8 w-8 shrink-0 items-center justify-center overflow-hidden rounded-lg bg-primary/10 text-primary">
|
|
<img
|
|
v-if="headerBrandIconUrl"
|
|
:src="headerBrandIconUrl"
|
|
alt=""
|
|
class="h-full w-full object-cover"
|
|
loading="lazy"
|
|
>
|
|
<UIcon v-else name="i-lucide-orbit" class="size-4" />
|
|
</span>
|
|
<span class="truncate">{{ headerBrandTitle }}</span>
|
|
</NuxtLink>
|
|
<div class="flex shrink-0 items-center gap-2 text-sm sm:gap-3">
|
|
<NuxtLink to="/" class="text-muted hover:text-default whitespace-nowrap">
|
|
站点首页
|
|
</NuxtLink>
|
|
<div
|
|
v-if="showPublicLayoutToggle"
|
|
class="inline-flex rounded-lg border border-default bg-elevated/40 p-0.5 shadow-sm"
|
|
>
|
|
<UButton
|
|
size="xs"
|
|
:color="publicLayoutMode === 'showcase' ? 'primary' : 'neutral'"
|
|
:variant="publicLayoutMode === 'showcase' ? 'solid' : 'ghost'"
|
|
class="rounded-md"
|
|
@click="publicLayoutMode = 'showcase'"
|
|
>
|
|
<UIcon name="i-lucide-layout-template" class="size-3.5 sm:mr-1" />
|
|
<span class="hidden sm:inline">展示</span>
|
|
</UButton>
|
|
<UButton
|
|
size="xs"
|
|
:color="publicLayoutMode === 'detailed' ? 'primary' : 'neutral'"
|
|
:variant="publicLayoutMode === 'detailed' ? 'solid' : 'ghost'"
|
|
class="rounded-md"
|
|
@click="publicLayoutMode = 'detailed'"
|
|
>
|
|
<UIcon name="i-lucide-book-open" class="size-3.5 sm:mr-1" />
|
|
<span class="hidden sm:inline">阅读</span>
|
|
</UButton>
|
|
</div>
|
|
<template v-if="!initialized">
|
|
<USkeleton class="h-5 w-12 rounded" />
|
|
</template>
|
|
<NuxtLink
|
|
v-else-if="showPublicConsoleLink"
|
|
to="/me"
|
|
class="text-muted hover:text-default whitespace-nowrap"
|
|
>
|
|
控制台
|
|
</NuxtLink>
|
|
</div>
|
|
</UContainer>
|
|
</header>
|
|
<main>
|
|
<slot />
|
|
</main>
|
|
</div>
|
|
</UApp>
|
|
</template>
|
|
|