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,289 @@
|
||||
package store
|
||||
|
||||
import (
|
||||
"context"
|
||||
"database/sql"
|
||||
"errors"
|
||||
"time"
|
||||
)
|
||||
|
||||
var ErrNotFound = errors.New("not found")
|
||||
|
||||
const (
|
||||
VisibilityPublic = "public"
|
||||
VisibilityPrivate = "private"
|
||||
VisibilityLink = "link"
|
||||
VisibilityTicket = "ticket"
|
||||
)
|
||||
|
||||
type Gallery struct {
|
||||
ID string
|
||||
Slug string
|
||||
Title string
|
||||
TitleEs string
|
||||
Description string
|
||||
DescriptionEs string
|
||||
EventID string
|
||||
Visibility string
|
||||
ShareToken string
|
||||
CoverPhotoID string
|
||||
CreatedBy string
|
||||
CreatedAt time.Time
|
||||
UpdatedAt time.Time
|
||||
PhotoCount int
|
||||
}
|
||||
|
||||
const galleryColumns = `g.id, g.slug, g.title, g.title_es, g.description, g.description_es,
|
||||
g.event_id, g.visibility, g.share_token, g.cover_photo_id, g.created_by, g.created_at, g.updated_at`
|
||||
|
||||
type scanner interface{ Scan(...any) error }
|
||||
|
||||
func scanGallery(s scanner, withCount bool) (Gallery, error) {
|
||||
var v [13]any
|
||||
dest := make([]any, 0, 14)
|
||||
for i := range v {
|
||||
dest = append(dest, &v[i])
|
||||
}
|
||||
var count any
|
||||
if withCount {
|
||||
dest = append(dest, &count)
|
||||
}
|
||||
if err := s.Scan(dest...); err != nil {
|
||||
return Gallery{}, err
|
||||
}
|
||||
g := Gallery{
|
||||
ID: asString(v[0]),
|
||||
Slug: asString(v[1]),
|
||||
Title: asString(v[2]),
|
||||
TitleEs: asString(v[3]),
|
||||
Description: asString(v[4]),
|
||||
DescriptionEs: asString(v[5]),
|
||||
EventID: asString(v[6]),
|
||||
Visibility: asString(v[7]),
|
||||
ShareToken: asString(v[8]),
|
||||
CoverPhotoID: asString(v[9]),
|
||||
CreatedBy: asString(v[10]),
|
||||
CreatedAt: asTime(v[11]),
|
||||
UpdatedAt: asTime(v[12]),
|
||||
}
|
||||
if withCount {
|
||||
g.PhotoCount = int(asInt(count))
|
||||
}
|
||||
return g, nil
|
||||
}
|
||||
|
||||
func (db *DB) CreateGallery(ctx context.Context, g Gallery) error {
|
||||
_, err := db.ExecContext(ctx, db.Rebind(`
|
||||
INSERT INTO photos_galleries
|
||||
(id, slug, title, title_es, description, description_es, event_id, visibility, share_token, created_by, created_at, updated_at)
|
||||
VALUES (?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?, ?)`),
|
||||
g.ID, g.Slug, g.Title, nullable(g.TitleEs), nullable(g.Description), nullable(g.DescriptionEs),
|
||||
nullable(g.EventID), g.Visibility, g.ShareToken, nullable(g.CreatedBy),
|
||||
db.TimeArg(g.CreatedAt), db.TimeArg(g.UpdatedAt))
|
||||
return err
|
||||
}
|
||||
|
||||
func (db *DB) GetGallery(ctx context.Context, id string) (Gallery, error) {
|
||||
return db.getGalleryWhere(ctx, "g.id = ?", id)
|
||||
}
|
||||
|
||||
func (db *DB) GetGalleryBySlug(ctx context.Context, slug string) (Gallery, error) {
|
||||
return db.getGalleryWhere(ctx, "g.slug = ?", slug)
|
||||
}
|
||||
|
||||
// GetGalleryByEventID returns the newest gallery linked to an event, for
|
||||
// the /events/{slug}/gallery public route.
|
||||
func (db *DB) GetGalleryByEventID(ctx context.Context, eventID string) (Gallery, error) {
|
||||
galleries, err := db.queryGalleries(ctx, `
|
||||
SELECT `+galleryColumns+`,
|
||||
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
|
||||
FROM photos_galleries g
|
||||
WHERE g.event_id = ?
|
||||
ORDER BY g.created_at DESC LIMIT 1`, eventID)
|
||||
if err != nil {
|
||||
return Gallery{}, err
|
||||
}
|
||||
if len(galleries) == 0 {
|
||||
return Gallery{}, ErrNotFound
|
||||
}
|
||||
return galleries[0], nil
|
||||
}
|
||||
|
||||
func (db *DB) getGalleryWhere(ctx context.Context, where string, arg any) (Gallery, error) {
|
||||
row := db.QueryRowContext(ctx, db.Rebind(`
|
||||
SELECT `+galleryColumns+`,
|
||||
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
|
||||
FROM photos_galleries g WHERE `+where), arg)
|
||||
g, err := scanGallery(row, true)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return Gallery{}, ErrNotFound
|
||||
}
|
||||
return g, err
|
||||
}
|
||||
|
||||
// ListGalleries returns every gallery (admin view). eventID filters when set.
|
||||
func (db *DB) ListGalleries(ctx context.Context, eventID string, limit, offset int) ([]Gallery, error) {
|
||||
q := `
|
||||
SELECT ` + galleryColumns + `,
|
||||
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id) AS photo_count
|
||||
FROM photos_galleries g`
|
||||
args := []any{}
|
||||
if eventID != "" {
|
||||
q += " WHERE g.event_id = ?"
|
||||
args = append(args, eventID)
|
||||
}
|
||||
q += " ORDER BY g.created_at DESC LIMIT ? OFFSET ?"
|
||||
args = append(args, limit, offset)
|
||||
return db.queryGalleries(ctx, q, args...)
|
||||
}
|
||||
|
||||
// ListPublicGalleries returns visibility='public' galleries with at least
|
||||
// their ready photo counts, newest first.
|
||||
func (db *DB) ListPublicGalleries(ctx context.Context) ([]Gallery, error) {
|
||||
return db.queryGalleries(ctx, `
|
||||
SELECT `+galleryColumns+`,
|
||||
(SELECT COUNT(*) FROM photos_photos p WHERE p.gallery_id = g.id AND p.status = 'ready') AS photo_count
|
||||
FROM photos_galleries g
|
||||
WHERE g.visibility = 'public'
|
||||
ORDER BY g.created_at DESC`)
|
||||
}
|
||||
|
||||
func (db *DB) queryGalleries(ctx context.Context, q string, args ...any) ([]Gallery, error) {
|
||||
rows, err := db.QueryContext(ctx, db.Rebind(q), args...)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
defer rows.Close()
|
||||
galleries := []Gallery{}
|
||||
for rows.Next() {
|
||||
g, err := scanGallery(rows, true)
|
||||
if err != nil {
|
||||
return nil, err
|
||||
}
|
||||
galleries = append(galleries, g)
|
||||
}
|
||||
return galleries, rows.Err()
|
||||
}
|
||||
|
||||
type GalleryUpdate struct {
|
||||
Title *string
|
||||
TitleEs *string
|
||||
Description *string
|
||||
DescriptionEs *string
|
||||
EventID *string // empty string clears the link
|
||||
Visibility *string
|
||||
CoverPhotoID *string
|
||||
}
|
||||
|
||||
func (db *DB) UpdateGallery(ctx context.Context, id string, u GalleryUpdate) error {
|
||||
set := ""
|
||||
args := []any{}
|
||||
add := func(col string, val any) {
|
||||
if set != "" {
|
||||
set += ", "
|
||||
}
|
||||
set += col + " = ?"
|
||||
args = append(args, val)
|
||||
}
|
||||
if u.Title != nil {
|
||||
add("title", *u.Title)
|
||||
}
|
||||
if u.TitleEs != nil {
|
||||
add("title_es", nullable(*u.TitleEs))
|
||||
}
|
||||
if u.Description != nil {
|
||||
add("description", nullable(*u.Description))
|
||||
}
|
||||
if u.DescriptionEs != nil {
|
||||
add("description_es", nullable(*u.DescriptionEs))
|
||||
}
|
||||
if u.EventID != nil {
|
||||
add("event_id", nullable(*u.EventID))
|
||||
}
|
||||
if u.Visibility != nil {
|
||||
add("visibility", *u.Visibility)
|
||||
}
|
||||
if u.CoverPhotoID != nil {
|
||||
add("cover_photo_id", nullable(*u.CoverPhotoID))
|
||||
}
|
||||
if set == "" {
|
||||
return nil
|
||||
}
|
||||
add("updated_at", db.TimeArg(time.Now()))
|
||||
args = append(args, id)
|
||||
res, err := db.ExecContext(ctx, db.Rebind("UPDATE photos_galleries SET "+set+" WHERE id = ?"), args...)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errIfNoRows(res)
|
||||
}
|
||||
|
||||
func (db *DB) RotateShareToken(ctx context.Context, id, token string) error {
|
||||
res, err := db.ExecContext(ctx,
|
||||
db.Rebind("UPDATE photos_galleries SET share_token = ?, updated_at = ? WHERE id = ?"),
|
||||
token, db.TimeArg(time.Now()), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errIfNoRows(res)
|
||||
}
|
||||
|
||||
func (db *DB) DeleteGallery(ctx context.Context, id string) error {
|
||||
res, err := db.ExecContext(ctx, db.Rebind("DELETE FROM photos_galleries WHERE id = ?"), id)
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
return errIfNoRows(res)
|
||||
}
|
||||
|
||||
func (db *DB) SlugExists(ctx context.Context, slug string) (bool, error) {
|
||||
var v any
|
||||
err := db.QueryRowContext(ctx, db.Rebind("SELECT 1 FROM photos_galleries WHERE slug = ?"), slug).Scan(&v)
|
||||
if errors.Is(err, sql.ErrNoRows) {
|
||||
return false, nil
|
||||
}
|
||||
return err == nil, err
|
||||
}
|
||||
|
||||
func errIfNoRows(res sql.Result) error {
|
||||
n, err := res.RowsAffected()
|
||||
if err != nil {
|
||||
return err
|
||||
}
|
||||
if n == 0 {
|
||||
return ErrNotFound
|
||||
}
|
||||
return nil
|
||||
}
|
||||
|
||||
// nullable maps "" to NULL so optional text columns stay NULL not ”.
|
||||
func nullable(s string) any {
|
||||
if s == "" {
|
||||
return nil
|
||||
}
|
||||
return s
|
||||
}
|
||||
|
||||
func asInt(v any) int64 {
|
||||
switch x := v.(type) {
|
||||
case int64:
|
||||
return x
|
||||
case int:
|
||||
return int64(x)
|
||||
case int32:
|
||||
return int64(x)
|
||||
case float64:
|
||||
return int64(x)
|
||||
case []byte:
|
||||
var n int64
|
||||
for _, c := range x {
|
||||
if c < '0' || c > '9' {
|
||||
break
|
||||
}
|
||||
n = n*10 + int64(c-'0')
|
||||
}
|
||||
return n
|
||||
default:
|
||||
return 0
|
||||
}
|
||||
}
|
||||
Reference in New Issue
Block a user