Browse Source

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

Made-with: Cursor
main
npmrun 6 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 { mode } = usePublicProfileLayoutMode()
const PAGE_SIZE = 8
const postsPage = ref(1)
const timelinePage = ref(1)
const rssPage = ref(1)
type ReadingSection = 'posts' | 'timeline' | 'rss'
const readingSection = ref<ReadingSection>('posts')
watch(slug, () => {
postsPage.value = 1
timelinePage.value = 1
rssPage.value = 1
})
type PublicPostListItem = {
title: string
excerpt: string
@ -38,6 +26,7 @@ type PublicPostListItem = {
}
type PublicTimelineItem = {
id?: number
title?: string | null
occurredOn?: Date | string | null
linkUrl?: string | null
@ -50,9 +39,9 @@ type Payload = {
user: { publicSlug: string | null; nickname: string | null; avatar: string | null }
bio: { markdown: string } | null
links: { label: string; url: string; visibility: string }[]
posts: PublicPostListItem[]
timeline: PublicTimelineItem[]
rssItems: PublicRssListItem[]
posts: { items: PublicPostListItem[]; total: number }
timeline: { items: PublicTimelineItem[]; total: number }
rssItems: { items: PublicRssListItem[]; total: number }
}
function rssPublicHref(it: PublicRssListItem): string | undefined {
@ -87,10 +76,10 @@ const { data, pending, error } = await useAsyncData(
)
function firstReadingSection(d: Payload): ReadingSection {
if (d.posts.length) {
if (d.posts.total > 0) {
return 'posts'
}
if (d.timeline.length) {
if (d.timeline.total > 0) {
return 'timeline'
}
return 'rss'
@ -98,12 +87,12 @@ function firstReadingSection(d: Payload): ReadingSection {
function readingSectionValid(d: Payload, s: ReadingSection): boolean {
if (s === 'posts') {
return d.posts.length > 0
return d.posts.total > 0
}
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], () => {
@ -111,7 +100,7 @@ watch([data, mode], () => {
return
}
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
}
if (!readingSectionValid(d, readingSection.value)) {
@ -129,15 +118,9 @@ function selectReadingSection(s: ReadingSection) {
})
}
function slicePage<T>(list: T[], page: number): T[] {
const p = Math.max(1, page)
const start = (p - 1) * PAGE_SIZE
return list.slice(start, start + PAGE_SIZE)
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
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>
<template>
@ -188,13 +171,25 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</ul>
</div>
<div v-if="data.posts.length" class="space-y-2">
<h2 class="text-lg font-medium">
文章
</h2>
<div v-if="data.posts.total" class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium">
文章
</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">
<li
v-for="p in data.posts"
v-for="p in data.posts.items"
:key="p.slug"
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)}`"
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 }}
</div>
<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>
</div>
<div v-if="data.timeline.length" class="space-y-3">
<h2 class="text-lg font-medium text-highlighted">
时光机
</h2>
<div v-if="data.timeline.total" class="space-y-3">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium text-highlighted">
时光机
</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
v-for="(e, i) in data.timeline"
:key="i"
v-for="(e, i) in data.timeline.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
>
<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"
aria-hidden="true"
/>
@ -253,32 +265,57 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</ul>
</div>
<div v-if="data.rssItems.length" class="space-y-2">
<h2 class="text-lg font-medium">
阅读
</h2>
<div v-if="data.rssItems.total" class="space-y-2">
<div class="flex flex-wrap items-center justify-between gap-2">
<h2 class="text-lg font-medium">
阅读
</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">
<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
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="text-primary hover:underline"
>{{ rssPublicTitle(it) }}</a>
<span v-else class="text-muted">{{ rssPublicTitle(it) }}</span>
class="block p-3 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<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>
</ul>
</div>
<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="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>
</UContainer>
<!-- 阅读侧栏 + 列表分页 -->
<!-- 阅读侧栏 + 与展示一致的预览块 -->
<UContainer
v-else-if="data && mode === 'detailed'"
class="py-8 lg:py-10 max-w-6xl"
@ -327,63 +364,63 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
aria-label="页面区块"
>
<button
v-if="data.posts.length"
v-if="data.posts.total"
type="button"
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'"
@click="selectReadingSection('posts')"
>
文章 · {{ data.posts.length }}
文章 · {{ data.posts.total }}
</button>
<button
v-if="data.timeline.length"
v-if="data.timeline.total"
type="button"
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'"
@click="selectReadingSection('timeline')"
>
时光机 · {{ data.timeline.length }}
时光机 · {{ data.timeline.total }}
</button>
<button
v-if="data.rssItems.length"
v-if="data.rssItems.total"
type="button"
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'"
@click="selectReadingSection('rss')"
>
阅读 · {{ data.rssItems.length }}
阅读 · {{ data.rssItems.total }}
</button>
</nav>
<nav class="hidden lg:block space-y-1 border-t border-default pt-4 text-sm" aria-label="页面区块">
<button
v-if="data.posts.length"
v-if="data.posts.total"
type="button"
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'"
@click="selectReadingSection('posts')"
>
<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
v-if="data.timeline.length"
v-if="data.timeline.total"
type="button"
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'"
@click="selectReadingSection('timeline')"
>
<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
v-if="data.rssItems.length"
v-if="data.rssItems.total"
type="button"
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'"
@click="selectReadingSection('rss')"
>
<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>
</nav>
</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">
<section
v-show="readingSection === 'posts' && data.posts.length"
v-show="readingSection === 'posts' && data.posts.total"
id="public-reading-posts"
>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
文章
</h2>
<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>
<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">
<li
v-for="p in postsChunk"
v-for="p in data.posts.items"
:key="p.slug"
class="border-b border-default last:border-b-0"
>
@ -436,29 +485,44 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</NuxtLink>
</li>
</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
v-show="readingSection === 'timeline' && data.timeline.length"
v-show="readingSection === 'timeline' && data.timeline.total"
id="public-reading-timeline"
>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
时光机
</h2>
<ul class="space-y-5">
<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>
<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
v-for="(e, i) in timelineChunk"
:key="`${timelinePage}-${i}-${e.title ?? ''}`"
v-for="(e, i) in data.timeline.items"
: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">
<time
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>
</li>
</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
v-show="readingSection === 'rss' && data.rssItems.length"
v-show="readingSection === 'rss' && data.rssItems.total"
id="public-reading-rss"
>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
阅读
</h2>
<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>
<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">
<li
v-for="(it, i) in rssChunk"
:key="`${rssPage}-${i}`"
v-for="(it, i) in data.rssItems.items"
:key="i"
class="border-b border-default last:border-b-0"
>
<a
@ -529,18 +597,10 @@ const rssChunk = computed(() => slicePage(data.value?.rssItems ?? [], rssPage.va
</div>
</li>
</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>
<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="这里还没有公开内容"
description="站主尚未发布任何公开文章或动态。"
/>

Loading…
Cancel
Save