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>
177 lines
5.6 KiB
TypeScript
177 lines
5.6 KiB
TypeScript
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}`
|
|
);
|
|
},
|
|
};
|