feat: support query parameters to go to a specific page in the forum:

- `post_id`
- `page`
This commit is contained in:
FrenchGithubUser
2025-11-02 01:12:21 +01:00
parent 411eaced8e
commit ff269592e2
13 changed files with 186 additions and 57 deletions

View File

@@ -1,5 +1,5 @@
use arcadia_storage::models::collage::SearchCollagesQuery;
use arcadia_storage::models::series::SearchSeriesQuery; use arcadia_storage::models::series::SearchSeriesQuery;
use arcadia_storage::models::{collage::SearchCollagesQuery, forum::GetForumThreadPostsQuery};
use utoipa::{ use utoipa::{
openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme}, openapi::security::{HttpAuthScheme, HttpBuilder, SecurityScheme},
Modify, OpenApi, Modify, OpenApi,
@@ -92,7 +92,8 @@ use crate::handlers::{
GetUserApplicationsQuery, GetUserApplicationsQuery,
SearchTorrentRequestsQuery, SearchTorrentRequestsQuery,
SearchCollagesQuery, SearchCollagesQuery,
SearchSeriesQuery SearchSeriesQuery,
GetForumThreadPostsQuery
),) ),)
)] )]
pub struct ApiDoc; pub struct ApiDoc;

View File

@@ -8,13 +8,6 @@ use arcadia_storage::{models::forum::ForumThreadEnriched, redis::RedisPoolInterf
use serde::Deserialize; use serde::Deserialize;
use utoipa::IntoParams; use utoipa::IntoParams;
#[derive(Debug, Deserialize, IntoParams)]
pub struct GetForumThreadQuery {
pub title: String,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize, IntoParams)] #[derive(Debug, Deserialize, IntoParams)]
pub struct GetForumThreadQueryId { pub struct GetForumThreadQueryId {
pub id: i64, pub id: i64,

View File

@@ -5,25 +5,12 @@ use actix_web::{
}; };
use arcadia_common::error::Result; use arcadia_common::error::Result;
use arcadia_storage::{ use arcadia_storage::{
models::{common::PaginatedResults, forum::ForumPostHierarchy}, models::{
common::PaginatedResults,
forum::{ForumPostHierarchy, GetForumThreadPostsQuery},
},
redis::RedisPoolInterface, redis::RedisPoolInterface,
}; };
use serde::Deserialize;
use utoipa::IntoParams;
#[derive(Debug, Deserialize, IntoParams)]
pub struct GetForumThreadQuery {
pub title: String,
pub offset: Option<i64>,
pub limit: Option<i64>,
}
#[derive(Debug, Deserialize, IntoParams)]
pub struct GetForumThreadPostsQuery {
pub thread_id: i64,
pub page: u32,
pub page_size: u32,
}
#[utoipa::path( #[utoipa::path(
get, get,
@@ -41,10 +28,7 @@ pub async fn exec<R: RedisPoolInterface + 'static>(
) -> Result<HttpResponse> { ) -> Result<HttpResponse> {
//TODO: restrict access to some sub_categories based on forbidden_classes //TODO: restrict access to some sub_categories based on forbidden_classes
let thread = arc let thread = arc.pool.find_forum_thread_posts(query.into_inner()).await?;
.pool
.find_forum_thread_posts(query.thread_id, query.page, query.page_size)
.await?;
Ok(HttpResponse::Ok().json(thread)) Ok(HttpResponse::Ok().json(thread))
} }

View File

@@ -0,0 +1,23 @@
{
"db_name": "PostgreSQL",
"query": "\n SELECT COUNT(*)::BIGINT FROM forum_posts\n WHERE forum_thread_id = $1 AND id < $2\n ",
"describe": {
"columns": [
{
"ordinal": 0,
"name": "count",
"type_info": "Int8"
}
],
"parameters": {
"Left": [
"Int8",
"Int8"
]
},
"nullable": [
null
]
},
"hash": "0a8b1361933b00d93f861dee93762b059f94b0ca603ad4201a9a96143eaed957"
}

View File

@@ -1,7 +1,7 @@
use chrono::{DateTime, Local}; use chrono::{DateTime, Local};
use serde::{Deserialize, Serialize}; use serde::{Deserialize, Serialize};
use sqlx::prelude::FromRow; use sqlx::prelude::FromRow;
use utoipa::ToSchema; use utoipa::{IntoParams, ToSchema};
use super::user::{UserLite, UserLiteAvatar}; use super::user::{UserLite, UserLiteAvatar};
@@ -160,3 +160,11 @@ pub struct ForumPostAndThreadName {
pub sticky: bool, pub sticky: bool,
pub forum_thread_name: String, pub forum_thread_name: String,
} }
#[derive(Debug, Deserialize, IntoParams, ToSchema)]
pub struct GetForumThreadPostsQuery {
pub thread_id: i64,
pub page: Option<u32>,
pub page_size: u32,
pub post_id: Option<i64>,
}

