This commit is contained in:
2026-08-22 02:59:16 +02:00
commit 6a5bb1d699
100 changed files with 17409 additions and 0 deletions
+22
View File
@@ -0,0 +1,22 @@
DB_DRIVER=postgres
POSTGRES_DB=surprise
POSTGRES_USER=surprise
POSTGRES_PASSWORD=surprise_dev_password
POSTGRES_PORT=5432
DATABASE_URL=postgres://surprise:surprise_dev_password@localhost:5432/surprise?sslmode=disable
SQLITE_PATH=./data/surprise.db
PORT=8080
CORS_ORIGIN=http://localhost:5173,http://127.0.0.1:5173
SESSION_SECRET=local-development-session-secret-change-me
COOKIE_SECURE=false
STORAGE_ENDPOINT=localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=gallery-media
STORAGE_USE_SSL=false
MINIO_API_PORT=9000
MINIO_CONSOLE_PORT=9001
VITE_API_BASE_URL=http://localhost:8080
+19
View File
@@ -0,0 +1,19 @@
.env
.env.*
!.env.example
node_modules/
frontend/dist/
frontend/.vite/
coverage/
*.test
*.exe
*.db
*.db-shm
*.db-wal
data/
backend/data/
.DS_Store
Thumbs.db
+34
View File
@@ -0,0 +1,34 @@
.PHONY: dev frontend-dev migrate seed test build lint format compose-up compose-down
dev:
go -C backend run ./cmd/server
frontend-dev:
npm --prefix frontend run dev
migrate:
go -C backend run ./cmd/migrate -dir ../migrations
seed:
go -C backend run ./cmd/seed
test:
go -C backend test ./...
build:
go -C backend build ./...
npm --prefix frontend run build
lint:
go -C backend vet ./...
npm --prefix frontend run lint
format:
go -C backend fmt ./...
npm --prefix frontend run format
compose-up:
docker compose up -d --wait
compose-down:
docker compose down
+322
View File
@@ -0,0 +1,322 @@
# Northline Delivery Studio
Northline is an original photographer client-delivery platform inspired by the category of premium gallery products such as DLVRD. It is built around one loop:
```text
Upload -> Curate -> Customize -> Publish -> Send a private link
```
Photographers get a focused studio dashboard. Clients get a private editorial gallery without creating an account.
## Stack
- Frontend: React, TypeScript, Vite, React Router, Tailwind CSS, Framer Motion
- Backend: Go 1.24+, `net/http`, REST/JSON, `database/sql`
- Database: PostgreSQL by default; SQLite remains available for lightweight local testing
- Storage: MinIO locally through an S3-compatible storage interface
- Media: direct browser uploads with presigned URLs, asynchronous preview processing, signed download URLs
## Run Locally
Prerequisites:
- Docker Desktop with the Compose plugin
- Go 1.24 or newer
- Node.js 20 or newer
- GNU Make, or use the direct commands below
From the repository root:
```bash
cp .env.example .env
npm --prefix frontend install
docker compose up -d --wait
make migrate
make seed
```
Start the API:
```bash
make dev
```
Start the frontend in a second terminal:
```bash
make frontend-dev
```
Open:
- Dashboard: [http://localhost:5173/login](http://localhost:5173/login)
- Dev diagnostics: [http://localhost:5173/dashboard/dev](http://localhost:5173/dashboard/dev)
- Demo client gallery: [http://localhost:5173/g/emma-james-wedding](http://localhost:5173/g/emma-james-wedding)
- MinIO console: [http://localhost:9001](http://localhost:9001)
### PowerShell
```powershell
Copy-Item .env.example .env
npm --prefix frontend install
docker compose up -d --wait
go -C backend run ./cmd/migrate -dir ../migrations
go -C backend run ./cmd/seed
```
In separate terminals:
```powershell
go -C backend run ./cmd/server
npm --prefix frontend run dev
```
## Demo Credentials
The seed command creates:
```text
Email: demo@example.com
Password: DemoPassword123!
Studio: Northline Studio
```
It also creates the published demo gallery:
```text
Title: Emma & James
Slug: emma-james-wedding
Client: Emma & James
```
The demo media uses external placeholder URLs so the gallery is visually useful immediately. Real uploads use MinIO/S3 objects and never store media bytes in PostgreSQL.
If an upload stops at 0%, open **Dashboard -> Dev**. The workbench shows the effective browser origin, allowed CORS origins, MinIO reachability, bucket, and the latest client-side upload error.
## Storage Configuration
The default `.env.example` uses MinIO:
```dotenv
STORAGE_ENDPOINT=localhost:9000
STORAGE_ACCESS_KEY=minioadmin
STORAGE_SECRET_KEY=minioadmin
STORAGE_BUCKET=gallery-media
STORAGE_USE_SSL=false
```
The Go `storage.Storage` interface supports:
- `CreateUploadURL`
- `CreateDownloadURL`
- `Delete`
- `Stat`
- `Get`
- `Put`
The current implementation is `MinIO`, using the MinIO Go SDK. Replacing it with AWS S3, Cloudflare R2, or Backblaze B2 only requires another implementation of that interface and provider configuration.
Upload flow:
```text
Browser -> POST /api/galleries/:id/uploads
API -> creates media row + presigned PUT URL
Browser -> PUT file directly to MinIO
Browser -> POST /api/uploads/:id/complete
Worker -> generates image previews and marks media READY
```
Original object keys are private. The API exposes only short-lived signed URLs to authenticated owners or authorized gallery visitors.
## SQLite Mode
SQLite is retained as a development/test option. It still requires MinIO for the complete upload workflow, but it does not require PostgreSQL.
```dotenv
DB_DRIVER=sqlite
SQLITE_PATH=./data/surprise.db
```
Then run the same migration and seed commands:
```bash
make migrate
make seed
make dev
```
PowerShell process variables use `$env:` syntax:
```powershell
$env:DB_DRIVER = "sqlite"
$env:SQLITE_PATH = ".\data\surprise.db"
go -C backend run ./cmd/migrate -dir ../migrations
go -C backend run ./cmd/seed
go -C backend run ./cmd/server
```
The migration runner selects `migrations/sqlite/` for SQLite and the root migration files for PostgreSQL.
## Product Workflow
### Photographer
1. Register or sign in.
2. Create a gallery with title, client, and description.
3. Drag in photos or videos.
4. Uploads go directly to MinIO using presigned URLs with per-file progress.
5. Set a cover image, ordering, layout, appearance, branding, password, expiry, and client controls.
6. Preview the exact public renderer.
7. Publish and copy `/g/:slug`.
### Client
1. Open the private gallery link.
2. Enter a gallery password if required.
3. Browse responsive image and video media.
4. Open photos in an immersive fullscreen viewer.
5. Favorite photographs anonymously.
6. Download individual originals or request an asynchronous gallery ZIP.
## API Overview
Authentication:
```text
POST /api/auth/register
POST /api/auth/login
POST /api/auth/logout
GET /api/auth/me
```
Authenticated galleries:
```text
GET /api/galleries
POST /api/galleries
GET /api/galleries/:id
PATCH /api/galleries/:id
DELETE /api/galleries/:id
POST /api/galleries/:id/publish
POST /api/galleries/:id/unpublish
GET /api/galleries/:id/preview
```
Uploads and media:
```text
GET /api/galleries/:id/media
POST /api/galleries/:id/uploads
POST /api/uploads/:id/complete
PATCH /api/media/:id
POST /api/media/:id/download
DELETE /api/media/:id
DELETE /api/uploads/:id
```
Public gallery:
```text
GET /api/public/galleries/:slug
POST /api/public/galleries/:slug/authenticate
POST /api/public/galleries/:slug/media/:mediaId/favorite
DELETE /api/public/galleries/:slug/media/:mediaId/favorite
POST /api/public/galleries/:slug/media/:mediaId/download
POST /api/public/galleries/:slug/download-all
GET /api/public/galleries/:slug/download-all/:jobId
```
Health:
```text
GET /health
```
Photographer routes use an HTTP-only signed session cookie. Public visitor identity and gallery access are separate signed cookies; clients do not need accounts.
## Database
The gallery platform migration adds:
- `users`: photographer accounts and bcrypt password hashes
- `galleries`: ownership, slug, publication state, controls, expiry, theme, and branding JSON
- `media`: original object metadata, preview keys, processing state, dimensions, and ordering
- `favorites`: anonymous gallery-scoped favorite records
- `downloads`: individual download audit records
- `download_jobs`: asynchronous ZIP status and object key
Migration files:
- `migrations/003_gallery_platform.sql` for PostgreSQL
- `migrations/sqlite/003_gallery_platform.sql` for SQLite
The schema does not store uploaded media content. `external_url` exists only to make the development seed gallery useful without shipping copyrighted or binary assets; real uploads have private `storage_key` values instead.
## Project Structure
```text
backend/
cmd/server/ API and worker startup
cmd/migrate/ dialect-aware SQL migration runner
cmd/seed/ demo photographer and gallery seed
internal/auth/ bcrypt auth, signed sessions, visitor access cookies
internal/galleries/ gallery CRUD and public gallery responses
internal/media/ upload lifecycle, media repository, preview worker
internal/storage/ MinIO/S3-compatible storage interface and adapter
internal/downloads async individual/ZIP download handling
frontend/
src/components/dashboard/ photographer workspace, editor, upload queue
src/components/gallery/ shared public/preview renderer and viewer
src/features/auth/ authenticated session context
src/pages/ auth, dashboard, editor, public gallery routes
src/lib/ typed API client and formatting helpers
migrations/ PostgreSQL and SQLite SQL migrations
docker-compose.yml PostgreSQL and MinIO
```
Preview and public delivery use the same `ClientGallery` component. The preview endpoint changes only authorization and the `preview` banner; it does not create a second mock gallery UI.
## Checks
Backend:
```bash
```
Frontend:
```bash
npm --prefix frontend run build
npm --prefix frontend run lint
npm --prefix frontend run format:check
```
The backend test suite includes real SQLite migration/repository/auth coverage. A live PostgreSQL/MinIO run requires Docker and should be performed with the clean-checkout commands above.
## Deliberate MVP Boundaries
Implemented:
- Photographer registration/login/logout
- Gallery CRUD and ownership checks
- Gallery publication and preview
- MinIO presigned direct uploads
- Per-file upload progress and cancellation
- Image preview/thumbnail worker
- Video playback using optimized/original object URLs
- Gallery themes, layouts, branding, controls, password, expiry, and cover selection
- Public responsive gallery at `/g/:slug`
- Fullscreen photo viewer with keyboard and touch navigation
- Anonymous favorites
- Signed individual downloads
- Background ZIP generation and polling
Not implemented yet:
- Billing, subscriptions, teams, organizations, and CRM
- Persistent distributed job queue
- FFmpeg video transcoding and streaming manifests
- Full HEIC decoding; unsupported image formats fall back to the original object
- Production email delivery
- Advanced proofing/comments/analytics
- Custom domains and cloud archive workflows
+108
View File
@@ -0,0 +1,108 @@
package main
import (
"context"
"database/sql"
"flag"
"fmt"
"log"
"os"
"path/filepath"
"sort"
"strings"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
)
func main() {
directory := flag.String("dir", "migrations", "directory containing SQL migrations")
flag.Parse()
cfg := config.Load()
ctx := context.Background()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
if err := run(ctx, database, *directory, cfg.DBDriver); err != nil {
log.Fatal(err)
}
}
func run(ctx context.Context, database *sql.DB, directory, driver string) error {
if _, err := database.ExecContext(ctx, `
CREATE TABLE IF NOT EXISTS schema_migrations (
version TEXT PRIMARY KEY,
applied_at TIMESTAMP NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
return fmt.Errorf("create migration table: %w", err)
}
directory = migrationDirectory(directory, driver)
files, err := migrationFiles(directory)
if err != nil {
return err
}
for _, path := range files {
version := filepath.Base(path)
var applied bool
if err := database.QueryRowContext(ctx, `SELECT EXISTS (SELECT 1 FROM schema_migrations WHERE version = $1)`, version).Scan(&applied); err != nil {
return fmt.Errorf("check migration %s: %w", version, err)
}
if applied {
continue
}
sql, err := os.ReadFile(path)
if err != nil {
return fmt.Errorf("read migration %s: %w", version, err)
}
tx, err := database.BeginTx(ctx, nil)
if err != nil {
return fmt.Errorf("begin migration %s: %w", version, err)
}
if _, err := tx.ExecContext(ctx, string(sql)); err != nil {
_ = tx.Rollback()
return fmt.Errorf("apply migration %s: %w", version, err)
}
if _, err := tx.ExecContext(ctx, `INSERT INTO schema_migrations (version) VALUES ($1)`, version); err != nil {
_ = tx.Rollback()
return fmt.Errorf("record migration %s: %w", version, err)
}
if err := tx.Commit(); err != nil {
return fmt.Errorf("commit migration %s: %w", version, err)
}
log.Printf("applied migration %s", version)
}
return nil
}
func migrationDirectory(directory, driver string) string {
if strings.EqualFold(driver, "sqlite") || strings.EqualFold(driver, "sqlite3") {
return filepath.Join(directory, "sqlite")
}
return directory
}
func migrationFiles(directory string) ([]string, error) {
entries, err := os.ReadDir(directory)
if err != nil {
return nil, fmt.Errorf("read migration directory: %w", err)
}
files := make([]string, 0, len(entries))
for _, entry := range entries {
if !entry.IsDir() && strings.HasSuffix(entry.Name(), ".sql") {
files = append(files, filepath.Join(directory, entry.Name()))
}
}
sort.Strings(files)
return files, nil
}
+100
View File
@@ -0,0 +1,100 @@
package main
import (
"context"
"database/sql"
"fmt"
"log"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
demoEmail = "demo@example.com"
demoPassword = "DemoPassword123!"
)
func main() {
cfg := config.Load()
ctx := context.Background()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
if err := seed(ctx, database); err != nil {
log.Fatal(err)
}
log.Printf("seeded %s with the Northline Studio demo gallery", demoEmail)
}
func seed(ctx context.Context, database *sql.DB) error {
passwordHash, err := bcrypt.GenerateFromPassword([]byte(demoPassword), bcrypt.DefaultCost)
if err != nil {
return fmt.Errorf("hash demo password: %w", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4)
ON CONFLICT (email) DO UPDATE SET password_hash = EXCLUDED.password_hash, name = EXCLUDED.name, updated_at = CURRENT_TIMESTAMP
`, demoUserID, demoEmail, string(passwordHash), "Northline Studio"); err != nil {
return fmt.Errorf("seed demo user: %w", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO galleries (
id, user_id, slug, title, client_name, description, status,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
cover_media_id, theme_config, branding_config, published_at
)
VALUES ($1, $2, $3, $4, $5, $6, 'published', TRUE, TRUE, TRUE, FALSE, $7, $8, $9, CURRENT_TIMESTAMP)
ON CONFLICT (slug) DO UPDATE SET
user_id = EXCLUDED.user_id, title = EXCLUDED.title, client_name = EXCLUDED.client_name,
description = EXCLUDED.description, status = EXCLUDED.status,
downloads_enabled = EXCLUDED.downloads_enabled, favorites_enabled = EXCLUDED.favorites_enabled,
download_all_enabled = EXCLUDED.download_all_enabled, watermark_enabled = EXCLUDED.watermark_enabled,
cover_media_id = EXCLUDED.cover_media_id, theme_config = EXCLUDED.theme_config,
branding_config = EXCLUDED.branding_config, published_at = EXCLUDED.published_at,
updated_at = CURRENT_TIMESTAMP
`, demoGalleryID, demoUserID, "emma-james-wedding", "Emma & James", "Emma & James", "An early summer wedding, held close to the water and the people who make it home.", demoCoverID,
`{"mode":"light","layout":"editorial","accent":"#ad695b","font":"serif"}`,
`{"studioName":"Northline Studio","tagline":"Photographs for keeps.","websiteUrl":"https://example.com","instagramUrl":"https://instagram.com"}`); err != nil {
return fmt.Errorf("seed demo gallery: %w", err)
}
mediaRows := []struct {
id, filename, mime, externalURL string
sortOrder int
duration float64
}{
{demoCoverID.String(), "emma-james-01.jpg", "image/jpeg", "https://images.unsplash.com/photo-1519741497674-611481863552?auto=format&fit=crop&w=1800&q=88", 1, 0},
{demoPhotoID.String(), "emma-james-02.jpg", "image/jpeg", "https://images.unsplash.com/photo-1511285560929-80b456fea0bc?auto=format&fit=crop&w=1800&q=88", 2, 0},
{demoVideoID.String(), "emma-james-film.mp4", "video/mp4", "https://storage.googleapis.com/coverr-main/mp4/Mt_Baker.mp4", 3, 31.0},
}
for _, item := range mediaRows {
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, external_url, processing_status, sort_order, duration_seconds)
VALUES ($1, $2, $3, $4, 0, $5, $6, 'READY', $7, $8)
ON CONFLICT (id) DO UPDATE SET
gallery_id = EXCLUDED.gallery_id, original_filename = EXCLUDED.original_filename,
mime_type = EXCLUDED.mime_type, storage_key = EXCLUDED.storage_key,
external_url = EXCLUDED.external_url, processing_status = EXCLUDED.processing_status,
sort_order = EXCLUDED.sort_order, duration_seconds = EXCLUDED.duration_seconds, updated_at = CURRENT_TIMESTAMP
`, uuid.MustParse(item.id), demoGalleryID, item.filename, item.mime, "demo/"+item.filename, item.externalURL, item.sortOrder, item.duration); err != nil {
return fmt.Errorf("seed demo media %s: %w", item.filename, err)
}
}
return nil
}
var (
demoUserID = uuid.MustParse("55555555-5555-4555-8555-555555555555")
demoGalleryID = uuid.MustParse("66666666-6666-4666-8666-666666666666")
demoCoverID = uuid.MustParse("77777777-7777-4777-8777-777777777777")
demoPhotoID = uuid.MustParse("88888888-8888-4888-8888-888888888888")
demoVideoID = uuid.MustParse("99999999-9999-4999-8999-999999999999")
)
+153
View File
@@ -0,0 +1,153 @@
package main
import (
"context"
"log"
"net/http"
"os"
"os/signal"
"strings"
"syscall"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/config"
"github.com/example/sndit/backend/internal/db"
devtools "github.com/example/sndit/backend/internal/dev"
"github.com/example/sndit/backend/internal/downloads"
"github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/gifts"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
)
func main() {
cfg := config.Load()
ctx, stop := signal.NotifyContext(context.Background(), os.Interrupt, syscall.SIGTERM)
defer stop()
database, err := db.New(ctx, cfg.DBDriver, cfg.DatabaseDSN())
if err != nil {
log.Fatalf("database unavailable: %v", err)
}
defer database.Close()
objectStorage, err := storage.NewMinIO(storage.Config{
Endpoint: cfg.StorageEndpoint,
AccessKey: cfg.StorageAccessKey,
SecretKey: cfg.StorageSecretKey,
Bucket: cfg.StorageBucket,
UseSSL: cfg.StorageUseSSL,
CORSOrigins: cfg.CORSOrigin,
})
if err != nil {
log.Fatalf("storage unavailable: %v", err)
}
if err := objectStorage.EnsureBucket(ctx); err != nil {
log.Fatalf("storage unavailable: %v", err)
}
authRepository := auth.NewRepository(database)
authService, err := auth.NewService(authRepository, cfg.SessionSecret, cfg.CookieSecure)
if err != nil {
log.Fatalf("authentication unavailable: %v", err)
}
galleryRepository := galleries.NewRepository(database)
mediaRepository := media.NewRepository(database)
mediaProcessor := media.NewProcessor(mediaRepository, objectStorage, 2)
defer mediaProcessor.Close()
downloadRepository := downloads.NewRepository(database)
downloadService := downloads.NewService(downloadRepository, mediaRepository, objectStorage, 1)
defer downloadService.Close()
authHandler := auth.NewHandler(authService)
galleryHandler := galleries.NewHandler(galleryRepository, mediaRepository, objectStorage, authService)
mediaHandler := media.NewHandler(mediaRepository, objectStorage, mediaProcessor, authService)
downloadHandler := downloads.NewHandler(galleryRepository, mediaRepository, objectStorage, authService, downloadService)
devHandler := devtools.NewHandler(database, objectStorage, authService, devtools.Config{
DBDriver: cfg.DBDriver,
StorageEndpoint: cfg.StorageEndpoint,
StorageBucket: cfg.StorageBucket,
StorageUseSSL: cfg.StorageUseSSL,
CORSOrigins: cfg.CORSOrigin,
CookieSecure: cfg.CookieSecure,
})
mux := http.NewServeMux()
mux.HandleFunc("GET /health", health)
authHandler.RegisterRoutes(mux)
galleryHandler.RegisterProtectedRoutes(mux, authService.Require)
mediaHandler.RegisterRoutes(mux, authService.Require)
galleryHandler.RegisterPublicRoutes(mux)
downloadHandler.RegisterRoutes(mux)
devHandler.RegisterRoutes(mux, authService.Require)
// Keep the original public gift endpoint available while the gallery product
// uses /api/public/galleries/:slug.
legacyGifts := gifts.NewHandler(gifts.NewService(gifts.NewRepository(database)))
mux.Handle("/api/gifts/", legacyGifts.Routes())
server := &http.Server{
Addr: ":" + cfg.Port,
Handler: withCORS(withLogging(mux), cfg.CORSOrigin),
ReadHeaderTimeout: 5 * time.Second,
ReadTimeout: 10 * time.Second,
WriteTimeout: 10 * time.Second,
IdleTimeout: 60 * time.Second,
}
go func() {
log.Printf("API listening on http://localhost%s", server.Addr)
if err := server.ListenAndServe(); err != nil && err != http.ErrServerClosed {
log.Fatalf("server failed: %v", err)
}
}()
<-ctx.Done()
shutdownCtx, cancel := context.WithTimeout(context.Background(), 5*time.Second)
defer cancel()
if err := server.Shutdown(shutdownCtx); err != nil {
log.Printf("server shutdown failed: %v", err)
}
}
func health(w http.ResponseWriter, _ *http.Request) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(http.StatusOK)
_, _ = w.Write([]byte(`{"status":"ok"}` + "\n"))
}
func withCORS(next http.Handler, allowedOrigin string) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
origin := r.Header.Get("Origin")
if origin != "" && originAllowed(origin, allowedOrigin) {
w.Header().Set("Access-Control-Allow-Origin", origin)
w.Header().Set("Access-Control-Allow-Credentials", "true")
w.Header().Set("Vary", "Origin")
}
if r.Method == http.MethodOptions {
w.Header().Set("Access-Control-Allow-Methods", "GET, POST, PATCH, DELETE, OPTIONS")
w.Header().Set("Access-Control-Allow-Headers", "Content-Type")
w.WriteHeader(http.StatusNoContent)
return
}
next.ServeHTTP(w, r)
})
}
func originAllowed(origin, configured string) bool {
for _, allowed := range strings.Split(configured, ",") {
if strings.TrimSpace(allowed) == "*" || strings.TrimSpace(allowed) == origin {
return true
}
}
return false
}
func withLogging(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
started := time.Now()
next.ServeHTTP(w, r)
log.Printf("%s %s %s", r.Method, r.URL.Path, time.Since(started).Round(time.Millisecond))
})
}
+39
View File
@@ -0,0 +1,39 @@
module github.com/example/sndit/backend
go 1.24.0
require (
github.com/google/uuid v1.6.0
github.com/jackc/pgx/v5 v5.7.6
github.com/joho/godotenv v1.5.1
github.com/minio/minio-go/v7 v7.0.95
golang.org/x/crypto v0.39.0
modernc.org/sqlite v1.39.1
)
require (
github.com/dustin/go-humanize v1.0.1 // indirect
github.com/go-ini/ini v1.67.0 // indirect
github.com/goccy/go-json v0.10.5 // indirect
github.com/jackc/pgpassfile v1.0.0 // indirect
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 // indirect
github.com/jackc/puddle/v2 v2.2.2 // indirect
github.com/klauspost/compress v1.18.0 // indirect
github.com/klauspost/cpuid/v2 v2.2.11 // indirect
github.com/mattn/go-isatty v0.0.20 // indirect
github.com/minio/crc64nvme v1.0.2 // indirect
github.com/minio/md5-simd v1.1.2 // indirect
github.com/ncruces/go-strftime v0.1.9 // indirect
github.com/philhofer/fwd v1.2.0 // indirect
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec // indirect
github.com/rs/xid v1.6.0 // indirect
github.com/tinylib/msgp v1.3.0 // indirect
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b // indirect
golang.org/x/net v0.41.0 // indirect
golang.org/x/sync v0.16.0 // indirect
golang.org/x/sys v0.36.0 // indirect
golang.org/x/text v0.26.0 // indirect
modernc.org/libc v1.66.10 // indirect
modernc.org/mathutil v1.7.1 // indirect
modernc.org/memory v1.11.0 // indirect
)
+100
View File
@@ -0,0 +1,100 @@
github.com/davecgh/go-spew v1.1.0/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/davecgh/go-spew v1.1.1 h1:vj9j/u1bqnvCEfJOwUhtlOARqs3+rkHYY13jYWTU97c=
github.com/davecgh/go-spew v1.1.1/go.mod h1:J7Y8YcW2NihsgmVo/mv3lAwl/skON4iLHjSsI+c5H38=
github.com/dustin/go-humanize v1.0.1 h1:GzkhY7T5VNhEkwH0PVJgjz+fX1rhBrR7pRT3mDkpeCY=
github.com/dustin/go-humanize v1.0.1/go.mod h1:Mu1zIs6XwVuF/gI1OepvI0qD18qycQx+mFykh5fBlto=
github.com/go-ini/ini v1.67.0 h1:z6ZrTEZqSWOTyH2FlglNbNgARyHG8oLW9gMELqKr06A=
github.com/go-ini/ini v1.67.0/go.mod h1:ByCAeIL28uOIIG0E3PJtZPDL8WnHpFKFOtgjp+3Ies8=
github.com/goccy/go-json v0.10.5 h1:Fq85nIqj+gXn/S5ahsiTlK3TmC85qgirsdTP/+DeaC4=
github.com/goccy/go-json v0.10.5/go.mod h1:oq7eo15ShAhp70Anwd5lgX2pLfOS3QCiwU/PULtXL6M=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e h1:ijClszYn+mADRFY17kjQEVQ1XRhq2/JR1M3sGqeJoxs=
github.com/google/pprof v0.0.0-20250317173921-a4b03ec1a45e/go.mod h1:boTsfXsheKC2y+lKOCMpSfarhxDeIzfZG1jqGcPl3cA=
github.com/google/uuid v1.6.0 h1:NIvaJDMOsjHA8n1jAhLSgzrAzy1Hgr+hNrb57e+94F0=
github.com/google/uuid v1.6.0/go.mod h1:TIyPZe4MgqvfeYDBFedMoGGpEw/LqOeaOT+nhxU+yHo=
github.com/jackc/pgpassfile v1.0.0 h1:/6Hmqy13Ss2zCq62VdNG8tM1wchn8zjSGOBJ6icpsIM=
github.com/jackc/pgpassfile v1.0.0/go.mod h1:CEx0iS5ambNFdcRtxPj5JhEz+xB6uRky5eyVu/W2HEg=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761 h1:iCEnooe7UlwOQYpKFhBabPMi4aNAfoODPEFNiAnClxo=
github.com/jackc/pgservicefile v0.0.0-20240606120523-5a60cdf6a761/go.mod h1:5TJZWKEWniPve33vlWYSoGYefn3gLQRzjfDlhSJ9ZKM=
github.com/jackc/pgx/v5 v5.7.6 h1:rWQc5FwZSPX58r1OQmkuaNicxdmExaEz5A2DO2hUuTk=
github.com/jackc/pgx/v5 v5.7.6/go.mod h1:aruU7o91Tc2q2cFp5h4uP3f6ztExVpyVv88Xl/8Vl8M=
github.com/jackc/puddle/v2 v2.2.2 h1:PR8nw+E/1w0GLuRFSmiioY6UooMp6KJv0/61nB7icHo=
github.com/jackc/puddle/v2 v2.2.2/go.mod h1:vriiEXHvEE654aYKXXjOvZM39qJ0q+azkZFrfEOc3H4=
github.com/joho/godotenv v1.5.1 h1:7eLL/+HRGLY0ldzfGMeQkb7vMd0as4CfYvUVzLqw0N0=
github.com/joho/godotenv v1.5.1/go.mod h1:f4LDr5Voq0i2e/R5DDNOoa2zzDfwtkZa6DnEwAbqwq4=
github.com/klauspost/compress v1.18.0 h1:c/Cqfb0r+Yi+JtIEq73FWXVkRonBlf0CRNYc8Zttxdo=
github.com/klauspost/compress v1.18.0/go.mod h1:2Pp+KzxcywXVXMr50+X0Q/Lsb43OQHYWRCY2AiWywWQ=
github.com/klauspost/cpuid/v2 v2.0.1/go.mod h1:FInQzS24/EEf25PyTYn52gqo7WaD8xa0213Md/qVLRg=
github.com/klauspost/cpuid/v2 v2.2.11 h1:0OwqZRYI2rFrjS4kvkDnqJkKHdHaRnCm68/DY4OxRzU=
github.com/klauspost/cpuid/v2 v2.2.11/go.mod h1:hqwkgyIinND0mEev00jJYCxPNVRVXFQeu1XKlok6oO0=
github.com/mattn/go-isatty v0.0.20 h1:xfD0iDuEKnDkl03q4limB+vH+GxLEtL/jb4xVJSWWEY=
github.com/mattn/go-isatty v0.0.20/go.mod h1:W+V8PltTTMOvKvAeJH7IuucS94S2C6jfK/D7dTCTo3Y=
github.com/minio/crc64nvme v1.0.2 h1:6uO1UxGAD+kwqWWp7mBFsi5gAse66C4NXO8cmcVculg=
github.com/minio/crc64nvme v1.0.2/go.mod h1:eVfm2fAzLlxMdUGc0EEBGSMmPwmXD5XiNRpnu9J3bvg=
github.com/minio/md5-simd v1.1.2 h1:Gdi1DZK69+ZVMoNHRXJyNcxrMA4dSxoYHZSQbirFg34=
github.com/minio/md5-simd v1.1.2/go.mod h1:MzdKDxYpY2BT9XQFocsiZf/NKVtR7nkE4RoEpN+20RM=
github.com/minio/minio-go/v7 v7.0.95 h1:ywOUPg+PebTMTzn9VDsoFJy32ZuARN9zhB+K3IYEvYU=
github.com/minio/minio-go/v7 v7.0.95/go.mod h1:wOOX3uxS334vImCNRVyIDdXX9OsXDm89ToynKgqUKlo=
github.com/ncruces/go-strftime v0.1.9 h1:bY0MQC28UADQmHmaF5dgpLmImcShSi2kHU9XLdhx/f4=
github.com/ncruces/go-strftime v0.1.9/go.mod h1:Fwc5htZGVVkseilnfgOVb9mKy6w1naJmn9CehxcKcls=
github.com/philhofer/fwd v1.2.0 h1:e6DnBTl7vGY+Gz322/ASL4Gyp1FspeMvx1RNDoToZuM=
github.com/philhofer/fwd v1.2.0/go.mod h1:RqIHx9QI14HlwKwm98g9Re5prTQ6LdeRQn+gXJFxsJM=
github.com/pmezard/go-difflib v1.0.0 h1:4DBwDE0NGyQoBHbLQYPwSUPoCMWR5BEzIk/f1lZbAQM=
github.com/pmezard/go-difflib v1.0.0/go.mod h1:iKH77koFhYxTK1pcRnkKkqfTogsbg7gZNVY4sRDYZ/4=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec h1:W09IVJc94icq4NjY3clb7Lk8O1qJ8BdBEF8z0ibU0rE=
github.com/remyoudompheng/bigfft v0.0.0-20230129092748-24d4a6f8daec/go.mod h1:qqbHyh8v60DhA7CoWK5oRCqLrMHRGoxYCSS9EjAz6Eo=
github.com/rs/xid v1.6.0 h1:fV591PaemRlL6JfRxGDEPl69wICngIQ3shQtzfy2gxU=
github.com/rs/xid v1.6.0/go.mod h1:7XoLgs4eV+QndskICGsho+ADou8ySMSjJKDIan90Nz0=
github.com/stretchr/objx v0.1.0/go.mod h1:HFkY916IF+rwdDfMAkV7OtwuqBVzrE8GR6GFx+wExME=
github.com/stretchr/testify v1.3.0/go.mod h1:M5WIy9Dh21IEIfnGCwXGc5bZfKNJtfHm1UVUgZn+9EI=
github.com/stretchr/testify v1.7.0/go.mod h1:6Fq8oRcR53rry900zMqJjRRixrwX3KX962/h/Wwjteg=
github.com/stretchr/testify v1.9.0 h1:HtqpIVDClZ4nwg75+f6Lvsy/wHu+3BoSGCbBAcpTsTg=
github.com/stretchr/testify v1.9.0/go.mod h1:r2ic/lqez/lEtzL7wO/rwa5dbSLXVDPFyf8C91i36aY=
github.com/tinylib/msgp v1.3.0 h1:ULuf7GPooDaIlbyvgAxBV/FI7ynli6LZ1/nVUNu+0ww=
github.com/tinylib/msgp v1.3.0/go.mod h1:ykjzy2wzgrlvpDCRc4LA8UXy6D8bzMSuAF3WD57Gok0=
golang.org/x/crypto v0.39.0 h1:SHs+kF4LP+f+p14esP5jAoDpHU8Gu/v9lFRK6IT5imM=
golang.org/x/crypto v0.39.0/go.mod h1:L+Xg3Wf6HoL4Bn4238Z6ft6KfEpN0tJGo53AAPC632U=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b h1:M2rDM6z3Fhozi9O7NWsxAkg/yqS/lQJ6PmkyIV3YP+o=
golang.org/x/exp v0.0.0-20250620022241-b7579e27df2b/go.mod h1:3//PLf8L/X+8b4vuAfHzxeRUl04Adcb341+IGKfnqS8=
golang.org/x/mod v0.27.0 h1:kb+q2PyFnEADO2IEF935ehFUXlWiNjJWtRNgBLSfbxQ=
golang.org/x/mod v0.27.0/go.mod h1:rWI627Fq0DEoudcK+MBkNkCe0EetEaDSwJJkCcjpazc=
golang.org/x/net v0.41.0 h1:vBTly1HeNPEn3wtREYfy4GZ/NECgw2Cnl+nK6Nz3uvw=
golang.org/x/net v0.41.0/go.mod h1:B/K4NNqkfmg07DQYrbwvSluqCJOOXwUjeb/5lOisjbA=
golang.org/x/sync v0.16.0 h1:ycBJEhp9p4vXvUZNszeOq0kGTPghopOL8q0fq3vstxw=
golang.org/x/sync v0.16.0/go.mod h1:1dzgHSNfp02xaA81J2MS99Qcpr2w7fw1gpm99rleRqA=
golang.org/x/sys v0.6.0/go.mod h1:oPkhp1MJrh7nUepCBck5+mAzfO9JrbApNNgaTdGDITg=
golang.org/x/sys v0.36.0 h1:KVRy2GtZBrk1cBYA7MKu5bEZFxQk4NIDV6RLVcC8o0k=
golang.org/x/sys v0.36.0/go.mod h1:OgkHotnGiDImocRcuBABYBEXf8A9a87e/uXjp9XT3ks=
golang.org/x/text v0.26.0 h1:P42AVeLghgTYr4+xUnTRKDMqpar+PtX7KWuNQL21L8M=
golang.org/x/text v0.26.0/go.mod h1:QK15LZJUUQVJxhz7wXgxSy/CJaTFjd0G+YLonydOVQA=
golang.org/x/tools v0.36.0 h1:kWS0uv/zsvHEle1LbV5LE8QujrxB3wfQyxHfhOk0Qkg=
golang.org/x/tools v0.36.0/go.mod h1:WBDiHKJK8YgLHlcQPYQzNCkUxUypCaa5ZegCVutKm+s=
gopkg.in/check.v1 v0.0.0-20161208181325-20d25e280405/go.mod h1:Co6ibVJAznAaIkqp8huTwlJQCZ016jof/cbN4VW5Yz0=
gopkg.in/yaml.v3 v3.0.0-20200313102051-9f266ea9e77c/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
gopkg.in/yaml.v3 v3.0.1 h1:fxVm/GzAzEWqLHuvctI91KS9hhNmmWOoWu0XTYJS7CA=
gopkg.in/yaml.v3 v3.0.1/go.mod h1:K4uyk7z7BCEPqu6E+C64Yfv1cQ7kz7rIZviUmN+EgEM=
modernc.org/cc/v4 v4.26.5 h1:xM3bX7Mve6G8K8b+T11ReenJOT+BmVqQj0FY5T4+5Y4=
modernc.org/cc/v4 v4.26.5/go.mod h1:uVtb5OGqUKpoLWhqwNQo/8LwvoiEBLvZXIQ/SmO6mL0=
modernc.org/ccgo/v4 v4.28.1 h1:wPKYn5EC/mYTqBO373jKjvX2n+3+aK7+sICCv4Fjy1A=
modernc.org/ccgo/v4 v4.28.1/go.mod h1:uD+4RnfrVgE6ec9NGguUNdhqzNIeeomeXf6CL0GTE5Q=
modernc.org/fileutil v1.3.40 h1:ZGMswMNc9JOCrcrakF1HrvmergNLAmxOPjizirpfqBA=
modernc.org/fileutil v1.3.40/go.mod h1:HxmghZSZVAz/LXcMNwZPA/DRrQZEVP9VX0V4LQGQFOc=
modernc.org/gc/v2 v2.6.5 h1:nyqdV8q46KvTpZlsw66kWqwXRHdjIlJOhG6kxiV/9xI=
modernc.org/gc/v2 v2.6.5/go.mod h1:YgIahr1ypgfe7chRuJi2gD7DBQiKSLMPgBQe9oIiito=
modernc.org/goabi0 v0.2.0 h1:HvEowk7LxcPd0eq6mVOAEMai46V+i7Jrj13t4AzuNks=
modernc.org/goabi0 v0.2.0/go.mod h1:CEFRnnJhKvWT1c1JTI3Avm+tgOWbkOu5oPA8eH8LnMI=
modernc.org/libc v1.66.10 h1:yZkb3YeLx4oynyR+iUsXsybsX4Ubx7MQlSYEw4yj59A=
modernc.org/libc v1.66.10/go.mod h1:8vGSEwvoUoltr4dlywvHqjtAqHBaw0j1jI7iFBTAr2I=
modernc.org/mathutil v1.7.1 h1:GCZVGXdaN8gTqB1Mf/usp1Y/hSqgI2vAGGP4jZMCxOU=
modernc.org/mathutil v1.7.1/go.mod h1:4p5IwJITfppl0G4sUEDtCr4DthTaT47/N3aT6MhfgJg=
modernc.org/memory v1.11.0 h1:o4QC8aMQzmcwCK3t3Ux/ZHmwFPzE6hf2Y5LbkRs+hbI=
modernc.org/memory v1.11.0/go.mod h1:/JP4VbVC+K5sU2wZi9bHoq2MAkCnrt2r98UGeSK7Mjw=
modernc.org/opt v0.1.4 h1:2kNGMRiUjrp4LcaPuLY2PzUfqM/w9N23quVwhKt5Qm8=
modernc.org/opt v0.1.4/go.mod h1:03fq9lsNfvkYSfxrfUhZCWPk1lm4cq4N+Bh//bEtgns=
modernc.org/sortutil v1.2.1 h1:+xyoGf15mM3NMlPDnFqrteY07klSFxLElE2PVuWIJ7w=
modernc.org/sortutil v1.2.1/go.mod h1:7ZI3a3REbai7gzCLcotuw9AC4VZVpYMjDzETGsSMqJE=
modernc.org/sqlite v1.39.1 h1:H+/wGFzuSCIEVCvXYVHX5RQglwhMOvtHSv+VtidL2r4=
modernc.org/sqlite v1.39.1/go.mod h1:9fjQZ0mB1LLP0GYrp39oOJXx/I2sxEnZtzCmEQIKvGE=
modernc.org/strutil v1.2.1 h1:UneZBkQA+DX2Rp35KcM69cSsNES9ly8mQWD71HKlOA0=
modernc.org/strutil v1.2.1/go.mod h1:EHkiggD70koQxjVdSBM3JKM7k6L0FbGE5eymy9i3B9A=
modernc.org/token v1.1.0 h1:Xl7Ap9dKaEs5kLoOQeQmPWevfnk/DM5qcLcYlA8ys6Y=
modernc.org/token v1.1.0/go.mod h1:UGzOrNV1mAFSEB63lOFHIpNRUVMvYTc6yu1SMY/XTDM=
+93
View File
@@ -0,0 +1,93 @@
package auth
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/auth/register", h.Register)
mux.HandleFunc("POST /api/auth/login", h.Login)
mux.HandleFunc("POST /api/auth/logout", h.Logout)
mux.HandleFunc("GET /api/auth/me", h.Me)
}
type credentialsRequest struct {
Email string `json:"email"`
Password string `json:"password"`
Name string `json:"name"`
}
func (h *Handler) Register(w http.ResponseWriter, r *http.Request) {
var request credentialsRequest
if !decodeJSON(w, r, &request) {
return
}
user, err := h.service.Register(r.Context(), request.Email, request.Password, request.Name)
if err != nil {
if errors.Is(err, ErrEmailTaken) {
writeJSON(w, http.StatusConflict, map[string]string{"error": "email is already registered"})
return
}
writeJSON(w, http.StatusBadRequest, map[string]string{"error": err.Error()})
return
}
h.service.SetSession(w, user)
writeJSON(w, http.StatusCreated, map[string]User{"user": user})
}
func (h *Handler) Login(w http.ResponseWriter, r *http.Request) {
var request credentialsRequest
if !decodeJSON(w, r, &request) {
return
}
user, err := h.service.Login(r.Context(), request.Email, request.Password)
if err != nil {
if errors.Is(err, ErrInvalidCredentials) {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "invalid email or password"})
return
}
writeJSON(w, http.StatusInternalServerError, map[string]string{"error": "could not sign in"})
return
}
h.service.SetSession(w, user)
writeJSON(w, http.StatusOK, map[string]User{"user": user})
}
func (h *Handler) Logout(w http.ResponseWriter, _ *http.Request) {
h.service.ClearSession(w)
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) Me(w http.ResponseWriter, r *http.Request) {
user, err := h.service.UserFromRequest(r.Context(), r)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
writeJSON(w, http.StatusOK, map[string]User{"user": user})
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeJSON(w, http.StatusUnsupportedMediaType, map[string]string{"error": "content type must be application/json"})
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeJSON(w, http.StatusBadRequest, map[string]string{"error": "invalid JSON body"})
return false
}
return true
}
+14
View File
@@ -0,0 +1,14 @@
package auth
import "github.com/google/uuid"
type User struct {
ID uuid.UUID `json:"id"`
Email string `json:"email"`
Name string `json:"name"`
}
type storedUser struct {
User
PasswordHash string
}
+68
View File
@@ -0,0 +1,68 @@
package auth
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrEmailTaken = errors.New("email already registered")
func (r *Repository) CreateUser(ctx context.Context, email, passwordHash, name string) (User, error) {
user := User{ID: uuid.New(), Email: strings.ToLower(strings.TrimSpace(email)), Name: strings.TrimSpace(name)}
_, err := r.db.ExecContext(ctx, `
INSERT INTO users (id, email, password_hash, name)
VALUES ($1, $2, $3, $4)
`, user.ID, user.Email, passwordHash, user.Name)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return User{}, ErrEmailTaken
}
return User{}, fmt.Errorf("create user: %w", err)
}
return user, nil
}
func (r *Repository) FindByEmail(ctx context.Context, email string) (storedUser, error) {
var user storedUser
err := r.db.QueryRowContext(ctx, `
SELECT id, email, name, password_hash
FROM users
WHERE lower(email) = lower($1)
`, strings.TrimSpace(email)).Scan(&user.ID, &user.Email, &user.Name, &user.PasswordHash)
if errors.Is(err, sql.ErrNoRows) {
return storedUser{}, sql.ErrNoRows
}
if err != nil {
return storedUser{}, fmt.Errorf("find user by email: %w", err)
}
return user, nil
}
func (r *Repository) FindByID(ctx context.Context, id uuid.UUID) (User, error) {
var user User
err := r.db.QueryRowContext(ctx, `
SELECT id, email, name
FROM users
WHERE id = $1
`, id).Scan(&user.ID, &user.Email, &user.Name)
if errors.Is(err, sql.ErrNoRows) {
return User{}, sql.ErrNoRows
}
if err != nil {
return User{}, fmt.Errorf("find user by id: %w", err)
}
return user, nil
}
+243
View File
@@ -0,0 +1,243 @@
package auth
import (
"context"
"crypto/hmac"
"crypto/sha256"
"crypto/subtle"
"database/sql"
"encoding/base64"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const (
sessionCookieName = "studio_session"
visitorCookieName = "studio_visitor"
accessCookieName = "studio_gallery_access"
sessionDuration = 7 * 24 * time.Hour
accessDuration = 12 * time.Hour
)
var (
ErrInvalidCredentials = errors.New("invalid credentials")
ErrInvalidSession = errors.New("invalid session")
)
type Service struct {
repository *Repository
secret []byte
secure bool
}
func NewService(repository *Repository, secret string, secureCookie bool) (*Service, error) {
if len(secret) < 32 {
return nil, fmt.Errorf("session secret must be at least 32 characters")
}
return &Service{repository: repository, secret: []byte(secret), secure: secureCookie}, nil
}
func (s *Service) Register(ctx context.Context, email, password, name string) (User, error) {
email = strings.ToLower(strings.TrimSpace(email))
name = strings.TrimSpace(name)
if !strings.Contains(email, "@") || len(email) > 254 {
return User{}, fmt.Errorf("enter a valid email address")
}
if len(password) < 8 {
return User{}, fmt.Errorf("password must be at least 8 characters")
}
if name == "" || len(name) > 120 {
return User{}, fmt.Errorf("name is required")
}
hash, err := bcrypt.GenerateFromPassword([]byte(password), bcrypt.DefaultCost)
if err != nil {
return User{}, fmt.Errorf("hash password: %w", err)
}
return s.repository.CreateUser(ctx, email, string(hash), name)
}
func (s *Service) Login(ctx context.Context, email, password string) (User, error) {
user, err := s.repository.FindByEmail(ctx, email)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidCredentials
}
if err != nil {
return User{}, err
}
if err := bcrypt.CompareHashAndPassword([]byte(user.PasswordHash), []byte(password)); err != nil {
return User{}, ErrInvalidCredentials
}
return user.User, nil
}
func (s *Service) SetSession(w http.ResponseWriter, user User) {
payload := sessionPayload{UserID: user.ID.String(), ExpiresAt: time.Now().Add(sessionDuration).Unix()}
token, err := s.signJSON(payload)
if err != nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: token,
Path: "/",
MaxAge: int(sessionDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) ClearSession(w http.ResponseWriter) {
http.SetCookie(w, &http.Cookie{
Name: sessionCookieName,
Value: "",
Path: "/",
MaxAge: -1,
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) UserFromRequest(ctx context.Context, r *http.Request) (User, error) {
cookie, err := r.Cookie(sessionCookieName)
if err != nil {
return User{}, ErrInvalidSession
}
var payload sessionPayload
if err := s.verifyJSON(cookie.Value, &payload); err != nil {
return User{}, ErrInvalidSession
}
userID, err := uuid.Parse(payload.UserID)
if err != nil || payload.ExpiresAt <= time.Now().Unix() {
return User{}, ErrInvalidSession
}
user, err := s.repository.FindByID(ctx, userID)
if errors.Is(err, sql.ErrNoRows) {
return User{}, ErrInvalidSession
}
return user, err
}
type contextKey string
const userContextKey contextKey = "authenticated-user"
func (s *Service) Require(next http.Handler) http.Handler {
return http.HandlerFunc(func(w http.ResponseWriter, r *http.Request) {
user, err := s.UserFromRequest(r.Context(), r)
if err != nil {
writeJSON(w, http.StatusUnauthorized, map[string]string{"error": "authentication required"})
return
}
next.ServeHTTP(w, r.WithContext(context.WithValue(r.Context(), userContextKey, user)))
})
}
func UserFromContext(ctx context.Context) (User, bool) {
user, ok := ctx.Value(userContextKey).(User)
return user, ok
}
func (s *Service) EnsureVisitor(w http.ResponseWriter, r *http.Request) string {
if cookie, err := r.Cookie(visitorCookieName); err == nil {
if _, err := uuid.Parse(cookie.Value); err == nil {
return cookie.Value
}
}
visitorID := uuid.NewString()
http.SetCookie(w, &http.Cookie{
Name: visitorCookieName,
Value: visitorID,
Path: "/",
MaxAge: int(365 * 24 * time.Hour / time.Second),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
return visitorID
}
func (s *Service) GrantGalleryAccess(w http.ResponseWriter, slug string) {
payload := accessPayload{Slug: slug, ExpiresAt: time.Now().Add(accessDuration).Unix()}
token, err := s.signJSON(payload)
if err != nil {
return
}
http.SetCookie(w, &http.Cookie{
Name: accessCookieName,
Value: token,
Path: "/",
MaxAge: int(accessDuration.Seconds()),
HttpOnly: true,
Secure: s.secure,
SameSite: http.SameSiteLaxMode,
})
}
func (s *Service) HasGalleryAccess(r *http.Request, slug string) bool {
cookie, err := r.Cookie(accessCookieName)
if err != nil {
return false
}
var payload accessPayload
if err := s.verifyJSON(cookie.Value, &payload); err != nil {
return false
}
return payload.Slug == slug && payload.ExpiresAt > time.Now().Unix()
}
type sessionPayload struct {
UserID string `json:"userId"`
ExpiresAt int64 `json:"expiresAt"`
}
type accessPayload struct {
Slug string `json:"slug"`
ExpiresAt int64 `json:"expiresAt"`
}
func (s *Service) signJSON(value any) (string, error) {
data, err := json.Marshal(value)
if err != nil {
return "", err
}
encoded := base64.RawURLEncoding.EncodeToString(data)
return encoded + "." + s.signature(encoded), nil
}
func (s *Service) verifyJSON(token string, target any) error {
encoded, signature, ok := strings.Cut(token, ".")
if !ok || subtle.ConstantTimeCompare([]byte(signature), []byte(s.signature(encoded))) != 1 {
return ErrInvalidSession
}
data, err := base64.RawURLEncoding.DecodeString(encoded)
if err != nil {
return ErrInvalidSession
}
if err := json.Unmarshal(data, target); err != nil {
return ErrInvalidSession
}
return nil
}
func (s *Service) signature(value string) string {
hash := hmac.New(sha256.New, s.secret)
_, _ = hash.Write([]byte(value))
return base64.RawURLEncoding.EncodeToString(hash.Sum(nil))
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+56
View File
@@ -0,0 +1,56 @@
package auth
import (
"context"
"net/http"
"net/http/httptest"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRegisterLoginAndSession(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
if _, err := database.ExecContext(ctx, `
CREATE TABLE users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
)
`); err != nil {
t.Fatalf("create users table: %v", err)
}
service, err := NewService(NewRepository(database), "test-session-secret-that-is-long-enough", false)
if err != nil {
t.Fatalf("create auth service: %v", err)
}
user, err := service.Register(ctx, "Photographer@Example.com", "DemoPassword123!", "Northline Studio")
if err != nil {
t.Fatalf("register user: %v", err)
}
if user.Email != "photographer@example.com" {
t.Fatalf("email was not normalized: %q", user.Email)
}
loggedIn, err := service.Login(ctx, "PHOTOGRAPHER@example.com", "DemoPassword123!")
if err != nil || loggedIn.ID != user.ID {
t.Fatalf("login failed: user=%+v err=%v", loggedIn, err)
}
recorder := httptest.NewRecorder()
service.SetSession(recorder, user)
request := httptest.NewRequest(http.MethodGet, "/api/auth/me", nil)
request.AddCookie(recorder.Result().Cookies()[0])
fromSession, err := service.UserFromRequest(ctx, request)
if err != nil || fromSession.ID != user.ID {
t.Fatalf("session lookup failed: user=%+v err=%v", fromSession, err)
}
}
+70
View File
@@ -0,0 +1,70 @@
package config
import (
"os"
"strconv"
"strings"
"github.com/joho/godotenv"
)
// Config contains the small set of process-level settings needed by the API.
type Config struct {
DBDriver string
Port string
DatabaseURL string
SQLitePath string
CORSOrigin string
SessionSecret string
CookieSecure bool
StorageEndpoint string
StorageAccessKey string
StorageSecretKey string
StorageBucket string
StorageUseSSL bool
}
func Load() Config {
// Loading is intentionally best-effort: real environment variables still win,
// while local commands can be run from either the repository or backend folder.
for _, path := range []string{".env", "../.env", "../../.env"} {
_ = godotenv.Load(path)
}
return Config{
DBDriver: envOrDefault("DB_DRIVER", "postgres"),
Port: envOrDefault("PORT", "8080"),
DatabaseURL: envOrDefault("DATABASE_URL", "postgres://surprise:surprise_dev_password@localhost:5432/surprise?sslmode=disable"),
SQLitePath: envOrDefault("SQLITE_PATH", "./data/surprise.db"),
CORSOrigin: envOrDefault("CORS_ORIGIN", "http://localhost:5173,http://127.0.0.1:5173"),
SessionSecret: envOrDefault("SESSION_SECRET", "local-development-session-secret-change-me"),
CookieSecure: parseBoolEnv("COOKIE_SECURE", false),
StorageEndpoint: envOrDefault("STORAGE_ENDPOINT", "localhost:9000"),
StorageAccessKey: envOrDefault("STORAGE_ACCESS_KEY", "minioadmin"),
StorageSecretKey: envOrDefault("STORAGE_SECRET_KEY", "minioadmin"),
StorageBucket: envOrDefault("STORAGE_BUCKET", "gallery-media"),
StorageUseSSL: parseBoolEnv("STORAGE_USE_SSL", false),
}
}
func (c Config) DatabaseDSN() string {
if strings.EqualFold(c.DBDriver, "sqlite") || strings.EqualFold(c.DBDriver, "sqlite3") {
return c.SQLitePath
}
return c.DatabaseURL
}
func envOrDefault(key, fallback string) string {
if value := strings.TrimSpace(os.Getenv(key)); value != "" {
return value
}
return fallback
}
func parseBoolEnv(key string, fallback bool) bool {
value, err := strconv.ParseBool(strings.TrimSpace(os.Getenv(key)))
if err != nil {
return fallback
}
return value
}
+80
View File
@@ -0,0 +1,80 @@
package db
import (
"context"
"database/sql"
"fmt"
"os"
"path/filepath"
"strings"
_ "github.com/jackc/pgx/v5/stdlib"
_ "modernc.org/sqlite"
)
func New(ctx context.Context, driver, dsn string) (*sql.DB, error) {
driverName, err := normalizeDriver(driver)
if err != nil {
return nil, err
}
if driverName == "sqlite" {
if err := ensureSQLiteDirectory(dsn); err != nil {
return nil, fmt.Errorf("prepare sqlite path: %w", err)
}
}
database, err := sql.Open(driverName, dsn)
if err != nil {
return nil, fmt.Errorf("open %s database: %w", driverName, err)
}
if driverName == "sqlite" {
// In-memory databases are connection-local, so keep SQLite single-connection
// and enable foreign keys for every operation through this handle.
database.SetMaxOpenConns(1)
database.SetMaxIdleConns(1)
if _, err := database.ExecContext(ctx, "PRAGMA foreign_keys = ON"); err != nil {
database.Close()
return nil, fmt.Errorf("configure sqlite: %w", err)
}
} else {
database.SetMaxOpenConns(10)
database.SetMaxIdleConns(1)
}
if err := database.PingContext(ctx); err != nil {
database.Close()
return nil, fmt.Errorf("ping %s database: %w", driverName, err)
}
return database, nil
}
func normalizeDriver(driver string) (string, error) {
switch strings.ToLower(strings.TrimSpace(driver)) {
case "postgres", "postgresql", "pgx":
return "pgx", nil
case "sqlite", "sqlite3":
return "sqlite", nil
default:
return "", fmt.Errorf("unsupported database driver %q (use postgres or sqlite)", driver)
}
}
func ensureSQLiteDirectory(dsn string) error {
if dsn == ":memory:" || strings.HasPrefix(dsn, "file::memory:") {
return nil
}
path := strings.SplitN(dsn, "?", 2)[0]
path = strings.TrimPrefix(path, "file:")
if path == "" || path == ":memory:" {
return nil
}
directory := filepath.Dir(path)
if directory == "." || directory == "" {
return nil
}
return os.MkdirAll(directory, 0o755)
}
+130
View File
@@ -0,0 +1,130 @@
package dev
import (
"context"
"crypto/rand"
"database/sql"
"encoding/hex"
"encoding/json"
"fmt"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage"
)
type Config struct {
DBDriver string
StorageEndpoint string
StorageBucket string
StorageUseSSL bool
CORSOrigins string
CookieSecure bool
}
type Handler struct {
db *sql.DB
storage storage.Storage
auth *auth.Service
config Config
}
func NewHandler(db *sql.DB, objectStorage storage.Storage, authService *auth.Service, config Config) *Handler {
return &Handler{db: db, storage: objectStorage, auth: authService, config: config}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/dev/diagnostics", require(http.HandlerFunc(h.Diagnostics)))
mux.Handle("POST /api/dev/storage-check", require(http.HandlerFunc(h.StorageCheck)))
}
func (h *Handler) Diagnostics(w http.ResponseWriter, r *http.Request) {
user, _ := auth.UserFromContext(r.Context())
databaseError := ""
databaseContext, cancel := context.WithTimeout(r.Context(), 2*time.Second)
if err := h.db.PingContext(databaseContext); err != nil {
databaseError = err.Error()
}
cancel()
storageError := ""
storageContext, storageCancel := context.WithTimeout(r.Context(), 3*time.Second)
if err := h.storage.EnsureBucket(storageContext); err != nil {
storageError = err.Error()
}
storageCancel()
writeJSON(w, http.StatusOK, map[string]any{
"environment": "development",
"now": time.Now().UTC().Format(time.RFC3339),
"user": map[string]string{
"id": user.ID.String(),
"email": user.Email,
"name": user.Name,
},
"database": map[string]any{
"driver": h.config.DBDriver,
"connected": databaseError == "",
"error": databaseError,
},
"storage": map[string]any{
"provider": "MinIO / S3-compatible",
"endpoint": h.config.StorageEndpoint,
"bucket": h.config.StorageBucket,
"secure": h.config.StorageUseSSL,
"reachable": storageError == "",
"error": storageError,
},
"http": map[string]any{
"corsOrigins": splitOrigins(h.config.CORSOrigins),
"cookieSecure": h.config.CookieSecure,
},
})
}
func (h *Handler) StorageCheck(w http.ResponseWriter, r *http.Request) {
if err := h.storage.EnsureBucket(r.Context()); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "bucket", "error": err.Error()})
return
}
var randomBytes [12]byte
if _, err := rand.Read(randomBytes[:]); err != nil {
writeJSON(w, http.StatusInternalServerError, map[string]any{"ok": false, "step": "random", "error": err.Error()})
return
}
key := fmt.Sprintf("dev/diagnostics/%s.txt", hex.EncodeToString(randomBytes[:]))
contents := "northline storage check " + time.Now().UTC().Format(time.RFC3339Nano)
if err := h.storage.Put(r.Context(), key, strings.NewReader(contents), int64(len(contents)), "text/plain"); err != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "put", "error": err.Error()})
return
}
info, statErr := h.storage.Stat(r.Context(), key)
deleteErr := h.storage.Delete(r.Context(), key)
if statErr != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "stat", "error": statErr.Error()})
return
}
if deleteErr != nil {
writeJSON(w, http.StatusServiceUnavailable, map[string]any{"ok": false, "step": "delete", "error": deleteErr.Error()})
return
}
writeJSON(w, http.StatusOK, map[string]any{"ok": true, "bytes": info.Size, "contentType": info.ContentType})
}
func splitOrigins(value string) []string {
result := make([]string, 0)
for _, origin := range strings.Split(value, ",") {
if trimmed := strings.TrimSpace(origin); trimmed != "" {
result = append(result, trimmed)
}
}
return result
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+145
View File
@@ -0,0 +1,145 @@
package downloads
import (
"encoding/json"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/galleries"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Handler struct {
galleries *galleries.Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
service *Service
}
func NewHandler(galleryRepository *galleries.Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service, service *Service) *Handler {
return &Handler{galleries: galleryRepository, media: mediaRepository, storage: objectStorage, auth: authService, service: service}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux) {
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/download", h.Download)
mux.HandleFunc("POST /api/public/galleries/{slug}/download-all", h.DownloadAll)
mux.HandleFunc("GET /api/public/galleries/{slug}/download-all/{jobId}", h.DownloadAllStatus)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "downloads are disabled")
return
}
mediaID, err := uuid.Parse(r.PathValue("mediaId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID || item.ProcessingStatus != media.StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url, err := h.downloadURL(r, item)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
_ = h.media.RecordDownload(r.Context(), record.ID, &mediaID, visitorID)
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) DownloadAll(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Create(r.Context(), record.ID, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not start gallery download")
return
}
writeJSON(w, http.StatusAccepted, map[string]string{"jobId": job.ID.String(), "status": job.Status})
}
func (h *Handler) DownloadAllStatus(w http.ResponseWriter, r *http.Request) {
record, err := h.publicRecord(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.DownloadAllEnabled || !record.DownloadsEnabled {
writeError(w, http.StatusForbidden, "gallery downloads are disabled")
return
}
jobID, err := uuid.Parse(r.PathValue("jobId"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid download job id")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
job, err := h.service.Get(r.Context(), jobID, record.ID, visitorID)
if err != nil {
writeError(w, http.StatusNotFound, "download job not found")
return
}
response := map[string]any{"jobId": job.ID.String(), "status": job.Status}
if job.Error != "" {
response["error"] = job.Error
}
if job.Status == StatusReady {
url, err := h.storage.CreateDownloadURL(r.Context(), job.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download URL")
return
}
response["url"] = url
}
writeJSON(w, http.StatusOK, response)
}
func (h *Handler) publicRecord(r *http.Request) (galleries.GalleryRecord, error) {
record, err := h.galleries.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
if err != nil || record.IsExpired() {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
return galleries.GalleryRecord{}, galleries.ErrNotFound
}
return record, nil
}
func (h *Handler) downloadURL(r *http.Request, item media.Record) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
return h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+22
View File
@@ -0,0 +1,22 @@
package downloads
import "github.com/google/uuid"
const (
StatusQueued = "QUEUED"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Job struct {
ID uuid.UUID
GalleryID uuid.UUID
VisitorID string
Status string
StorageKey string
Error string
CreatedAt string
UpdatedAt string
CompletedAt string
}
+94
View File
@@ -0,0 +1,94 @@
package downloads
import (
"context"
"database/sql"
"errors"
"fmt"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("download job not found")
func (r *Repository) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job := Job{ID: uuid.New(), GalleryID: galleryID, VisitorID: visitorID, Status: StatusQueued}
_, err := r.db.ExecContext(ctx, `
INSERT INTO download_jobs (id, gallery_id, visitor_id, status)
VALUES ($1, $2, $3, $4)
`, job.ID, job.GalleryID, job.VisitorID, job.Status)
if err != nil {
return Job{}, fmt.Errorf("create download job: %w", err)
}
return r.GetForVisitor(ctx, job.ID, galleryID, visitorID)
}
func (r *Repository) GetForVisitor(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return r.get(ctx, `
WHERE id = $1 AND gallery_id = $2 AND visitor_id = $3
`, jobID, galleryID, visitorID)
}
func (r *Repository) Get(ctx context.Context, jobID uuid.UUID) (Job, error) {
return r.get(ctx, `WHERE id = $1`, jobID)
}
func (r *Repository) get(ctx context.Context, predicate string, args ...any) (Job, error) {
var (
job Job
storageKey, jobError, createdAt, updatedAt sql.NullString
completedAt sql.NullString
)
err := r.db.QueryRowContext(ctx, `
SELECT id, gallery_id, visitor_id, status, storage_key, error, created_at, updated_at, completed_at
FROM download_jobs
`+predicate+`
`, args...).Scan(
&job.ID, &job.GalleryID, &job.VisitorID, &job.Status, &storageKey, &jobError,
&createdAt, &updatedAt, &completedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Job{}, ErrNotFound
}
if err != nil {
return Job{}, fmt.Errorf("find download job: %w", err)
}
job.StorageKey = storageKey.String
job.Error = jobError.String
job.CreatedAt = createdAt.String
job.UpdatedAt = updatedAt.String
job.CompletedAt = completedAt.String
return job, nil
}
func (r *Repository) MarkProcessing(ctx context.Context, jobID uuid.UUID) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, updated_at = CURRENT_TIMESTAMP WHERE id = $2
`, StatusProcessing, jobID)
return err
}
func (r *Repository) MarkReady(ctx context.Context, jobID uuid.UUID, storageKey string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, storage_key = $2, error = NULL,
updated_at = CURRENT_TIMESTAMP, completed_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusReady, storageKey, jobID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, jobID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE download_jobs SET status = $1, error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, jobID)
return err
}
+185
View File
@@ -0,0 +1,185 @@
package downloads
import (
"archive/zip"
"context"
"fmt"
"io"
"net/http"
"os"
"path/filepath"
"strings"
"sync"
"time"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Service struct {
repository *Repository
media *media.Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
var placeholderClient = &http.Client{Timeout: time.Minute}
func NewService(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, workers int) *Service {
if workers < 1 {
workers = 1
}
service := &Service{
repository: repository,
media: mediaRepository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 32),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
service.waitGroup.Add(1)
go service.worker()
}
return service
}
func (s *Service) Create(ctx context.Context, galleryID uuid.UUID, visitorID string) (Job, error) {
job, err := s.repository.Create(ctx, galleryID, visitorID)
if err != nil {
return Job{}, err
}
select {
case s.jobs <- job.ID:
case <-s.stop:
return Job{}, fmt.Errorf("download service is stopping")
}
return job, nil
}
func (s *Service) Get(ctx context.Context, jobID, galleryID uuid.UUID, visitorID string) (Job, error) {
return s.repository.GetForVisitor(ctx, jobID, galleryID, visitorID)
}
func (s *Service) Close() {
close(s.stop)
s.waitGroup.Wait()
}
func (s *Service) worker() {
defer s.waitGroup.Done()
for {
select {
case jobID := <-s.jobs:
s.process(jobID)
case <-s.stop:
return
}
}
}
func (s *Service) process(jobID uuid.UUID) {
ctx := context.Background()
if err := s.repository.MarkProcessing(ctx, jobID); err != nil {
return
}
var job Job
// The worker needs the gallery ID and visitor only for status storage. The
// job lookup below is intentionally not visitor-scoped because the ID is
// generated internally and never exposed before creation succeeds.
job, err := s.repository.Get(ctx, jobID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
items, err := s.media.ListByGallery(ctx, job.GalleryID)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporary, err := os.CreateTemp("", "gallery-download-*.zip")
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
temporaryPath := temporary.Name()
defer os.Remove(temporaryPath)
archive := zip.NewWriter(temporary)
for _, item := range items {
if item.ProcessingStatus != media.StatusReady {
continue
}
object, err := s.openItem(ctx, item)
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
entry, err := archive.Create(filepath.Base(item.OriginalFilename))
if err == nil {
_, err = io.Copy(entry, object)
}
_ = object.Close()
if err != nil {
_ = archive.Close()
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
}
if err := archive.Close(); err != nil {
_ = temporary.Close()
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
if err := temporary.Close(); err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
fileInfo, err := os.Stat(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
key := fmt.Sprintf("galleries/%s/downloads/%s.zip", job.GalleryID, job.ID)
file, err := os.Open(temporaryPath)
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
err = s.storage.Put(ctx, key, file, fileInfo.Size(), "application/zip")
_ = file.Close()
if err != nil {
_ = s.repository.MarkFailed(ctx, jobID, err.Error())
return
}
_ = s.repository.MarkReady(ctx, jobID, key)
}
func (s *Service) openItem(ctx context.Context, item media.Record) (io.ReadCloser, error) {
if item.ExternalURL != "" {
request, err := http.NewRequestWithContext(ctx, http.MethodGet, item.ExternalURL, nil)
if err != nil {
return nil, err
}
response, err := placeholderClient.Do(request)
if err != nil {
return nil, err
}
if response.StatusCode >= http.StatusBadRequest {
_ = response.Body.Close()
return nil, fmt.Errorf("download placeholder returned %s", response.Status)
}
return response.Body, nil
}
if strings.TrimSpace(item.StorageKey) == "" {
return nil, fmt.Errorf("media has no storage object")
}
return s.storage.Get(ctx, item.StorageKey)
}
+677
View File
@@ -0,0 +1,677 @@
package galleries
import (
"context"
"encoding/json"
"errors"
"fmt"
"net/http"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/media"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
"golang.org/x/crypto/bcrypt"
)
const signedURLDuration = time.Hour
type Handler struct {
repository *Repository
media *media.Repository
storage storage.Storage
auth *auth.Service
}
func NewHandler(repository *Repository, mediaRepository *media.Repository, objectStorage storage.Storage, authService *auth.Service) *Handler {
return &Handler{
repository: repository,
media: mediaRepository,
storage: objectStorage,
auth: authService,
}
}
func (h *Handler) RegisterProtectedRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/galleries", require(http.HandlerFunc(h.List)))
mux.Handle("POST /api/galleries", require(http.HandlerFunc(h.Create)))
mux.Handle("GET /api/galleries/{id}", require(http.HandlerFunc(h.Get)))
mux.Handle("PATCH /api/galleries/{id}", require(http.HandlerFunc(h.Update)))
mux.Handle("DELETE /api/galleries/{id}", require(http.HandlerFunc(h.Delete)))
mux.Handle("POST /api/galleries/{id}/publish", require(http.HandlerFunc(h.Publish)))
mux.Handle("POST /api/galleries/{id}/unpublish", require(http.HandlerFunc(h.Unpublish)))
mux.Handle("GET /api/galleries/{id}/preview", require(http.HandlerFunc(h.Preview)))
}
func (h *Handler) RegisterPublicRoutes(mux *http.ServeMux) {
mux.HandleFunc("GET /api/public/galleries/{slug}", h.Public)
mux.HandleFunc("POST /api/public/galleries/{slug}/authenticate", h.AuthenticatePublic)
mux.HandleFunc("POST /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Favorite)
mux.HandleFunc("DELETE /api/public/galleries/{slug}/media/{mediaId}/favorite", h.Unfavorite)
}
type createRequest struct {
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
}
type updateRequest struct {
Title *string `json:"title"`
ClientName *string `json:"clientName"`
Description *string `json:"description"`
Password *string `json:"password"`
ClearPassword bool `json:"clearPassword"`
DownloadsEnabled *bool `json:"downloadsEnabled"`
FavoritesEnabled *bool `json:"favoritesEnabled"`
DownloadAllEnabled *bool `json:"downloadAllEnabled"`
WatermarkEnabled *bool `json:"watermarkEnabled"`
ExpiresAt *string `json:"expiresAt"`
CoverMediaID *string `json:"coverMediaId"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
}
type publicPasswordRequest struct {
Password string `json:"password"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
summaries, err := h.repository.ListForUser(r.Context(), user.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load galleries")
return
}
for index := range summaries {
if summaries[index].CoverMediaID == "" {
continue
}
coverID, err := uuid.Parse(summaries[index].CoverMediaID)
if err != nil {
continue
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil {
continue
}
summaries[index].CoverURL, _ = h.mediaURL(r.Context(), cover, false)
}
writeJSON(w, http.StatusOK, map[string]any{"galleries": summaries})
}
func (h *Handler) Create(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
var request createRequest
if !decodeJSON(w, r, &request) {
return
}
request.Title = strings.TrimSpace(request.Title)
request.ClientName = strings.TrimSpace(request.ClientName)
if request.Title == "" || len(request.Title) > 180 || request.ClientName == "" || len(request.ClientName) > 180 {
writeError(w, http.StatusBadRequest, "title and client name are required")
return
}
record, err := h.repository.Create(r.Context(), user.ID, newSlug(request.Title), request.Title, request.ClientName, strings.TrimSpace(request.Description))
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create gallery")
return
}
writeJSON(w, http.StatusCreated, map[string]any{"gallery": h.detail(record, nil)})
}
func (h *Handler) Get(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
}
func (h *Handler) Preview(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
gallery, err := h.publicPayload(r.Context(), r, record, true, true, "")
if err != nil {
writeError(w, http.StatusInternalServerError, "could not build gallery preview")
return
}
gallery.Preview = true
writeJSON(w, http.StatusOK, map[string]any{"gallery": gallery})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
current, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
input := UpdateInput{
Title: current.Title,
ClientName: current.ClientName,
Description: current.Description,
DownloadsEnabled: current.DownloadsEnabled,
FavoritesEnabled: current.FavoritesEnabled,
DownloadAllEnabled: current.DownloadAllEnabled,
WatermarkEnabled: current.WatermarkEnabled,
ExpiresAt: stringPointer(current.ExpiresAt),
CoverMediaID: stringPointer(current.CoverMediaID),
ThemeConfig: current.ThemeConfig,
BrandingConfig: current.BrandingConfig,
}
if current.PasswordHash != "" {
input.PasswordHash = &current.PasswordHash
}
if request.Title != nil {
input.Title = strings.TrimSpace(*request.Title)
}
if request.ClientName != nil {
input.ClientName = strings.TrimSpace(*request.ClientName)
}
if request.Description != nil {
input.Description = strings.TrimSpace(*request.Description)
}
if input.Title == "" || input.ClientName == "" || len(input.Title) > 180 || len(input.ClientName) > 180 {
writeError(w, http.StatusBadRequest, "title and client name are required")
return
}
if request.Password != nil {
if strings.TrimSpace(*request.Password) == "" {
input.ClearPassword = true
} else if len(*request.Password) < 4 {
writeError(w, http.StatusBadRequest, "gallery password must be at least 4 characters")
return
} else {
hash, err := bcrypt.GenerateFromPassword([]byte(*request.Password), bcrypt.DefaultCost)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not secure gallery password")
return
}
hashed := string(hash)
input.PasswordHash = &hashed
input.ClearPassword = false
}
}
if request.ClearPassword {
input.ClearPassword = true
input.PasswordHash = nil
}
if request.DownloadsEnabled != nil {
input.DownloadsEnabled = *request.DownloadsEnabled
}
if request.FavoritesEnabled != nil {
input.FavoritesEnabled = *request.FavoritesEnabled
}
if request.DownloadAllEnabled != nil {
input.DownloadAllEnabled = *request.DownloadAllEnabled
}
if request.WatermarkEnabled != nil {
input.WatermarkEnabled = *request.WatermarkEnabled
}
if request.ExpiresAt != nil {
value := strings.TrimSpace(*request.ExpiresAt)
if value != "" {
if _, err := time.Parse(time.RFC3339, value); err != nil {
writeError(w, http.StatusBadRequest, "expiry must be an ISO timestamp")
return
}
}
input.ExpiresAt = &value
}
if request.CoverMediaID != nil {
value := strings.TrimSpace(*request.CoverMediaID)
if value != "" {
coverID, err := uuid.Parse(value)
if err != nil {
writeError(w, http.StatusBadRequest, "invalid cover media id")
return
}
cover, err := h.media.GetForUser(r.Context(), user.ID, coverID)
if err != nil || cover.GalleryID != current.ID {
writeError(w, http.StatusBadRequest, "cover media does not belong to this gallery")
return
}
}
input.CoverMediaID = &value
}
if len(request.ThemeConfig) > 0 {
if !json.Valid(request.ThemeConfig) {
writeError(w, http.StatusBadRequest, "theme config must be valid JSON")
return
}
input.ThemeConfig = request.ThemeConfig
}
if len(request.BrandingConfig) > 0 {
if !json.Valid(request.BrandingConfig) {
writeError(w, http.StatusBadRequest, "branding config must be valid JSON")
return
}
input.BrandingConfig = request.BrandingConfig
}
record, err := h.repository.Update(r.Context(), user.ID, current.ID, input)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery")
return
}
items, err := h.media.ListByGallery(r.Context(), record.ID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery media")
return
}
views, err := h.mediaViews(r.Context(), items, "", true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, views)})
}
func (h *Handler) Publish(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, StatusPublished)
}
func (h *Handler) Unpublish(w http.ResponseWriter, r *http.Request) {
h.setStatus(w, r, StatusDraft)
}
func (h *Handler) setStatus(w http.ResponseWriter, r *http.Request, status string) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
record, err := h.recordForUser(r, user.ID)
if err != nil {
writeGalleryError(w, err)
return
}
record, err = h.repository.SetStatus(r.Context(), user.ID, record.ID, status)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not update gallery status")
return
}
writeJSON(w, http.StatusOK, map[string]any{"gallery": h.detail(record, nil)})
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
id, err := pathUUID(r, "id")
if err != nil {
writeGalleryError(w, err)
return
}
if err := h.repository.Delete(r.Context(), user.ID, id); err != nil {
writeGalleryError(w, err)
return
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Public(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
gallery := h.lockedPayload(record)
writeJSON(w, http.StatusOK, gallery)
return
}
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, http.StatusOK, gallery)
}
func (h *Handler) AuthenticatePublic(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
record, err := h.repository.GetPublicBySlug(r.Context(), slug)
if err != nil || record.IsExpired() {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request publicPasswordRequest
if !decodeJSON(w, r, &request) {
return
}
if record.PasswordHash == "" || bcrypt.CompareHashAndPassword([]byte(record.PasswordHash), []byte(request.Password)) != nil {
writeError(w, http.StatusUnauthorized, "incorrect gallery password")
return
}
h.auth.GrantGalleryAccess(w, record.Slug)
visitorID := ""
if record.FavoritesEnabled {
visitorID = h.auth.EnsureVisitor(w, r)
}
gallery, err := h.publicPayload(r.Context(), r, record, false, false, visitorID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load gallery")
return
}
writeJSON(w, http.StatusOK, gallery)
}
func (h *Handler) Favorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, true)
}
func (h *Handler) Unfavorite(w http.ResponseWriter, r *http.Request) {
h.setFavorite(w, r, false)
}
func (h *Handler) setFavorite(w http.ResponseWriter, r *http.Request, favorited bool) {
record, err := h.publicRecordForRequest(r)
if err != nil {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
if !record.FavoritesEnabled {
writeError(w, http.StatusForbidden, "favorites are disabled")
return
}
mediaID, err := pathUUID(r, "mediaId")
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.media.GetByID(r.Context(), mediaID)
if err != nil || item.GalleryID != record.ID {
writeError(w, http.StatusNotFound, "media not found")
return
}
visitorID := h.auth.EnsureVisitor(w, r)
if err := h.media.SetFavorite(r.Context(), record.ID, mediaID, visitorID, favorited); err != nil {
writeError(w, http.StatusInternalServerError, "could not update favorite")
return
}
writeJSON(w, http.StatusOK, map[string]bool{"favorited": favorited})
}
func (h *Handler) publicRecordForRequest(r *http.Request) (GalleryRecord, error) {
record, err := h.repository.GetPublicBySlug(r.Context(), strings.TrimSpace(r.PathValue("slug")))
if err != nil || record.IsExpired() {
return GalleryRecord{}, ErrNotFound
}
if record.PasswordHash != "" && !h.auth.HasGalleryAccess(r, record.Slug) {
return GalleryRecord{}, ErrNotFound
}
return record, nil
}
func (h *Handler) publicPayload(ctx context.Context, _ *http.Request, record GalleryRecord, preview, includeOriginal bool, visitorID string) (Public, error) {
items, err := h.media.ListByGallery(ctx, record.ID)
if err != nil {
return Public{}, err
}
views, err := h.mediaViews(ctx, items, visitorID, includeOriginal)
if err != nil {
return Public{}, err
}
gallery := Public{
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
Media: views,
}
for index := range items {
if record.CoverMediaID != "" && items[index].ID.String() == record.CoverMediaID {
gallery.Cover = &views[index]
break
}
}
if gallery.Cover == nil {
for index := range items {
if media.IsImage(items[index]) && views[index].PreviewURL != "" {
gallery.Cover = &views[index]
break
}
}
}
return gallery, nil
}
func (h *Handler) lockedPayload(record GalleryRecord) map[string]any {
return map[string]any{
"slug": record.Slug,
"title": record.Title,
"clientName": record.ClientName,
"description": record.Description,
"themeConfig": record.ThemeConfig,
"brandingConfig": record.BrandingConfig,
"requiresPassword": true,
"media": []media.Public{},
}
}
func (h *Handler) recordForUser(r *http.Request, userID uuid.UUID) (GalleryRecord, error) {
id, err := pathUUID(r, "id")
if err != nil {
return GalleryRecord{}, err
}
return h.repository.GetForUser(r.Context(), userID, id)
}
func (h *Handler) detail(record GalleryRecord, items []media.Public) Detail {
if items == nil {
items = []media.Public{}
}
return Detail{
ID: record.ID.String(),
Slug: record.Slug,
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
Status: record.Status,
DownloadsEnabled: record.DownloadsEnabled,
FavoritesEnabled: record.FavoritesEnabled,
DownloadAllEnabled: record.DownloadAllEnabled,
WatermarkEnabled: record.WatermarkEnabled,
ExpiresAt: record.ExpiresAt,
CoverMediaID: record.CoverMediaID,
ThemeConfig: record.ThemeConfig,
BrandingConfig: record.BrandingConfig,
CreatedAt: record.CreatedAt,
PublishedAt: record.PublishedAt,
Media: items,
}
}
func (h *Handler) mediaViews(ctx context.Context, items []media.Record, visitorID string, includeOriginal bool) ([]media.Public, error) {
views := make([]media.Public, 0, len(items))
for _, item := range items {
view := media.Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
if item.ProcessingStatus == media.StatusReady {
var err error
view.ThumbnailURL, err = h.mediaVariantURL(ctx, item, item.ThumbnailKey)
if err != nil {
return nil, err
}
view.PreviewURL, err = h.mediaURL(ctx, item, false)
if err != nil {
return nil, err
}
if includeOriginal {
view.OriginalURL, err = h.mediaURL(ctx, item, true)
if err != nil {
return nil, err
}
}
}
if visitorID != "" {
var err error
view.Favorited, err = h.media.IsFavorited(ctx, item.GalleryID, item.ID, visitorID)
if err != nil {
return nil, err
}
}
views = append(views, view)
}
return views, nil
}
func (h *Handler) mediaURL(ctx context.Context, item media.Record, original bool) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
key := item.PreviewKey
if original {
key = item.StorageKey
}
return h.mediaVariantURL(ctx, item, key)
}
func (h *Handler) mediaVariantURL(ctx context.Context, item media.Record, key string) (string, error) {
if item.ExternalURL != "" {
return item.ExternalURL, nil
}
if key == "" {
return "", fmt.Errorf("media object is not ready")
}
return h.storage.CreateDownloadURL(ctx, key, signedURLDuration)
}
func newSlug(title string) string {
var builder strings.Builder
lastWasSeparator := true
for _, character := range strings.ToLower(title) {
if (character >= 'a' && character <= 'z') || (character >= '0' && character <= '9') {
builder.WriteRune(character)
lastWasSeparator = false
continue
}
if builder.Len() > 0 && !lastWasSeparator {
builder.WriteByte('-')
lastWasSeparator = true
}
}
slug := strings.Trim(builder.String(), "-")
if slug == "" {
slug = "gallery"
}
suffix := strings.ReplaceAll(uuid.NewString(), "-", "")[:8]
return slug + "-" + suffix
}
func pathUUID(r *http.Request, name string) (uuid.UUID, error) {
id, err := uuid.Parse(r.PathValue(name))
if err != nil {
return uuid.Nil, fmt.Errorf("invalid %s", name)
}
return id, nil
}
func stringPointer(value string) *string {
if value == "" {
return nil
}
copy := value
return &copy
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeGalleryError(w http.ResponseWriter, err error) {
if errors.Is(err, ErrNotFound) || strings.HasPrefix(err.Error(), "invalid ") {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gallery")
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
@@ -0,0 +1,90 @@
package galleries
import (
"context"
"encoding/json"
"net/http"
"net/http/httptest"
"os"
"path/filepath"
"runtime"
"testing"
"github.com/example/sndit/backend/internal/auth"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/example/sndit/backend/internal/media"
"github.com/google/uuid"
)
func TestPublicGalleryAndFavoritesOnSQLite(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
galleryID := uuid.MustParse("66666666-6666-4666-8666-666666666666")
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
t.Fatalf("insert user: %v", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, cover_media_id, branding_config)
VALUES ($1, $2, $3, $4, $5, $6, 'published', $7, $8)
`, galleryID, userID, "demo-gallery", "Emma & James", "Emma & James", "A day worth keeping.", mediaID, `{"studioName":"Northline Studio"}`); err != nil {
t.Fatalf("insert gallery: %v", err)
}
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, storage_key, external_url, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, galleryID, "one.jpg", "image/jpeg", "demo/one.jpg", "https://example.com/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
authService, err := auth.NewService(auth.NewRepository(database), "test-gallery-secret-that-is-long-enough", false)
if err != nil {
t.Fatalf("create auth service: %v", err)
}
handler := NewHandler(NewRepository(database), media.NewRepository(database), nil, authService)
mux := http.NewServeMux()
handler.RegisterPublicRoutes(mux)
getRequest := httptest.NewRequest(http.MethodGet, "/api/public/galleries/demo-gallery", nil)
getRecorder := httptest.NewRecorder()
mux.ServeHTTP(getRecorder, getRequest)
if getRecorder.Code != http.StatusOK {
t.Fatalf("expected public gallery 200, got %d: %s", getRecorder.Code, getRecorder.Body.String())
}
var gallery Public
if err := json.NewDecoder(getRecorder.Body).Decode(&gallery); err != nil {
t.Fatalf("decode public gallery: %v", err)
}
if gallery.Title != "Emma & James" || len(gallery.Media) != 1 || gallery.Media[0].PreviewURL != "https://example.com/one.jpg" {
t.Fatalf("unexpected public gallery: %+v", gallery)
}
favoriteRequest := httptest.NewRequest(http.MethodPost, "/api/public/galleries/demo-gallery/media/77777777-7777-4777-8777-777777777777/favorite", nil)
for _, cookie := range getRecorder.Result().Cookies() {
favoriteRequest.AddCookie(cookie)
}
favoriteRecorder := httptest.NewRecorder()
mux.ServeHTTP(favoriteRecorder, favoriteRequest)
if favoriteRecorder.Code != http.StatusOK {
t.Fatalf("expected favorite 200, got %d: %s", favoriteRecorder.Code, favoriteRecorder.Body.String())
}
}
+125
View File
@@ -0,0 +1,125 @@
package galleries
import (
"encoding/json"
"strings"
"time"
"github.com/google/uuid"
)
const (
StatusDraft = "draft"
StatusPublished = "published"
StatusArchived = "archived"
)
type GalleryRecord struct {
ID uuid.UUID
UserID uuid.UUID
Slug string
Title string
ClientName string
Description string
Status string
PasswordHash string
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt string
CoverMediaID string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
CreatedAt string
UpdatedAt string
PublishedAt string
}
type Summary struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
CoverURL string `json:"coverUrl,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
PhotoCount int `json:"photoCount"`
VideoCount int `json:"videoCount"`
TotalBytes int64 `json:"totalBytes"`
}
type UpdateInput struct {
Title string
ClientName string
Description string
PasswordHash *string
ClearPassword bool
DownloadsEnabled bool
FavoritesEnabled bool
DownloadAllEnabled bool
WatermarkEnabled bool
ExpiresAt *string
CoverMediaID *string
ThemeConfig json.RawMessage
BrandingConfig json.RawMessage
}
func (g GalleryRecord) IsExpired() bool {
if strings.TrimSpace(g.ExpiresAt) == "" {
return false
}
value := strings.TrimSpace(g.ExpiresAt)
layouts := []string{
time.RFC3339Nano,
"2006-01-02 15:04:05-07:00",
"2006-01-02 15:04:05-07",
"2006-01-02 15:04:05",
}
var parsed time.Time
var err error
for _, layout := range layouts {
parsed, err = time.Parse(layout, value)
if err == nil {
break
}
}
if err != nil {
return false
}
return !time.Now().Before(parsed)
}
func (g GalleryRecord) ToSummary(photoCount, videoCount int, totalBytes int64) Summary {
return Summary{
ID: g.ID,
Slug: g.Slug,
Title: g.Title,
ClientName: g.ClientName,
Description: g.Description,
Status: g.Status,
DownloadsEnabled: g.DownloadsEnabled,
FavoritesEnabled: g.FavoritesEnabled,
DownloadAllEnabled: g.DownloadAllEnabled,
WatermarkEnabled: g.WatermarkEnabled,
ExpiresAt: g.ExpiresAt,
CoverMediaID: g.CoverMediaID,
ThemeConfig: g.ThemeConfig,
BrandingConfig: g.BrandingConfig,
CreatedAt: g.CreatedAt,
PublishedAt: g.PublishedAt,
PhotoCount: photoCount,
VideoCount: videoCount,
TotalBytes: totalBytes,
}
}
+230
View File
@@ -0,0 +1,230 @@
package galleries
import (
"context"
"database/sql"
"encoding/json"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("gallery not found")
func (r *Repository) Create(ctx context.Context, userID uuid.UUID, slug, title, clientName, description string) (GalleryRecord, error) {
id := uuid.New()
_, err := r.db.ExecContext(ctx, `
INSERT INTO galleries (id, user_id, slug, title, client_name, description, status, theme_config, branding_config)
VALUES ($1, $2, $3, $4, $5, $6, 'draft', '{}', '{}')
`, id, userID, slug, title, clientName, description)
if err != nil {
if strings.Contains(strings.ToLower(err.Error()), "unique") {
return GalleryRecord{}, fmt.Errorf("gallery slug already exists: %w", err)
}
return GalleryRecord{}, fmt.Errorf("create gallery: %w", err)
}
return r.GetForUser(ctx, userID, id)
}
func (r *Repository) ListForUser(ctx context.Context, userID uuid.UUID) ([]Summary, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT
g.id, g.slug, g.title, g.client_name, g.description, g.status,
g.downloads_enabled, g.favorites_enabled, g.download_all_enabled,
g.watermark_enabled, g.expires_at, g.cover_media_id,
g.theme_config, g.branding_config, g.created_at, g.published_at,
COUNT(CASE WHEN m.mime_type LIKE 'image/%' THEN 1 END),
COUNT(CASE WHEN m.mime_type LIKE 'video/%' THEN 1 END),
COALESCE(SUM(m.file_size), 0)
FROM galleries g
LEFT JOIN media m ON m.gallery_id = g.id
WHERE g.user_id = $1 AND g.status <> 'archived'
GROUP BY g.id
ORDER BY g.created_at DESC
`, userID)
if err != nil {
return nil, fmt.Errorf("list galleries: %w", err)
}
defer rows.Close()
result := make([]Summary, 0)
for rows.Next() {
var (
item Summary
expiresAt, coverMediaID, createdAt sql.NullString
publishedAt sql.NullString
themeConfig, brandingConfig []byte
)
if err := rows.Scan(
&item.ID, &item.Slug, &item.Title, &item.ClientName, &item.Description, &item.Status,
&item.DownloadsEnabled, &item.FavoritesEnabled, &item.DownloadAllEnabled,
&item.WatermarkEnabled, &expiresAt, &coverMediaID, &themeConfig, &brandingConfig,
&createdAt, &publishedAt, &item.PhotoCount, &item.VideoCount, &item.TotalBytes,
); err != nil {
return nil, fmt.Errorf("scan gallery summary: %w", err)
}
item.ExpiresAt = expiresAt.String
item.CoverMediaID = coverMediaID.String
item.ThemeConfig = nonEmptyJSON(themeConfig)
item.BrandingConfig = nonEmptyJSON(brandingConfig)
item.CreatedAt = createdAt.String
item.PublishedAt = publishedAt.String
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate galleries: %w", err)
}
return result, nil
}
func (r *Repository) GetForUser(ctx context.Context, userID, galleryID uuid.UUID) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE id = $1 AND user_id = $2
`, galleryID, userID)
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (GalleryRecord, error) {
return r.get(ctx, `
SELECT id, user_id, slug, title, client_name, description, status, password_hash,
downloads_enabled, favorites_enabled, download_all_enabled, watermark_enabled,
expires_at, cover_media_id, theme_config, branding_config, created_at, updated_at, published_at
FROM galleries
WHERE slug = $1 AND status = 'published'
`, slug)
}
func (r *Repository) Update(ctx context.Context, userID, galleryID uuid.UUID, input UpdateInput) (GalleryRecord, error) {
var passwordValue any
if input.ClearPassword {
passwordValue = nil
} else if input.PasswordHash != nil {
passwordValue = *input.PasswordHash
} else {
current, err := r.GetForUser(ctx, userID, galleryID)
if err != nil {
return GalleryRecord{}, err
}
passwordValue = current.PasswordHash
}
var expiresValue any
if input.ExpiresAt != nil && strings.TrimSpace(*input.ExpiresAt) != "" {
expiresValue = *input.ExpiresAt
}
var coverValue any
if input.CoverMediaID != nil && strings.TrimSpace(*input.CoverMediaID) != "" {
coverID, err := uuid.Parse(strings.TrimSpace(*input.CoverMediaID))
if err != nil {
return GalleryRecord{}, fmt.Errorf("parse cover media id: %w", err)
}
coverValue = coverID
}
_, err := r.db.ExecContext(ctx, `
UPDATE galleries
SET title = $1, client_name = $2, description = $3,
password_hash = $4, downloads_enabled = $5, favorites_enabled = $6,
download_all_enabled = $7, watermark_enabled = $8, expires_at = $9,
cover_media_id = $10, theme_config = $11, branding_config = $12,
updated_at = CURRENT_TIMESTAMP
WHERE id = $13 AND user_id = $14
`, input.Title, input.ClientName, input.Description, passwordValue, input.DownloadsEnabled,
input.FavoritesEnabled, input.DownloadAllEnabled, input.WatermarkEnabled, expiresValue,
coverValue, jsonValue(input.ThemeConfig), jsonValue(input.BrandingConfig), galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("update gallery: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) SetStatus(ctx context.Context, userID, galleryID uuid.UUID, status string) (GalleryRecord, error) {
var query string
if status == StatusPublished {
query = `UPDATE galleries SET status = $1, published_at = CURRENT_TIMESTAMP, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
} else {
query = `UPDATE galleries SET status = $1, published_at = NULL, updated_at = CURRENT_TIMESTAMP WHERE id = $2 AND user_id = $3`
}
_, err := r.db.ExecContext(ctx, query, status, galleryID, userID)
if err != nil {
return GalleryRecord{}, fmt.Errorf("set gallery status: %w", err)
}
return r.GetForUser(ctx, userID, galleryID)
}
func (r *Repository) Delete(ctx context.Context, userID, galleryID uuid.UUID) error {
result, err := r.db.ExecContext(ctx, `DELETE FROM galleries WHERE id = $1 AND user_id = $2`, galleryID, userID)
if err != nil {
return fmt.Errorf("delete gallery: %w", err)
}
count, err := result.RowsAffected()
if err != nil || count == 0 {
return ErrNotFound
}
return nil
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (GalleryRecord, error) {
return scanGallery(r.db.QueryRowContext(ctx, query, args...))
}
func scanGallery(row rowScanner) (GalleryRecord, error) {
var (
gallery GalleryRecord
passwordHash, expiresAt, coverMediaID sql.NullString
themeConfig, brandingConfig []byte
createdAt, updatedAt, publishedAt sql.NullString
)
err := row.Scan(
&gallery.ID, &gallery.UserID, &gallery.Slug, &gallery.Title, &gallery.ClientName,
&gallery.Description, &gallery.Status, &passwordHash, &gallery.DownloadsEnabled,
&gallery.FavoritesEnabled, &gallery.DownloadAllEnabled, &gallery.WatermarkEnabled,
&expiresAt, &coverMediaID, &themeConfig, &brandingConfig, &createdAt, &updatedAt, &publishedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return GalleryRecord{}, ErrNotFound
}
if err != nil {
return GalleryRecord{}, fmt.Errorf("scan gallery: %w", err)
}
gallery.PasswordHash = passwordHash.String
gallery.ExpiresAt = expiresAt.String
gallery.CoverMediaID = coverMediaID.String
gallery.ThemeConfig = nonEmptyJSON(themeConfig)
gallery.BrandingConfig = nonEmptyJSON(brandingConfig)
gallery.CreatedAt = createdAt.String
gallery.UpdatedAt = updatedAt.String
gallery.PublishedAt = publishedAt.String
return gallery, nil
}
func nonEmptyJSON(value []byte) json.RawMessage {
if len(value) == 0 {
return json.RawMessage(`{}`)
}
return json.RawMessage(value)
}
func jsonValue(value json.RawMessage) string {
if len(value) == 0 {
return `{}`
}
return string(value)
}
@@ -0,0 +1,87 @@
package galleries
import (
"context"
"encoding/json"
"os"
"path/filepath"
"runtime"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
"github.com/google/uuid"
)
func TestRepositoryHandlesSQLiteGallerySchema(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationPath := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite", "003_gallery_platform.sql")
migration, err := os.ReadFile(migrationPath)
if err != nil {
t.Fatalf("read gallery migration: %v", err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply gallery migration: %v", err)
}
userID := uuid.MustParse("55555555-5555-4555-8555-555555555555")
if _, err := database.ExecContext(ctx, `INSERT INTO users (id, email, password_hash, name) VALUES ($1, $2, $3, $4)`, userID, "demo@example.com", "hash", "Northline Studio"); err != nil {
t.Fatalf("insert user: %v", err)
}
repository := NewRepository(database)
record, err := repository.Create(ctx, userID, "emma-james-wedding-12345678", "Emma & James", "Emma & James", "A summer wedding.")
if err != nil {
t.Fatalf("create gallery: %v", err)
}
if record.Status != StatusDraft || record.Title != "Emma & James" {
t.Fatalf("unexpected gallery: %+v", record)
}
mediaID := uuid.MustParse("77777777-7777-4777-8777-777777777777")
if _, err := database.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, 'READY', 0)
`, mediaID, record.ID, "one.jpg", "image/jpeg", 4096, "gallery/one.jpg"); err != nil {
t.Fatalf("insert media: %v", err)
}
updated, err := repository.Update(ctx, userID, record.ID, UpdateInput{
Title: record.Title,
ClientName: record.ClientName,
Description: record.Description,
DownloadsEnabled: true,
FavoritesEnabled: true,
DownloadAllEnabled: true,
ThemeConfig: json.RawMessage(`{"mode":"dark"}`),
BrandingConfig: json.RawMessage(`{"studioName":"Northline"}`),
})
if err != nil {
t.Fatalf("update gallery: %v", err)
}
if string(updated.ThemeConfig) != `{"mode":"dark"}` {
t.Fatalf("theme config was not persisted: %s", updated.ThemeConfig)
}
summaries, err := repository.ListForUser(ctx, userID)
if err != nil {
t.Fatalf("list galleries: %v", err)
}
if len(summaries) != 1 || summaries[0].PhotoCount != 1 || summaries[0].TotalBytes != 4096 {
t.Fatalf("unexpected summary: %+v", summaries)
}
if _, err := repository.SetStatus(ctx, userID, record.ID, StatusPublished); err != nil {
t.Fatalf("publish gallery: %v", err)
}
if _, err := repository.GetPublicBySlug(ctx, record.Slug); err != nil {
t.Fatalf("public gallery lookup: %v", err)
}
}
+45
View File
@@ -0,0 +1,45 @@
package galleries
import (
"encoding/json"
"github.com/example/sndit/backend/internal/media"
)
type Detail struct {
ID string `json:"id"`
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
Status string `json:"status"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
CoverMediaID string `json:"coverMediaId,omitempty"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
CreatedAt string `json:"createdAt"`
PublishedAt string `json:"publishedAt,omitempty"`
Media []media.Public `json:"media"`
}
type Public struct {
Slug string `json:"slug"`
Title string `json:"title"`
ClientName string `json:"clientName"`
Description string `json:"description"`
ThemeConfig json.RawMessage `json:"themeConfig"`
BrandingConfig json.RawMessage `json:"brandingConfig"`
DownloadsEnabled bool `json:"downloadsEnabled"`
FavoritesEnabled bool `json:"favoritesEnabled"`
DownloadAllEnabled bool `json:"downloadAllEnabled"`
WatermarkEnabled bool `json:"watermarkEnabled"`
ExpiresAt string `json:"expiresAt,omitempty"`
Preview bool `json:"preview,omitempty"`
RequiresPassword bool `json:"requiresPassword"`
Cover *media.Public `json:"cover,omitempty"`
Media []media.Public `json:"media"`
}
+73
View File
@@ -0,0 +1,73 @@
package gifts
import (
"encoding/json"
"errors"
"net/http"
"strings"
)
type Handler struct {
service *Service
}
func NewHandler(service *Service) *Handler {
return &Handler{service: service}
}
func (h *Handler) Routes() http.Handler {
mux := http.NewServeMux()
mux.HandleFunc("GET /health", h.Health)
mux.HandleFunc("GET /api/gifts/{slug}", h.GetGift)
return mux
}
func (h *Handler) Health(w http.ResponseWriter, _ *http.Request) {
writeJSON(w, http.StatusOK, map[string]string{"status": "ok"})
}
func (h *Handler) GetGift(w http.ResponseWriter, r *http.Request) {
slug := strings.TrimSpace(r.PathValue("slug"))
if !validSlug(slug) {
writeError(w, http.StatusBadRequest, "invalid gift slug")
return
}
gift, err := h.service.GetPublicGift(r.Context(), slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
writeError(w, http.StatusNotFound, "gift not found")
return
}
writeError(w, http.StatusInternalServerError, "could not load gift")
return
}
writeJSON(w, http.StatusOK, gift)
}
func validSlug(slug string) bool {
if len(slug) == 0 || len(slug) > 100 {
return false
}
for index, character := range slug {
if (character >= 'a' && character <= 'z') ||
(character >= 'A' && character <= 'Z') ||
(character >= '0' && character <= '9') ||
(character == '-' && index > 0 && index < len(slug)-1) {
continue
}
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+125
View File
@@ -0,0 +1,125 @@
package gifts
import (
"context"
"encoding/json"
"errors"
"net/http"
"net/http/httptest"
"testing"
"github.com/google/uuid"
)
type fakeStore struct {
gift PublicGift
err error
}
func (f fakeStore) GetPublicBySlug(context.Context, string) (PublicGift, error) {
return f.gift, f.err
}
func TestGetGiftReturnsPublicRepresentation(t *testing.T) {
giftID := uuid.MustParse("11111111-1111-4111-8111-111111111111")
itemID := uuid.MustParse("22222222-2222-4222-8222-222222222222")
service := NewService(fakeStore{gift: PublicGift{
ID: giftID,
Slug: "demo",
RecipientName: "Anna",
SenderName: "Alex",
Title: "A little surprise for you",
IntroMessage: "I made something for you.",
RevealMessage: "A beautiful final note.",
Items: []PublicGiftItem{{
ID: itemID,
Type: "text",
Title: "A note",
Text: "Hello",
SortOrder: 1,
}},
}})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if contentType := recorder.Header().Get("Content-Type"); contentType != "application/json; charset=utf-8" {
t.Fatalf("unexpected content type: %q", contentType)
}
var response PublicGift
if err := json.NewDecoder(recorder.Body).Decode(&response); err != nil {
t.Fatalf("decode response: %v", err)
}
if response.RecipientName != "Anna" || len(response.Items) != 1 {
t.Fatalf("unexpected response: %+v", response)
}
}
func TestGetGiftReturnsNotFound(t *testing.T) {
service := NewService(fakeStore{err: ErrNotFound})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/missing", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusNotFound {
t.Fatalf("expected 404, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"gift not found\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftHidesStoreErrors(t *testing.T) {
service := NewService(fakeStore{err: errors.New("database connection lost")})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/demo", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusInternalServerError {
t.Fatalf("expected 500, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"error\":\"could not load gift\"}\n" {
t.Fatalf("unexpected error body: %q", body)
}
}
func TestGetGiftRejectsInvalidSlug(t *testing.T) {
service := NewService(fakeStore{})
request := httptest.NewRequest(http.MethodGet, "/api/gifts/not%20a%20slug", nil)
recorder := httptest.NewRecorder()
NewHandler(service).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusBadRequest {
t.Fatalf("expected 400, got %d", recorder.Code)
}
}
func TestHealthReturnsOK(t *testing.T) {
recorder := httptest.NewRecorder()
request := httptest.NewRequest(http.MethodGet, "/health", nil)
NewHandler(NewService(fakeStore{})).Routes().ServeHTTP(recorder, request)
if recorder.Code != http.StatusOK {
t.Fatalf("expected 200, got %d", recorder.Code)
}
if body := recorder.Body.String(); body != "{\"status\":\"ok\"}\n" {
t.Fatalf("unexpected health body: %q", body)
}
}
func TestServiceRejectsEmptySlug(t *testing.T) {
service := NewService(fakeStore{})
_, err := service.GetPublicGift(context.Background(), "")
if err != ErrNotFound {
t.Fatalf("expected ErrNotFound, got %v", err)
}
}
+28
View File
@@ -0,0 +1,28 @@
package gifts
import (
"encoding/json"
"github.com/google/uuid"
)
type PublicGift struct {
ID uuid.UUID `json:"id"`
Slug string `json:"slug"`
RecipientName string `json:"recipientName"`
SenderName string `json:"senderName"`
Title string `json:"title"`
IntroMessage string `json:"introMessage"`
RevealMessage string `json:"revealMessage"`
Items []PublicGiftItem `json:"items"`
}
type PublicGiftItem struct {
ID uuid.UUID `json:"id"`
Type string `json:"type"`
Title string `json:"title,omitempty"`
Text string `json:"text,omitempty"`
MediaURL string `json:"mediaUrl,omitempty"`
SortOrder int `json:"sortOrder"`
Metadata json.RawMessage `json:"metadata,omitempty"`
}
+97
View File
@@ -0,0 +1,97 @@
package gifts
import (
"context"
"database/sql"
"errors"
"fmt"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
func (r *Repository) GetPublicBySlug(ctx context.Context, slug string) (PublicGift, error) {
var gift PublicGift
err := r.db.QueryRowContext(ctx, `
SELECT id, slug, recipient_name, sender_name, title, intro_message, reveal_message
FROM gifts
WHERE slug = $1 AND status = 'published'
`, slug).Scan(
&gift.ID,
&gift.Slug,
&gift.RecipientName,
&gift.SenderName,
&gift.Title,
&gift.IntroMessage,
&gift.RevealMessage,
)
if err != nil {
if errors.Is(err, sql.ErrNoRows) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("find gift: %w", err)
}
rows, err := r.db.QueryContext(ctx, `
SELECT id, type, title, text, media_url, sort_order, metadata
FROM gift_items
WHERE gift_id = $1
ORDER BY sort_order ASC, id ASC
`, gift.ID)
if err != nil {
return PublicGift{}, fmt.Errorf("find gift items: %w", err)
}
defer rows.Close()
gift.Items = make([]PublicGiftItem, 0)
for rows.Next() {
var (
item PublicGiftItem
title sql.NullString
text sql.NullString
mediaURL sql.NullString
metadata []byte
)
if err := rows.Scan(
&item.ID,
&item.Type,
&title,
&text,
&mediaURL,
&item.SortOrder,
&metadata,
); err != nil {
return PublicGift{}, fmt.Errorf("scan gift item: %w", err)
}
item.Title = title.String
item.Text = text.String
item.MediaURL = mediaURL.String
if len(metadata) == 0 {
item.Metadata = []byte(`{}`)
} else {
item.Metadata = metadata
}
gift.Items = append(gift.Items, item)
}
if err := rows.Err(); err != nil {
return PublicGift{}, fmt.Errorf("iterate gift items: %w", err)
}
return gift, nil
}
// Store is the read contract used by the service. Keeping it small makes the
// HTTP layer straightforward to test without a running database.
type Store interface {
GetPublicBySlug(context.Context, string) (PublicGift, error)
}
var _ Store = (*Repository)(nil)
@@ -0,0 +1,57 @@
package gifts
import (
"context"
"os"
"path/filepath"
"runtime"
"sort"
"strings"
"testing"
appdb "github.com/example/sndit/backend/internal/db"
)
func TestRepositoryReadsSQLiteMigrations(t *testing.T) {
ctx := context.Background()
database, err := appdb.New(ctx, "sqlite", ":memory:")
if err != nil {
t.Fatalf("open sqlite database: %v", err)
}
defer database.Close()
_, sourceFile, _, ok := runtime.Caller(0)
if !ok {
t.Fatal("find test source file")
}
migrationDirectory := filepath.Join(filepath.Dir(sourceFile), "..", "..", "..", "migrations", "sqlite")
entries, err := os.ReadDir(migrationDirectory)
if err != nil {
t.Fatalf("read sqlite migrations: %v", err)
}
sort.Slice(entries, func(i, j int) bool { return entries[i].Name() < entries[j].Name() })
for _, entry := range entries {
if entry.IsDir() || !strings.HasSuffix(entry.Name(), ".sql") {
continue
}
migration, err := os.ReadFile(filepath.Join(migrationDirectory, entry.Name()))
if err != nil {
t.Fatalf("read migration %s: %v", entry.Name(), err)
}
if _, err := database.ExecContext(ctx, string(migration)); err != nil {
t.Fatalf("apply migration %s: %v", entry.Name(), err)
}
}
gift, err := NewRepository(database).GetPublicBySlug(ctx, "demo")
if err != nil {
t.Fatalf("load demo gift: %v", err)
}
if gift.RecipientName != "Anna" || gift.SenderName != "Alex" {
t.Fatalf("unexpected gift: %+v", gift)
}
if len(gift.Items) != 3 || gift.Items[0].Type != "image" || gift.Items[1].SortOrder != 2 {
t.Fatalf("unexpected gift items: %+v", gift.Items)
}
}
+33
View File
@@ -0,0 +1,33 @@
package gifts
import (
"context"
"errors"
"fmt"
)
var ErrNotFound = errors.New("gift not found")
type Service struct {
store Store
}
func NewService(store Store) *Service {
return &Service{store: store}
}
func (s *Service) GetPublicGift(ctx context.Context, slug string) (PublicGift, error) {
if slug == "" {
return PublicGift{}, ErrNotFound
}
gift, err := s.store.GetPublicBySlug(ctx, slug)
if err != nil {
if errors.Is(err, ErrNotFound) {
return PublicGift{}, ErrNotFound
}
return PublicGift{}, fmt.Errorf("get public gift: %w", err)
}
return gift, nil
}
+355
View File
@@ -0,0 +1,355 @@
package media
import (
"context"
"encoding/json"
"fmt"
"mime"
"net/http"
"path/filepath"
"strings"
"time"
"github.com/example/sndit/backend/internal/auth"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
const (
maxUploadSize = int64(10 << 30)
uploadURLDuration = 24 * time.Hour
)
type ProcessorQueue interface {
Enqueue(uuid.UUID)
}
type Handler struct {
repository *Repository
storage storage.Storage
processor ProcessorQueue
auth *auth.Service
}
func NewHandler(repository *Repository, objectStorage storage.Storage, processor ProcessorQueue, authService *auth.Service) *Handler {
return &Handler{repository: repository, storage: objectStorage, processor: processor, auth: authService}
}
func (h *Handler) RegisterRoutes(mux *http.ServeMux, require func(http.Handler) http.Handler) {
mux.Handle("GET /api/galleries/{id}/media", require(http.HandlerFunc(h.List)))
mux.Handle("POST /api/galleries/{id}/uploads", require(http.HandlerFunc(h.CreateUpload)))
mux.Handle("POST /api/uploads/{id}/complete", require(http.HandlerFunc(h.CompleteUpload)))
mux.Handle("PATCH /api/media/{id}", require(http.HandlerFunc(h.Update)))
mux.Handle("POST /api/media/{id}/download", require(http.HandlerFunc(h.Download)))
mux.Handle("DELETE /api/uploads/{id}", require(http.HandlerFunc(h.Delete)))
mux.Handle("DELETE /api/media/{id}", require(http.HandlerFunc(h.Delete)))
}
type uploadRequest struct {
Filename string `json:"filename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
}
type updateRequest struct {
SortOrder *int `json:"sortOrder"`
}
func (h *Handler) List(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
items, err := h.repository.ListByGallery(r.Context(), galleryID)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not load media")
return
}
views := make([]Public, 0, len(items))
for _, item := range items {
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URLs")
return
}
views = append(views, view)
}
writeJSON(w, http.StatusOK, map[string]any{"media": views})
}
func (h *Handler) CreateUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
galleryID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid gallery id")
return
}
belongs, err := h.repository.GalleryBelongsToUser(r.Context(), galleryID, user.ID)
if err != nil || !belongs {
writeError(w, http.StatusNotFound, "gallery not found")
return
}
var request uploadRequest
if !decodeJSON(w, r, &request) {
return
}
filename := safeFilename(request.Filename)
mimeType := strings.ToLower(strings.TrimSpace(request.MimeType))
if mimeType == "" {
mimeType = mime.TypeByExtension(filepath.Ext(filename))
}
if filename == "" || len(filename) > 255 || !allowedMimeType(mimeType) {
writeError(w, http.StatusBadRequest, "unsupported media file")
return
}
if request.FileSize <= 0 || request.FileSize > maxUploadSize {
writeError(w, http.StatusBadRequest, "file size must be between 1 byte and 10 GB")
return
}
mediaID := uuid.New()
storageKey := fmt.Sprintf("galleries/%s/%s/original/%s", galleryID, mediaID, filename)
item, err := h.repository.Create(r.Context(), galleryID, mediaID, filename, mimeType, request.FileSize, storageKey)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create upload")
return
}
uploadURL, err := h.storage.CreateUploadURL(r.Context(), storageKey, mimeType, uploadURLDuration)
if err != nil {
_, _ = h.repository.Delete(r.Context(), user.ID, mediaID)
writeError(w, http.StatusInternalServerError, "could not create upload URL")
return
}
writeJSON(w, http.StatusCreated, map[string]any{
"uploadId": mediaID.String(),
"uploadUrl": uploadURL,
"media": publicFromRecord(item),
})
}
func (h *Handler) CompleteUpload(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
info, err := h.storage.Stat(r.Context(), item.StorageKey)
if err != nil {
writeError(w, http.StatusBadRequest, "uploaded object is not available yet")
return
}
item, err = h.repository.Complete(r.Context(), user.ID, mediaID, info.Size)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not complete upload")
return
}
h.processor.Enqueue(item.ID)
writeJSON(w, http.StatusOK, map[string]any{"media": publicFromRecord(item)})
}
func (h *Handler) Update(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
var request updateRequest
if !decodeJSON(w, r, &request) {
return
}
if request.SortOrder == nil || *request.SortOrder < 0 {
writeError(w, http.StatusBadRequest, "sort order must be zero or greater")
return
}
if err := h.repository.UpdateSortOrder(r.Context(), user.ID, mediaID, *request.SortOrder); err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
item, _ := h.repository.GetForUser(r.Context(), user.ID, mediaID)
view, err := h.view(r.Context(), item, true)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not sign media URL")
return
}
writeJSON(w, http.StatusOK, map[string]any{"media": view})
}
func (h *Handler) Delete(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.Delete(r.Context(), user.ID, mediaID)
if err != nil {
writeError(w, http.StatusNotFound, "media not found")
return
}
for _, key := range []string{item.StorageKey, item.PreviewKey, item.ThumbnailKey} {
if key != "" && key != item.ExternalURL {
_ = h.storage.Delete(r.Context(), key)
}
}
w.WriteHeader(http.StatusNoContent)
}
func (h *Handler) Download(w http.ResponseWriter, r *http.Request) {
user, ok := auth.UserFromContext(r.Context())
if !ok {
writeError(w, http.StatusUnauthorized, "authentication required")
return
}
mediaID, err := parseID(r.PathValue("id"))
if err != nil {
writeError(w, http.StatusBadRequest, "invalid media id")
return
}
item, err := h.repository.GetForUser(r.Context(), user.ID, mediaID)
if err != nil || item.ProcessingStatus != StatusReady {
writeError(w, http.StatusNotFound, "media not found")
return
}
url := item.ExternalURL
if url == "" {
url, err = h.storage.CreateDownloadURL(r.Context(), item.StorageKey, time.Hour)
if err != nil {
writeError(w, http.StatusInternalServerError, "could not create download")
return
}
}
_ = h.repository.RecordDownload(r.Context(), item.GalleryID, &item.ID, user.ID.String())
writeJSON(w, http.StatusOK, map[string]string{"url": url})
}
func (h *Handler) view(ctx context.Context, item Record, includeOriginal bool) (Public, error) {
view := publicFromRecord(item)
if item.ProcessingStatus != StatusReady {
return view, nil
}
if item.ExternalURL != "" {
view.ThumbnailURL = item.ExternalURL
view.PreviewURL = item.ExternalURL
if includeOriginal {
view.OriginalURL = item.ExternalURL
}
return view, nil
}
previewKey := item.PreviewKey
if previewKey == "" {
previewKey = item.StorageKey
}
thumbnailKey := item.ThumbnailKey
if thumbnailKey == "" {
thumbnailKey = previewKey
}
var err error
view.ThumbnailURL, err = h.storage.CreateDownloadURL(ctx, thumbnailKey, time.Hour)
if err != nil {
return Public{}, err
}
view.PreviewURL, err = h.storage.CreateDownloadURL(ctx, previewKey, time.Hour)
if err != nil {
return Public{}, err
}
if includeOriginal {
view.OriginalURL, err = h.storage.CreateDownloadURL(ctx, item.StorageKey, time.Hour)
if err != nil {
return Public{}, err
}
}
return view, nil
}
func publicFromRecord(item Record) Public {
return Public{
ID: item.ID,
OriginalFilename: item.OriginalFilename,
MimeType: item.MimeType,
FileSize: item.FileSize,
ProcessingStatus: item.ProcessingStatus,
Width: item.Width,
Height: item.Height,
DurationSeconds: item.DurationSeconds,
SortOrder: item.SortOrder,
}
}
func allowedMimeType(value string) bool {
switch value {
case "image/jpeg", "image/png", "image/webp", "image/heic", "image/heif", "video/mp4", "video/quicktime", "video/webm":
return true
default:
return false
}
}
func safeFilename(value string) string {
value = strings.ReplaceAll(value, "\\", "/")
value = filepath.Base(value)
if value == "." || value == ".." {
return ""
}
return strings.TrimSpace(value)
}
func parseID(value string) (uuid.UUID, error) {
return uuid.Parse(value)
}
func decodeJSON(w http.ResponseWriter, r *http.Request, target any) bool {
if !strings.HasPrefix(r.Header.Get("Content-Type"), "application/json") {
writeError(w, http.StatusUnsupportedMediaType, "content type must be application/json")
return false
}
decoder := json.NewDecoder(http.MaxBytesReader(w, r.Body, 1<<20))
decoder.DisallowUnknownFields()
if err := decoder.Decode(target); err != nil {
writeError(w, http.StatusBadRequest, "invalid JSON body")
return false
}
return true
}
func writeError(w http.ResponseWriter, status int, message string) {
writeJSON(w, status, map[string]string{"error": message})
}
func writeJSON(w http.ResponseWriter, status int, value any) {
w.Header().Set("Content-Type", "application/json; charset=utf-8")
w.WriteHeader(status)
_ = json.NewEncoder(w).Encode(value)
}
+46
View File
@@ -0,0 +1,46 @@
package media
import "github.com/google/uuid"
const (
StatusUploading = "UPLOADING"
StatusProcessing = "PROCESSING"
StatusReady = "READY"
StatusFailed = "FAILED"
)
type Record struct {
ID uuid.UUID
GalleryID uuid.UUID
OriginalFilename string
MimeType string
FileSize int64
StorageKey string
ExternalURL string
ThumbnailKey string
PreviewKey string
ProcessingStatus string
ProcessingError string
Width int
Height int
DurationSeconds float64
SortOrder int
CreatedAt string
UpdatedAt string
}
type Public struct {
ID uuid.UUID `json:"id"`
OriginalFilename string `json:"originalFilename"`
MimeType string `json:"mimeType"`
FileSize int64 `json:"fileSize"`
ProcessingStatus string `json:"processingStatus"`
Width int `json:"width,omitempty"`
Height int `json:"height,omitempty"`
DurationSeconds float64 `json:"durationSeconds,omitempty"`
SortOrder int `json:"sortOrder"`
ThumbnailURL string `json:"thumbnailUrl,omitempty"`
PreviewURL string `json:"previewUrl,omitempty"`
OriginalURL string `json:"originalUrl,omitempty"`
Favorited bool `json:"favorited"`
}
+154
View File
@@ -0,0 +1,154 @@
package media
import (
"bytes"
"context"
"fmt"
"image"
"image/jpeg"
_ "image/png"
"io"
"sync"
"github.com/example/sndit/backend/internal/storage"
"github.com/google/uuid"
)
type Processor struct {
repository *Repository
storage storage.Storage
jobs chan uuid.UUID
stop chan struct{}
waitGroup sync.WaitGroup
}
func NewProcessor(repository *Repository, objectStorage storage.Storage, workers int) *Processor {
if workers < 1 {
workers = 1
}
processor := &Processor{
repository: repository,
storage: objectStorage,
jobs: make(chan uuid.UUID, 256),
stop: make(chan struct{}),
}
for index := 0; index < workers; index++ {
processor.waitGroup.Add(1)
go processor.worker()
}
return processor
}
func (p *Processor) Enqueue(mediaID uuid.UUID) {
select {
case p.jobs <- mediaID:
case <-p.stop:
}
}
func (p *Processor) Close() {
close(p.stop)
p.waitGroup.Wait()
}
func (p *Processor) worker() {
defer p.waitGroup.Done()
for {
select {
case mediaID := <-p.jobs:
p.process(mediaID)
case <-p.stop:
return
}
}
}
func (p *Processor) process(mediaID uuid.UUID) {
ctx := context.Background()
item, err := p.repository.GetByID(ctx, mediaID)
if err != nil {
return
}
if item.ExternalURL != "" || !IsImage(item) {
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, item.Width, item.Height)
return
}
object, err := p.storage.Get(ctx, item.StorageKey)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
defer object.Close()
decoded, _, err := image.Decode(io.LimitReader(object, 100<<20))
if err != nil {
// Formats without a stdlib decoder, such as HEIC, remain usable through
// the original object until a dedicated processing service is added.
_ = p.repository.MarkReady(ctx, mediaID, item.StorageKey, item.StorageKey, 0, 0)
return
}
width := decoded.Bounds().Dx()
height := decoded.Bounds().Dy()
previewKey := variantKey(item, "preview.jpg")
thumbnailKey := variantKey(item, "thumbnail.jpg")
preview, err := encodeJPEG(resize(decoded, 2400), 88)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
thumbnail, err := encodeJPEG(resize(decoded, 640), 84)
if err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, previewKey, bytes.NewReader(preview), int64(len(preview)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.storage.Put(ctx, thumbnailKey, bytes.NewReader(thumbnail), int64(len(thumbnail)), "image/jpeg"); err != nil {
_ = p.repository.MarkFailed(ctx, mediaID, err.Error())
return
}
if err := p.repository.MarkReady(ctx, mediaID, previewKey, thumbnailKey, width, height); err != nil {
return
}
}
func variantKey(item Record, filename string) string {
return fmt.Sprintf("galleries/%s/%s/%s", item.GalleryID, item.ID, filename)
}
func resize(source image.Image, maxSide int) image.Image {
bounds := source.Bounds()
width, height := bounds.Dx(), bounds.Dy()
if width <= maxSide && height <= maxSide {
return source
}
scale := float64(maxSide) / float64(width)
if height > width {
scale = float64(maxSide) / float64(height)
}
newWidth := int(float64(width) * scale)
newHeight := int(float64(height) * scale)
destination := image.NewRGBA(image.Rect(0, 0, newWidth, newHeight))
for y := 0; y < newHeight; y++ {
for x := 0; x < newWidth; x++ {
sourceX := bounds.Min.X + x*width/newWidth
sourceY := bounds.Min.Y + y*height/newHeight
destination.Set(x, y, source.At(sourceX, sourceY))
}
}
return destination
}
func encodeJPEG(source image.Image, quality int) ([]byte, error) {
var output bytes.Buffer
if err := jpeg.Encode(&output, source, &jpeg.Options{Quality: quality}); err != nil {
return nil, fmt.Errorf("encode preview: %w", err)
}
return output.Bytes(), nil
}
+225
View File
@@ -0,0 +1,225 @@
package media
import (
"context"
"database/sql"
"errors"
"fmt"
"strings"
"github.com/google/uuid"
)
type Repository struct {
db *sql.DB
}
func NewRepository(db *sql.DB) *Repository {
return &Repository{db: db}
}
var ErrNotFound = errors.New("media not found")
func (r *Repository) Create(ctx context.Context, galleryID, id uuid.UUID, filename, mimeType string, fileSize int64, storageKey string) (Record, error) {
_, err := r.db.ExecContext(ctx, `
INSERT INTO media (id, gallery_id, original_filename, mime_type, file_size, storage_key, processing_status, sort_order)
VALUES ($1, $2, $3, $4, $5, $6, $7, COALESCE((SELECT MAX(sort_order) + 1 FROM media WHERE gallery_id = $2), 0))
`, id, galleryID, filename, mimeType, fileSize, storageKey, StatusUploading)
if err != nil {
return Record{}, fmt.Errorf("create media: %w", err)
}
return r.GetByID(ctx, id)
}
func (r *Repository) GalleryBelongsToUser(ctx context.Context, galleryID, userID uuid.UUID) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM galleries WHERE id = $1 AND user_id = $2 AND status <> 'archived')
`, galleryID, userID).Scan(&exists)
return exists, err
}
func (r *Repository) GetByID(ctx context.Context, id uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE id = $1
`, id)
}
func (r *Repository) GetForUser(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
return r.get(ctx, `
SELECT m.id, m.gallery_id, m.original_filename, m.mime_type, m.file_size, m.storage_key,
m.external_url, m.thumbnail_key, m.preview_key, m.processing_status, m.processing_error,
m.width, m.height, m.duration_seconds, m.sort_order, m.created_at, m.updated_at
FROM media m
JOIN galleries g ON g.id = m.gallery_id
WHERE m.id = $1 AND g.user_id = $2
`, mediaID, userID)
}
func (r *Repository) ListByGallery(ctx context.Context, galleryID uuid.UUID) ([]Record, error) {
rows, err := r.db.QueryContext(ctx, `
SELECT id, gallery_id, original_filename, mime_type, file_size, storage_key,
external_url, thumbnail_key, preview_key, processing_status, processing_error,
width, height, duration_seconds, sort_order, created_at, updated_at
FROM media
WHERE gallery_id = $1
ORDER BY sort_order ASC, id ASC
`, galleryID)
if err != nil {
return nil, fmt.Errorf("list media: %w", err)
}
defer rows.Close()
result := make([]Record, 0)
for rows.Next() {
item, err := scanMedia(rows)
if err != nil {
return nil, err
}
result = append(result, item)
}
if err := rows.Err(); err != nil {
return nil, fmt.Errorf("iterate media: %w", err)
}
return result, nil
}
func (r *Repository) Complete(ctx context.Context, userID, mediaID uuid.UUID, fileSize int64) (Record, error) {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET file_size = $1, processing_status = $2, processing_error = NULL, updated_at = CURRENT_TIMESTAMP
WHERE id = $3 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $4)
`, fileSize, StatusProcessing, mediaID, userID)
if err != nil {
return Record{}, fmt.Errorf("complete media upload: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return Record{}, ErrNotFound
}
return r.GetByID(ctx, mediaID)
}
func (r *Repository) MarkReady(ctx context.Context, mediaID uuid.UUID, previewKey, thumbnailKey string, width, height int) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = NULL, preview_key = $2, thumbnail_key = $3,
width = $4, height = $5, updated_at = CURRENT_TIMESTAMP
WHERE id = $6
`, StatusReady, previewKey, thumbnailKey, width, height, mediaID)
return err
}
func (r *Repository) MarkFailed(ctx context.Context, mediaID uuid.UUID, message string) error {
_, err := r.db.ExecContext(ctx, `
UPDATE media
SET processing_status = $1, processing_error = $2, updated_at = CURRENT_TIMESTAMP
WHERE id = $3
`, StatusFailed, message, mediaID)
return err
}
func (r *Repository) UpdateSortOrder(ctx context.Context, userID, mediaID uuid.UUID, sortOrder int) error {
result, err := r.db.ExecContext(ctx, `
UPDATE media
SET sort_order = $1, updated_at = CURRENT_TIMESTAMP
WHERE id = $2 AND gallery_id IN (SELECT id FROM galleries WHERE user_id = $3)
`, sortOrder, mediaID, userID)
if err != nil {
return fmt.Errorf("update media order: %w", err)
}
if count, _ := result.RowsAffected(); count == 0 {
return ErrNotFound
}
return nil
}
func (r *Repository) Delete(ctx context.Context, userID, mediaID uuid.UUID) (Record, error) {
item, err := r.GetForUser(ctx, userID, mediaID)
if err != nil {
return Record{}, err
}
if _, err := r.db.ExecContext(ctx, `DELETE FROM media WHERE id = $1`, mediaID); err != nil {
return Record{}, fmt.Errorf("delete media: %w", err)
}
return item, nil
}
func (r *Repository) IsFavorited(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string) (bool, error) {
var exists bool
err := r.db.QueryRowContext(ctx, `
SELECT EXISTS (SELECT 1 FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3)
`, galleryID, mediaID, visitorID).Scan(&exists)
return exists, err
}
func (r *Repository) SetFavorite(ctx context.Context, galleryID, mediaID uuid.UUID, visitorID string, favorited bool) error {
if favorited {
_, err := r.db.ExecContext(ctx, `
INSERT INTO favorites (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
ON CONFLICT (gallery_id, media_id, visitor_id) DO NOTHING
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
_, err := r.db.ExecContext(ctx, `DELETE FROM favorites WHERE gallery_id = $1 AND media_id = $2 AND visitor_id = $3`, galleryID, mediaID, visitorID)
return err
}
func (r *Repository) RecordDownload(ctx context.Context, galleryID uuid.UUID, mediaID *uuid.UUID, visitorID string) error {
_, err := r.db.ExecContext(ctx, `
INSERT INTO downloads (id, gallery_id, media_id, visitor_id)
VALUES ($1, $2, $3, $4)
`, uuid.New(), galleryID, mediaID, visitorID)
return err
}
type rowScanner interface {
Scan(...any) error
}
func (r *Repository) get(ctx context.Context, query string, args ...any) (Record, error) {
return scanMedia(r.db.QueryRowContext(ctx, query, args...))
}
func scanMedia(row rowScanner) (Record, error) {
var (
item Record
externalURL, thumbnailKey, previewKey, processingError sql.NullString
width, height sql.NullInt64
duration sql.NullFloat64
createdAt, updatedAt sql.NullString
)
err := row.Scan(
&item.ID, &item.GalleryID, &item.OriginalFilename, &item.MimeType, &item.FileSize,
&item.StorageKey, &externalURL, &thumbnailKey, &previewKey, &item.ProcessingStatus,
&processingError, &width, &height, &duration, &item.SortOrder, &createdAt, &updatedAt,
)
if errors.Is(err, sql.ErrNoRows) {
return Record{}, ErrNotFound
}
if err != nil {
return Record{}, fmt.Errorf("scan media: %w", err)
}
item.ExternalURL = externalURL.String
item.ThumbnailKey = thumbnailKey.String
item.PreviewKey = previewKey.String
item.ProcessingError = processingError.String
item.Width = int(width.Int64)
item.Height = int(height.Int64)
item.DurationSeconds = duration.Float64
item.CreatedAt = createdAt.String
item.UpdatedAt = updatedAt.String
return item, nil
}
func IsImage(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "image/")
}
func IsVideo(item Record) bool {
return strings.HasPrefix(strings.ToLower(item.MimeType), "video/")
}
+143
View File
@@ -0,0 +1,143 @@
package storage
import (
"context"
"fmt"
"io"
"strings"
"time"
"github.com/minio/minio-go/v7"
"github.com/minio/minio-go/v7/pkg/cors"
"github.com/minio/minio-go/v7/pkg/credentials"
)
type Config struct {
Endpoint string
AccessKey string
SecretKey string
Bucket string
UseSSL bool
CORSOrigins string
}
type ObjectInfo struct {
Size int64
ContentType string
}
// Storage is deliberately S3-shaped so the MinIO implementation can be
// replaced by AWS S3, R2, B2, or another compatible provider later.
type Storage interface {
EnsureBucket(context.Context) error
CreateUploadURL(context.Context, string, string, time.Duration) (string, error)
CreateDownloadURL(context.Context, string, time.Duration) (string, error)
Delete(context.Context, string) error
Stat(context.Context, string) (ObjectInfo, error)
Get(context.Context, string) (io.ReadCloser, error)
Put(context.Context, string, io.Reader, int64, string) error
}
type MinIO struct {
client *minio.Client
bucket string
corsOrigins string
}
func NewMinIO(config Config) (*MinIO, error) {
client, err := minio.New(config.Endpoint, &minio.Options{
Creds: credentials.NewStaticV4(config.AccessKey, config.SecretKey, ""),
Secure: config.UseSSL,
})
if err != nil {
return nil, fmt.Errorf("create object storage client: %w", err)
}
if config.Bucket == "" {
return nil, fmt.Errorf("object storage bucket is required")
}
return &MinIO{client: client, bucket: config.Bucket, corsOrigins: config.CORSOrigins}, nil
}
func (s *MinIO) EnsureBucket(ctx context.Context) error {
exists, err := s.client.BucketExists(ctx, s.bucket)
if err != nil {
return fmt.Errorf("check object storage bucket: %w", err)
}
if exists {
return nil
}
if err := s.client.MakeBucket(ctx, s.bucket, minio.MakeBucketOptions{}); err != nil {
response := minio.ToErrorResponse(err)
if response.Code != "BucketAlreadyExists" && response.Code != "BucketAlreadyOwnedByYou" {
return fmt.Errorf("create object storage bucket: %w", err)
}
}
origins := make([]string, 0)
for _, origin := range strings.Split(s.corsOrigins, ",") {
if value := strings.TrimSpace(origin); value != "" {
origins = append(origins, value)
}
}
if len(origins) == 0 {
origins = []string{"*"}
}
if err := s.client.SetBucketCors(ctx, s.bucket, cors.NewConfig([]cors.Rule{{
ID: "northline-browser-uploads",
AllowedOrigin: origins,
AllowedMethod: []string{"GET", "PUT", "POST", "PATCH", "DELETE", "HEAD"},
AllowedHeader: []string{"*"},
ExposeHeader: []string{"ETag", "x-amz-request-id", "x-amz-id-2"},
MaxAgeSeconds: 3600,
}})); err != nil {
return fmt.Errorf("configure object storage CORS: %w", err)
}
return nil
}
func (s *MinIO) CreateUploadURL(ctx context.Context, key, _ string, expiry time.Duration) (string, error) {
url, err := s.client.PresignedPutObject(ctx, s.bucket, key, expiry)
if err != nil {
return "", fmt.Errorf("create upload URL: %w", err)
}
return url.String(), nil
}
func (s *MinIO) CreateDownloadURL(ctx context.Context, key string, expiry time.Duration) (string, error) {
url, err := s.client.PresignedGetObject(ctx, s.bucket, key, expiry, nil)
if err != nil {
return "", fmt.Errorf("create download URL: %w", err)
}
return url.String(), nil
}
func (s *MinIO) Delete(ctx context.Context, key string) error {
if err := s.client.RemoveObject(ctx, s.bucket, key, minio.RemoveObjectOptions{}); err != nil {
return fmt.Errorf("delete object: %w", err)
}
return nil
}
func (s *MinIO) Stat(ctx context.Context, key string) (ObjectInfo, error) {
info, err := s.client.StatObject(ctx, s.bucket, key, minio.StatObjectOptions{})
if err != nil {
return ObjectInfo{}, fmt.Errorf("stat object: %w", err)
}
return ObjectInfo{Size: info.Size, ContentType: info.ContentType}, nil
}
func (s *MinIO) Get(ctx context.Context, key string) (io.ReadCloser, error) {
object, err := s.client.GetObject(ctx, s.bucket, key, minio.GetObjectOptions{})
if err != nil {
return nil, fmt.Errorf("get object: %w", err)
}
return object, nil
}
func (s *MinIO) Put(ctx context.Context, key string, reader io.Reader, size int64, contentType string) error {
if _, err := s.client.PutObject(ctx, s.bucket, key, reader, size, minio.PutObjectOptions{ContentType: contentType}); err != nil {
return fmt.Errorf("put object: %w", err)
}
return nil
}
var _ Storage = (*MinIO)(nil)
+41
View File
@@ -0,0 +1,41 @@
services:
postgres:
image: postgres:16-alpine
restart: unless-stopped
environment:
POSTGRES_DB: ${POSTGRES_DB:-surprise}
POSTGRES_USER: ${POSTGRES_USER:-surprise}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:-surprise_dev_password}
ports:
- "${POSTGRES_PORT:-5432}:5432"
volumes:
- postgres_data:/var/lib/postgresql/data
- ./migrations:/migrations:ro
healthcheck:
test: ["CMD-SHELL", "pg_isready -U $${POSTGRES_USER} -d $${POSTGRES_DB}"]
interval: 5s
timeout: 5s
retries: 10
minio:
image: minio/minio:latest
restart: unless-stopped
command: server /data --console-address ":9001"
environment:
MINIO_ROOT_USER: ${STORAGE_ACCESS_KEY:-minioadmin}
MINIO_ROOT_PASSWORD: ${STORAGE_SECRET_KEY:-minioadmin}
MINIO_API_CORS_ALLOW_ORIGIN: ${CORS_ORIGIN:-http://localhost:5173}
ports:
- "${MINIO_API_PORT:-9000}:9000"
- "${MINIO_CONSOLE_PORT:-9001}:9001"
volumes:
- minio_data:/data
healthcheck:
test: ["CMD-SHELL", "curl -f http://localhost:9000/minio/health/live || exit 1"]
interval: 5s
timeout: 5s
retries: 10
volumes:
postgres_data:
minio_data:
+2
View File
@@ -0,0 +1,2 @@
dist
node_modules
+6
View File
@@ -0,0 +1,6 @@
{
"semi": true,
"singleQuote": true,
"trailingComma": "all",
"printWidth": 100
}
+27
View File
@@ -0,0 +1,27 @@
import js from '@eslint/js';
import globals from 'globals';
import reactHooks from 'eslint-plugin-react-hooks';
import reactRefresh from 'eslint-plugin-react-refresh';
import tseslint from 'typescript-eslint';
export default tseslint.config(
{ ignores: ['dist', 'node_modules'] },
js.configs.recommended,
...tseslint.configs.recommended,
{
files: ['**/*.{ts,tsx}'],
languageOptions: {
ecmaVersion: 2022,
globals: { ...globals.browser, ...globals.node },
parserOptions: { ecmaFeatures: { jsx: true } },
},
plugins: {
'react-hooks': reactHooks,
'react-refresh': reactRefresh,
},
rules: {
...reactHooks.configs.recommended.rules,
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
},
},
);
+17
View File
@@ -0,0 +1,17 @@
<!doctype html>
<html lang="en">
<head>
<meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0, viewport-fit=cover" />
<meta name="theme-color" content="#17131b" />
<meta
name="description"
content="Beautiful private galleries for photographers and their clients."
/>
<title>Northline Delivery Studio</title>
</head>
<body>
<div id="root"></div>
<script type="module" src="/src/main.tsx"></script>
</body>
</html>
+4336
View File
File diff suppressed because it is too large Load Diff
+41
View File
@@ -0,0 +1,41 @@
{
"name": "little-something-frontend",
"private": true,
"version": "0.1.0",
"type": "module",
"engines": {
"node": ">=20.19.0"
},
"scripts": {
"dev": "vite",
"build": "tsc -b && vite build",
"lint": "eslint .",
"format": "prettier --write .",
"format:check": "prettier --check .",
"preview": "vite preview"
},
"dependencies": {
"framer-motion": "^12.23.12",
"react": "^19.1.1",
"react-dom": "^19.1.1",
"react-router-dom": "^7.8.2"
},
"devDependencies": {
"@eslint/js": "^9.33.0",
"@types/node": "^24.3.0",
"@types/react": "^19.1.10",
"@types/react-dom": "^19.1.7",
"@vitejs/plugin-react": "^5.0.2",
"autoprefixer": "^10.4.21",
"eslint": "^9.33.0",
"eslint-plugin-react-hooks": "^5.2.0",
"eslint-plugin-react-refresh": "^0.4.20",
"globals": "^16.3.0",
"postcss": "^8.5.6",
"prettier": "^3.6.2",
"tailwindcss": "^3.4.17",
"typescript": "~5.9.2",
"typescript-eslint": "^8.39.0",
"vite": "^7.1.3"
}
}
+6
View File
@@ -0,0 +1,6 @@
export default {
plugins: {
tailwindcss: {},
autoprefixer: {},
},
};
+99
View File
@@ -0,0 +1,99 @@
import { BrowserRouter, Route, Routes } from 'react-router-dom';
import { RequireAuth } from './components/app/RequireAuth';
import { AuthProvider } from './features/auth/AuthContext';
import GalleryPreviewPage from './pages/GalleryPreviewPage';
import LoginPage from './pages/LoginPage';
import PublicGalleryPage from './pages/PublicGalleryPage';
import RegisterPage from './pages/RegisterPage';
import DashboardPage from './pages/dashboard/DashboardPage';
import GalleriesPage from './pages/dashboard/GalleriesPage';
import GalleryEditorPage from './pages/dashboard/GalleryEditorPage';
import PlaceholderPage from './pages/dashboard/PlaceholderPage';
import DevPage from './pages/dashboard/DevPage';
import NotFoundPage from './pages/NotFoundPage';
export default function App() {
return (
<BrowserRouter>
<AuthProvider>
<Routes>
<Route path="/g/:slug" element={<PublicGalleryPage />} />
<Route path="/login" element={<LoginPage />} />
<Route path="/register" element={<RegisterPage />} />
<Route
path="/preview/:id"
element={
<RequireAuth>
<GalleryPreviewPage />
</RequireAuth>
}
/>
<Route
path="/dashboard"
element={
<RequireAuth>
<DashboardPage />
</RequireAuth>
}
/>
<Route
path="/dashboard/galleries"
element={
<RequireAuth>
<GalleriesPage />
</RequireAuth>
}
/>
<Route
path="/dashboard/galleries/new"
element={
<RequireAuth>
<GalleryEditorPage />
</RequireAuth>
}
/>
<Route
path="/dashboard/galleries/:id/edit"
element={
<RequireAuth>
<GalleryEditorPage />
</RequireAuth>
}
/>
<Route
path="/dashboard/storage"
element={
<RequireAuth>
<PlaceholderPage
title="Storage"
description="A calm view of every original, preview, and byte in your studio is on its way."
/>
</RequireAuth>
}
/>
<Route
path="/dashboard/settings"
element={
<RequireAuth>
<PlaceholderPage
title="Settings"
description="Your studio identity and delivery defaults will have a home here soon."
/>
</RequireAuth>
}
/>
<Route
path="/dashboard/dev"
element={
<RequireAuth>
<DevPage />
</RequireAuth>
}
/>
<Route path="*" element={<NotFoundPage />} />
</Routes>
</AuthProvider>
</BrowserRouter>
);
}
@@ -0,0 +1,8 @@
export function AppLoading() {
return (
<div className="app-loading">
<span className="app-loading__mark">N</span>
<span className="app-loading__line" aria-hidden="true" />
</div>
);
}
@@ -0,0 +1,23 @@
import type { ReactNode } from 'react';
export function AuthLayout({ children }: { children: ReactNode }) {
return (
<div className="auth-shell">
<div className="auth-shell__texture" aria-hidden="true" />
<div className="auth-shell__brand">
<span className="platform-mark">N</span>
<span>Northline</span>
</div>
<div className="auth-shell__aside">
<p className="platform-kicker">The work deserves a beautiful handoff.</p>
<h1 className="platform-display">
Deliver the
<br />
<em>feeling.</em>
</h1>
<p>Private galleries for the photographs people keep forever.</p>
</div>
<div className="auth-shell__panel">{children}</div>
</div>
);
}
@@ -0,0 +1,18 @@
import type { ReactNode } from 'react';
import { Navigate, useLocation } from 'react-router-dom';
import { AppLoading } from './AppLoading';
import { useAuth } from '../../features/auth/useAuth';
export function RequireAuth({ children }: { children: ReactNode }) {
const { user, loading } = useAuth();
const location = useLocation();
if (loading) {
return <AppLoading />;
}
if (!user) {
return <Navigate to="/login" replace state={{ from: location.pathname }} />;
}
return children;
}
@@ -0,0 +1,90 @@
import { NavLink, Link } from 'react-router-dom';
import type { ReactNode } from 'react';
import { useAuth } from '../../features/auth/useAuth';
const navItems = [
{ label: 'Overview', path: '/dashboard', end: true },
{ label: 'Galleries', path: '/dashboard/galleries', end: false },
{ label: 'Storage', path: '/dashboard/storage', end: false },
{ label: 'Settings', path: '/dashboard/settings', end: false },
{ label: 'Dev', path: '/dashboard/dev', end: false },
];
export function DashboardLayout({ children }: { children: ReactNode }) {
const { user, signOut } = useAuth();
const initials = user?.name
.split(' ')
.map((part) => part[0])
.join('')
.slice(0, 2)
.toUpperCase();
return (
<div className="studio-shell">
<aside className="studio-sidebar">
<Link className="studio-logo" to="/dashboard">
<span className="studio-logo__mark">N</span>
<span>
Northline
<small>delivery studio</small>
</span>
</Link>
<div className="studio-sidebar__label">Workspace</div>
<nav className="studio-nav" aria-label="Main navigation">
{navItems.map((item) => (
<NavLink
className={({ isActive }) => `studio-nav__item ${isActive ? 'is-active' : ''}`}
end={item.end}
key={item.path}
to={item.path}
>
<span className="studio-nav__dot" aria-hidden="true" />
{item.label}
</NavLink>
))}
</nav>
<div className="studio-sidebar__bottom">
<div className="studio-sidebar__note">
<span className="studio-sidebar__note-mark">+</span>
<span>
<strong>Make it memorable.</strong>
<small>Your work deserves a proper handoff.</small>
</span>
</div>
<div className="studio-account">
<span className="studio-account__avatar">{initials || 'N'}</span>
<span className="studio-account__details">
<strong>{user?.name}</strong>
<small>{user?.email}</small>
</span>
<button
type="button"
onClick={() => void signOut()}
aria-label="Sign out"
className="studio-account__logout"
>
&#8599;
</button>
</div>
</div>
</aside>
<div className="studio-main">
<div className="studio-mobilebar">
<Link className="studio-logo" to="/dashboard">
<span className="studio-logo__mark">N</span>
<span>Northline</span>
</Link>
<button
type="button"
className="studio-mobilebar__account"
onClick={() => void signOut()}
>
{initials || 'N'}
</button>
</div>
{children}
</div>
</div>
);
}
@@ -0,0 +1,84 @@
import { useState } from 'react';
import { Link } from 'react-router-dom';
import type { GallerySummary } from '../../types/gallery';
import { formatBytes, formatDate } from '../../lib/format';
interface GalleryCardProps {
gallery: GallerySummary;
onDelete: (gallery: GallerySummary) => void;
}
export function GalleryCard({ gallery, onDelete }: GalleryCardProps) {
const [copied, setCopied] = useState(false);
const clientURL = `${window.location.origin}/g/${gallery.slug}`;
async function copyLink() {
try {
await navigator.clipboard.writeText(clientURL);
setCopied(true);
window.setTimeout(() => setCopied(false), 1800);
} catch {
setCopied(false);
}
}
return (
<article className="gallery-card">
<Link className="gallery-card__cover" to={`/dashboard/galleries/${gallery.id}/edit`}>
{gallery.coverUrl ? (
<img src={gallery.coverUrl} alt="" loading="lazy" />
) : (
<div className="gallery-card__cover-placeholder" aria-hidden="true">
<span>{gallery.clientName.slice(0, 1).toUpperCase()}</span>
</div>
)}
<span className={`status-pill status-pill--${gallery.status}`}>
<i aria-hidden="true" /> {gallery.status}
</span>
<span className="gallery-card__cover-arrow" aria-hidden="true">
&#8599;
</span>
</Link>
<div className="gallery-card__body">
<div className="gallery-card__heading">
<div>
<p>{gallery.clientName}</p>
<h3>{gallery.title}</h3>
</div>
<span className="gallery-card__date">{formatDate(gallery.createdAt)}</span>
</div>
<div className="gallery-card__meta">
<span>{gallery.photoCount} photos</span>
<span>{gallery.videoCount} videos</span>
<span>{formatBytes(gallery.totalBytes)}</span>
</div>
<div className="gallery-card__actions">
<Link className="text-action" to={`/dashboard/galleries/${gallery.id}/edit`}>
Edit <span aria-hidden="true">&#8599;</span>
</Link>
<Link className="text-action text-action--muted" to={`/preview/${gallery.id}`}>
Preview
</Link>
{gallery.status === 'published' && (
<button
className="text-action text-action--muted"
type="button"
onClick={() => void copyLink()}
>
{copied ? 'Copied' : 'Copy link'}
</button>
)}
<button
className="gallery-card__delete"
type="button"
onClick={() => onDelete(gallery)}
aria-label={`Delete ${gallery.title}`}
>
&#215;
</button>
</div>
</div>
</article>
);
}
@@ -0,0 +1,19 @@
interface StudioStatProps {
label: string;
value: string;
note: string;
accent?: 'coral' | 'violet' | 'gold' | 'ink';
}
export function StudioStat({ label, value, note, accent = 'coral' }: StudioStatProps) {
return (
<div className={`studio-stat studio-stat--${accent}`}>
<div className="studio-stat__topline">
<span>{label}</span>
<i aria-hidden="true" />
</div>
<strong>{value}</strong>
<small>{note}</small>
</div>
);
}
@@ -0,0 +1,180 @@
import { useRef, useState } from 'react';
import { completeUpload, createUpload, uploadToStorage } from '../../lib/api';
import { formatBytes } from '../../lib/format';
import type { MediaItem } from '../../types/gallery';
type UploadStatus = 'queued' | 'uploading' | 'processing' | 'ready' | 'failed' | 'cancelled';
interface UploadEntry {
id: string;
file: File;
progress: number;
status: UploadStatus;
error?: string;
}
interface UploadDropzoneProps {
galleryId: string;
onMedia: (media: MediaItem) => void;
onRefresh: () => void;
}
export function UploadDropzone({ galleryId, onMedia, onRefresh }: UploadDropzoneProps) {
const [entries, setEntries] = useState<UploadEntry[]>([]);
const [dragging, setDragging] = useState(false);
const controllers = useRef(new Map<string, AbortController>());
const inputRef = useRef<HTMLInputElement>(null);
function addFiles(files: File[]) {
files.forEach((file, index) => {
const id = `${file.name}-${file.lastModified}-${Date.now()}-${index}`;
setEntries((current) => [...current, { id, file, progress: 0, status: 'queued' }]);
void upload(id, file);
});
}
async function upload(id: string, file: File) {
const controller = new AbortController();
controllers.current.set(id, controller);
try {
setEntry(id, { status: 'uploading', progress: 0 });
const created = await createUpload(galleryId, file);
await uploadToStorage(
created.uploadUrl,
file,
(progress) => setEntry(id, { progress }),
controller.signal,
);
setEntry(id, { status: 'processing', progress: 100 });
const completed = await completeUpload(created.uploadId);
setEntry(id, {
status: completed.processingStatus === 'READY' ? 'ready' : 'processing',
progress: 100,
});
onMedia(completed);
window.setTimeout(onRefresh, 1400);
} catch (reason) {
if (reason instanceof DOMException && reason.name === 'AbortError') {
setEntry(id, { status: 'cancelled' });
} else {
const message = reason instanceof Error ? reason.message : 'Upload failed.';
setEntry(id, {
status: 'failed',
error: message,
});
try {
window.localStorage.setItem(
'northline:last-upload-error',
JSON.stringify({ at: new Date().toISOString(), filename: file.name, message }),
);
} catch {
// Diagnostics should never make an upload failure worse.
}
}
} finally {
controllers.current.delete(id);
}
}
function setEntry(id: string, update: Partial<UploadEntry>) {
setEntries((current) =>
current.map((entry) => (entry.id === id ? { ...entry, ...update } : entry)),
);
}
function cancel(id: string) {
controllers.current.get(id)?.abort();
}
function retry(entry: UploadEntry) {
setEntry(entry.id, { status: 'queued', progress: 0, error: undefined });
void upload(entry.id, entry.file);
}
const totalProgress = entries.length
? Math.round(entries.reduce((total, entry) => total + entry.progress, 0) / entries.length)
: 0;
return (
<div className="upload-zone-wrap">
<button
className={`upload-zone ${dragging ? 'is-dragging' : ''}`}
type="button"
onClick={() => inputRef.current?.click()}
onDragEnter={(event) => {
event.preventDefault();
setDragging(true);
}}
onDragOver={(event) => event.preventDefault()}
onDragLeave={() => setDragging(false)}
onDrop={(event) => {
event.preventDefault();
setDragging(false);
addFiles(Array.from(event.dataTransfer.files));
}}
>
<span className="upload-zone__orb" aria-hidden="true">
+
</span>
<span className="upload-zone__title">Drop finished work here</span>
<span className="upload-zone__hint">
or click to browse / JPG, PNG, WEBP, HEIC, MP4, MOV
</span>
</button>
<input
ref={inputRef}
className="sr-only"
type="file"
multiple
accept="image/jpeg,image/png,image/webp,image/heic,image/heif,video/mp4,video/quicktime,video/webm"
onChange={(event) => {
addFiles(Array.from(event.target.files || []));
event.target.value = '';
}}
/>
{entries.length > 0 && (
<div className="upload-queue">
<div className="upload-queue__heading">
<span>Upload queue</span>
<strong>{totalProgress}% overall</strong>
</div>
<div className="upload-queue__track">
<span style={{ width: `${totalProgress}%` }} />
</div>
<div className="upload-queue__items">
{entries.map((entry) => (
<div className="upload-row" key={entry.id}>
<span className="upload-row__type">
{entry.file.type.startsWith('video/') ? 'MOV' : 'IMG'}
</span>
<span className="upload-row__name">
{entry.file.name}
<small>{formatBytes(entry.file.size)}</small>
</span>
<span className={`upload-row__status upload-row__status--${entry.status}`}>
{entry.status === 'uploading' ? `${entry.progress}%` : entry.status}
</span>
{entry.error && <small className="upload-row__error">{entry.error}</small>}
{(entry.status === 'uploading' || entry.status === 'processing') && (
<button
type="button"
onClick={() => cancel(entry.id)}
aria-label={`Cancel ${entry.file.name}`}
>
&#215;
</button>
)}
{entry.status === 'failed' && (
<button type="button" onClick={() => retry(entry)}>
Retry
</button>
)}
</div>
))}
</div>
</div>
)}
</div>
);
}
@@ -0,0 +1,377 @@
import { useEffect, useState, type CSSProperties } from 'react';
import { AnimatePresence } from 'framer-motion';
import {
downloadMedia,
favoriteMedia,
getDownloadAllStatus,
startDownloadAll,
} from '../../lib/api';
import { formatDuration } from '../../lib/format';
import type { DownloadJob, MediaItem, PublicGallery } from '../../types/gallery';
import { PhotoViewer } from './PhotoViewer';
interface ClientGalleryProps {
gallery: PublicGallery;
}
export function ClientGallery({ gallery }: ClientGalleryProps) {
const [items, setItems] = useState(gallery.media);
const [viewerIndex, setViewerIndex] = useState<number | null>(null);
const [downloadJob, setDownloadJob] = useState<DownloadJob | null>(null);
const photos = items.filter((item) => item.mimeType.startsWith('image/'));
const cover = gallery.cover || photos[0];
const theme = gallery.themeConfig || {};
const branding = gallery.brandingConfig || {};
const style = { '--gallery-accent': theme.accent || '#a85e55' } as CSSProperties;
useEffect(() => setItems(gallery.media), [gallery.media]);
useEffect(() => {
if (!downloadJob || !['QUEUED', 'PROCESSING'].includes(downloadJob.status)) return undefined;
const timer = window.setTimeout(async () => {
try {
const updated = await getDownloadAllStatus(gallery.slug, downloadJob.jobId);
setDownloadJob(updated);
} catch (reason) {
setDownloadJob((current) =>
current
? {
...current,
status: 'FAILED',
error: reason instanceof Error ? reason.message : 'Download failed.',
}
: current,
);
}
}, 1300);
return () => window.clearTimeout(timer);
}, [downloadJob, gallery.slug]);
async function toggleFavorite(item: MediaItem) {
if (gallery.favoritesEnabled === false) return;
const next = !item.favorited;
setItems((current) =>
current.map((candidate) =>
candidate.id === item.id ? { ...candidate, favorited: next } : candidate,
),
);
try {
await favoriteMedia(gallery.slug, item.id, next);
} catch {
setItems((current) =>
current.map((candidate) =>
candidate.id === item.id ? { ...candidate, favorited: !next } : candidate,
),
);
}
}
async function downloadOne(item: MediaItem) {
try {
const url = await downloadMedia(gallery.slug, item.id);
const anchor = document.createElement('a');
anchor.href = url;
anchor.download = item.originalFilename;
anchor.target = '_blank';
anchor.rel = 'noreferrer';
anchor.click();
} catch {
// The client gallery stays usable if a single signed URL cannot be issued.
}
}
async function downloadAll() {
if (downloadJob?.status === 'QUEUED' || downloadJob?.status === 'PROCESSING') return;
try {
setDownloadJob(await startDownloadAll(gallery.slug));
} catch (reason) {
setDownloadJob({
jobId: '',
status: 'FAILED',
error: reason instanceof Error ? reason.message : 'Download failed.',
});
}
}
function openPhoto(item: MediaItem) {
const index = photos.findIndex((photo) => photo.id === item.id);
setViewerIndex(index >= 0 ? index : null);
}
const mode = theme.mode === 'dark' ? 'dark' : 'light';
const layout = theme.layout || 'editorial';
return (
<div
className={`client-gallery client-gallery--${mode} client-gallery--${layout}`}
style={style}
>
{gallery.preview && (
<div className="preview-ribbon">
<span>Preview mode</span>
<span>This is how your clients will see it</span>
</div>
)}
<header className="client-gallery__nav">
<a
className="client-brand"
href={branding.websiteUrl || '#'}
onClick={(event) => {
if (!branding.websiteUrl) event.preventDefault();
}}
>
{branding.logoUrl ? (
<img src={branding.logoUrl} alt="" />
) : (
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
)}
<span>{branding.studioName || 'Your studio'}</span>
</a>
<div className="client-gallery__nav-actions">
{gallery.downloadAllEnabled !== false && gallery.downloadsEnabled !== false && (
<button type="button" className="client-nav-button" onClick={() => void downloadAll()}>
{downloadJob?.status === 'PROCESSING'
? 'Preparing ZIP...'
: downloadJob?.status === 'READY'
? 'ZIP ready'
: 'Download gallery'}{' '}
<span aria-hidden="true">&#8595;</span>
</button>
)}
<span className="client-gallery__edition">{gallery.clientName}</span>
</div>
</header>
<main>
<section className="client-hero">
<div className="client-hero__copy">
<p className="client-kicker">A collection for {gallery.clientName}</p>
<h1 className="client-display">{gallery.title}</h1>
{gallery.description && (
<p className="client-hero__description">{gallery.description}</p>
)}
</div>
<div className="client-hero__cover">
{cover?.previewUrl ? (
<img src={cover.previewUrl} alt="" />
) : (
<div className="client-hero__fallback">
<span>{gallery.clientName.slice(0, 1)}</span>
</div>
)}
<div className="client-hero__cover-caption">
<span>Open to remember</span>
<span>{items.length} pieces</span>
</div>
</div>
<div className="client-hero__scroll">
<span /> Scroll to wander
</div>
</section>
<section className="client-work-section">
<div className="client-section-heading">
<div>
<p className="client-kicker">The collection</p>
<h2 className="client-display">
The day, <em>held still.</em>
</h2>
</div>
<span>
{photos.length} photographs /{' '}
{items.filter((item) => item.mimeType.startsWith('video/')).length} films
</span>
</div>
{items.length === 0 ? (
<div className="client-empty">
<span>+</span>
<p>Your gallery is still being arranged.</p>
</div>
) : (
<div className="client-media-grid">
{items.map((item) => (
<MediaCard
key={item.id}
item={item}
gallery={gallery}
onOpen={() => openPhoto(item)}
onFavorite={() => void toggleFavorite(item)}
onDownload={() => void downloadOne(item)}
/>
))}
</div>
)}
</section>
<section className="client-download-section">
<div className="client-download-section__copy">
<p className="client-kicker">Take it with you</p>
<h2 className="client-display">
The moments are
<br />
<em>yours to keep.</em>
</h2>
<p>Save the full-resolution photographs and revisit this chapter whenever you like.</p>
</div>
{gallery.downloadsEnabled !== false && (
<button
className="client-download-button"
type="button"
onClick={() => void downloadAll()}
disabled={downloadJob?.status === 'PROCESSING' || downloadJob?.status === 'QUEUED'}
>
<span>
{downloadJob?.status === 'PROCESSING'
? 'Preparing your gallery'
: downloadJob?.status === 'READY'
? 'Download ready'
: 'Download all originals'}
</span>
<span aria-hidden="true">&#8595;</span>
</button>
)}
{downloadJob?.status === 'READY' && downloadJob.url && (
<a
className="client-download-ready"
href={downloadJob.url}
target="_blank"
rel="noreferrer"
>
Your ZIP is ready <span aria-hidden="true">&#8599;</span>
</a>
)}
{downloadJob?.status === 'FAILED' && (
<p className="client-download-error">
{downloadJob.error || 'The download could not be prepared.'}
</p>
)}
</section>
</main>
<footer className="client-footer">
<div>
<span className="client-brand__mark">{branding.studioName?.slice(0, 1) || 'N'}</span>
<div>
<strong>{branding.studioName || 'Your studio'}</strong>
<small>{branding.tagline || 'Photographs for keeps.'}</small>
</div>
</div>
<div className="client-footer__links">
{branding.websiteUrl && (
<a href={branding.websiteUrl} target="_blank" rel="noreferrer">
Website &#8599;
</a>
)}
{branding.instagramUrl && (
<a href={branding.instagramUrl} target="_blank" rel="noreferrer">
Instagram &#8599;
</a>
)}
</div>
<span className="client-footer__credit">Delivered with intention</span>
</footer>
<AnimatePresence>
{viewerIndex !== null && (
<PhotoViewer
items={photos}
index={viewerIndex}
onClose={() => setViewerIndex(null)}
onChange={setViewerIndex}
onFavorite={(item) => void toggleFavorite(item)}
onDownload={(item) => void downloadOne(item)}
/>
)}
</AnimatePresence>
</div>
);
}
function MediaCard({
item,
gallery,
onOpen,
onFavorite,
onDownload,
}: {
item: MediaItem;
gallery: PublicGallery;
onOpen: () => void;
onFavorite: () => void;
onDownload: () => void;
}) {
const isVideo = item.mimeType.startsWith('video/');
const ready = item.processingStatus === 'READY';
return (
<article
className={`client-media-card ${isVideo ? 'client-media-card--video' : ''} ${gallery.watermarkEnabled ? 'client-media-card--watermarked' : ''}`}
>
{isVideo ? (
<div className="client-media-card__video-wrap">
{ready && item.previewUrl ? (
<video
controls
playsInline
preload="metadata"
poster={gallery.cover?.previewUrl}
src={item.previewUrl}
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
<span className="client-media-card__video-label">
Film {formatDuration(item.durationSeconds)}
</span>
</div>
) : (
<button
className="client-media-card__image"
type="button"
onClick={onOpen}
disabled={!ready}
>
{ready && (item.thumbnailUrl || item.previewUrl) ? (
<img
src={item.thumbnailUrl || item.previewUrl}
alt={item.originalFilename}
loading="lazy"
/>
) : (
<div className="client-media-card__processing">
{item.processingStatus.toLowerCase()}
</div>
)}
{gallery.watermarkEnabled && (
<span className="client-watermark">
{gallery.brandingConfig.studioName || 'Preview'}
</span>
)}
<span className="client-media-card__open" aria-hidden="true">
&#8599;
</span>
</button>
)}
<div className="client-media-card__footer">
<span>{item.originalFilename}</span>
{gallery.favoritesEnabled !== false && (
<button
type="button"
className={item.favorited ? 'is-favorited' : ''}
onClick={onFavorite}
aria-label={item.favorited ? 'Remove favorite' : 'Favorite photo'}
>
{item.favorited ? '♥' : '♡'}
</button>
)}
{gallery.downloadsEnabled !== false && (
<button type="button" onClick={onDownload} aria-label="Download original">
&#8595;
</button>
)}
</div>
</article>
);
}
@@ -0,0 +1,71 @@
import { useState, type FormEvent } from 'react';
import type { PublicGallery } from '../../types/gallery';
interface PasswordGateProps {
gallery: PublicGallery;
onUnlock: (password: string) => Promise<void>;
}
export function PasswordGate({ gallery, onUnlock }: PasswordGateProps) {
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError('');
try {
await onUnlock(password);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'That password did not work.');
} finally {
setSubmitting(false);
}
}
return (
<div className="client-lock-screen">
<div className="client-lock-screen__glow" aria-hidden="true" />
<div className="client-lock-screen__brand">
<span className="client-monogram">
{gallery.brandingConfig.studioName?.slice(0, 1) || 'N'}
</span>
<span>{gallery.brandingConfig.studioName || 'Private gallery'}</span>
</div>
<div className="client-lock-screen__center">
<span className="client-lock-screen__lock" aria-hidden="true">
+
</span>
<p className="client-kicker">A private delivery</p>
<h1 className="client-display">{gallery.title}</h1>
<p>This gallery was made for {gallery.clientName}. Enter the password to open it.</p>
<form className="client-password-form" onSubmit={submit}>
<label className="sr-only" htmlFor="gallery-password">
Gallery password
</label>
<input
id="gallery-password"
type="password"
autoComplete="current-password"
placeholder="Enter password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
<button
className="client-round-button"
type="submit"
disabled={submitting}
aria-label="Open gallery"
>
{submitting ? '...' : <span aria-hidden="true">&#8599;</span>}
</button>
</form>
{error && <p className="client-form-error">{error}</p>}
</div>
<p className="client-lock-screen__footer">The work is waiting inside.</p>
</div>
);
}
@@ -0,0 +1,141 @@
import { useEffect, useRef } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import { formatBytes } from '../../lib/format';
import type { MediaItem } from '../../types/gallery';
interface PhotoViewerProps {
items: MediaItem[];
index: number;
onClose: () => void;
onChange: (index: number) => void;
onFavorite: (item: MediaItem) => void;
onDownload: (item: MediaItem) => void;
}
export function PhotoViewer({
items,
index,
onClose,
onChange,
onFavorite,
onDownload,
}: PhotoViewerProps) {
const startX = useRef<number | null>(null);
const item = items[index];
useEffect(() => {
const previousOverflow = document.body.style.overflow;
document.body.style.overflow = 'hidden';
return () => {
document.body.style.overflow = previousOverflow;
};
}, []);
useEffect(() => {
[index - 1, index + 1].forEach((neighborIndex) => {
const neighbor = items[neighborIndex];
if (neighbor?.previewUrl) {
const image = new Image();
image.src = neighbor.previewUrl;
}
});
}, [index, items]);
useEffect(() => {
function handleKeyDown(event: KeyboardEvent) {
if (event.key === 'Escape') onClose();
if (event.key === 'ArrowLeft' && index > 0) onChange(index - 1);
if (event.key === 'ArrowRight' && index < items.length - 1) onChange(index + 1);
}
window.addEventListener('keydown', handleKeyDown);
return () => window.removeEventListener('keydown', handleKeyDown);
}, [index, items.length, onChange, onClose]);
if (!item) return null;
return (
<motion.div
className="photo-viewer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
role="dialog"
aria-modal="true"
aria-label="Photo viewer"
>
<div className="photo-viewer__topline">
<span>{item.originalFilename}</span>
<button type="button" onClick={onClose} aria-label="Close photo viewer">
&#215;
</button>
</div>
<div
className="photo-viewer__stage"
onTouchStart={(event) => {
startX.current = event.touches[0]?.clientX ?? null;
}}
onTouchEnd={(event) => {
if (startX.current === null) return;
const distance = event.changedTouches[0].clientX - startX.current;
if (Math.abs(distance) > 45)
onChange(distance > 0 ? Math.max(0, index - 1) : Math.min(items.length - 1, index + 1));
startX.current = null;
}}
>
<button
className="photo-viewer__arrow photo-viewer__arrow--left"
type="button"
onClick={() => onChange(Math.max(0, index - 1))}
disabled={index === 0}
aria-label="Previous photo"
>
&#8592;
</button>
<AnimatePresence mode="wait" initial={false}>
<motion.div
className="photo-viewer__image-wrap"
key={item.id}
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0, scale: 1.02 }}
transition={{ duration: 0.25 }}
>
{item.previewUrl ? (
<img src={item.previewUrl} alt={item.originalFilename} draggable={false} />
) : (
<span>Preview processing</span>
)}
</motion.div>
</AnimatePresence>
<button
className="photo-viewer__arrow photo-viewer__arrow--right"
type="button"
onClick={() => onChange(Math.min(items.length - 1, index + 1))}
disabled={index === items.length - 1}
aria-label="Next photo"
>
&#8594;
</button>
</div>
<div className="photo-viewer__bottomline">
<span>
{String(index + 1).padStart(2, '0')} / {String(items.length).padStart(2, '0')}{' '}
<small>{formatBytes(item.fileSize)}</small>
</span>
<div>
<button
type="button"
className={item.favorited ? 'is-favorited' : ''}
onClick={() => onFavorite(item)}
>
{item.favorited ? '♥' : '♡'} <span>{item.favorited ? 'Favorited' : 'Favorite'}</span>
</button>
<button type="button" onClick={() => onDownload(item)}>
&#8595; <span>Download</span>
</button>
</div>
</div>
</motion.div>
);
}
@@ -0,0 +1,21 @@
import type { ReactNode } from 'react';
export type BackgroundTone = 'intro' | 'reveal' | 'content' | 'finish';
interface BackgroundProps {
children: ReactNode;
tone?: BackgroundTone;
}
export function Background({ children, tone = 'intro' }: BackgroundProps) {
return (
<div className={`gift-background gift-background--${tone}`}>
<div className="gift-background__wash" aria-hidden="true" />
<div className="gift-background__orb gift-background__orb--one" aria-hidden="true" />
<div className="gift-background__orb gift-background__orb--two" aria-hidden="true" />
<div className="gift-background__grid" aria-hidden="true" />
<div className="gift-background__noise" aria-hidden="true" />
<main className="gift-background__content">{children}</main>
</div>
);
}
+36
View File
@@ -0,0 +1,36 @@
import type { CSSProperties } from 'react';
const pieces = [
{ left: 8, top: -6, size: 9, delay: 0, color: '#ed9a79', rotation: 18 },
{ left: 19, top: 6, size: 6, delay: 0.45, color: '#f5d58d', rotation: 72 },
{ left: 31, top: -12, size: 8, delay: 0.9, color: '#b7a5e5', rotation: 38 },
{ left: 46, top: 3, size: 5, delay: 0.2, color: '#f6eee2', rotation: 92 },
{ left: 61, top: -9, size: 10, delay: 0.7, color: '#df8e9b', rotation: 140 },
{ left: 75, top: 4, size: 6, delay: 0.1, color: '#f5d58d', rotation: 210 },
{ left: 89, top: -14, size: 8, delay: 0.58, color: '#9fb7cc', rotation: 265 },
{ left: 13, top: 18, size: 5, delay: 1.1, color: '#f6eee2', rotation: 305 },
{ left: 39, top: 13, size: 7, delay: 0.32, color: '#df8e9b', rotation: 180 },
{ left: 68, top: 17, size: 5, delay: 0.8, color: '#b7a5e5', rotation: 18 },
{ left: 83, top: 20, size: 9, delay: 1.28, color: '#ed9a79', rotation: 122 },
{ left: 54, top: -18, size: 4, delay: 1.45, color: '#f6eee2', rotation: 245 },
];
export function Confetti() {
return (
<div className="confetti" aria-hidden="true">
{pieces.map((piece, index) => {
const style = {
left: `${piece.left}%`,
top: `${piece.top}%`,
width: `${piece.size}px`,
height: `${piece.size * 2.1}px`,
backgroundColor: piece.color,
animationDelay: `${piece.delay}s`,
transform: `rotate(${piece.rotation}deg)`,
} satisfies CSSProperties;
return <span className="confetti__piece" key={index} style={style} />;
})}
</div>
);
}
@@ -0,0 +1,217 @@
import { useEffect, useState } from 'react';
import { AnimatePresence, motion } from 'framer-motion';
import type { Gift } from '../../types/gift';
import { Background } from './Background';
import { Confetti } from './Confetti';
import { GiftItem } from './GiftItem';
import { IntroScreen } from './IntroScreen';
import { ProgressIndicator } from './ProgressIndicator';
import { RevealScreen } from './RevealScreen';
type ExperienceStage = 'INTRO' | 'OPEN_PROMPT' | 'REVEAL' | 'CONTENT' | 'FINISH';
interface GiftExperienceProps {
gift: Gift;
}
const screenTransition = {
duration: 0.65,
ease: [0.22, 1, 0.36, 1] as const,
};
export function GiftExperience({ gift }: GiftExperienceProps) {
const [stage, setStage] = useState<ExperienceStage>('INTRO');
const [activeIndex, setActiveIndex] = useState(0);
useEffect(() => {
if (stage !== 'REVEAL') {
return undefined;
}
const timer = window.setTimeout(() => {
setStage(gift.items.length > 0 ? 'CONTENT' : 'FINISH');
}, 1550);
return () => window.clearTimeout(timer);
}, [gift.items.length, stage]);
function showNextItem() {
if (activeIndex < gift.items.length - 1) {
setActiveIndex((index) => index + 1);
return;
}
setStage('FINISH');
}
function replay() {
setActiveIndex(0);
setStage('INTRO');
}
return (
<div className="gift-experience" aria-live="polite">
<AnimatePresence mode="wait" initial={false}>
{stage === 'INTRO' && (
<motion.div
className="experience-screen"
key="intro"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<IntroScreen gift={gift} onContinue={() => setStage('OPEN_PROMPT')} />
</motion.div>
)}
{stage === 'OPEN_PROMPT' && (
<motion.div
className="experience-screen"
key="open-prompt"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<RevealScreen
recipientName={gift.recipientName}
isRevealing={false}
onReveal={() => setStage('REVEAL')}
/>
</motion.div>
)}
{stage === 'REVEAL' && (
<motion.div
className="experience-screen"
key="reveal"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
transition={screenTransition}
>
<RevealScreen
recipientName={gift.recipientName}
isRevealing
onReveal={() => undefined}
/>
</motion.div>
)}
{stage === 'CONTENT' && (
<motion.div
className="experience-screen"
key="content"
initial={{ opacity: 0, y: 20 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -20 }}
transition={screenTransition}
>
<Background tone="content">
<div className="content-screen">
<div className="content-screen__topline">
<span className="brand-lockup brand-lockup--ink">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">a few things for you</span>
</div>
<div className="content-screen__intro">
<p className="eyebrow eyebrow--ink">chapter {activeIndex + 1}</p>
<h1 className="display">
For the moments
<br />
<em>worth keeping.</em>
</h1>
</div>
<ProgressIndicator current={activeIndex} total={gift.items.length} />
<AnimatePresence mode="wait" initial={false}>
{gift.items[activeIndex] && (
<motion.div
className="content-screen__item"
key={gift.items[activeIndex].id}
initial={{ opacity: 0, x: 24 }}
animate={{ opacity: 1, x: 0 }}
exit={{ opacity: 0, x: -24 }}
transition={{ duration: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
<GiftItem item={gift.items[activeIndex]} />
</motion.div>
)}
</AnimatePresence>
<div className="content-screen__bottomline">
<span>for {gift.recipientName}</span>
<button className="next-action" type="button" onClick={showNextItem}>
<span>
{activeIndex === gift.items.length - 1 ? 'Keep this moment' : 'Next memory'}
</span>
<span aria-hidden="true">&#8599;</span>
</button>
</div>
</div>
</Background>
</motion.div>
)}
{stage === 'FINISH' && (
<motion.div
className="experience-screen"
key="finish"
initial={{ opacity: 0, scale: 0.98 }}
animate={{ opacity: 1, scale: 1 }}
exit={{ opacity: 0 }}
transition={screenTransition}
>
<Background tone="finish">
<Confetti />
<div className="finish-screen">
<div className="finish-screen__topline">
<span className="brand-lockup brand-lockup--light">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">the end, for now</span>
</div>
<div className="finish-screen__center">
<motion.div
className="finish-screen__spark"
initial={{ scale: 0, rotate: -20 }}
animate={{ scale: 1, rotate: 0 }}
transition={{ delay: 0.25, type: 'spring', stiffness: 180, damping: 12 }}
>
<span aria-hidden="true">&#10022;</span>
</motion.div>
<p className="eyebrow eyebrow--quiet">a final note</p>
<h1 className="display">
Made with love,
<br />
<em>{gift.recipientName}.</em>
</h1>
<p className="finish-screen__message">{gift.revealMessage}</p>
<p className="finish-screen__signature">
Always,
<br />
<strong>{gift.senderName}</strong>
</p>
</div>
<button
className="ghost-action ghost-action--light finish-screen__replay"
type="button"
onClick={replay}
>
<span>See it again</span>
<span aria-hidden="true">&#8599;</span>
</button>
</div>
</Background>
</motion.div>
)}
</AnimatePresence>
</div>
);
}
+24
View File
@@ -0,0 +1,24 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
import { ImageItem } from './ImageItem';
import { TextItem } from './TextItem';
import { VideoItem } from './VideoItem';
interface GiftItemProps {
item: GiftItemData;
}
export function GiftItem({ item }: GiftItemProps) {
switch (item.type.toLowerCase()) {
case 'image':
case 'photo':
return <ImageItem item={item} />;
case 'video':
return <VideoItem item={item} />;
case 'text':
case 'note':
case 'card':
case 'link':
default:
return <TextItem item={item} />;
}
}
@@ -0,0 +1,36 @@
import { useState } from 'react';
import type { GiftItem as GiftItemData } from '../../types/gift';
interface ImageItemProps {
item: GiftItemData;
}
export function ImageItem({ item }: ImageItemProps) {
const [hasError, setHasError] = useState(false);
return (
<div className="gift-image-item">
<div className="gift-image-item__frame">
{item.mediaUrl && !hasError ? (
<img
src={item.mediaUrl}
alt={item.title || 'A memory from your gift'}
onError={() => setHasError(true)}
/>
) : (
<div className="gift-image-item__placeholder">
<span className="placeholder-sun" aria-hidden="true" />
<span>{hasError ? 'A memory for you' : 'Your image goes here'}</span>
</div>
)}
{(item.title || item.text) && (
<div className="gift-image-item__caption">
{item.title && <strong>{item.title}</strong>}
{item.text && <span>{item.text}</span>}
</div>
)}
</div>
</div>
);
}
@@ -0,0 +1,90 @@
import { motion } from 'framer-motion';
import type { Gift } from '../../types/gift';
import { Background } from './Background';
interface IntroScreenProps {
gift: Gift;
onContinue: () => void;
}
export function IntroScreen({ gift, onContinue }: IntroScreenProps) {
return (
<Background tone="intro">
<div className="intro-screen">
<motion.div
className="intro-screen__topline"
initial={{ opacity: 0, y: -12 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.7, delay: 0.1 }}
>
<span className="brand-lockup">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">A private little moment</span>
</motion.div>
<div className="intro-screen__center">
<motion.p
className="eyebrow eyebrow--warm"
initial={{ opacity: 0, letterSpacing: '0.28em' }}
animate={{ opacity: 1, letterSpacing: '0.18em' }}
transition={{ duration: 0.9, delay: 0.3 }}
>
{gift.title}
</motion.p>
<motion.h1
className="display intro-screen__title"
initial={{ opacity: 0, y: 24 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.9, delay: 0.45, ease: [0.22, 1, 0.36, 1] }}
>
Hey <em>{gift.recipientName}.</em>
</motion.h1>
<motion.p
className="intro-screen__message"
initial={{ opacity: 0, y: 16 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.65 }}
>
{gift.introMessage}
</motion.p>
<motion.button
className="primary-action"
type="button"
onClick={onContinue}
initial={{ opacity: 0, y: 14 }}
animate={{ opacity: 1, y: 0 }}
transition={{ duration: 0.8, delay: 0.85 }}
whileHover={{ y: -3 }}
whileTap={{ scale: 0.98 }}
>
<span>Open your surprise</span>
<span className="primary-action__icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<path
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
</span>
</motion.button>
</div>
<motion.div
className="intro-screen__footer"
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
transition={{ duration: 0.8, delay: 1.1 }}
>
<span>made with intention</span>
<span>
from <strong>{gift.senderName}</strong>
</span>
</motion.div>
</div>
</Background>
);
}
@@ -0,0 +1,22 @@
interface ProgressIndicatorProps {
current: number;
total: number;
}
export function ProgressIndicator({ current, total }: ProgressIndicatorProps) {
return (
<div className="progress-indicator" aria-label={`Memory ${current + 1} of ${total}`}>
<div className="progress-indicator__track" aria-hidden="true">
{Array.from({ length: total }, (_, index) => (
<span
className={`progress-indicator__segment ${index <= current ? 'is-active' : ''}`}
key={index}
/>
))}
</div>
<span className="progress-indicator__count">
{String(current + 1).padStart(2, '0')} / {String(total).padStart(2, '0')}
</span>
</div>
);
}
@@ -0,0 +1,90 @@
import { AnimatePresence, motion } from 'framer-motion';
import { Background } from './Background';
interface RevealScreenProps {
recipientName: string;
isRevealing: boolean;
onReveal: () => void;
}
export function RevealScreen({ recipientName, isRevealing, onReveal }: RevealScreenProps) {
return (
<Background tone="reveal">
<div className="reveal-screen">
<div className="reveal-screen__topline">
<span className="brand-lockup brand-lockup--light">
<span>little</span>
<span>something</span>
</span>
<span className="topline-note">just for {recipientName}</span>
</div>
<div className="reveal-screen__center">
<motion.div
className="reveal-orbit reveal-orbit--outer"
animate={isRevealing ? { rotate: 360, scale: 1.2 } : { rotate: 0, scale: 1 }}
transition={{ duration: 1.5, ease: 'easeInOut' }}
/>
<motion.div
className="reveal-orbit reveal-orbit--inner"
animate={isRevealing ? { rotate: -360, scale: 0.76 } : { rotate: 0, scale: 1 }}
transition={{ duration: 1.35, ease: 'easeInOut' }}
/>
<motion.div
className="reveal-seal"
animate={
isRevealing
? { scale: [1, 1.06, 0.2], opacity: [1, 1, 0], rotate: [0, -8, 18] }
: { scale: 1, opacity: 1, rotate: 0 }
}
transition={{ duration: 1.25, times: [0, 0.48, 1], ease: [0.22, 1, 0.36, 1] }}
>
<span className="reveal-seal__halo" />
<span className="reveal-seal__mark">ls</span>
<span className="reveal-seal__label">open gently</span>
</motion.div>
<AnimatePresence mode="wait">
<motion.div
className="reveal-screen__copy"
key={isRevealing ? 'revealing' : 'prompt'}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0, y: -10 }}
transition={{ duration: 0.35 }}
>
<p className="eyebrow eyebrow--quiet">
{isRevealing ? 'here it comes' : 'one tiny step'}
</p>
<h1 className="display">
{isRevealing ? 'Making room for a little magic.' : 'There is something inside.'}
</h1>
<p>
{isRevealing
? 'Take a breath. Your moment is opening.'
: 'No rush. This one was made to be opened slowly.'}
</p>
</motion.div>
</AnimatePresence>
{!isRevealing && (
<motion.button
className="ghost-action ghost-action--light"
type="button"
onClick={onReveal}
initial={{ opacity: 0, y: 10 }}
animate={{ opacity: 1, y: 0 }}
transition={{ delay: 0.15 }}
whileHover={{ y: -2 }}
whileTap={{ scale: 0.98 }}
>
<span>Unwrap the moment</span>
<span aria-hidden="true">&#8599;</span>
</motion.button>
)}
</div>
</div>
</Background>
);
}
+25
View File
@@ -0,0 +1,25 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
interface TextItemProps {
item: GiftItemData;
}
export function TextItem({ item }: TextItemProps) {
const eyebrow =
typeof item.metadata?.eyebrow === 'string' ? item.metadata.eyebrow : 'A note from me';
return (
<div className="gift-text-item">
<div className="gift-text-item__quote" aria-hidden="true">
&ldquo;
</div>
<p className="eyebrow eyebrow--ink">{eyebrow}</p>
{item.title && <h2 className="display">{item.title}</h2>}
<p className="gift-text-item__body">{item.text || 'A little note, just because.'}</p>
<div className="gift-text-item__signature" aria-hidden="true">
<span />
<span>with love</span>
</div>
</div>
);
}
@@ -0,0 +1,31 @@
import type { GiftItem as GiftItemData } from '../../types/gift';
interface VideoItemProps {
item: GiftItemData;
}
export function VideoItem({ item }: VideoItemProps) {
return (
<div className="gift-video-item">
{item.mediaUrl ? (
<video
controls
playsInline
preload="metadata"
poster={item.metadata?.poster as string | undefined}
>
<source src={item.mediaUrl} />
Your browser does not support video playback.
</video>
) : (
<div className="gift-video-item__placeholder">
<span className="video-play" aria-hidden="true">
&#9654;
</span>
<span>Your video goes here</span>
</div>
)}
{item.text && <p>{item.text}</p>}
</div>
);
}
@@ -0,0 +1,30 @@
import { useEffect, useState, type ReactNode } from 'react';
import { getMe, logout, type User } from '../../lib/api';
import { AuthContext } from './AuthContextValue';
export function AuthProvider({ children }: { children: ReactNode }) {
const [user, setUser] = useState<User | null>(null);
const [loading, setLoading] = useState(true);
useEffect(() => {
getMe()
.then(setUser)
.catch(() => setUser(null))
.finally(() => setLoading(false));
}, []);
async function signOut() {
await logout().catch(() => undefined);
setUser(null);
}
return (
<AuthContext.Provider value={{ user, loading, setUser, signOut }}>
{children}
</AuthContext.Provider>
);
}
/* The hook lives in useAuth.ts so Fast Refresh only sees the provider here. */
@@ -0,0 +1,12 @@
import { createContext } from 'react';
import type { User } from '../../lib/api';
export interface AuthContextValue {
user: User | null;
loading: boolean;
setUser: (user: User | null) => void;
signOut: () => Promise<void>;
}
export const AuthContext = createContext<AuthContextValue | null>(null);
+11
View File
@@ -0,0 +1,11 @@
import { useContext } from 'react';
import { AuthContext } from './AuthContextValue';
export function useAuth() {
const context = useContext(AuthContext);
if (!context) {
throw new Error('useAuth must be used inside AuthProvider');
}
return context;
}
+312
View File
@@ -0,0 +1,312 @@
import type { Gift } from '../types/gift';
import type { DevDiagnostics, DevStorageCheck } from '../types/dev';
import type {
DownloadJob,
GalleryDetail,
GallerySummary,
MediaItem,
PublicGallery,
} from '../types/gallery';
const apiBaseUrl = (import.meta.env.VITE_API_BASE_URL || 'http://localhost:8080').replace(
/\/$/,
'',
);
export class ApiError extends Error {
readonly status: number;
constructor(message: string, status: number) {
super(message);
this.name = 'ApiError';
this.status = status;
}
}
async function request<T>(path: string, options: RequestInit = {}): Promise<T> {
const headers = new Headers(options.headers);
if (options.body && !headers.has('Content-Type')) {
headers.set('Content-Type', 'application/json');
}
let response: Response;
try {
response = await fetch(`${apiBaseUrl}${path}`, {
...options,
credentials: 'include',
headers,
});
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
throw new ApiError('The server could not be reached. Check your connection and try again.', 0);
}
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: unknown } | null;
const message = typeof payload?.error === 'string' ? payload.error : 'Something went wrong.';
throw new ApiError(message, response.status);
}
if (response.status === 204) {
return undefined as T;
}
return (await response.json()) as T;
}
export async function getGift(slug: string, signal?: AbortSignal): Promise<Gift> {
let response: Response;
try {
response = await fetch(`${apiBaseUrl}/api/gifts/${encodeURIComponent(slug)}`, { signal });
} catch (error) {
if (error instanceof DOMException && error.name === 'AbortError') {
throw error;
}
throw new ApiError(
'The surprise could not be reached. Check your connection and try again.',
0,
);
}
if (!response.ok) {
const payload = (await response.json().catch(() => null)) as { error?: unknown } | null;
const message =
typeof payload?.error === 'string' ? payload.error : 'This gift is not available.';
throw new ApiError(message, response.status);
}
return (await response.json()) as Gift;
}
export interface User {
id: string;
email: string;
name: string;
}
export async function getMe(): Promise<User> {
const response = await request<{ user: User }>('/api/auth/me');
return response.user;
}
export async function login(email: string, password: string): Promise<User> {
const response = await request<{ user: User }>('/api/auth/login', {
method: 'POST',
body: JSON.stringify({ email, password }),
});
return response.user;
}
export async function register(name: string, email: string, password: string): Promise<User> {
const response = await request<{ user: User }>('/api/auth/register', {
method: 'POST',
body: JSON.stringify({ name, email, password }),
});
return response.user;
}
export async function logout(): Promise<void> {
await request('/api/auth/logout', { method: 'POST' });
}
export async function getGalleries(): Promise<GallerySummary[]> {
const response = await request<{ galleries: GallerySummary[] }>('/api/galleries');
return response.galleries;
}
export async function getGallery(id: string): Promise<GalleryDetail> {
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}`);
return response.gallery;
}
export async function getGalleryPreview(id: string): Promise<PublicGallery> {
const response = await request<{ gallery: PublicGallery }>(`/api/galleries/${id}/preview`);
return response.gallery;
}
export async function createGallery(input: {
title: string;
clientName: string;
description: string;
}): Promise<GalleryDetail> {
const response = await request<{ gallery: GalleryDetail }>('/api/galleries', {
method: 'POST',
body: JSON.stringify(input),
});
return response.gallery;
}
export interface UpdateGalleryInput {
title?: string;
clientName?: string;
description?: string;
password?: string;
clearPassword?: boolean;
downloadsEnabled?: boolean;
favoritesEnabled?: boolean;
downloadAllEnabled?: boolean;
watermarkEnabled?: boolean;
expiresAt?: string;
coverMediaId?: string;
themeConfig?: Record<string, unknown>;
brandingConfig?: Record<string, unknown>;
}
export async function updateGallery(id: string, input: UpdateGalleryInput): Promise<GalleryDetail> {
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}`, {
method: 'PATCH',
body: JSON.stringify(input),
});
return response.gallery;
}
export async function publishGallery(id: string): Promise<GalleryDetail> {
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}/publish`, {
method: 'POST',
});
return response.gallery;
}
export async function unpublishGallery(id: string): Promise<GalleryDetail> {
const response = await request<{ gallery: GalleryDetail }>(`/api/galleries/${id}/unpublish`, {
method: 'POST',
});
return response.gallery;
}
export async function deleteGallery(id: string): Promise<void> {
await request(`/api/galleries/${id}`, { method: 'DELETE' });
}
export async function createUpload(
galleryId: string,
file: File,
): Promise<{ uploadId: string; uploadUrl: string; media: MediaItem }> {
return request(`/api/galleries/${galleryId}/uploads`, {
method: 'POST',
body: JSON.stringify({ filename: file.name, mimeType: file.type, fileSize: file.size }),
});
}
export function uploadToStorage(
url: string,
file: File,
onProgress: (progress: number) => void,
signal?: AbortSignal,
): Promise<void> {
return new Promise((resolve, reject) => {
const xhr = new XMLHttpRequest();
xhr.open('PUT', url);
xhr.setRequestHeader('Content-Type', file.type || 'application/octet-stream');
xhr.upload.onprogress = (event) => {
if (event.lengthComputable) {
onProgress(Math.round((event.loaded / event.total) * 100));
}
};
xhr.onload = () => {
if (xhr.status >= 200 && xhr.status < 300) {
resolve();
} else {
const detail = xhr.responseText ? ` ${xhr.responseText.slice(0, 160)}` : '';
reject(
new ApiError(
`Direct storage upload failed with HTTP ${xhr.status}.${detail}`,
xhr.status,
),
);
}
};
xhr.onerror = () => {
let origin = 'the configured object storage';
try {
origin = new URL(url).origin;
} catch {
// Keep the actionable generic message when a presigned URL is malformed.
}
reject(
new ApiError(
`Could not reach ${origin}. This is usually a MinIO CORS or origin mismatch.`,
0,
),
);
};
xhr.onabort = () => reject(new DOMException('Upload aborted', 'AbortError'));
signal?.addEventListener('abort', () => xhr.abort(), { once: true });
xhr.send(file);
});
}
export async function completeUpload(uploadId: string): Promise<MediaItem> {
const response = await request<{ media: MediaItem }>(`/api/uploads/${uploadId}/complete`, {
method: 'POST',
});
return response.media;
}
export async function deleteMedia(mediaId: string): Promise<void> {
await request(`/api/media/${mediaId}`, { method: 'DELETE' });
}
export async function updateMediaOrder(mediaId: string, sortOrder: number): Promise<MediaItem> {
const response = await request<{ media: MediaItem }>(`/api/media/${mediaId}`, {
method: 'PATCH',
body: JSON.stringify({ sortOrder }),
});
return response.media;
}
export async function getPublicGallery(slug: string): Promise<PublicGallery> {
return request<PublicGallery>(`/api/public/galleries/${encodeURIComponent(slug)}`);
}
export async function authenticatePublicGallery(
slug: string,
password: string,
): Promise<PublicGallery> {
return request<PublicGallery>(`/api/public/galleries/${encodeURIComponent(slug)}/authenticate`, {
method: 'POST',
body: JSON.stringify({ password }),
});
}
export async function favoriteMedia(
slug: string,
mediaId: string,
favorited: boolean,
): Promise<boolean> {
const method = favorited ? 'POST' : 'DELETE';
const response = await request<{ favorited: boolean }>(
`/api/public/galleries/${encodeURIComponent(slug)}/media/${mediaId}/favorite`,
{ method },
);
return response.favorited;
}
export async function downloadMedia(slug: string, mediaId: string): Promise<string> {
const response = await request<{ url: string }>(
`/api/public/galleries/${encodeURIComponent(slug)}/media/${mediaId}/download`,
{ method: 'POST' },
);
return response.url;
}
export async function startDownloadAll(slug: string): Promise<DownloadJob> {
return request<DownloadJob>(`/api/public/galleries/${encodeURIComponent(slug)}/download-all`, {
method: 'POST',
});
}
export async function getDownloadAllStatus(slug: string, jobId: string): Promise<DownloadJob> {
return request<DownloadJob>(
`/api/public/galleries/${encodeURIComponent(slug)}/download-all/${jobId}`,
);
}
export async function getDevDiagnostics(): Promise<DevDiagnostics> {
return request<DevDiagnostics>('/api/dev/diagnostics');
}
export async function runDevStorageCheck(): Promise<DevStorageCheck> {
return request<DevStorageCheck>('/api/dev/storage-check', { method: 'POST' });
}
+24
View File
@@ -0,0 +1,24 @@
export function formatBytes(bytes: number) {
if (bytes === 0) return '0 MB';
const units = ['B', 'KB', 'MB', 'GB', 'TB'];
const index = Math.min(Math.floor(Math.log(bytes) / Math.log(1024)), units.length - 1);
return `${(bytes / 1024 ** index).toFixed(index === 0 ? 0 : 1)} ${units[index]}`;
}
export function formatDate(value: string) {
if (!value) return 'Not dated';
const date = new Date(value.replace(' ', 'T'));
if (Number.isNaN(date.getTime())) return 'Not dated';
return new Intl.DateTimeFormat('en', { month: 'short', day: 'numeric', year: 'numeric' }).format(
date,
);
}
export function formatDuration(seconds?: number) {
if (!seconds || seconds < 1) return '';
const minutes = Math.floor(seconds / 60);
const remainder = Math.floor(seconds % 60)
.toString()
.padStart(2, '0');
return `${minutes}:${remainder}`;
}
+11
View File
@@ -0,0 +1,11 @@
import { StrictMode } from 'react';
import { createRoot } from 'react-dom/client';
import App from './App';
import './styles/index.css';
createRoot(document.getElementById('root')!).render(
<StrictMode>
<App />
</StrictMode>,
);
+33
View File
@@ -0,0 +1,33 @@
import { useEffect, useState } from 'react';
import { Link, useParams } from 'react-router-dom';
import { ClientGallery } from '../components/gallery/ClientGallery';
import { getGalleryPreview } from '../lib/api';
import type { PublicGallery } from '../types/gallery';
import { ClientError, ClientLoading } from './PublicGalleryPage';
export default function GalleryPreviewPage() {
const { id = '' } = useParams<{ id: string }>();
const [gallery, setGallery] = useState<PublicGallery | null>(null);
const [error, setError] = useState('');
useEffect(() => {
getGalleryPreview(id)
.then(setGallery)
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : 'Could not load preview.'),
);
}, [id]);
if (error)
return (
<>
<ClientError message={error} />
<Link className="preview-return-link" to={`/dashboard/galleries/${id}/edit`}>
Return to editor
</Link>
</>
);
if (!gallery) return <ClientLoading />;
return <ClientGallery gallery={gallery} />;
}
+88
View File
@@ -0,0 +1,88 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { Background } from '../components/gift/Background';
import { GiftExperience } from '../components/gift/GiftExperience';
import { getGift } from '../lib/api';
import type { Gift } from '../types/gift';
type GiftRequestState =
{ status: 'loading' } | { status: 'success'; gift: Gift } | { status: 'error'; message: string };
export default function GiftPage() {
const { slug } = useParams<{ slug: string }>();
const [attempt, setAttempt] = useState(0);
const [request, setRequest] = useState<GiftRequestState>({ status: 'loading' });
useEffect(() => {
if (!slug) {
return;
}
const controller = new AbortController();
setRequest({ status: 'loading' });
getGift(slug, controller.signal)
.then((gift) => setRequest({ status: 'success', gift }))
.catch((error: unknown) => {
if (error instanceof DOMException && error.name === 'AbortError') {
return;
}
setRequest({
status: 'error',
message: error instanceof Error ? error.message : 'This gift could not be opened.',
});
});
return () => controller.abort();
}, [attempt, slug]);
if (request.status === 'success') {
return <GiftExperience gift={request.gift} />;
}
if (request.status === 'error') {
return (
<Background tone="intro">
<div className="request-state">
<p className="eyebrow eyebrow--warm">a small hiccup</p>
<h1 className="display">This moment is hiding.</h1>
<p>{request.message}</p>
<button
className="primary-action"
type="button"
onClick={() => setAttempt((value) => value + 1)}
>
<span>Try again</span>
<span className="primary-action__icon" aria-hidden="true">
<svg viewBox="0 0 20 20" fill="none">
<path
d="M4 10h11M10.5 4.5 16 10l-5.5 5.5"
stroke="currentColor"
strokeWidth="1.5"
/>
</svg>
</span>
</button>
</div>
</Background>
);
}
return (
<Background tone="intro">
<div
className="request-state request-state--loading"
aria-busy="true"
aria-label="Loading your gift"
>
<span className="loading-mark" aria-hidden="true">
ls
</span>
<p className="eyebrow eyebrow--warm">opening something special</p>
<h1 className="display">Just a moment.</h1>
<span className="loading-line" aria-hidden="true" />
</div>
</Background>
);
}
+82
View File
@@ -0,0 +1,82 @@
import { useState } from 'react';
import { Link, useLocation, useNavigate } from 'react-router-dom';
import { AuthLayout } from '../components/app/AuthLayout';
import { useAuth } from '../features/auth/useAuth';
import { login } from '../lib/api';
export default function LoginPage() {
const navigate = useNavigate();
const location = useLocation();
const { setUser } = useAuth();
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError('');
try {
const user = await login(email, password);
setUser(user);
const destination = (location.state as { from?: string } | null)?.from || '/dashboard';
navigate(destination, { replace: true });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not sign in.');
} finally {
setSubmitting(false);
}
}
return (
<AuthLayout>
<div className="auth-card">
<div className="auth-card__heading">
<p className="platform-kicker platform-kicker--accent">Welcome back</p>
<h2 className="platform-display">
Your work,
<br />
<em>waiting.</em>
</h2>
<p>Sign in to keep shaping the way your clients experience their photographs.</p>
</div>
<form className="auth-form" onSubmit={submit}>
<label>
Email address
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
autoComplete="current-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button
className="platform-button platform-button--dark"
type="submit"
disabled={submitting}
>
<span>{submitting ? 'Opening studio...' : 'Sign in'}</span>
<span aria-hidden="true">&#8599;</span>
</button>
</form>
<p className="auth-card__footer">
New to Northline? <Link to="/register">Create an account</Link>
</p>
</div>
</AuthLayout>
);
}
+19
View File
@@ -0,0 +1,19 @@
import { Link } from 'react-router-dom';
export default function NotFoundPage() {
return (
<div className="client-error client-error--not-found">
<span className="client-loading__mark">N</span>
<p className="client-kicker">Nothing here yet</p>
<h1 className="client-display">
This link took
<br />
<em>a wrong turn.</em>
</h1>
<p>The gallery you are looking for may have moved or never existed.</p>
<Link className="client-download-ready" to="/login">
Return to studio <span aria-hidden="true">&#8599;</span>
</Link>
</div>
);
}
+66
View File
@@ -0,0 +1,66 @@
import { useEffect, useState } from 'react';
import { useParams } from 'react-router-dom';
import { ClientGallery } from '../components/gallery/ClientGallery';
import { PasswordGate } from '../components/gallery/PasswordGate';
import { getPublicGallery, authenticatePublicGallery } from '../lib/api';
import type { PublicGallery } from '../types/gallery';
export default function PublicGalleryPage() {
const { slug = '' } = useParams<{ slug: string }>();
const [gallery, setGallery] = useState<PublicGallery | null>(null);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
setLoading(true);
setError('');
getPublicGallery(slug)
.then(setGallery)
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : 'This gallery could not be opened.'),
)
.finally(() => setLoading(false));
}, [slug]);
if (loading) {
return <ClientLoading />;
}
if (error || !gallery) {
return <ClientError message={error || 'This gallery could not be opened.'} />;
}
if (gallery.requiresPassword) {
return (
<PasswordGate
gallery={gallery}
onUnlock={async (password) => setGallery(await authenticatePublicGallery(slug, password))}
/>
);
}
return <ClientGallery gallery={gallery} />;
}
export function ClientLoading() {
return (
<div className="client-loading">
<span className="client-loading__mark">N</span>
<p>Preparing your gallery</p>
<span className="client-loading__line" />
</div>
);
}
export function ClientError({ message }: { message: string }) {
return (
<div className="client-error">
<span className="client-loading__mark">N</span>
<p className="client-kicker">A quiet moment</p>
<h1 className="client-display">
This gallery is
<br />
<em>out of reach.</em>
</h1>
<p>{message}</p>
</div>
);
}
+91
View File
@@ -0,0 +1,91 @@
import { useState } from 'react';
import { Link, useNavigate } from 'react-router-dom';
import { AuthLayout } from '../components/app/AuthLayout';
import { useAuth } from '../features/auth/useAuth';
import { register } from '../lib/api';
export default function RegisterPage() {
const navigate = useNavigate();
const { setUser } = useAuth();
const [name, setName] = useState('');
const [email, setEmail] = useState('');
const [password, setPassword] = useState('');
const [error, setError] = useState('');
const [submitting, setSubmitting] = useState(false);
async function submit(event: React.FormEvent<HTMLFormElement>) {
event.preventDefault();
setSubmitting(true);
setError('');
try {
const user = await register(name, email, password);
setUser(user);
navigate('/dashboard', { replace: true });
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not create your account.');
} finally {
setSubmitting(false);
}
}
return (
<AuthLayout>
<div className="auth-card">
<div className="auth-card__heading">
<p className="platform-kicker platform-kicker--accent">Start your studio</p>
<h2 className="platform-display">
Make the handoff
<br />
<em>matter.</em>
</h2>
<p>Create a private home for the work your clients have been waiting to see.</p>
</div>
<form className="auth-form" onSubmit={submit}>
<label>
Studio or photographer name
<input
autoComplete="name"
value={name}
onChange={(event) => setName(event.target.value)}
required
/>
</label>
<label>
Email address
<input
type="email"
autoComplete="email"
value={email}
onChange={(event) => setEmail(event.target.value)}
required
/>
</label>
<label>
Password
<input
type="password"
autoComplete="new-password"
minLength={8}
value={password}
onChange={(event) => setPassword(event.target.value)}
required
/>
</label>
{error && <p className="form-error">{error}</p>}
<button
className="platform-button platform-button--dark"
type="submit"
disabled={submitting}
>
<span>{submitting ? 'Creating studio...' : 'Create account'}</span>
<span aria-hidden="true">&#8599;</span>
</button>
</form>
<p className="auth-card__footer">
Already have an account? <Link to="/login">Sign in</Link>
</p>
</div>
</AuthLayout>
);
}
@@ -0,0 +1,126 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
import { GalleryCard } from '../../components/dashboard/GalleryCard';
import { StudioStat } from '../../components/dashboard/StudioStat';
import { useAuth } from '../../features/auth/useAuth';
import { deleteGallery, getGalleries } from '../../lib/api';
import { formatBytes } from '../../lib/format';
import type { GallerySummary } from '../../types/gallery';
export default function DashboardPage() {
const { user } = useAuth();
const [galleries, setGalleries] = useState<GallerySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
getGalleries()
.then(setGalleries)
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : 'Could not load galleries.'),
)
.finally(() => setLoading(false));
}, []);
async function removeGallery(gallery: GallerySummary) {
if (!window.confirm(`Delete "${gallery.title}"? This cannot be undone.`)) return;
try {
await deleteGallery(gallery.id);
setGalleries((current) => current.filter((item) => item.id !== gallery.id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not delete gallery.');
}
}
const photos = galleries.reduce((total, gallery) => total + gallery.photoCount, 0);
const storage = galleries.reduce((total, gallery) => total + gallery.totalBytes, 0);
const published = galleries.filter((gallery) => gallery.status === 'published').length;
return (
<DashboardLayout>
<div className="studio-page">
<header className="studio-page__header">
<div>
<p className="studio-kicker">{user?.name || 'Studio'} / overview</p>
<h1 className="studio-display">
Make the handoff <em>matter.</em>
</h1>
<p className="studio-page__lede">
Everything your clients need, in one beautiful place.
</p>
</div>
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
<span>New gallery</span>
<span aria-hidden="true">+</span>
</Link>
</header>
<section className="studio-stats" aria-label="Studio overview">
<StudioStat
label="Galleries"
value={String(galleries.length).padStart(2, '0')}
note="All your work, in one place"
accent="coral"
/>
<StudioStat
label="Published"
value={String(published).padStart(2, '0')}
note="Currently out in the world"
accent="violet"
/>
<StudioStat
label="Photographs"
value={String(photos).padStart(2, '0')}
note="Ready to be remembered"
accent="gold"
/>
<StudioStat
label="Storage used"
value={formatBytes(storage)}
note="Across every gallery"
accent="ink"
/>
</section>
<section className="studio-section studio-section--recent">
<div className="studio-section__heading">
<div>
<p className="studio-kicker">Your latest work</p>
<h2 className="studio-heading">Recent galleries</h2>
</div>
<Link className="inline-link" to="/dashboard/galleries">
View all galleries <span aria-hidden="true">&#8599;</span>
</Link>
</div>
{error && <p className="studio-alert studio-alert--error">{error}</p>}
{loading ? (
<div className="studio-list-loading">
<span />
<span />
<span />
</div>
) : galleries.length === 0 ? (
<div className="studio-empty">
<span className="studio-empty__mark">+</span>
<div>
<h3>Your first gallery starts here.</h3>
<p>Give finished work a place that feels as considered as the work itself.</p>
</div>
<Link className="inline-link" to="/dashboard/galleries/new">
Create a gallery <span aria-hidden="true">&#8599;</span>
</Link>
</div>
) : (
<div className="gallery-grid gallery-grid--dashboard">
{galleries.slice(0, 3).map((gallery) => (
<GalleryCard key={gallery.id} gallery={gallery} onDelete={removeGallery} />
))}
</div>
)}
</section>
</div>
</DashboardLayout>
);
}
+299
View File
@@ -0,0 +1,299 @@
import { useEffect, useState, type ReactNode } from 'react';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
import { getDevDiagnostics, runDevStorageCheck } from '../../lib/api';
import type { DevDiagnostics, DevStorageCheck } from '../../types/dev';
interface UploadErrorLog {
at: string;
filename: string;
message: string;
}
export default function DevPage() {
const [diagnostics, setDiagnostics] = useState<DevDiagnostics | null>(null);
const [storageCheck, setStorageCheck] = useState<DevStorageCheck | null>(null);
const [uploadError, setUploadError] = useState<UploadErrorLog | null>(null);
const [loading, setLoading] = useState(true);
const [checking, setChecking] = useState(false);
const [error, setError] = useState('');
async function refresh() {
setLoading(true);
setError('');
try {
setDiagnostics(await getDevDiagnostics());
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not load diagnostics.');
} finally {
setLoading(false);
}
}
useEffect(() => {
void refresh();
try {
const raw = window.localStorage.getItem('northline:last-upload-error');
if (raw) setUploadError(JSON.parse(raw) as UploadErrorLog);
} catch {
setUploadError(null);
}
}, []);
async function checkStorage() {
setChecking(true);
try {
setStorageCheck(await runDevStorageCheck());
} catch (reason) {
setStorageCheck({
ok: false,
step: 'api',
error: reason instanceof Error ? reason.message : 'Storage check failed.',
});
} finally {
setChecking(false);
void refresh();
}
}
const browser = {
origin: window.location.origin,
online: navigator.onLine,
xhr: typeof XMLHttpRequest !== 'undefined',
fileApi: typeof File !== 'undefined' && typeof FileReader !== 'undefined',
secureContext: window.isSecureContext,
};
return (
<DashboardLayout>
<div className="studio-page dev-page">
<header className="studio-page__header studio-page__header--compact">
<div>
<p className="studio-kicker">Workspace / diagnostics</p>
<h1 className="studio-display">
The <em>workbench.</em>
</h1>
<p className="studio-page__lede">
Useful, non-secret signals for local development and upload debugging.
</p>
</div>
<div className="dev-page__actions">
<button
className="editor-button editor-button--quiet"
type="button"
onClick={() => void refresh()}
disabled={loading}
>
Refresh
</button>
<button
className="editor-button editor-button--accent"
type="button"
onClick={() => void checkStorage()}
disabled={checking}
>
{checking ? 'Checking...' : 'Test storage'}
</button>
</div>
</header>
{error && <p className="studio-alert studio-alert--error">{error}</p>}
<div className="dev-grid">
<DevPanel title="Runtime" eyebrow="01 / API">
{loading || !diagnostics ? (
<DevLoading />
) : (
<>
<DevRow label="Environment" value={diagnostics.environment} />
<DevRow label="API time" value={diagnostics.now} />
<DevRow label="Account" value={diagnostics.user.email} />
<DevRow
label="Session cookie"
value={diagnostics.http.cookieSecure ? 'Secure' : 'Local / insecure'}
/>
</>
)}
</DevPanel>
<DevPanel title="Database" eyebrow="02 / Persistence">
{loading || !diagnostics ? (
<DevLoading />
) : (
<>
<DevStatus
label={diagnostics.database.driver}
ok={diagnostics.database.connected}
/>
<DevRow
label="Connection"
value={
diagnostics.database.connected
? 'Reachable'
: diagnostics.database.error || 'Unavailable'
}
/>
</>
)}
</DevPanel>
<DevPanel title="Object storage" eyebrow="03 / MinIO">
{loading || !diagnostics ? (
<DevLoading />
) : (
<>
<DevStatus
label={diagnostics.storage.provider}
ok={diagnostics.storage.reachable}
/>
<DevRow label="Endpoint" value={diagnostics.storage.endpoint} />
<DevRow label="Bucket" value={diagnostics.storage.bucket} />
<DevRow label="Protocol" value={diagnostics.storage.secure ? 'HTTPS' : 'HTTP'} />
{diagnostics.storage.error && (
<p className="dev-panel__error">{diagnostics.storage.error}</p>
)}
</>
)}
</DevPanel>
<DevPanel title="Browser" eyebrow="04 / Client">
<DevRow label="Origin" value={browser.origin} />
<DevRow label="Network" value={browser.online ? 'Online' : 'Offline'} />
<DevRow label="XMLHttpRequest" value={browser.xhr ? 'Available' : 'Unavailable'} />
<DevRow label="File API" value={browser.fileApi ? 'Available' : 'Unavailable'} />
<DevRow
label="Secure context"
value={browser.secureContext ? 'Yes' : 'No (normal for local HTTP)'}
/>
</DevPanel>
</div>
<section className="dev-panel dev-panel--wide">
<div className="dev-panel__heading">
<p className="studio-kicker">05 / Upload path</p>
<h2>What happens when you choose a file.</h2>
</div>
<div className="dev-flow">
<span>
01 <strong>API URL</strong>
<small>Creates media metadata</small>
</span>
<i>&#8594;</i>
<span>
02 <strong>Presigned PUT</strong>
<small>Browser to MinIO</small>
</span>
<i>&#8594;</i>
<span>
03 <strong>Complete</strong>
<small>Stat object and queue processing</small>
</span>
<i>&#8594;</i>
<span>
04 <strong>Ready</strong>
<small>Preview becomes visible</small>
</span>
</div>
<p className="dev-hint">
If an upload stops at 0%, the failure is usually the browser reaching the presigned
MinIO origin. Check that the browser origin below is listed in the MinIO bucket CORS
rule.
</p>
{diagnostics && (
<div className="dev-cors">
<span>Effective browser origins</span>
{diagnostics.http.corsOrigins.map((origin) => (
<code key={origin}>{origin}</code>
))}
</div>
)}
</section>
<section className="dev-panel dev-panel--wide">
<div className="dev-panel__heading">
<p className="studio-kicker">06 / Last client failure</p>
<h2>Recent upload signal.</h2>
</div>
{uploadError ? (
<div className="dev-last-error">
<span className="dev-last-error__mark">!</span>
<div>
<strong>{uploadError.filename}</strong>
<p>{uploadError.message}</p>
<small>{new Date(uploadError.at).toLocaleString()}</small>
</div>
<button
type="button"
onClick={() => {
window.localStorage.removeItem('northline:last-upload-error');
setUploadError(null);
}}
>
Clear
</button>
</div>
) : (
<p className="dev-empty">
No client-side upload failures have been recorded in this browser.
</p>
)}
{storageCheck && (
<div className={`dev-check-result ${storageCheck.ok ? 'is-ok' : 'is-failed'}`}>
<strong>{storageCheck.ok ? 'Storage check passed' : 'Storage check failed'}</strong>
<span>
{storageCheck.ok
? `${storageCheck.bytes} bytes / ${storageCheck.contentType}`
: `${storageCheck.step}: ${storageCheck.error}`}
</span>
</div>
)}
</section>
<details className="dev-raw">
<summary>Show raw diagnostics JSON</summary>
<pre>{diagnostics ? JSON.stringify(diagnostics, null, 2) : 'No response yet.'}</pre>
</details>
</div>
</DashboardLayout>
);
}
function DevPanel({
title,
eyebrow,
children,
}: {
title: string;
eyebrow: string;
children: ReactNode;
}) {
return (
<section className="dev-panel">
<div className="dev-panel__heading">
<p className="studio-kicker">{eyebrow}</p>
<h2>{title}</h2>
</div>
{children}
</section>
);
}
function DevRow({ label, value }: { label: string; value: string }) {
return (
<div className="dev-row">
<span>{label}</span>
<strong>{value}</strong>
</div>
);
}
function DevStatus({ label, ok }: { label: string; ok: boolean }) {
return (
<div className={`dev-status ${ok ? 'is-ok' : 'is-failed'}`}>
<i /> <strong>{label}</strong>
<span>{ok ? 'reachable' : 'unavailable'}</span>
</div>
);
}
function DevLoading() {
return (
<div className="dev-loading">
<span />
<span />
<span />
</div>
);
}
@@ -0,0 +1,82 @@
import { useEffect, useState } from 'react';
import { Link } from 'react-router-dom';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
import { GalleryCard } from '../../components/dashboard/GalleryCard';
import { deleteGallery, getGalleries } from '../../lib/api';
import type { GallerySummary } from '../../types/gallery';
export default function GalleriesPage() {
const [galleries, setGalleries] = useState<GallerySummary[]>([]);
const [loading, setLoading] = useState(true);
const [error, setError] = useState('');
useEffect(() => {
getGalleries()
.then(setGalleries)
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : 'Could not load galleries.'),
)
.finally(() => setLoading(false));
}, []);
async function removeGallery(gallery: GallerySummary) {
if (!window.confirm(`Delete "${gallery.title}"? This cannot be undone.`)) return;
try {
await deleteGallery(gallery.id);
setGalleries((current) => current.filter((item) => item.id !== gallery.id));
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not delete gallery.');
}
}
return (
<DashboardLayout>
<div className="studio-page">
<header className="studio-page__header studio-page__header--compact">
<div>
<p className="studio-kicker">Workspace / library</p>
<h1 className="studio-display">
Your <em>galleries.</em>
</h1>
<p className="studio-page__lede">
The places where your finished work becomes a shared memory.
</p>
</div>
<Link className="platform-button platform-button--accent" to="/dashboard/galleries/new">
<span>New gallery</span>
<span aria-hidden="true">+</span>
</Link>
</header>
{error && <p className="studio-alert studio-alert--error">{error}</p>}
{loading ? (
<div className="studio-list-loading">
<span />
<span />
<span />
</div>
) : galleries.length === 0 ? (
<div className="studio-empty studio-empty--large">
<span className="studio-empty__mark">+</span>
<div>
<h3>A quiet room, waiting.</h3>
<p>
Create a gallery and give your next client a delivery experience they will remember.
</p>
</div>
<Link className="platform-button platform-button--dark" to="/dashboard/galleries/new">
<span>Create your first gallery</span>
<span aria-hidden="true">&#8599;</span>
</Link>
</div>
) : (
<div className="gallery-grid">
{galleries.map((gallery) => (
<GalleryCard key={gallery.id} gallery={gallery} onDelete={removeGallery} />
))}
</div>
)}
</div>
</DashboardLayout>
);
}
@@ -0,0 +1,653 @@
import { useEffect, useState, type FormEvent } from 'react';
import { Link, useNavigate, useParams } from 'react-router-dom';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
import { UploadDropzone } from '../../components/dashboard/UploadDropzone';
import { useAuth } from '../../features/auth/useAuth';
import {
createGallery,
deleteMedia,
getGallery,
publishGallery,
updateGallery,
updateMediaOrder,
} from '../../lib/api';
import { formatBytes, formatDuration } from '../../lib/format';
import type { GalleryDetail, GalleryLayout, GalleryMode, MediaItem } from '../../types/gallery';
interface EditorDraft {
title: string;
clientName: string;
description: string;
downloadsEnabled: boolean;
favoritesEnabled: boolean;
downloadAllEnabled: boolean;
watermarkEnabled: boolean;
expiresAt: string;
coverMediaId: string;
themeMode: GalleryMode;
layout: GalleryLayout;
accent: string;
font: 'sans' | 'serif';
studioName: string;
tagline: string;
websiteUrl: string;
instagramUrl: string;
}
const initialDraft: EditorDraft = {
title: '',
clientName: '',
description: '',
downloadsEnabled: true,
favoritesEnabled: true,
downloadAllEnabled: true,
watermarkEnabled: false,
expiresAt: '',
coverMediaId: '',
themeMode: 'light',
layout: 'editorial',
accent: '#ad695b',
font: 'serif',
studioName: '',
tagline: '',
websiteUrl: '',
instagramUrl: '',
};
function draftFromGallery(gallery: GalleryDetail): EditorDraft {
return {
title: gallery.title,
clientName: gallery.clientName,
description: gallery.description,
downloadsEnabled: gallery.downloadsEnabled,
favoritesEnabled: gallery.favoritesEnabled,
downloadAllEnabled: gallery.downloadAllEnabled,
watermarkEnabled: gallery.watermarkEnabled,
expiresAt: gallery.expiresAt ? gallery.expiresAt.slice(0, 10) : '',
coverMediaId: gallery.coverMediaId || '',
themeMode: gallery.themeConfig.mode || 'light',
layout: gallery.themeConfig.layout || 'editorial',
accent: gallery.themeConfig.accent || '#ad695b',
font: gallery.themeConfig.font || 'serif',
studioName: gallery.brandingConfig.studioName || '',
tagline: gallery.brandingConfig.tagline || '',
websiteUrl: gallery.brandingConfig.websiteUrl || '',
instagramUrl: gallery.brandingConfig.instagramUrl || '',
};
}
export default function GalleryEditorPage() {
const { id = 'new' } = useParams<{ id: string }>();
const navigate = useNavigate();
const { user } = useAuth();
const isNew = id === 'new';
const [gallery, setGallery] = useState<GalleryDetail | null>(null);
const [draft, setDraft] = useState<EditorDraft>(initialDraft);
const [password, setPassword] = useState('');
const [clearPassword, setClearPassword] = useState(false);
const [loading, setLoading] = useState(!isNew);
const [saving, setSaving] = useState(false);
const [publishing, setPublishing] = useState(false);
const [notice, setNotice] = useState('');
const [error, setError] = useState('');
useEffect(() => {
if (isNew) return;
getGallery(id)
.then((value) => {
setGallery(value);
setDraft(draftFromGallery(value));
})
.catch((reason: unknown) =>
setError(reason instanceof Error ? reason.message : 'Could not load gallery.'),
)
.finally(() => setLoading(false));
}, [id, isNew]);
function setField<K extends keyof EditorDraft>(field: K, value: EditorDraft[K]) {
setDraft((current) => ({ ...current, [field]: value }));
}
async function refreshGallery() {
if (isNew) return;
try {
const value = await getGallery(id);
setGallery(value);
setDraft((current) => ({
...current,
coverMediaId: value.coverMediaId || current.coverMediaId,
}));
} catch {
// A completed upload can take a moment to appear while processing.
}
}
async function save(): Promise<GalleryDetail | null> {
setSaving(true);
setError('');
try {
if (isNew) {
const created = await createGallery({
title: draft.title,
clientName: draft.clientName,
description: draft.description,
});
setGallery(created);
setDraft(draftFromGallery(created));
navigate(`/dashboard/galleries/${created.id}/edit`, { replace: true });
setNotice('Gallery created');
return created;
}
const updated = await updateGallery(id, {
title: draft.title,
clientName: draft.clientName,
description: draft.description,
downloadsEnabled: draft.downloadsEnabled,
favoritesEnabled: draft.favoritesEnabled,
downloadAllEnabled: draft.downloadAllEnabled,
watermarkEnabled: draft.watermarkEnabled,
expiresAt: draft.expiresAt ? new Date(`${draft.expiresAt}T23:59:59Z`).toISOString() : '',
coverMediaId: draft.coverMediaId,
themeConfig: {
mode: draft.themeMode,
layout: draft.layout,
accent: draft.accent,
font: draft.font,
},
brandingConfig: {
studioName: draft.studioName,
tagline: draft.tagline,
websiteUrl: draft.websiteUrl,
instagramUrl: draft.instagramUrl,
},
...(password ? { password } : {}),
...(clearPassword ? { clearPassword: true } : {}),
});
setGallery(updated);
setDraft(draftFromGallery(updated));
setPassword('');
setClearPassword(false);
setNotice('Changes saved');
return updated;
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not save gallery.');
return null;
} finally {
setSaving(false);
}
}
async function publish() {
setPublishing(true);
const saved = await save();
const galleryId = saved?.id || gallery?.id;
if (galleryId) {
try {
const published = await publishGallery(galleryId);
setGallery(published);
setNotice('Gallery published');
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not publish gallery.');
}
}
setPublishing(false);
}
async function removeMedia(item: MediaItem) {
if (!window.confirm(`Remove ${item.originalFilename}?`)) return;
try {
await deleteMedia(item.id);
setGallery((current) =>
current
? { ...current, media: current.media.filter((media) => media.id !== item.id) }
: current,
);
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not remove media.');
}
}
async function moveMedia(item: MediaItem, direction: -1 | 1) {
if (!gallery) return;
const index = gallery.media.findIndex((media) => media.id === item.id);
const other = gallery.media[index + direction];
if (!other) return;
try {
await updateMediaOrder(item.id, other.sortOrder);
await updateMediaOrder(other.id, item.sortOrder);
await refreshGallery();
} catch (reason) {
setError(reason instanceof Error ? reason.message : 'Could not reorder media.');
}
}
function submit(event: FormEvent<HTMLFormElement>) {
event.preventDefault();
void save();
}
if (loading) {
return (
<DashboardLayout>
<div className="studio-page studio-page--loading">
<span className="app-loading__mark">N</span>
<p>Opening gallery editor...</p>
</div>
</DashboardLayout>
);
}
return (
<DashboardLayout>
<div className="editor-page">
<header className="editor-header">
<div className="editor-header__back">
<Link to="/dashboard/galleries">&#8592; All galleries</Link>
<span>/</span>
<span>{isNew ? 'New gallery' : draft.title || 'Untitled gallery'}</span>
</div>
<div className="editor-header__actions">
{gallery && (
<Link className="editor-button editor-button--quiet" to={`/preview/${gallery.id}`}>
Preview <span aria-hidden="true">&#8599;</span>
</Link>
)}
<button
className="editor-button editor-button--quiet"
type="button"
onClick={() => void save()}
disabled={saving}
>
{saving ? 'Saving...' : 'Save changes'}
</button>
<button
className="editor-button editor-button--accent"
type="button"
onClick={() => void publish()}
disabled={publishing}
>
{publishing
? 'Publishing...'
: gallery?.status === 'published'
? 'Republish'
: 'Publish gallery'}{' '}
<span aria-hidden="true">&#8599;</span>
</button>
</div>
</header>
<div className="editor-titlebar">
<div>
<p className="studio-kicker">
{isNew ? 'New delivery' : `Editing / ${gallery?.status || 'draft'}`}
</p>
<h1 className="studio-display">
{isNew ? (
<>
A new <em>story.</em>
</>
) : (
<>
{draft.title || 'Untitled'} <em>gallery.</em>
</>
)}
</h1>
</div>
{gallery?.status === 'published' && (
<span className="editor-live">
<i /> Live at /g/{gallery.slug}
</span>
)}
</div>
{error && <p className="studio-alert studio-alert--error">{error}</p>}
{notice && <p className="studio-alert studio-alert--success">{notice}</p>}
<form className="editor-layout" onSubmit={submit}>
<div className="editor-maincol">
<section className="editor-section editor-section--first">
<div className="editor-section__heading">
<p className="studio-kicker">01 / The introduction</p>
<h2>Give it a name.</h2>
<p>This is the first thing your client will see.</p>
</div>
<div className="editor-fields editor-fields--two">
<label>
Gallery title
<input
value={draft.title}
onChange={(event) => setField('title', event.target.value)}
placeholder="Emma & James — Wedding"
required
/>
</label>
<label>
Client name
<input
value={draft.clientName}
onChange={(event) => setField('clientName', event.target.value)}
placeholder="Emma & James"
required
/>
</label>
</div>
<label className="editor-fields__full">
A note about this gallery
<textarea
value={draft.description}
onChange={(event) => setField('description', event.target.value)}
placeholder="A few words to set the scene..."
rows={3}
/>
</label>
</section>
<section className="editor-section">
<div className="editor-section__heading">
<p className="studio-kicker">02 / The work</p>
<h2>Bring it all in.</h2>
<p>
Originals stay private in object storage. Previews are prepared in the background.
</p>
</div>
{gallery ? (
<UploadDropzone
galleryId={gallery.id}
onMedia={(item) =>
setGallery((current) =>
current ? { ...current, media: [...current.media, item] } : current,
)
}
onRefresh={() => void refreshGallery()}
/>
) : (
<div className="editor-locked">
<span>01</span>
<p>Save the gallery details above to start uploading work.</p>
</div>
)}
{gallery && (
<div className="editor-media-grid">
{gallery.media.map((item, index) => (
<EditorMediaTile
key={item.id}
item={item}
index={index}
total={gallery.media.length}
cover={draft.coverMediaId === item.id}
onCover={() => setField('coverMediaId', item.id)}
onMove={(direction) => void moveMedia(item, direction)}
onDelete={() => void removeMedia(item)}
/>
))}
</div>
)}
{gallery && gallery.media.length === 0 && (
<div className="editor-media-empty">
Your uploaded photographs will take center stage here.
</div>
)}
</section>
</div>
<aside className="editor-aside">
<section className="editor-section editor-section--aside">
<div className="editor-section__heading">
<p className="studio-kicker">03 / Client controls</p>
<h2>Set the boundaries.</h2>
</div>
<div className="toggle-list">
<Toggle
label="Downloads"
hint="Let clients save the originals."
checked={draft.downloadsEnabled}
onChange={(value) => setField('downloadsEnabled', value)}
/>
<Toggle
label="Favorites"
hint="Let clients mark their favorites."
checked={draft.favoritesEnabled}
onChange={(value) => setField('favoritesEnabled', value)}
/>
<Toggle
label="Download all"
hint="Offer a single gallery ZIP."
checked={draft.downloadAllEnabled}
onChange={(value) => setField('downloadAllEnabled', value)}
/>
<Toggle
label="Watermark previews"
hint="Add a light overlay to previews."
checked={draft.watermarkEnabled}
onChange={(value) => setField('watermarkEnabled', value)}
/>
</div>
<label className="editor-field-single">
Gallery password{' '}
<input
type="password"
autoComplete="new-password"
value={password}
onChange={(event) => setPassword(event.target.value)}
placeholder="Leave blank for none"
/>
</label>
<label className="checkbox-line">
<input
type="checkbox"
checked={clearPassword}
onChange={(event) => setClearPassword(event.target.checked)}
/>
<span>Remove existing password</span>
</label>
<label className="editor-field-single">
Gallery expires{' '}
<input
type="date"
value={draft.expiresAt}
onChange={(event) => setField('expiresAt', event.target.value)}
/>
</label>
</section>
<section className="editor-section editor-section--aside">
<div className="editor-section__heading">
<p className="studio-kicker">04 / The atmosphere</p>
<h2>Make it feel like you.</h2>
</div>
<div className="choice-label">Mode</div>
<div className="choice-row">
{(['light', 'dark'] as GalleryMode[]).map((mode) => (
<button
className={draft.themeMode === mode ? 'is-selected' : ''}
type="button"
key={mode}
onClick={() => setField('themeMode', mode)}
>
{mode}
</button>
))}
</div>
<div className="choice-label">Layout</div>
<div className="choice-row choice-row--wrap">
{(['editorial', 'masonry', 'grid'] as GalleryLayout[]).map((layout) => (
<button
className={draft.layout === layout ? 'is-selected' : ''}
type="button"
key={layout}
onClick={() => setField('layout', layout)}
>
{layout}
</button>
))}
</div>
<label className="editor-color-field">
Accent color{' '}
<span>
<input
type="color"
value={draft.accent}
onChange={(event) => setField('accent', event.target.value)}
/>
<code>{draft.accent}</code>
</span>
</label>
<div className="choice-label">Type</div>
<div className="choice-row">
<button
className={draft.font === 'serif' ? 'is-selected' : ''}
type="button"
onClick={() => setField('font', 'serif')}
>
Editorial
</button>
<button
className={draft.font === 'sans' ? 'is-selected' : ''}
type="button"
onClick={() => setField('font', 'sans')}
>
Clean
</button>
</div>
</section>
<section className="editor-section editor-section--aside">
<div className="editor-section__heading">
<p className="studio-kicker">05 / Your signature</p>
<h2>Leave your mark.</h2>
</div>
<label className="editor-field-single">
Studio name{' '}
<input
value={draft.studioName}
onChange={(event) => setField('studioName', event.target.value)}
placeholder={user?.name || 'Your studio'}
/>
</label>
<label className="editor-field-single">
Tagline{' '}
<input
value={draft.tagline}
onChange={(event) => setField('tagline', event.target.value)}
placeholder="Photographs for keeps."
/>
</label>
<label className="editor-field-single">
Website{' '}
<input
type="url"
value={draft.websiteUrl}
onChange={(event) => setField('websiteUrl', event.target.value)}
placeholder="https://yourstudio.com"
/>
</label>
<label className="editor-field-single">
Instagram{' '}
<input
value={draft.instagramUrl}
onChange={(event) => setField('instagramUrl', event.target.value)}
placeholder="https://instagram.com/yourstudio"
/>
</label>
</section>
</aside>
</form>
</div>
</DashboardLayout>
);
}
function Toggle({
label,
hint,
checked,
onChange,
}: {
label: string;
hint: string;
checked: boolean;
onChange: (value: boolean) => void;
}) {
return (
<label className="toggle-line">
<span>
<strong>{label}</strong>
<small>{hint}</small>
</span>
<input
type="checkbox"
checked={checked}
onChange={(event) => onChange(event.target.checked)}
/>
<i aria-hidden="true" />
</label>
);
}
function EditorMediaTile({
item,
index,
total,
cover,
onCover,
onMove,
onDelete,
}: {
item: MediaItem;
index: number;
total: number;
cover: boolean;
onCover: () => void;
onMove: (direction: -1 | 1) => void;
onDelete: () => void;
}) {
const isVideo = item.mimeType.startsWith('video/');
return (
<article className={`editor-media-tile ${cover ? 'is-cover' : ''}`}>
<div className="editor-media-tile__image">
{item.previewUrl ? (
<img src={item.previewUrl} alt="" loading="lazy" />
) : (
<div className="editor-media-tile__placeholder">
<span>{item.processingStatus}</span>
</div>
)}
{isVideo && (
<span className="editor-media-tile__video">
VIDEO {formatDuration(item.durationSeconds)}
</span>
)}
{cover && <span className="editor-media-tile__cover">Cover</span>}
</div>
<div className="editor-media-tile__body">
<strong>{item.originalFilename}</strong>
<small>
{formatBytes(item.fileSize)} / {item.processingStatus.toLowerCase()}
</small>
</div>
<div className="editor-media-tile__actions">
<button type="button" onClick={onCover}>
{cover ? 'Cover image' : 'Set cover'}
</button>
<button
type="button"
disabled={index === 0}
onClick={() => onMove(-1)}
aria-label="Move media earlier"
>
&#8593;
</button>
<button
type="button"
disabled={index === total - 1}
onClick={() => onMove(1)}
aria-label="Move media later"
>
&#8595;
</button>
<button type="button" onClick={onDelete} aria-label={`Delete ${item.originalFilename}`}>
&#215;
</button>
</div>
</article>
);
}
@@ -0,0 +1,25 @@
import { Link } from 'react-router-dom';
import { DashboardLayout } from '../../components/dashboard/DashboardLayout';
interface PlaceholderPageProps {
title: string;
description: string;
}
export default function PlaceholderPage({ title, description }: PlaceholderPageProps) {
return (
<DashboardLayout>
<div className="studio-page studio-page--placeholder">
<p className="studio-kicker">Workspace / soon</p>
<h1 className="studio-display">
{title} <em>is coming.</em>
</h1>
<p className="studio-page__lede">{description}</p>
<Link className="inline-link" to="/dashboard">
Return to overview <span aria-hidden="true">&#8599;</span>
</Link>
</div>
</DashboardLayout>
);
}
File diff suppressed because it is too large Load Diff
+34
View File
@@ -0,0 +1,34 @@
export interface DevDiagnostics {
environment: string;
now: string;
user: {
id: string;
email: string;
name: string;
};
database: {
driver: string;
connected: boolean;
error?: string;
};
storage: {
provider: string;
endpoint: string;
bucket: string;
secure: boolean;
reachable: boolean;
error?: string;
};
http: {
corsOrigins: string[];
cookieSecure: boolean;
};
}
export interface DevStorageCheck {
ok: boolean;
bytes?: number;
contentType?: string;
step?: string;
error?: string;
}
+87
View File
@@ -0,0 +1,87 @@
export type GalleryStatus = 'draft' | 'published' | 'archived';
export type ProcessingStatus = 'UPLOADING' | 'PROCESSING' | 'READY' | 'FAILED';
export type GalleryLayout = 'grid' | 'masonry' | 'editorial';
export type GalleryMode = 'light' | 'dark';
export interface ThemeConfig {
mode?: GalleryMode;
layout?: GalleryLayout;
accent?: string;
font?: 'sans' | 'serif';
}
export interface BrandingConfig {
studioName?: string;
tagline?: string;
logoUrl?: string;
websiteUrl?: string;
instagramUrl?: string;
}
export interface MediaItem {
id: string;
originalFilename: string;
mimeType: string;
fileSize: number;
processingStatus: ProcessingStatus;
width?: number;
height?: number;
durationSeconds?: number;
sortOrder: number;
thumbnailUrl?: string;
previewUrl?: string;
originalUrl?: string;
favorited?: boolean;
}
export interface GallerySummary {
id: string;
slug: string;
title: string;
clientName: string;
description: string;
status: GalleryStatus;
downloadsEnabled: boolean;
favoritesEnabled: boolean;
downloadAllEnabled: boolean;
watermarkEnabled: boolean;
expiresAt?: string;
coverMediaId?: string;
coverUrl?: string;
themeConfig: ThemeConfig;
brandingConfig: BrandingConfig;
createdAt: string;
publishedAt?: string;
photoCount: number;
videoCount: number;
totalBytes: number;
}
export interface GalleryDetail extends GallerySummary {
media: MediaItem[];
}
export interface PublicGallery {
slug: string;
title: string;
clientName: string;
description: string;
themeConfig: ThemeConfig;
brandingConfig: BrandingConfig;
downloadsEnabled?: boolean;
favoritesEnabled?: boolean;
downloadAllEnabled?: boolean;
watermarkEnabled?: boolean;
expiresAt?: string;
preview?: boolean;
requiresPassword: boolean;
cover?: MediaItem;
media: MediaItem[];
}
export interface DownloadJob {
jobId: string;
status: 'QUEUED' | 'PROCESSING' | 'READY' | 'FAILED';
url?: string;
error?: string;
}
+20
View File
@@ -0,0 +1,20 @@
export interface GiftItem {
id: string;
type: string;
title?: string;
text?: string;
mediaUrl?: string;
sortOrder: number;
metadata?: Record<string, unknown>;
}
export interface Gift {
id: string;
slug: string;
recipientName: string;
senderName: string;
title: string;
introMessage: string;
revealMessage: string;
items: GiftItem[];
}
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />
+9
View File
@@ -0,0 +1,9 @@
import type { Config } from 'tailwindcss';
export default {
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
theme: {
extend: {},
},
plugins: [],
} satisfies Config;
+21
View File
@@ -0,0 +1,21 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
"target": "ES2022",
"useDefineForClassFields": true,
"lib": ["ES2022", "DOM", "DOM.Iterable"],
"allowJs": false,
"skipLibCheck": true,
"esModuleInterop": true,
"allowSyntheticDefaultImports": true,
"strict": true,
"forceConsistentCasingInFileNames": true,
"module": "ESNext",
"moduleResolution": "Bundler",
"resolveJsonModule": true,
"isolatedModules": true,
"noEmit": true,
"jsx": "react-jsx"
},
"include": ["src"]
}
+4
View File
@@ -0,0 +1,4 @@
{
"files": [],
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
}
+17
View File
@@ -0,0 +1,17 @@
{
"compilerOptions": {
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
"target": "ES2023",
"lib": ["ES2023"],
"module": "ESNext",
"skipLibCheck": true,
"moduleResolution": "Bundler",
"allowImportingTsExtensions": true,
"verbatimModuleSyntax": true,
"moduleDetection": "force",
"noEmit": true,
"strict": true,
"types": ["node"]
},
"include": ["vite.config.ts", "tailwind.config.ts"]
}
+11
View File
@@ -0,0 +1,11 @@
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';
export default defineConfig({
envDir: '..',
plugins: [react()],
server: {
port: 5173,
strictPort: true,
},
});
+31
View File
@@ -0,0 +1,31 @@
CREATE EXTENSION IF NOT EXISTS pgcrypto;
CREATE TABLE IF NOT EXISTS gifts (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
slug VARCHAR(100) NOT NULL UNIQUE,
recipient_name TEXT NOT NULL,
sender_name TEXT NOT NULL,
title TEXT NOT NULL,
intro_message TEXT NOT NULL,
reveal_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT gifts_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE TABLE IF NOT EXISTS gift_items (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gift_id UUID NOT NULL REFERENCES gifts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT,
text TEXT,
media_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT gift_items_type_check CHECK (length(trim(type)) > 0)
);
CREATE INDEX IF NOT EXISTS gift_items_gift_id_sort_order_idx
ON gift_items (gift_id, sort_order, id);
+91
View File
@@ -0,0 +1,91 @@
INSERT INTO gifts (
id,
slug,
recipient_name,
sender_name,
title,
intro_message,
reveal_message,
status
)
VALUES (
'11111111-1111-4111-8111-111111111111',
'demo',
'Anna',
'Alex',
'A little surprise for you',
'I made something for you.',
'You make ordinary days feel like something worth remembering. Here is to all the little adventures still ahead.',
'published'
)
ON CONFLICT (slug) DO UPDATE SET
recipient_name = EXCLUDED.recipient_name,
sender_name = EXCLUDED.sender_name,
title = EXCLUDED.title,
intro_message = EXCLUDED.intro_message,
reveal_message = EXCLUDED.reveal_message,
status = EXCLUDED.status,
updated_at = NOW();
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'22222222-2222-4222-8222-222222222222',
id,
'image',
'The good kind of ordinary.',
'The tiny moments are usually the ones that stay with us the longest.',
'https://images.unsplash.com/photo-1511988617509-8e73b0f3b7d2?auto=format&fit=crop&w=1400&q=85',
1,
'{"caption":"A little snapshot of a very good day.","accent":"coral"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'33333333-3333-4333-8333-333333333333',
id,
'text',
'A note for your next chapter.',
'Keep choosing the places, people, and tiny rituals that make you feel most like yourself. I will always be cheering for you.',
NULL,
2,
'{"eyebrow":"A note from me","accent":"lavender"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'44444444-4444-4444-8444-444444444444',
id,
'text',
'One more thing.',
'There is nowhere else I would rather be than somewhere in the middle of the next story with you.',
NULL,
3,
'{"eyebrow":"P.S.","accent":"gold"}'::jsonb
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = EXCLUDED.gift_id,
type = EXCLUDED.type,
title = EXCLUDED.title,
text = EXCLUDED.text,
media_url = EXCLUDED.media_url,
sort_order = EXCLUDED.sort_order,
metadata = EXCLUDED.metadata;
+92
View File
@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS users (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_idx ON users (LOWER(email));
CREATE TABLE IF NOT EXISTS galleries (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
user_id UUID NOT NULL REFERENCES users(id) ON DELETE CASCADE,
slug VARCHAR(160) NOT NULL UNIQUE,
title TEXT NOT NULL,
client_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
password_hash TEXT,
downloads_enabled BOOLEAN NOT NULL DEFAULT TRUE,
favorites_enabled BOOLEAN NOT NULL DEFAULT TRUE,
download_all_enabled BOOLEAN NOT NULL DEFAULT TRUE,
watermark_enabled BOOLEAN NOT NULL DEFAULT FALSE,
expires_at TIMESTAMPTZ,
cover_media_id UUID,
theme_config JSONB NOT NULL DEFAULT '{}'::jsonb,
branding_config JSONB NOT NULL DEFAULT '{}'::jsonb,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
published_at TIMESTAMPTZ,
CONSTRAINT galleries_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE INDEX IF NOT EXISTS galleries_user_id_created_at_idx ON galleries (user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS media (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id UUID NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size BIGINT NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL,
external_url TEXT,
thumbnail_key TEXT,
preview_key TEXT,
processing_status TEXT NOT NULL DEFAULT 'UPLOADING',
processing_error TEXT,
width INTEGER,
height INTEGER,
duration_seconds DOUBLE PRECISION,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
CONSTRAINT media_processing_status_check CHECK (processing_status IN ('UPLOADING', 'PROCESSING', 'READY', 'FAILED'))
);
CREATE INDEX IF NOT EXISTS media_gallery_sort_order_idx ON media (gallery_id, sort_order, id);
CREATE TABLE IF NOT EXISTS favorites (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id UUID NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
media_id UUID NOT NULL REFERENCES media(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
UNIQUE (gallery_id, media_id, visitor_id)
);
CREATE TABLE IF NOT EXISTS downloads (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id UUID NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
media_id UUID REFERENCES media(id) ON DELETE SET NULL,
visitor_id TEXT NOT NULL,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW()
);
CREATE INDEX IF NOT EXISTS downloads_gallery_created_at_idx ON downloads (gallery_id, created_at DESC);
CREATE TABLE IF NOT EXISTS download_jobs (
id UUID PRIMARY KEY DEFAULT gen_random_uuid(),
gallery_id UUID NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'QUEUED',
storage_key TEXT,
error TEXT,
created_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
updated_at TIMESTAMPTZ NOT NULL DEFAULT NOW(),
completed_at TIMESTAMPTZ,
CONSTRAINT download_jobs_status_check CHECK (status IN ('QUEUED', 'PROCESSING', 'READY', 'FAILED'))
);
CREATE INDEX IF NOT EXISTS download_jobs_gallery_visitor_idx ON download_jobs (gallery_id, visitor_id, created_at DESC);
+29
View File
@@ -0,0 +1,29 @@
CREATE TABLE IF NOT EXISTS gifts (
id TEXT PRIMARY KEY NOT NULL,
slug TEXT NOT NULL UNIQUE,
recipient_name TEXT NOT NULL,
sender_name TEXT NOT NULL,
title TEXT NOT NULL,
intro_message TEXT NOT NULL,
reveal_message TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'published',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT gifts_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE TABLE IF NOT EXISTS gift_items (
id TEXT PRIMARY KEY NOT NULL,
gift_id TEXT NOT NULL REFERENCES gifts(id) ON DELETE CASCADE,
type TEXT NOT NULL,
title TEXT,
text TEXT,
media_url TEXT,
sort_order INTEGER NOT NULL DEFAULT 0,
metadata TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT gift_items_type_check CHECK (length(trim(type)) > 0)
);
CREATE INDEX IF NOT EXISTS gift_items_gift_id_sort_order_idx
ON gift_items (gift_id, sort_order, id);
+91
View File
@@ -0,0 +1,91 @@
INSERT INTO gifts (
id,
slug,
recipient_name,
sender_name,
title,
intro_message,
reveal_message,
status
)
VALUES (
'11111111-1111-4111-8111-111111111111',
'demo',
'Anna',
'Alex',
'A little surprise for you',
'I made something for you.',
'You make ordinary days feel like something worth remembering. Here is to all the little adventures still ahead.',
'published'
)
ON CONFLICT (slug) DO UPDATE SET
recipient_name = excluded.recipient_name,
sender_name = excluded.sender_name,
title = excluded.title,
intro_message = excluded.intro_message,
reveal_message = excluded.reveal_message,
status = excluded.status,
updated_at = CURRENT_TIMESTAMP;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'22222222-2222-4222-8222-222222222222',
id,
'image',
'The good kind of ordinary.',
'The tiny moments are usually the ones that stay with us the longest.',
'https://images.unsplash.com/photo-1511988617509-8e73b0f3b7d2?auto=format&fit=crop&w=1400&q=85',
1,
'{"caption":"A little snapshot of a very good day.","accent":"coral"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'33333333-3333-4333-8333-333333333333',
id,
'text',
'A note for your next chapter.',
'Keep choosing the places, people, and tiny rituals that make you feel most like yourself. I will always be cheering for you.',
NULL,
2,
'{"eyebrow":"A note from me","accent":"lavender"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;
INSERT INTO gift_items (id, gift_id, type, title, text, media_url, sort_order, metadata)
SELECT
'44444444-4444-4444-8444-444444444444',
id,
'text',
'One more thing.',
'There is nowhere else I would rather be than somewhere in the middle of the next story with you.',
NULL,
3,
'{"eyebrow":"P.S.","accent":"gold"}'
FROM gifts
WHERE slug = 'demo'
ON CONFLICT (id) DO UPDATE SET
gift_id = excluded.gift_id,
type = excluded.type,
title = excluded.title,
text = excluded.text,
media_url = excluded.media_url,
sort_order = excluded.sort_order,
metadata = excluded.metadata;
@@ -0,0 +1,92 @@
CREATE TABLE IF NOT EXISTS users (
id TEXT PRIMARY KEY NOT NULL,
email TEXT NOT NULL UNIQUE,
password_hash TEXT NOT NULL,
name TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE UNIQUE INDEX IF NOT EXISTS users_email_lower_idx ON users (lower(email));
CREATE TABLE IF NOT EXISTS galleries (
id TEXT PRIMARY KEY NOT NULL,
user_id TEXT NOT NULL REFERENCES users(id) ON DELETE CASCADE,
slug TEXT NOT NULL UNIQUE,
title TEXT NOT NULL,
client_name TEXT NOT NULL,
description TEXT NOT NULL DEFAULT '',
status TEXT NOT NULL DEFAULT 'draft',
password_hash TEXT,
downloads_enabled INTEGER NOT NULL DEFAULT 1,
favorites_enabled INTEGER NOT NULL DEFAULT 1,
download_all_enabled INTEGER NOT NULL DEFAULT 1,
watermark_enabled INTEGER NOT NULL DEFAULT 0,
expires_at TEXT,
cover_media_id TEXT,
theme_config TEXT NOT NULL DEFAULT '{}',
branding_config TEXT NOT NULL DEFAULT '{}',
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
published_at TEXT,
CONSTRAINT galleries_status_check CHECK (status IN ('draft', 'published', 'archived'))
);
CREATE INDEX IF NOT EXISTS galleries_user_id_created_at_idx ON galleries (user_id, created_at DESC);
CREATE TABLE IF NOT EXISTS media (
id TEXT PRIMARY KEY NOT NULL,
gallery_id TEXT NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
original_filename TEXT NOT NULL,
mime_type TEXT NOT NULL,
file_size INTEGER NOT NULL DEFAULT 0,
storage_key TEXT NOT NULL,
external_url TEXT,
thumbnail_key TEXT,
preview_key TEXT,
processing_status TEXT NOT NULL DEFAULT 'UPLOADING',
processing_error TEXT,
width INTEGER,
height INTEGER,
duration_seconds REAL,
sort_order INTEGER NOT NULL DEFAULT 0,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT media_processing_status_check CHECK (processing_status IN ('UPLOADING', 'PROCESSING', 'READY', 'FAILED'))
);
CREATE INDEX IF NOT EXISTS media_gallery_sort_order_idx ON media (gallery_id, sort_order, id);
CREATE TABLE IF NOT EXISTS favorites (
id TEXT PRIMARY KEY NOT NULL,
gallery_id TEXT NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
media_id TEXT NOT NULL REFERENCES media(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
UNIQUE (gallery_id, media_id, visitor_id)
);
CREATE TABLE IF NOT EXISTS downloads (
id TEXT PRIMARY KEY NOT NULL,
gallery_id TEXT NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
media_id TEXT REFERENCES media(id) ON DELETE SET NULL,
visitor_id TEXT NOT NULL,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP
);
CREATE INDEX IF NOT EXISTS downloads_gallery_created_at_idx ON downloads (gallery_id, created_at DESC);
CREATE TABLE IF NOT EXISTS download_jobs (
id TEXT PRIMARY KEY NOT NULL,
gallery_id TEXT NOT NULL REFERENCES galleries(id) ON DELETE CASCADE,
visitor_id TEXT NOT NULL,
status TEXT NOT NULL DEFAULT 'QUEUED',
storage_key TEXT,
error TEXT,
created_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
updated_at TEXT NOT NULL DEFAULT CURRENT_TIMESTAMP,
completed_at TEXT,
CONSTRAINT download_jobs_status_check CHECK (status IN ('QUEUED', 'PROCESSING', 'READY', 'FAILED'))
);
CREATE INDEX IF NOT EXISTS download_jobs_gallery_visitor_idx ON download_jobs (gallery_id, visitor_id, created_at DESC);