Browse Source

feat(public): paginated public posts, timeline, and reading pages

Made-with: Cursor
main
npmrun 9 hours ago
parent
commit
86024e69e4
  1. 150
      app/pages/@[publicSlug]/posts/index.vue
  2. 148
      app/pages/@[publicSlug]/reading/index.vue
  3. 160
      app/pages/@[publicSlug]/timeline/index.vue

150
app/pages/@[publicSlug]/posts/index.vue

@ -0,0 +1,150 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import {
formatPublishedDateOnly,
occurredOnToIsoAttr,
} from '../../../utils/timeline-datetime'
definePageMeta({
layout: 'public',
title: '文章',
})
const route = useRoute()
const router = useRouter()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
const page = ref(parsePage(route.query.page))
watch(
() => [route.params.publicSlug, route.query.page] as const,
() => {
page.value = parsePage(route.query.page)
},
)
type PublicPostListItem = {
title: string
excerpt: string
slug: string
publishedAt: Date | null
coverUrl?: string | null
}
type Payload = {
items: PublicPostListItem[]
total: number
page: number
pageSize: number
}
function onPageChange(p: number) {
page.value = p
const query: Record<string, string | string[] | undefined> = { ...route.query }
if (p > 1) {
query.page = String(p)
}
else {
delete query.page
}
void router.replace({ query })
}
const { data, pending, error } = await useAsyncData(
() => `public-posts-${slug.value}-${page.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(slug.value)}/posts`
const url = page.value > 1 ? `${base}?page=${page.value}` : base
const res = await $fetch<ApiResponse<Payload>>(url)
return unwrapApiBody(res)
},
{ watch: [slug, page] },
)
</script>
<template>
<UContainer class="py-8 lg:py-10 max-w-6xl">
<div v-if="pending" class="text-muted py-10">
加载中
</div>
<UAlert
v-else-if="error"
color="error"
title="无法加载文章列表"
class="my-6"
/>
<template v-else-if="data">
<UEmpty
v-if="data.total === 0"
title="暂无公开文章"
description="站主尚未发布任何公开文章。"
/>
<template v-else>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
文章
</h2>
<ul class="border-t border-default">
<li
v-for="p in data.items"
:key="p.slug"
class="border-b border-default last:border-b-0"
>
<NuxtLink
:to="`/@${slug}/posts/${encodeURIComponent(p.slug)}`"
class="group flex flex-col gap-4 py-6 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary focus-visible:ring-offset-2 focus-visible:ring-offset-default rounded-lg sm:flex-row sm:gap-5"
>
<div
v-if="p.coverUrl"
class="w-full shrink-0 overflow-hidden rounded-xl border border-default sm:w-40"
>
<img
:src="p.coverUrl"
alt=""
class="aspect-[16/9] w-full object-cover sm:aspect-[4/3] sm:min-h-[7rem] sm:h-full"
>
</div>
<div class="min-w-0 flex-1">
<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 text-xl font-semibold text-pretty text-highlighted leading-snug group-hover:text-primary transition-colors">
{{ p.title }}
</div>
<p
v-if="p.excerpt"
class="mt-3 text-sm text-muted text-pretty leading-relaxed"
>
{{ p.excerpt }}
</p>
</div>
</NuxtLink>
</li>
</ul>
<div v-if="data.total > 10" class="flex justify-end mt-6">
<UPagination
:page="page"
:total="data.total"
items-per-page="10"
size="sm"
@update:page="onPageChange"
/>
</div>
</template>
</template>
</UContainer>
</template>

148
app/pages/@[publicSlug]/reading/index.vue

@ -0,0 +1,148 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
definePageMeta({
layout: 'public',
title: '阅读',
})
const route = useRoute()
const router = useRouter()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
const page = ref(parsePage(route.query.page))
watch(
() => [route.params.publicSlug, route.query.page] as const,
() => {
page.value = parsePage(route.query.page)
},
)
type PublicRssListItem = { title?: string | null; canonicalUrl?: string | null; canonical_url?: string | null }
type Payload = {
items: PublicRssListItem[]
total: number
page: number
pageSize: number
}
function rssPublicHref(it: PublicRssListItem): string | undefined {
const u = it.canonicalUrl ?? it.canonical_url
return typeof u === 'string' && u.trim().length ? u : undefined
}
function rssPublicTitle(it: PublicRssListItem): string {
const t = it.title
return typeof t === 'string' && t.trim().length ? t : '未命名'
}
function rssHostname(href: string | undefined): string {
if (!href) {
return ''
}
try {
return new URL(href).hostname
}
catch {
return href
}
}
function onPageChange(p: number) {
page.value = p
const query: Record<string, string | string[] | undefined> = { ...route.query }
if (p > 1) {
query.page = String(p)
}
else {
delete query.page
}
void router.replace({ query })
}
const { data, pending, error } = await useAsyncData(
() => `public-reading-${slug.value}-${page.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(slug.value)}/reading`
const url = page.value > 1 ? `${base}?page=${page.value}` : base
const res = await $fetch<ApiResponse<Payload>>(url)
return unwrapApiBody(res)
},
{ watch: [slug, page] },
)
</script>
<template>
<UContainer class="py-8 lg:py-10 max-w-6xl">
<div v-if="pending" class="text-muted py-10">
加载中
</div>
<UAlert
v-else-if="error"
color="error"
title="无法加载阅读列表"
class="my-6"
/>
<template v-else-if="data">
<UEmpty
v-if="data.total === 0"
title="暂无阅读摘录"
description="站主尚未同步任何公开阅读条目。"
/>
<template v-else>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
阅读
</h2>
<ul class="border-t border-default">
<li
v-for="(it, i) in data.items"
:key="i"
class="border-b border-default last:border-b-0"
>
<a
v-if="rssPublicHref(it)"
:href="rssPublicHref(it)"
target="_blank"
rel="noopener noreferrer"
class="group block py-5 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary rounded-lg"
>
<div class="text-base font-semibold text-pretty text-primary group-hover:underline">
{{ rssPublicTitle(it) }}
</div>
<div class="mt-1.5 break-all text-sm text-muted">
{{ rssHostname(rssPublicHref(it)) }}
</div>
</a>
<div v-else class="py-5 text-muted">
{{ rssPublicTitle(it) }}
</div>
</li>
</ul>
<div v-if="data.total > 10" class="flex justify-end mt-6">
<UPagination
:page="page"
:total="data.total"
items-per-page="10"
size="sm"
@update:page="onPageChange"
/>
</div>
</template>
</template>
</UContainer>
</template>

