feat: Implement advanced filtering with new filter conditions and a strategy-based query builder.
This commit is contained in:
@@ -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 />
|
||||
|
||||
|
||||
@@ -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;
|
||||
},
|
||||
|
||||
@@ -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">></SelectItem>
|
||||
<SelectItem value="lt"><</SelectItem>
|
||||
<SelectItem value="gte">>=</SelectItem>
|
||||
<SelectItem value="lte"><=</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>}
|
||||
|
||||
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user