december improvements #2

Open
GKaszewski wants to merge 7 commits from december into master
9 changed files with 481 additions and 74 deletions
Showing only changes of commit 333c180b17 - Show all commits

View File

@@ -120,6 +120,8 @@ export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) {
value={`${cameraMake} ${cameraModel}`}
/>
)}
{cameraMake && <DetailRow label="Make" value={cameraMake} />}
{cameraModel && <DetailRow label="Model" value={cameraModel} />}
<DetailRow label="MIME Type" value={media.mime_type} />
<DetailRow label="File Hash" value={media.hash} isMono />

View File

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

View File

@@ -22,6 +22,13 @@ function MediaPage() {
const [sortBy, setSortBy] = useState<string>("created_at");
const [sortOrder, setSortOrder] = useState<"asc" | "desc">("desc");
const [mimeType, setMimeType] = useState<string | undefined>(undefined);
const [filters, setFilters] = useState<string[]>([]);
// 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<Media | null>(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 (
<div className="space-y-6">
<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}>
<div className="flex flex-col gap-4">
<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={mimeType || "all"} onValueChange={(val) => setMimeType(val === "all" ? undefined : val)}>
<SelectTrigger className="w-[120px]">
<SelectValue placeholder="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>
<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>
</div>
</div>
{/* Advanced Filters */}
<div className="flex flex-wrap gap-2 items-center border p-2 rounded bg-gray-50">
<span className="text-sm font-semibold">Add Filter:</span>
<Select value={filterField} onValueChange={setFilterField}>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Sort by" />
<SelectValue placeholder="Field" />
</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>
<SelectItem value="metadata.Make">Make</SelectItem>
<SelectItem value="metadata.Model">Model</SelectItem>
<SelectItem value="metadata.ISO">ISO</SelectItem>
<SelectItem value="metadata.FNumber">F-Number</SelectItem>
<SelectItem value="custom">Custom Field...</SelectItem>
</SelectContent>
</Select>
{sortBy === "custom" && (
{filterField === '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);
}
}}
className="border rounded px-2 py-1 text-sm w-[120px]"
placeholder="Field name"
value={customFieldName}
onChange={(e) => setCustomFieldName(e.target.value)}
/>
)}
<Select
value={sortOrder}
onValueChange={(val) => setSortOrder(val as "asc" | "desc")}
>
<SelectTrigger className="w-[140px]">
<SelectValue placeholder="Order" />
<Select value={filterOperator} onValueChange={setFilterOperator}>
<SelectTrigger className="w-[100px]">
<SelectValue placeholder="Op" />
</SelectTrigger>
<SelectContent>
<SelectItem value="desc">Newest First</SelectItem>
<SelectItem value="asc">Oldest First</SelectItem>
<SelectItem value="eq">=</SelectItem>
<SelectItem value="neq">!=</SelectItem>
<SelectItem value="like">Like</SelectItem>
<SelectItem value="gt">&gt;</SelectItem>
<SelectItem value="lt">&lt;</SelectItem>
<SelectItem value="gte">&gt;=</SelectItem>
<SelectItem value="lte">&lt;=</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>
<input
type="text"
value={filterValue}
onChange={(e) => setFilterValue(e.target.value)}
placeholder="Value"
className="border rounded px-2 py-1 text-sm w-[150px]"
onKeyDown={(e) => e.key === 'Enter' && handleAddFilter()}
/>
<Button onClick={handleAddFilter} size="sm">Add</Button>
</div>
{/* Active Filters List */}
{filters.length > 0 && (
<div className="flex gap-2 flex-wrap">
{filters.map((f, i) => (
<div key={i} className="bg-blue-100 text-blue-800 px-2 py-1 rounded text-sm flex items-center gap-2 border border-blue-200">
<span>{f.replace(/:/g, ' ')}</span>
<button onClick={() => handleRemoveFilter(i)} className="text-red-500 font-bold hover:text-red-700">×</button>
</div>
))}
</div>
)}
</div>
{isLoading && <p>Loading photos...</p>}

View File

@@ -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<PaginatedResponse<Media>> => {
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;

View File

@@ -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<ListMediaParams> 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<AppState> for ApiListMediaOptions {
parts: &mut Parts,
state: &AppState,
) -> Result<Self, Self::Rejection> {
let Query(params) = Query::<ListMediaParams>::from_request_parts(parts, state)
let Query(raw_params) = Query::<Vec<(String, String)>>::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()))
}
}

View File

@@ -42,6 +42,8 @@ pub struct ListMediaParams {
pub mime_type: Option<String>,
#[serde(default)]
pub metadata: Vec<String>,
#[serde(default)]
pub filters: Vec<String>,
pub page: Option<u32>,
pub limit: Option<u32>,
}

View File

@@ -59,6 +59,7 @@ pub struct SortParams {
pub struct FilterParams {
pub mime_type: Option<String>,
pub metadata_filters: Option<Vec<MetadataFilter>>,
pub conditions: Option<Vec<FilterCondition>>,
// In the future, we can add fields like:
// pub date_range: Option<(chrono::DateTime<chrono::Utc>, chrono::DateTime<chrono::Utc>)>,
}
@@ -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<MediaMetadata>,

View File

@@ -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<String>,
}
impl StandardFilterStrategy {
pub fn new(allowed_columns: Vec<String>) -> 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::<i32>().unwrap_or(0));
return Ok(());
}
FilterOperator::Neq => {
query.push(format!(
" AND EXTRACT(YEAR FROM media.{}) != ",
condition.field
));
query.push_bind(condition.value.parse::<i32>().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<T> {
pub struct MediaQueryBuilder {
sort_strategies: Vec<Box<dyn SortStrategy>>,
filter_strategies: Vec<Box<dyn FilterStrategy>>,
}
impl MediaQueryBuilder {
pub fn new(sort_strategies: Vec<Box<dyn SortStrategy>>) -> Self {
pub fn new(
sort_strategies: Vec<Box<dyn SortStrategy>>,
filter_strategies: Vec<Box<dyn FilterStrategy>>,
) -> 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 ");

View File

@@ -28,14 +28,19 @@ impl PostgresMediaRepository {
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)),
let sort_strategies: Vec<Box<dyn crate::query_builder::SortStrategy>> = vec![
Box::new(crate::query_builder::StandardSortStrategy::new(allowed_columns.clone())),
Box::new(crate::query_builder::MetadataSortStrategy),
];
let filter_strategies: Vec<Box<dyn crate::query_builder::FilterStrategy>> = 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)),
}
}