♻️ Refactored some components to new UI

This commit is contained in:
2025-11-27 21:52:51 +08:00
parent 8af7037b24
commit 040e19025e
19 changed files with 404 additions and 522 deletions

View File

@@ -1,8 +1,9 @@
<template>
<div :class="['flex gap-3 items-center', { 'gap-2': compact }]">
<v-avatar
:image="publisherAvatar"
:size="compact ? 24 : 40"
<n-avatar
round
:src="publisherAvatar"
:size="compact ? 24 : 36"
:border="compact"
@click="router.push('/publishers/' + props.item.publisher.name)"
/>

View File

@@ -1,107 +1,117 @@
<template>
<div :class="['card', { 'shadow-none': props.flat }]">
<div :class="['card-body', { 'p-0': props.slim }]">
<div :class="['flex flex-col', compact ? 'gap-1' : 'gap-3']">
<post-header :item="props.item" :compact="compact" />
<n-card>
<div :class="['flex flex-col', compact ? 'gap-1' : 'gap-3']">
<post-header :item="props.item" :compact="compact" />
<div v-if="props.item.title || props.item.description">
<h2 v-if="props.item.title" class="text-lg">
{{ props.item.title }}
</h2>
<p v-if="props.item.description" class="text-sm">
{{ props.item.description }}
</p>
</div>
<div v-if="props.item.title || props.item.description">
<h2 v-if="props.item.title" class="text-lg">
{{ props.item.title }}
</h2>
<p v-if="props.item.description" class="text-sm">
{{ props.item.description }}
</p>
</div>
<article
v-if="htmlContent"
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0 max-w-none"
>
<div v-html="htmlContent" />
</article>
<article
v-if="htmlContent"
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0 max-w-none"
>
<div v-html="htmlContent" />
</article>
<template v-if="showReferenced">
<div v-if="props.item.repliedPost || props.item.repliedGone" class="border rounded-md">
<div class="p-2 flex items-center gap-2">
<span class="mdi mdi-reply"></span>
<span class="font-bold">Replying to</span>
</div>
<div v-if="props.item.repliedGone" class="px-4 pb-3 text-sm opacity-60">
Post unavailable
</div>
<post-item
v-else-if="props.item.repliedPost"
class="px-4 pb-3"
:item="props.item.repliedPost"
slim
compact
flat
@react="handleReaction"
/>
</div>
<div v-if="props.item.forwardedPost || props.item.forwardedGone" class="border rounded-md">
<div class="p-2 flex items-center gap-2">
<span class="mdi mdi-forward"></span>
<span class="font-bold">Forwarded</span>
</div>
<div v-if="props.item.forwardedGone" class="px-4 pb-3 text-sm opacity-60">
Post unavailable
</div>
<post-item
v-else-if="props.item.forwardedPost"
class="px-4 pb-3"
:item="props.item.forwardedPost"
slim
compact
flat
@react="handleReaction"
/>
</div>
</template>
<attachment-list
v-if="!compact"
:attachments="props.item.attachments"
:max-height="640"
/>
<div ref="repliesTarget">
<replies-compact-list
v-if="props.item.repliesCount && !compact && repliesVisible"
:params="{ postId: props.item.id }"
:hide-quick-reply="true"
@react="handleReplyReaction"
/>
</div>
<template v-if="showReferenced">
<div
v-if="props.item.isTruncated"
class="flex gap-2 text-xs opacity-80 items-center"
v-if="props.item.repliedPost || props.item.repliedGone"
class="border rounded-md"
>
<span class="mdi mdi-dots-horizontal"></span>
<p>Post truncated, tap to see details...</p>
</div>
<!-- Post Reactions -->
<div v-if="!compact" @click.stop>
<post-reaction-list
:parent-id="props.item.id"
:reactions="props.item.reactionsCount"
:reactions-made="props.item.reactionsMade"
:can-react="true"
<div class="p-2 flex items-center gap-2">
<span class="mdi mdi-reply"></span>
<span class="font-bold">Replying to</span>
</div>
<div
v-if="props.item.repliedGone"
class="px-4 pb-3 text-sm opacity-60"
>
Post unavailable
</div>
<post-item
v-else-if="props.item.repliedPost"
class="px-4 pb-3"
:item="props.item.repliedPost"
slim
compact
flat
@react="handleReaction"
/>
</div>
<div
v-if="props.item.forwardedPost || props.item.forwardedGone"
class="border rounded-md"
>
<div class="p-2 flex items-center gap-2">
<span class="mdi mdi-forward"></span>
<span class="font-bold">Forwarded</span>
</div>
<div
v-if="props.item.forwardedGone"
class="px-4 pb-3 text-sm opacity-60"
>
Post unavailable
</div>
<post-item
v-else-if="props.item.forwardedPost"
class="px-4 pb-3"
:item="props.item.forwardedPost"
slim
compact
flat
@react="handleReaction"
/>
</div>
</template>
<attachment-list
v-if="!compact"
:attachments="props.item.attachments"
:max-height="640"
/>
<div ref="repliesTarget">
<replies-compact-list
v-if="props.item.repliesCount && !compact && repliesVisible"
:params="{ postId: props.item.id }"
:hide-quick-reply="true"
@react="handleReplyReaction"
/>
</div>
<div
v-if="props.item.isTruncated"
class="flex gap-2 text-xs opacity-80 items-center"
>
<span class="mdi mdi-dots-horizontal"></span>
<p>Post truncated, tap to see details...</p>
</div>
<!-- Post Reactions -->
<div v-if="!compact" @click.stop>
<post-reaction-list
:parent-id="props.item.id"
:reactions="props.item.reactionsCount"
:reactions-made="props.item.reactionsMade"
:can-react="true"
@react="handleReaction"
/>
</div>
</div>
</div>
</n-card>
</template>
<script lang="ts" setup>
import { ref, watch } from "vue"
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { SnPost } from "~/types/api"
import { useIntersectionObserver } from '@vueuse/core'
import { useIntersectionObserver } from "@vueuse/core"
const props = withDefaults(
defineProps<{
@@ -152,8 +162,9 @@ const repliesVisible = ref(false)
useIntersectionObserver(
repliesTarget,
([{ isIntersecting }]) => {
if (isIntersecting) {
(entries) => {
const entry = entries[0]
if (entry?.isIntersecting) {
repliesVisible.value = true
}
},

View File

@@ -1,140 +1,57 @@
<template>
<div class="d-flex flex-wrap gap-2">
<div class="flex flex-wrap gap-3">
<!-- Add Reaction Button -->
<v-chip
<n-tag
v-if="canReact"
rounded
clickable
style="cursor: pointer"
type="primary"
:disabled="submitting"
prepend-icon="mdi-plus"
@click="showReactionDialog"
>
<template #icon>
<n-icon :component="HeartPlus" />
</template>
React
</v-chip>
</n-tag>
<!-- Existing Reactions -->
<v-chip
v-for="(count, symbol) in reactions"
:key="symbol"
rounded
: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>
<n-space>
<n-tag
v-for="(count, symbol) in reactions"
:key="symbol"
:type="getReactionColor(symbol)"
:disabled="submitting"
@click="reactToPost(symbol)"
style="cursor: pointer"
class="reaction-tag"
>
<span class="reaction-emoji">{{ getReactionEmoji(symbol) }}</span>
<span class="reaction-symbol ms-2">{{ symbol }}</span>
<code class="text-xs ms-1.5">x{{ count }}</code>
</n-tag>
</n-space>
</div>
<!-- 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 -->
<n-modal v-model:show="reactionDialog">
<n-card class="max-w-[540px]">
<template #header>
<span class="font-bold">React Post</span>
</template>
<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="font-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-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-xs"
>
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="font-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-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-xs"
>
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="font-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-xs text-center mb-1">{{
reaction.symbol
}}</span>
<span
v-if="getReactionCount(reaction.symbol) > 0"
class="text-xs"
>
x{{ getReactionCount(reaction.symbol) }}
</span>
<div v-else class="spacer"></div>
</div>
</v-card>
</div>
</div>
<n-alert type="info" title="Reaction not available">
Due to various of reasons, we stop providing the react creation on the
FloatingIsland. To react post, head to web.solian.app
</n-alert>
</div>
</v-card>
</v-dialog>
</n-card>
</n-modal>
</template>
<script setup lang="ts">
import { ref } from "vue"
import { Smile, Meh, Frown, HeartPlus } from "lucide-vue-next"
interface Props {
parentId: string
@@ -191,12 +108,21 @@ function getReactionEmoji(symbol: string): string {
return reaction?.emoji || "❓"
}
function getReactionColor(symbol: string): string {
const attitude =
availableReactions.find((r) => r.symbol === symbol)?.attitude || 1
function getReactionColor(
symbol: string
):
| "success"
| "error"
| "primary"
| "default"
| "info"
| "warning"
| undefined {
const attitude = availableReactions.find((r) => r.symbol === symbol)?.attitude
if (attitude === 0) return "success"
if (attitude === 2) return "error"
return "primary"
// neutral or unspecified attitudes use default
return "default"
}
async function reactToPost(symbol: string) {
@@ -255,64 +181,3 @@ function getReactionCount(symbol: string): number {
return (props.reactions || {})[symbol] || 0
}
</script>
<style scoped>
.reaction-emoji {
font-size: 16px;
margin-right: 4px;
}
.reaction-symbol {
font-size: 12px;
font-weight: 500;
}
.reaction-count {
font-size: 10px;
padding: 0 4px;
}
.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

@@ -1,29 +1,22 @@
<template>
<v-card
<n-card
class="replies-compact-list"
flat
border
embedded
title="Replies"
prepend-icon="mdi-comment-text-multiple"
density="compact"
size="small"
>
<!-- Error State -->
<v-alert
v-if="hasError"
type="error"
class="mb-4"
closable
@click:close="refresh"
>
<n-alert v-if="hasError" type="error" closable @click:close="refresh">
{{ error }}
</v-alert>
</n-alert>
<!-- Replies List -->
<div class="flex flex-col gap-2 pb-2.5">
<div class="flex flex-col gap-2">
<template v-for="item in replies" :key="item.id">
<v-sheet class="px-4" @click="router.push('/posts/' + item.id)">
<div @click="router.push('/posts/' + item.id)">
<div class="flex gap-3">
<v-avatar :image="getPublisherAvatar(item)" size="24" border />
<n-avatar :src="getPublisherAvatar(item)" :size="24" round />
<article
v-if="getHtmlContent(item)"
class="prose prose-sm dark:prose-invert prose-slate prose-p:m-0 max-w-none flex-1"
@@ -31,24 +24,22 @@
<div v-html="getHtmlContent(item)" />
</article>
</div>
</v-sheet>
</div>
</template>
<!-- Empty State -->
<div v-if="!replies || replies.length === 0" class="text-center py-8 text-muted-foreground">
<v-icon
icon="mdi-comment-outline"
size="48"
class="mb-2 opacity-50"
/>
<div
v-if="!replies || replies.length === 0"
class="text-center py-8 text-muted-foreground"
>
<v-icon icon="mdi-comment-outline" size="48" class="mb-2 opacity-50" />
<p>No replies yet</p>
</div>
</div>
</v-card>
</n-card>
</template>
<script setup lang="ts">
import { useRepliesList } from "~/composables/useRepliesList"
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
import type { RepliesListParams } from "~/composables/useRepliesList"

View File

@@ -3,15 +3,15 @@
<post-quick-reply v-if="!props.hideQuickReply" class="mb-4" />
<!-- Error State -->
<v-alert
<n-alert
v-if="hasError"
type="error"
class="mb-4"
closable
@click:close="refresh"
:closable="true"
@close="refresh"
>
{{ error }}
</v-alert>
</n-alert>
<!-- Replies List -->
<v-infinite-scroll
@@ -36,18 +36,16 @@
<!-- Loading State -->
<template #loading>
<div class="flex justify-center py-4">
<v-progress-circular indeterminate size="32" />
<n-spin size="large" />
</div>
</template>
<!-- Empty State -->
<template #empty>
<div v-if="!replies" class="text-center py-8 text-muted-foreground">
<v-icon
icon="mdi-comment-outline"
size="48"
class="mb-2 opacity-50"
/>
<n-icon size="48" class="mb-2 opacity-50">
<i class="mdi mdi-comment-outline"></i>
</n-icon>
<p>No replies yet</p>
</div>
</template>