This commit is contained in:
2026-05-05 16:52:40 +02:00
commit bdb523d4b8
58 changed files with 8880 additions and 0 deletions

9
.gitignore vendored Normal file
View File

@@ -0,0 +1,9 @@
.env
**/.env
**/__pycache__/
**/*.pyc
**/.venv/
**/node_modules/
**/dist/
sop-back/data/
.DS_Store

65
docker-compose.yml Normal file
View 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
View File

@@ -0,0 +1,6 @@
.venv
__pycache__
*.pyc
.env
data/
.git

17
sop-back/.env.example Normal file
View 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
View 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
View 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
View File

View File

View File

View 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)

View 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

View 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)

View File

View 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
View 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",
)

View File

View 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
View 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"])

View File

View 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")

View File

View 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

View File

View 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

View 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
View File

@@ -0,0 +1,4 @@
node_modules
dist
.git
.vscode

8
sop-front/.editorconfig Normal file
View 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
View File

@@ -0,0 +1 @@
* text=auto eol=lf

39
sop-front/.gitignore vendored Normal file
View 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
View 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"
}
}

View 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
View 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
View 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
View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />

View 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
View 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
View 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

File diff suppressed because it is too large Load Diff

52
sop-front/package.json Normal file
View 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"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

5
sop-front/src/App.vue Normal file
View File

@@ -0,0 +1,5 @@
<script setup lang="ts"></script>
<template>
<RouterView />
</template>

View 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' }))
},
}

View 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;
}

View 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>

View 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
View 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')

View 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

View 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 = []
},
},
})

View 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>

View 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>

View 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>

View 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
View File

@@ -0,0 +1,14 @@
{
"files": [],
"references": [
{
"path": "./tsconfig.node.json"
},
{
"path": "./tsconfig.app.json"
},
{
"path": "./tsconfig.vitest.json"
}
]
}

View 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"
}
}

View 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
View 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))
},
},
})

View 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)),
},
}),
)