: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

@@ -1,4 +1,5 @@
<template> <template>
<nuxt-loading-indicator />
<nuxt-layout> <nuxt-layout>
<nuxt-page /> <nuxt-page />
</nuxt-layout> </nuxt-layout>

View File

@@ -18,4 +18,15 @@
html, html,
body { body {
font-family: var(--font-family); 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);
} }

View File

@@ -1,9 +1,6 @@
<template> <template>
<div <div class="relative rounded-md overflow-hidden" :style="containerStyle">
v-if="itemType == 'image'" <template v-if="itemType == 'image'">
class="relative rounded-md overflow-hidden"
:style="`width: 100%; max-height: 800px; aspect-ratio: ${aspectRatio}`"
>
<!-- Blurhash placeholder --> <!-- Blurhash placeholder -->
<div <div
v-if="blurhash" v-if="blurhash"
@@ -20,14 +17,26 @@
<!-- Main image --> <!-- Main image -->
<img <img
:src="remoteSource" :src="remoteSource"
class="w-full h-auto rounded-md transition-opacity duration-500" class="w-full h-auto rounded-md transition-opacity duration-500 object-cover cursor-pointer"
:class="{ 'opacity-0': !imageLoaded && blurhash }" :class="{ 'opacity-0': !imageLoaded && blurhash }"
@load="imageLoaded = true" @load="imageLoaded = true"
@error="imageLoaded = true" @error="imageLoaded = true"
@click="openExternally"
/>
</template>
<audio
v-else-if="itemType == 'audio'"
class="w-full h-auto"
:src="remoteSource"
controls
/>
<video
v-else-if="itemType == 'video'"
class="w-full h-auto"
:src="remoteSource"
controls
/> />
</div> </div>
<audio v-else-if="itemType == 'audio'" :src="remoteSource" controls />
<video v-else-if="itemType == 'video'" :src="remoteSource" controls />
</template> </template>
<script lang="ts" setup> <script lang="ts" setup>
@@ -35,7 +44,7 @@ import { computed, ref, onMounted, watch } from "vue"
import { decode } from "blurhash" import { decode } from "blurhash"
import type { SnAttachment } from "~/types/api" 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 itemType = computed(() => props.item.mimeType.split("/")[0] ?? "unknown")
const blurhash = computed(() => props.item.fileMeta?.blur) const blurhash = computed(() => props.item.fileMeta?.blur)
@@ -50,6 +59,10 @@ const aspectRatio = computed(
) )
const imageLoaded = ref(false) const imageLoaded = ref(false)
function openExternally() {
window.open(remoteSource.value + "?original=true", "_blank")
}
const blurCanvas = ref<HTMLCanvasElement | null>(null) const blurCanvas = ref<HTMLCanvasElement | null>(null)
const apiBase = useSolarNetworkUrl() 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 = () => { const decodeBlurhash = () => {
if (!blurhash.value || !blurCanvas.value) return if (!blurhash.value || !blurCanvas.value) return

View File

@@ -15,18 +15,29 @@
<article <article
v-if="htmlContent" 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" /> <div v-html="htmlContent" />
</article> </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 <attachment-item
v-for="attachment in props.item.attachments" v-for="attachment in props.item.attachments"
:key="attachment.id" :key="attachment.id"
:item="attachment" :item="attachment"
/> />
</div> </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> </div>
</v-card-text> </v-card-text>
</v-card> </v-card>
@@ -39,6 +50,7 @@ import type { SnPost } from "~/types/api"
import PostHeader from "./PostHeader.vue" import PostHeader from "./PostHeader.vue"
import AttachmentItem from "./AttachmentItem.vue" import AttachmentItem from "./AttachmentItem.vue"
import PostReactionList from "./PostReactionList.vue"
const props = defineProps<{ item: SnPost }>() const props = defineProps<{ item: SnPost }>()
@@ -46,6 +58,33 @@ const marked = new Marked()
const htmlContent = ref<string>("") 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( watch(
props.item, props.item,
async (value) => { async (value) => {

View 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>

View File

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

View File

@@ -1,58 +1,206 @@
<template> <template>
<v-container> <v-container class="py-6">
<div v-if="loading" class="text-center py-8"> <div v-if="loading" class="text-center py-12">
<v-progress-circular indeterminate size="64" /> <v-progress-circular indeterminate size="64" color="primary" />
<p class="mt-4">Loading post...</p> <p class="mt-4">Loading post...</p>
</div> </div>
<div v-else-if="error" class="text-center py-8"> <div v-else-if="error" class="text-center py-12">
<v-alert type="error" class="mb-4"> <v-alert type="error" class="mb-4" prominent>
<v-alert-title>Error Loading Post</v-alert-title>
{{ error }} {{ error }}
</v-alert> </v-alert>
<v-btn color="primary" @click="fetchPost">Try Again</v-btn>
</div> </div>
<div v-else-if="post"> <div v-else-if="post" class="max-w-4xl mx-auto">
<v-card class="mb-4"> <!-- Article Type: Split Header and Content -->
<v-card-text> <template v-if="post.type === 1">
<div class="flex flex-col gap-3"> <!-- Post Header Section (Article) -->
<post-header :item="post" /> <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"> <!-- Post Title and Description -->
<h1 v-if="post.title" class="text-2xl font-bold"> <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 }} {{ post.title }}
</h1> </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 }} {{ post.description }}
</p> </p>
</div> </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 <article
v-if="htmlContent" 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" /> <div v-html="htmlContent" />
</article> </article>
<div v-if="post.attachments && post.attachments.length > 0" class="d-flex gap-2 flex-wrap mt-4"> <!-- 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 <attachment-item
v-for="attachment in post.attachments" v-for="attachment in post.attachments"
:key="attachment.id" :key="attachment.id"
:item="attachment" :item="attachment"
class="w-full"
/> />
</div> </div>
</div>
</v-card-text>
</v-card>
</template>
<div v-if="post.tags && post.tags.length > 0" class="mt-4"> <!-- 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>
<!-- 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"
>
<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-chip
v-for="tag in post.tags" v-for="tag in post.tags"
:key="tag" :key="tag"
size="small" size="small"
variant="outlined" variant="outlined"
class="mr-2 mb-2" color="primary"
class="cursor-pointer hover:bg-primary hover:text-primary-foreground transition-colors"
> >
<v-icon start size="16">mdi-tag</v-icon>
{{ tag }} {{ tag }}
</v-chip> </v-chip>
</div> </div>
</div>
</v-card-text> </v-card-text>
</v-card> </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> </div>
</v-container> </v-container>
</template> </template>
@@ -64,47 +212,57 @@ import type { SnPost } from "~/types/api"
import PostHeader from "~/components/PostHeader.vue" import PostHeader from "~/components/PostHeader.vue"
import AttachmentItem from "~/components/AttachmentItem.vue" import AttachmentItem from "~/components/AttachmentItem.vue"
import PostReactionList from "~/components/PostReactionList.vue"
const route = useRoute() const route = useRoute()
const id = route.params.id as string 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 post = ref<SnPost | null>(null)
const loading = ref(true) const loading = ref(true)
const error = ref("") const error = ref("")
const htmlContent = 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() 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() { async function fetchPost() {
try { try {
const api = useSolarNetwork() const api = useSolarNetwork()
const resp = await api(`/sphere/posts/${id}`) const resp = await api(`/sphere/posts/${id}`)
post.value = resp as SnPost post.value = resp as SnPost
if (post.value.content) { 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) { } catch (e) {
error.value = e instanceof Error ? e.message : "Failed to load post" 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(() => { onMounted(() => {
fetchPost() fetchPost()
}) })

View File

@@ -19,12 +19,12 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"fslightbox-vue": "^2.2.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^16.3.0", "marked": "^16.3.0",
"nuxt": "^4.1.2", "nuxt": "^4.1.2",
"nuxt-og-image": "^5.1.11", "nuxt-og-image": "^5.1.11",
"pinia": "^3.0.3", "pinia": "^3.0.3",
"sharp": "^0.34.4",
"tailwindcss": "^4.1.13", "tailwindcss": "^4.1.13",
"tus-js-client": "^4.3.1", "tus-js-client": "^4.3.1",
"vue": "^3.5.21", "vue": "^3.5.21",
@@ -206,6 +206,52 @@
"@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="], "@humanwhocodes/retry": ["@humanwhocodes/retry@0.4.3", "", {}, "sha512-bV0Tgo9K4hfPCek+aMAn81RppFKv2ySDQeMoSZuvTASywNTnVJCArCZE2FWqpvIatKu7VMRLWlR1EazvVhDyhQ=="],
"@img/colour": ["@img/colour@1.0.0", "", {}, "sha512-A5P/LfWGFSl6nsckYtjw9da+19jB8hkJ6ACTGcDfEJ0aE+l2n2El7dsVM7UVHZQ9s2lmYMWlrS21YLy2IR1LUw=="],
"@img/sharp-darwin-arm64": ["@img/sharp-darwin-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-arm64": "1.2.3" }, "os": "darwin", "cpu": "arm64" }, "sha512-sitdlPzDVyvmINUdJle3TNHl+AG9QcwiAMsXmccqsCOMZNIdW2/7S26w0LyU8euiLVzFBL3dXPwVCq/ODnf2vA=="],
"@img/sharp-darwin-x64": ["@img/sharp-darwin-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-darwin-x64": "1.2.3" }, "os": "darwin", "cpu": "x64" }, "sha512-rZheupWIoa3+SOdF/IcUe1ah4ZDpKBGWcsPX6MT0lYniH9micvIU7HQkYTfrx5Xi8u+YqwLtxC/3vl8TQN6rMg=="],
"@img/sharp-libvips-darwin-arm64": ["@img/sharp-libvips-darwin-arm64@1.2.3", "", { "os": "darwin", "cpu": "arm64" }, "sha512-QzWAKo7kpHxbuHqUC28DZ9pIKpSi2ts2OJnoIGI26+HMgq92ZZ4vk8iJd4XsxN+tYfNJxzH6W62X5eTcsBymHw=="],
"@img/sharp-libvips-darwin-x64": ["@img/sharp-libvips-darwin-x64@1.2.3", "", { "os": "darwin", "cpu": "x64" }, "sha512-Ju+g2xn1E2AKO6YBhxjj+ACcsPQRHT0bhpglxcEf+3uyPY+/gL8veniKoo96335ZaPo03bdDXMv0t+BBFAbmRA=="],
"@img/sharp-libvips-linux-arm": ["@img/sharp-libvips-linux-arm@1.2.3", "", { "os": "linux", "cpu": "arm" }, "sha512-x1uE93lyP6wEwGvgAIV0gP6zmaL/a0tGzJs/BIDDG0zeBhMnuUPm7ptxGhUbcGs4okDJrk4nxgrmxpib9g6HpA=="],
"@img/sharp-libvips-linux-arm64": ["@img/sharp-libvips-linux-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-I4RxkXU90cpufazhGPyVujYwfIm9Nk1QDEmiIsaPwdnm013F7RIceaCc87kAH+oUB1ezqEvC6ga4m7MSlqsJvQ=="],
"@img/sharp-libvips-linux-ppc64": ["@img/sharp-libvips-linux-ppc64@1.2.3", "", { "os": "linux", "cpu": "ppc64" }, "sha512-Y2T7IsQvJLMCBM+pmPbM3bKT/yYJvVtLJGfCs4Sp95SjvnFIjynbjzsa7dY1fRJX45FTSfDksbTp6AGWudiyCg=="],
"@img/sharp-libvips-linux-s390x": ["@img/sharp-libvips-linux-s390x@1.2.3", "", { "os": "linux", "cpu": "s390x" }, "sha512-RgWrs/gVU7f+K7P+KeHFaBAJlNkD1nIZuVXdQv6S+fNA6syCcoboNjsV2Pou7zNlVdNQoQUpQTk8SWDHUA3y/w=="],
"@img/sharp-libvips-linux-x64": ["@img/sharp-libvips-linux-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-3JU7LmR85K6bBiRzSUc/Ff9JBVIFVvq6bomKE0e63UXGeRw2HPVEjoJke1Yx+iU4rL7/7kUjES4dZ/81Qjhyxg=="],
"@img/sharp-libvips-linuxmusl-arm64": ["@img/sharp-libvips-linuxmusl-arm64@1.2.3", "", { "os": "linux", "cpu": "arm64" }, "sha512-F9q83RZ8yaCwENw1GieztSfj5msz7GGykG/BA+MOUefvER69K/ubgFHNeSyUu64amHIYKGDs4sRCMzXVj8sEyw=="],
"@img/sharp-libvips-linuxmusl-x64": ["@img/sharp-libvips-linuxmusl-x64@1.2.3", "", { "os": "linux", "cpu": "x64" }, "sha512-U5PUY5jbc45ANM6tSJpsgqmBF/VsL6LnxJmIf11kB7J5DctHgqm0SkuXzVWtIY90GnJxKnC/JT251TDnk1fu/g=="],
"@img/sharp-linux-arm": ["@img/sharp-linux-arm@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm": "1.2.3" }, "os": "linux", "cpu": "arm" }, "sha512-Xyam4mlqM0KkTHYVSuc6wXRmM7LGN0P12li03jAnZ3EJWZqj83+hi8Y9UxZUbxsgsK1qOEwg7O0Bc0LjqQVtxA=="],
"@img/sharp-linux-arm64": ["@img/sharp-linux-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-YXU1F/mN/Wu786tl72CyJjP/Ngl8mGHN1hST4BGl+hiW5jhCnV2uRVTNOcaYPs73NeT/H8Upm3y9582JVuZHrQ=="],
"@img/sharp-linux-ppc64": ["@img/sharp-linux-ppc64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-ppc64": "1.2.3" }, "os": "linux", "cpu": "ppc64" }, "sha512-F4PDtF4Cy8L8hXA2p3TO6s4aDt93v+LKmpcYFLAVdkkD3hSxZzee0rh6/+94FpAynsuMpLX5h+LRsSG3rIciUQ=="],
"@img/sharp-linux-s390x": ["@img/sharp-linux-s390x@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-s390x": "1.2.3" }, "os": "linux", "cpu": "s390x" }, "sha512-qVrZKE9Bsnzy+myf7lFKvng6bQzhNUAYcVORq2P7bDlvmF6u2sCmK2KyEQEBdYk+u3T01pVsPrkj943T1aJAsw=="],
"@img/sharp-linux-x64": ["@img/sharp-linux-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linux-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-ZfGtcp2xS51iG79c6Vhw9CWqQC8l2Ot8dygxoDoIQPTat/Ov3qAa8qpxSrtAEAJW+UjTXc4yxCjNfxm4h6Xm2A=="],
"@img/sharp-linuxmusl-arm64": ["@img/sharp-linuxmusl-arm64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-arm64": "1.2.3" }, "os": "linux", "cpu": "arm64" }, "sha512-8hDVvW9eu4yHWnjaOOR8kHVrew1iIX+MUgwxSuH2XyYeNRtLUe4VNioSqbNkB7ZYQJj9rUTT4PyRscyk2PXFKA=="],
"@img/sharp-linuxmusl-x64": ["@img/sharp-linuxmusl-x64@0.34.4", "", { "optionalDependencies": { "@img/sharp-libvips-linuxmusl-x64": "1.2.3" }, "os": "linux", "cpu": "x64" }, "sha512-lU0aA5L8QTlfKjpDCEFOZsTYGn3AEiO6db8W5aQDxj0nQkVrZWmN3ZP9sYKWJdtq3PWPhUNlqehWyXpYDcI9Sg=="],
"@img/sharp-wasm32": ["@img/sharp-wasm32@0.34.4", "", { "dependencies": { "@emnapi/runtime": "^1.5.0" }, "cpu": "none" }, "sha512-33QL6ZO/qpRyG7woB/HUALz28WnTMI2W1jgX3Nu2bypqLIKx/QKMILLJzJjI+SIbvXdG9fUnmrxR7vbi1sTBeA=="],
"@img/sharp-win32-arm64": ["@img/sharp-win32-arm64@0.34.4", "", { "os": "win32", "cpu": "arm64" }, "sha512-2Q250do/5WXTwxW3zjsEuMSv5sUU4Tq9VThWKlU2EYLm4MB7ZeMwF+SFJutldYODXF6jzc6YEOC+VfX0SZQPqA=="],
"@img/sharp-win32-ia32": ["@img/sharp-win32-ia32@0.34.4", "", { "os": "win32", "cpu": "ia32" }, "sha512-3ZeLue5V82dT92CNL6rsal6I2weKw1cYu+rGKm8fOCCtJTR2gYeUfY3FqUnIJsMUPIH68oS5jmZ0NiJ508YpEw=="],
"@img/sharp-win32-x64": ["@img/sharp-win32-x64@0.34.4", "", { "os": "win32", "cpu": "x64" }, "sha512-xIyj4wpYs8J18sVN3mSQjwrw7fKUqRw+Z5rnHNCy5fYTxigBz81u5mOMPmFumwjcn8+ld1ppptMBCLic1nz6ig=="],
"@intlify/bundle-utils": ["@intlify/bundle-utils@11.0.1", "", { "dependencies": { "@intlify/message-compiler": "^11.1.10", "@intlify/shared": "^11.1.10", "acorn": "^8.8.2", "esbuild": "^0.25.4", "escodegen": "^2.1.0", "estree-walker": "^2.0.2", "jsonc-eslint-parser": "^2.3.0", "source-map-js": "^1.0.2", "yaml-eslint-parser": "^1.2.2" } }, "sha512-5l10G5wE2cQRsZMS9y0oSFMOLW5IG/SgbkIUltqnwF1EMRrRbUAHFiPabXdGTHeexCsMTcxj/1w9i0rzjJU9IQ=="], "@intlify/bundle-utils": ["@intlify/bundle-utils@11.0.1", "", { "dependencies": { "@intlify/message-compiler": "^11.1.10", "@intlify/shared": "^11.1.10", "acorn": "^8.8.2", "esbuild": "^0.25.4", "escodegen": "^2.1.0", "estree-walker": "^2.0.2", "jsonc-eslint-parser": "^2.3.0", "source-map-js": "^1.0.2", "yaml-eslint-parser": "^1.2.2" } }, "sha512-5l10G5wE2cQRsZMS9y0oSFMOLW5IG/SgbkIUltqnwF1EMRrRbUAHFiPabXdGTHeexCsMTcxj/1w9i0rzjJU9IQ=="],
"@intlify/core": ["@intlify/core@11.1.12", "", { "dependencies": { "@intlify/core-base": "11.1.12", "@intlify/shared": "11.1.12" } }, "sha512-Uccp4VtalUSk/b4F9nBBs7VGgIh9VnXTSHHQ+Kc0AetsHJLxdi04LfhfSi4dujtsTAWnHMHWZw07UbMm6Umq1g=="], "@intlify/core": ["@intlify/core@11.1.12", "", { "dependencies": { "@intlify/core-base": "11.1.12", "@intlify/shared": "11.1.12" } }, "sha512-Uccp4VtalUSk/b4F9nBBs7VGgIh9VnXTSHHQ+Kc0AetsHJLxdi04LfhfSi4dujtsTAWnHMHWZw07UbMm6Umq1g=="],
@@ -1098,8 +1144,6 @@
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"fslightbox-vue": ["fslightbox-vue@2.2.1", "", { "peerDependencies": { "vue": ">=2.5.0" } }, "sha512-GMlp8JoyRxN8dJuIGQCoB2O9CWnxG7uTK4bBzaw+VyXyVUHFA30UPRXSUFFnHuprX1qF+L0f7oimVF/FGSDwgA=="],
"function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="], "function-bind": ["function-bind@1.1.2", "", {}, "sha512-7XHNxH7qX9xG5mIwxkhumTox/MIRNcOgDrxWsMt2pAr23WHp6MrRlN7FBSFpCpr+oVO0F744iUgR82nJMfG2SA=="],
"fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="], "fuse.js": ["fuse.js@7.1.0", "", {}, "sha512-trLf4SzuuUxfusZADLINj+dE8clK1frKdmqiJNb1Es75fmI5oY6X2mxLVUciLLjxqw/xr72Dhy+lER6dGd02FQ=="],
@@ -1688,7 +1732,7 @@
"setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="], "setprototypeof": ["setprototypeof@1.2.0", "", {}, "sha512-E5LDX7Wrp85Kil5bhZv46j8jOeboKq5JMmYM3gVGdGH8xFpPWXUMsNrlODCrkoxMEeNi/XZIwuRvY4XNwYMJpw=="],
"sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="], "sharp": ["sharp@0.34.4", "", { "dependencies": { "@img/colour": "^1.0.0", "detect-libc": "^2.1.0", "semver": "^7.7.2" }, "optionalDependencies": { "@img/sharp-darwin-arm64": "0.34.4", "@img/sharp-darwin-x64": "0.34.4", "@img/sharp-libvips-darwin-arm64": "1.2.3", "@img/sharp-libvips-darwin-x64": "1.2.3", "@img/sharp-libvips-linux-arm": "1.2.3", "@img/sharp-libvips-linux-arm64": "1.2.3", "@img/sharp-libvips-linux-ppc64": "1.2.3", "@img/sharp-libvips-linux-s390x": "1.2.3", "@img/sharp-libvips-linux-x64": "1.2.3", "@img/sharp-libvips-linuxmusl-arm64": "1.2.3", "@img/sharp-libvips-linuxmusl-x64": "1.2.3", "@img/sharp-linux-arm": "0.34.4", "@img/sharp-linux-arm64": "0.34.4", "@img/sharp-linux-ppc64": "0.34.4", "@img/sharp-linux-s390x": "0.34.4", "@img/sharp-linux-x64": "0.34.4", "@img/sharp-linuxmusl-arm64": "0.34.4", "@img/sharp-linuxmusl-x64": "0.34.4", "@img/sharp-wasm32": "0.34.4", "@img/sharp-win32-arm64": "0.34.4", "@img/sharp-win32-ia32": "0.34.4", "@img/sharp-win32-x64": "0.34.4" } }, "sha512-FUH39xp3SBPnxWvd5iib1X8XY7J0K0X7d93sie9CJg2PO8/7gmg89Nve6OjItK53/MlAushNNxteBYfM6DEuoA=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="], "shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
@@ -2142,6 +2186,8 @@
"ipx/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="], "ipx/pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"ipx/sharp": ["sharp@0.32.6", "", { "dependencies": { "color": "^4.2.3", "detect-libc": "^2.0.2", "node-addon-api": "^6.1.0", "prebuild-install": "^7.1.1", "semver": "^7.5.4", "simple-get": "^4.0.1", "tar-fs": "^3.0.4", "tunnel-agent": "^0.6.0" } }, "sha512-KyLTWwgcR9Oe4d9HwCwNM2l7+J0dUQwn/yf7S0EnTtb0eVS4RxO0eUSvxPtzT4F3SY+C4K6fqdv/DO27sJ/v/w=="],
"is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="], "is-wsl/is-docker": ["is-docker@2.2.1", "", { "bin": { "is-docker": "cli.js" } }, "sha512-F+i2BKsFrH66iaUFc0woD8sLy8getkwTwtOBjvs56Cx4CgJDeKQeqfz8wAYiSb8JOprWhHH5p77PbmYCvvUuXQ=="],
"jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="], "jsonc-eslint-parser/eslint-visitor-keys": ["eslint-visitor-keys@3.4.3", "", {}, "sha512-wpc+LXeiyiisxPlEkUzU6svyS1frIO3Mgxj1fdy7Pm8Ygzguax2N3Fa/D/ag1WqbOprdI+uY6wMUl8/a2G+iag=="],
@@ -2216,8 +2262,6 @@
"rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="], "rollup-plugin-visualizer/open": ["open@8.4.2", "", { "dependencies": { "define-lazy-prop": "^2.0.0", "is-docker": "^2.1.1", "is-wsl": "^2.2.0" } }, "sha512-7x81NCL719oNbsq/3mh+hVrAWmFuEYUqrq/Iw3kUzH8ReypT9QQ0BLoJS7/G9k6N81XjW4qHWtjWwe/9eLy1EQ=="],
"sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="], "source-map-support/source-map": ["source-map@0.6.1", "", {}, "sha512-UjgapumWlbMhkBgzT7Ykc5YXUT46F0iKu8SGXq0bcwP5dz/h0Plj6enJqjz1Zbq2l5WaqYnrVbwWOWMyF3F47g=="],
"string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="], "string-width/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
@@ -2422,6 +2466,8 @@
"importx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.23.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg=="], "importx/esbuild/@esbuild/win32-x64": ["@esbuild/win32-x64@0.23.1", "", { "os": "win32", "cpu": "x64" }, "sha512-BHpFFeslkWrXWyUPnbKm+xYYVYruCinGcftSBaa8zoF9hZO4BcSCFUvHVTtzpIY6YzUnYtuEhZ+C9iEXjxnasg=="],
"ipx/sharp/node-addon-api": ["node-addon-api@6.1.0", "", {}, "sha512-+eawOlIgy680F0kBzPUNFhMZGtJ1YmqM6l4+Crf4IkImjYrO/mqPwRMh352g23uIaQKFItcQ64I7KMaJxHgAVA=="],
"lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="], "lazystream/readable-stream/safe-buffer": ["safe-buffer@5.1.2", "", {}, "sha512-Gd2UZBJDkXlY7GbJxfsE8/nvKkUEU1G38c1siN6QP6a9PT9MmHB8GnpscSmMJSoF8LOIrt8ud/wPtojys4G6+g=="],
"lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="], "lazystream/readable-stream/string_decoder": ["string_decoder@1.1.1", "", { "dependencies": { "safe-buffer": "~5.1.0" } }, "sha512-n/ShnvDi6FHbbVfviro+WojiFzv+s8MPMHBczVePfUpDJLwoLT0ht1l4YwBCbi8pJAveEEdnkHyPyTP/mzRfwg=="],

View File

@@ -15,6 +15,7 @@ export default defineNuxtConfig({
], ],
css: ["~/assets/css/main.css"], css: ["~/assets/css/main.css"],
app: { app: {
pageTransition: { name: 'page', mode: 'out-in' },
head: { head: {
titleTemplate: "%s - Solar Network" titleTemplate: "%s - Solar Network"
} }

View File

@@ -25,7 +25,6 @@
"blurhash": "^2.0.5", "blurhash": "^2.0.5",
"cfturnstile-vue3": "^2.0.0", "cfturnstile-vue3": "^2.0.0",
"eslint": "^9.0.0", "eslint": "^9.0.0",
"fslightbox-vue": "^2.2.1",
"luxon": "^3.7.2", "luxon": "^3.7.2",
"marked": "^16.3.0", "marked": "^16.3.0",
"nuxt": "^4.1.2", "nuxt": "^4.1.2",