diff --git a/libertas-frontend/src/components/media/media-details-sidebar.tsx b/libertas-frontend/src/components/media/media-details-sidebar.tsx index 1ab43a4..946ff51 100644 --- a/libertas-frontend/src/components/media/media-details-sidebar.tsx +++ b/libertas-frontend/src/components/media/media-details-sidebar.tsx @@ -120,6 +120,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { value={`${cameraMake} ${cameraModel}`} /> )} + {cameraMake && } + {cameraModel && } diff --git a/libertas-frontend/src/features/media/use-media.ts b/libertas-frontend/src/features/media/use-media.ts index ac8e30d..9d4b7b2 100644 --- a/libertas-frontend/src/features/media/use-media.ts +++ b/libertas-frontend/src/features/media/use-media.ts @@ -18,16 +18,24 @@ const MEDIA_KEY = ["media"]; * This uses `useInfiniteQuery` for "load more" functionality. */ export const useGetMediaList = ( - params: { - sort_by?: string; - order?: "asc" | "desc"; - mime_type?: string; - } = {} + page: number, + limit: number, + sortBy?: string, + order?: 'asc' | 'desc', + mimeType?: string, + filters?: string[] ) => { return useInfiniteQuery({ - queryKey: [MEDIA_KEY, "list", params], + queryKey: [MEDIA_KEY, "list", page, limit, sortBy, order, mimeType, filters], queryFn: ({ pageParam = 1 }) => - getMediaList({ page: pageParam, limit: 20, ...params }), + getMediaList({ + page: pageParam, + limit, + sort_by: sortBy, + order, + mime_type: mimeType, + filters, + }), getNextPageParam: (lastPage) => { return lastPage.has_next_page ? lastPage.page + 1 : undefined; }, diff --git a/libertas-frontend/src/routes/media/index.tsx b/libertas-frontend/src/routes/media/index.tsx index b0d514f..9460ac4 100644 --- a/libertas-frontend/src/routes/media/index.tsx +++ b/libertas-frontend/src/routes/media/index.tsx @@ -22,6 +22,13 @@ function MediaPage() { const [sortBy, setSortBy] = useState("created_at"); const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc"); const [mimeType, setMimeType] = useState(undefined); + const [filters, setFilters] = useState([]); + + // Filter input state + const [filterField, setFilterField] = useState('original_filename'); + const [customFieldName, setCustomFieldName] = useState(''); + const [filterOperator, setFilterOperator] = useState('like'); + const [filterValue, setFilterValue] = useState(''); const { data, @@ -30,11 +37,7 @@ function MediaPage() { fetchNextPage, hasNextPage, isFetchingNextPage, - } = useGetMediaList({ - sort_by: sortBy, - order: sortOrder, - mime_type: mimeType === "all" ? undefined : mimeType, - }); + } = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters); const [selectedMedia, setSelectedMedia] = useState(null); @@ -50,67 +53,145 @@ function MediaPage() { [groupedMedia] ); + const handleAddFilter = () => { + const field = filterField === 'custom' ? customFieldName : filterField; + if (field && filterValue) { + const newFilter = `${field}:${filterOperator}:${filterValue}`; + setFilters([...filters, newFilter]); + setFilterValue(''); // Clear value after adding + } + }; + + const handleRemoveFilter = (index: number) => { + const newFilters = [...filters]; + newFilters.splice(index, 1); + setFilters(newFilters); + }; + return (
-
-

All Photos

-
- setMimeType(val === "all" ? undefined : val)}> + + + + + All Types + JPEG + PNG + MP4 + + + + + + {sortBy === "custom" && ( + { + if (e.target.value) setSortBy(e.target.value); + }} + onKeyDown={(e) => { + if (e.key === 'Enter') { + setSortBy(e.currentTarget.value); + } + }} + /> + )} + + +
+
+ + {/* Advanced Filters */} +
+ Add Filter: + - {sortBy === "custom" && ( + {filterField === 'custom' && ( { - if (e.target.value) setSortBy(e.target.value); - }} - onKeyDown={(e) => { - if (e.key === 'Enter') { - setSortBy(e.currentTarget.value); - } - }} + className="border rounded px-2 py-1 text-sm w-[120px]" + placeholder="Field name" + value={customFieldName} + onChange={(e) => setCustomFieldName(e.target.value)} /> )} - + + - Newest First - Oldest First + = + != + Like + > + < + >= + <= - + setFilterValue(e.target.value)} + placeholder="Value" + className="border rounded px-2 py-1 text-sm w-[150px]" + onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()} + /> +
+ + {/* Active Filters List */} + {filters.length > 0 && ( +
+ {filters.map((f, i) => ( +
+ {f.replace(/:/g, ' ')} + +
+ ))} +
+ )}
{isLoading &&

Loading photos...

} diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index d0df003..af726fe 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -1,12 +1,13 @@ import type { Media, MediaDetails, PaginatedResponse } from "@/domain/types" import apiClient from "@/services/api-client" -type MediaListParams = { - page: number; - limit: number; +export interface MediaListParams { + page?: number; + limit?: number; sort_by?: string; - order?: "asc" | "desc"; + order?: 'asc' | 'desc'; mime_type?: string; + filters?: string[]; }; const API_PREFIX = import.meta.env.VITE_PREFIX_PATH || ""; @@ -28,10 +29,20 @@ export const getMediaList = async ({ sort_by, order, mime_type, + filters, }: MediaListParams): Promise> => { - const { data } = await apiClient.get("/media", { - params: { page, limit, sort_by, order, mime_type }, - }); + const params = new URLSearchParams(); + if (page) params.append("page", page.toString()); + if (limit) params.append("limit", limit.toString()); + if (sort_by) params.append("sort_by", sort_by); + if (order) params.append("order", order); + if (mime_type) params.append("mime_type", mime_type); + + if (filters && filters.length > 0) { + filters.forEach(f => params.append("filters", f)); + } + + const { data } = await apiClient.get(`/media?${params.toString()}`); data.data = data.data.map(processMediaUrls); return data; diff --git a/libertas_api/src/extractors/query_options.rs b/libertas_api/src/extractors/query_options.rs index 5154e65..bca8d46 100644 --- a/libertas_api/src/extractors/query_options.rs +++ b/libertas_api/src/extractors/query_options.rs @@ -5,7 +5,8 @@ use axum::{ use libertas_core::{ error::CoreError, schema::{ - FilterParams, ListMediaOptions, MetadataFilter, PaginationParams, SortOrder, SortParams, + FilterCondition, FilterOperator, FilterParams, ListMediaOptions, MetadataFilter, + PaginationParams, SortOrder, SortParams, }, }; @@ -61,9 +62,44 @@ impl From for ListMediaOptions { Some(PaginationParams { page, limit }) }; + let conditions = if params.filters.is_empty() { + None + } else { + let mut conds = Vec::new(); + for filter_str in params.filters { + let parts: Vec<&str> = filter_str.splitn(3, ':').collect(); + if parts.len() == 3 { + let field = parts[0].to_string(); + let op_str = parts[1]; + let value = parts[2].to_string(); + + let operator = match op_str.to_lowercase().as_str() { + "eq" => Some(FilterOperator::Eq), + "neq" => Some(FilterOperator::Neq), + "like" => Some(FilterOperator::Like), + "gt" => Some(FilterOperator::Gt), + "lt" => Some(FilterOperator::Lt), + "gte" => Some(FilterOperator::Gte), + "lte" => Some(FilterOperator::Lte), + _ => None, + }; + + if let Some(op) = operator { + conds.push(FilterCondition { + field, + operator: op, + value, + }); + } + } + } + if conds.is_empty() { None } else { Some(conds) } + }; + let filter = Some(FilterParams { mime_type: params.mime_type, metadata_filters, + conditions, }); ListMediaOptions { @@ -81,10 +117,33 @@ impl FromRequestParts for ApiListMediaOptions { parts: &mut Parts, state: &AppState, ) -> Result { - let Query(params) = Query::::from_request_parts(parts, state) + let Query(raw_params) = Query::>::from_request_parts(parts, state) .await .map_err(|e| ApiError::from(CoreError::Validation(e.to_string())))?; + let mut params = ListMediaParams { + sort_by: None, + order: None, + mime_type: None, + metadata: Vec::new(), + filters: Vec::new(), + page: None, + limit: None, + }; + + for (key, value) in raw_params { + match key.as_str() { + "sort_by" => params.sort_by = Some(value), + "order" => params.order = Some(value), + "mime_type" => params.mime_type = Some(value), + "metadata" => params.metadata.push(value), + "filters" => params.filters.push(value), + "page" => params.page = value.parse().ok(), + "limit" => params.limit = value.parse().ok(), + _ => {} + } + } + Ok(ApiListMediaOptions(params.into())) } } diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 167f728..2ffb73e 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -42,6 +42,8 @@ pub struct ListMediaParams { pub mime_type: Option, #[serde(default)] pub metadata: Vec, + #[serde(default)] + pub filters: Vec, pub page: Option, pub limit: Option, } diff --git a/libertas_core/src/schema.rs b/libertas_core/src/schema.rs index 7eb840e..16a8256 100644 --- a/libertas_core/src/schema.rs +++ b/libertas_core/src/schema.rs @@ -59,6 +59,7 @@ pub struct SortParams { pub struct FilterParams { pub mime_type: Option, pub metadata_filters: Option>, + pub conditions: Option>, // In the future, we can add fields like: // pub date_range: Option<(chrono::DateTime, chrono::DateTime)>, } @@ -82,6 +83,24 @@ pub struct MetadataFilter { pub tag_value: String, } +#[derive(Debug, Clone)] +pub struct FilterCondition { + pub field: String, + pub operator: FilterOperator, + pub value: String, +} + +#[derive(Debug, Clone, PartialEq)] +pub enum FilterOperator { + Eq, + Neq, + Like, + Gt, + Lt, + Gte, + Lte, +} + pub struct MediaImportBundle { pub media_model: Media, pub metadata_models: Vec, diff --git a/libertas_infra/src/query_builder.rs b/libertas_infra/src/query_builder.rs index 32c4d07..4d3e872 100644 --- a/libertas_infra/src/query_builder.rs +++ b/libertas_infra/src/query_builder.rs @@ -88,10 +88,187 @@ impl SortStrategy for MetadataSortStrategy { } 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 FilterStrategy: Send + Sync { + fn can_handle(&self, field: &str) -> bool; + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + field: &'a str, + ) -> CoreResult<()>; + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()>; +} + +pub struct StandardFilterStrategy { + allowed_columns: Vec, +} + +impl StandardFilterStrategy { + pub fn new(allowed_columns: Vec) -> Self { + Self { allowed_columns } + } +} + +impl FilterStrategy for StandardFilterStrategy { + fn can_handle(&self, field: &str) -> bool { + self.allowed_columns.contains(&field.to_string()) + } + + fn apply_join<'a>( + &self, + _query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + _field: &'a str, + ) -> CoreResult<()> { + Ok(()) + } + + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()> { + use libertas_core::schema::FilterOperator; + + let is_timestamp = + ["date_taken", "created_at", "updated_at"].contains(&condition.field.as_str()); + let is_year = condition.value.len() == 4 && condition.value.chars().all(char::is_numeric); + + if is_timestamp && is_year { + match condition.operator { + FilterOperator::Eq => { + query.push(format!( + " AND EXTRACT(YEAR FROM media.{}) = ", + condition.field + )); + query.push_bind(condition.value.parse::().unwrap_or(0)); + return Ok(()); + } + FilterOperator::Neq => { + query.push(format!( + " AND EXTRACT(YEAR FROM media.{}) != ", + condition.field + )); + query.push_bind(condition.value.parse::().unwrap_or(0)); + return Ok(()); + } + FilterOperator::Gt => { + query.push(format!(" AND media.{} > ", condition.field)); + query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Lt => { + query.push(format!(" AND media.{} < ", condition.field)); + query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Gte => { + query.push(format!(" AND media.{} >= ", condition.field)); + query.push_bind(format!("{}-01-01 00:00:00.000Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + FilterOperator::Lte => { + query.push(format!(" AND media.{} <= ", condition.field)); + query.push_bind(format!("{}-12-31 23:59:59.999Z", condition.value)); + query.push("::timestamptz"); + return Ok(()); + } + _ => {} // Fallthrough for Like + } + } + + let op = match condition.operator { + FilterOperator::Eq => "=", + FilterOperator::Neq => "!=", + FilterOperator::Like => "ILIKE", + FilterOperator::Gt => ">", + FilterOperator::Lt => "<", + FilterOperator::Gte => ">=", + FilterOperator::Lte => "<=", + }; + + query.push(format!(" AND media.{} {} ", condition.field, op)); + + if condition.operator == FilterOperator::Like { + query.push_bind(format!("%{}%", condition.value)); + } else { + query.push_bind(&condition.value); + } + + if is_timestamp { + query.push("::timestamptz"); + } + + Ok(()) + } +} + +pub struct MetadataFilterStrategy; + +impl FilterStrategy for MetadataFilterStrategy { + fn can_handle(&self, field: &str) -> bool { + field.starts_with("metadata.") + } + + fn apply_join<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + field: &'a str, + ) -> CoreResult<()> { + // Alias based on field name to allow multiple metadata filters + // e.g. metadata.Camera -> filter_metadata_Camera + let alias = format!("filter_{}", field.replace(".", "_")); + let tag_name = field.strip_prefix("metadata.").unwrap_or(field); + + query.push(format!( + " JOIN media_metadata {} ON media.id = {}.media_id AND {}.tag_name = ", + alias, alias, alias + )); + query.push_bind(tag_name); + + Ok(()) + } + + fn apply_condition<'a>( + &self, + query: &mut SqlxQueryBuilder<'a, sqlx::Postgres>, + condition: &'a libertas_core::schema::FilterCondition, + ) -> CoreResult<()> { + use libertas_core::schema::FilterOperator; + + let alias = format!("filter_{}", condition.field.replace(".", "_")); + + let op = match condition.operator { + FilterOperator::Eq => "=", + FilterOperator::Neq => "!=", + FilterOperator::Like => "ILIKE", + FilterOperator::Gt => ">", + FilterOperator::Lt => "<", + FilterOperator::Gte => ">=", + FilterOperator::Lte => "<=", + }; + + query.push(format!(" AND {}.tag_value {} ", alias, op)); + + if condition.operator == FilterOperator::Like { + query.push_bind(format!("%{}%", condition.value)); + } else { + query.push_bind(&condition.value); + } + Ok(()) } } @@ -106,12 +283,17 @@ pub trait QueryBuilder { pub struct MediaQueryBuilder { sort_strategies: Vec>, + filter_strategies: Vec>, } impl MediaQueryBuilder { - pub fn new(sort_strategies: Vec>) -> Self { + pub fn new( + sort_strategies: Vec>, + filter_strategies: Vec>, + ) -> Self { Self { sort_strategies, + filter_strategies, } } @@ -126,10 +308,30 @@ impl MediaQueryBuilder { query.push(" JOIN media_metadata mm ON media.id = mm.media_id "); } } + + if let Some(conditions) = &filter.conditions { + let mut joined_fields = std::collections::HashSet::new(); + for condition in conditions { + if joined_fields.contains(&condition.field) { + continue; + } + let strategy = self + .filter_strategies + .iter() + .find(|s| s.can_handle(&condition.field)); + if let Some(strategy) = strategy { + strategy.apply_join(&mut query, &condition.field)?; + joined_fields.insert(condition.field.clone()); + } + } + } } if let Some(sort) = &options.sort { - let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by)); + 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)?; } @@ -168,6 +370,18 @@ impl MediaQueryBuilder { query.push(" ) "); } } + + if let Some(conditions) = &filter.conditions { + for condition in conditions { + let strategy = self + .filter_strategies + .iter() + .find(|s| s.can_handle(&condition.field)); + if let Some(strategy) = strategy { + strategy.apply_condition(&mut query, condition)?; + } + } + } } Ok((query, metadata_filter_count)) } @@ -183,13 +397,19 @@ impl MediaQueryBuilder { SortOrder::Desc => "DESC", }; - let strategy = self.sort_strategies.iter().find(|s| s.can_handle(&sort.sort_by)); - + 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 { - // 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))); + // 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 57d6d4f..d113145 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -28,14 +28,19 @@ impl PostgresMediaRepository { allowed_columns.push("date_taken".to_string()); - let strategies: Vec> = vec![ - Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns)), + let sort_strategies: Vec> = vec![ + Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns.clone())), Box::new(crate::query_builder::MetadataSortStrategy), ]; + let filter_strategies: Vec> = vec![ + Box::new(crate::query_builder::StandardFilterStrategy::new(allowed_columns)), + Box::new(crate::query_builder::MetadataFilterStrategy), + ]; + Self { pool, - query_builder: Arc::new(MediaQueryBuilder::new(strategies)), + query_builder: Arc::new(MediaQueryBuilder::new(sort_strategies, filter_strategies)), } }