init
This commit is contained in:
@@ -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
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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")
|
||||||
|
)
|
||||||
@@ -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))
|
||||||
|
})
|
||||||
|
}
|
||||||
@@ -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
@@ -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=
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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 = ¤t.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 ©
|
||||||
|
}
|
||||||
|
|
||||||
|
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())
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
|
}
|
||||||
@@ -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"`
|
||||||
|
}
|
||||||
@@ -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
|
||||||
|
}
|
||||||
@@ -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/")
|
||||||
|
}
|
||||||
@@ -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)
|
||||||
@@ -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:
|
||||||
@@ -0,0 +1,2 @@
|
|||||||
|
dist
|
||||||
|
node_modules
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"semi": true,
|
||||||
|
"singleQuote": true,
|
||||||
|
"trailingComma": "all",
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
@@ -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 }],
|
||||||
|
},
|
||||||
|
},
|
||||||
|
);
|
||||||
@@ -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>
|
||||||
Generated
+4336
File diff suppressed because it is too large
Load Diff
@@ -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"
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,6 @@
|
|||||||
|
export default {
|
||||||
|
plugins: {
|
||||||
|
tailwindcss: {},
|
||||||
|
autoprefixer: {},
|
||||||
|
},
|
||||||
|
};
|
||||||
@@ -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"
|
||||||
|
>
|
||||||
|
↗
|
||||||
|
</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">
|
||||||
|
↗
|
||||||
|
</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">↗</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}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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}`}
|
||||||
|
>
|
||||||
|
×
|
||||||
|
</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">↓</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">↓</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">↗</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 ↗
|
||||||
|
</a>
|
||||||
|
)}
|
||||||
|
{branding.instagramUrl && (
|
||||||
|
<a href={branding.instagramUrl} target="_blank" rel="noreferrer">
|
||||||
|
Instagram ↗
|
||||||
|
</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">
|
||||||
|
↗
|
||||||
|
</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">
|
||||||
|
↓
|
||||||
|
</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">↗</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">
|
||||||
|
×
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
←
|
||||||
|
</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"
|
||||||
|
>
|
||||||
|
→
|
||||||
|
</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)}>
|
||||||
|
↓ <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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">↗</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">✦</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">↗</span>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</Background>
|
||||||
|
</motion.div>
|
||||||
|
)}
|
||||||
|
</AnimatePresence>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">↗</span>
|
||||||
|
</motion.button>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</Background>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">
|
||||||
|
“
|
||||||
|
</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">
|
||||||
|
▶
|
||||||
|
</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);
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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' });
|
||||||
|
}
|
||||||
@@ -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}`;
|
||||||
|
}
|
||||||
@@ -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>,
|
||||||
|
);
|
||||||
@@ -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} />;
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">↗</span>
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
<p className="auth-card__footer">
|
||||||
|
New to Northline? <Link to="/register">Create an account</Link>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</AuthLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">↗</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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">↗</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">↗</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">↗</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>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -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>→</i>
|
||||||
|
<span>
|
||||||
|
02 <strong>Presigned PUT</strong>
|
||||||
|
<small>Browser to MinIO</small>
|
||||||
|
</span>
|
||||||
|
<i>→</i>
|
||||||
|
<span>
|
||||||
|
03 <strong>Complete</strong>
|
||||||
|
<small>Stat object and queue processing</small>
|
||||||
|
</span>
|
||||||
|
<i>→</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">↗</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">← 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">↗</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">↗</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"
|
||||||
|
>
|
||||||
|
↑
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
disabled={index === total - 1}
|
||||||
|
onClick={() => onMove(1)}
|
||||||
|
aria-label="Move media later"
|
||||||
|
>
|
||||||
|
↓
|
||||||
|
</button>
|
||||||
|
<button type="button" onClick={onDelete} aria-label={`Delete ${item.originalFilename}`}>
|
||||||
|
×
|
||||||
|
</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">↗</span>
|
||||||
|
</Link>
|
||||||
|
</div>
|
||||||
|
</DashboardLayout>
|
||||||
|
);
|
||||||
|
}
|
||||||
File diff suppressed because it is too large
Load Diff
@@ -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;
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
|
}
|
||||||
@@ -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[];
|
||||||
|
}
|
||||||
Vendored
+1
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
@@ -0,0 +1,9 @@
|
|||||||
|
import type { Config } from 'tailwindcss';
|
||||||
|
|
||||||
|
export default {
|
||||||
|
content: ['./index.html', './src/**/*.{js,ts,jsx,tsx}'],
|
||||||
|
theme: {
|
||||||
|
extend: {},
|
||||||
|
},
|
||||||
|
plugins: [],
|
||||||
|
} satisfies Config;
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -0,0 +1,4 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [{ "path": "./tsconfig.app.json" }, { "path": "./tsconfig.node.json" }]
|
||||||
|
}
|
||||||
@@ -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"]
|
||||||
|
}
|
||||||
@@ -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,
|
||||||
|
},
|
||||||
|
});
|
||||||
@@ -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);
|
||||||
@@ -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;
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
@@ -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);
|
||||||
Reference in New Issue
Block a user