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

<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>