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 (
);
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
";