setup admin connection
This commit is contained in:
@@ -35,7 +35,8 @@ services:
|
||||
environment:
|
||||
PROJECT_NAME: "Smash or Pass"
|
||||
ALLOWED_ORIGINS: '["http://localhost:8080","http://localhost:5173"]'
|
||||
ADMIN_ENABLED: "true"
|
||||
ADMIN_USERNAME: "admin"
|
||||
ADMIN_PASSWORD: "change-me"
|
||||
DATABASE_URL: "sqlite:///./data/sop.db"
|
||||
S3_ENDPOINT_URL: "http://minio:9000"
|
||||
S3_PUBLIC_URL: "http://localhost:9000"
|
||||
|
||||
@@ -2,8 +2,10 @@
|
||||
PROJECT_NAME=Smash or Pass
|
||||
ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:8080"]
|
||||
|
||||
# Admin gate
|
||||
ADMIN_ENABLED=true
|
||||
# Admin account — set both to enable admin login at /login.
|
||||
# Leave empty to fully disable admin endpoints.
|
||||
ADMIN_USERNAME=admin
|
||||
ADMIN_PASSWORD=change-me
|
||||
|
||||
# Database (SQLite file path)
|
||||
DATABASE_URL=sqlite:///./data/sop.db
|
||||
|
||||
37
sop-back/app/api/routes/auth.py
Normal file
37
sop-back/app/api/routes/auth.py
Normal file
@@ -0,0 +1,37 @@
|
||||
import secrets
|
||||
from fastapi import APIRouter, HTTPException, status
|
||||
from pydantic import BaseModel
|
||||
|
||||
from app.core.config import settings
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
|
||||
class LoginRequest(BaseModel):
|
||||
username: str
|
||||
password: str
|
||||
|
||||
|
||||
class LoginResponse(BaseModel):
|
||||
token: str
|
||||
|
||||
|
||||
@router.post("/admin/login", response_model=LoginResponse)
|
||||
def admin_login(body: LoginRequest):
|
||||
if not settings.ADMIN_USERNAME or not settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin account is not configured",
|
||||
)
|
||||
# constant-time compare to avoid leaking timing info
|
||||
user_ok = secrets.compare_digest(body.username, settings.ADMIN_USERNAME)
|
||||
pass_ok = secrets.compare_digest(body.password, settings.ADMIN_PASSWORD)
|
||||
if not (user_ok and pass_ok):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid credentials",
|
||||
)
|
||||
# The token IS the password. Single account, no expiry, no rotation —
|
||||
# simple and matches what require_admin() expects in the Authorization
|
||||
# header. Swap for a signed/expiring token if you ever add multiple users.
|
||||
return LoginResponse(token=settings.ADMIN_PASSWORD)
|
||||
@@ -1,6 +1,4 @@
|
||||
from fastapi import APIRouter
|
||||
from app.core.config import settings
|
||||
from app.schemas.schemas import AdminStatus
|
||||
|
||||
router = APIRouter()
|
||||
|
||||
@@ -8,8 +6,3 @@ 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)
|
||||
|
||||
@@ -9,7 +9,10 @@ class Settings(BaseSettings):
|
||||
VERSION: str = "0.1.0"
|
||||
ALLOWED_ORIGINS: List[str] = ["*"]
|
||||
|
||||
ADMIN_ENABLED: bool = False
|
||||
# Single hardcoded admin account, configured via env.
|
||||
# If either field is empty, admin login is effectively disabled.
|
||||
ADMIN_USERNAME: str = ""
|
||||
ADMIN_PASSWORD: str = ""
|
||||
|
||||
DATABASE_URL: str = "sqlite:///./data/sop.db"
|
||||
|
||||
|
||||
@@ -1,10 +1,30 @@
|
||||
from fastapi import HTTPException, status
|
||||
import secrets
|
||||
from fastapi import Header, HTTPException, status
|
||||
from app.core.config import settings
|
||||
|
||||
|
||||
def require_admin() -> None:
|
||||
if not settings.ADMIN_ENABLED:
|
||||
def require_admin(authorization: str | None = Header(default=None)) -> None:
|
||||
"""Gate admin endpoints on a Bearer token equal to ADMIN_PASSWORD.
|
||||
|
||||
The token is issued by POST /admin/login after the username + password
|
||||
match the values from .env. We keep things deliberately simple — no JWT,
|
||||
no expiry — because there is exactly one admin account.
|
||||
"""
|
||||
if not settings.ADMIN_PASSWORD:
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_403_FORBIDDEN,
|
||||
detail="Admin module is disabled",
|
||||
detail="Admin account is not configured",
|
||||
)
|
||||
if not authorization or not authorization.lower().startswith("bearer "):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Missing bearer token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
token = authorization.split(" ", 1)[1].strip()
|
||||
if not secrets.compare_digest(token, settings.ADMIN_PASSWORD):
|
||||
raise HTTPException(
|
||||
status_code=status.HTTP_401_UNAUTHORIZED,
|
||||
detail="Invalid token",
|
||||
headers={"WWW-Authenticate": "Bearer"},
|
||||
)
|
||||
|
||||
@@ -6,7 +6,7 @@ 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
|
||||
from app.api.routes import health, collections, admin, auth
|
||||
|
||||
|
||||
@asynccontextmanager
|
||||
@@ -35,5 +35,6 @@ app.add_middleware(
|
||||
)
|
||||
|
||||
app.include_router(health.router, tags=["meta"])
|
||||
app.include_router(auth.router, tags=["auth"])
|
||||
app.include_router(collections.router, prefix="/collections", tags=["collections"])
|
||||
app.include_router(admin.router, prefix="/admin", tags=["admin"])
|
||||
|
||||
@@ -33,7 +33,3 @@ class CollectionOut(BaseModel):
|
||||
|
||||
class CollectionCreate(BaseModel):
|
||||
name: str
|
||||
|
||||
|
||||
class AdminStatus(BaseModel):
|
||||
admin_enabled: bool
|
||||
|
||||
@@ -95,20 +95,37 @@ npm run dev
|
||||
|
||||
## 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.
|
||||
**All design tokens live in [`src/assets/main.css`](src/assets/main.css)** under `@theme`. The palette is **graded** (Tailwind-style): each hue has a 50–900 scale, with **500** as the canonical brand value. Add new shades there; don't hardcode hex values in components.
|
||||
|
||||
Current palette comes from the user's reference image:
|
||||
### Scales (`50` lightest → `900/950` darkest)
|
||||
|
||||
| Token | Hex | Role |
|
||||
| Family | 500 (brand) | Use |
|
||||
|---|---|---|
|
||||
| `--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 |
|
||||
| `--color-purple-{50…900}` | `#8324DE` | Primary brand |
|
||||
| `--color-red-{50…900}` | `#FF453B` | Pass / errors / destructive |
|
||||
| `--color-lime-{50…900}` | `#B9D532` | Smash / success |
|
||||
| `--color-neutral-{50…950}` | `#63586E` | Backgrounds, surfaces, text, borders |
|
||||
|
||||
Fonts: **Bebas Neue** (display) + **Inter** (body), loaded via Google Fonts in `main.css`.
|
||||
Components reference shades like `var(--color-purple-300)`, `var(--color-neutral-800)`, etc. The neutral scale handles dark-mode surfaces (`50` = off-white text, `950` = `#16141A` background).
|
||||
|
||||
### Semantic aliases (prefer these in components)
|
||||
|
||||
These point at scale values — change them once in `main.css` to retheme:
|
||||
|
||||
| Alias | → Scale | Role |
|
||||
|---|---|---|
|
||||
| `--color-primary` | `purple-500` | CTAs / brand |
|
||||
| `--color-primary-hover` | `purple-400` | CTA hover |
|
||||
| `--color-smash` | `lime-500` | Smash action |
|
||||
| `--color-pass` | `red-500` | Pass action |
|
||||
| `--color-bg` | `neutral-950` | App background |
|
||||
| `--color-surface` | `neutral-900` | Card surface |
|
||||
| `--color-surface-2` | `neutral-800` | Elevated surface |
|
||||
| `--color-border` | `neutral-700` | Borders |
|
||||
| `--color-text` | `neutral-50` | Primary text |
|
||||
| `--color-text-muted` | `neutral-300` | Secondary text |
|
||||
|
||||
Fonts: **Bebas Neue** (display) + **Roboto** (body), loaded via Google Fonts in `main.css`.
|
||||
|
||||
---
|
||||
|
||||
|
||||
@@ -1,3 +1,5 @@
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
|
||||
|
||||
export interface Character {
|
||||
@@ -24,6 +26,15 @@ export interface CollectionDetail {
|
||||
|
||||
async function handle<T>(res: Response): Promise<T> {
|
||||
if (!res.ok) {
|
||||
// 401 on an admin call means our token is stale — drop it so the UI
|
||||
// falls back to the unauthenticated state.
|
||||
if (res.status === 401) {
|
||||
try {
|
||||
useAuthStore().clear()
|
||||
} catch {
|
||||
/* pinia not ready in non-component context — ignore */
|
||||
}
|
||||
}
|
||||
let detail = res.statusText
|
||||
try {
|
||||
const body = await res.json()
|
||||
@@ -37,13 +48,14 @@ async function handle<T>(res: Response): Promise<T> {
|
||||
return (await res.json()) as T
|
||||
}
|
||||
|
||||
function authHeaders(): HeadersInit {
|
||||
const token = useAuthStore().token
|
||||
return token ? { Authorization: `Bearer ${token}` } : {}
|
||||
}
|
||||
|
||||
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`))
|
||||
},
|
||||
@@ -52,11 +64,27 @@ export const api = {
|
||||
return handle(await fetch(`${API_BASE}/collections/${id}`))
|
||||
},
|
||||
|
||||
async login(username: string, password: string): Promise<{ token: string }> {
|
||||
return handle(
|
||||
await fetch(`${API_BASE}/admin/login`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ username, password }),
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
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 }))
|
||||
return handle(
|
||||
await fetch(`${API_BASE}/admin/collections`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: fd,
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async addCharacters(collectionId: number, files: File[]): Promise<CollectionDetail> {
|
||||
@@ -65,12 +93,18 @@ export const api = {
|
||||
return handle(
|
||||
await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, {
|
||||
method: 'POST',
|
||||
headers: authHeaders(),
|
||||
body: fd,
|
||||
}),
|
||||
)
|
||||
},
|
||||
|
||||
async deleteCollection(id: number): Promise<void> {
|
||||
return handle(await fetch(`${API_BASE}/admin/collections/${id}`, { method: 'DELETE' }))
|
||||
return handle(
|
||||
await fetch(`${API_BASE}/admin/collections/${id}`, {
|
||||
method: 'DELETE',
|
||||
headers: authHeaders(),
|
||||
}),
|
||||
)
|
||||
},
|
||||
}
|
||||
|
||||
@@ -1,37 +1,84 @@
|
||||
@import 'tailwindcss';
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap');
|
||||
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Roboto:wght@400;500;700&display=swap');
|
||||
|
||||
/* ============================================================
|
||||
Smash or Pass — design tokens
|
||||
Palette source: provided reference image.
|
||||
Graded scales (50 = lightest, 900/950 = darkest).
|
||||
The 500 step is the canonical brand value for each hue.
|
||||
Semantic aliases (primary, smash, pass, bg, surface…) live
|
||||
at the bottom and point at scale values — change once here
|
||||
to retheme the whole app.
|
||||
============================================================ */
|
||||
@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 */
|
||||
/* Purple — primary brand hue (anchored on #8324DE = 500) */
|
||||
--color-purple-50: #faf3ff;
|
||||
--color-purple-100: #f0d9ff;
|
||||
--color-purple-200: #dcb3ff;
|
||||
--color-purple-300: #c189f5;
|
||||
--color-purple-400: #a256ec;
|
||||
--color-purple-500: #8324de;
|
||||
--color-purple-600: #6e1cc2;
|
||||
--color-purple-700: #57169a;
|
||||
--color-purple-800: #3d1070;
|
||||
--color-purple-900: #270a4a;
|
||||
|
||||
/* 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;
|
||||
/* Red — pass (anchored on #FF453B = 500) */
|
||||
--color-red-50: #fff1f0;
|
||||
--color-red-100: #ffd9d6;
|
||||
--color-red-200: #ffb3ad;
|
||||
--color-red-300: #ff8b82;
|
||||
--color-red-400: #ff6961;
|
||||
--color-red-500: #ff453b;
|
||||
--color-red-600: #e0291f;
|
||||
--color-red-700: #b81f17;
|
||||
--color-red-800: #8a1611;
|
||||
--color-red-900: #5c0e0a;
|
||||
|
||||
--color-primary: #8324de;
|
||||
--color-primary-hover: #9442e8;
|
||||
--color-smash: #b9d532;
|
||||
--color-pass: #ff453b;
|
||||
/* Lime — smash (anchored on #B9D532 = 500) */
|
||||
--color-lime-50: #f6fae3;
|
||||
--color-lime-100: #eaf3b8;
|
||||
--color-lime-200: #d9e886;
|
||||
--color-lime-300: #c8de5f;
|
||||
--color-lime-400: #c1d948;
|
||||
--color-lime-500: #b9d532;
|
||||
--color-lime-600: #94ab26;
|
||||
--color-lime-700: #6f811d;
|
||||
--color-lime-800: #4d5914;
|
||||
--color-lime-900: #2c330b;
|
||||
|
||||
/* Neutral — dark-mode greys with a slight purple tint
|
||||
(50 = #FBF9FD off-white, 950 = #16141A dark grey background) */
|
||||
--color-neutral-50: #fbf9fd;
|
||||
--color-neutral-100: #ebe8ef;
|
||||
--color-neutral-200: #d2cdda;
|
||||
--color-neutral-300: #b9b1c2;
|
||||
--color-neutral-400: #8e8499;
|
||||
--color-neutral-500: #63586e;
|
||||
--color-neutral-600: #4f4659;
|
||||
--color-neutral-700: #3a3340;
|
||||
--color-neutral-800: #2a2630;
|
||||
--color-neutral-900: #1f1c24;
|
||||
--color-neutral-950: #16141a;
|
||||
|
||||
/* ----------------------------------------------------------
|
||||
Semantic aliases — reference these in components, not the
|
||||
scale values directly, so the theme stays repaintable.
|
||||
---------------------------------------------------------- */
|
||||
--color-primary: var(--color-purple-500);
|
||||
--color-primary-hover: var(--color-purple-400);
|
||||
--color-smash: var(--color-lime-500);
|
||||
--color-pass: var(--color-red-500);
|
||||
|
||||
--color-bg: var(--color-neutral-950);
|
||||
--color-surface: var(--color-neutral-900);
|
||||
--color-surface-2: var(--color-neutral-800);
|
||||
--color-border: var(--color-neutral-700);
|
||||
--color-text: var(--color-neutral-100);
|
||||
--color-text-muted: var(--color-neutral-300);
|
||||
|
||||
/* Typography */
|
||||
--font-sans: 'Inter', system-ui, sans-serif;
|
||||
--font-display: 'Bebas Neue', 'Inter', sans-serif;
|
||||
--font-sans: 'Roboto', system-ui, sans-serif;
|
||||
--font-display: 'Bebas Neue', 'Roboto', sans-serif;
|
||||
|
||||
/* Radii */
|
||||
--radius-card: 1.25rem;
|
||||
|
||||
@@ -1,10 +1,20 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type CollectionSummary } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const props = defineProps<{ collections: CollectionSummary[] }>()
|
||||
const emit = defineEmits<{ (e: 'refresh'): void }>()
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
function logout() {
|
||||
auth.clear()
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
const name = ref('')
|
||||
const files = ref<File[]>([])
|
||||
const busy = ref(false)
|
||||
@@ -57,11 +67,17 @@ async function remove(id: number) {
|
||||
>
|
||||
<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"
|
||||
class="inline-block rounded-md bg-[var(--color-primary)] px-2 py-0.5 text-xs font-bold uppercase text-white"
|
||||
>
|
||||
Admin
|
||||
</span>
|
||||
<h2 class="text-2xl">Créer une collection</h2>
|
||||
<button
|
||||
class="ml-auto rounded-md border border-[var(--color-border)] px-3 py-1 text-sm text-[var(--color-text-muted)] hover:border-[var(--color-primary)] hover:text-[var(--color-text)]"
|
||||
@click="logout"
|
||||
>
|
||||
Déconnexion
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
@@ -86,17 +102,17 @@ async function remove(id: number) {
|
||||
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)]"
|
||||
class="block w-full text-sm text-[var(--color-text-muted)] file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--color-primary)] 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>
|
||||
<p v-if="error" class="text-sm text-[var(--color-red-500)]">{{ 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"
|
||||
class="rounded-lg bg-[var(--color-primary)] 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>
|
||||
@@ -117,7 +133,7 @@ async function remove(id: number) {
|
||||
</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"
|
||||
class="rounded-md border border-[var(--color-red-500)] px-3 py-1 text-sm text-[var(--color-red-500)] hover:bg-[var(--color-red-500)] hover:text-white"
|
||||
@click="remove(c.id)"
|
||||
>
|
||||
Supprimer
|
||||
|
||||
@@ -2,42 +2,60 @@
|
||||
import { computed, ref } from 'vue'
|
||||
import type { Character } from '@/api/client'
|
||||
|
||||
const props = defineProps<{ character: Character; topMost: boolean }>()
|
||||
const props = defineProps<{ character: Character; depth: number }>()
|
||||
const emit = defineEmits<{
|
||||
(e: 'smash'): void
|
||||
(e: 'pass'): void
|
||||
}>()
|
||||
|
||||
const SWIPE_THRESHOLD = 120
|
||||
const ROTATION_PER_PX = 0.08 // deg — gives ~10° at threshold, ~25° at exit
|
||||
const STACK_SCALE_STEP = 0.04
|
||||
const STACK_OFFSET_STEP = 12 // px
|
||||
|
||||
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 isTop = computed(() => props.depth === 0)
|
||||
|
||||
// One transform combines stack position (for cards behind) and drag (top card).
|
||||
const transform = computed(() => {
|
||||
if (exiting.value === 'right') {
|
||||
return 'translate(800px, 0) rotate(30deg)'
|
||||
if (exiting.value === 'right') return 'translate(120vw, 0) rotate(30deg)'
|
||||
if (exiting.value === 'left') return 'translate(-120vw, 0) rotate(-30deg)'
|
||||
if (isTop.value) {
|
||||
const rot = dragX.value * ROTATION_PER_PX
|
||||
return `translate(${dragX.value}px, ${dragY.value}px) rotate(${rot}deg)`
|
||||
}
|
||||
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 scale = 1 - props.depth * STACK_SCALE_STEP
|
||||
const y = props.depth * STACK_OFFSET_STEP
|
||||
return `translateY(${y}px) scale(${scale})`
|
||||
})
|
||||
|
||||
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)))
|
||||
// Labels: fade in with drag, and grow / counter-rotate slightly for punch.
|
||||
const smashProgress = computed(() =>
|
||||
Math.min(1, Math.max(0, dragX.value / SWIPE_THRESHOLD)),
|
||||
)
|
||||
const passProgress = computed(() =>
|
||||
Math.min(1, Math.max(0, -dragX.value / SWIPE_THRESHOLD)),
|
||||
)
|
||||
const smashLabelStyle = computed(() => ({
|
||||
opacity: smashProgress.value,
|
||||
transform: `rotate(${-12 - smashProgress.value * 6}deg) scale(${0.8 + smashProgress.value * 0.4})`,
|
||||
}))
|
||||
const passLabelStyle = computed(() => ({
|
||||
opacity: passProgress.value,
|
||||
transform: `rotate(${12 + passProgress.value * 6}deg) scale(${0.8 + passProgress.value * 0.4})`,
|
||||
}))
|
||||
|
||||
function onPointerDown(e: PointerEvent) {
|
||||
if (!props.topMost || exiting.value) return
|
||||
if (!isTop.value || 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)
|
||||
}
|
||||
|
||||
@@ -50,22 +68,29 @@ function onPointerMove(e: PointerEvent) {
|
||||
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 {
|
||||
if (dragX.value > SWIPE_THRESHOLD) finish('right')
|
||||
else if (dragX.value < -SWIPE_THRESHOLD) finish('left')
|
||||
else {
|
||||
dragX.value = 0
|
||||
dragY.value = 0
|
||||
}
|
||||
}
|
||||
|
||||
function onPointerCancel(e: PointerEvent) {
|
||||
dragging.value = false
|
||||
dragX.value = 0
|
||||
dragY.value = 0
|
||||
;(e.currentTarget as HTMLElement).releasePointerCapture?.(e.pointerId)
|
||||
}
|
||||
|
||||
function onTransitionEnd(e: TransitionEvent) {
|
||||
if (e.propertyName !== 'transform' || !exiting.value) return
|
||||
if (exiting.value === 'right') emit('smash')
|
||||
else emit('pass')
|
||||
}
|
||||
|
||||
function finish(dir: 'left' | 'right') {
|
||||
exiting.value = dir
|
||||
setTimeout(() => {
|
||||
if (dir === 'right') emit('smash')
|
||||
else emit('pass')
|
||||
}, 250)
|
||||
}
|
||||
|
||||
defineExpose({
|
||||
@@ -76,13 +101,14 @@ defineExpose({
|
||||
|
||||
<template>
|
||||
<div
|
||||
class="absolute inset-0 select-none touch-none"
|
||||
class="absolute inset-0 select-none touch-none will-change-transform"
|
||||
:class="{ 'transition-transform duration-300 ease-out': !dragging }"
|
||||
:style="{ transform }"
|
||||
:style="{ transform, zIndex: 100 - depth, opacity: depth > 2 ? 0 : 1 }"
|
||||
@pointerdown="onPointerDown"
|
||||
@pointermove="onPointerMove"
|
||||
@pointerup="onPointerUp"
|
||||
@pointercancel="onPointerUp"
|
||||
@pointercancel="onPointerCancel"
|
||||
@transitionend="onTransitionEnd"
|
||||
>
|
||||
<div
|
||||
class="relative h-full w-full overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
|
||||
@@ -97,20 +123,18 @@ defineExpose({
|
||||
<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>
|
||||
<p class="text-xl font-semibold text-[var(--color-text)]">{{ 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 }"
|
||||
class="pointer-events-none absolute left-6 top-6 origin-top-left rounded-lg border-4 border-[var(--color-smash)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-smash)]"
|
||||
:style="smashLabelStyle"
|
||||
>
|
||||
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 }"
|
||||
class="pointer-events-none absolute right-6 top-6 origin-top-right rounded-lg border-4 border-[var(--color-pass)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-pass)]"
|
||||
:style="passLabelStyle"
|
||||
>
|
||||
Pass
|
||||
</div>
|
||||
|
||||
@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
|
||||
import HomeView from '@/views/HomeView.vue'
|
||||
import GameView from '@/views/GameView.vue'
|
||||
import SummaryView from '@/views/SummaryView.vue'
|
||||
import LoginView from '@/views/LoginView.vue'
|
||||
|
||||
const router = createRouter({
|
||||
history: createWebHistory(import.meta.env.BASE_URL),
|
||||
@@ -9,6 +10,8 @@ const router = createRouter({
|
||||
{ path: '/', name: 'home', component: HomeView },
|
||||
{ path: '/game/:id', name: 'game', component: GameView, props: true },
|
||||
{ path: '/summary', name: 'summary', component: SummaryView },
|
||||
// Admin login — intentionally not linked from the UI; reach it by URL.
|
||||
{ path: '/login', name: 'login', component: LoginView },
|
||||
],
|
||||
})
|
||||
|
||||
|
||||
26
sop-front/src/stores/auth.ts
Normal file
26
sop-front/src/stores/auth.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { defineStore } from 'pinia'
|
||||
|
||||
const STORAGE_KEY = 'sop.adminToken'
|
||||
|
||||
interface AuthState {
|
||||
token: string | null
|
||||
}
|
||||
|
||||
export const useAuthStore = defineStore('auth', {
|
||||
state: (): AuthState => ({
|
||||
token: localStorage.getItem(STORAGE_KEY),
|
||||
}),
|
||||
getters: {
|
||||
isAuthenticated: (s) => !!s.token,
|
||||
},
|
||||
actions: {
|
||||
setToken(token: string) {
|
||||
this.token = token
|
||||
localStorage.setItem(STORAGE_KEY, token)
|
||||
},
|
||||
clear() {
|
||||
this.token = null
|
||||
localStorage.removeItem(STORAGE_KEY)
|
||||
},
|
||||
},
|
||||
})
|
||||
@@ -31,12 +31,14 @@ export const useGameStore = defineStore('game', {
|
||||
this.passed = []
|
||||
},
|
||||
smash() {
|
||||
const c = this.queue.shift()
|
||||
if (c) this.smashed.push(c)
|
||||
this._consume('smashed')
|
||||
},
|
||||
pass() {
|
||||
this._consume('passed')
|
||||
},
|
||||
_consume(bucket: 'smashed' | 'passed') {
|
||||
const c = this.queue.shift()
|
||||
if (c) this.passed.push(c)
|
||||
if (c) this[bucket].push(c)
|
||||
},
|
||||
reset() {
|
||||
this.collection = null
|
||||
|
||||
@@ -13,7 +13,8 @@ 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())
|
||||
// Top of queue first; SwipeCard handles its own z-index via `depth`.
|
||||
const visibleStack = computed(() => store.queue.slice(0, 3))
|
||||
|
||||
async function load() {
|
||||
loading.value = true
|
||||
@@ -45,12 +46,12 @@ onMounted(load)
|
||||
<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)]"
|
||||
class="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-text)]"
|
||||
@click="router.push('/')"
|
||||
>
|
||||
← Retour
|
||||
</button>
|
||||
<h1 class="display text-2xl text-[var(--color-fur)]">
|
||||
<h1 class="display text-2xl text-[var(--color-text)]">
|
||||
{{ store.collection?.name ?? '' }}
|
||||
</h1>
|
||||
<span class="text-sm text-[var(--color-text-muted)]">
|
||||
@@ -61,29 +62,21 @@ onMounted(load)
|
||||
|
||||
<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"
|
||||
class="h-full bg-[var(--color-primary)] transition-all"
|
||||
:style="{ width: `${store.progress}%` }"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<p v-if="error" class="text-center text-[var(--color-iris)]">{{ error }}</p>
|
||||
<p v-if="error" class="text-center text-[var(--color-red-500)]">{{ 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">
|
||||
<div v-else class="relative mx-auto aspect-[3/4] w-full max-w-md flex-1 overflow-visible">
|
||||
<SwipeCard
|
||||
v-for="(c, idx) in visibleStack"
|
||||
:key="c.id"
|
||||
:ref="idx === visibleStack.length - 1 ? (el) => (topCard = el as never) : undefined"
|
||||
:ref="(el) => { if (idx === 0) topCard = el as InstanceType<typeof SwipeCard> | null }"
|
||||
: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,
|
||||
}"
|
||||
:depth="idx"
|
||||
@smash="store.smash()"
|
||||
@pass="store.pass()"
|
||||
/>
|
||||
|
||||
@@ -2,11 +2,13 @@
|
||||
import { onMounted, ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api, type CollectionSummary } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
import AdminPanel from '@/components/AdminPanel.vue'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const collections = ref<CollectionSummary[]>([])
|
||||
const adminEnabled = ref(false)
|
||||
const loading = ref(true)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
@@ -14,9 +16,7 @@ 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
|
||||
collections.value = await api.listCollections()
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
@@ -34,16 +34,16 @@ onMounted(load)
|
||||
<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 class="display text-6xl text-[var(--color-text)] md:text-8xl">
|
||||
<span class="text-[var(--color-primary)]">Smash</span> or
|
||||
<span class="text-[var(--color-red-500)]">Pass</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>
|
||||
<p v-if="error" class="mb-4 text-center text-[var(--color-red-500)]">{{ error }}</p>
|
||||
|
||||
<section v-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement…</section>
|
||||
|
||||
@@ -56,15 +56,15 @@ onMounted(load)
|
||||
<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)]"
|
||||
class="group cursor-pointer rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 transition hover:border-[var(--color-primary)] hover:bg-[var(--color-surface-2)]"
|
||||
@click="play(c.id)"
|
||||
>
|
||||
<h2 class="mb-2 text-2xl text-[var(--color-fur)]">{{ c.name }}</h2>
|
||||
<h2 class="mb-2 text-2xl text-[var(--color-text)]">{{ 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"
|
||||
class="mt-4 inline-block rounded-md bg-[var(--color-primary)] px-3 py-1 text-sm font-semibold text-white opacity-0 transition group-hover:opacity-100"
|
||||
>
|
||||
Jouer →
|
||||
</p>
|
||||
@@ -72,7 +72,7 @@ onMounted(load)
|
||||
</ul>
|
||||
</section>
|
||||
|
||||
<div v-if="adminEnabled" class="mt-16">
|
||||
<div v-if="auth.isAuthenticated" class="mt-16">
|
||||
<AdminPanel :collections="collections" @refresh="load" />
|
||||
</div>
|
||||
</main>
|
||||
|
||||
74
sop-front/src/views/LoginView.vue
Normal file
74
sop-front/src/views/LoginView.vue
Normal file
@@ -0,0 +1,74 @@
|
||||
<script setup lang="ts">
|
||||
import { ref } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { api } from '@/api/client'
|
||||
import { useAuthStore } from '@/stores/auth'
|
||||
|
||||
const router = useRouter()
|
||||
const auth = useAuthStore()
|
||||
|
||||
const username = ref('')
|
||||
const password = ref('')
|
||||
const busy = ref(false)
|
||||
const error = ref<string | null>(null)
|
||||
|
||||
async function submit() {
|
||||
error.value = null
|
||||
if (!username.value || !password.value) {
|
||||
error.value = 'Identifiants requis'
|
||||
return
|
||||
}
|
||||
busy.value = true
|
||||
try {
|
||||
const { token } = await api.login(username.value, password.value)
|
||||
auth.setToken(token)
|
||||
router.push('/')
|
||||
} catch (e) {
|
||||
error.value = (e as Error).message
|
||||
} finally {
|
||||
busy.value = false
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<template>
|
||||
<main class="mx-auto flex min-h-screen max-w-md items-center justify-center px-6">
|
||||
<section
|
||||
class="w-full rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-8"
|
||||
>
|
||||
<h1 class="display mb-6 text-center text-4xl text-[var(--color-text)]">Connexion admin</h1>
|
||||
|
||||
<form class="space-y-4" @submit.prevent="submit">
|
||||
<div>
|
||||
<label class="mb-1 block text-sm text-[var(--color-text-muted)]">Identifiant</label>
|
||||
<input
|
||||
v-model="username"
|
||||
type="text"
|
||||
autocomplete="username"
|
||||
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)]">Mot de passe</label>
|
||||
<input
|
||||
v-model="password"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
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>
|
||||
|
||||
<p v-if="error" class="text-sm text-[var(--color-red-500)]">{{ error }}</p>
|
||||
|
||||
<button
|
||||
type="submit"
|
||||
:disabled="busy"
|
||||
class="w-full rounded-lg bg-[var(--color-primary)] px-5 py-2 font-semibold text-white transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
|
||||
>
|
||||
{{ busy ? 'Connexion…' : 'Se connecter' }}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</main>
|
||||
</template>
|
||||
@@ -1,5 +1,5 @@
|
||||
<script setup lang="ts">
|
||||
import { computed } from 'vue'
|
||||
import { computed, onMounted } from 'vue'
|
||||
import { useRouter } from 'vue-router'
|
||||
import { useGameStore } from '@/stores/game'
|
||||
|
||||
@@ -21,15 +21,15 @@ function home() {
|
||||
router.push('/')
|
||||
}
|
||||
|
||||
if (!store.collection) {
|
||||
router.replace('/')
|
||||
}
|
||||
onMounted(() => {
|
||||
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>
|
||||
<h1 class="display text-5xl text-[var(--color-text)] md:text-7xl">Résultats</h1>
|
||||
<p class="mt-2 text-[var(--color-text-muted)]">
|
||||
{{ collectionName }} · {{ total }} personnage{{ total > 1 ? 's' : '' }}
|
||||
</p>
|
||||
@@ -37,13 +37,13 @@ if (!store.collection) {
|
||||
|
||||
<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)]"
|
||||
class="rounded-lg bg-[var(--color-primary)] 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)]"
|
||||
class="rounded-lg border border-[var(--color-border)] px-5 py-2 font-semibold text-[var(--color-text)] transition hover:border-[var(--color-primary)]"
|
||||
@click="home"
|
||||
>
|
||||
Accueil
|
||||
|
||||
Reference in New Issue
Block a user