december improvements #2

Open
GKaszewski wants to merge 7 commits from december into master
7 changed files with 249 additions and 52 deletions
Showing only changes of commit 15177f218b - Show all commits

View File

@@ -11,7 +11,6 @@ import {
shareAlbum, shareAlbum,
updateAlbum, updateAlbum,
type AddMediaToAlbumPayload, type AddMediaToAlbumPayload,
type CreateAlbumPayload,
type RemoveMediaFromAlbumPayload, type RemoveMediaFromAlbumPayload,
type SetAlbumThumbnailPayload, type SetAlbumThumbnailPayload,
type ShareAlbumPayload, type ShareAlbumPayload,
@@ -150,7 +149,7 @@ export const useRemoveMediaFromAlbum = (albumId: string) => {
* Mutation hook to share an album with another user. * Mutation hook to share an album with another user.
*/ */
export const useShareAlbum = (albumId: string) => { export const useShareAlbum = (albumId: string) => {
const queryClient = useQueryClient(); // const queryClient = useQueryClient();
return useMutation({ return useMutation({
mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload), mutationFn: (payload: ShareAlbumPayload) => shareAlbum(albumId, payload),
onSuccess: () => { onSuccess: () => {

View File

@@ -17,10 +17,17 @@ const MEDIA_KEY = ["media"];
* Query hook to fetch a paginated list of all media. * Query hook to fetch a paginated list of all media.
* This uses `useInfiniteQuery` for "load more" functionality. * 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({ return useInfiniteQuery({
queryKey: [MEDIA_KEY, "list"], queryKey: [MEDIA_KEY, "list", params],
queryFn: ({ pageParam = 1 }) => getMediaList({ page: pageParam, limit: 20 }), queryFn: ({ pageParam = 1 }) =>
getMediaList({ page: pageParam, limit: 20, ...params }),
getNextPageParam: (lastPage) => { getNextPageParam: (lastPage) => {
return lastPage.has_next_page ? lastPage.page + 1 : undefined; return lastPage.has_next_page ? lastPage.page + 1 : undefined;
}, },

View File

@@ -1,4 +1,3 @@
import React from "react";
import ReactDOM from "react-dom/client"; import ReactDOM from "react-dom/client";
import { RouterProvider, createRouter } from "@tanstack/react-router"; import { RouterProvider, createRouter } from "@tanstack/react-router";
import { QueryClient, QueryClientProvider } from "@tanstack/react-query"; import { QueryClient, QueryClientProvider } from "@tanstack/react-query";

View File

@@ -3,16 +3,26 @@ import { createFileRoute } from "@tanstack/react-router";
import { Button } from "@/components/ui/button"; import { Button } from "@/components/ui/button";
import { AuthenticatedImage } from "@/components/media/authenticated-image"; import { AuthenticatedImage } from "@/components/media/authenticated-image";
import type { Media } from "@/domain/types"; 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 { MediaViewer } from "@/components/media/media-viewer";
import { groupMediaByDate } from "@/lib/date-utils"; // Import our new helper import { groupMediaByDate } from "@/lib/date-utils";
import { parseISO } from "date-fns"; import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from "@/components/ui/select";
export const Route = createFileRoute("/media/")({ export const Route = createFileRoute("/media/")({
component: MediaPage, component: MediaPage,
}); });
function 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 { const {
data, data,
isLoading, isLoading,
@@ -20,20 +30,16 @@ function MediaPage() {
fetchNextPage, fetchNextPage,
hasNextPage, hasNextPage,
isFetchingNextPage, isFetchingNextPage,
} = useGetMediaList(); } = useGetMediaList({
sort_by: sortBy,
order: sortOrder,
mime_type: mimeType === "all" ? undefined : mimeType,
});
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null); const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
const allMedia = useMemo( const allMedia = useMemo(
() => () => data?.pages.flatMap((page) => page.data) ?? [],
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] [data]
); );
@@ -46,8 +52,65 @@ function MediaPage() {
return ( return (
<div className="space-y-6"> <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> <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> </div>
{isLoading && <p>Loading photos...</p>} {isLoading && <p>Loading photos...</p>}

View File

@@ -2,11 +2,14 @@ import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types"
import apiClient from "@/services/api-client" import apiClient from "@/services/api-client"
type MediaListParams = { type MediaListParams = {
page: number page: number;
limit: 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 => ({ export const processMediaUrls = (media: Media): Media => ({
...media, ...media,
@@ -22,9 +25,12 @@ export const processMediaUrls = (media: Media): Media => ({
export const getMediaList = async ({ export const getMediaList = async ({
page, page,
limit, limit,
sort_by,
order,
mime_type,
}: MediaListParams): Promise<PaginatedResponse<Media>> => { }: MediaListParams): Promise<PaginatedResponse<Media>> => {
const { data } = await apiClient.get("/media", { const { data } = await apiClient.get("/media", {
params: { page, limit }, params: { page, limit, sort_by, order, mime_type },
}); });
data.data = data.data.map(processMediaUrls); data.data = data.data.map(processMediaUrls);

View File

@@ -4,6 +4,98 @@ use libertas_core::{
}; };
use sqlx::QueryBuilder as SqlxQueryBuilder; 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> { pub trait QueryBuilder<T> {
fn apply_options_to_query<'a>( fn apply_options_to_query<'a>(
&self, &self,
@@ -13,28 +105,40 @@ pub trait QueryBuilder<T> {
} }
pub struct MediaQueryBuilder { pub struct MediaQueryBuilder {
allowed_sort_columns: Vec<String>, sort_strategies: Vec<Box<dyn SortStrategy>>,
} }
impl MediaQueryBuilder { impl MediaQueryBuilder {
pub fn new(allowed_sort_columns: Vec<String>) -> Self { pub fn new(sort_strategies: Vec<Box<dyn SortStrategy>>) -> Self {
Self { Self {
allowed_sort_columns, sort_strategies,
} }
} }
fn validate_sort_column<'a>(&self, column: &'a str) -> CoreResult<&'a str> { pub fn apply_joins<'a>(
if self.allowed_sort_columns.contains(&column.to_string()) { &self,
Ok(column) mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
} else { options: &'a ListMediaOptions,
Err(CoreError::Validation(format!( ) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
"Sorting by '{}' is not supported", if let Some(filter) = &options.filter {
column 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, &self,
mut query: SqlxQueryBuilder<'a, sqlx::Postgres>, mut query: SqlxQueryBuilder<'a, sqlx::Postgres>,
options: &'a ListMediaOptions, options: &'a ListMediaOptions,
@@ -49,7 +153,6 @@ impl MediaQueryBuilder {
if let Some(metadata_filters) = &filter.metadata_filters { if let Some(metadata_filters) = &filter.metadata_filters {
if !metadata_filters.is_empty() { if !metadata_filters.is_empty() {
metadata_filter_count = metadata_filters.len() as i64; metadata_filter_count = metadata_filters.len() as i64;
query.push(" JOIN media_metadata mm ON media.id = mm.media_id ");
query.push(" AND ( "); query.push(" AND ( ");
for (i, filter) in metadata_filters.iter().enumerate() { for (i, filter) in metadata_filters.iter().enumerate() {
@@ -75,18 +178,19 @@ impl MediaQueryBuilder {
options: &'a ListMediaOptions, options: &'a ListMediaOptions,
) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> { ) -> CoreResult<SqlxQueryBuilder<'a, sqlx::Postgres>> {
if let Some(sort) = &options.sort { if let Some(sort) = &options.sort {
let column = self.validate_sort_column(&sort.sort_by)?;
let direction = match sort.sort_order { let direction = match sort.sort_order {
SortOrder::Asc => "ASC", SortOrder::Asc => "ASC",
SortOrder::Desc => "DESC", 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 { } else {
"NULLS FIRST" // 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)));
let order_by_clause = format!("ORDER BY {} {} {}", column, direction, nulls_order); }
query.push(order_by_clause);
} else { } else {
query.push(" ORDER BY media.created_at DESC NULLS LAST "); query.push(" ORDER BY media.created_at DESC NULLS LAST ");
} }

View File

@@ -21,14 +21,21 @@ pub struct PostgresMediaRepository {
impl PostgresMediaRepository { impl PostgresMediaRepository {
pub fn new(pool: PgPool, config: &AppConfig) -> Self { pub fn new(pool: PgPool, config: &AppConfig) -> Self {
let allowed_columns = config let mut allowed_columns = config
.allowed_sort_columns .allowed_sort_columns
.clone() .clone()
.unwrap_or_else(|| vec!["created_at".to_string(), "original_filename".to_string()]); .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 { Self {
pool, 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)> { ) -> CoreResult<(Vec<Media>, i64)> {
let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media"; let count_base_sql = "SELECT COUNT(DISTINCT media.id) as total FROM media";
let mut count_query = sqlx::QueryBuilder::new(count_base_sql); 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(" WHERE media.owner_id = ");
count_query.push_bind(user_id); count_query.push_bind(user_id);
let (mut count_query, metadata_filter_count) = self let (mut count_query, metadata_filter_count) = self
.query_builder .query_builder
.apply_filters_to_query(count_query, options)?; .apply_conditions(count_query, options)?;
if metadata_filter_count > 0 { if metadata_filter_count > 0 {
count_query.push(" GROUP BY media.id "); 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 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); 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(" WHERE media.owner_id = ");
data_query.push_bind(user_id); data_query.push_bind(user_id);
let (mut data_query, metadata_filter_count) = self let (mut data_query, metadata_filter_count) = self
.query_builder .query_builder
.apply_filters_to_query(data_query, options)?; .apply_conditions(data_query, options)?;
if metadata_filter_count > 0 { if metadata_filter_count > 0 {
data_query.push(" GROUP BY media.id "); 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 JOIN face_regions fr ON media.id = fr.media_id
"; ";
let mut count_query = sqlx::QueryBuilder::new(count_base_sql); 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(" WHERE fr.person_id = ");
count_query.push_bind(person_id); count_query.push_bind(person_id);
let (mut count_query, _metadata_filter_count) = self let (mut count_query, _metadata_filter_count) = self
.query_builder .query_builder
.apply_filters_to_query(count_query, options)?; .apply_conditions(count_query, options)?;
let total_items_result = count_query let total_items_result = count_query
.build_query_scalar() .build_query_scalar()
@@ -195,12 +211,15 @@ impl MediaRepository for PostgresMediaRepository {
JOIN face_regions fr ON media.id = fr.media_id JOIN face_regions fr ON media.id = fr.media_id
"; ";
let mut data_query = sqlx::QueryBuilder::new(data_base_sql); 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(" WHERE fr.person_id = ");
data_query.push_bind(person_id); data_query.push_bind(person_id);
let (mut data_query, _metadata_filter_count) = self let (mut data_query, _metadata_filter_count) = self
.query_builder .query_builder
.apply_filters_to_query(data_query, options)?; .apply_conditions(data_query, options)?;
data_query.push(" GROUP BY media.id "); data_query.push(" GROUP BY media.id ");