From 15177f218be811f1d0f2174c2450eb5e6a85f75a Mon Sep 17 00:00:00 2001 From: Gabriel Kaszewski Date: Wed, 3 Dec 2025 22:53:27 +0100 Subject: [PATCH] feat: Implement flexible media sorting by standard columns and metadata tags by refactoring backend query building and updating frontend API parameters. --- .../src/features/albums/use-albums.ts | 3 +- .../src/features/media/use-media.ts | 13 +- libertas-frontend/src/main.tsx | 1 - libertas-frontend/src/routes/media/index.tsx | 91 +++++++++-- .../src/services/media-service.ts | 18 ++- libertas_infra/src/query_builder.rs | 144 +++++++++++++++--- .../src/repositories/media_repository.rs | 31 +++- 7 files changed, 249 insertions(+), 52 deletions(-) diff --git a/libertas-frontend/src/features/albums/use-albums.ts b/libertas-frontend/src/features/albums/use-albums.ts index 4fb14fc..88f6ae1 100644 --- a/libertas-frontend/src/features/albums/use-albums.ts +++ b/libertas-frontend/src/features/albums/use-albums.ts @@ -11,7 +11,6 @@ import { shareAlbum, updateAlbum, type AddMediaToAlbumPayload, - type CreateAlbumPayload, type RemoveMediaFromAlbumPayload, type SetAlbumThumbnailPayload, type ShareAlbumPayload, @@ -150,7 +149,7 @@ export const useRemoveMediaFromAlbum = (albumId: string) => { * Mutation hook to share an album with another user. */ export const useShareAlbum = (albumId: string) => { - const queryClient = useQueryClient(); + // const queryClient = useQueryClient(); return useMutation({ mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload), onSuccess: () => { diff --git a/libertas-frontend/src/features/media/use-media.ts b/libertas-frontend/src/features/media/use-media.ts index bf476d4..ac8e30d 100644 --- a/libertas-frontend/src/features/media/use-media.ts +++ b/libertas-frontend/src/features/media/use-media.ts @@ -17,10 +17,17 @@ const MEDIA_KEY = ["media"]; * Query hook to fetch a paginated list of all media. * This uses `useInfiniteQuery` for "load more" functionality. */ -export const useGetMediaList = () => { +export const useGetMediaList = ( + params: { + sort_by?: string; + order?: "asc" | "desc"; + mime_type?: string; + } = {} +) => { return useInfiniteQuery({ - queryKey: [MEDIA_KEY, "list"], - queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }), + queryKey: [MEDIA_KEY, "list", params], + queryFn: ({ pageParam = 1 }) => + getMediaList({ page: pageParam, limit: 20, ...params }), getNextPageParam: (lastPage) => { return lastPage.has_next_page ? lastPage.page + 1 : undefined; }, diff --git a/libertas-frontend/src/main.tsx b/libertas-frontend/src/main.tsx index 7830be8..fd8a9b3 100644 --- a/libertas-frontend/src/main.tsx +++ b/libertas-frontend/src/main.tsx @@ -1,4 +1,3 @@ -import React from "react"; import ReactDOM from "react-dom/client"; import { RouterProvider, createRouter } from "@tanstack/react-router"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; diff --git a/libertas-frontend/src/routes/media/index.tsx b/libertas-frontend/src/routes/media/index.tsx index c3383d7..b0d514f 100644 --- a/libertas-frontend/src/routes/media/index.tsx +++ b/libertas-frontend/src/routes/media/index.tsx @@ -3,16 +3,26 @@ import { createFileRoute } from "@tanstack/react-router"; import { Button } from "@/components/ui/button"; import { AuthenticatedImage } from "@/components/media/authenticated-image"; import type { Media } from "@/domain/types"; -import { useMemo, useState } from "react"; // Import useMemo +import { useMemo, useState } from "react"; import { MediaViewer } from "@/components/media/media-viewer"; -import { groupMediaByDate } from "@/lib/date-utils"; // Import our new helper -import { parseISO } from "date-fns"; +import { groupMediaByDate } from "@/lib/date-utils"; +import { + Select, + SelectContent, + SelectItem, + SelectTrigger, + SelectValue, +} from "@/components/ui/select"; export const Route = createFileRoute("/media/")({ component: MediaPage, }); function MediaPage() { + const [sortBy, setSortBy] = useState("created_at"); + const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); + const [mimeType, setMimeType] = useState(undefined); + const { data, isLoading, @@ -20,20 +30,16 @@ function MediaPage() { fetchNextPage, hasNextPage, isFetchingNextPage, - } = useGetMediaList(); + } = useGetMediaList({ + sort_by: sortBy, + order: sortOrder, + mime_type: mimeType === "all" ? undefined : mimeType, + }); const [selectedMedia, setSelectedMedia] = useState(null); const allMedia = useMemo( - () => - data?.pages - .flatMap((page) => page.data) - .sort((a, b) => { - // Sort by date (newest first) - const dateA = a.date_taken ?? a.created_at; - const dateB = b.date_taken ?? b.created_at; - return parseISO(dateB).getTime() - parseISO(dateA).getTime(); - }) ?? [], + () => data?.pages.flatMap((page) => page.data) ?? [], [data] ); @@ -46,8 +52,65 @@ function MediaPage() { return (
-
+

All Photos

+
+ + + {sortBy === "custom" && ( + { + if (e.target.value) setSortBy(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setSortBy(e.currentTarget.value); + } + }} + /> + )} + + + + +
{isLoading &&

Loading photos...

} diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index b2cc7a7..d0df003 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -2,11 +2,14 @@ import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types" import apiClient from "@/services/api-client" type MediaListParams = { - page: number - limit: number -} + page: number; + limit: number; + sort_by?: string; + order?: "asc" | "desc"; + mime_type?: string; +}; -const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || ''; +const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || ""; export const processMediaUrls = (media: Media): Media => ({ ...media, @@ -22,9 +25,12 @@ export const processMediaUrls = (media: Media): Media => ({ export const getMediaList = async ({ page, limit, + sort_by, + order, + mime_type, }: MediaListParams): Promise> => { const { data } = await apiClient.get("/media", { - params: { page, limit }, + params: { page, limit, sort_by, order, mime_type }, }); data.data = data.data.map(processMediaUrls); @@ -66,7 +72,7 @@ export const getMediaDetails = async ( console.log('Data for media details: ', data); // Process the media URLs in the details response - data.file_url = `${API_PREFIX}${data.file_url}`; + data.file_url = `${API_PREFIX}${data.file_url}`; data.thumbnail_url = data.thumbnail_url ? `${API_PREFIX}${data.thumbnail_url}` : null; diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs index 0f08bc6..32c4d07 100644 --- a/libertas_infra/src/query_builder.rs +++ b/libertas_infra/src/query_builder.rs @@ -4,6 +4,98 @@ use libertas_core::{ }; use sqlx::QueryBuilder as SqlxQueryBuilder; +pub trait SortStrategy: Send + Sync { + fn can_handle(&self, column: &str) -> bool; + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + ) -> CoreResult<()>; + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + direction: &str, + ) -> CoreResult<()>; +} + +pub struct StandardSortStrategy { + allowed_columns: Vec, +} + +impl StandardSortStrategy { + pub fn new(allowed_columns: Vec) -> Self { + Self { allowed_columns } + } +} + +impl SortStrategy for StandardSortStrategy { + fn can_handle(&self, column: &str) -> bool { + self.allowed_columns.contains(&column.to_string()) + } + + fn apply_join<'a>( + &self, + _query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _column: &'a str, + ) -> CoreResult<()> { + Ok(()) + } + + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + direction: &str, + ) -> CoreResult<()> { + let nulls_order = if direction == "ASC" { + "NULLS LAST" + } else { + "NULLS FIRST" + }; + let order_by_clause = format!(" ORDER BY {} {} {}", column, direction, nulls_order); + query.push(order_by_clause); + Ok(()) + } +} + +pub struct MetadataSortStrategy; + +impl SortStrategy for MetadataSortStrategy { + fn can_handle(&self, _column: &str) -> bool { + true // Handles everything else + } + + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + column: &'a str, + ) -> CoreResult<()> { + // Join with media_metadata to sort by tag value + query.push(" LEFT JOIN media_metadata sort_mm ON media.id = sort_mm.media_id AND sort_mm.tag_name = "); + query.push_bind(column); + Ok(()) + } + + fn apply_sort<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _column: &'a str, + direction: &str, + ) -> CoreResult<()> { + let nulls_order = if direction == "ASC" { + "NULLS LAST" + } else { + "NULLS FIRST" + }; + + let order_by_clause = format!(" ORDER BY sort_mm.tag_value {} {}", direction, nulls_order); + query.push(order_by_clause); + + Ok(()) + } +} + pub trait QueryBuilder { fn apply_options_to_query<'a>( &self, @@ -13,28 +105,40 @@ pub trait QueryBuilder { } pub struct MediaQueryBuilder { - allowed_sort_columns: Vec, + sort_strategies: Vec>, } impl MediaQueryBuilder { - pub fn new(allowed_sort_columns: Vec) -> Self { + pub fn new(sort_strategies: Vec>) -> Self { Self { - allowed_sort_columns, + sort_strategies, } } - fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> { - if self.allowed_sort_columns.contains(&column.to_string()) { - Ok(column) - } else { - Err(CoreError::Validation(format!( - "Sorting by '{}' is not supported", - column - ))) + pub fn apply_joins<'a>( + &self, + mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, + options: &'a ListMediaOptions, + ) -> CoreResult> { + if let Some(filter) = &options.filter { + if let Some(metadata_filters) = &filter.metadata_filters { + if !metadata_filters.is_empty() { + query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); + } + } } + + if let Some(sort) = &options.sort { + let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by)); + if let Some(strategy) = strategy { + strategy.apply_join(&mut query, &sort.sort_by)?; + } + } + + Ok(query) } - pub fn apply_filters_to_query<'a>( + pub fn apply_conditions<'a>( &self, mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, options: &'a ListMediaOptions, @@ -49,7 +153,6 @@ impl MediaQueryBuilder { if let Some(metadata_filters) = &filter.metadata_filters { if !metadata_filters.is_empty() { metadata_filter_count = metadata_filters.len() as i64; - query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); query.push(" AND ( "); for (i, filter) in metadata_filters.iter().enumerate() { @@ -75,18 +178,19 @@ impl MediaQueryBuilder { options: &'a ListMediaOptions, ) -> CoreResult> { if let Some(sort) = &options.sort { - let column = self.validate_sort_column(&sort.sort_by)?; let direction = match sort.sort_order { SortOrder::Asc => "ASC", SortOrder::Desc => "DESC", }; - let nulls_order = if direction == "ASC" { - "NULLS LAST" + + let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by)); + + if let Some(strategy) = strategy { + strategy.apply_sort(&mut query, &sort.sort_by, direction)?; } else { - "NULLS FIRST" - }; - let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order); - query.push(order_by_clause); + // Should not happen if we have a default/catch-all strategy, but good to handle + return Err(CoreError::Validation(format!("No sort strategy found for column: {}", sort.sort_by))); + } } else { query.push(" ORDER BY media.created_at DESC NULLS LAST "); } diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index e2b9212..57d6d4f 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -21,14 +21,21 @@ pub struct PostgresMediaRepository { impl PostgresMediaRepository { pub fn new(pool: PgPool, config: &AppConfig) -> Self { - let allowed_columns = config + let mut allowed_columns = config .allowed_sort_columns .clone() .unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]); + + allowed_columns.push("date_taken".to_string()); + + let strategies: Vec> = vec![ + Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns)), + Box::new(crate::query_builder::MetadataSortStrategy), + ]; Self { pool, - query_builder: Arc::new(MediaQueryBuilder::new(allowed_columns)), + query_builder: Arc::new(MediaQueryBuilder::new(strategies)), } } @@ -107,12 +114,15 @@ impl MediaRepository for PostgresMediaRepository { ) -> CoreResult<(Vec, i64)> { let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media"; let mut count_query = sqlx::QueryBuilder::new(count_base_sql); + + count_query = self.query_builder.apply_joins(count_query, options)?; + count_query.push(" WHERE media.owner_id = "); count_query.push_bind(user_id); let (mut count_query, metadata_filter_count) = self .query_builder - .apply_filters_to_query(count_query, options)?; + .apply_conditions(count_query, options)?; if metadata_filter_count > 0 { count_query.push(" GROUP BY media.id "); @@ -133,12 +143,15 @@ impl MediaRepository for PostgresMediaRepository { let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path, media.date_taken FROM media"; let mut data_query = sqlx::QueryBuilder::new(data_base_sql); + + data_query = self.query_builder.apply_joins(data_query, options)?; + data_query.push(" WHERE media.owner_id = "); data_query.push_bind(user_id); let (mut data_query, metadata_filter_count) = self .query_builder - .apply_filters_to_query(data_query, options)?; + .apply_conditions(data_query, options)?; if metadata_filter_count > 0 { data_query.push(" GROUP BY media.id "); @@ -174,12 +187,15 @@ impl MediaRepository for PostgresMediaRepository { JOIN face_regions fr ON media.id = fr.media_id "; let mut count_query = sqlx::QueryBuilder::new(count_base_sql); + + count_query = self.query_builder.apply_joins(count_query, options)?; + count_query.push(" WHERE fr.person_id = "); count_query.push_bind(person_id); let (mut count_query, _metadata_filter_count) = self .query_builder - .apply_filters_to_query(count_query, options)?; + .apply_conditions(count_query, options)?; let total_items_result = count_query .build_query_scalar() @@ -195,12 +211,15 @@ impl MediaRepository for PostgresMediaRepository { JOIN face_regions fr ON media.id = fr.media_id "; let mut data_query = sqlx::QueryBuilder::new(data_base_sql); + + data_query = self.query_builder.apply_joins(data_query, options)?; + data_query.push(" WHERE fr.person_id = "); data_query.push_bind(person_id); let (mut data_query, _metadata_filter_count) = self .query_builder - .apply_filters_to_query(data_query, options)?; + .apply_conditions(data_query, options)?; data_query.push(" GROUP BY media.id ");