diff --git a/app/components/Post/PostEditor.vue b/app/components/Post/PostEditor.vue index 29cc353..c5b5573 100644 --- a/app/components/Post/PostEditor.vue +++ b/app/components/Post/PostEditor.vue @@ -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 diff --git a/app/components/Post/PostItem.vue b/app/components/Post/PostItem.vue index 7fc1b54..8e954a8 100644 --- a/app/components/Post/PostItem.vue +++ b/app/components/Post/PostItem.vue @@ -22,6 +22,10 @@ +
+ +

{{ props.item.repliesCount }} replies

+

Post truncated, tap to see details...

@@ -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] diff --git a/app/components/Post/PostList.vue b/app/components/Post/PostList.vue new file mode 100644 index 0000000..69496c2 --- /dev/null +++ b/app/components/Post/PostList.vue @@ -0,0 +1,73 @@ + + + diff --git a/app/components/Post/RepliesList.vue b/app/components/Post/RepliesList.vue new file mode 100644 index 0000000..fb0b69e --- /dev/null +++ b/app/components/Post/RepliesList.vue @@ -0,0 +1,78 @@ + + + + + diff --git a/app/composables/usePostList.ts b/app/composables/usePostList.ts new file mode 100644 index 0000000..3d24eed --- /dev/null +++ b/app/composables/usePostList.ts @@ -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({ + 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("/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 + } +} diff --git a/app/composables/useRepliesList.ts b/app/composables/useRepliesList.ts new file mode 100644 index 0000000..d67ed67 --- /dev/null +++ b/app/composables/useRepliesList.ts @@ -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({ + 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 = { + 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( + `/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 + } +} diff --git a/app/pages/posts/[id].vue b/app/pages/posts/[id].vue index 09e6926..727f41d 100644 --- a/app/pages/posts/[id].vue +++ b/app/pages/posts/[id].vue @@ -70,6 +70,10 @@ + + + +
@@ -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(() => {