Browse Source

feat(public): profile preview slices and links to full list pages

Made-with: Cursor
main
npmrun 9 hours ago
parent
commit
044cc02c82
  1. 276
      app/pages/@[publicSlug]/index.vue

276
app/pages/@[publicSlug]/index.vue

@ -14,21 +14,9 @@ const route = useRoute()
const slug = computed(() => route.params.publicSlug as string) const slug = computed(() => route.params.publicSlug as string)
const { mode } = usePublicProfileLayoutMode() const { mode } = usePublicProfileLayoutMode()
const PAGE_SIZE = 8
const postsPage = ref(1)
const timelinePage = ref(1)
const rssPage = ref(1)
type ReadingSection = 'posts' | 'timeline' | 'rss' type ReadingSection = 'posts' | 'timeline' | 'rss'
const readingSection = ref<ReadingSection>('posts') const readingSection = ref<ReadingSection>('posts')
watch(slug, () => {
postsPage.value = 1
timelinePage.value = 1
rssPage.value = 1
})
type PublicPostListItem = { type PublicPostListItem = {
title: string title: string
excerpt: string excerpt: string
@ -38,6 +26,7 @@ type PublicPostListItem = {
} }
type PublicTimelineItem = { type PublicTimelineItem = {
id?: number
title?: string | null title?: string | null
occurredOn?: Date | string | null occurredOn?: Date | string | null
linkUrl?: string | null linkUrl?: string | null
@ -50,9 +39,9 @@ type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null } user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null bio: { markdown: string } | null
links: { label: string; url: string; visibility: string }[] links: { label: string; url: string; visibility: string }[]
posts: PublicPostListItem[] posts: { items: PublicPostListItem[]; total: number }
timeline: PublicTimelineItem[] timeline: { items: PublicTimelineItem[]; total: number }
rssItems: PublicRssListItem[] rssItems: { items: PublicRssListItem[]; total: number }
} }
function rssPublicHref(it: PublicRssListItem): string | undefined { function rssPublicHref(it: PublicRssListItem): string | undefined {
@ -87,10 +76,10 @@ const { data, pending, error } = await useAsyncData(
) )
function firstReadingSection(d: Payload): ReadingSection { function firstReadingSection(d: Payload): ReadingSection {
if (d.posts.length) { if (d.posts.total > 0) {
return 'posts' return 'posts'
} }
if (d.timeline.length) { if (d.timeline.total > 0) {
return 'timeline' return 'timeline'
} }
return 'rss' return 'rss'
@ -98,12 +87,12 @@ function firstReadingSection(d: Payload): ReadingSection {
function readingSectionValid(d: Payload, s: ReadingSection): boolean { function readingSectionValid(d: Payload, s: ReadingSection): boolean {
if (s === 'posts') { if (s === 'posts') {
return d.posts.length > 0 return d.posts.total > 0
} }
if (s === 'timeline') { if (s === 'timeline') {
return d.timeline.length > 0 return d.timeline.total > 0
} }
return d.rssItems.length > 0 return d.rssItems.total > 0
} }
watch([data, mode], () => { watch([data, mode], () => {
@ -111,7 +100,7 @@ watch([data, mode], () => {
return return
} }
const d = data.value const d = data.value
if (!d.posts.length && !d.timeline.length && !d.rssItems.length) { if (!d.posts.total && !d.timeline.total && !d.rssItems.total) {
return return
} }
if (!readingSectionValid(d, readingSection.value)) { if (!readingSectionValid(d, readingSection.value)) {
@ -129,15 +118,9 @@ function selectReadingSection(s: ReadingSection) {
}) })
} }
function slicePage<T>(list: T[], page: number): T[] { function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
const p = Math.max(1, page) return e.id ?? i
const start = (p - 1) * PAGE_SIZE
return list.slice(start, start + PAGE_SIZE)
} }
const postsChunk = computed(() => slicePage(data.value?.posts ?? [], postsPage.value))
const timelineChunk = computed(() => slicePage(data.value?.timeline ?? [], timelinePage.value))
const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.value))
</script> </script>
<template> <template>
@ -188,13 +171,25 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</ul> </ul>
</div> </div>
<div v-if="data.posts.length" class="space-y-2"> <div v-if="data.posts.total" class="space-y-2">
<h2 class="text-lg font-medium"> <div class="flex flex-wrap items-center justify-between gap-2">
文章 <h2 class="text-lg font-medium">
</h2> 文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="space-y-2"> <ul class="space-y-2">
<li <li
v-for="p in data.posts" v-for="p in data.posts.items"
:key="p.slug" :key="p.slug"
class="border border-default rounded-lg overflow-hidden transition-colors hover:bg-elevated/50" class="border border-default rounded-lg overflow-hidden transition-colors hover:bg-elevated/50"
> >
@ -202,7 +197,12 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`" :to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg" class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
> >
<div class="font-medium text-highlighted"> <time
v-if="p.publishedAt"
class="block text-xs font-medium tabular-nums text-muted"
:datetime="occurredOnToIsoAttr(p.publishedAt)"
>{{ formatPublishedDateOnly(p.publishedAt) }}</time>
<div class="mt-1 font-medium text-highlighted">
{{ p.title }} {{ p.title }}
</div> </div>
<div v-if="p.excerpt" class="text-sm text-muted mt-1"> <div v-if="p.excerpt" class="text-sm text-muted mt-1">
@ -213,18 +213,30 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</ul> </ul>
</div> </div>
<div v-if="data.timeline.length" class="space-y-3"> <div v-if="data.timeline.total" class="space-y-3">
<h2 class="text-lg font-medium text-highlighted"> <div class="flex flex-wrap items-center justify-between gap-2">
时光机 <h2 class="text-lg font-medium text-highlighted">
</h2> 时光机
</h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0"> <ul class="relative space-y-0">
<li <li
v-for="(e, i) in data.timeline" v-for="(e, i) in data.timeline.items"
:key="i" :key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0" class="relative flex gap-4 pb-6 pl-1 last:pb-0"
> >
<div <div
v-if="i < data.timeline.length - 1" v-if="i < data.timeline.items.length - 1"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default" class="absolute left-[11px] top-5 bottom-0 w-px bg-default"
aria-hidden="true" aria-hidden="true"
/> />
@ -253,32 +265,57 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</ul> </ul>
</div> </div>
<div v-if="data.rssItems.length" class="space-y-2"> <div v-if="data.rssItems.total" class="space-y-2">
<h2 class="text-lg font-medium"> <div class="flex flex-wrap items-center justify-between gap-2">
阅读 <h2 class="text-lg font-medium">
</h2> 阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="space-y-2"> <ul class="space-y-2">
<li v-for="(it, i) in data.rssItems" :key="i"> <li
v-for="(it, i) in data.rssItems.items"
:key="i"
class="rounded-lg border border-default transition-colors hover:bg-elevated/40"
>
<a <a
v-if="rssPublicHref(it)" v-if="rssPublicHref(it)"
:href="rssPublicHref(it)" :href="rssPublicHref(it)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="text-primary hover:underline" class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>{{ rssPublicTitle(it) }}</a> >
<span v-else class="text-muted">{{ rssPublicTitle(it) }}</span> <div class="font-medium text-primary">
{{ rssPublicTitle(it) }}
</div>
<div class="mt-1 text-sm text-muted break-all">
{{ rssHostname(rssPublicHref(it)) }}
</div>
</a>
<div v-else class="p-3 text-muted">
{{ rssPublicTitle(it) }}
</div>
</li> </li>
</ul> </ul>
</div> </div>
<UEmpty <UEmpty
v-if="!data.posts.length && !data.timeline.length && !data.rssItems.length && !data.bio && !data.links.length" v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容" title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。" description="站主尚未发布任何公开文章或动态。"
/> />
</UContainer> </UContainer>
<!-- 阅读侧栏 + 列表分页 --> <!-- 阅读侧栏 + 与展示一致的预览块 -->
<UContainer <UContainer
v-else-if="data && mode === 'detailed'" v-else-if="data && mode === 'detailed'"
class="py-8 lg:py-10 max-w-6xl" class="py-8 lg:py-10 max-w-6xl"
@ -327,63 +364,63 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
aria-label="页面区块" aria-label="页面区块"
> >
<button <button
v-if="data.posts.length" v-if="data.posts.total"
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors" class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'posts' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'" :class="readingSection === 'posts' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('posts')" @click="selectReadingSection('posts')"
> >
文章 · {{ data.posts.length }} 文章 · {{ data.posts.total }}
</button> </button>
<button <button
v-if="data.timeline.length" v-if="data.timeline.total"
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors" class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'timeline' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'" :class="readingSection === 'timeline' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('timeline')" @click="selectReadingSection('timeline')"
> >
时光机 · {{ data.timeline.length }} 时光机 · {{ data.timeline.total }}
</button> </button>
<button <button
v-if="data.rssItems.length" v-if="data.rssItems.total"
type="button" type="button"
class="rounded-full border px-3 py-1 text-xs transition-colors" class="rounded-full border px-3 py-1 text-xs transition-colors"
:class="readingSection === 'rss' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'" :class="readingSection === 'rss' ? 'border-primary/40 bg-primary/10 text-highlighted' : 'border-default bg-elevated/40 text-muted hover:text-default'"
@click="selectReadingSection('rss')" @click="selectReadingSection('rss')"
> >
阅读 · {{ data.rssItems.length }} 阅读 · {{ data.rssItems.total }}
</button> </button>
</nav> </nav>
<nav class="hidden lg:block space-y-1 border-t border-default pt-4 text-sm" aria-label="页面区块"> <nav class="hidden lg:block space-y-1 border-t border-default pt-4 text-sm" aria-label="页面区块">
<button <button
v-if="data.posts.length" v-if="data.posts.total"
type="button" type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors" class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'posts' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'" :class="readingSection === 'posts' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('posts')" @click="selectReadingSection('posts')"
> >
<span>文章</span> <span>文章</span>
<span class="tabular-nums text-xs opacity-80">{{ data.posts.length }}</span> <span class="tabular-nums text-xs opacity-80">{{ data.posts.total }}</span>
</button> </button>
<button <button
v-if="data.timeline.length" v-if="data.timeline.total"
type="button" type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors" class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'timeline' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'" :class="readingSection === 'timeline' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('timeline')" @click="selectReadingSection('timeline')"
> >
<span>时光机</span> <span>时光机</span>
<span class="tabular-nums text-xs opacity-80">{{ data.timeline.length }}</span> <span class="tabular-nums text-xs opacity-80">{{ data.timeline.total }}</span>
</button> </button>
<button <button
v-if="data.rssItems.length" v-if="data.rssItems.total"
type="button" type="button"
class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors" class="flex w-full items-center justify-between rounded-md px-2 py-2 text-left transition-colors"
:class="readingSection === 'rss' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'" :class="readingSection === 'rss' ? 'bg-elevated text-highlighted' : 'text-muted hover:bg-elevated/60 hover:text-default'"
@click="selectReadingSection('rss')" @click="selectReadingSection('rss')"
> >
<span>阅读</span> <span>阅读</span>
<span class="tabular-nums text-xs opacity-80">{{ data.rssItems.length }}</span> <span class="tabular-nums text-xs opacity-80">{{ data.rssItems.total }}</span>
</button> </button>
</nav> </nav>
</div> </div>
@ -391,15 +428,27 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
<div id="public-reading-main" class="min-w-0 scroll-mt-24 space-y-14"> <div id="public-reading-main" class="min-w-0 scroll-mt-24 space-y-14">
<section <section
v-show="readingSection === 'posts' && data.posts.length" v-show="readingSection === 'posts' && data.posts.total"
id="public-reading-posts" id="public-reading-posts"
> >
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4"> <div class="flex flex-wrap items-center justify-between gap-2 mb-4">
文章 <h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
</h2> 文章
</h2>
<UButton
v-if="data.posts.total > 5"
:to="`/@${slug}/posts`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.posts.total }}
</UButton>
</div>
<ul class="border-t border-default"> <ul class="border-t border-default">
<li <li
v-for="p in postsChunk" v-for="p in data.posts.items"
:key="p.slug" :key="p.slug"
class="border-b border-default last:border-b-0" class="border-b border-default last:border-b-0"
> >
@ -436,29 +485,44 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</NuxtLink> </NuxtLink>
</li> </li>
</ul> </ul>
<div v-if="data.posts.length > PAGE_SIZE" class="mt-5 flex justify-end border-t border-default pt-4">
<UPagination
v-model:page="postsPage"
:total="data.posts.length"
:items-per-page="PAGE_SIZE"
size="sm"
/>
</div>
</section> </section>
<section <section
v-show="readingSection === 'timeline' && data.timeline.length" v-show="readingSection === 'timeline' && data.timeline.total"
id="public-reading-timeline" id="public-reading-timeline"
> >
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4"> <div class="flex flex-wrap items-center justify-between gap-2 mb-4">
时光机 <h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
</h2> 时光机
<ul class="space-y-5"> </h2>
<UButton
v-if="data.timeline.total > 5"
:to="`/@${slug}/timeline`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.timeline.total }}
</UButton>
</div>
<ul class="relative space-y-0">
<li <li
v-for="(e, i) in timelineChunk" v-for="(e, i) in data.timeline.items"
:key="`${timelinePage}-${i}-${e.title ?? ''}`" :key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
> >
<article class="rounded-xl border border-default bg-elevated/25 px-5 py-5 shadow-sm sm:px-6"> <div
v-if="i < data.timeline.items.length - 1"
class="absolute left-[11px] top-5 bottom-0 w-px bg-default"
aria-hidden="true"
/>
<div class="relative z-[1] flex shrink-0 flex-col items-center pt-0.5">
<span class="size-2.5 rounded-full bg-primary ring-4 ring-primary/15" />
</div>
<article
class="min-w-0 flex-1 rounded-xl border border-default bg-elevated/25 px-5 py-5 shadow-sm sm:px-6"
>
<div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4"> <div class="flex flex-col gap-1 sm:flex-row sm:items-baseline sm:justify-between sm:gap-4">
<time <time
class="shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right" class="shrink-0 text-xs font-medium tabular-nums text-muted sm:order-2 sm:text-right"
@ -487,27 +551,31 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</article> </article>
</li> </li>
</ul> </ul>
<div v-if="data.timeline.length > PAGE_SIZE" class="mt-5 flex justify-end border-t border-default pt-4">
<UPagination
v-model:page="timelinePage"
:total="data.timeline.length"
:items-per-page="PAGE_SIZE"
size="sm"
/>
</div>
</section> </section>
<section <section
v-show="readingSection === 'rss' && data.rssItems.length" v-show="readingSection === 'rss' && data.rssItems.total"
id="public-reading-rss" id="public-reading-rss"
> >
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4"> <div class="flex flex-wrap items-center justify-between gap-2 mb-4">
阅读 <h2 class="text-xs font-semibold uppercase tracking-wider text-muted">
</h2> 阅读
</h2>
<UButton
v-if="data.rssItems.total > 5"
:to="`/@${slug}/reading`"
variant="outline"
size="xs"
icon="i-lucide-arrow-right"
trailing
>
查看全部 {{ data.rssItems.total }}
</UButton>
</div>
<ul class="border-t border-default"> <ul class="border-t border-default">
<li <li
v-for="(it, i) in rssChunk" v-for="(it, i) in data.rssItems.items"
:key="`${rssPage}-${i}`" :key="i"
class="border-b border-default last:border-b-0" class="border-b border-default last:border-b-0"
> >
<a <a
@ -529,18 +597,10 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</div> </div>
</li> </li>
</ul> </ul>
<div v-if="data.rssItems.length > PAGE_SIZE" class="mt-5 flex justify-end border-t border-default pt-4">
<UPagination
v-model:page="rssPage"
:total="data.rssItems.length"
:items-per-page="PAGE_SIZE"
size="sm"
/>
</div>
</section> </section>
<UEmpty <UEmpty
v-if="!data.posts.length && !data.timeline.length && !data.rssItems.length && !data.bio && !data.links.length" v-if="!data.posts.total && !data.timeline.total && !data.rssItems.total && !data.bio && !data.links.length"
title="这里还没有公开内容" title="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。" description="站主尚未发布任何公开文章或动态。"
/> />

Loading…
Cancel
Save