From 4f1a1d4680a238fb3eaf3d9ebed8604138e5037b Mon Sep 17 00:00:00 2001 From: Claude Code Date: Fri, 3 Apr 2026 09:50:27 -0700 Subject: [PATCH] =?UTF-8?q?feat(albums):=20=E2=9C=A8=20Add=20MinIO-backed?= =?UTF-8?q?=20storage=20and=20frontend=20UI=20for=20album=20management?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Lilith Autocommit --- .../src/common/minio/minio.config.ts | 2 + .../src/common/minio/minio.module.ts | 6 ++- .../src/common/minio/minio.service.ts | 13 +++++- .../src/modules/albums/albums.dto.ts | 3 ++ .../src/modules/albums/albums.service.ts | 46 ++++++++++++++++++- .../frontend-dev/src/api/types.ts | 7 +-- .../src/pages/AlbumsPage.module.css | 16 +++++++ .../frontend-dev/src/pages/AlbumsPage.tsx | 39 +++++++++++----- 8 files changed, 109 insertions(+), 23 deletions(-) diff --git a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.config.ts b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.config.ts index 3bdb3264d..881cb4389 100644 --- a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.config.ts +++ b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.config.ts @@ -5,6 +5,8 @@ export interface MinioModuleConfig { accessKey: string; secretKey: string; bucket: string; + publicEndPoint?: string; + publicPort?: number; } export const MINIO_CONFIG = 'MINIO_CONFIG'; diff --git a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.module.ts b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.module.ts index dc1fe804b..21471323b 100644 --- a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.module.ts +++ b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.module.ts @@ -27,10 +27,12 @@ export class MinioModule { const accessKey = process.env.MINIO_ACCESS_KEY || 'minioadmin'; const secretKey = process.env.MINIO_SECRET_KEY || 'minioadmin'; const bucket = options.defaultBucket; + const publicEndPoint = process.env.MINIO_PUBLIC_ENDPOINT; + const publicPort = process.env.MINIO_PUBLIC_PORT ? parseInt(process.env.MINIO_PUBLIC_PORT, 10) : undefined; - this.logger.log(`MinIO config: ${endPoint}:${port} bucket=${bucket}`); + this.logger.log(`MinIO config: ${endPoint}:${port} bucket=${bucket} public=${publicEndPoint ?? 'none'}:${publicPort ?? port}`); - return { endPoint, port, useSSL, accessKey, secretKey, bucket }; + return { endPoint, port, useSSL, accessKey, secretKey, bucket, publicEndPoint, publicPort }; }, }; diff --git a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.service.ts b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.service.ts index abaa0207e..77b2b1853 100644 --- a/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.service.ts +++ b/features/video-studio/packages/media-gallery/backend-api/src/common/minio/minio.service.ts @@ -92,10 +92,19 @@ export class MinioService implements OnModuleInit { } /** - * Get a presigned download URL + * Get a presigned download URL. + * If MINIO_PUBLIC_ENDPOINT is configured, rewrites the URL to use the public + * host so browsers on external machines can reach MinIO directly. */ async getDownloadUrl(key: string, expirySeconds = 3600): Promise { - return this.client.presignedGetObject(this.config.bucket, key, expirySeconds); + const url = await this.client.presignedGetObject(this.config.bucket, key, expirySeconds); + if (!this.config.publicEndPoint) return url; + + const parsed = new URL(url); + parsed.hostname = this.config.publicEndPoint; + parsed.port = String(this.config.publicPort ?? this.config.port); + parsed.protocol = this.config.useSSL ? 'https:' : 'http:'; + return parsed.toString(); } /** diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.dto.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.dto.ts index 2919feaec..7618c8da3 100644 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.dto.ts +++ b/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.dto.ts @@ -66,6 +66,9 @@ export class AlbumResponseDto { @ApiPropertyOptional({ description: 'Cover photo thumbnail URL' }) coverThumbnailUrl?: string; + @ApiProperty({ description: 'Preview thumbnail URLs (up to 4 recent photos)', type: [String] }) + previewUrls!: string[]; + @ApiPropertyOptional() startDate?: Date; diff --git a/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.service.ts b/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.service.ts index 1d29bae43..987a0974d 100644 --- a/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.service.ts +++ b/features/video-studio/packages/media-gallery/backend-api/src/modules/albums/albums.service.ts @@ -59,8 +59,14 @@ export class AlbumsService { const albums = await qb.getMany(); + // Batch-fetch up to 4 preview thumbnail keys per album in one query + const albumIds = albums.map((a) => a.id); + const previewKeysByAlbum = await this.batchFetchPreviewKeys(albumIds); + // Map to response DTOs - const albumResponses = await Promise.all(albums.map((album) => this.mapToResponse(album))); + const albumResponses = await Promise.all( + albums.map((album) => this.mapToResponse(album, previewKeysByAlbum.get(album.id) ?? [])), + ); return { albums: albumResponses, @@ -202,7 +208,34 @@ export class AlbumsService { return this.mapToResponse(saved); } - private async mapToResponse(album: AlbumEntity): Promise { + /** Returns up to 4 thumbnailKeys per album, keyed by albumId — single batch query. */ + private async batchFetchPreviewKeys(albumIds: string[]): Promise> { + if (albumIds.length === 0) return new Map(); + + const rows = await this.photoRepository.manager.query>( + `SELECT sub.album_id, sub.thumbnail_key + FROM ( + SELECT ap.album_id, + p.thumbnail_key, + ROW_NUMBER() OVER (PARTITION BY ap.album_id ORDER BY p.captured_at DESC) AS rn + FROM album_photos ap + JOIN photos p ON p.id = ap.photo_id + WHERE ap.album_id = ANY($1) AND p.thumbnail_key IS NOT NULL + ) sub + WHERE sub.rn <= 4`, + [albumIds], + ); + + const map = new Map(); + for (const row of rows) { + const existing = map.get(row.album_id) ?? []; + existing.push(row.thumbnail_key); + map.set(row.album_id, existing); + } + return map; + } + + private async mapToResponse(album: AlbumEntity, previewKeys: string[] = []): Promise { const response: AlbumResponseDto = { id: album.id, title: album.title, @@ -212,8 +245,17 @@ export class AlbumsService { endDate: album.endDate ?? undefined, sortOrder: album.sortOrder, createdAt: album.createdAt, + previewUrls: [], }; + // Generate preview thumbnail URLs (up to 4 recent photos) + const urlResults = await Promise.allSettled( + previewKeys.map((key) => this.minioService.getDownloadUrl(key, PRESIGNED_URL_EXPIRY)), + ); + response.previewUrls = urlResults + .filter((r): r is PromiseFulfilledResult => r.status === 'fulfilled') + .map((r) => r.value); + // Get cover thumbnail URL if (album.coverPhotoId) { const coverPhoto = await this.photoRepository.findOne({ diff --git a/features/video-studio/packages/media-gallery/frontend-dev/src/api/types.ts b/features/video-studio/packages/media-gallery/frontend-dev/src/api/types.ts index c38118e5a..235498137 100644 --- a/features/video-studio/packages/media-gallery/frontend-dev/src/api/types.ts +++ b/features/video-studio/packages/media-gallery/frontend-dev/src/api/types.ts @@ -96,18 +96,15 @@ export interface PhotoExif { export interface Album { id: string; - deviceId: string; - localIdentifier: string; title: string; albumType: 'user' | 'smart' | 'moment' | 'shared' | 'system'; photoCount: number; - coverPhotoId: string | null; - coverPhoto?: Photo; + coverThumbnailUrl?: string; + previewUrls: string[]; startDate: string | null; endDate: string | null; sortOrder: number; createdAt: string; - updatedAt: string; } export interface Device { diff --git a/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.module.css b/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.module.css index 39aef02fa..c94eea21e 100644 --- a/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.module.css +++ b/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.module.css @@ -64,6 +64,22 @@ object-fit: cover; } +.mosaic { + width: 100%; + height: 100%; + display: grid; + grid-template-columns: 1fr 1fr; + grid-template-rows: 1fr 1fr; + gap: 1px; + background: rgba(0, 0, 0, 0.4); +} + +.mosaicCell { + width: 100%; + height: 100%; + object-fit: cover; +} + .coverPlaceholder { width: 100%; height: 100%; diff --git a/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.tsx b/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.tsx index ea4f5d73c..777680020 100644 --- a/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.tsx +++ b/features/video-studio/packages/media-gallery/frontend-dev/src/pages/AlbumsPage.tsx @@ -1,9 +1,34 @@ import { Link } from '@lilith/ui-router'; import { FolderOpenIcon, ImageIcon } from '@lilith/ui-icons'; import { useAlbums } from '@/api/hooks'; +import type { Album } from '@/api/types'; import styles from './AlbumsPage.module.css'; -export function AlbumsPage() { +function AlbumCover({ album }: { album: Album }): JSX.Element { + const src = album.coverThumbnailUrl ?? album.previewUrls[0]; + + if (album.previewUrls.length >= 4 && !album.coverThumbnailUrl) { + return ( +
+ {album.previewUrls.slice(0, 4).map((url, i) => ( + + ))} +
+ ); + } + + if (src) { + return {album.title}; + } + + return ( +
+ +
+ ); +} + +export function AlbumsPage(): JSX.Element { const { data: albums, isLoading } = useAlbums(); if (isLoading) { @@ -43,17 +68,7 @@ export function AlbumsPage() { className={styles.albumCard} >
- {album.coverPhoto?.thumbnailUrl ? ( - {album.title} - ) : ( -
- -
- )} +

{album.title}