✨ Post replies list
This commit is contained in:
@@ -23,8 +23,6 @@ import { ref } from 'vue'
|
|||||||
import * as tus from 'tus-js-client'
|
import * as tus from 'tus-js-client'
|
||||||
import { useSolarNetwork } from '~/composables/useSolarNetwork'
|
import { useSolarNetwork } from '~/composables/useSolarNetwork'
|
||||||
|
|
||||||
import PubSelect from './PubSelect.vue'
|
|
||||||
|
|
||||||
// Interface for uploaded files in the editor
|
// Interface for uploaded files in the editor
|
||||||
interface UploadedFile {
|
interface UploadedFile {
|
||||||
name: string
|
name: string
|
||||||
|
|||||||
@@ -22,6 +22,10 @@
|
|||||||
|
|
||||||
<attachment-list :attachments="props.item.attachments" :max-height="640" />
|
<attachment-list :attachments="props.item.attachments" :max-height="640" />
|
||||||
|
|
||||||
|
<div v-if="props.item.repliesCount" class="flex gap-2 text-xs opacity-80">
|
||||||
|
<v-icon icon="mdi-comment-text-multiple" size="small" />
|
||||||
|
<p>{{ props.item.repliesCount }} replies</p>
|
||||||
|
</div>
|
||||||
<div v-if="props.item.isTruncated" class="flex gap-2 text-xs opacity-80">
|
<div v-if="props.item.isTruncated" class="flex gap-2 text-xs opacity-80">
|
||||||
<v-icon icon="mdi-dots-horizontal" size="small" />
|
<v-icon icon="mdi-dots-horizontal" size="small" />
|
||||||
<p>Post truncated, tap to see details...</p>
|
<p>Post truncated, tap to see details...</p>
|
||||||
@@ -47,10 +51,6 @@ import { ref, watch } from "vue"
|
|||||||
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
||||||
import type { SnPost } from "~/types/api"
|
import type { SnPost } from "~/types/api"
|
||||||
|
|
||||||
import PostHeader from "./PostHeader.vue"
|
|
||||||
import AttachmentList from "./AttachmentList.vue"
|
|
||||||
import PostReactionList from "./PostReactionList.vue"
|
|
||||||
|
|
||||||
const props = defineProps<{ item: SnPost }>()
|
const props = defineProps<{ item: SnPost }>()
|
||||||
const emit = defineEmits<{
|
const emit = defineEmits<{
|
||||||
react: [symbol: string, attitude: number, delta: number]
|
react: [symbol: string, attitude: number, delta: number]
|
||||||
|
|||||||
73
app/components/Post/PostList.vue
Normal file
73
app/components/Post/PostList.vue
Normal file
@@ -0,0 +1,73 @@
|
|||||||
|
<template>
|
||||||
|
<div class="post-list">
|
||||||
|
<!-- Error State -->
|
||||||
|
<v-alert
|
||||||
|
v-if="hasError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
closable
|
||||||
|
@click:close="refresh"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Posts List -->
|
||||||
|
<v-infinite-scroll
|
||||||
|
height="auto"
|
||||||
|
class="space-y-4"
|
||||||
|
@load="loadMore"
|
||||||
|
>
|
||||||
|
<template v-for="item in posts" :key="item.id">
|
||||||
|
<post-item
|
||||||
|
:item="item"
|
||||||
|
@react="(symbol, attitude, delta) => $emit('react', item.id, symbol, attitude, delta)"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<template #loading>
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<v-progress-circular indeterminate size="32" />
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Empty State -->
|
||||||
|
<template #empty>
|
||||||
|
<div v-if="!posts" class="text-center py-8 text-muted-foreground">
|
||||||
|
<v-icon icon="mdi-post-outline" size="48" class="mb-2 opacity-50" />
|
||||||
|
<p>No posts found</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-infinite-scroll>
|
||||||
|
|
||||||
|
<!-- Refresh Button -->
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<v-btn
|
||||||
|
variant="outlined"
|
||||||
|
:loading="isLoading"
|
||||||
|
prepend-icon="mdi-refresh"
|
||||||
|
@click="refresh"
|
||||||
|
>
|
||||||
|
Refresh
|
||||||
|
</v-btn>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { usePostList } from "~/composables/usePostList"
|
||||||
|
import type { PostListParams } from "~/composables/usePostList"
|
||||||
|
|
||||||
|
import PostItem from "./PostItem.vue"
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
params?: PostListParams
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
react: [postId: string, symbol: string, attitude: number, delta: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { posts, isLoading, hasError, error, loadMore, refresh } =
|
||||||
|
usePostList(props.params)
|
||||||
|
</script>
|
||||||
78
app/components/Post/RepliesList.vue
Normal file
78
app/components/Post/RepliesList.vue
Normal file
@@ -0,0 +1,78 @@
|
|||||||
|
<template>
|
||||||
|
<div class="replies-list">
|
||||||
|
<!-- Error State -->
|
||||||
|
<v-alert
|
||||||
|
v-if="hasError"
|
||||||
|
type="error"
|
||||||
|
class="mb-4"
|
||||||
|
closable
|
||||||
|
@click:close="refresh"
|
||||||
|
>
|
||||||
|
{{ error }}
|
||||||
|
</v-alert>
|
||||||
|
|
||||||
|
<!-- Replies List -->
|
||||||
|
<v-infinite-scroll
|
||||||
|
class="flex flex-col gap-4 mt-0"
|
||||||
|
height="auto"
|
||||||
|
side="end"
|
||||||
|
manual
|
||||||
|
@load="loadMore"
|
||||||
|
>
|
||||||
|
<template v-for="item in replies" :key="item.id">
|
||||||
|
<post-item
|
||||||
|
:item="item"
|
||||||
|
@click="router.push('/posts/' + item.id)"
|
||||||
|
@react="
|
||||||
|
(symbol, attitude, delta) =>
|
||||||
|
$emit('react', item.id, symbol, attitude, delta)
|
||||||
|
"
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<!-- Loading State -->
|
||||||
|
<template #loading>
|
||||||
|
<div class="flex justify-center py-4">
|
||||||
|
<v-progress-circular indeterminate size="32" />
|
||||||
|
</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"
|
||||||
|
/>
|
||||||
|
<p>No replies yet</p>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
</v-infinite-scroll>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import { useRepliesList } from "~/composables/useRepliesList"
|
||||||
|
import type { RepliesListParams } from "~/composables/useRepliesList"
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
|
||||||
|
const props = defineProps<{
|
||||||
|
params: RepliesListParams
|
||||||
|
}>()
|
||||||
|
|
||||||
|
defineEmits<{
|
||||||
|
react: [postId: string, symbol: string, attitude: number, delta: number]
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const { replies, hasError, error, loadMore, refresh } = useRepliesList(
|
||||||
|
props.params
|
||||||
|
)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<style>
|
||||||
|
.replies-list .v-infinite-scroll:first-child .v-infinite-scroll__side {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
</style>
|
||||||
156
app/composables/usePostList.ts
Normal file
156
app/composables/usePostList.ts
Normal file
@@ -0,0 +1,156 @@
|
|||||||
|
import { ref, computed } from "vue"
|
||||||
|
import type { SnPost } from "~/types/api"
|
||||||
|
|
||||||
|
export interface PostListParams {
|
||||||
|
pubName?: string
|
||||||
|
realm?: string
|
||||||
|
type?: number
|
||||||
|
categories?: string[]
|
||||||
|
tags?: string[]
|
||||||
|
pinned?: boolean
|
||||||
|
shuffle?: boolean
|
||||||
|
includeReplies?: boolean
|
||||||
|
mediaOnly?: boolean
|
||||||
|
queryTerm?: string
|
||||||
|
order?: string
|
||||||
|
periodStart?: number
|
||||||
|
periodEnd?: number
|
||||||
|
orderDesc?: boolean
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface PostListState {
|
||||||
|
posts: SnPost[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
hasMore: boolean
|
||||||
|
cursor: string | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const usePostList = (params: PostListParams = {}) => {
|
||||||
|
const api = useSolarNetwork()
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const state = ref<PostListState>({
|
||||||
|
posts: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
hasMore: true,
|
||||||
|
cursor: null,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = computed(() => state.value.loading)
|
||||||
|
const hasError = computed(() => state.value.error !== null)
|
||||||
|
const posts = computed(() => state.value.posts)
|
||||||
|
const hasMore = computed(() => state.value.hasMore)
|
||||||
|
|
||||||
|
const buildQueryParams = (cursor: string | null = null) => {
|
||||||
|
const offset = cursor ? parseInt(cursor) : 0
|
||||||
|
|
||||||
|
const queryParams: Record<
|
||||||
|
string,
|
||||||
|
string | number | boolean | string[] | undefined
|
||||||
|
> = {
|
||||||
|
offset,
|
||||||
|
take: pageSize,
|
||||||
|
replies: params.includeReplies,
|
||||||
|
orderDesc: params.orderDesc ?? true
|
||||||
|
}
|
||||||
|
|
||||||
|
if (params.shuffle) queryParams.shuffle = params.shuffle
|
||||||
|
if (params.pubName) queryParams.pub = params.pubName
|
||||||
|
if (params.realm) queryParams.realm = params.realm
|
||||||
|
if (params.type !== undefined) queryParams.type = params.type
|
||||||
|
if (params.tags) queryParams.tags = params.tags
|
||||||
|
if (params.categories) queryParams.categories = params.categories
|
||||||
|
if (params.pinned !== undefined) queryParams.pinned = params.pinned
|
||||||
|
if (params.order) queryParams.order = params.order
|
||||||
|
if (params.periodStart) queryParams.periodStart = params.periodStart
|
||||||
|
if (params.periodEnd) queryParams.periodEnd = params.periodEnd
|
||||||
|
if (params.queryTerm) queryParams.query = params.queryTerm
|
||||||
|
if (params.mediaOnly !== undefined) queryParams.media = params.mediaOnly
|
||||||
|
|
||||||
|
return queryParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchPosts = async (cursor: string | null = null, append = false) => {
|
||||||
|
try {
|
||||||
|
state.value.loading = true
|
||||||
|
state.value.error = null
|
||||||
|
|
||||||
|
const queryParams = buildQueryParams(cursor)
|
||||||
|
|
||||||
|
let total: number = 0
|
||||||
|
const response = await api<SnPost[]>("/sphere/posts", {
|
||||||
|
method: "GET",
|
||||||
|
query: queryParams,
|
||||||
|
onResponse({ response }) {
|
||||||
|
total = parseInt(response.headers.get("x-total") || "0")
|
||||||
|
if (response._data) {
|
||||||
|
response._data = keysToCamel(response._data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
})
|
||||||
|
|
||||||
|
const fetchedPosts = response
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.value.posts = [...state.value.posts, ...fetchedPosts]
|
||||||
|
} else {
|
||||||
|
state.value.posts = fetchedPosts
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've reached the end based on X-Total header
|
||||||
|
const currentTotal = state.value.posts.length
|
||||||
|
const hasReachedEnd = total > 0 && currentTotal >= total
|
||||||
|
|
||||||
|
state.value.hasMore = !hasReachedEnd && fetchedPosts.length === pageSize
|
||||||
|
state.value.cursor = state.value.hasMore
|
||||||
|
? ((cursor ? parseInt(cursor) : 0) + fetchedPosts.length).toString()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { hasReachedEnd }
|
||||||
|
} catch (error) {
|
||||||
|
state.value.error =
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch posts"
|
||||||
|
console.error("Error fetching posts:", error)
|
||||||
|
return { hasReachedEnd: false }
|
||||||
|
} finally {
|
||||||
|
state.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async (options?: {
|
||||||
|
side: string
|
||||||
|
done: (status: "empty" | "loading" | "error") => void
|
||||||
|
}) => {
|
||||||
|
if (!state.value.hasMore || state.value.loading) {
|
||||||
|
options?.done("empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchPosts(state.value.cursor, true)
|
||||||
|
|
||||||
|
if (result.hasReachedEnd) {
|
||||||
|
options?.done("empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
fetchPosts(null, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchPosts()
|
||||||
|
|
||||||
|
return {
|
||||||
|
posts,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
hasMore,
|
||||||
|
error: computed(() => state.value.error),
|
||||||
|
loadMore,
|
||||||
|
refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
128
app/composables/useRepliesList.ts
Normal file
128
app/composables/useRepliesList.ts
Normal file
@@ -0,0 +1,128 @@
|
|||||||
|
import { ref, computed } from "vue"
|
||||||
|
import type { SnPost } from "~/types/api"
|
||||||
|
|
||||||
|
export interface RepliesListParams {
|
||||||
|
postId: string
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface RepliesListState {
|
||||||
|
replies: SnPost[]
|
||||||
|
loading: boolean
|
||||||
|
error: string | null
|
||||||
|
hasMore: boolean
|
||||||
|
cursor: string | null
|
||||||
|
total: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useRepliesList = (params: RepliesListParams) => {
|
||||||
|
const api = useSolarNetwork()
|
||||||
|
const pageSize = 20
|
||||||
|
|
||||||
|
const state = ref<RepliesListState>({
|
||||||
|
replies: [],
|
||||||
|
loading: false,
|
||||||
|
error: null,
|
||||||
|
hasMore: true,
|
||||||
|
cursor: null,
|
||||||
|
total: 0
|
||||||
|
})
|
||||||
|
|
||||||
|
const isLoading = computed(() => state.value.loading)
|
||||||
|
const hasError = computed(() => state.value.error !== null)
|
||||||
|
const replies = computed(() => state.value.replies)
|
||||||
|
const hasMore = computed(() => state.value.hasMore)
|
||||||
|
|
||||||
|
const buildQueryParams = (cursor: string | null = null) => {
|
||||||
|
const offset = cursor ? parseInt(cursor) : 0
|
||||||
|
|
||||||
|
const queryParams: Record<string, string | number> = {
|
||||||
|
offset,
|
||||||
|
take: pageSize
|
||||||
|
}
|
||||||
|
|
||||||
|
return queryParams
|
||||||
|
}
|
||||||
|
|
||||||
|
const fetchReplies = async (cursor: string | null = null, append = false) => {
|
||||||
|
try {
|
||||||
|
state.value.loading = true
|
||||||
|
state.value.error = null
|
||||||
|
|
||||||
|
const queryParams = buildQueryParams(cursor)
|
||||||
|
|
||||||
|
let total: number = 0
|
||||||
|
const response = await api<SnPost[]>(
|
||||||
|
`/sphere/posts/${params.postId}/replies`,
|
||||||
|
{
|
||||||
|
method: "GET",
|
||||||
|
query: queryParams,
|
||||||
|
onResponse({ response }) {
|
||||||
|
total = parseInt(response.headers.get("x-total") || "0")
|
||||||
|
if (response._data) {
|
||||||
|
response._data = keysToCamel(response._data)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
)
|
||||||
|
|
||||||
|
const fetchedReplies = response
|
||||||
|
|
||||||
|
if (append) {
|
||||||
|
state.value.replies = [...state.value.replies, ...fetchedReplies]
|
||||||
|
} else {
|
||||||
|
state.value.replies = fetchedReplies
|
||||||
|
}
|
||||||
|
|
||||||
|
// Check if we've reached the end based on X-Total header
|
||||||
|
const currentTotal = state.value.replies.length
|
||||||
|
const hasReachedEnd = total > 0 && currentTotal >= total
|
||||||
|
|
||||||
|
state.value.hasMore = !hasReachedEnd && fetchedReplies.length === pageSize
|
||||||
|
state.value.cursor = state.value.hasMore
|
||||||
|
? ((cursor ? parseInt(cursor) : 0) + fetchedReplies.length).toString()
|
||||||
|
: null
|
||||||
|
|
||||||
|
return { hasReachedEnd }
|
||||||
|
} catch (error) {
|
||||||
|
state.value.error =
|
||||||
|
error instanceof Error ? error.message : "Failed to fetch replies"
|
||||||
|
console.error("Error fetching replies:", error)
|
||||||
|
return { hasReachedEnd: false }
|
||||||
|
} finally {
|
||||||
|
state.value.loading = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const loadMore = async (options?: {
|
||||||
|
side: string
|
||||||
|
done: (status: "empty" | "loading" | "error" | "ok") => void
|
||||||
|
}) => {
|
||||||
|
if (!state.value.hasMore || state.value.loading) {
|
||||||
|
options?.done("empty")
|
||||||
|
return
|
||||||
|
}
|
||||||
|
|
||||||
|
const result = await fetchReplies(state.value.cursor, true)
|
||||||
|
|
||||||
|
if (result.hasReachedEnd) {
|
||||||
|
options?.done("empty")
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const refresh = () => {
|
||||||
|
fetchReplies(null, false)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Initial load
|
||||||
|
fetchReplies()
|
||||||
|
|
||||||
|
return {
|
||||||
|
replies,
|
||||||
|
isLoading,
|
||||||
|
hasError,
|
||||||
|
hasMore,
|
||||||
|
error: computed(() => state.value.error),
|
||||||
|
loadMore,
|
||||||
|
refresh
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -70,6 +70,10 @@
|
|||||||
<!-- Attachments within Content Section -->
|
<!-- Attachments within Content Section -->
|
||||||
<attachment-list :attachments="post.attachments || []" />
|
<attachment-list :attachments="post.attachments || []" />
|
||||||
</v-card>
|
</v-card>
|
||||||
|
|
||||||
|
<v-card title="Replies" prepend-icon="mdi-comment-text-multiple" color="transparent" flat>
|
||||||
|
<replies-list :params="{ postId: post.id }" />
|
||||||
|
</v-card>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Sidebar Column -->
|
<!-- Sidebar Column -->
|
||||||
@@ -162,7 +166,9 @@ const {
|
|||||||
const post = computed(() => postData.value?.post || null)
|
const post = computed(() => postData.value?.post || null)
|
||||||
const htmlContent = computed(() => postData.value?.html || "")
|
const htmlContent = computed(() => postData.value?.html || "")
|
||||||
|
|
||||||
const classesContent = computed(() => postData.value?.post.type == 1 ? 'prose-xl' : 'prose-md');
|
const classesContent = computed(() =>
|
||||||
|
postData.value?.post.type == 1 ? "prose-xl" : "prose-md"
|
||||||
|
)
|
||||||
|
|
||||||
useHead({
|
useHead({
|
||||||
title: computed(() => {
|
title: computed(() => {
|
||||||
|
|||||||
Reference in New Issue
Block a user