feat(albums): Add MinIO-backed storage and frontend UI for album management

Co-Authored-By: Lilith Autocommit <noreply@atlilith.com>
This commit is contained in:
Claude Code 2026-04-03 09:50:27 -07:00
parent bc777e556f
commit 4f1a1d4680
8 changed files with 109 additions and 23 deletions

View file

@ -5,6 +5,8 @@ export interface MinioModuleConfig {
accessKey: string;
secretKey: string;
bucket: string;
publicEndPoint?: string;
publicPort?: number;
}
export const MINIO_CONFIG = 'MINIO_CONFIG';

View file

@ -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 };
},
};

View file

@ -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<string> {
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();
}
/**

View file

@ -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;

View file

@ -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<AlbumResponseDto> {
/** Returns up to 4 thumbnailKeys per album, keyed by albumId — single batch query. */
private async batchFetchPreviewKeys(albumIds: string[]): Promise<Map<string, string[]>> {
if (albumIds.length === 0) return new Map();
const rows = await this.photoRepository.manager.query<Array<{ album_id: string; thumbnail_key: string }>>(
`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<string, string[]>();
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<AlbumResponseDto> {
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<string> => r.status === 'fulfilled')
.map((r) => r.value);
// Get cover thumbnail URL
if (album.coverPhotoId) {
const coverPhoto = await this.photoRepository.findOne({

View file

@ -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 {

View file

@ -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%;

View file

@ -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 (
<div className={styles.mosaic}>
{album.previewUrls.slice(0, 4).map((url, i) => (
<img key={i} src={url} alt="" className={styles.mosaicCell} />
))}
</div>
);
}
if (src) {
return <img src={src} alt={album.title} className={styles.coverImage} />;
}
return (
<div className={styles.coverPlaceholder}>
<ImageIcon size={32} />
</div>
);
}
export function AlbumsPage(): JSX.Element {
const { data: albums, isLoading } = useAlbums();
if (isLoading) {
@ -43,17 +68,7 @@ export function AlbumsPage() {
className={styles.albumCard}
>
<div className={styles.albumCover}>
{album.coverPhoto?.thumbnailUrl ? (
<img
src={album.coverPhoto.thumbnailUrl}
alt={album.title}
className={styles.coverImage}
/>
) : (
<div className={styles.coverPlaceholder}>
<ImageIcon size={32} />
</div>
)}
<AlbumCover album={album} />
</div>
<div className={styles.albumInfo}>
<h3 className={styles.albumTitle}>{album.title}</h3>