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,122 @@
|
||||
// Package store owns all database access. Photo tables use the photos_
|
||||
// prefix and are migrated by this service alone; the handful of reads
|
||||
// against Drizzle-owned tables (users, events, tickets, payments) are
|
||||
// confined to access.go.
|
||||
package store
|
||||
|
||||
import (
|
||||
"database/sql"
|
||||
"fmt"
|
||||
"strings"
|
||||
"time"
|
||||
|
||||
_ "github.com/jackc/pgx/v5/stdlib"
|
||||
_ "modernc.org/sqlite"
|
||||
|
||||
"git.azzamo.net/Michilis/Spanglish/photo-api/internal/config"
|
||||
)
|
||||
|
||||
const (
|
||||
Postgres = "postgres"
|
||||
SQLite = "sqlite"
|
||||
|
||||
// timeLayout is a fixed-width UTC format so SQLite text timestamps
|
||||
// sort correctly; the backend stores ISO strings in SQLite the same way.
|
||||
timeLayout = "2006-01-02T15:04:05.000Z"
|
||||
)
|
||||
|
||||
type DB struct {
|
||||
*sql.DB
|
||||
Type string
|
||||
}
|
||||
|
||||
func Open(cfg config.Config) (*DB, error) {
|
||||
switch cfg.DBType {
|
||||
case Postgres:
|
||||
sqlDB, err := sql.Open("pgx", cfg.DatabaseURL)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open postgres: %w", err)
|
||||
}
|
||||
sqlDB.SetMaxOpenConns(10)
|
||||
return &DB{DB: sqlDB, Type: Postgres}, nil
|
||||
case SQLite:
|
||||
dsn := "file:" + strings.TrimPrefix(cfg.DatabaseURL, "file:") +
|
||||
"?_pragma=busy_timeout(5000)&_pragma=journal_mode(WAL)&_pragma=foreign_keys(1)"
|
||||
sqlDB, err := sql.Open("sqlite", dsn)
|
||||
if err != nil {
|
||||
return nil, fmt.Errorf("open sqlite: %w", err)
|
||||
}
|
||||
// One writer at a time keeps the shared file friendly to the backend.
|
||||
sqlDB.SetMaxOpenConns(1)
|
||||
return &DB{DB: sqlDB, Type: SQLite}, nil
|
||||
default:
|
||||
return nil, fmt.Errorf("unsupported DB_TYPE %q", cfg.DBType)
|
||||
}
|
||||
}
|
||||
|
||||
// Rebind converts ?-style placeholders to $n for Postgres. Queries in this
|
||||
// package never contain literal question marks in strings.
|
||||
func (db *DB) Rebind(query string) string {
|
||||
if db.Type != Postgres {
|
||||
return query
|
||||
}
|
||||
var b strings.Builder
|
||||
n := 0
|
||||
for _, r := range query {
|
||||
if r == '?' {
|
||||
n++
|
||||
fmt.Fprintf(&b, "$%d", n)
|
||||
} else {
|
||||
b.WriteRune(r)
|
||||
}
|
||||
}
|
||||
return b.String()
|
||||
}
|
||||
|
||||
// TimeArg converts a time for binding: time.Time for Postgres (timestamptz
|
||||
// columns), fixed-layout UTC text for SQLite (text columns).
|
||||
func (db *DB) TimeArg(t time.Time) any {
|
||||
if db.Type == Postgres {
|
||||
return t.UTC()
|
||||
}
|
||||
return t.UTC().Format(timeLayout)
|
||||
}
|
||||
|
||||
// asString normalizes scanned values across drivers.
|
||||
func asString(v any) string {
|
||||
switch x := v.(type) {
|
||||
case nil:
|
||||
return ""
|
||||
case string:
|
||||
return x
|
||||
case []byte:
|
||||
return string(x)
|
||||
case time.Time:
|
||||
return x.UTC().Format(timeLayout)
|
||||
default:
|
||||
return fmt.Sprintf("%v", x)
|
||||
}
|
||||
}
|
||||
|
||||
// asTime parses a scanned timestamp from either driver; zero time on failure.
|
||||
func asTime(v any) time.Time {
|
||||
switch x := v.(type) {
|
||||
case time.Time:
|
||||
return x.UTC()
|
||||
case string:
|
||||
return parseTime(x)
|
||||
case []byte:
|
||||
return parseTime(string(x))
|
||||
default:
|
||||
return time.Time{}
|
||||
}
|
||||
}
|
||||
|
||||
func parseTime(s string) time.Time {
|
||||
for _, layout := range []string{timeLayout, time.RFC3339Nano, time.RFC3339, "2006-01-02 15:04:05.999999999-07:00", "2006-01-02 15:04:05"} {
|
||||
if t, err := time.Parse(layout, s); err == nil {
|
||||
return t.UTC()
|
||||
}
|
||||
}
|
||||
return time.Time{}
|
||||
}
|
||||
Reference in New Issue
Block a user