245 lines
8.9 KiB
TypeScript
245 lines
8.9 KiB
TypeScript
import { useGetMediaList } from "@/features/media/use-media";
|
||
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 { MediaViewer } from "@/components/media/media-viewer";
|
||
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 [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,
|
||
isLoading,
|
||
error,
|
||
fetchNextPage,
|
||
hasNextPage,
|
||
isFetchingNextPage,
|
||
} = useGetMediaList(1, 20, sortBy, sortOrder, mimeType, filters);
|
||
|
||
const [selectedMedia, setSelectedMedia] = useState<Media | null>(null);
|
||
|
||
const allMedia = useMemo(
|
||
() => data?.pages.flatMap((page) => page.data) ?? [],
|
||
[data]
|
||
);
|
||
|
||
const groupedMedia = useMemo(() => groupMediaByDate(allMedia), [allMedia]);
|
||
|
||
const groupEntries = useMemo(
|
||
() => Array.from(groupedMedia.entries()),
|
||
[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 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="Field" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<SelectItem value="original_filename">Filename</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>
|
||
|
||
{filterField === 'custom' && (
|
||
<input
|
||
className="border rounded px-2 py-1 text-sm w-[120px]"
|
||
placeholder="Field name"
|
||
value={customFieldName}
|
||
onChange={(e) => setCustomFieldName(e.target.value)}
|
||
/>
|
||
)}
|
||
|
||
<Select value={filterOperator} onValueChange={setFilterOperator}>
|
||
<SelectTrigger className="w-[100px]">
|
||
<SelectValue placeholder="Op" />
|
||
</SelectTrigger>
|
||
<SelectContent>
|
||
<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>
|
||
|
||
<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>}
|
||
{error && <p>Error loading photos: {error.message}</p>}
|
||
|
||
{data && (
|
||
<div className="space-y-8">
|
||
{groupEntries.map(([title, media]) => (
|
||
<section key={title}>
|
||
<h2 className="text-xl font-semibold mb-4">{title}</h2>
|
||
|
||
<div className="grid grid-cols-3 sm:grid-cols-4 md:grid-cols-6 lg:grid-cols-8 gap-2">
|
||
{media.map((media) => (
|
||
<div
|
||
key={media.id}
|
||
className="aspect-square bg-gray-200 rounded-md overflow-hidden cursor-pointer hover:opacity-80 transition-opacity"
|
||
onClick={() => setSelectedMedia(media)}
|
||
>
|
||
<AuthenticatedImage
|
||
src={media.thumbnail_url ?? media.file_url}
|
||
alt={media.original_filename}
|
||
className="w-full h-full object-cover"
|
||
/>
|
||
</div>
|
||
))}
|
||
</div>
|
||
</section>
|
||
))}
|
||
</div>
|
||
)}
|
||
|
||
{hasNextPage && (
|
||
<div className="flex justify-center mt-6">
|
||
<Button onClick={() => fetchNextPage()} disabled={isFetchingNextPage}>
|
||
{isFetchingNextPage ? "Loading more..." : "Load More"}
|
||
</Button>
|
||
</div>
|
||
)}
|
||
|
||
<MediaViewer
|
||
media={selectedMedia}
|
||
onOpenChange={(open) => {
|
||
if (!open) {
|
||
setSelectedMedia(null);
|
||
}
|
||
}}
|
||
/>
|
||
</div>
|
||
);
|
||
}
|