feat: Implement flexible media sorting by standard columns and metadata tags by refactoring backend query building and updating frontend API parameters.
This commit is contained in:
@@ -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: () => {
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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";
|
||||
|
||||
@@ -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<string>("created_at");
|
||||
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
|
||||
const [mimeType, setMimeType] = useState<string | undefined>(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<Media | null>(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 (
|
||||
<div className="space-y-6">
|
||||
<div className="flex items-center justify-between">
|
||||
<div className="flex flex-col sm:flex-row items-start sm:items-center justify-between gap-4">
|
||||
<h1 className="text-3xl font-bold">All Photos</h1>
|
||||
<div className="flex flex-wrap items-center gap-2">
|
||||
<Select value={sortBy} onValueChange={setSortBy}>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Sort by" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="created_at">Date Created</SelectItem>
|
||||
<SelectItem value="date_taken">Date Taken</SelectItem>
|
||||
<SelectItem value="original_filename">Filename</SelectItem>
|
||||
<SelectItem value="custom">Custom Tag...</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
{sortBy === "custom" && (
|
||||
<input
|
||||
type="text"
|
||||
placeholder="Enter tag name"
|
||||
className="border rounded px-2 py-1 text-sm w-[140px]"
|
||||
onBlur={(e) => {
|
||||
if (e.target.value) setSortBy(e.target.value);
|
||||
}}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter') {
|
||||
setSortBy(e.currentTarget.value);
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)}
|
||||
|
||||
<Select
|
||||
value={sortOrder}
|
||||
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Order" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="desc">Newest First</SelectItem>
|
||||
<SelectItem value="asc">Oldest First</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
|
||||
<Select
|
||||
value={mimeType ?? "all"}
|
||||
onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}
|
||||
>
|
||||
<SelectTrigger className="w-[140px]">
|
||||
<SelectValue placeholder="Filter by Type" />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
<SelectItem value="all">All Types</SelectItem>
|
||||
<SelectItem value="image/jpeg">JPEG</SelectItem>
|
||||
<SelectItem value="image/png">PNG</SelectItem>
|
||||
<SelectItem value="video/mp4">MP4</SelectItem>
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isLoading && <p>Loading photos...</p>}
|
||||
|
||||
@@ -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<PaginatedResponse<Media>> => {
|
||||
const { data } = await apiClient.get("/media", {
|
||||
params: { page, limit },
|
||||
params: { page, limit, sort_by, order, mime_type },
|
||||
});
|
||||
|
||||
data.data = data.data.map(processMediaUrls);
|
||||
|
||||
@@ -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<String>,
|
||||
}
|
||||
|
||||
impl StandardSortStrategy {
|
||||
pub fn new(allowed_columns: Vec<String>) -> 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<T> {
|
||||
fn apply_options_to_query<'a>(
|
||||
&self,
|
||||
@@ -13,28 +105,40 @@ pub trait QueryBuilder<T> {
|
||||
}
|
||||
|
||||
pub struct MediaQueryBuilder {
|
||||
allowed_sort_columns: Vec<String>,
|
||||
sort_strategies: Vec<Box<dyn SortStrategy>>,
|
||||
}
|
||||
|
||||
impl MediaQueryBuilder {
|
||||
pub fn new(allowed_sort_columns: Vec<String>) -> Self {
|
||||
pub fn new(sort_strategies: Vec<Box<dyn SortStrategy>>) -> 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<SqlxQueryBuilder<'a, sqlx::Postgres>> {
|
||||
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 ");
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub fn apply_filters_to_query<'a>(
|
||||
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_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<SqlxQueryBuilder<'a, sqlx::Postgres>> {
|
||||
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 ");
|
||||
}
|
||||
|
||||
@@ -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<Box<dyn crate::query_builder::SortStrategy>> = 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<Media>, 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 ");
|
||||
|
||||
|
||||
Reference in New Issue
Block a user