✨ Post replies list
This commit is contained in:
@@ -23,8 +23,6 @@ import { ref } from 'vue'
|
||||
import * as tus from 'tus-js-client'
|
||||
import { useSolarNetwork } from '~/composables/useSolarNetwork'
|
||||
|
||||
import PubSelect from './PubSelect.vue'
|
||||
|
||||
// Interface for uploaded files in the editor
|
||||
interface UploadedFile {
|
||||
name: string
|
||||
|
||||
@@ -22,6 +22,10 @@
|
||||
|
||||
<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">
|
||||
<v-icon icon="mdi-dots-horizontal" size="small" />
|
||||
<p>Post truncated, tap to see details...</p>
|
||||
@@ -47,10 +51,6 @@ import { ref, watch } from "vue"
|
||||
import { useMarkdownProcessor } from "~/composables/useMarkdownProcessor"
|
||||
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 emit = defineEmits<{
|
||||
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 -->
|
||||
<attachment-list :attachments="post.attachments || []" />
|
||||
</v-card>
|
||||
|
||||
<v-card title="Replies" prepend-icon="mdi-comment-text-multiple" color="transparent" flat>
|
||||
<replies-list :params="{ postId: post.id }" />
|
||||
</v-card>
|
||||
</div>
|
||||
|
||||
<!-- Sidebar Column -->
|
||||
@@ -162,7 +166,9 @@ const {
|
||||
const post = computed(() => postData.value?.post || null)
|
||||
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({
|
||||
title: computed(() => {
|
||||
|
||||
Reference in New Issue
Block a user