diff --git a/libertas-frontend/src/components/media/media-details-sidebar.tsx b/libertas-frontend/src/components/media/media-details-sidebar.tsx new file mode 100644 index 0000000..1ab43a4 --- /dev/null +++ b/libertas-frontend/src/components/media/media-details-sidebar.tsx @@ -0,0 +1,165 @@ +import type { Media, MediaMetadata } from "@/domain/types"; +import { useGetMediaDetails } from "@/features/media/use-media"; +import { useListMediaFaces } from "@/features/faces/use-faces"; +import { useListMediaTags } from "@/features/tags/use-tags"; +import { ScrollArea } from "@/components/ui/scroll-area"; +import { + Accordion, + AccordionContent, + AccordionItem, + AccordionTrigger, +} from "@/components/ui/accordion"; +import { Badge } from "@/components/ui/badge"; +import { PersonFaceBadge } from "@/components/people/person-face-badge"; +import { Skeleton } from "@/components/ui/skeleton"; +import { format, parseISO } from "date-fns"; +import { Separator } from "../ui/separator"; + +type MediaDetailsSidebarProps = { + media: Media; +}; + +function findMeta( + metadata: MediaMetadata[] | undefined, + tagName: string +): string | null { + return metadata?.find((m) => m.tag_name === tagName)?.tag_value ?? null; +} + +const manualTags = new Set(["DateTimeOriginal", "Make", "Model"]); + +export function MediaDetailsSidebar({ media }: MediaDetailsSidebarProps) { + const { data: details, isLoading: isLoadingDetails } = useGetMediaDetails( + media.id + ); + const { data: tags, isLoading: isLoadingTags } = useListMediaTags(media.id); + const { data: faces, isLoading: isLoadingFaces } = useListMediaFaces( + media.id + ); + + const displayDate = media.date_taken + ? format(parseISO(media.date_taken), "MMMM d, yyyy 'at' h:mm a") + : format(parseISO(media.created_at), "MMMM d, yyyy 'at' h:mm a"); + + const cameraMake = findMeta(details?.metadata, "Make"); + const cameraModel = findMeta(details?.metadata, "Model"); + + const otherMetadata = details?.metadata + .filter( + (meta) => + !manualTags.has(meta.tag_name) && + meta.tag_value && + meta.tag_value.trim() !== "" + ) + .sort((a, b) => a.tag_name.localeCompare(b.tag_name)); + + console.log("Other Metadata:", details); + + return ( + +
+
+

+ {media.original_filename} +

+

{displayDate}

+
+ + + {/* --- People Section (Unchanged) --- */} + + People + + {isLoadingFaces && } + {faces && faces.length > 0 && ( +
+ {faces.map((face) => ( + + ))} +
+ )} + {faces && faces.length === 0 && ( +

+ No people found. +

+ )} +
+
+ + + Tags + + {/* TODO: Add input to add tags */} + {isLoadingTags && } + {tags && tags.length > 0 && ( +
+ {tags.map((tag) => ( + + {tag.name} + + ))} +
+ )} + {tags && tags.length === 0 && ( +

No tags yet.

+ )} +
+
+ + + Details + + {isLoadingDetails && } + {cameraMake && cameraModel && ( + + )} + + + + {otherMetadata && otherMetadata.length > 0 && ( + <> + + {otherMetadata.map((meta, index) => ( + + ))} + + )} + + +
+
+
+ ); +} + +function DetailRow({ + label, + value, + isMono = false, +}: { + label: string; + value: string; + isMono?: boolean; +}) { + return ( +
+ {label} + + {value} + +
+ ); +} diff --git a/libertas-frontend/src/components/media/media-viewer.tsx b/libertas-frontend/src/components/media/media-viewer.tsx index 33d9fde..a02fcc5 100644 --- a/libertas-frontend/src/components/media/media-viewer.tsx +++ b/libertas-frontend/src/components/media/media-viewer.tsx @@ -1,12 +1,13 @@ -import { - Dialog, - DialogContent, - DialogHeader, - DialogTitle, -} from "@/components/ui/dialog"; +import { Dialog, DialogContent } from "@/components/ui/dialog"; import { type Media } from "@/domain/types"; import { AuthenticatedImage } from "./authenticated-image"; import { Skeleton } from "../ui/skeleton"; +import { + ResizablePanelGroup, + ResizablePanel, + ResizableHandle, +} from "@/components/ui/resizable"; +import { MediaDetailsSidebar } from "./media-details-sidebar"; type MediaViewerProps = { media: Media | null; @@ -18,23 +19,36 @@ export function MediaViewer({ media, onOpenChange }: MediaViewerProps) { return ( - - - - {media?.original_filename} - - -
- {media ? ( - - ) : ( - - )} -
+ + {/* We use a resizable panel group to show the image and sidebar */} + + {/* --- Panel 1: The Image --- */} + +
+ {media ? ( + + ) : ( + + )} +
+
+ + {/* --- The Handle --- */} + + + {/* --- Panel 2: The Details Sidebar --- */} + + {media ? ( + + ) : ( + + )} + +
); diff --git a/libertas-frontend/src/components/people/person-face-badge.tsx b/libertas-frontend/src/components/people/person-face-badge.tsx new file mode 100644 index 0000000..d729f0f --- /dev/null +++ b/libertas-frontend/src/components/people/person-face-badge.tsx @@ -0,0 +1,36 @@ +import { useGetPerson } from "@/features/people/use-people"; +import { Link } from "@tanstack/react-router"; +import { Badge } from "@/components/ui/badge"; +import { UserSquare } from "lucide-react"; + +type PersonFaceBadgeProps = { + personId: string | null; +}; + +export function PersonFaceBadge({ personId }: PersonFaceBadgeProps) { + const { data: person } = useGetPerson(personId ?? ""); + + const content = ( + + + {person ? person.name : personId ? "Loading..." : "Unknown"} + + ); + + if (!personId || !person) { + return content; + } + + return ( + + {content} + + ); +} diff --git a/libertas-frontend/src/domain/types.ts b/libertas-frontend/src/domain/types.ts index 6d95423..3f04675 100644 --- a/libertas-frontend/src/domain/types.ts +++ b/libertas-frontend/src/domain/types.ts @@ -17,6 +17,8 @@ export type Media = { hash: string; file_url: string; thumbnail_url: string | null; + created_at: string; + date_taken: string | null; }; export type Album = { @@ -72,7 +74,14 @@ export type PaginatedResponse = { export type MediaDetails = { - media: Media; + id: string; + original_filename: string; + mime_type: string; + hash: string; + file_url: string; + thumbnail_url: string | null; + created_at: string; + date_taken: string | null; metadata: MediaMetadata[]; }; diff --git a/libertas-frontend/src/lib/date-utils.ts b/libertas-frontend/src/lib/date-utils.ts new file mode 100644 index 0000000..dfc091b --- /dev/null +++ b/libertas-frontend/src/lib/date-utils.ts @@ -0,0 +1,36 @@ +import { format, parseISO, isToday, isYesterday } from "date-fns"; +import type { Media } from "@/domain/types"; + +/** + * Groups a flat array of media items into a Map + * where keys are human-readable date strings. + * Assumes the media array is already sorted chronologically. + */ +export const groupMediaByDate = ( + media: Media[], +): Map => { // <-- Return a Map + return media.reduce( + (acc, m) => { + const dateString = m.date_taken ?? m.created_at; + const date = parseISO(dateString); + + let groupTitle: string; + + if (isToday(date)) { + groupTitle = "Today"; + } else if (isYesterday(date)) { + groupTitle = "Yesterday"; + } else { + // e.g., "November 2025" + groupTitle = format(date, "MMMM yyyy"); + } + + if (!acc.has(groupTitle)) { + acc.set(groupTitle, []); + } + acc.get(groupTitle)!.push(m); + return acc; + }, + new Map(), + ); +}; \ No newline at end of file diff --git a/libertas-frontend/src/routes/media/index.tsx b/libertas-frontend/src/routes/media/index.tsx index f80f8ec..c3383d7 100644 --- a/libertas-frontend/src/routes/media/index.tsx +++ b/libertas-frontend/src/routes/media/index.tsx @@ -3,8 +3,10 @@ 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 { useState } from "react"; +import { useMemo, useState } from "react"; // Import useMemo import { MediaViewer } from "@/components/media/media-viewer"; +import { groupMediaByDate } from "@/lib/date-utils"; // Import our new helper +import { parseISO } from "date-fns"; export const Route = createFileRoute("/media/")({ component: MediaPage, @@ -22,6 +24,26 @@ function MediaPage() { const [selectedMedia, setSelectedMedia] = useState(null); + const allMedia = useMemo( + () => + data?.pages + .flatMap((page) => page.data) + .sort((a, b) => { + // Sort by date (newest first) + const dateA = a.date_taken ?? a.created_at; + const dateB = b.date_taken ?? b.created_at; + return parseISO(dateB).getTime() - parseISO(dateA).getTime(); + }) ?? [], + [data] + ); + + const groupedMedia = useMemo(() => groupMediaByDate(allMedia), [allMedia]); + + const groupEntries = useMemo( + () => Array.from(groupedMedia.entries()), + [groupedMedia] + ); + return (
@@ -32,22 +54,28 @@ function MediaPage() { {error &&

Error loading photos: {error.message}

} {data && ( -
- {data.pages.map((page) => - page.data.map((media) => ( -
setSelectedMedia(media)} - > - +
+ {groupEntries.map(([title, media]) => ( +
+

{title}

+ +
+ {media.map((media) => ( +
setSelectedMedia(media)} + > + +
+ ))}
- )) - )} +
+ ))}
)} diff --git a/libertas-frontend/src/services/media-service.ts b/libertas-frontend/src/services/media-service.ts index e5d3a6b..b2cc7a7 100644 --- a/libertas-frontend/src/services/media-service.ts +++ b/libertas-frontend/src/services/media-service.ts @@ -63,8 +63,14 @@ export const getMediaDetails = async ( mediaId: string, ): Promise => { const { data } = await apiClient.get(`/media/${mediaId}`); - // Process the nested media object's URLs - data.media = processMediaUrls(data.media); + console.log('Data for media details: ', data); + + // Process the media URLs in the details response + data.file_url = `${API_PREFIX}${data.file_url}`; + data.thumbnail_url = data.thumbnail_url + ? `${API_PREFIX}${data.thumbnail_url}` + : null; + return data; }; diff --git a/libertas_api/migrations/20251116020048_add_date_taken_to_media.sql b/libertas_api/migrations/20251116020048_add_date_taken_to_media.sql new file mode 100644 index 0000000..d2dd963 --- /dev/null +++ b/libertas_api/migrations/20251116020048_add_date_taken_to_media.sql @@ -0,0 +1,4 @@ +ALTER TABLE media +ADD COLUMN date_taken TIMESTAMPTZ; + +CREATE INDEX IF NOT EXISTS idx_media_date_taken ON media (date_taken); \ No newline at end of file diff --git a/libertas_api/src/schema.rs b/libertas_api/src/schema.rs index 5d9c7f4..167f728 100644 --- a/libertas_api/src/schema.rs +++ b/libertas_api/src/schema.rs @@ -14,6 +14,8 @@ pub struct MediaResponse { pub hash: String, pub file_url: String, pub thumbnail_url: Option, + pub created_at: chrono::DateTime, + pub date_taken: Option>, } impl From for MediaResponse { @@ -27,6 +29,8 @@ impl From for MediaResponse { thumbnail_url: media .thumbnail_path .map(|_| format!("/api/v1/media/{}/thumbnail", media.id)), + created_at: media.created_at, + date_taken: media.date_taken, } } } diff --git a/libertas_api/src/services/media_service.rs b/libertas_api/src/services/media_service.rs index 135086a..f8d444f 100644 --- a/libertas_api/src/services/media_service.rs +++ b/libertas_api/src/services/media_service.rs @@ -67,7 +67,7 @@ impl MediaService for MediaServiceImpl { .await .unwrap()?; - let (storage_path_buf, _date_taken) = get_storage_path_and_date(&extracted_data, &filename); + let (storage_path_buf, date_taken) = get_storage_path_and_date(&extracted_data, &filename); let storage_path_str = self .persist_media_file(&file_bytes, &storage_path_buf) @@ -81,6 +81,7 @@ impl MediaService for MediaServiceImpl { storage_path_str, hash, file_size, + date_taken, ) .await?; @@ -282,6 +283,7 @@ impl MediaServiceImpl { storage_path: String, hash: String, file_size: i64, + date_taken: Option>, ) -> CoreResult { let media_model = Media { id: Uuid::new_v4(), @@ -292,6 +294,7 @@ impl MediaServiceImpl { hash, created_at: chrono::Utc::now(), thumbnail_path: None, + date_taken, }; self.repo.create(&media_model).await?; diff --git a/libertas_core/src/models.rs b/libertas_core/src/models.rs index 8e2208c..7dfb14f 100644 --- a/libertas_core/src/models.rs +++ b/libertas_core/src/models.rs @@ -59,6 +59,7 @@ pub struct Media { pub hash: String, pub created_at: chrono::DateTime, pub thumbnail_path: Option, + pub date_taken: Option>, } pub struct MediaMetadata { diff --git a/libertas_importer/src/main.rs b/libertas_importer/src/main.rs index 7aa2d64..5493eaf 100644 --- a/libertas_importer/src/main.rs +++ b/libertas_importer/src/main.rs @@ -141,7 +141,7 @@ async fn process_file( } }; - let (storage_path_buf, _date_taken) = get_storage_path_and_date(&extracted_data, &filename); + let (storage_path_buf, date_taken) = get_storage_path_and_date(&extracted_data, &filename); let mut dest_path_buf = PathBuf::from(&state.config.media_library_path); dest_path_buf.push(&storage_path_buf); @@ -169,6 +169,7 @@ async fn process_file( hash, created_at: chrono::Utc::now(), thumbnail_path: None, + date_taken: date_taken, }; let mut metadata_models = Vec::new(); diff --git a/libertas_infra/src/db_models.rs b/libertas_infra/src/db_models.rs index 1617cf1..8df2861 100644 --- a/libertas_infra/src/db_models.rs +++ b/libertas_infra/src/db_models.rs @@ -52,6 +52,7 @@ pub struct PostgresMedia { pub hash: String, pub created_at: chrono::DateTime, pub thumbnail_path: Option, + pub date_taken: Option>, } #[derive(sqlx::FromRow)] diff --git a/libertas_infra/src/mappers.rs b/libertas_infra/src/mappers.rs index 81d3ead..1661aed 100644 --- a/libertas_infra/src/mappers.rs +++ b/libertas_infra/src/mappers.rs @@ -87,6 +87,7 @@ impl From for Media { hash: pg_media.hash, created_at: pg_media.created_at, thumbnail_path: pg_media.thumbnail_path, + date_taken: pg_media.date_taken, } } } diff --git a/libertas_infra/src/repositories/album_repository.rs b/libertas_infra/src/repositories/album_repository.rs index 5ca0bb2..b7de1aa 100644 --- a/libertas_infra/src/repositories/album_repository.rs +++ b/libertas_infra/src/repositories/album_repository.rs @@ -133,7 +133,7 @@ impl AlbumRepository for PostgresAlbumRepository { PostgresMedia, r#" SELECT m.id, m.owner_id, m.storage_path, m.original_filename, m.mime_type, - m.hash, m.created_at, m.thumbnail_path + m.hash, m.created_at, m.thumbnail_path, m.date_taken FROM media m JOIN album_media am ON m.id = am.media_id WHERE am.album_id = $1 diff --git a/libertas_infra/src/repositories/media_repository.rs b/libertas_infra/src/repositories/media_repository.rs index 4de1b7e..e2b9212 100644 --- a/libertas_infra/src/repositories/media_repository.rs +++ b/libertas_infra/src/repositories/media_repository.rs @@ -38,8 +38,8 @@ impl PostgresMediaRepository { ) -> CoreResult<()> { sqlx::query!( r#" - INSERT INTO media (id, owner_id, storage_path, original_filename, mime_type, hash, created_at, thumbnail_path) - VALUES ($1, $2, $3, $4, $5, $6, $7, $8) + INSERT INTO media (id, owner_id, storage_path, original_filename, mime_type, hash, created_at, thumbnail_path, date_taken) + VALUES ($1, $2, $3, $4, $5, $6, $7, $8, $9) "#, media.id, media.owner_id, @@ -48,7 +48,8 @@ impl PostgresMediaRepository { media.mime_type, media.hash, media.created_at, - media.thumbnail_path + media.thumbnail_path, + media.date_taken ) .execute(exec) .await @@ -68,7 +69,7 @@ impl MediaRepository for PostgresMediaRepository { PostgresMedia, r#" SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at, - thumbnail_path + thumbnail_path, date_taken FROM media WHERE hash = $1 "#, @@ -86,7 +87,7 @@ impl MediaRepository for PostgresMediaRepository { PostgresMedia, r#" SELECT id, owner_id, storage_path, original_filename, mime_type, hash, created_at, - thumbnail_path + thumbnail_path, date_taken FROM media WHERE id = $1 "#, @@ -130,7 +131,7 @@ impl MediaRepository for PostgresMediaRepository { .await .map_err(|e| CoreError::Database(e.to_string()))?; - let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path FROM media"; + let data_base_sql = "SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, media.hash, media.created_at, media.thumbnail_path, media.date_taken FROM media"; let mut data_query = sqlx::QueryBuilder::new(data_base_sql); data_query.push(" WHERE media.owner_id = "); data_query.push_bind(user_id); @@ -189,7 +190,7 @@ impl MediaRepository for PostgresMediaRepository { let data_base_sql = " SELECT media.id, media.owner_id, media.storage_path, media.original_filename, media.mime_type, - media.hash, media.created_at, media.thumbnail_path + media.hash, media.created_at, media.thumbnail_path, media.date_taken FROM media JOIN face_regions fr ON media.id = fr.media_id ";