160
app/pages/@[publicSlug]/timeline/index.vue

@ -0,0 +1,160 @@
<script setup lang="ts">
import { unwrapApiBody, type ApiResponse } from '../../../utils/http/factory'
import {
formatOccurredOnDisplay,
occurredOnToIsoAttr,
} from '../../../utils/timeline-datetime'
definePageMeta({
layout: 'public',
title: '时光机',
})
const route = useRoute()
const router = useRouter()
const slug = computed(() => route.params.publicSlug as string)
function parsePage(raw: unknown): number {
const n =
typeof raw === 'string'
? Number.parseInt(raw, 10)
: typeof raw === 'number'
? raw
: Number.NaN
if (!Number.isFinite(n) || n < 1) {
return 1
}
return Math.floor(n)
}
const page = ref(parsePage(route.query.page))
watch(
() => [route.params.publicSlug, route.query.page] as const,
() => {
page.value = parsePage(route.query.page)
},
)
type PublicTimelineItem = {
id?: number
title?: string | null
occurredOn?: Date | string | null
linkUrl?: string | null
bodyMarkdown?: string | null
}
type Payload = {
items: PublicTimelineItem[]
total: number
page: number
pageSize: number
}
function timelineItemKey(e: PublicTimelineItem, i: number): string | number {
return e.id ?? i
}
function onPageChange(p: number) {
page.value = p
const query: Record<string, string | string[] | undefined> = { ...route.query }
if (p > 1) {
query.page = String(p)
}
else {
delete query.page
}
void router.replace({ query })
}
const { data, pending, error } = await useAsyncData(
() => `public-timeline-${slug.value}-${page.value}`,
async () => {
const base = `/api/public/profile/${encodeURIComponent(slug.value)}/timeline`
const url = page.value > 1 ? `${base}?page=${page.value}` : base
const res = await $fetch<ApiResponse<Payload>>(url)
return unwrapApiBody(res)
},
{ watch: [slug, page] },
)
</script>
<template>
<UContainer class="py-8 lg:py-10 max-w-6xl">
<div v-if="pending" class="text-muted py-10">
加载中
</div>
<UAlert
v-else-if="error"
color="error"
title="无法加载时光机"
class="my-6"
/>
<template v-else-if="data">
<UEmpty
v-if="data.total === 0"
title="暂无时光机记录"
description="站主尚未发布任何公开动态。"
/>
<template v-else>
<h2 class="text-xs font-semibold uppercase tracking-wider text-muted mb-4">
时光机
</h2>
<ul class="relative space-y-0">
<li
v-for="(e, i) in data.items"
:key="timelineItemKey(e, i)"
class="relative flex gap-4 pb-6 pl-1 last:pb-0"
>
<div
v-if="i < data.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"
:datetime="e.occurredOn ? occurredOnToIsoAttr(e.occurredOn) : undefined"
>{{ formatOccurredOnDisplay(e.occurredOn ?? '') }}</time>
<h3 class="text-pretty text-lg font-semibold text-highlighted sm:order-1 sm:min-w-0 sm:flex-1">
{{ e.title }}
</h3>
</div>
<p
v-if="e.bodyMarkdown && e.bodyMarkdown.trim()"
class="mt-4 whitespace-pre-wrap border-t border-default/60 pt-4 text-sm leading-relaxed text-default text-pretty"
>
{{ e.bodyMarkdown }}
</p>
<a
v-if="e.linkUrl"
:href="e.linkUrl"
target="_blank"
rel="noopener noreferrer"
class="mt-4 inline-flex items-center gap-1.5 text-sm font-medium text-primary hover:underline"
>
<span>打开链接</span>
<UIcon name="i-lucide-external-link" class="size-3.5 opacity-80" />
</a>
</article>
</li>
</ul>
<div v-if="data.total > 10" class="flex justify-end mt-6">
<UPagination
:page="page"
:total="data.total"
items-per-page="10"
size="sm"
@update:page="onPageChange"
/>
</div>
</template>
</template>
</UContainer>
</template>
Loading…
Cancel
Save