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