first
This commit is contained in:
9
.gitignore
vendored
Normal file
9
.gitignore
vendored
Normal file
@@ -0,0 +1,9 @@
|
|||||||
|
.env
|
||||||
|
**/.env
|
||||||
|
**/__pycache__/
|
||||||
|
**/*.pyc
|
||||||
|
**/.venv/
|
||||||
|
**/node_modules/
|
||||||
|
**/dist/
|
||||||
|
sop-back/data/
|
||||||
|
.DS_Store
|
||||||
65
docker-compose.yml
Normal file
65
docker-compose.yml
Normal file
@@ -0,0 +1,65 @@
|
|||||||
|
services:
|
||||||
|
minio:
|
||||||
|
image: minio/minio:latest
|
||||||
|
command: server /data --console-address ":9001"
|
||||||
|
environment:
|
||||||
|
MINIO_ROOT_USER: minioadmin
|
||||||
|
MINIO_ROOT_PASSWORD: minioadmin
|
||||||
|
ports:
|
||||||
|
- "9000:9000" # S3 API
|
||||||
|
- "9001:9001" # Web console
|
||||||
|
volumes:
|
||||||
|
- minio-data:/data
|
||||||
|
healthcheck:
|
||||||
|
test: ["CMD", "curl", "-f", "http://localhost:9000/minio/health/live"]
|
||||||
|
interval: 10s
|
||||||
|
timeout: 3s
|
||||||
|
retries: 5
|
||||||
|
|
||||||
|
# One-shot: create the bucket and make it public-read so <img src> works.
|
||||||
|
minio-init:
|
||||||
|
image: minio/mc:latest
|
||||||
|
depends_on:
|
||||||
|
minio:
|
||||||
|
condition: service_healthy
|
||||||
|
entrypoint: >
|
||||||
|
/bin/sh -c "
|
||||||
|
mc alias set local http://minio:9000 minioadmin minioadmin &&
|
||||||
|
mc mb -p local/sop || true &&
|
||||||
|
mc anonymous set download local/sop
|
||||||
|
"
|
||||||
|
|
||||||
|
backend:
|
||||||
|
build:
|
||||||
|
context: ./sop-back
|
||||||
|
environment:
|
||||||
|
PROJECT_NAME: "Smash or Pass"
|
||||||
|
ALLOWED_ORIGINS: '["http://localhost:8080","http://localhost:5173"]'
|
||||||
|
ADMIN_ENABLED: "true"
|
||||||
|
DATABASE_URL: "sqlite:///./data/sop.db"
|
||||||
|
S3_ENDPOINT_URL: "http://minio:9000"
|
||||||
|
S3_PUBLIC_URL: "http://localhost:9000"
|
||||||
|
S3_ACCESS_KEY: "minioadmin"
|
||||||
|
S3_SECRET_KEY: "minioadmin"
|
||||||
|
S3_BUCKET: "sop"
|
||||||
|
S3_REGION: "us-east-1"
|
||||||
|
ports:
|
||||||
|
- "8000:8000"
|
||||||
|
volumes:
|
||||||
|
- backend-data:/app/data
|
||||||
|
depends_on:
|
||||||
|
- minio-init
|
||||||
|
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./sop-front
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: "http://localhost:8000"
|
||||||
|
ports:
|
||||||
|
- "8080:80"
|
||||||
|
depends_on:
|
||||||
|
- backend
|
||||||
|
|
||||||
|
volumes:
|
||||||
|
minio-data:
|
||||||
|
backend-data:
|
||||||
6
sop-back/.dockerignore
Normal file
6
sop-back/.dockerignore
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
.venv
|
||||||
|
__pycache__
|
||||||
|
*.pyc
|
||||||
|
.env
|
||||||
|
data/
|
||||||
|
.git
|
||||||
17
sop-back/.env.example
Normal file
17
sop-back/.env.example
Normal file
@@ -0,0 +1,17 @@
|
|||||||
|
# API
|
||||||
|
PROJECT_NAME=Smash or Pass
|
||||||
|
ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:8080"]
|
||||||
|
|
||||||
|
# Admin gate
|
||||||
|
ADMIN_ENABLED=true
|
||||||
|
|
||||||
|
# Database (SQLite file path)
|
||||||
|
DATABASE_URL=sqlite:///./data/sop.db
|
||||||
|
|
||||||
|
# MinIO / S3
|
||||||
|
S3_ENDPOINT_URL=http://minio:9000
|
||||||
|
S3_PUBLIC_URL=http://localhost:9000
|
||||||
|
S3_ACCESS_KEY=minioadmin
|
||||||
|
S3_SECRET_KEY=minioadmin
|
||||||
|
S3_BUCKET=sop
|
||||||
|
S3_REGION=us-east-1
|
||||||
18
sop-back/Dockerfile
Normal file
18
sop-back/Dockerfile
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
FROM python:3.12-slim
|
||||||
|
|
||||||
|
ENV PYTHONDONTWRITEBYTECODE=1 \
|
||||||
|
PYTHONUNBUFFERED=1 \
|
||||||
|
PIP_NO_CACHE_DIR=1
|
||||||
|
|
||||||
|
WORKDIR /app
|
||||||
|
|
||||||
|
COPY requirements.txt .
|
||||||
|
RUN pip install --no-cache-dir -r requirements.txt
|
||||||
|
|
||||||
|
COPY app ./app
|
||||||
|
|
||||||
|
RUN mkdir -p /app/data
|
||||||
|
|
||||||
|
EXPOSE 8000
|
||||||
|
|
||||||
|
CMD ["uvicorn", "app.main:app", "--host", "0.0.0.0", "--port", "8000"]
|
||||||
142
sop-back/README.md
Normal file
142
sop-back/README.md
Normal file
@@ -0,0 +1,142 @@
|
|||||||
|
# Smash or Pass — Backend
|
||||||
|
|
||||||
|
FastAPI + SQLAlchemy (SQLite) + boto3 (MinIO/S3). Serves the API consumed by [`sop-front`](../sop-front/).
|
||||||
|
|
||||||
|
See [`../CLAUDE.md`](../CLAUDE.md) for full architecture notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Python 3.12, FastAPI, Uvicorn
|
||||||
|
- SQLAlchemy 2 (sync) + SQLite
|
||||||
|
- Pydantic v2 + `pydantic-settings`
|
||||||
|
- `boto3` for S3-compatible object storage (MinIO)
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
sop-back/
|
||||||
|
├── app/
|
||||||
|
│ ├── main.py # FastAPI factory + lifespan (DB + bucket init)
|
||||||
|
│ ├── core/{config,deps}.py # Settings, admin gate
|
||||||
|
│ ├── db/database.py # Engine, SessionLocal, get_db, Base
|
||||||
|
│ ├── models/models.py # Collection, Character
|
||||||
|
│ ├── schemas/schemas.py # Pydantic schemas
|
||||||
|
│ ├── services/storage.py # MinIO upload/delete, bucket bootstrap
|
||||||
|
│ └── api/routes/
|
||||||
|
│ ├── health.py # /health, /admin/status
|
||||||
|
│ ├── collections.py # /collections
|
||||||
|
│ └── admin.py # /admin/* (require_admin)
|
||||||
|
├── requirements.txt
|
||||||
|
├── Dockerfile
|
||||||
|
└── .env.example
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
Copy `.env.example` to `.env` and adjust:
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `ADMIN_ENABLED` | `false` | When `true`, `/admin/*` routes are exposed and the frontend renders the admin panel |
|
||||||
|
| `ALLOWED_ORIGINS` | `["*"]` | CORS, JSON array string |
|
||||||
|
| `DATABASE_URL` | `sqlite:///./data/sop.db` | SQLite file path |
|
||||||
|
| `S3_ENDPOINT_URL` | `http://localhost:9000` | What the **backend** uses to reach MinIO |
|
||||||
|
| `S3_PUBLIC_URL` | `http://localhost:9000` | What gets stored in `s3_url` and dereferenced by the **browser** |
|
||||||
|
| `S3_ACCESS_KEY` / `S3_SECRET_KEY` | `minioadmin` / `minioadmin` | Credentials |
|
||||||
|
| `S3_BUCKET` | `sop` | Auto-created on startup, set public-read |
|
||||||
|
| `S3_REGION` | `us-east-1` | Required by boto3 |
|
||||||
|
|
||||||
|
> **Security note:** `ADMIN_ENABLED=true` exposes admin endpoints to anyone who can reach the backend. There is no user auth — by design, per project spec. In production, either (a) put the backend behind an authenticated reverse proxy / VPN, (b) keep `ADMIN_ENABLED=false` outside of admin sessions, or (c) replace the gate with proper auth.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
You need a running MinIO. Easiest path: spin it up via Docker.
|
||||||
|
|
||||||
|
```bash
|
||||||
|
# from repo root
|
||||||
|
docker compose up -d minio minio-init
|
||||||
|
```
|
||||||
|
|
||||||
|
Then run the backend natively:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sop-back
|
||||||
|
python -m venv .venv
|
||||||
|
source .venv/bin/activate
|
||||||
|
pip install -r requirements.txt
|
||||||
|
cp .env.example .env
|
||||||
|
# edit .env: set ADMIN_ENABLED=true if you want to upload
|
||||||
|
uvicorn app.main:app --reload
|
||||||
|
```
|
||||||
|
|
||||||
|
- API: http://localhost:8000
|
||||||
|
- Interactive docs: http://localhost:8000/docs
|
||||||
|
- Health: http://localhost:8000/health
|
||||||
|
|
||||||
|
The SQLite DB file is created at `sop-back/data/sop.db` on first startup; tables are auto-created. There is no Alembic — if you change models, delete the file during dev.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## API surface
|
||||||
|
|
||||||
|
| Method | Path | Auth | Purpose |
|
||||||
|
|---|---|---|---|
|
||||||
|
| GET | `/health` | — | Liveness |
|
||||||
|
| GET | `/admin/status` | — | `{admin_enabled: bool}` |
|
||||||
|
| GET | `/collections` | — | List collections (with `character_count`) |
|
||||||
|
| GET | `/collections/{id}` | — | Collection + characters |
|
||||||
|
| POST | `/admin/collections` | admin | multipart `name` + `files[]` |
|
||||||
|
| POST | `/admin/collections/{id}/characters` | admin | multipart `files[]` |
|
||||||
|
| DELETE | `/admin/collections/{id}` | admin | Delete collection + S3 objects |
|
||||||
|
| DELETE | `/admin/characters/{id}` | admin | Delete one character + its S3 object |
|
||||||
|
|
||||||
|
Allowed image types: `image/jpeg`, `image/png`, `image/webp`, `image/gif`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production deployment
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
The repo-root `docker-compose.yml` already wires backend + MinIO + bucket init. From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build backend minio minio-init
|
||||||
|
```
|
||||||
|
|
||||||
|
For a real deployment you should override these defaults:
|
||||||
|
|
||||||
|
1. **Use long, random MinIO credentials.** Edit the `MINIO_ROOT_USER` / `MINIO_ROOT_PASSWORD` env on the `minio` service and the matching `S3_ACCESS_KEY` / `S3_SECRET_KEY` on the backend.
|
||||||
|
2. **Set `S3_PUBLIC_URL` to the public hostname** browsers will hit (e.g. `https://media.example.com`). Put MinIO behind a TLS-terminating reverse proxy on that hostname.
|
||||||
|
3. **Set `ALLOWED_ORIGINS`** to the exact frontend origin(s) — never `["*"]` in prod.
|
||||||
|
4. **Persist volumes**: `minio-data` and `backend-data` (the SQLite file) — already declared in compose. Back them up.
|
||||||
|
5. **Run behind a reverse proxy** (Caddy / Traefik / nginx) terminating TLS in front of port 8000.
|
||||||
|
6. Consider switching `DATABASE_URL` to PostgreSQL if you expect concurrent writes.
|
||||||
|
|
||||||
|
### Without Docker
|
||||||
|
|
||||||
|
Same install steps as local, but:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
pip install -r requirements.txt gunicorn
|
||||||
|
gunicorn app.main:app -k uvicorn.workers.UvicornWorker -w 2 -b 0.0.0.0:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
Run under systemd or a supervisor of your choice. Front it with nginx/Caddy for TLS.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Schema migrations
|
||||||
|
|
||||||
|
There are none yet — `Base.metadata.create_all` runs on startup. If the schema changes incompatibly, either:
|
||||||
|
|
||||||
|
- Wipe `data/sop.db` (dev), or
|
||||||
|
- Add Alembic before the next prod deploy.
|
||||||
0
sop-back/app/__init__.py
Normal file
0
sop-back/app/__init__.py
Normal file
0
sop-back/app/api/__init__.py
Normal file
0
sop-back/app/api/__init__.py
Normal file
0
sop-back/app/api/routes/__init__.py
Normal file
0
sop-back/app/api/routes/__init__.py
Normal file
109
sop-back/app/api/routes/admin.py
Normal file
109
sop-back/app/api/routes/admin.py
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException, UploadFile, File, Form
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from sqlalchemy.exc import IntegrityError
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.core.deps import require_admin
|
||||||
|
from app.models.models import Collection, Character
|
||||||
|
from app.schemas.schemas import CollectionOut
|
||||||
|
from app.services.storage import upload_image, delete_object_by_url
|
||||||
|
|
||||||
|
router = APIRouter(dependencies=[Depends(require_admin)])
|
||||||
|
|
||||||
|
ALLOWED_TYPES = {"image/jpeg", "image/png", "image/webp", "image/gif"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections", response_model=CollectionOut, status_code=201)
|
||||||
|
async def create_collection(
|
||||||
|
name: str = Form(...),
|
||||||
|
files: List[UploadFile] = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
name = name.strip()
|
||||||
|
if not name:
|
||||||
|
raise HTTPException(status_code=400, detail="Collection name required")
|
||||||
|
if not files:
|
||||||
|
raise HTTPException(status_code=400, detail="At least one image is required")
|
||||||
|
|
||||||
|
collection = Collection(name=name)
|
||||||
|
db.add(collection)
|
||||||
|
try:
|
||||||
|
db.flush()
|
||||||
|
except IntegrityError:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(status_code=409, detail="A collection with this name already exists")
|
||||||
|
|
||||||
|
for f in files:
|
||||||
|
if f.content_type not in ALLOWED_TYPES:
|
||||||
|
db.rollback()
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=415,
|
||||||
|
detail=f"Unsupported file type: {f.content_type}",
|
||||||
|
)
|
||||||
|
data = await f.read()
|
||||||
|
_, url = upload_image(data, f.filename or "image", f.content_type or "")
|
||||||
|
char_name = (f.filename or "image").rsplit(".", 1)[0]
|
||||||
|
db.add(
|
||||||
|
Character(
|
||||||
|
name=char_name,
|
||||||
|
filename=f.filename or "image",
|
||||||
|
s3_url=url,
|
||||||
|
collection_id=collection.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
|
||||||
|
db.commit()
|
||||||
|
db.refresh(collection)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
|
@router.post("/collections/{collection_id}/characters", response_model=CollectionOut)
|
||||||
|
async def add_characters(
|
||||||
|
collection_id: int,
|
||||||
|
files: List[UploadFile] = File(...),
|
||||||
|
db: Session = Depends(get_db),
|
||||||
|
):
|
||||||
|
collection = db.get(Collection, collection_id)
|
||||||
|
if not collection:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
for f in files:
|
||||||
|
if f.content_type not in ALLOWED_TYPES:
|
||||||
|
raise HTTPException(status_code=415, detail=f"Unsupported file type: {f.content_type}")
|
||||||
|
data = await f.read()
|
||||||
|
_, url = upload_image(data, f.filename or "image", f.content_type or "")
|
||||||
|
char_name = (f.filename or "image").rsplit(".", 1)[0]
|
||||||
|
db.add(
|
||||||
|
Character(
|
||||||
|
name=char_name,
|
||||||
|
filename=f.filename or "image",
|
||||||
|
s3_url=url,
|
||||||
|
collection_id=collection.id,
|
||||||
|
)
|
||||||
|
)
|
||||||
|
db.commit()
|
||||||
|
db.refresh(collection)
|
||||||
|
return collection
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/collections/{collection_id}", status_code=204)
|
||||||
|
def delete_collection(collection_id: int, db: Session = Depends(get_db)):
|
||||||
|
collection = db.get(Collection, collection_id)
|
||||||
|
if not collection:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
urls = [c.s3_url for c in collection.characters]
|
||||||
|
db.delete(collection)
|
||||||
|
db.commit()
|
||||||
|
for u in urls:
|
||||||
|
delete_object_by_url(u)
|
||||||
|
|
||||||
|
|
||||||
|
@router.delete("/characters/{character_id}", status_code=204)
|
||||||
|
def delete_character(character_id: int, db: Session = Depends(get_db)):
|
||||||
|
ch = db.get(Character, character_id)
|
||||||
|
if not ch:
|
||||||
|
raise HTTPException(status_code=404, detail="Character not found")
|
||||||
|
url = ch.s3_url
|
||||||
|
db.delete(ch)
|
||||||
|
db.commit()
|
||||||
|
delete_object_by_url(url)
|
||||||
31
sop-back/app/api/routes/collections.py
Normal file
31
sop-back/app/api/routes/collections.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from fastapi import APIRouter, Depends, HTTPException
|
||||||
|
from sqlalchemy.orm import Session
|
||||||
|
from typing import List
|
||||||
|
|
||||||
|
from app.db.database import get_db
|
||||||
|
from app.models.models import Collection, Character
|
||||||
|
from app.schemas.schemas import CollectionSummary, CollectionOut
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("", response_model=List[CollectionSummary])
|
||||||
|
def list_collections(db: Session = Depends(get_db)):
|
||||||
|
rows = db.query(Collection).order_by(Collection.created_at.desc()).all()
|
||||||
|
return [
|
||||||
|
CollectionSummary(
|
||||||
|
id=c.id,
|
||||||
|
name=c.name,
|
||||||
|
created_at=c.created_at,
|
||||||
|
character_count=len(c.characters),
|
||||||
|
)
|
||||||
|
for c in rows
|
||||||
|
]
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/{collection_id}", response_model=CollectionOut)
|
||||||
|
def get_collection(collection_id: int, db: Session = Depends(get_db)):
|
||||||
|
c = db.get(Collection, collection_id)
|
||||||
|
if not c:
|
||||||
|
raise HTTPException(status_code=404, detail="Collection not found")
|
||||||
|
return c
|
||||||
15
sop-back/app/api/routes/health.py
Normal file
15
sop-back/app/api/routes/health.py
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
from fastapi import APIRouter
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.schemas.schemas import AdminStatus
|
||||||
|
|
||||||
|
router = APIRouter()
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/health")
|
||||||
|
def health():
|
||||||
|
return {"status": "ok"}
|
||||||
|
|
||||||
|
|
||||||
|
@router.get("/admin/status", response_model=AdminStatus)
|
||||||
|
def admin_status():
|
||||||
|
return AdminStatus(admin_enabled=settings.ADMIN_ENABLED)
|
||||||
0
sop-back/app/core/__init__.py
Normal file
0
sop-back/app/core/__init__.py
Normal file
24
sop-back/app/core/config.py
Normal file
24
sop-back/app/core/config.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from typing import List
|
||||||
|
from pydantic_settings import BaseSettings, SettingsConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class Settings(BaseSettings):
|
||||||
|
model_config = SettingsConfigDict(env_file=".env", extra="ignore")
|
||||||
|
|
||||||
|
PROJECT_NAME: str = "Smash or Pass"
|
||||||
|
VERSION: str = "0.1.0"
|
||||||
|
ALLOWED_ORIGINS: List[str] = ["*"]
|
||||||
|
|
||||||
|
ADMIN_ENABLED: bool = False
|
||||||
|
|
||||||
|
DATABASE_URL: str = "sqlite:///./data/sop.db"
|
||||||
|
|
||||||
|
S3_ENDPOINT_URL: str = "http://localhost:9000"
|
||||||
|
S3_PUBLIC_URL: str = "http://localhost:9000"
|
||||||
|
S3_ACCESS_KEY: str = "minioadmin"
|
||||||
|
S3_SECRET_KEY: str = "minioadmin"
|
||||||
|
S3_BUCKET: str = "sop"
|
||||||
|
S3_REGION: str = "us-east-1"
|
||||||
|
|
||||||
|
|
||||||
|
settings = Settings()
|
||||||
10
sop-back/app/core/deps.py
Normal file
10
sop-back/app/core/deps.py
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
from fastapi import HTTPException, status
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def require_admin() -> None:
|
||||||
|
if not settings.ADMIN_ENABLED:
|
||||||
|
raise HTTPException(
|
||||||
|
status_code=status.HTTP_403_FORBIDDEN,
|
||||||
|
detail="Admin module is disabled",
|
||||||
|
)
|
||||||
0
sop-back/app/db/__init__.py
Normal file
0
sop-back/app/db/__init__.py
Normal file
24
sop-back/app/db/database.py
Normal file
24
sop-back/app/db/database.py
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
from sqlalchemy import create_engine
|
||||||
|
from sqlalchemy.orm import sessionmaker, declarative_base, Session
|
||||||
|
from app.core.config import settings
|
||||||
|
import os
|
||||||
|
|
||||||
|
connect_args = {}
|
||||||
|
if settings.DATABASE_URL.startswith("sqlite"):
|
||||||
|
connect_args["check_same_thread"] = False
|
||||||
|
db_path = settings.DATABASE_URL.replace("sqlite:///", "")
|
||||||
|
parent = os.path.dirname(db_path)
|
||||||
|
if parent:
|
||||||
|
os.makedirs(parent, exist_ok=True)
|
||||||
|
|
||||||
|
engine = create_engine(settings.DATABASE_URL, connect_args=connect_args)
|
||||||
|
SessionLocal = sessionmaker(bind=engine, autoflush=False, autocommit=False)
|
||||||
|
Base = declarative_base()
|
||||||
|
|
||||||
|
|
||||||
|
def get_db():
|
||||||
|
db = SessionLocal()
|
||||||
|
try:
|
||||||
|
yield db
|
||||||
|
finally:
|
||||||
|
db.close()
|
||||||
39
sop-back/app/main.py
Normal file
39
sop-back/app/main.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from contextlib import asynccontextmanager
|
||||||
|
from fastapi import FastAPI
|
||||||
|
from fastapi.middleware.cors import CORSMiddleware
|
||||||
|
|
||||||
|
from app.core.config import settings
|
||||||
|
from app.db.database import Base, engine
|
||||||
|
from app.models import models # noqa: F401 (register models)
|
||||||
|
from app.services.storage import ensure_bucket
|
||||||
|
from app.api.routes import health, collections, admin
|
||||||
|
|
||||||
|
|
||||||
|
@asynccontextmanager
|
||||||
|
async def lifespan(app: FastAPI):
|
||||||
|
Base.metadata.create_all(bind=engine)
|
||||||
|
try:
|
||||||
|
ensure_bucket()
|
||||||
|
except Exception as e:
|
||||||
|
# Don't crash the API if MinIO is briefly unavailable at startup.
|
||||||
|
print(f"[startup] MinIO bucket init skipped: {e}")
|
||||||
|
yield
|
||||||
|
|
||||||
|
|
||||||
|
app = FastAPI(
|
||||||
|
title=settings.PROJECT_NAME,
|
||||||
|
version=settings.VERSION,
|
||||||
|
lifespan=lifespan,
|
||||||
|
)
|
||||||
|
|
||||||
|
app.add_middleware(
|
||||||
|
CORSMiddleware,
|
||||||
|
allow_origins=settings.ALLOWED_ORIGINS,
|
||||||
|
allow_credentials=True,
|
||||||
|
allow_methods=["*"],
|
||||||
|
allow_headers=["*"],
|
||||||
|
)
|
||||||
|
|
||||||
|
app.include_router(health.router, tags=["meta"])
|
||||||
|
app.include_router(collections.router, prefix="/collections", tags=["collections"])
|
||||||
|
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||||
0
sop-back/app/models/__init__.py
Normal file
0
sop-back/app/models/__init__.py
Normal file
31
sop-back/app/models/models.py
Normal file
31
sop-back/app/models/models.py
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
from sqlalchemy import Column, Integer, String, ForeignKey, DateTime, func
|
||||||
|
from sqlalchemy.orm import relationship
|
||||||
|
from app.db.database import Base
|
||||||
|
|
||||||
|
|
||||||
|
class Collection(Base):
|
||||||
|
__tablename__ = "collections"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(120), nullable=False, unique=True)
|
||||||
|
created_at = Column(DateTime, server_default=func.now())
|
||||||
|
|
||||||
|
characters = relationship(
|
||||||
|
"Character",
|
||||||
|
back_populates="collection",
|
||||||
|
cascade="all, delete-orphan",
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
class Character(Base):
|
||||||
|
__tablename__ = "characters"
|
||||||
|
|
||||||
|
id = Column(Integer, primary_key=True, index=True)
|
||||||
|
name = Column(String(255), nullable=False)
|
||||||
|
filename = Column(String(255), nullable=False)
|
||||||
|
s3_url = Column(String(1024), nullable=False)
|
||||||
|
collection_id = Column(
|
||||||
|
Integer, ForeignKey("collections.id", ondelete="CASCADE"), nullable=False
|
||||||
|
)
|
||||||
|
|
||||||
|
collection = relationship("Collection", back_populates="characters")
|
||||||
0
sop-back/app/schemas/__init__.py
Normal file
0
sop-back/app/schemas/__init__.py
Normal file
39
sop-back/app/schemas/schemas.py
Normal file
39
sop-back/app/schemas/schemas.py
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
from datetime import datetime
|
||||||
|
from typing import List
|
||||||
|
from pydantic import BaseModel, ConfigDict
|
||||||
|
|
||||||
|
|
||||||
|
class CharacterOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
filename: str
|
||||||
|
s3_url: str
|
||||||
|
collection_id: int
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionSummary(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
created_at: datetime
|
||||||
|
character_count: int = 0
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionOut(BaseModel):
|
||||||
|
model_config = ConfigDict(from_attributes=True)
|
||||||
|
|
||||||
|
id: int
|
||||||
|
name: str
|
||||||
|
created_at: datetime
|
||||||
|
characters: List[CharacterOut] = []
|
||||||
|
|
||||||
|
|
||||||
|
class CollectionCreate(BaseModel):
|
||||||
|
name: str
|
||||||
|
|
||||||
|
|
||||||
|
class AdminStatus(BaseModel):
|
||||||
|
admin_enabled: bool
|
||||||
0
sop-back/app/services/__init__.py
Normal file
0
sop-back/app/services/__init__.py
Normal file
63
sop-back/app/services/storage.py
Normal file
63
sop-back/app/services/storage.py
Normal file
@@ -0,0 +1,63 @@
|
|||||||
|
import uuid
|
||||||
|
import boto3
|
||||||
|
from botocore.client import Config
|
||||||
|
from botocore.exceptions import ClientError
|
||||||
|
from app.core.config import settings
|
||||||
|
|
||||||
|
|
||||||
|
def _client():
|
||||||
|
return boto3.client(
|
||||||
|
"s3",
|
||||||
|
endpoint_url=settings.S3_ENDPOINT_URL,
|
||||||
|
aws_access_key_id=settings.S3_ACCESS_KEY,
|
||||||
|
aws_secret_access_key=settings.S3_SECRET_KEY,
|
||||||
|
region_name=settings.S3_REGION,
|
||||||
|
config=Config(signature_version="s3v4"),
|
||||||
|
)
|
||||||
|
|
||||||
|
|
||||||
|
def ensure_bucket() -> None:
|
||||||
|
s3 = _client()
|
||||||
|
try:
|
||||||
|
s3.head_bucket(Bucket=settings.S3_BUCKET)
|
||||||
|
except ClientError:
|
||||||
|
s3.create_bucket(Bucket=settings.S3_BUCKET)
|
||||||
|
# Make objects publicly readable so the frontend can <img src=...> directly.
|
||||||
|
policy = (
|
||||||
|
'{"Version":"2012-10-17","Statement":[{"Effect":"Allow","Principal":{"AWS":["*"]},'
|
||||||
|
'"Action":["s3:GetObject"],"Resource":["arn:aws:s3:::'
|
||||||
|
+ settings.S3_BUCKET
|
||||||
|
+ '/*"]}]}'
|
||||||
|
)
|
||||||
|
try:
|
||||||
|
s3.put_bucket_policy(Bucket=settings.S3_BUCKET, Policy=policy)
|
||||||
|
except ClientError:
|
||||||
|
pass
|
||||||
|
|
||||||
|
|
||||||
|
def upload_image(file_bytes: bytes, filename: str, content_type: str) -> tuple[str, str]:
|
||||||
|
"""Upload bytes to S3, return (object_key, public_url)."""
|
||||||
|
ext = ""
|
||||||
|
if "." in filename:
|
||||||
|
ext = "." + filename.rsplit(".", 1)[-1].lower()
|
||||||
|
key = f"characters/{uuid.uuid4().hex}{ext}"
|
||||||
|
s3 = _client()
|
||||||
|
s3.put_object(
|
||||||
|
Bucket=settings.S3_BUCKET,
|
||||||
|
Key=key,
|
||||||
|
Body=file_bytes,
|
||||||
|
ContentType=content_type or "application/octet-stream",
|
||||||
|
)
|
||||||
|
public_url = f"{settings.S3_PUBLIC_URL.rstrip('/')}/{settings.S3_BUCKET}/{key}"
|
||||||
|
return key, public_url
|
||||||
|
|
||||||
|
|
||||||
|
def delete_object_by_url(url: str) -> None:
|
||||||
|
prefix = f"{settings.S3_PUBLIC_URL.rstrip('/')}/{settings.S3_BUCKET}/"
|
||||||
|
if not url.startswith(prefix):
|
||||||
|
return
|
||||||
|
key = url[len(prefix):]
|
||||||
|
try:
|
||||||
|
_client().delete_object(Bucket=settings.S3_BUCKET, Key=key)
|
||||||
|
except ClientError:
|
||||||
|
pass
|
||||||
7
sop-back/requirements.txt
Normal file
7
sop-back/requirements.txt
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
fastapi>=0.111.0
|
||||||
|
uvicorn[standard]>=0.29.0
|
||||||
|
pydantic>=2.7.0
|
||||||
|
pydantic-settings>=2.2.0
|
||||||
|
sqlalchemy>=2.0.30
|
||||||
|
boto3>=1.34.0
|
||||||
|
python-multipart>=0.0.9
|
||||||
4
sop-front/.dockerignore
Normal file
4
sop-front/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
|||||||
|
node_modules
|
||||||
|
dist
|
||||||
|
.git
|
||||||
|
.vscode
|
||||||
8
sop-front/.editorconfig
Normal file
8
sop-front/.editorconfig
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
|
||||||
|
charset = utf-8
|
||||||
|
indent_size = 2
|
||||||
|
indent_style = space
|
||||||
|
insert_final_newline = true
|
||||||
|
trim_trailing_whitespace = true
|
||||||
|
end_of_line = lf
|
||||||
|
max_line_length = 100
|
||||||
1
sop-front/.gitattributes
vendored
Normal file
1
sop-front/.gitattributes
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
* text=auto eol=lf
|
||||||
39
sop-front/.gitignore
vendored
Normal file
39
sop-front/.gitignore
vendored
Normal file
@@ -0,0 +1,39 @@
|
|||||||
|
# Logs
|
||||||
|
logs
|
||||||
|
*.log
|
||||||
|
npm-debug.log*
|
||||||
|
yarn-debug.log*
|
||||||
|
yarn-error.log*
|
||||||
|
pnpm-debug.log*
|
||||||
|
lerna-debug.log*
|
||||||
|
|
||||||
|
node_modules
|
||||||
|
.DS_Store
|
||||||
|
dist
|
||||||
|
dist-ssr
|
||||||
|
coverage
|
||||||
|
*.local
|
||||||
|
|
||||||
|
# Editor directories and files
|
||||||
|
.vscode/*
|
||||||
|
!.vscode/extensions.json
|
||||||
|
.idea
|
||||||
|
*.suo
|
||||||
|
*.ntvs*
|
||||||
|
*.njsproj
|
||||||
|
*.sln
|
||||||
|
*.sw?
|
||||||
|
|
||||||
|
*.tsbuildinfo
|
||||||
|
|
||||||
|
.eslintcache
|
||||||
|
|
||||||
|
# Cypress
|
||||||
|
/cypress/videos/
|
||||||
|
/cypress/screenshots/
|
||||||
|
|
||||||
|
# Vitest
|
||||||
|
__screenshots__/
|
||||||
|
|
||||||
|
# Vite
|
||||||
|
*.timestamp-*-*.mjs
|
||||||
10
sop-front/.oxlintrc.json
Normal file
10
sop-front/.oxlintrc.json
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"$schema": "./node_modules/oxlint/configuration_schema.json",
|
||||||
|
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
|
||||||
|
"env": {
|
||||||
|
"browser": true
|
||||||
|
},
|
||||||
|
"categories": {
|
||||||
|
"correctness": "error"
|
||||||
|
}
|
||||||
|
}
|
||||||
6
sop-front/.prettierrc.json
Normal file
6
sop-front/.prettierrc.json
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
{
|
||||||
|
"$schema": "https://json.schemastore.org/prettierrc",
|
||||||
|
"semi": false,
|
||||||
|
"singleQuote": true,
|
||||||
|
"printWidth": 100
|
||||||
|
}
|
||||||
10
sop-front/.vscode/extensions.json
vendored
Normal file
10
sop-front/.vscode/extensions.json
vendored
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
{
|
||||||
|
"recommendations": [
|
||||||
|
"Vue.volar",
|
||||||
|
"vitest.explorer",
|
||||||
|
"dbaeumer.vscode-eslint",
|
||||||
|
"EditorConfig.EditorConfig",
|
||||||
|
"oxc.oxc-vscode",
|
||||||
|
"esbenp.prettier-vscode"
|
||||||
|
]
|
||||||
|
}
|
||||||
14
sop-front/Dockerfile
Normal file
14
sop-front/Dockerfile
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
FROM node:22-alpine AS build
|
||||||
|
WORKDIR /app
|
||||||
|
COPY package.json package-lock.json* ./
|
||||||
|
RUN npm install
|
||||||
|
COPY . .
|
||||||
|
ARG VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
|
||||||
|
RUN npm run build-only
|
||||||
|
|
||||||
|
FROM nginx:alpine
|
||||||
|
COPY --from=build /app/dist /usr/share/nginx/html
|
||||||
|
COPY nginx.conf /etc/nginx/conf.d/default.conf
|
||||||
|
EXPOSE 80
|
||||||
|
CMD ["nginx", "-g", "daemon off;"]
|
||||||
149
sop-front/README.md
Normal file
149
sop-front/README.md
Normal file
@@ -0,0 +1,149 @@
|
|||||||
|
# Smash or Pass — Frontend
|
||||||
|
|
||||||
|
Vue 3 + TypeScript + Vite + Pinia + Tailwind CSS v4. Talks to the [`sop-back`](../sop-back/) FastAPI server.
|
||||||
|
|
||||||
|
See [`../CLAUDE.md`](../CLAUDE.md) for full architecture notes.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Stack
|
||||||
|
|
||||||
|
- Vue 3 with `<script setup lang="ts">` (TypeScript everywhere)
|
||||||
|
- Vite 8 (dev server + bundler)
|
||||||
|
- Vue Router (SPA: `/`, `/game/:id`, `/summary`)
|
||||||
|
- Pinia (game state: queue / smashed / passed)
|
||||||
|
- Tailwind CSS v4 via `@tailwindcss/vite`. Custom palette + fonts in [`src/assets/main.css`](src/assets/main.css) inside an `@theme { ... }` block.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Project layout
|
||||||
|
|
||||||
|
```
|
||||||
|
sop-front/
|
||||||
|
├── index.html
|
||||||
|
├── vite.config.ts # Tailwind v4 plugin + alias '@'
|
||||||
|
├── nginx.conf # SPA fallback for production image
|
||||||
|
├── Dockerfile # multi-stage: vite build → nginx
|
||||||
|
└── src/
|
||||||
|
├── main.ts # imports ./assets/main.css
|
||||||
|
├── App.vue # only <RouterView />
|
||||||
|
├── router/index.ts
|
||||||
|
├── api/client.ts # typed fetch wrapper, reads VITE_API_BASE_URL
|
||||||
|
├── stores/game.ts # Pinia store
|
||||||
|
├── assets/main.css # Tailwind + @theme tokens (palette, fonts)
|
||||||
|
├── components/
|
||||||
|
│ ├── SwipeCard.vue # pointer-event drag, exposes triggerSmash/Pass
|
||||||
|
│ └── AdminPanel.vue
|
||||||
|
└── views/
|
||||||
|
├── HomeView.vue
|
||||||
|
├── GameView.vue
|
||||||
|
└── SummaryView.vue
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Configuration
|
||||||
|
|
||||||
|
One environment variable, **build-time** (Vite inlines it):
|
||||||
|
|
||||||
|
| Var | Default | Notes |
|
||||||
|
|---|---|---|
|
||||||
|
| `VITE_API_BASE_URL` | `http://localhost:8000` | Backend origin |
|
||||||
|
|
||||||
|
For dev, set in a `.env.local`:
|
||||||
|
|
||||||
|
```
|
||||||
|
VITE_API_BASE_URL=http://localhost:8000
|
||||||
|
```
|
||||||
|
|
||||||
|
For Docker builds, pass it as a build arg (already wired in the repo-root `docker-compose.yml`):
|
||||||
|
|
||||||
|
```yaml
|
||||||
|
frontend:
|
||||||
|
build:
|
||||||
|
context: ./sop-front
|
||||||
|
args:
|
||||||
|
VITE_API_BASE_URL: "https://api.example.com"
|
||||||
|
```
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Local development
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cd sop-front
|
||||||
|
npm install
|
||||||
|
npm run dev
|
||||||
|
```
|
||||||
|
|
||||||
|
- App: http://localhost:5173
|
||||||
|
- Make sure the backend is running at `VITE_API_BASE_URL` (default `http://localhost:8000`).
|
||||||
|
|
||||||
|
### Other scripts
|
||||||
|
|
||||||
|
| Command | What |
|
||||||
|
|---|---|
|
||||||
|
| `npm run dev` | Vite dev server with HMR |
|
||||||
|
| `npm run build` | Type-check + production build into `dist/` |
|
||||||
|
| `npm run type-check` | `vue-tsc --build` only |
|
||||||
|
| `npm run preview` | Serve the built `dist/` locally |
|
||||||
|
| `npm run lint` | oxlint + eslint |
|
||||||
|
| `npm run format` | Prettier |
|
||||||
|
| `npm run test:unit` | Vitest |
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Customizing colors / fonts
|
||||||
|
|
||||||
|
**All design tokens live in [`src/assets/main.css`](src/assets/main.css)** under `@theme`. Add new colors there as `--color-<name>: #hex;` and they become available as Tailwind utilities (`bg-<name>`, `text-<name>`) and as `var(--color-<name>)`. Don't hardcode hex values in components.
|
||||||
|
|
||||||
|
Current palette comes from the user's reference image:
|
||||||
|
|
||||||
|
| Token | Hex | Role |
|
||||||
|
|---|---|---|
|
||||||
|
| `--color-clothes` | `#8324DE` | Primary purple (CTAs) |
|
||||||
|
| `--color-smash` / `--color-tongue` | `#B9D532` | Smash green |
|
||||||
|
| `--color-pass` / `--color-iris` | `#FF453B` | Pass red |
|
||||||
|
| `--color-bg` | `#16141A` | Dark grey background |
|
||||||
|
| `--color-surface` | `#1F1C24` | Card surface |
|
||||||
|
| `--color-fur` | `#FBF9FD` | Off-white text |
|
||||||
|
|
||||||
|
Fonts: **Bebas Neue** (display) + **Inter** (body), loaded via Google Fonts in `main.css`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Production deployment
|
||||||
|
|
||||||
|
### Docker (recommended)
|
||||||
|
|
||||||
|
A multi-stage Dockerfile builds the SPA with Vite and serves the static bundle with nginx. From the repo root:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
docker compose up -d --build frontend
|
||||||
|
```
|
||||||
|
|
||||||
|
The image listens on port 80 inside the container; the compose file maps it to host `:8080`.
|
||||||
|
|
||||||
|
For a real deployment:
|
||||||
|
|
||||||
|
1. **Set `VITE_API_BASE_URL`** to the public backend URL **at build time** (build arg, not runtime env). Rebuild the image whenever it changes.
|
||||||
|
2. **Front it with HTTPS** — Caddy / Traefik / Cloudflare in front of port 80.
|
||||||
|
3. The bundled `nginx.conf` already does SPA fallback (`try_files $uri /index.html`).
|
||||||
|
4. Make sure the backend CORS `ALLOWED_ORIGINS` includes the frontend's public origin.
|
||||||
|
|
||||||
|
### Static hosting (no Docker)
|
||||||
|
|
||||||
|
```bash
|
||||||
|
npm install
|
||||||
|
VITE_API_BASE_URL=https://api.example.com npm run build
|
||||||
|
```
|
||||||
|
|
||||||
|
Upload the contents of `dist/` to any static host (S3+CloudFront, Netlify, Vercel, GitHub Pages, etc.). Configure SPA fallback so any unknown path serves `index.html`.
|
||||||
|
|
||||||
|
---
|
||||||
|
|
||||||
|
## Notes
|
||||||
|
|
||||||
|
- `VITE_API_BASE_URL` is **baked into the JS bundle** at build time. Changing it requires a rebuild.
|
||||||
|
- All API calls go through [`src/api/client.ts`](src/api/client.ts) — extend that wrapper rather than calling `fetch` directly from components.
|
||||||
|
- The admin panel renders only when `GET /admin/status` returns `admin_enabled: true`. That flag lives in the **backend's** `.env`.
|
||||||
1
sop-front/env.d.ts
vendored
Normal file
1
sop-front/env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
|||||||
|
/// <reference types="vite/client" />
|
||||||
32
sop-front/eslint.config.ts
Normal file
32
sop-front/eslint.config.ts
Normal file
@@ -0,0 +1,32 @@
|
|||||||
|
import { globalIgnores } from 'eslint/config'
|
||||||
|
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
|
||||||
|
import pluginVue from 'eslint-plugin-vue'
|
||||||
|
import pluginVitest from '@vitest/eslint-plugin'
|
||||||
|
import pluginOxlint from 'eslint-plugin-oxlint'
|
||||||
|
import skipFormatting from 'eslint-config-prettier/flat'
|
||||||
|
|
||||||
|
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
|
||||||
|
// import { configureVueProject } from '@vue/eslint-config-typescript'
|
||||||
|
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
|
||||||
|
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
|
||||||
|
|
||||||
|
export default defineConfigWithVueTs(
|
||||||
|
{
|
||||||
|
name: 'app/files-to-lint',
|
||||||
|
files: ['**/*.{vue,ts,mts,tsx}'],
|
||||||
|
},
|
||||||
|
|
||||||
|
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
|
||||||
|
|
||||||
|
...pluginVue.configs['flat/essential'],
|
||||||
|
vueTsConfigs.recommended,
|
||||||
|
|
||||||
|
{
|
||||||
|
...pluginVitest.configs.recommended,
|
||||||
|
files: ['src/**/__tests__/*'],
|
||||||
|
},
|
||||||
|
|
||||||
|
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
|
||||||
|
|
||||||
|
skipFormatting,
|
||||||
|
)
|
||||||
13
sop-front/index.html
Normal file
13
sop-front/index.html
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="fr">
|
||||||
|
<head>
|
||||||
|
<meta charset="UTF-8">
|
||||||
|
<link rel="icon" href="/favicon.ico">
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1.0">
|
||||||
|
<title>Smash or Pass ?</title>
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<div id="app"></div>
|
||||||
|
<script type="module" src="/src/main.ts"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
||||||
10
sop-front/nginx.conf
Normal file
10
sop-front/nginx.conf
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
server {
|
||||||
|
listen 80;
|
||||||
|
server_name _;
|
||||||
|
root /usr/share/nginx/html;
|
||||||
|
index index.html;
|
||||||
|
|
||||||
|
location / {
|
||||||
|
try_files $uri $uri/ /index.html;
|
||||||
|
}
|
||||||
|
}
|
||||||
7022
sop-front/package-lock.json
generated
Normal file
7022
sop-front/package-lock.json
generated
Normal file
File diff suppressed because it is too large
Load Diff
52
sop-front/package.json
Normal file
52
sop-front/package.json
Normal file
@@ -0,0 +1,52 @@
|
|||||||
|
{
|
||||||
|
"name": "sop-front",
|
||||||
|
"version": "0.0.0",
|
||||||
|
"private": true,
|
||||||
|
"type": "module",
|
||||||
|
"scripts": {
|
||||||
|
"dev": "vite",
|
||||||
|
"build": "run-p type-check \"build-only {@}\" --",
|
||||||
|
"preview": "vite preview",
|
||||||
|
"test:unit": "vitest",
|
||||||
|
"build-only": "vite build",
|
||||||
|
"type-check": "vue-tsc --build",
|
||||||
|
"lint": "run-s lint:*",
|
||||||
|
"lint:oxlint": "oxlint . --fix",
|
||||||
|
"lint:eslint": "eslint . --fix --cache",
|
||||||
|
"format": "prettier --write --experimental-cli src/"
|
||||||
|
},
|
||||||
|
"dependencies": {
|
||||||
|
"pinia": "^3.0.4",
|
||||||
|
"vue": "^3.5.32",
|
||||||
|
"vue-router": "^5.0.4"
|
||||||
|
},
|
||||||
|
"devDependencies": {
|
||||||
|
"@tailwindcss/vite": "^4.0.0",
|
||||||
|
"tailwindcss": "^4.0.0",
|
||||||
|
"@tsconfig/node24": "^24.0.4",
|
||||||
|
"@types/jsdom": "^28.0.1",
|
||||||
|
"@types/node": "^24.12.2",
|
||||||
|
"@vitejs/plugin-vue": "^6.0.6",
|
||||||
|
"@vitest/eslint-plugin": "^1.6.16",
|
||||||
|
"@vue/eslint-config-typescript": "^14.7.0",
|
||||||
|
"@vue/test-utils": "^2.4.6",
|
||||||
|
"@vue/tsconfig": "^0.9.1",
|
||||||
|
"eslint": "^10.2.1",
|
||||||
|
"eslint-config-prettier": "^10.1.8",
|
||||||
|
"eslint-plugin-oxlint": "~1.60.0",
|
||||||
|
"eslint-plugin-vue": "~10.8.0",
|
||||||
|
"jiti": "^2.6.1",
|
||||||
|
"jsdom": "^29.0.2",
|
||||||
|
"npm-run-all2": "^8.0.4",
|
||||||
|
"oxlint": "~1.60.0",
|
||||||
|
"prettier": "3.8.3",
|
||||||
|
"typescript": "~6.0.0",
|
||||||
|
"vite": "^8.0.8",
|
||||||
|
"vite-plugin-vue-devtools": "^8.1.1",
|
||||||
|
"vitest": "^4.1.4",
|
||||||
|
"vue-tsc": "^3.2.6"
|
||||||
|
},
|
||||||
|
"engines": {
|
||||||
|
"node": "^20.19.0 || >=22.12.0"
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
sop-front/public/favicon.ico
Normal file
BIN
sop-front/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 4.2 KiB |
5
sop-front/src/App.vue
Normal file
5
sop-front/src/App.vue
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
<script setup lang="ts"></script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<RouterView />
|
||||||
|
</template>
|
||||||
76
sop-front/src/api/client.ts
Normal file
76
sop-front/src/api/client.ts
Normal file
@@ -0,0 +1,76 @@
|
|||||||
|
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
|
||||||
|
|
||||||
|
export interface Character {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
filename: string
|
||||||
|
s3_url: string
|
||||||
|
collection_id: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionSummary {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
character_count: number
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface CollectionDetail {
|
||||||
|
id: number
|
||||||
|
name: string
|
||||||
|
created_at: string
|
||||||
|
characters: Character[]
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handle<T>(res: Response): Promise<T> {
|
||||||
|
if (!res.ok) {
|
||||||
|
let detail = res.statusText
|
||||||
|
try {
|
||||||
|
const body = await res.json()
|
||||||
|
if (body && typeof body.detail === 'string') detail = body.detail
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
throw new Error(detail)
|
||||||
|
}
|
||||||
|
if (res.status === 204) return undefined as T
|
||||||
|
return (await res.json()) as T
|
||||||
|
}
|
||||||
|
|
||||||
|
export const api = {
|
||||||
|
base: API_BASE,
|
||||||
|
|
||||||
|
async adminStatus(): Promise<{ admin_enabled: boolean }> {
|
||||||
|
return handle(await fetch(`${API_BASE}/admin/status`))
|
||||||
|
},
|
||||||
|
|
||||||
|
async listCollections(): Promise<CollectionSummary[]> {
|
||||||
|
return handle(await fetch(`${API_BASE}/collections`))
|
||||||
|
},
|
||||||
|
|
||||||
|
async getCollection(id: number): Promise<CollectionDetail> {
|
||||||
|
return handle(await fetch(`${API_BASE}/collections/${id}`))
|
||||||
|
},
|
||||||
|
|
||||||
|
async createCollection(name: string, files: File[]): Promise<CollectionDetail> {
|
||||||
|
const fd = new FormData()
|
||||||
|
fd.append('name', name)
|
||||||
|
for (const f of files) fd.append('files', f)
|
||||||
|
return handle(await fetch(`${API_BASE}/admin/collections`, { method: 'POST', body: fd }))
|
||||||
|
},
|
||||||
|
|
||||||
|
async addCharacters(collectionId: number, files: File[]): Promise<CollectionDetail> {
|
||||||
|
const fd = new FormData()
|
||||||
|
for (const f of files) fd.append('files', f)
|
||||||
|
return handle(
|
||||||
|
await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, {
|
||||||
|
method: 'POST',
|
||||||
|
body: fd,
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
},
|
||||||
|
|
||||||
|
async deleteCollection(id: number): Promise<void> {
|
||||||
|
return handle(await fetch(`${API_BASE}/admin/collections/${id}`, { method: 'DELETE' }))
|
||||||
|
},
|
||||||
|
}
|
||||||
59
sop-front/src/assets/main.css
Normal file
59
sop-front/src/assets/main.css
Normal file
@@ -0,0 +1,59 @@
|
|||||||
|
@import 'tailwindcss';
|
||||||
|
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap');
|
||||||
|
|
||||||
|
/* ============================================================
|
||||||
|
Smash or Pass — design tokens
|
||||||
|
Palette source: provided reference image.
|
||||||
|
============================================================ */
|
||||||
|
@theme {
|
||||||
|
/* Brand palette */
|
||||||
|
--color-tongue: #b9d532; /* smash / accent green */
|
||||||
|
--color-iris: #ff453b; /* pass / red */
|
||||||
|
--color-paws: #63586e; /* muted */
|
||||||
|
--color-fur: #fbf9fd; /* off-white */
|
||||||
|
--color-rayures: #f0d9ff; /* soft lilac */
|
||||||
|
--color-body: #bfa2db; /* light purple */
|
||||||
|
--color-body2: #8363a2; /* mid purple */
|
||||||
|
--color-clothes: #8324de; /* primary purple */
|
||||||
|
|
||||||
|
/* Semantic roles */
|
||||||
|
--color-bg: #16141a; /* dark grey background */
|
||||||
|
--color-surface: #1f1c24; /* card surface */
|
||||||
|
--color-surface-2: #2a2630; /* elevated surface */
|
||||||
|
--color-border: #3a3340;
|
||||||
|
--color-text: #fbf9fd;
|
||||||
|
--color-text-muted: #b9b1c2;
|
||||||
|
|
||||||
|
--color-primary: #8324de;
|
||||||
|
--color-primary-hover: #9442e8;
|
||||||
|
--color-smash: #b9d532;
|
||||||
|
--color-pass: #ff453b;
|
||||||
|
|
||||||
|
/* Typography */
|
||||||
|
--font-sans: 'Inter', system-ui, sans-serif;
|
||||||
|
--font-display: 'Bebas Neue', 'Inter', sans-serif;
|
||||||
|
|
||||||
|
/* Radii */
|
||||||
|
--radius-card: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
html,
|
||||||
|
body,
|
||||||
|
#app {
|
||||||
|
height: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--color-bg);
|
||||||
|
color: var(--color-text);
|
||||||
|
font-family: var(--font-sans);
|
||||||
|
-webkit-font-smoothing: antialiased;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1,
|
||||||
|
h2,
|
||||||
|
h3,
|
||||||
|
.display {
|
||||||
|
font-family: var(--font-display);
|
||||||
|
letter-spacing: 0.02em;
|
||||||
|
}
|
||||||
129
sop-front/src/components/AdminPanel.vue
Normal file
129
sop-front/src/components/AdminPanel.vue
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { ref } from 'vue'
|
||||||
|
import { api, type CollectionSummary } from '@/api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{ collections: CollectionSummary[] }>()
|
||||||
|
const emit = defineEmits<{ (e: 'refresh'): void }>()
|
||||||
|
|
||||||
|
const name = ref('')
|
||||||
|
const files = ref<File[]>([])
|
||||||
|
const busy = ref(false)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const fileInput = ref<HTMLInputElement | null>(null)
|
||||||
|
|
||||||
|
function onFileChange(e: Event) {
|
||||||
|
const input = e.target as HTMLInputElement
|
||||||
|
files.value = input.files ? Array.from(input.files) : []
|
||||||
|
}
|
||||||
|
|
||||||
|
async function submit() {
|
||||||
|
error.value = null
|
||||||
|
if (!name.value.trim()) {
|
||||||
|
error.value = 'Nom de collection requis'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
if (files.value.length === 0) {
|
||||||
|
error.value = 'Au moins une image est requise'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
busy.value = true
|
||||||
|
try {
|
||||||
|
await api.createCollection(name.value.trim(), files.value)
|
||||||
|
name.value = ''
|
||||||
|
files.value = []
|
||||||
|
if (fileInput.value) fileInput.value.value = ''
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
busy.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function remove(id: number) {
|
||||||
|
if (!confirm('Supprimer cette collection ?')) return
|
||||||
|
try {
|
||||||
|
await api.deleteCollection(id)
|
||||||
|
emit('refresh')
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<section
|
||||||
|
class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6"
|
||||||
|
>
|
||||||
|
<header class="mb-4 flex items-center gap-2">
|
||||||
|
<span
|
||||||
|
class="inline-block rounded-md bg-[var(--color-clothes)] px-2 py-0.5 text-xs font-bold uppercase text-white"
|
||||||
|
>
|
||||||
|
Admin
|
||||||
|
</span>
|
||||||
|
<h2 class="text-2xl">Créer une collection</h2>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<form class="space-y-4" @submit.prevent="submit">
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm text-[var(--color-text-muted)]">
|
||||||
|
Nom de la collection
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
v-model="name"
|
||||||
|
type="text"
|
||||||
|
placeholder="ex: Personnages Disney"
|
||||||
|
class="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div>
|
||||||
|
<label class="mb-1 block text-sm text-[var(--color-text-muted)]">
|
||||||
|
Images ({{ files.length }} sélectionnée{{ files.length > 1 ? 's' : '' }})
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
ref="fileInput"
|
||||||
|
type="file"
|
||||||
|
accept="image/*"
|
||||||
|
multiple
|
||||||
|
class="block w-full text-sm text-[var(--color-text-muted)] file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--color-clothes)] file:px-4 file:py-2 file:font-semibold file:text-white hover:file:bg-[var(--color-primary-hover)]"
|
||||||
|
@change="onFileChange"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-sm text-[var(--color-iris)]">{{ error }}</p>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
:disabled="busy"
|
||||||
|
class="rounded-lg bg-[var(--color-clothes)] px-5 py-2 font-semibold text-white transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
|
||||||
|
>
|
||||||
|
{{ busy ? 'Envoi...' : 'Créer la collection' }}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<div v-if="props.collections.length" class="mt-8">
|
||||||
|
<h3 class="mb-2 text-lg">Gérer les collections</h3>
|
||||||
|
<ul class="space-y-2">
|
||||||
|
<li
|
||||||
|
v-for="c in props.collections"
|
||||||
|
:key="c.id"
|
||||||
|
class="flex items-center justify-between rounded-lg bg-[var(--color-surface-2)] px-3 py-2"
|
||||||
|
>
|
||||||
|
<span>
|
||||||
|
<span class="font-semibold">{{ c.name }}</span>
|
||||||
|
<span class="ml-2 text-sm text-[var(--color-text-muted)]">
|
||||||
|
{{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
<button
|
||||||
|
class="rounded-md border border-[var(--color-iris)] px-3 py-1 text-sm text-[var(--color-iris)] hover:bg-[var(--color-iris)] hover:text-white"
|
||||||
|
@click="remove(c.id)"
|
||||||
|
>
|
||||||
|
Supprimer
|
||||||
|
</button>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
</section>
|
||||||
|
</template>
|
||||||
119
sop-front/src/components/SwipeCard.vue
Normal file
119
sop-front/src/components/SwipeCard.vue
Normal file
@@ -0,0 +1,119 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, ref } from 'vue'
|
||||||
|
import type { Character } from '@/api/client'
|
||||||
|
|
||||||
|
const props = defineProps<{ character: Character; topMost: boolean }>()
|
||||||
|
const emit = defineEmits<{
|
||||||
|
(e: 'smash'): void
|
||||||
|
(e: 'pass'): void
|
||||||
|
}>()
|
||||||
|
|
||||||
|
const dragX = ref(0)
|
||||||
|
const dragY = ref(0)
|
||||||
|
const dragging = ref(false)
|
||||||
|
const exiting = ref<'left' | 'right' | null>(null)
|
||||||
|
const startX = ref(0)
|
||||||
|
const startY = ref(0)
|
||||||
|
const pointerId = ref<number | null>(null)
|
||||||
|
|
||||||
|
const SWIPE_THRESHOLD = 120
|
||||||
|
|
||||||
|
const transform = computed(() => {
|
||||||
|
if (exiting.value === 'right') {
|
||||||
|
return 'translate(800px, 0) rotate(30deg)'
|
||||||
|
}
|
||||||
|
if (exiting.value === 'left') {
|
||||||
|
return 'translate(-800px, 0) rotate(-30deg)'
|
||||||
|
}
|
||||||
|
const rot = dragX.value / 20
|
||||||
|
return `translate(${dragX.value}px, ${dragY.value}px) rotate(${rot}deg)`
|
||||||
|
})
|
||||||
|
|
||||||
|
const smashOpacity = computed(() => Math.min(1, Math.max(0, dragX.value / SWIPE_THRESHOLD)))
|
||||||
|
const passOpacity = computed(() => Math.min(1, Math.max(0, -dragX.value / SWIPE_THRESHOLD)))
|
||||||
|
|
||||||
|
function onPointerDown(e: PointerEvent) {
|
||||||
|
if (!props.topMost || exiting.value) return
|
||||||
|
dragging.value = true
|
||||||
|
startX.value = e.clientX
|
||||||
|
startY.value = e.clientY
|
||||||
|
pointerId.value = e.pointerId
|
||||||
|
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerMove(e: PointerEvent) {
|
||||||
|
if (!dragging.value) return
|
||||||
|
dragX.value = e.clientX - startX.value
|
||||||
|
dragY.value = (e.clientY - startY.value) * 0.3
|
||||||
|
}
|
||||||
|
|
||||||
|
function onPointerUp() {
|
||||||
|
if (!dragging.value) return
|
||||||
|
dragging.value = false
|
||||||
|
if (dragX.value > SWIPE_THRESHOLD) {
|
||||||
|
finish('right')
|
||||||
|
} else if (dragX.value < -SWIPE_THRESHOLD) {
|
||||||
|
finish('left')
|
||||||
|
} else {
|
||||||
|
dragX.value = 0
|
||||||
|
dragY.value = 0
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function finish(dir: 'left' | 'right') {
|
||||||
|
exiting.value = dir
|
||||||
|
setTimeout(() => {
|
||||||
|
if (dir === 'right') emit('smash')
|
||||||
|
else emit('pass')
|
||||||
|
}, 250)
|
||||||
|
}
|
||||||
|
|
||||||
|
defineExpose({
|
||||||
|
triggerSmash: () => finish('right'),
|
||||||
|
triggerPass: () => finish('left'),
|
||||||
|
})
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<div
|
||||||
|
class="absolute inset-0 select-none touch-none"
|
||||||
|
:class="{ 'transition-transform duration-300 ease-out': !dragging }"
|
||||||
|
:style="{ transform }"
|
||||||
|
@pointerdown="onPointerDown"
|
||||||
|
@pointermove="onPointerMove"
|
||||||
|
@pointerup="onPointerUp"
|
||||||
|
@pointercancel="onPointerUp"
|
||||||
|
>
|
||||||
|
<div
|
||||||
|
class="relative h-full w-full overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
|
||||||
|
>
|
||||||
|
<img
|
||||||
|
:src="character.s3_url"
|
||||||
|
:alt="character.name"
|
||||||
|
class="h-full w-full object-cover"
|
||||||
|
draggable="false"
|
||||||
|
/>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4"
|
||||||
|
>
|
||||||
|
<p class="text-xl font-semibold text-[var(--color-fur)]">{{ character.name }}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute left-6 top-6 rounded-lg border-4 border-[var(--color-smash)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-smash)]"
|
||||||
|
style="transform: rotate(-12deg)"
|
||||||
|
:style="{ opacity: smashOpacity }"
|
||||||
|
>
|
||||||
|
Smash
|
||||||
|
</div>
|
||||||
|
<div
|
||||||
|
class="pointer-events-none absolute right-6 top-6 rounded-lg border-4 border-[var(--color-pass)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-pass)]"
|
||||||
|
style="transform: rotate(12deg)"
|
||||||
|
:style="{ opacity: passOpacity }"
|
||||||
|
>
|
||||||
|
Pass
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</template>
|
||||||
14
sop-front/src/main.ts
Normal file
14
sop-front/src/main.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import './assets/main.css'
|
||||||
|
|
||||||
|
import { createApp } from 'vue'
|
||||||
|
import { createPinia } from 'pinia'
|
||||||
|
|
||||||
|
import App from './App.vue'
|
||||||
|
import router from './router'
|
||||||
|
|
||||||
|
const app = createApp(App)
|
||||||
|
|
||||||
|
app.use(createPinia())
|
||||||
|
app.use(router)
|
||||||
|
|
||||||
|
app.mount('#app')
|
||||||
15
sop-front/src/router/index.ts
Normal file
15
sop-front/src/router/index.ts
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
import { createRouter, createWebHistory } from 'vue-router'
|
||||||
|
import HomeView from '@/views/HomeView.vue'
|
||||||
|
import GameView from '@/views/GameView.vue'
|
||||||
|
import SummaryView from '@/views/SummaryView.vue'
|
||||||
|
|
||||||
|
const router = createRouter({
|
||||||
|
history: createWebHistory(import.meta.env.BASE_URL),
|
||||||
|
routes: [
|
||||||
|
{ path: '/', name: 'home', component: HomeView },
|
||||||
|
{ path: '/game/:id', name: 'game', component: GameView, props: true },
|
||||||
|
{ path: '/summary', name: 'summary', component: SummaryView },
|
||||||
|
],
|
||||||
|
})
|
||||||
|
|
||||||
|
export default router
|
||||||
48
sop-front/src/stores/game.ts
Normal file
48
sop-front/src/stores/game.ts
Normal file
@@ -0,0 +1,48 @@
|
|||||||
|
import { defineStore } from 'pinia'
|
||||||
|
import type { Character, CollectionDetail } from '@/api/client'
|
||||||
|
|
||||||
|
interface GameState {
|
||||||
|
collection: CollectionDetail | null
|
||||||
|
queue: Character[]
|
||||||
|
smashed: Character[]
|
||||||
|
passed: Character[]
|
||||||
|
}
|
||||||
|
|
||||||
|
export const useGameStore = defineStore('game', {
|
||||||
|
state: (): GameState => ({
|
||||||
|
collection: null,
|
||||||
|
queue: [],
|
||||||
|
smashed: [],
|
||||||
|
passed: [],
|
||||||
|
}),
|
||||||
|
getters: {
|
||||||
|
isFinished: (s) => s.collection !== null && s.queue.length === 0,
|
||||||
|
progress: (s) => {
|
||||||
|
const total = s.collection?.characters.length ?? 0
|
||||||
|
if (total === 0) return 0
|
||||||
|
return ((s.smashed.length + s.passed.length) / total) * 100
|
||||||
|
},
|
||||||
|
},
|
||||||
|
actions: {
|
||||||
|
start(collection: CollectionDetail) {
|
||||||
|
this.collection = collection
|
||||||
|
this.queue = [...collection.characters]
|
||||||
|
this.smashed = []
|
||||||
|
this.passed = []
|
||||||
|
},
|
||||||
|
smash() {
|
||||||
|
const c = this.queue.shift()
|
||||||
|
if (c) this.smashed.push(c)
|
||||||
|
},
|
||||||
|
pass() {
|
||||||
|
const c = this.queue.shift()
|
||||||
|
if (c) this.passed.push(c)
|
||||||
|
},
|
||||||
|
reset() {
|
||||||
|
this.collection = null
|
||||||
|
this.queue = []
|
||||||
|
this.smashed = []
|
||||||
|
this.passed = []
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
109
sop-front/src/views/GameView.vue
Normal file
109
sop-front/src/views/GameView.vue
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed, onMounted, ref, watch } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api } from '@/api/client'
|
||||||
|
import { useGameStore } from '@/stores/game'
|
||||||
|
import SwipeCard from '@/components/SwipeCard.vue'
|
||||||
|
|
||||||
|
const props = defineProps<{ id: string }>()
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useGameStore()
|
||||||
|
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
const topCard = ref<InstanceType<typeof SwipeCard> | null>(null)
|
||||||
|
|
||||||
|
const visibleStack = computed(() => store.queue.slice(0, 3).reverse())
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
try {
|
||||||
|
const collection = await api.getCollection(Number(props.id))
|
||||||
|
if (collection.characters.length === 0) {
|
||||||
|
error.value = 'Cette collection est vide.'
|
||||||
|
return
|
||||||
|
}
|
||||||
|
store.start(collection)
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
watch(
|
||||||
|
() => store.isFinished,
|
||||||
|
(finished) => {
|
||||||
|
if (finished) router.push({ name: 'summary' })
|
||||||
|
},
|
||||||
|
)
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mx-auto flex min-h-screen max-w-2xl flex-col px-4 py-6">
|
||||||
|
<header class="mb-4 flex items-center justify-between">
|
||||||
|
<button
|
||||||
|
class="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-fur)]"
|
||||||
|
@click="router.push('/')"
|
||||||
|
>
|
||||||
|
← Retour
|
||||||
|
</button>
|
||||||
|
<h1 class="display text-2xl text-[var(--color-fur)]">
|
||||||
|
{{ store.collection?.name ?? '' }}
|
||||||
|
</h1>
|
||||||
|
<span class="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{{ (store.collection?.characters.length ?? 0) - store.queue.length }} /
|
||||||
|
{{ store.collection?.characters.length ?? 0 }}
|
||||||
|
</span>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mb-4 h-1 w-full overflow-hidden rounded-full bg-[var(--color-surface-2)]">
|
||||||
|
<div
|
||||||
|
class="h-full bg-[var(--color-clothes)] transition-all"
|
||||||
|
:style="{ width: `${store.progress}%` }"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<p v-if="error" class="text-center text-[var(--color-iris)]">{{ error }}</p>
|
||||||
|
<p v-else-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement…</p>
|
||||||
|
|
||||||
|
<div v-else class="relative mx-auto aspect-[3/4] w-full max-w-md flex-1">
|
||||||
|
<SwipeCard
|
||||||
|
v-for="(c, idx) in visibleStack"
|
||||||
|
:key="c.id"
|
||||||
|
:ref="idx === visibleStack.length - 1 ? (el) => (topCard = el as never) : undefined"
|
||||||
|
:character="c"
|
||||||
|
:top-most="idx === visibleStack.length - 1"
|
||||||
|
:style="{
|
||||||
|
transform:
|
||||||
|
idx === visibleStack.length - 1
|
||||||
|
? undefined
|
||||||
|
: `scale(${1 - (visibleStack.length - 1 - idx) * 0.04}) translateY(${(visibleStack.length - 1 - idx) * 12}px)`,
|
||||||
|
opacity: idx === visibleStack.length - 1 ? 1 : 0.6,
|
||||||
|
zIndex: idx,
|
||||||
|
}"
|
||||||
|
@smash="store.smash()"
|
||||||
|
@pass="store.pass()"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div v-if="!loading && !error" class="mt-6 flex items-center justify-center gap-8">
|
||||||
|
<button
|
||||||
|
aria-label="Pass"
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-[var(--color-pass)] bg-[var(--color-surface)] text-3xl text-[var(--color-pass)] transition hover:bg-[var(--color-pass)] hover:text-white"
|
||||||
|
@click="topCard?.triggerPass()"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
aria-label="Smash"
|
||||||
|
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-[var(--color-smash)] bg-[var(--color-surface)] text-3xl text-[var(--color-smash)] transition hover:bg-[var(--color-smash)] hover:text-black"
|
||||||
|
@click="topCard?.triggerSmash()"
|
||||||
|
>
|
||||||
|
♥
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
79
sop-front/src/views/HomeView.vue
Normal file
79
sop-front/src/views/HomeView.vue
Normal file
@@ -0,0 +1,79 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { onMounted, ref } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { api, type CollectionSummary } from '@/api/client'
|
||||||
|
import AdminPanel from '@/components/AdminPanel.vue'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const collections = ref<CollectionSummary[]>([])
|
||||||
|
const adminEnabled = ref(false)
|
||||||
|
const loading = ref(true)
|
||||||
|
const error = ref<string | null>(null)
|
||||||
|
|
||||||
|
async function load() {
|
||||||
|
loading.value = true
|
||||||
|
error.value = null
|
||||||
|
try {
|
||||||
|
const [status, list] = await Promise.all([api.adminStatus(), api.listCollections()])
|
||||||
|
adminEnabled.value = status.admin_enabled
|
||||||
|
collections.value = list
|
||||||
|
} catch (e) {
|
||||||
|
error.value = (e as Error).message
|
||||||
|
} finally {
|
||||||
|
loading.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function play(id: number) {
|
||||||
|
router.push({ name: 'game', params: { id } })
|
||||||
|
}
|
||||||
|
|
||||||
|
onMounted(load)
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mx-auto max-w-5xl px-6 py-12">
|
||||||
|
<header class="mb-12 text-center">
|
||||||
|
<h1 class="display text-6xl text-[var(--color-fur)] md:text-8xl">
|
||||||
|
Smash <span class="text-[var(--color-clothes)]">or</span> Pass
|
||||||
|
<span class="text-[var(--color-iris)]">?</span>
|
||||||
|
</h1>
|
||||||
|
<p class="mt-3 text-[var(--color-text-muted)]">
|
||||||
|
Choisissez une collection et lancez la partie.
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<p v-if="error" class="mb-4 text-center text-[var(--color-iris)]">{{ error }}</p>
|
||||||
|
|
||||||
|
<section v-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement…</section>
|
||||||
|
|
||||||
|
<section v-else>
|
||||||
|
<div v-if="collections.length === 0" class="text-center text-[var(--color-text-muted)]">
|
||||||
|
Aucune collection disponible pour le moment.
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<ul v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="c in collections"
|
||||||
|
:key="c.id"
|
||||||
|
class="group cursor-pointer rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 transition hover:border-[var(--color-clothes)] hover:bg-[var(--color-surface-2)]"
|
||||||
|
@click="play(c.id)"
|
||||||
|
>
|
||||||
|
<h2 class="mb-2 text-2xl text-[var(--color-fur)]">{{ c.name }}</h2>
|
||||||
|
<p class="text-sm text-[var(--color-text-muted)]">
|
||||||
|
{{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
<p
|
||||||
|
class="mt-4 inline-block rounded-md bg-[var(--color-clothes)] px-3 py-1 text-sm font-semibold text-white opacity-0 transition group-hover:opacity-100"
|
||||||
|
>
|
||||||
|
Jouer →
|
||||||
|
</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<div v-if="adminEnabled" class="mt-16">
|
||||||
|
<AdminPanel :collections="collections" @refresh="load" />
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
91
sop-front/src/views/SummaryView.vue
Normal file
91
sop-front/src/views/SummaryView.vue
Normal file
@@ -0,0 +1,91 @@
|
|||||||
|
<script setup lang="ts">
|
||||||
|
import { computed } from 'vue'
|
||||||
|
import { useRouter } from 'vue-router'
|
||||||
|
import { useGameStore } from '@/stores/game'
|
||||||
|
|
||||||
|
const router = useRouter()
|
||||||
|
const store = useGameStore()
|
||||||
|
|
||||||
|
const collectionName = computed(() => store.collection?.name ?? '')
|
||||||
|
const total = computed(() => store.smashed.length + store.passed.length)
|
||||||
|
|
||||||
|
function replay() {
|
||||||
|
const id = store.collection?.id
|
||||||
|
store.reset()
|
||||||
|
if (id) router.push({ name: 'game', params: { id } })
|
||||||
|
else router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
function home() {
|
||||||
|
store.reset()
|
||||||
|
router.push('/')
|
||||||
|
}
|
||||||
|
|
||||||
|
if (!store.collection) {
|
||||||
|
router.replace('/')
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<template>
|
||||||
|
<main class="mx-auto max-w-5xl px-6 py-12">
|
||||||
|
<header class="mb-8 text-center">
|
||||||
|
<h1 class="display text-5xl text-[var(--color-fur)] md:text-7xl">Résultats</h1>
|
||||||
|
<p class="mt-2 text-[var(--color-text-muted)]">
|
||||||
|
{{ collectionName }} · {{ total }} personnage{{ total > 1 ? 's' : '' }}
|
||||||
|
</p>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<div class="mb-10 flex flex-wrap items-center justify-center gap-4">
|
||||||
|
<button
|
||||||
|
class="rounded-lg bg-[var(--color-clothes)] px-5 py-2 font-semibold text-white transition hover:bg-[var(--color-primary-hover)]"
|
||||||
|
@click="replay"
|
||||||
|
>
|
||||||
|
Rejouer
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
class="rounded-lg border border-[var(--color-border)] px-5 py-2 font-semibold text-[var(--color-fur)] transition hover:border-[var(--color-clothes)]"
|
||||||
|
@click="home"
|
||||||
|
>
|
||||||
|
Accueil
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-4 text-2xl text-[var(--color-smash)]">
|
||||||
|
Smashed ({{ store.smashed.length }})
|
||||||
|
</h2>
|
||||||
|
<p v-if="store.smashed.length === 0" class="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Aucun.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="c in store.smashed"
|
||||||
|
:key="c.id"
|
||||||
|
class="overflow-hidden rounded-lg border-2 border-[var(--color-smash)] bg-[var(--color-surface)]"
|
||||||
|
>
|
||||||
|
<img :src="c.s3_url" :alt="c.name" class="aspect-square w-full object-cover" />
|
||||||
|
<p class="truncate px-2 py-1 text-xs">{{ c.name }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
|
||||||
|
<section>
|
||||||
|
<h2 class="mb-4 text-2xl text-[var(--color-pass)]">Passed ({{ store.passed.length }})</h2>
|
||||||
|
<p v-if="store.passed.length === 0" class="text-sm text-[var(--color-text-muted)]">
|
||||||
|
Aucun.
|
||||||
|
</p>
|
||||||
|
<ul v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3">
|
||||||
|
<li
|
||||||
|
v-for="c in store.passed"
|
||||||
|
:key="c.id"
|
||||||
|
class="overflow-hidden rounded-lg border-2 border-[var(--color-pass)] bg-[var(--color-surface)] opacity-75"
|
||||||
|
>
|
||||||
|
<img :src="c.s3_url" :alt="c.name" class="aspect-square w-full object-cover" />
|
||||||
|
<p class="truncate px-2 py-1 text-xs">{{ c.name }}</p>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
</section>
|
||||||
|
</div>
|
||||||
|
</main>
|
||||||
|
</template>
|
||||||
18
sop-front/tsconfig.app.json
Normal file
18
sop-front/tsconfig.app.json
Normal file
@@ -0,0 +1,18 @@
|
|||||||
|
{
|
||||||
|
"extends": "@vue/tsconfig/tsconfig.dom.json",
|
||||||
|
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
|
||||||
|
"exclude": ["src/**/__tests__/*"],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Extra safety for array and object lookups, but may have false positives.
|
||||||
|
"noUncheckedIndexedAccess": true,
|
||||||
|
|
||||||
|
// Path mapping for cleaner imports.
|
||||||
|
"paths": {
|
||||||
|
"@/*": ["./src/*"]
|
||||||
|
},
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
14
sop-front/tsconfig.json
Normal file
14
sop-front/tsconfig.json
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
{
|
||||||
|
"files": [],
|
||||||
|
"references": [
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.node.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.app.json"
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"path": "./tsconfig.vitest.json"
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
27
sop-front/tsconfig.node.json
Normal file
27
sop-front/tsconfig.node.json
Normal file
@@ -0,0 +1,27 @@
|
|||||||
|
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
|
||||||
|
{
|
||||||
|
"extends": "@tsconfig/node24/tsconfig.json",
|
||||||
|
"include": [
|
||||||
|
"vite.config.*",
|
||||||
|
"vitest.config.*",
|
||||||
|
"cypress.config.*",
|
||||||
|
"playwright.config.*",
|
||||||
|
"eslint.config.*"
|
||||||
|
],
|
||||||
|
"compilerOptions": {
|
||||||
|
// Most tools use transpilation instead of Node.js's native type-stripping.
|
||||||
|
// Bundler mode provides a smoother developer experience.
|
||||||
|
"module": "preserve",
|
||||||
|
"moduleResolution": "bundler",
|
||||||
|
|
||||||
|
// Include Node.js types and avoid accidentally including other `@types/*` packages.
|
||||||
|
"types": ["node"],
|
||||||
|
|
||||||
|
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
|
||||||
|
"noEmit": true,
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
19
sop-front/tsconfig.vitest.json
Normal file
19
sop-front/tsconfig.vitest.json
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
{
|
||||||
|
"extends": "./tsconfig.app.json",
|
||||||
|
|
||||||
|
// Override to include only test files and clear exclusions.
|
||||||
|
// Application code imported in tests is automatically included via module resolution.
|
||||||
|
"include": ["src/**/__tests__/*", "env.d.ts"],
|
||||||
|
"exclude": [],
|
||||||
|
|
||||||
|
"compilerOptions": {
|
||||||
|
// Vitest runs in a different environment than the application code.
|
||||||
|
// Adjust lib and types accordingly.
|
||||||
|
"lib": [],
|
||||||
|
"types": ["node", "jsdom"],
|
||||||
|
|
||||||
|
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
|
||||||
|
// Specified here to keep it out of the root directory.
|
||||||
|
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
|
||||||
|
}
|
||||||
|
}
|
||||||
24
sop-front/vite.config.ts
Normal file
24
sop-front/vite.config.ts
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
import { fileURLToPath, URL } from 'node:url'
|
||||||
|
|
||||||
|
import { defineConfig } from 'vite'
|
||||||
|
import vue from '@vitejs/plugin-vue'
|
||||||
|
import vueDevTools from 'vite-plugin-vue-devtools'
|
||||||
|
import tailwindcss from '@tailwindcss/vite'
|
||||||
|
|
||||||
|
// https://vite.dev/config/
|
||||||
|
export default defineConfig({
|
||||||
|
plugins: [
|
||||||
|
vue(),
|
||||||
|
vueDevTools(),
|
||||||
|
tailwindcss(),
|
||||||
|
],
|
||||||
|
server: {
|
||||||
|
host: '0.0.0.0',
|
||||||
|
port: 5173,
|
||||||
|
},
|
||||||
|
resolve: {
|
||||||
|
alias: {
|
||||||
|
'@': fileURLToPath(new URL('./src', import.meta.url))
|
||||||
|
},
|
||||||
|
},
|
||||||
|
})
|
||||||
14
sop-front/vitest.config.ts
Normal file
14
sop-front/vitest.config.ts
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
import { fileURLToPath } from 'node:url'
|
||||||
|
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
|
||||||
|
import viteConfig from './vite.config'
|
||||||
|
|
||||||
|
export default mergeConfig(
|
||||||
|
viteConfig,
|
||||||
|
defineConfig({
|
||||||
|
test: {
|
||||||
|
environment: 'jsdom',
|
||||||
|
exclude: [...configDefaults.exclude, 'e2e/**'],
|
||||||
|
root: fileURLToPath(new URL('./', import.meta.url)),
|
||||||
|
},
|
||||||
|
}),
|
||||||
|
)
|
||||||
Reference in New Issue
Block a user