View File

@@ -4,7 +4,8 @@ use crate::{
common::PaginatedResults, common::PaginatedResults,
forum::{ forum::{
ForumPost, ForumPostAndThreadName, ForumPostHierarchy, ForumThread, ForumPost, ForumPostAndThreadName, ForumPostHierarchy, ForumThread,
ForumThreadEnriched, UserCreatedForumPost, UserCreatedForumThread, ForumThreadEnriched, GetForumThreadPostsQuery, UserCreatedForumPost,
UserCreatedForumThread,
}, },
}, },
}; };
@@ -305,11 +306,30 @@ impl ConnectionPool {
pub async fn find_forum_thread_posts( pub async fn find_forum_thread_posts(
&self, &self,
forum_thread_id: i64, form: GetForumThreadPostsQuery,
page: u32,
page_size: u32,
) -> Result<PaginatedResults<ForumPostHierarchy>> { ) -> Result<PaginatedResults<ForumPostHierarchy>> {
let offset = (page - 1) * page_size; let page_size = form.page_size as i64;
let mut current_page = form.page.unwrap_or(1);
let offset = if let Some(post_id) = form.post_id {
let position = sqlx::query_scalar!(
r#"
SELECT COUNT(*)::BIGINT FROM forum_posts
WHERE forum_thread_id = $1 AND id < $2
"#,
form.thread_id,
post_id
)
.fetch_one(self.borrow())
.await?
.unwrap_or(0);
// i64 ceil division is unstable as of now
current_page = ((position + 1) as u64).div_ceil(form.page_size as u64) as u32;
((position / page_size) * page_size) as i64
} else {
((form.page.unwrap_or(1) - 1) as i64) * page_size
};
let forum_thread_data = sqlx::query!( let forum_thread_data = sqlx::query!(
r#" r#"
@@ -354,9 +374,9 @@ impl ConnectionPool {
LIMIT $3 LIMIT $3
) p; ) p;
"#, "#,
forum_thread_id, form.thread_id,
offset as i64, offset,
page_size as i64 page_size
) )
.fetch_one(self.borrow()) .fetch_one(self.borrow())
.await .await
@@ -372,8 +392,8 @@ impl ConnectionPool {
let paginated_results = PaginatedResults { let paginated_results = PaginatedResults {
results: posts, results: posts,
page, page: current_page,
page_size, page_size: form.page_size,
total_items: forum_thread_data.total_items.unwrap_or(0), total_items: forum_thread_data.total_items.unwrap_or(0),
}; };

View File

@@ -315,7 +315,7 @@ export interface paths {
path?: never; path?: never;
cookie?: never; cookie?: never;
}; };
get: operations["Get forum thread"]; get: operations["Get forum thread's posts"];
put?: never; put?: never;
post?: never; post?: never;
delete?: never; delete?: never;
@@ -1415,6 +1415,16 @@ export interface components {
id: number; id: number;
name: string; name: string;
}; };
GetForumThreadPostsQuery: {
/** Format: int32 */
page?: number | null;
/** Format: int32 */
page_size: number;
/** Format: int64 */
post_id?: number | null;
/** Format: int64 */
thread_id: number;
};
GetUserApplicationsQuery: { GetUserApplicationsQuery: {
/** Format: int64 */ /** Format: int64 */
limit?: number | null; limit?: number | null;
@@ -3163,12 +3173,13 @@ export interface operations {
}; };
}; };
}; };
"Get forum thread": { "Get forum thread's posts": {
parameters: { parameters: {
query: { query: {
thread_id: number; thread_id: number;
page: number; page?: number | null;
page_size: number; page_size: number;
post_id?: number | null;
}; };
header?: never; header?: never;
path?: never; path?: never;

View File

@@ -23,10 +23,16 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed, defineProps, ref } from 'vue' import { computed, defineProps, ref } from 'vue'
import PaginationSelector from './PaginationSelector.vue' import PaginationSelector from './PaginationSelector.vue'
import { onMounted } from 'vue'
import { useRouter } from 'vue-router'
const router = useRouter()
const props = defineProps<{ const props = defineProps<{
totalItems: number totalItems: number
pageSize: number pageSize: number
initialPage?: number | null
totalPages: number
}>() }>()
const currentPage = ref(1) const currentPage = ref(1)
@@ -40,10 +46,8 @@ const emit = defineEmits<{
changePage: [Pagination] changePage: [Pagination]
}>() }>()
const totalPages = computed(() => Math.ceil(props.totalItems / props.pageSize))
const pageRanges = computed(() => { const pageRanges = computed(() => {
const total = totalPages.value const total = props.totalPages
const maxVisible = 15 const maxVisible = 15
let startPage = Math.max(1, currentPage.value - Math.floor(maxVisible / 2)) let startPage = Math.max(1, currentPage.value - Math.floor(maxVisible / 2))
let endPage = startPage + maxVisible - 1 let endPage = startPage + maxVisible - 1
@@ -62,10 +66,24 @@ const pageRanges = computed(() => {
}) })
const goToPage = (page: number) => { const goToPage = (page: number) => {
if (page < 1 || page > totalPages.value) return if (page < 1 || page > props.totalPages) return
currentPage.value = page currentPage.value = page
router.push({
// path: route.path,
query: { page: page },
})
emit('changePage', { page, pageSize: props.pageSize }) emit('changePage', { page, pageSize: props.pageSize })
} }
defineExpose({
goToPage,
})
onMounted(() => {
if (props.initialPage) {
currentPage.value = props.initialPage
}
})
</script> </script>
<style scoped> <style scoped>
.pagination { .pagination {

View File

@@ -1,5 +1,15 @@
<template> <template>
<ContentContainer class="comment-container"> <ContentContainer class="comment-container" :id="`post-${comment.id}`">
<div style="float: right">
<RouterLink
:to="{
query: { post_id: comment.id },
hash: `#post-${comment.id}`,
}"
>
<i class="pi pi-link" />
</RouterLink>
</div>
<div class="comment"> <div class="comment">
<div class="user"> <div class="user">
<img class="avatar" :src="comment.created_by.avatar ?? '/default_user_avatar.jpg'" :alt="comment.created_by.username + '\'s avatar'" /> <img class="avatar" :src="comment.created_by.avatar ?? '/default_user_avatar.jpg'" :alt="comment.created_by.username + '\'s avatar'" />

View File

@@ -107,7 +107,8 @@
"new_post": "New post", "new_post": "New post",
"new_thread": "New thread", "new_thread": "New thread",
"thread_name": "Thread name", "thread_name": "Thread name",
"discuss": "Discuss" "discuss": "Discuss",
"go_to_last_page_to_reply": "Go to last page to reply"
}, },
"user": { "user": {
"username": "Username", "username": "Username",

View File

@@ -31,8 +31,16 @@ export const getForumThread = async (forumThreadId: number): Promise<ForumThread
export type PaginatedResults_ForumPostHierarchy = components['schemas']['PaginatedResults_ForumPostHierarchy'] export type PaginatedResults_ForumPostHierarchy = components['schemas']['PaginatedResults_ForumPostHierarchy']
export const getForumThreadPosts = async (forumThreadId: number, page: number, page_size: number): Promise<PaginatedResults_ForumPostHierarchy> => { export type GetForumThreadPostsQuery = components['schemas']['GetForumThreadPostsQuery']
return (await api.get<PaginatedResults_ForumPostHierarchy>(`/forum/thread/posts?thread_id=${forumThreadId}&page=${page}&page_size=${page_size}`)).data
export const getForumThreadPosts = async (query: GetForumThreadPostsQuery): Promise<PaginatedResults_ForumPostHierarchy> => {
return (
await api.get<PaginatedResults_ForumPostHierarchy>(
`/forum/thread/posts?thread_id=${query.thread_id}&page_size=${query.page_size}` +
(query.page !== null ? `&page=${query.page}` : '') +
(query.post_id !== null ? `&post_id=${query.post_id}` : ''),
)
).data
} }
export type UserCreatedForumPost = components['schemas']['UserCreatedForumPost'] export type UserCreatedForumPost = components['schemas']['UserCreatedForumPost']

View File

@@ -356,3 +356,13 @@ export const isAttributeUsed = (attribute: keyof Torrent | keyof TorrentRequest,
return true return true
} }
} }
export const scrollToHash = () => {
;(function h() {
const e = document.querySelector(location.hash)
if (e) {
e.scrollIntoView({ behavior: 'smooth' })
} else {
setTimeout(h, 100)
}
})()
}

View File

@@ -5,7 +5,14 @@
<RouterLink :to="`/forum/sub-category/${forumThread.forum_sub_category_id}`">{{ forumThread.forum_sub_category_name }}</RouterLink> > <RouterLink :to="`/forum/sub-category/${forumThread.forum_sub_category_id}`">{{ forumThread.forum_sub_category_name }}</RouterLink> >
{{ forumThread.name }} {{ forumThread.name }}
</div> </div>
<PaginatedResults :totalItems="totalPosts" @change-page="fetchForumThreadPosts($event.page, $event.pageSize)" :page-size="pageSize"> <PaginatedResults
v-if="forumThreadPosts.length > 0"
:totalPages
:initialPage
:totalItems="totalPosts"
@change-page="fetchForumThreadPosts($event.page, $event.pageSize, null)"
:page-size="pageSize"
>
<GeneralComment v-for="post in forumThreadPosts" :key="post.id" :comment="post" /> <GeneralComment v-for="post in forumThreadPosts" :key="post.id" :comment="post" />
</PaginatedResults> </PaginatedResults>
<Form v-slot="$form" :initialValues="newPost" :resolver @submit="onFormSubmit" validateOnSubmit :validateOnValueUpdate="false"> <Form v-slot="$form" :initialValues="newPost" :resolver @submit="onFormSubmit" validateOnSubmit :validateOnValueUpdate="false">
@@ -17,7 +24,15 @@
:label="t('forum.new_post')" :label="t('forum.new_post')"
> >
<template #buttons> <template #buttons>
<Button type="submit" label="Post" icon="pi pi-send" :loading="sendingPost" class="post-button" /> <Button
type="submit"
label="Post"
icon="pi pi-send"
:loading="sendingPost"
class="post-button"
:disabled="currentPage !== totalPages"
v-tooltip.top="currentPage !== totalPages ? t('forum.go_to_last_page_to_reply') : ''"
/>
</template> </template>
</BBCodeEditor> </BBCodeEditor>
<Message v-if="$form.content?.invalid" severity="error" size="small" variant="simple"> <Message v-if="$form.content?.invalid" severity="error" size="small" variant="simple">
@@ -48,6 +63,9 @@ import { Form } from '@primevue/forms'
import { Button } from 'primevue' import { Button } from 'primevue'
import BBCodeEditor from '@/components/community/BBCodeEditor.vue' import BBCodeEditor from '@/components/community/BBCodeEditor.vue'
import PaginatedResults from '@/components/PaginatedResults.vue' import PaginatedResults from '@/components/PaginatedResults.vue'
import { nextTick } from 'vue'
import { scrollToHash } from '@/services/helpers'
import { computed } from 'vue'
const route = useRoute() const route = useRoute()
const { t } = useI18n() const { t } = useI18n()
@@ -56,6 +74,9 @@ const forumThread = ref<null | ForumThreadEnriched>(null)
const forumThreadPosts = ref<ForumPostHierarchy[]>([]) const forumThreadPosts = ref<ForumPostHierarchy[]>([])
const totalPosts = ref(0) const totalPosts = ref(0)
const pageSize = ref(10) const pageSize = ref(10)
const totalPages = computed(() => Math.ceil(totalPosts.value / pageSize.value))
const currentPage = ref(1)
let initialPage: number | null = null
const newPost = ref<UserCreatedForumPost>({ const newPost = ref<UserCreatedForumPost>({
content: '', content: '',
forum_thread_id: 0, forum_thread_id: 0,
@@ -64,14 +85,35 @@ const sendingPost = ref(false)
const bbcodeEditorEmptyInput = ref(false) const bbcodeEditorEmptyInput = ref(false)
const siteName = import.meta.env.VITE_SITE_NAME const siteName = import.meta.env.VITE_SITE_NAME
const fetchForumThreadPosts = async (page: number, page_size: number) => { const fetchForumThreadPosts = async (page: number | null, page_size: number, post_id: number | null) => {
const paginatedPosts = await getForumThreadPosts(parseInt(route.params.id as string), page, page_size) const paginatedPosts = await getForumThreadPosts({
thread_id: parseInt(route.params.id as string),
page,
page_size,
post_id,
})
forumThreadPosts.value = paginatedPosts.results forumThreadPosts.value = paginatedPosts.results
totalPosts.value = paginatedPosts.total_items totalPosts.value = paginatedPosts.total_items
await nextTick()
if (post_id !== null) {
initialPage = paginatedPosts.page
scrollToHash()
}
currentPage.value = paginatedPosts.page
} }
onMounted(async () => { onMounted(async () => {
;[forumThread.value] = await Promise.all([getForumThread(+route.params.id!), fetchForumThreadPosts(1, pageSize.value)]) let page: number | null = 1
if (route.query.page) {
page = parseInt(route.query.page as string)
initialPage = page
} else if (route.query.post_id) {
page = null
}
;[forumThread.value] = await Promise.all([
getForumThread(+route.params.id!),
fetchForumThreadPosts(page, pageSize.value, route.query.post_id ? parseInt(route.query.post_id as string) : null),
])
document.title = forumThread.value ? `${forumThread.value.name} - ${siteName}` : `Forum thread - ${siteName}` document.title = forumThread.value ? `${forumThread.value.name} - ${siteName}` : `Forum thread - ${siteName}`
}) })