3 changed files with 458 additions and 0 deletions
@ -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> |
||||
@ -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> |
||||
@ -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…
Reference in new issue