:sparklesS: Post reaction
This commit is contained in:
@@ -1,4 +1,5 @@
|
||||
<template>
|
||||
<nuxt-loading-indicator />
|
||||
<nuxt-layout>
|
||||
<nuxt-page />
|
||||
</nuxt-layout>
|
||||
|
||||
@@ -18,4 +18,15 @@
|
||||
html,
|
||||
body {
|
||||
font-family: var(--font-family);
|
||||
background-color: rgba(var(--v-theme-background), 1);
|
||||
}
|
||||
|
||||
.page-enter-active,
|
||||
.page-leave-active {
|
||||
transition: all 0.4s;
|
||||
}
|
||||
.page-enter-from,
|
||||
.page-leave-to {
|
||||
opacity: 0;
|
||||
filter: blur(1rem);
|
||||
}
|
||||
@@ -1,33 +1,42 @@
|
||||
<template>
|
||||
<div
|
||||
v-if="itemType == 'image'"
|
||||
class="relative rounded-md overflow-hidden"
|
||||
:style="`width: 100%; max-height: 800px; aspect-ratio: ${aspectRatio}`"
|
||||
>
|
||||
<!-- Blurhash placeholder -->
|
||||
<div
|
||||
v-if="blurhash"
|
||||
class="absolute inset-0 z-[-1]"
|
||||
:style="blurhashContainerStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="blurCanvas"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
width="32"
|
||||
height="32"
|
||||
<div class="relative rounded-md overflow-hidden" :style="containerStyle">
|
||||
<template v-if="itemType == 'image'">
|
||||
<!-- Blurhash placeholder -->
|
||||
<div
|
||||
v-if="blurhash"
|
||||
class="absolute inset-0 z-[-1]"
|
||||
:style="blurhashContainerStyle"
|
||||
>
|
||||
<canvas
|
||||
ref="blurCanvas"
|
||||
class="absolute top-0 left-0 w-full h-full"
|
||||
width="32"
|
||||
height="32"
|
||||
/>
|
||||
</div>
|
||||
<!-- Main image -->
|
||||
<img
|
||||
:src="remoteSource"
|
||||
class="w-full h-auto rounded-md transition-opacity duration-500 object-cover cursor-pointer"
|
||||
:class="{ 'opacity-0': !imageLoaded && blurhash }"
|
||||
@load="imageLoaded = true"
|
||||
@error="imageLoaded = true"
|
||||
@click="openExternally"
|
||||
/>
|
||||
</div>
|
||||
<!-- Main image -->
|
||||
<img
|
||||
</template>
|
||||
<audio
|
||||
v-else-if="itemType == 'audio'"
|
||||
class="w-full h-auto"
|
||||
:src="remoteSource"
|
||||
class="w-full h-auto rounded-md transition-opacity duration-500"
|
||||
:class="{ 'opacity-0': !imageLoaded && blurhash }"
|
||||
@load="imageLoaded = true"
|
||||
@error="imageLoaded = true"
|
||||
controls
|
||||
/>
|
||||
<video
|
||||
v-else-if="itemType == 'video'"
|
||||
class="w-full h-auto"
|
||||
:src="remoteSource"
|
||||
controls
|
||||
/>
|
||||
</div>
|
||||
<audio v-else-if="itemType == 'audio'" :src="remoteSource" controls />
|
||||
<video v-else-if="itemType == 'video'" :src="remoteSource" controls />
|
||||
</template>
|
||||
|
||||
<script lang="ts" setup>
|
||||
@@ -35,7 +44,7 @@ import { computed, ref, onMounted, watch } from "vue"
|
||||
import { decode } from "blurhash"
|
||||
import type { SnAttachment } from "~/types/api"
|
||||
|
||||
const props = defineProps<{ item: SnAttachment }>()
|
||||
const props = defineProps<{ item: SnAttachment; maxHeight?: string }>()
|
||||
|
||||
const itemType = computed(() => props.item.mimeType.split("/")[0] ?? "unknown")
|
||||
const blurhash = computed(() => props.item.fileMeta?.blur)
|
||||
@@ -50,6 +59,10 @@ const aspectRatio = computed(
|
||||
)
|
||||
const imageLoaded = ref(false)
|
||||
|
||||
function openExternally() {
|
||||
window.open(remoteSource.value + "?original=true", "_blank")
|
||||
}
|
||||
|
||||
const blurCanvas = ref<HTMLCanvasElement | null>(null)
|
||||
|
||||
const apiBase = useSolarNetworkUrl()
|
||||
@@ -61,6 +74,13 @@ const blurhashContainerStyle = computed(() => {
|
||||
}
|
||||
})
|
||||
|
||||
const containerStyle = computed(() => {
|
||||
return {
|
||||
"max-height": props.maxHeight ?? "720px",
|
||||
"aspect-ratio": aspectRatio.value
|
||||
}
|
||||
})
|
||||
|
||||
const decodeBlurhash = () => {
|
||||
if (!blurhash.value || !blurCanvas.value) return
|
||||
|
||||
|
||||
@@ -15,18 +15,29 @@
|
||||
|
||||
<article
|
||||
v-if="htmlContent"
|
||||
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0"
|
||||
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0 max-w-none"
|
||||
>
|
||||
<div v-html="htmlContent" />
|
||||
</article>
|
||||
|
||||
<div v-if="props.item.attachments" class="d-flex gap-2 flex-wrap">
|
||||
<div v-if="props.item.attachments.length > 0" class="d-flex gap-2 flex-wrap" @click.stop>
|
||||
<attachment-item
|
||||
v-for="attachment in props.item.attachments"
|
||||
:key="attachment.id"
|
||||
:item="attachment"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<!-- Post Reactions -->
|
||||
<div @click.stop>
|
||||
<post-reaction-list
|
||||
:parent-id="props.item.id"
|
||||
:reactions="(props.item as any).reactions || {}"
|
||||
:reactions-made="(props.item as any).reactionsMade || {}"
|
||||
:can-react="true"
|
||||
@react="handleReaction"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</v-card-text>
|
||||
</v-card>
|
||||
@@ -39,6 +50,7 @@ import type { SnPost } from "~/types/api"
|
||||
|
||||
import PostHeader from "./PostHeader.vue"
|
||||
import AttachmentItem from "./AttachmentItem.vue"
|
||||
import PostReactionList from "./PostReactionList.vue"
|
||||
|
||||
const props = defineProps<{ item: SnPost }>()
|
||||
|
||||
@@ -46,6 +58,33 @@ const marked = new Marked()
|
||||
|
||||
const htmlContent = ref<string>("")
|
||||
|
||||
function handleReaction(symbol: string, attitude: number, delta: number) {
|
||||
// Update the local item data
|
||||
if (!props.item) return
|
||||
|
||||
const reactions = (props.item 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 = (props.item as any).reactionsMade || {}
|
||||
if (delta > 0) {
|
||||
reactionsMade[symbol] = true
|
||||
} else {
|
||||
delete reactionsMade[symbol]
|
||||
}
|
||||
|
||||
// Update the item object (this will trigger reactivity)
|
||||
;(props.item as any).reactions = { ...reactions }
|
||||
;(props.item as any).reactionsMade = { ...reactionsMade }
|
||||
}
|
||||
|
||||
watch(
|
||||
props.item,
|
||||
async (value) => {
|
||||
|
||||
341
app/components/PostReactionList.vue
Normal file
341
app/components/PostReactionList.vue
Normal file
@@ -0,0 +1,341 @@
|
||||
<template>
|
||||
<v-chip-group class="d-flex flex-wrap gap-2">
|
||||
<!-- Add Reaction Button -->
|
||||
<v-chip
|
||||
v-if="canReact"
|
||||
color="primary"
|
||||
rounded
|
||||
:disabled="submitting"
|
||||
@click="showReactionDialog"
|
||||
>
|
||||
<v-icon start size="16">mdi-plus</v-icon>
|
||||
<span class="text-caption">React</span>
|
||||
</v-chip>
|
||||
|
||||
<!-- Existing Reactions -->
|
||||
<v-chip
|
||||
v-for="(count, symbol) in reactions"
|
||||
rounded
|
||||
:key="symbol"
|
||||
:color="getReactionColor(symbol)"
|
||||
:disabled="submitting"
|
||||
@click="reactToPost(symbol)"
|
||||
>
|
||||
<span class="reaction-emoji">{{ getReactionEmoji(symbol) }}</span>
|
||||
<span class="reaction-symbol">{{ symbol }}</span>
|
||||
<v-chip size="x-small" variant="flat" class="reaction-count ms-1">
|
||||
{{ count }}
|
||||
</v-chip>
|
||||
</v-chip>
|
||||
</v-chip-group>
|
||||
|
||||
<!-- Reaction Selection Dialog -->
|
||||
<v-dialog v-model="reactionDialog" max-width="500" height="600">
|
||||
<v-card prepend-icon="mdi-emoticon-outline" title="React Post">
|
||||
<!-- Dialog Content -->
|
||||
<div class="dialog-content">
|
||||
<!-- Positive Reactions -->
|
||||
<div class="reaction-section">
|
||||
<div class="section-header d-flex align-center px-6 py-3">
|
||||
<v-icon class="me-2">mdi-emoticon-happy</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">Positive</span>
|
||||
</div>
|
||||
<div class="reaction-grid">
|
||||
<v-card
|
||||
v-for="reaction in getReactionsByAttitude(0)"
|
||||
:key="reaction.symbol"
|
||||
class="reaction-card mx-2"
|
||||
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||
:disabled="submitting"
|
||||
@click="selectReaction(reaction.symbol)"
|
||||
>
|
||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||
<span class="text-caption text-center mb-1">{{
|
||||
reaction.symbol
|
||||
}}</span>
|
||||
<span
|
||||
v-if="getReactionCount(reaction.symbol) > 0"
|
||||
class="text-caption font-weight-bold"
|
||||
>
|
||||
x{{ getReactionCount(reaction.symbol) }}
|
||||
</span>
|
||||
<div v-else class="spacer"></div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Neutral Reactions -->
|
||||
<div class="reaction-section">
|
||||
<div class="section-header d-flex align-center px-6 py-3">
|
||||
<v-icon class="me-2">mdi-emoticon-neutral</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">Neutral</span>
|
||||
</div>
|
||||
<div class="reaction-grid">
|
||||
<v-card
|
||||
v-for="reaction in getReactionsByAttitude(1)"
|
||||
:key="reaction.symbol"
|
||||
class="reaction-card mx-2"
|
||||
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||
:disabled="submitting"
|
||||
@click="selectReaction(reaction.symbol)"
|
||||
>
|
||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||
<span class="text-caption text-center mb-1">{{
|
||||
reaction.symbol
|
||||
}}</span>
|
||||
<span
|
||||
v-if="getReactionCount(reaction.symbol) > 0"
|
||||
class="text-caption font-weight-bold"
|
||||
>
|
||||
x{{ getReactionCount(reaction.symbol) }}
|
||||
</span>
|
||||
<div v-else class="spacer"></div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Negative Reactions -->
|
||||
<div class="reaction-section">
|
||||
<div class="section-header d-flex align-center px-6 py-3">
|
||||
<v-icon class="me-2">mdi-emoticon-sad</v-icon>
|
||||
<span class="text-subtitle-1 font-weight-bold">Negative</span>
|
||||
</div>
|
||||
<div class="reaction-grid">
|
||||
<v-card
|
||||
v-for="reaction in getReactionsByAttitude(2)"
|
||||
:key="reaction.symbol"
|
||||
class="reaction-card mx-2"
|
||||
:class="{ selected: isReactionMade(reaction.symbol) }"
|
||||
:disabled="submitting"
|
||||
@click="selectReaction(reaction.symbol)"
|
||||
>
|
||||
<div class="d-flex flex-column align-center justify-center pa-3">
|
||||
<span class="text-h4 mb-1">{{ reaction.emoji }}</span>
|
||||
<span class="text-caption text-center mb-1">{{
|
||||
reaction.symbol
|
||||
}}</span>
|
||||
<span
|
||||
v-if="getReactionCount(reaction.symbol) > 0"
|
||||
class="text-caption font-weight-bold"
|
||||
>
|
||||
x{{ getReactionCount(reaction.symbol) }}
|
||||
</span>
|
||||
<div v-else class="spacer"></div>
|
||||
</div>
|
||||
</v-card>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</v-card>
|
||||
</v-dialog>
|
||||
</template>
|
||||
|
||||
<script setup lang="ts">
|
||||
import { ref, computed } from "vue"
|
||||
|
||||
interface Props {
|
||||
parentId: string
|
||||
reactions?: Record<string, number>
|
||||
reactionsMade?: Record<string, boolean>
|
||||
canReact?: boolean
|
||||
}
|
||||
|
||||
interface ReactionTemplate {
|
||||
symbol: string
|
||||
emoji: string
|
||||
attitude: number
|
||||
}
|
||||
|
||||
const props = withDefaults(defineProps<Props>(), {
|
||||
reactions: () => ({}),
|
||||
reactionsMade: () => ({}),
|
||||
canReact: true
|
||||
})
|
||||
|
||||
const emit = defineEmits<{
|
||||
react: [symbol: string, attitude: number, delta: number]
|
||||
}>()
|
||||
|
||||
const submitting = ref(false)
|
||||
const reactionDialog = ref(false)
|
||||
|
||||
// Available reaction templates
|
||||
const availableReactions: ReactionTemplate[] = [
|
||||
{ symbol: "like", emoji: "👍", attitude: 1 },
|
||||
{ symbol: "love", emoji: "❤️", attitude: 2 },
|
||||
{ symbol: "laugh", emoji: "😂", attitude: 1 },
|
||||
{ symbol: "wow", emoji: "😮", attitude: 1 },
|
||||
{ symbol: "sad", emoji: "😢", attitude: -1 },
|
||||
{ symbol: "angry", emoji: "😠", attitude: -2 },
|
||||
{ symbol: "fire", emoji: "🔥", attitude: 2 },
|
||||
{ symbol: "clap", emoji: "👏", attitude: 1 },
|
||||
{ symbol: "think", emoji: "🤔", attitude: 0 },
|
||||
{ symbol: "pray", emoji: "🙏", attitude: 1 },
|
||||
{ symbol: "celebrate", emoji: "🎉", attitude: 2 },
|
||||
{ symbol: "heart", emoji: "💖", attitude: 2 }
|
||||
]
|
||||
|
||||
function getReactionEmoji(symbol: string): string {
|
||||
const reaction = availableReactions.find((r) => r.symbol === symbol)
|
||||
return reaction?.emoji || "❓"
|
||||
}
|
||||
|
||||
function getReactionColor(symbol: string): string {
|
||||
const attitude =
|
||||
availableReactions.find((r) => r.symbol === symbol)?.attitude || 0
|
||||
if (attitude > 0) return "success"
|
||||
if (attitude < 0) return "error"
|
||||
return "primary"
|
||||
}
|
||||
|
||||
async function reactToPost(symbol: string) {
|
||||
if (submitting.value) return
|
||||
|
||||
const reaction = availableReactions.find((r) => r.symbol === symbol)
|
||||
if (!reaction) return
|
||||
|
||||
try {
|
||||
submitting.value = true
|
||||
const api = useSolarNetwork()
|
||||
const response = await api(`/sphere/posts/${props.parentId}/reactions`, {
|
||||
method: "POST",
|
||||
body: {
|
||||
symbol: symbol,
|
||||
attitude: reaction.attitude
|
||||
}
|
||||
})
|
||||
|
||||
// Check if we're removing the reaction (204 status) or adding (200)
|
||||
// In Nuxt, we can check the response status through the fetch response
|
||||
const isRemoving =
|
||||
response && typeof response === "object" && "status" in response
|
||||
? (response as any).status === 204
|
||||
: false
|
||||
const delta = isRemoving ? -1 : 1
|
||||
|
||||
emit("react", symbol, reaction.attitude, delta)
|
||||
} catch (error) {
|
||||
console.error("Failed to react to post:", error)
|
||||
// You might want to show a toast notification here
|
||||
} finally {
|
||||
submitting.value = false
|
||||
}
|
||||
}
|
||||
|
||||
function showReactionDialog() {
|
||||
reactionDialog.value = true
|
||||
}
|
||||
|
||||
function selectReaction(symbol: string) {
|
||||
reactionDialog.value = false
|
||||
reactToPost(symbol)
|
||||
}
|
||||
|
||||
// Computed properties and helper functions
|
||||
const totalReactionsCount = computed(() => {
|
||||
return Object.values(props.reactions || {}).reduce(
|
||||
(sum, count) => sum + count,
|
||||
0
|
||||
)
|
||||
})
|
||||
|
||||
function getReactionsByAttitude(attitude: number): ReactionTemplate[] {
|
||||
return availableReactions.filter((reaction) => reaction.attitude === attitude)
|
||||
}
|
||||
|
||||
function isReactionMade(symbol: string): boolean {
|
||||
return (props.reactionsMade || {})[symbol] || false
|
||||
}
|
||||
|
||||
function getReactionCount(symbol: string): number {
|
||||
return (props.reactions || {})[symbol] || 0
|
||||
}
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.post-reaction-list {
|
||||
min-height: 32px;
|
||||
}
|
||||
|
||||
.reaction-chip {
|
||||
height: 28px !important;
|
||||
border-radius: 14px;
|
||||
}
|
||||
|
||||
.reaction-emoji {
|
||||
font-size: 16px;
|
||||
margin-right: 4px;
|
||||
}
|
||||
|
||||
.reaction-symbol {
|
||||
font-size: 12px;
|
||||
font-weight: 500;
|
||||
}
|
||||
|
||||
.reaction-count {
|
||||
height: 16px !important;
|
||||
font-size: 10px;
|
||||
padding: 0 4px;
|
||||
}
|
||||
|
||||
/* Dialog Styles */
|
||||
.reaction-dialog {
|
||||
height: 600px;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
}
|
||||
|
||||
.dialog-content {
|
||||
flex: 1;
|
||||
overflow-y: auto;
|
||||
max-height: calc(600px - 80px);
|
||||
}
|
||||
|
||||
.reaction-section {
|
||||
margin-bottom: 16px;
|
||||
}
|
||||
|
||||
.section-header {
|
||||
background-color: rgba(var(--v-theme-surface-variant), 0.5);
|
||||
border-bottom: 1px solid rgb(var(--v-theme-outline-variant));
|
||||
}
|
||||
|
||||
.reaction-grid {
|
||||
display: flex;
|
||||
overflow-x: auto;
|
||||
gap: 8px;
|
||||
padding: 16px;
|
||||
scrollbar-width: none;
|
||||
-ms-overflow-style: none;
|
||||
}
|
||||
|
||||
.reaction-grid::-webkit-scrollbar {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.reaction-card {
|
||||
min-width: 80px;
|
||||
height: 100px;
|
||||
cursor: pointer;
|
||||
transition: all 0.2s ease;
|
||||
border: 2px solid transparent;
|
||||
}
|
||||
|
||||
.reaction-card:hover {
|
||||
transform: scale(1.05);
|
||||
box-shadow: 0 4px 12px rgba(0, 0, 0, 0.15);
|
||||
}
|
||||
|
||||
.reaction-card.selected {
|
||||
border-color: rgb(var(--v-theme-primary));
|
||||
background-color: rgb(var(--v-theme-primary-container));
|
||||
}
|
||||
|
||||
.spacer {
|
||||
height: 16px;
|
||||
}
|
||||
</style>
|
||||
@@ -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"
|
||||
|
||||
@@ -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()
|
||||
})
|
||||
|
||||
Reference in New Issue
Block a user