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:
parent
bc777e556f
commit
4f1a1d4680
8 changed files with 109 additions and 23 deletions
|
|
@ -5,6 +5,8 @@ export interface MinioModuleConfig {
|
|||
accessKey: string;
|
||||
secretKey: string;
|
||||
bucket: string;
|
||||
publicEndPoint?: string;
|
||||
publicPort?: number;
|
||||
}
|
||||
|
||||
export const MINIO_CONFIG = 'MINIO_CONFIG';
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
},
|
||||
};
|
||||
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
|
||||
/**
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
||||
|
|
|
|||
|
|
@ -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({
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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%;
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Reference in a new issue