Files
k-photos/libertas-frontend/src/routes/media/index.tsx

245 lines
8.9 KiB
TypeScript
Raw Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
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">&gt;</SelectItem>
<SelectItem value="lt">&lt;</SelectItem>
<SelectItem value="gte">&gt;=</SelectItem>
<SelectItem value="lte">&lt;=</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>
);
}