:sparklesS: Post reaction

This commit is contained in:
2025-09-20 23:28:25 +08:00
parent dd6ff13228
commit e9de02b084
10 changed files with 732 additions and 89 deletions

View File

@@ -156,7 +156,7 @@
</div>
<div>
<v-card v-if="htmlBio" title="Bio" prepend-icon="mdi-pencil">
<v-card-text class="px-8">
<v-card-text>
<article
class="bio-prose prose prose-sm dark:prose-invert prose-slate"
v-html="htmlBio"

View File

@@ -1,58 +1,206 @@
<template>
<v-container>
<div v-if="loading" class="text-center py-8">
<v-progress-circular indeterminate size="64" />
<v-container class="py-6">
<div v-if="loading" class="text-center py-12">
<v-progress-circular indeterminate size="64" color="primary" />
<p class="mt-4">Loading post...</p>
</div>
<div v-else-if="error" class="text-center py-8">
<v-alert type="error" class="mb-4">
<div v-else-if="error" class="text-center py-12">
<v-alert type="error" class="mb-4" prominent>
<v-alert-title>Error Loading Post</v-alert-title>
{{ error }}
</v-alert>
<v-btn color="primary" @click="fetchPost">Try Again</v-btn>
</div>
<div v-else-if="post">
<v-card class="mb-4">
<v-card-text>
<div class="flex flex-col gap-3">
<post-header :item="post" />
<div v-else-if="post" class="max-w-4xl mx-auto">
<!-- Article Type: Split Header and Content -->
<template v-if="post.type === 1">
<!-- Post Header Section (Article) -->
<v-card class="mb-4 elevation-2" rounded="lg">
<v-card-text class="pa-6">
<post-header :item="post" class="mb-4" />
<div v-if="post.title || post.description">
<h1 v-if="post.title" class="text-2xl font-bold">
<!-- Post Title and Description -->
<div v-if="post.title || post.description" class="mb-4">
<h1
v-if="post.title"
class="text-3xl font-bold mb-3 leading-tight"
>
{{ post.title }}
</h1>
<p v-if="post.description" class="text-sm text-gray-600 dark:text-gray-400">
<p
v-if="post.description"
class="text-lg text-medium-emphasis leading-relaxed"
>
{{ post.description }}
</p>
</div>
<!-- Post Metadata -->
<div class="flex items-center gap-4 text-sm text-medium-emphasis">
<div class="flex items-center gap-1">
<v-icon size="16">mdi-calendar</v-icon>
<span>{{ formatDate(post.createdAt) }}</span>
</div>
<div
v-if="post.updatedAt && post.updatedAt !== post.createdAt"
class="flex items-center gap-1"
>
<v-icon size="16">mdi-pencil</v-icon>
<span>Updated {{ formatDate(post.updatedAt) }}</span>
</div>
<div
v-if="(post as any).viewCount || (post as any).view_count"
class="flex items-center gap-1"
>
<v-icon size="16">mdi-eye</v-icon>
<span
>{{
(post as any).viewCount || (post as any).view_count || 0
}}
views</span
>
</div>
</div>
</v-card-text>
</v-card>
<!-- Merged Content and Attachments Section (Article) -->
<v-card class="mb-4 elevation-1" rounded="lg">
<v-card-text class="pa-8">
<article
v-if="htmlContent"
class="prose prose-lg dark:prose-invert prose-slate prose-p:m-0 max-w-none"
class="prose prose-xl dark:prose-invert prose-slate max-w-none mb-8"
>
<div v-html="htmlContent" />
</article>
<div v-if="post.attachments && post.attachments.length > 0" class="d-flex gap-2 flex-wrap mt-4">
<attachment-item
v-for="attachment in post.attachments"
:key="attachment.id"
:item="attachment"
/>
<!-- Attachments within Content Section -->
<div v-if="post.attachments && post.attachments.length > 0">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<attachment-item
v-for="attachment in post.attachments"
:key="attachment.id"
:item="attachment"
class="w-full"
/>
</div>
</div>
</v-card-text>
</v-card>
</template>
<!-- Other Types: Merged Header, Content, and Attachments -->
<template v-else>
<!-- Merged Header, Content, and Attachments Section -->
<v-card class="mb-4 elevation-1" rounded="lg">
<v-card-text class="pa-6">
<post-header :item="post" class="mb-4" />
<!-- Post Title and Description -->
<div v-if="post.title || post.description" class="mb-4">
<h1
v-if="post.title"
class="text-3xl font-bold mb-3 leading-tight"
>
{{ post.title }}
</h1>
<p
v-if="post.description"
class="text-lg text-medium-emphasis leading-relaxed"
>
{{ post.description }}
</p>
</div>
<div v-if="post.tags && post.tags.length > 0" class="mt-4">
<v-chip
v-for="tag in post.tags"
:key="tag"
size="small"
variant="outlined"
class="mr-2 mb-2"
<!-- Post Metadata -->
<div
class="flex items-center gap-4 text-sm text-medium-emphasis mb-4"
>
<div class="flex items-center gap-1">
<v-icon size="16">mdi-calendar</v-icon>
<span>{{ formatDate(post.createdAt) }}</span>
</div>
<div
v-if="post.updatedAt && post.updatedAt !== post.createdAt"
class="flex items-center gap-1"
>
{{ tag }}
</v-chip>
<v-icon size="16">mdi-pencil</v-icon>
<span>Updated {{ formatDate(post.updatedAt) }}</span>
</div>
<div
v-if="(post as any).viewCount || (post as any).view_count"
class="flex items-center gap-1"
>
<v-icon size="16">mdi-eye</v-icon>
<span
>{{
(post as any).viewCount || (post as any).view_count || 0
}}
views</span
>
</div>
</div>
<!-- Main Content -->
<article
v-if="htmlContent"
class="prose prose-xl dark:prose-invert prose-slate max-w-none mb-8"
>
<div v-html="htmlContent" />
</article>
<!-- Attachments within Merged Section -->
<div v-if="post.attachments && post.attachments.length > 0">
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 gap-4">
<attachment-item
v-for="attachment in post.attachments"
:key="attachment.id"
:item="attachment"
class="w-full"
/>
</div>
</div>
</v-card-text>
</v-card>
</template>
<!-- Tags Section -->
<v-card
v-if="post.tags && post.tags.length > 0"
class="mb-4 elevation-1"
rounded="lg"
>
<v-card-title class="text-h6">
<v-icon class="mr-2">mdi-tag-multiple</v-icon>
Tags
</v-card-title>
<v-card-text>
<div class="flex flex-wrap gap-2">
<v-chip
v-for="tag in post.tags"
:key="tag"
size="small"
variant="outlined"
color="primary"
class="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
>
<v-icon start size="16">mdi-tag</v-icon>
{{ tag }}
</v-chip>
</div>
</v-card-text>
</v-card>
<!-- Post Reactions -->
<div>
<post-reaction-list
can-react
:parent-id="id"
:reactions="(post as any).reactions || {}"
:reactions-made="(post as any).reactionsMade || {}"
@react="handleReaction"
/>
</div>
</div>
</v-container>
</template>
@@ -64,47 +212,57 @@ import type { SnPost } from "~/types/api"
import PostHeader from "~/components/PostHeader.vue"
import AttachmentItem from "~/components/AttachmentItem.vue"
import PostReactionList from "~/components/PostReactionList.vue"
const route = useRoute()
const id = route.params.id as string
useHead({
title: computed(() => {
if (loading.value) return 'Loading post...'
if (error.value) return 'Error'
if (!post.value) return 'Post not found'
return post.value.title || 'Post'
}),
meta: computed(() => {
if (post.value) {
const description = post.value.description || post.value.content?.substring(0, 150) || ''
return [
{ name: 'description', content: description },
]
}
return []
})
})
defineOgImage({
title: computed(() => post.value?.title || 'Post'),
description: computed(() => post.value?.description || post.value?.content?.substring(0, 150) || ''),
})
const post = ref<SnPost | null>(null)
const loading = ref(true)
const error = ref("")
const htmlContent = ref("")
useHead({
title: computed(() => {
if (loading.value) return "Loading post..."
if (error.value) return "Error"
if (!post.value) return "Post not found"
return post.value.title || "Post"
}),
meta: computed(() => {
if (post.value) {
const description =
post.value.description || post.value.content?.substring(0, 150) || ""
return [{ name: "description", content: description }]
}
return []
})
})
// defineOgImage({
// title: computed(() => post.value?.title || 'Post'),
// description: computed(() => post.value?.description || post.value?.content?.substring(0, 150) || ''),
// })
const marked = new Marked()
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString("en-US", {
year: "numeric",
month: "long",
day: "numeric"
})
}
async function fetchPost() {
try {
const api = useSolarNetwork()
const resp = await api(`/sphere/posts/${id}`)
post.value = resp as SnPost
if (post.value.content) {
htmlContent.value = await marked.parse(post.value.content, { breaks: true })
htmlContent.value = await marked.parse(post.value.content, {
breaks: true
})
}
} catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load post"
@@ -113,6 +271,33 @@ async function fetchPost() {
}
}
function handleReaction(symbol: string, attitude: number, delta: number) {
if (!post.value) return
// Update the reactions count
const reactions = (post.value as any).reactions || {}
const currentCount = reactions[symbol] || 0
const newCount = Math.max(0, currentCount + delta)
if (newCount === 0) {
delete reactions[symbol]
} else {
reactions[symbol] = newCount
}
// Update the reactionsMade status
const reactionsMade = (post.value as any).reactionsMade || {}
if (delta > 0) {
reactionsMade[symbol] = true
} else {
delete reactionsMade[symbol]
}
// Update the post object
;(post.value as any).reactions = reactions
;(post.value as any).reactionsMade = reactionsMade
}
onMounted(() => {
fetchPost()
})