Add photo galleries API and frontend for public and admin viewing.
Introduces the Go photo-api service, nginx/systemd deploy wiring, and Next.js gallery/lightbox pages so event photos can be managed and browsed. Co-authored-by: Cursor <cursoragent@cursor.com>
This commit is contained in:
@@ -17,3 +17,11 @@ export { siteSettingsApi } from './siteSettings';
|
||||
export { legalSettingsApi } from './legalSettings';
|
||||
export { legalPagesApi } from './legalPages';
|
||||
export { faqApi } from './faq';
|
||||
export { photosApi } from './photos';
|
||||
export type {
|
||||
Photo,
|
||||
PhotoGallery,
|
||||
PhotoGalleryEvent,
|
||||
GalleryVisibility,
|
||||
CreateGalleryInput,
|
||||
} from './photos';
|
||||
|
||||
@@ -0,0 +1,176 @@
|
||||
import { fetchApi, API_BASE, getToken } from './client';
|
||||
|
||||
// Client for the standalone photo-api Go service (photo-api/), reachable
|
||||
// under /api/photos via the Next rewrite (dev) or nginx (prod).
|
||||
|
||||
export type GalleryVisibility = 'public' | 'private' | 'link' | 'ticket';
|
||||
|
||||
export interface PhotoGalleryEvent {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
startDatetime: string;
|
||||
status: string;
|
||||
}
|
||||
|
||||
export interface PhotoGallery {
|
||||
id: string;
|
||||
slug: string;
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description?: string;
|
||||
descriptionEs?: string;
|
||||
eventId?: string;
|
||||
visibility: GalleryVisibility;
|
||||
coverPhotoId?: string;
|
||||
photoCount: number;
|
||||
coverUrl?: string;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
event?: PhotoGalleryEvent;
|
||||
// Present on admin responses only:
|
||||
shareToken?: string;
|
||||
shareUrl?: string;
|
||||
}
|
||||
|
||||
export interface Photo {
|
||||
id: string;
|
||||
galleryId: string;
|
||||
position: number;
|
||||
originalFilename?: string;
|
||||
contentType: string;
|
||||
sizeBytes: number;
|
||||
width?: number;
|
||||
height?: number;
|
||||
takenAt?: string;
|
||||
status: 'queued' | 'processing' | 'ready' | 'failed';
|
||||
lastError?: string;
|
||||
createdAt: string;
|
||||
urls: {
|
||||
thumb?: string;
|
||||
preview?: string;
|
||||
original: string;
|
||||
};
|
||||
}
|
||||
|
||||
export interface CreateGalleryInput {
|
||||
title: string;
|
||||
titleEs?: string;
|
||||
description?: string;
|
||||
descriptionEs?: string;
|
||||
eventId?: string;
|
||||
visibility?: GalleryVisibility;
|
||||
}
|
||||
|
||||
export const photosApi = {
|
||||
// Admin (requires admin/organizer token)
|
||||
createGallery: (data: CreateGalleryInput) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>('/api/photos/galleries', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
getGalleries: (eventId?: string) => {
|
||||
const query = eventId ? `?eventId=${encodeURIComponent(eventId)}` : '';
|
||||
return fetchApi<{ galleries: PhotoGallery[] }>(`/api/photos/galleries${query}`);
|
||||
},
|
||||
|
||||
getGallery: (id: string) =>
|
||||
fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(`/api/photos/galleries/${id}`),
|
||||
|
||||
updateGallery: (id: string, data: Partial<CreateGalleryInput> & { coverPhotoId?: string }) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify(data),
|
||||
}),
|
||||
|
||||
deleteGallery: (id: string) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/galleries/${id}`, { method: 'DELETE' }),
|
||||
|
||||
rotateShareToken: (id: string) =>
|
||||
fetchApi<{ gallery: PhotoGallery }>(`/api/photos/galleries/${id}/share-token`, {
|
||||
method: 'POST',
|
||||
}),
|
||||
|
||||
uploadPhoto: async (galleryId: string, file: File) => {
|
||||
const token = getToken();
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
|
||||
const res = await fetch(`${API_BASE}/api/photos/galleries/${galleryId}/photos`, {
|
||||
method: 'POST',
|
||||
headers: token ? { Authorization: `Bearer ${token}` } : {},
|
||||
body: formData,
|
||||
});
|
||||
if (!res.ok) {
|
||||
const errorData = await res.json().catch(() => ({ error: 'Upload failed' }));
|
||||
throw new Error(errorData.error || 'Upload failed');
|
||||
}
|
||||
return res.json() as Promise<{ photos: Photo[] }>;
|
||||
},
|
||||
|
||||
/**
|
||||
* Upload with byte-level progress (0..1). fetch() cannot report upload
|
||||
* progress, so this uses XMLHttpRequest; used by the admin uploader panel.
|
||||
*/
|
||||
uploadPhotoWithProgress: (
|
||||
galleryId: string,
|
||||
file: File,
|
||||
onProgress: (fraction: number) => void
|
||||
) =>
|
||||
new Promise<{ photos: Photo[] }>((resolve, reject) => {
|
||||
const xhr = new XMLHttpRequest();
|
||||
xhr.open('POST', `${API_BASE}/api/photos/galleries/${galleryId}/photos`);
|
||||
const token = getToken();
|
||||
if (token) xhr.setRequestHeader('Authorization', `Bearer ${token}`);
|
||||
xhr.upload.onprogress = (e) => {
|
||||
if (e.lengthComputable && e.total > 0) onProgress(e.loaded / e.total);
|
||||
};
|
||||
xhr.onload = () => {
|
||||
try {
|
||||
const body = JSON.parse(xhr.responseText || '{}');
|
||||
if (xhr.status >= 200 && xhr.status < 300) resolve(body);
|
||||
else reject(new Error(body.error || `Upload failed (${xhr.status})`));
|
||||
} catch {
|
||||
reject(new Error(`Upload failed (${xhr.status})`));
|
||||
}
|
||||
};
|
||||
xhr.onerror = () => reject(new Error('Network error during upload'));
|
||||
xhr.onabort = () => reject(new Error('Upload cancelled'));
|
||||
const formData = new FormData();
|
||||
formData.append('files', file);
|
||||
xhr.send(formData);
|
||||
}),
|
||||
|
||||
reorderPhotos: (galleryId: string, photoIds: string[]) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/galleries/${galleryId}/order`, {
|
||||
method: 'PATCH',
|
||||
body: JSON.stringify({ photoIds }),
|
||||
}),
|
||||
|
||||
deletePhoto: (photoId: string) =>
|
||||
fetchApi<{ message: string }>(`/api/photos/photos/${photoId}`, { method: 'DELETE' }),
|
||||
|
||||
retryPhoto: (photoId: string) =>
|
||||
fetchApi<{ photo: Photo }>(`/api/photos/photos/${photoId}/retry`, { method: 'POST' }),
|
||||
|
||||
// Viewer (anonymous or member token; share token via query param)
|
||||
getPublicGalleries: () =>
|
||||
fetchApi<{ galleries: PhotoGallery[] }>('/api/photos/public/galleries'),
|
||||
|
||||
getPublicGallery: (slug: string, token?: string) => {
|
||||
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||
`/api/photos/public/galleries/${encodeURIComponent(slug)}${query}`
|
||||
);
|
||||
},
|
||||
|
||||
/** Newest gallery linked to an event; backs /events/[slug]/gallery. */
|
||||
getEventGallery: (eventSlug: string, token?: string) => {
|
||||
const query = token ? `?token=${encodeURIComponent(token)}` : '';
|
||||
return fetchApi<{ gallery: PhotoGallery; photos: Photo[] }>(
|
||||
`/api/photos/public/events/${encodeURIComponent(eventSlug)}/gallery${query}`
|
||||
);
|
||||
},
|
||||
};
|
||||
Reference in New Issue
Block a user