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:
@@ -0,0 +1,153 @@
|
||||
package httpapi
|
||||
|
||||
import (
|
||||
"context"
|
||||
"time"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/store"
|
||||
)
|
||||
|
||||
// JSON shapes are camelCase like the backend's responses.
|
||||
|
||||
type eventJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
TitleEs string `json:"titleEs,omitempty"`
|
||||
StartDatetime string `json:"startDatetime"`
|
||||
Status string `json:"status"`
|
||||
}
|
||||
|
||||
type galleryJSON struct {
|
||||
ID string `json:"id"`
|
||||
Slug string `json:"slug"`
|
||||
Title string `json:"title"`
|
||||
TitleEs string `json:"titleEs,omitempty"`
|
||||
Description string `json:"description,omitempty"`
|
||||
DescriptionEs string `json:"descriptionEs,omitempty"`
|
||||
EventID string `json:"eventId,omitempty"`
|
||||
Visibility string `json:"visibility"`
|
||||
CoverPhotoID string `json:"coverPhotoId,omitempty"`
|
||||
PhotoCount int `json:"photoCount"`
|
||||
CoverURL string `json:"coverUrl,omitempty"`
|
||||
CreatedAt string `json:"createdAt"`
|
||||
UpdatedAt string `json:"updatedAt"`
|
||||
Event *eventJSON `json:"event,omitempty"`
|
||||
// Admin-only fields:
|
||||
ShareToken string `json:"shareToken,omitempty"`
|
||||
ShareURL string `json:"shareUrl,omitempty"`
|
||||
}
|
||||
|
||||
type photoURLs struct {
|
||||
Thumb string `json:"thumb,omitempty"`
|
||||
Preview string `json:"preview,omitempty"`
|
||||
Original string `json:"original"`
|
||||
}
|
||||
|
||||
type photoJSON struct {
|
||||
ID string `json:"id"`
|
||||
GalleryID string `json:"galleryId"`
|
||||
Position int `json:"position"`
|
||||
OriginalFilename string `json:"originalFilename,omitempty"`
|
||||
ContentType string `json:"contentType"`
|
||||
SizeBytes int64 `json:"sizeBytes"`
|
||||
Width int `json:"width,omitempty"`
|
||||
Height int `json:"height,omitempty"`
|
||||
TakenAt string `json:"takenAt,omitempty"`
|
||||
Status string `json:"status"`
|
||||
LastError string `json:"lastError,omitempty"` // admin only
|
||||
CreatedAt string `json:"createdAt"`
|
||||
URLs photoURLs `json:"urls"`
|
||||
}
|
||||
|
||||
func isoTime(t time.Time) string {
|
||||
if t.IsZero() {
|
||||
return ""
|
||||
}
|
||||
return t.UTC().Format("2006-01-02T15:04:05.000Z")
|
||||
}
|
||||
|
||||
// fileURL builds the access-checked file endpoint URL; token is appended
|
||||
// for link-mode viewers so the client can use URLs verbatim.
|
||||
func fileURL(photoID, variant, token string) string {
|
||||
u := "/api/photos/files/" + photoID + "/" + variant
|
||||
if token != "" {
|
||||
u += "?token=" + token
|
||||
}
|
||||
return u
|
||||
}
|
||||
|
||||
func (s *Server) photoToJSON(p store.Photo, token string, admin bool) photoJSON {
|
||||
out := photoJSON{
|
||||
ID: p.ID,
|
||||
GalleryID: p.GalleryID,
|
||||
Position: p.Position,
|
||||
OriginalFilename: p.OriginalFilename,
|
||||
ContentType: p.ContentType,
|
||||
SizeBytes: p.SizeBytes,
|
||||
Width: p.Width,
|
||||
Height: p.Height,
|
||||
TakenAt: isoTime(p.TakenAt),
|
||||
Status: p.Status,
|
||||
CreatedAt: isoTime(p.CreatedAt),
|
||||
URLs: photoURLs{Original: fileURL(p.ID, "original", token)},
|
||||
}
|
||||
if p.ThumbKey != "" {
|
||||
out.URLs.Thumb = fileURL(p.ID, "thumb", token)
|
||||
}
|
||||
if p.PreviewKey != "" {
|
||||
out.URLs.Preview = fileURL(p.ID, "preview", token)
|
||||
}
|
||||
if admin {
|
||||
out.LastError = p.LastError
|
||||
}
|
||||
return out
|
||||
}
|
||||
|
||||
func (s *Server) galleryToJSON(ctx context.Context, g store.Gallery, token string, admin bool) galleryJSON {
|
||||
out := galleryJSON{
|
||||
ID: g.ID,
|
||||
Slug: g.Slug,
|
||||
Title: g.Title,
|
||||
TitleEs: g.TitleEs,
|
||||
Description: g.Description,
|
||||
DescriptionEs: g.DescriptionEs,
|
||||
EventID: g.EventID,
|
||||
Visibility: g.Visibility,
|
||||
CoverPhotoID: g.CoverPhotoID,
|
||||
PhotoCount: g.PhotoCount,
|
||||
CreatedAt: isoTime(g.CreatedAt),
|
||||
UpdatedAt: isoTime(g.UpdatedAt),
|
||||
}
|
||||
if coverID, _ := s.db.GalleryCoverKey(ctx, g); coverID != "" {
|
||||
out.CoverURL = fileURL(coverID, "thumb", token)
|
||||
}
|
||||
if g.EventID != "" {
|
||||
if ev, err := s.db.GetEventSummary(ctx, g.EventID); err == nil {
|
||||
out.Event = &eventJSON{
|
||||
ID: ev.ID,
|
||||
Slug: ev.Slug,
|
||||
Title: ev.Title,
|
||||
TitleEs: ev.TitleEs,
|
||||
StartDatetime: isoTime(ev.StartDatetime),
|
||||
Status: ev.Status,
|
||||
}
|
||||
}
|
||||
}
|
||||
if admin {
|
||||
out.ShareToken = g.ShareToken
|
||||
// Event-linked galleries live under the event's URL; standalone
|
||||
// galleries keep their own /photos/<slug> URL.
|
||||
page := "/photos/" + g.Slug
|
||||
if out.Event != nil {
|
||||
page = "/events/" + out.Event.Slug + "/gallery"
|
||||
}
|
||||
out.ShareURL = s.cfg.FrontendURL + page
|
||||
// The token only grants anything in link/ticket mode; public and
|
||||
// private galleries get a clean URL.
|
||||
if g.Visibility == store.VisibilityLink || g.Visibility == store.VisibilityTicket {
|
||||
out.ShareURL += "?token=" + g.ShareToken
|
||||
}
|
||||
}
|
||||
return out
|
||||
}
|
||||
Reference in New Issue
Block a user