:sparklesS: Post reaction
This commit is contained in:
@@ -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>
|
Reference in New Issue
Block a user