From 1b3a357b5d0a075286c552aa4bd3af03113f5739 Mon Sep 17 00:00:00 2001 From: Oleg Proskurin Date: Sun, 12 Oct 2025 00:30:16 +0700 Subject: [PATCH] feat: add gallery --- apps/admin/next-env.d.ts | 5 + apps/api-service/src/db.ts | 8 + apps/api-service/src/routes/images.ts | 93 +++++ apps/api-service/src/server.ts | 7 + .../src/services/MinioStorageService.ts | 9 +- apps/landing/src/app/demo/gallery/page.tsx | 377 ++++++++++++++++++ .../src/components/demo/ImageZoomModal.tsx | 56 ++- .../demo/gallery/EmptyGalleryState.tsx | 2 +- .../demo/gallery/GalleryImageCard.tsx | 47 ++- .../components/shared/ImageMetadataBar.tsx | 24 +- .../src/hooks/useIntersectionObserver.ts | 2 +- apps/studio/next-env.d.ts | 5 + 12 files changed, 585 insertions(+), 50 deletions(-) create mode 100644 apps/admin/next-env.d.ts create mode 100644 apps/landing/src/app/demo/gallery/page.tsx create mode 100644 apps/studio/next-env.d.ts diff --git a/apps/admin/next-env.d.ts b/apps/admin/next-env.d.ts new file mode 100644 index 0000000..40c3d68 --- /dev/null +++ b/apps/admin/next-env.d.ts @@ -0,0 +1,5 @@ +/// +/// + +// NOTE: This file should not be edited +// see https://nextjs.org/docs/app/building-your-application/configuring/typescript for more information. diff --git a/apps/api-service/src/db.ts b/apps/api-service/src/db.ts index 0800e4c..a94d211 100644 --- a/apps/api-service/src/db.ts +++ b/apps/api-service/src/db.ts @@ -1,9 +1,17 @@ import { createDbClient } from '@banatie/database'; +import { config } from 'dotenv'; +import path from 'path'; + +// Load .env from api-service directory BEFORE reading env vars +// __dirname in tsx points to src directory, so ../.env goes to api-service/.env +config({ path: path.join(__dirname, '../.env'), debug: true }); const DATABASE_URL = process.env['DATABASE_URL'] || 'postgresql://banatie_user:banatie_secure_password@localhost:5434/banatie_db'; +console.log('[DB] Using DATABASE_URL:', DATABASE_URL); + export const db = createDbClient(DATABASE_URL); console.log( diff --git a/apps/api-service/src/routes/images.ts b/apps/api-service/src/routes/images.ts index 0393eba..988bb6a 100644 --- a/apps/api-service/src/routes/images.ts +++ b/apps/api-service/src/routes/images.ts @@ -1,6 +1,9 @@ import { Router, Request, Response } from 'express'; import { StorageFactory } from '../services/StorageFactory'; import { asyncHandler } from '../middleware/errorHandler'; +import { validateApiKey } from '../middleware/auth/validateApiKey'; +import { requireProjectKey } from '../middleware/auth/requireProjectKey'; +import { rateLimitByApiKey } from '../middleware/auth/rateLimiter'; export const imagesRouter = Router(); @@ -135,3 +138,93 @@ imagesRouter.get( } }), ); + +/** + * GET /api/images/generated + * List generated images for the authenticated project + */ +imagesRouter.get( + '/images/generated', + validateApiKey, + requireProjectKey, + rateLimitByApiKey, + asyncHandler(async (req: any, res: Response) => { + const timestamp = new Date().toISOString(); + const requestId = req.requestId; + + // Parse and validate query parameters + const limit = Math.min(Math.max(parseInt(req.query.limit as string, 10) || 30, 1), 100); + const offset = Math.max(parseInt(req.query.offset as string, 10) || 0, 0); + const prefix = (req.query.prefix as string) || undefined; + + // Validate query parameters + if (isNaN(limit) || isNaN(offset)) { + return res.status(400).json({ + success: false, + message: 'Invalid query parameters', + error: 'limit and offset must be valid numbers', + }); + } + + // Extract org/project from validated API key + const orgId = req.apiKey?.organizationSlug || 'default'; + const projectId = req.apiKey?.projectSlug!; + + console.log( + `[${timestamp}] [${requestId}] Listing generated images for org:${orgId}, project:${projectId}, limit:${limit}, offset:${offset}, prefix:${prefix || 'none'}`, + ); + + try { + // Get storage service instance + const storageService = await StorageFactory.getInstance(); + + // List files in generated category + const allFiles = await storageService.listFiles(orgId, projectId, 'generated', prefix); + + // Sort by lastModified descending (newest first) + allFiles.sort((a, b) => { + const dateA = a.lastModified ? new Date(a.lastModified).getTime() : 0; + const dateB = b.lastModified ? new Date(b.lastModified).getTime() : 0; + return dateB - dateA; + }); + + // Apply pagination + const total = allFiles.length; + const paginatedFiles = allFiles.slice(offset, offset + limit); + + // Map to response format with public URLs + const images = paginatedFiles.map((file) => ({ + filename: file.filename, + url: storageService.getPublicUrl(orgId, projectId, 'generated', file.filename), + size: file.size, + contentType: file.contentType, + lastModified: file.lastModified ? file.lastModified.toISOString() : new Date().toISOString(), + })); + + const hasMore = offset + limit < total; + + console.log( + `[${timestamp}] [${requestId}] Successfully listed ${images.length} of ${total} generated images`, + ); + + return res.status(200).json({ + success: true, + data: { + images, + total, + offset, + limit, + hasMore, + }, + }); + } catch (error) { + console.error(`[${timestamp}] [${requestId}] Failed to list generated images:`, error); + + return res.status(500).json({ + success: false, + message: 'Failed to list generated images', + error: error instanceof Error ? error.message : 'Unknown error occurred', + }); + } + }), +); diff --git a/apps/api-service/src/server.ts b/apps/api-service/src/server.ts index c9d85a6..1b218d5 100644 --- a/apps/api-service/src/server.ts +++ b/apps/api-service/src/server.ts @@ -1,3 +1,10 @@ +// Load environment variables FIRST before any other imports +import { config } from 'dotenv'; +import { resolve } from 'path'; + +// Explicitly load from api-service .env file (not root) +config({ path: resolve(__dirname, '../.env') }); + import { createApp, appConfig } from './app'; import fs from 'fs'; diff --git a/apps/api-service/src/services/MinioStorageService.ts b/apps/api-service/src/services/MinioStorageService.ts index 04d0bde..78a0ae2 100644 --- a/apps/api-service/src/services/MinioStorageService.ts +++ b/apps/api-service/src/services/MinioStorageService.ts @@ -449,11 +449,16 @@ export class MinioStorageService implements StorageService { etag: metadata.etag, path: obj.name, }); - } catch (error) {} + } catch (error) { + console.error('[MinIO listFiles] Error processing file:', obj.name, error); + } }); stream.on('end', () => resolve(files)); - stream.on('error', reject); + stream.on('error', (error) => { + console.error('[MinIO listFiles] Stream error:', error); + reject(error); + }); }); } } diff --git a/apps/landing/src/app/demo/gallery/page.tsx b/apps/landing/src/app/demo/gallery/page.tsx new file mode 100644 index 0000000..1dd9eb6 --- /dev/null +++ b/apps/landing/src/app/demo/gallery/page.tsx @@ -0,0 +1,377 @@ +'use client'; + +import { useState, useEffect, useCallback } from 'react'; +import { MinimizedApiKey } from '@/components/demo/MinimizedApiKey'; +import { ImageZoomModal } from '@/components/demo/ImageZoomModal'; +import { ImageGrid } from '@/components/demo/gallery/ImageGrid'; +import { EmptyGalleryState } from '@/components/demo/gallery/EmptyGalleryState'; + +const API_BASE_URL = process.env.NEXT_PUBLIC_API_URL || 'http://localhost:3000'; +const API_KEY_STORAGE_KEY = 'banatie_demo_api_key'; +const IMAGES_PER_PAGE = 30; + +type ImageItem = { + name: string; + url: string; + size: number; + contentType: string; + lastModified: string; +}; + +type ImagesResponse = { + success: boolean; + data?: { + images: ImageItem[]; + total: number; + offset: number; + limit: number; + hasMore: boolean; + }; + error?: string; + message?: string; +}; + +type ApiKeyInfo = { + organizationSlug?: string; + projectSlug?: string; +}; + +type DownloadTimeMap = { + [imageId: string]: number; +}; + +export default function GalleryPage() { + const [apiKey, setApiKey] = useState(''); + const [apiKeyVisible, setApiKeyVisible] = useState(false); + const [apiKeyValidated, setApiKeyValidated] = useState(false); + const [apiKeyInfo, setApiKeyInfo] = useState(null); + const [apiKeyError, setApiKeyError] = useState(''); + const [validatingKey, setValidatingKey] = useState(false); + + const [images, setImages] = useState([]); + const [offset, setOffset] = useState(0); + const [hasMore, setHasMore] = useState(false); + const [loading, setLoading] = useState(false); + const [loadingMore, setLoadingMore] = useState(false); + const [error, setError] = useState(''); + + const [zoomedImageUrl, setZoomedImageUrl] = useState(null); + const [downloadTimes, setDownloadTimes] = useState({}); + + useEffect(() => { + const storedApiKey = localStorage.getItem(API_KEY_STORAGE_KEY); + if (storedApiKey) { + setApiKey(storedApiKey); + validateStoredApiKey(storedApiKey); + } + }, []); + + const validateStoredApiKey = async (keyToValidate: string) => { + setValidatingKey(true); + setApiKeyError(''); + + try { + const response = await fetch(`${API_BASE_URL}/api/info`, { + headers: { + 'X-API-Key': keyToValidate, + }, + }); + + if (response.ok) { + const data = await response.json(); + setApiKeyValidated(true); + if (data.keyInfo) { + setApiKeyInfo({ + organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId, + projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId, + }); + } else { + setApiKeyInfo({ + organizationSlug: 'Unknown', + projectSlug: 'Unknown', + }); + } + await fetchImages(keyToValidate, 0); + } else { + localStorage.removeItem(API_KEY_STORAGE_KEY); + setApiKeyError('Stored API key is invalid or expired'); + setApiKeyValidated(false); + } + } catch (error) { + setApiKeyError('Failed to validate stored API key'); + setApiKeyValidated(false); + } finally { + setValidatingKey(false); + } + }; + + const validateApiKey = async () => { + if (!apiKey.trim()) { + setApiKeyError('Please enter an API key'); + return; + } + + setValidatingKey(true); + setApiKeyError(''); + + try { + const response = await fetch(`${API_BASE_URL}/api/info`, { + headers: { + 'X-API-Key': apiKey, + }, + }); + + if (response.ok) { + const data = await response.json(); + setApiKeyValidated(true); + localStorage.setItem(API_KEY_STORAGE_KEY, apiKey); + + if (data.keyInfo) { + setApiKeyInfo({ + organizationSlug: data.keyInfo.organizationSlug || data.keyInfo.organizationId, + projectSlug: data.keyInfo.projectSlug || data.keyInfo.projectId, + }); + } else { + setApiKeyInfo({ + organizationSlug: 'Unknown', + projectSlug: 'Unknown', + }); + } + + await fetchImages(apiKey, 0); + } else { + const error = await response.json(); + setApiKeyError(error.message || 'Invalid API key'); + setApiKeyValidated(false); + } + } catch (error) { + setApiKeyError('Failed to validate API key. Please check your connection.'); + setApiKeyValidated(false); + } finally { + setValidatingKey(false); + } + }; + + const revokeApiKey = () => { + localStorage.removeItem(API_KEY_STORAGE_KEY); + setApiKey(''); + setApiKeyValidated(false); + setApiKeyInfo(null); + setApiKeyError(''); + setImages([]); + setOffset(0); + setHasMore(false); + setError(''); + }; + + const fetchImages = async (keyToUse: string, fetchOffset: number) => { + if (fetchOffset === 0) { + setLoading(true); + } else { + setLoadingMore(true); + } + setError(''); + + try { + const response = await fetch( + `${API_BASE_URL}/api/images/generated?limit=${IMAGES_PER_PAGE}&offset=${fetchOffset}`, + { + headers: { + 'X-API-Key': keyToUse, + }, + } + ); + + if (!response.ok) { + const errorData: ImagesResponse = await response.json(); + throw new Error(errorData.error || errorData.message || 'Failed to fetch images'); + } + + const result: ImagesResponse = await response.json(); + + if (result.success && result.data) { + const { images: newImages, offset: newOffset, hasMore: newHasMore } = result.data; + + if (fetchOffset === 0) { + setImages(newImages); + } else { + setImages((prev) => [...prev, ...newImages]); + } + setOffset(newOffset); + setHasMore(newHasMore); + } else { + throw new Error(result.error || 'Failed to fetch images'); + } + } catch (error) { + setError(error instanceof Error ? error.message : 'Failed to load images'); + } finally { + setLoading(false); + setLoadingMore(false); + } + }; + + const handleLoadMore = () => { + const newOffset = offset + IMAGES_PER_PAGE; + fetchImages(apiKey, newOffset); + }; + + const handleDownloadMeasured = useCallback((imageId: string, downloadMs: number) => { + setDownloadTimes((prev) => ({ + ...prev, + [imageId]: downloadMs, + })); + }, []); + + return ( +
+ {apiKeyValidated && apiKeyInfo && ( + + )} + +
+

