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.
352 lines
12 KiB
352 lines
12 KiB
<script setup lang="ts">
|
|
import { unwrapApiBody, type ApiResponse } from '../../utils/http/factory'
|
|
import { safeExternalHref } from '../../utils/safe-external-href'
|
|
|
|
type Section = 'posts' | 'timeline' | 'reading'
|
|
|
|
type SocialLink = {
|
|
label: string
|
|
icon?: string
|
|
safeHref: string
|
|
}
|
|
|
|
type PostItem = {
|
|
title?: string | null
|
|
excerpt?: string | null
|
|
slug?: string | null
|
|
publishedAt?: Date | string | null
|
|
}
|
|
|
|
type TimelineItem = {
|
|
id?: number
|
|
title?: string | null
|
|
occurredOn?: Date | string | null
|
|
bodyMarkdown?: string | null
|
|
linkUrl?: string | null
|
|
}
|
|
|
|
type ReadingItem = {
|
|
title?: string | null
|
|
canonicalUrl?: string | null
|
|
canonical_url?: string | null
|
|
}
|
|
|
|
type ListPayload<T> = {
|
|
items: T[]
|
|
total: number
|
|
page: number
|
|
pageSize: number
|
|
}
|
|
|
|
const props = defineProps<{
|
|
slug: string
|
|
displayName: string
|
|
publicSlug: string | null
|
|
avatar: string | null
|
|
bioPreviewText: string
|
|
hasBioPreview: boolean
|
|
socialLinks: SocialLink[]
|
|
}>()
|
|
|
|
const activeSection = ref<Section>('posts')
|
|
const postsPage = ref(1)
|
|
const timelinePage = ref(1)
|
|
const readingPage = ref(1)
|
|
|
|
const { data: postsData, pending: postsPending } = await useAsyncData(
|
|
() => `reader-posts-${props.slug}-${postsPage.value}`,
|
|
async () => {
|
|
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/posts`
|
|
const url = postsPage.value > 1 ? `${base}?page=${postsPage.value}` : base
|
|
const res = await $fetch<ApiResponse<ListPayload<PostItem>>>(url)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [() => props.slug, postsPage] },
|
|
)
|
|
|
|
const { data: timelineData, pending: timelinePending } = await useAsyncData(
|
|
() => `reader-timeline-${props.slug}-${timelinePage.value}`,
|
|
async () => {
|
|
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/timeline`
|
|
const url = timelinePage.value > 1 ? `${base}?page=${timelinePage.value}` : base
|
|
const res = await $fetch<ApiResponse<ListPayload<TimelineItem>>>(url)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [() => props.slug, timelinePage] },
|
|
)
|
|
|
|
const { data: readingData, pending: readingPending } = await useAsyncData(
|
|
() => `reader-reading-${props.slug}-${readingPage.value}`,
|
|
async () => {
|
|
const base = `/api/public/profile/${encodeURIComponent(props.slug)}/reading`
|
|
const url = readingPage.value > 1 ? `${base}?page=${readingPage.value}` : base
|
|
const res = await $fetch<ApiResponse<ListPayload<ReadingItem>>>(url)
|
|
return unwrapApiBody(res)
|
|
},
|
|
{ watch: [() => props.slug, readingPage] },
|
|
)
|
|
|
|
const postsTotal = computed(() => postsData.value?.total ?? 0)
|
|
const timelineTotal = computed(() => timelineData.value?.total ?? 0)
|
|
const readingTotal = computed(() => readingData.value?.total ?? 0)
|
|
|
|
watch(() => props.slug, () => {
|
|
postsPage.value = 1
|
|
timelinePage.value = 1
|
|
readingPage.value = 1
|
|
activeSection.value = 'posts'
|
|
})
|
|
|
|
function postTitle(it: PostItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名文章'
|
|
}
|
|
|
|
function postExcerpt(it: PostItem): string {
|
|
return typeof it.excerpt === 'string' ? it.excerpt : ''
|
|
}
|
|
|
|
function postSlug(it: PostItem): string {
|
|
return typeof it.slug === 'string' ? it.slug : ''
|
|
}
|
|
|
|
function timelineTitle(it: TimelineItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名动态'
|
|
}
|
|
|
|
function timelineBody(it: TimelineItem): string {
|
|
return typeof it.bodyMarkdown === 'string' ? it.bodyMarkdown : ''
|
|
}
|
|
|
|
function timelineKey(it: TimelineItem, index: number): string | number {
|
|
return it.id ?? index
|
|
}
|
|
|
|
function timelineHref(it: TimelineItem): string | undefined {
|
|
return safeExternalHref(it.linkUrl)
|
|
}
|
|
|
|
function readingTitle(it: ReadingItem): string {
|
|
const t = it.title
|
|
return typeof t === 'string' && t.trim().length ? t : '未命名'
|
|
}
|
|
|
|
function readingHref(it: ReadingItem): string | undefined {
|
|
return safeExternalHref(it.canonicalUrl ?? it.canonical_url)
|
|
}
|
|
|
|
function readingHost(href: string | undefined): string {
|
|
if (!href) {
|
|
return ''
|
|
}
|
|
try {
|
|
return new URL(href).hostname
|
|
}
|
|
catch {
|
|
return href
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<template>
|
|
<div class="grid gap-6 lg:grid-cols-[16rem_minmax(0,1fr)] xl:gap-8">
|
|
<aside class="space-y-4 lg:sticky lg:top-20 lg:h-fit">
|
|
<section class="rounded-2xl border border-default/80 bg-elevated/20 px-4 py-5">
|
|
<div class="flex items-center gap-3">
|
|
<img
|
|
v-if="avatar"
|
|
:src="avatar"
|
|
alt=""
|
|
class="h-14 w-14 rounded-full border border-default object-cover"
|
|
>
|
|
<div>
|
|
<p class="font-semibold text-highlighted">
|
|
{{ displayName }}
|
|
</p>
|
|
<p v-if="publicSlug" class="text-xs text-muted">
|
|
@{{ publicSlug }}
|
|
</p>
|
|
</div>
|
|
</div>
|
|
<NuxtLink
|
|
v-if="hasBioPreview"
|
|
:to="`/@${slug}/about`"
|
|
class="mt-3 block rounded-lg border border-default/70 bg-default/20 px-3 py-2 text-xs text-muted hover:bg-elevated/40"
|
|
>
|
|
{{ bioPreviewText }}
|
|
</NuxtLink>
|
|
<ul v-if="socialLinks.length" class="mt-3 flex flex-wrap gap-2">
|
|
<li v-for="(l, i) in socialLinks" :key="i">
|
|
<a
|
|
:href="l.safeHref"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="inline-flex items-center gap-1 rounded-md border border-default px-2 py-1 text-xs text-muted hover:text-default"
|
|
>
|
|
<UIcon :name="socialLinkIconName({ ...l, url: l.safeHref })" class="size-3.5" />
|
|
{{ l.label }}
|
|
</a>
|
|
</li>
|
|
</ul>
|
|
</section>
|
|
|
|
<nav class="space-y-1 rounded-2xl border border-default/80 bg-elevated/20 p-2" aria-label="阅读区块切换">
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
:class="activeSection === 'posts' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
|
|
@click="activeSection = 'posts'"
|
|
>
|
|
<span>文章</span>
|
|
<span class="text-xs tabular-nums">{{ postsTotal }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
:class="activeSection === 'timeline' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
|
|
@click="activeSection = 'timeline'"
|
|
>
|
|
<span>时光机</span>
|
|
<span class="text-xs tabular-nums">{{ timelineTotal }}</span>
|
|
</button>
|
|
<button
|
|
type="button"
|
|
class="flex w-full items-center justify-between rounded-md px-3 py-2 text-left text-sm transition-colors"
|
|
:class="activeSection === 'reading' ? 'bg-primary/15 text-highlighted' : 'text-muted hover:bg-elevated/50'"
|
|
@click="activeSection = 'reading'"
|
|
>
|
|
<span>阅读</span>
|
|
<span class="text-xs tabular-nums">{{ readingTotal }}</span>
|
|
</button>
|
|
</nav>
|
|
</aside>
|
|
|
|
<section class="min-w-0">
|
|
<div v-if="activeSection === 'posts'" class="space-y-4">
|
|
<h2 class="text-lg font-semibold text-highlighted">
|
|
文章
|
|
</h2>
|
|
<div v-if="postsPending" class="text-sm text-muted">
|
|
加载中…
|
|
</div>
|
|
<UEmpty v-else-if="!postsData?.total" title="暂无公开文章" />
|
|
<template v-else>
|
|
<NuxtLink
|
|
v-for="(p, i) in postsData.items"
|
|
:key="`${postSlug(p)}-${i}`"
|
|
:to="`/@${slug}/posts/${encodeURIComponent(postSlug(p))}`"
|
|
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
|
|
>
|
|
<div class="font-semibold text-highlighted">
|
|
{{ postTitle(p) }}
|
|
</div>
|
|
<p v-if="postExcerpt(p)" class="mt-2 text-sm text-muted line-clamp-3">
|
|
{{ postExcerpt(p) }}
|
|
</p>
|
|
</NuxtLink>
|
|
<div v-if="postsData.total > 10" class="flex justify-end">
|
|
<UPagination
|
|
:page="postsPage"
|
|
:total="postsData.total"
|
|
:items-per-page="10"
|
|
size="sm"
|
|
@update:page="postsPage = $event"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-else-if="activeSection === 'timeline'" class="space-y-4">
|
|
<h2 class="text-lg font-semibold text-highlighted">
|
|
时光机
|
|
</h2>
|
|
<div v-if="timelinePending" class="text-sm text-muted">
|
|
加载中…
|
|
</div>
|
|
<UEmpty v-else-if="!timelineData?.total" title="暂无公开动态" />
|
|
<template v-else>
|
|
<article
|
|
v-for="(e, i) in timelineData.items"
|
|
:key="timelineKey(e, i)"
|
|
class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<h3 class="font-semibold text-highlighted">
|
|
{{ timelineTitle(e) }}
|
|
</h3>
|
|
<time
|
|
v-if="e.occurredOn"
|
|
class="text-xs tabular-nums text-muted"
|
|
:datetime="occurredOnToIsoAttr(e.occurredOn)"
|
|
>{{ formatOccurredOnDisplay(e.occurredOn) }}</time>
|
|
</div>
|
|
<p v-if="timelineBody(e)" class="mt-2 whitespace-pre-wrap text-sm text-muted">
|
|
{{ timelineBody(e) }}
|
|
</p>
|
|
<a
|
|
v-if="timelineHref(e)"
|
|
:href="timelineHref(e)"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="mt-2 inline-flex text-sm text-primary hover:underline"
|
|
>
|
|
打开链接
|
|
</a>
|
|
</article>
|
|
<div v-if="timelineData.total > 10" class="flex justify-end">
|
|
<UPagination
|
|
:page="timelinePage"
|
|
:total="timelineData.total"
|
|
:items-per-page="10"
|
|
size="sm"
|
|
@update:page="timelinePage = $event"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
|
|
<div v-else class="space-y-4">
|
|
<h2 class="text-lg font-semibold text-highlighted">
|
|
阅读
|
|
</h2>
|
|
<div v-if="readingPending" class="text-sm text-muted">
|
|
加载中…
|
|
</div>
|
|
<UEmpty v-else-if="!readingData?.total" title="暂无公开阅读条目" />
|
|
<template v-else>
|
|
<template v-for="(it, i) in readingData.items" :key="i">
|
|
<a
|
|
v-if="readingHref(it)"
|
|
:href="readingHref(it)"
|
|
target="_blank"
|
|
rel="noopener noreferrer"
|
|
class="block rounded-xl border border-default/80 bg-elevated/20 px-4 py-4"
|
|
>
|
|
<div class="font-semibold text-highlighted">
|
|
{{ readingTitle(it) }}
|
|
</div>
|
|
<div class="mt-2 text-sm text-muted">
|
|
{{ readingHost(readingHref(it)) }}
|
|
</div>
|
|
</a>
|
|
<div v-else class="rounded-xl border border-default/80 bg-elevated/20 px-4 py-4">
|
|
<div class="font-semibold text-highlighted">
|
|
{{ readingTitle(it) }}
|
|
</div>
|
|
</div>
|
|
</template>
|
|
<div v-if="readingData.total > 10" class="flex justify-end">
|
|
<UPagination
|
|
:page="readingPage"
|
|
:total="readingData.total"
|
|
:items-per-page="10"
|
|
size="sm"
|
|
@update:page="readingPage = $event"
|
|
/>
|
|
</div>
|
|
</template>
|
|
</div>
|
|
</section>
|
|
</div>
|
|
</template>
|
|
|