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