+ Image Gallery +

+

+ Browse your AI-generated images +

+
+ + {!apiKeyValidated && ( +
+

API Key

+
+
+ setApiKey(e.target.value)} + onKeyDown={(e) => { + if (e.key === 'Enter') { + e.preventDefault(); + validateApiKey(); + } + }} + placeholder="Enter your API key" + className="w-full px-4 py-3 bg-slate-800 border border-slate-700 rounded-lg text-white placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-amber-500 focus:border-transparent pr-12" + aria-label="API key input" + /> + +
+ +
+ + {apiKeyError && ( +
+

{apiKeyError}

+

+ {apiKeyError.includes('Invalid') + ? 'Please check your API key and try again. You can create a new key in the admin dashboard.' + : 'Please check your internet connection and try again.'} +

+
+ )} +
+ )} + + {apiKeyValidated && ( +
+ {loading ? ( +
+ +

Loading images...

+
+ ) : error ? ( +
+

{error}

+

+ {error.includes('fetch') || error.includes('load') + ? 'Unable to load images. Please check your connection and try refreshing the page.' + : 'An error occurred while fetching your images. Please try again later.'} +

+
+ ) : images.length === 0 ? ( + + ) : ( + <> + + + {hasMore && ( +
+ +
+ )} + + )} +
+ )} + + setZoomedImageUrl(null)} /> +
+ ); +} diff --git a/apps/landing/src/components/demo/ImageZoomModal.tsx b/apps/landing/src/components/demo/ImageZoomModal.tsx index 791bd7f..1f0d221 100644 --- a/apps/landing/src/components/demo/ImageZoomModal.tsx +++ b/apps/landing/src/components/demo/ImageZoomModal.tsx @@ -1,6 +1,6 @@ 'use client'; -import { useEffect } from 'react'; +import { useEffect, useRef } from 'react'; interface ImageZoomModalProps { imageUrl: string | null; @@ -8,6 +8,9 @@ interface ImageZoomModalProps { } export const ImageZoomModal = ({ imageUrl, onClose }: ImageZoomModalProps) => { + const closeButtonRef = useRef(null); + const modalRef = useRef(null); + useEffect(() => { const handleEscape = (e: KeyboardEvent) => { if (e.key === 'Escape') { @@ -17,6 +20,19 @@ export const ImageZoomModal = ({ imageUrl, onClose }: ImageZoomModalProps) => { if (imageUrl) { document.addEventListener('keydown', handleEscape); + + // Focus trap + const previousActiveElement = document.activeElement as HTMLElement; + closeButtonRef.current?.focus(); + + // Disable body scroll + document.body.style.overflow = 'hidden'; + + return () => { + document.removeEventListener('keydown', handleEscape); + document.body.style.overflow = ''; + previousActiveElement?.focus(); + }; } return () => { @@ -26,25 +42,39 @@ export const ImageZoomModal = ({ imageUrl, onClose }: ImageZoomModalProps) => { if (!imageUrl) return null; + const handleBackdropClick = (e: React.MouseEvent) => { + if (e.target === modalRef.current) { + onClose(); + } + }; + return (
- +
+ Full size image viewer + Press ESC to close + +
Zoomed e.stopPropagation()} />
diff --git a/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx b/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx index cb8ba83..0136ded 100644 --- a/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx +++ b/apps/landing/src/components/demo/gallery/EmptyGalleryState.tsx @@ -31,7 +31,7 @@ export const EmptyGalleryState = () => { Generate Images diff --git a/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx b/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx index b3bfd9c..ae8b3ac 100644 --- a/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx +++ b/apps/landing/src/components/demo/gallery/GalleryImageCard.tsx @@ -80,11 +80,11 @@ export const GalleryImageCard = ({ className="p-4 bg-slate-900/80 backdrop-blur-sm border border-slate-700 rounded-xl hover:border-slate-600 transition-all h-full" >
onZoom(imageUrl)} role="button" tabIndex={0} - aria-label="View full size image" + aria-label={`View full size image: ${filename}`} onKeyDown={handleKeyDown} > {isVisible ? ( @@ -95,47 +95,52 @@ export const GalleryImageCard = ({ className="w-full h-full object-cover transition-transform group-hover:scale-105" /> -
+
- + {formatTimestamp(lastModified)}
{filename}
-
- - - +
+
+ +
) : ( -
-
+
+
- +
+ {width} × {height} -