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,
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: () => {

View File

@@ -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;
},

View File

@@ -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";

View File

@@ -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>}

View File

@@ -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);
@@ -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;

View File

@@ -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 ");
}
}
}
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<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 ");
}

View File

@@ -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 ");