setup admin connection

This commit is contained in:
2026-05-06 12:22:17 +02:00
parent bdb523d4b8
commit 824173e63e
20 changed files with 425 additions and 136 deletions

View File

@@ -35,7 +35,8 @@ services:
environment: environment:
PROJECT_NAME: "Smash or Pass" PROJECT_NAME: "Smash or Pass"
ALLOWED_ORIGINS: '["http://localhost:8080","http://localhost:5173"]' ALLOWED_ORIGINS: '["http://localhost:8080","http://localhost:5173"]'
ADMIN_ENABLED: "true" ADMIN_USERNAME: "admin"
ADMIN_PASSWORD: "change-me"
DATABASE_URL: "sqlite:///./data/sop.db" DATABASE_URL: "sqlite:///./data/sop.db"
S3_ENDPOINT_URL: "http://minio:9000" S3_ENDPOINT_URL: "http://minio:9000"
S3_PUBLIC_URL: "http://localhost:9000" S3_PUBLIC_URL: "http://localhost:9000"

View File

@@ -2,8 +2,10 @@
PROJECT_NAME=Smash or Pass PROJECT_NAME=Smash or Pass
ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:8080"] ALLOWED_ORIGINS=["http://localhost:5173","http://localhost:8080"]
# Admin gate # Admin account — set both to enable admin login at /login.
ADMIN_ENABLED=true # Leave empty to fully disable admin endpoints.
ADMIN_USERNAME=admin
ADMIN_PASSWORD=change-me
# Database (SQLite file path) # Database (SQLite file path)
DATABASE_URL=sqlite:///./data/sop.db DATABASE_URL=sqlite:///./data/sop.db

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

View File

@@ -1,6 +1,4 @@
from fastapi import APIRouter from fastapi import APIRouter
from app.core.config import settings
from app.schemas.schemas import AdminStatus
router = APIRouter() router = APIRouter()
@@ -8,8 +6,3 @@ router = APIRouter()
@router.get("/health") @router.get("/health")
def health(): def health():
return {"status": "ok"} return {"status": "ok"}
@router.get("/admin/status", response_model=AdminStatus)
def admin_status():
return AdminStatus(admin_enabled=settings.ADMIN_ENABLED)

View File

@@ -9,7 +9,10 @@ class Settings(BaseSettings):
VERSION: str = "0.1.0" VERSION: str = "0.1.0"
ALLOWED_ORIGINS: List[str] = ["*"] 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" DATABASE_URL: str = "sqlite:///./data/sop.db"

View File

@@ -1,10 +1,30 @@
from fastapi import HTTPException, status import secrets
from fastapi import Header, HTTPException, status
from app.core.config import settings from app.core.config import settings
def require_admin() -> None: def require_admin(authorization: str | None = Header(default=None)) -> None:
if not settings.ADMIN_ENABLED: """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( raise HTTPException(
status_code=status.HTTP_403_FORBIDDEN, 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"},
) )

View File

@@ -6,7 +6,7 @@ from app.core.config import settings
from app.db.database import Base, engine from app.db.database import Base, engine
from app.models import models # noqa: F401 (register models) from app.models import models # noqa: F401 (register models)
from app.services.storage import ensure_bucket 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 @asynccontextmanager
@@ -35,5 +35,6 @@ app.add_middleware(
) )
app.include_router(health.router, tags=["meta"]) 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(collections.router, prefix="/collections", tags=["collections"])
app.include_router(admin.router, prefix="/admin", tags=["admin"]) app.include_router(admin.router, prefix="/admin", tags=["admin"])

View File

@@ -33,7 +33,3 @@ class CollectionOut(BaseModel):
class CollectionCreate(BaseModel): class CollectionCreate(BaseModel):
name: str name: str
class AdminStatus(BaseModel):
admin_enabled: bool

View File

@@ -95,20 +95,37 @@ npm run dev
## Customizing colors / fonts ## 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 50900 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-purple-{50…900}` | `#8324DE` | Primary brand |
| `--color-smash` / `--color-tongue` | `#B9D532` | Smash green | | `--color-red-{50…900}` | `#FF453B` | Pass / errors / destructive |
| `--color-pass` / `--color-iris` | `#FF453B` | Pass red | | `--color-lime-{50…900}` | `#B9D532` | Smash / success |
| `--color-bg` | `#16141A` | Dark grey background | | `--color-neutral-{50…950}` | `#63586E` | Backgrounds, surfaces, text, borders |
| `--color-surface` | `#1F1C24` | Card surface |
| `--color-fur` | `#FBF9FD` | Off-white text |
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`.
--- ---

View File

@@ -1,3 +1,5 @@
import { useAuthStore } from '@/stores/auth'
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000' const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
export interface Character { export interface Character {
@@ -24,6 +26,15 @@ export interface CollectionDetail {
async function handle<T>(res: Response): Promise<T> { async function handle<T>(res: Response): Promise<T> {
if (!res.ok) { 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 let detail = res.statusText
try { try {
const body = await res.json() const body = await res.json()
@@ -37,13 +48,14 @@ async function handle<T>(res: Response): Promise<T> {
return (await res.json()) as T return (await res.json()) as T
} }
function authHeaders(): HeadersInit {
const token = useAuthStore().token
return token ? { Authorization: `Bearer ${token}` } : {}
}
export const api = { export const api = {
base: API_BASE, base: API_BASE,
async adminStatus(): Promise<{ admin_enabled: boolean }> {
return handle(await fetch(`${API_BASE}/admin/status`))
},
async listCollections(): Promise<CollectionSummary[]> { async listCollections(): Promise<CollectionSummary[]> {
return handle(await fetch(`${API_BASE}/collections`)) return handle(await fetch(`${API_BASE}/collections`))
}, },
@@ -52,11 +64,27 @@ export const api = {
return handle(await fetch(`${API_BASE}/collections/${id}`)) 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> { async createCollection(name: string, files: File[]): Promise<CollectionDetail> {
const fd = new FormData() const fd = new FormData()
fd.append('name', name) fd.append('name', name)
for (const f of files) fd.append('files', f) 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> { async addCharacters(collectionId: number, files: File[]): Promise<CollectionDetail> {
@@ -65,12 +93,18 @@ export const api = {
return handle( return handle(
await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, { await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, {
method: 'POST', method: 'POST',
headers: authHeaders(),
body: fd, body: fd,
}), }),
) )
}, },
async deleteCollection(id: number): Promise<void> { 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(),
}),
)
}, },
} }

View File

@@ -1,37 +1,84 @@
@import 'tailwindcss'; @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 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 { @theme {
/* Brand palette */ /* Purple — primary brand hue (anchored on #8324DE = 500) */
--color-tongue: #b9d532; /* smash / accent green */ --color-purple-50: #faf3ff;
--color-iris: #ff453b; /* pass / red */ --color-purple-100: #f0d9ff;
--color-paws: #63586e; /* muted */ --color-purple-200: #dcb3ff;
--color-fur: #fbf9fd; /* off-white */ --color-purple-300: #c189f5;
--color-rayures: #f0d9ff; /* soft lilac */ --color-purple-400: #a256ec;
--color-body: #bfa2db; /* light purple */ --color-purple-500: #8324de;
--color-body2: #8363a2; /* mid purple */ --color-purple-600: #6e1cc2;
--color-clothes: #8324de; /* primary purple */ --color-purple-700: #57169a;
--color-purple-800: #3d1070;
--color-purple-900: #270a4a;
/* Semantic roles */ /* Red — pass (anchored on #FF453B = 500) */
--color-bg: #16141a; /* dark grey background */ --color-red-50: #fff1f0;
--color-surface: #1f1c24; /* card surface */ --color-red-100: #ffd9d6;
--color-surface-2: #2a2630; /* elevated surface */ --color-red-200: #ffb3ad;
--color-border: #3a3340; --color-red-300: #ff8b82;
--color-text: #fbf9fd; --color-red-400: #ff6961;
--color-text-muted: #b9b1c2; --color-red-500: #ff453b;
--color-red-600: #e0291f;
--color-red-700: #b81f17;
--color-red-800: #8a1611;
--color-red-900: #5c0e0a;
--color-primary: #8324de; /* Lime — smash (anchored on #B9D532 = 500) */
--color-primary-hover: #9442e8; --color-lime-50: #f6fae3;
--color-smash: #b9d532; --color-lime-100: #eaf3b8;
--color-pass: #ff453b; --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 */ /* Typography */
--font-sans: 'Inter', system-ui, sans-serif; --font-sans: 'Roboto', system-ui, sans-serif;
--font-display: 'Bebas Neue', 'Inter', sans-serif; --font-display: 'Bebas Neue', 'Roboto', sans-serif;
/* Radii */ /* Radii */
--radius-card: 1.25rem; --radius-card: 1.25rem;

View File

@@ -1,10 +1,20 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref } from 'vue' import { ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, type CollectionSummary } from '@/api/client' import { api, type CollectionSummary } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
const props = defineProps<{ collections: CollectionSummary[] }>() const props = defineProps<{ collections: CollectionSummary[] }>()
const emit = defineEmits<{ (e: 'refresh'): void }>() const emit = defineEmits<{ (e: 'refresh'): void }>()
const router = useRouter()
const auth = useAuthStore()
function logout() {
auth.clear()
router.push('/')
}
const name = ref('') const name = ref('')
const files = ref<File[]>([]) const files = ref<File[]>([])
const busy = ref(false) const busy = ref(false)
@@ -57,11 +67,17 @@ async function remove(id: number) {
> >
<header class="mb-4 flex items-center gap-2"> <header class="mb-4 flex items-center gap-2">
<span <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 Admin
</span> </span>
<h2 class="text-2xl">Créer une collection</h2> <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> </header>
<form class="space-y-4" @submit.prevent="submit"> <form class="space-y-4" @submit.prevent="submit">
@@ -86,17 +102,17 @@ async function remove(id: number) {
type="file" type="file"
accept="image/*" accept="image/*"
multiple 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" @change="onFileChange"
/> />
</div> </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 <button
type="submit" type="submit"
:disabled="busy" :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' }} {{ busy ? 'Envoi...' : 'Créer la collection' }}
</button> </button>
@@ -117,7 +133,7 @@ async function remove(id: number) {
</span> </span>
</span> </span>
<button <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)" @click="remove(c.id)"
> >
Supprimer Supprimer

View File

@@ -2,42 +2,60 @@
import { computed, ref } from 'vue' import { computed, ref } from 'vue'
import type { Character } from '@/api/client' import type { Character } from '@/api/client'
const props = defineProps<{ character: Character; topMost: boolean }>() const props = defineProps<{ character: Character; depth: number }>()
const emit = defineEmits<{ const emit = defineEmits<{
(e: 'smash'): void (e: 'smash'): void
(e: 'pass'): 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 dragX = ref(0)
const dragY = ref(0) const dragY = ref(0)
const dragging = ref(false) const dragging = ref(false)
const exiting = ref<'left' | 'right' | null>(null) const exiting = ref<'left' | 'right' | null>(null)
const startX = ref(0) const startX = ref(0)
const startY = 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(() => { const transform = computed(() => {
if (exiting.value === 'right') { if (exiting.value === 'right') return 'translate(120vw, 0) rotate(30deg)'
return 'translate(800px, 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') { const scale = 1 - props.depth * STACK_SCALE_STEP
return 'translate(-800px, 0) rotate(-30deg)' const y = props.depth * STACK_OFFSET_STEP
} return `translateY(${y}px) scale(${scale})`
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))) // Labels: fade in with drag, and grow / counter-rotate slightly for punch.
const passOpacity = computed(() => Math.min(1, Math.max(0, -dragX.value / SWIPE_THRESHOLD))) 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) { function onPointerDown(e: PointerEvent) {
if (!props.topMost || exiting.value) return if (!isTop.value || exiting.value) return
dragging.value = true dragging.value = true
startX.value = e.clientX startX.value = e.clientX
startY.value = e.clientY startY.value = e.clientY
pointerId.value = e.pointerId
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId) ;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
} }
@@ -50,22 +68,29 @@ function onPointerMove(e: PointerEvent) {
function onPointerUp() { function onPointerUp() {
if (!dragging.value) return if (!dragging.value) return
dragging.value = false dragging.value = false
if (dragX.value > SWIPE_THRESHOLD) { if (dragX.value > SWIPE_THRESHOLD) finish('right')
finish('right') else if (dragX.value < -SWIPE_THRESHOLD) finish('left')
} else if (dragX.value < -SWIPE_THRESHOLD) { else {
finish('left')
} else {
dragX.value = 0 dragX.value = 0
dragY.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') { function finish(dir: 'left' | 'right') {
exiting.value = dir exiting.value = dir
setTimeout(() => {
if (dir === 'right') emit('smash')
else emit('pass')
}, 250)
} }
defineExpose({ defineExpose({
@@ -76,13 +101,14 @@ defineExpose({
<template> <template>
<div <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 }" :class="{ 'transition-transform duration-300 ease-out': !dragging }"
:style="{ transform }" :style="{ transform, zIndex: 100 - depth, opacity: depth > 2 ? 0 : 1 }"
@pointerdown="onPointerDown" @pointerdown="onPointerDown"
@pointermove="onPointerMove" @pointermove="onPointerMove"
@pointerup="onPointerUp" @pointerup="onPointerUp"
@pointercancel="onPointerUp" @pointercancel="onPointerCancel"
@transitionend="onTransitionEnd"
> >
<div <div
class="relative h-full w-full overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl" 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 <div
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4" 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>
<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)]" 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="transform: rotate(-12deg)" :style="smashLabelStyle"
:style="{ opacity: smashOpacity }"
> >
Smash Smash
</div> </div>
<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)]" 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="transform: rotate(12deg)" :style="passLabelStyle"
:style="{ opacity: passOpacity }"
> >
Pass Pass
</div> </div>

View File

@@ -2,6 +2,7 @@ import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue' import HomeView from '@/views/HomeView.vue'
import GameView from '@/views/GameView.vue' import GameView from '@/views/GameView.vue'
import SummaryView from '@/views/SummaryView.vue' import SummaryView from '@/views/SummaryView.vue'
import LoginView from '@/views/LoginView.vue'
const router = createRouter({ const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL), history: createWebHistory(import.meta.env.BASE_URL),
@@ -9,6 +10,8 @@ const router = createRouter({
{ path: '/', name: 'home', component: HomeView }, { path: '/', name: 'home', component: HomeView },
{ path: '/game/:id', name: 'game', component: GameView, props: true }, { path: '/game/:id', name: 'game', component: GameView, props: true },
{ path: '/summary', name: 'summary', component: SummaryView }, { path: '/summary', name: 'summary', component: SummaryView },
// Admin login — intentionally not linked from the UI; reach it by URL.
{ path: '/login', name: 'login', component: LoginView },
], ],
}) })

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

View File

@@ -31,12 +31,14 @@ export const useGameStore = defineStore('game', {
this.passed = [] this.passed = []
}, },
smash() { smash() {
const c = this.queue.shift() this._consume('smashed')
if (c) this.smashed.push(c)
}, },
pass() { pass() {
this._consume('passed')
},
_consume(bucket: 'smashed' | 'passed') {
const c = this.queue.shift() const c = this.queue.shift()
if (c) this.passed.push(c) if (c) this[bucket].push(c)
}, },
reset() { reset() {
this.collection = null this.collection = null

View File

@@ -13,7 +13,8 @@ const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
const topCard = ref<InstanceType<typeof SwipeCard> | 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() { async function load() {
loading.value = true 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"> <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"> <header class="mb-4 flex items-center justify-between">
<button <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('/')" @click="router.push('/')"
> >
Retour Retour
</button> </button>
<h1 class="display text-2xl text-[var(--color-fur)]"> <h1 class="display text-2xl text-[var(--color-text)]">
{{ store.collection?.name ?? '' }} {{ store.collection?.name ?? '' }}
</h1> </h1>
<span class="text-sm text-[var(--color-text-muted)]"> <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="mb-4 h-1 w-full overflow-hidden rounded-full bg-[var(--color-surface-2)]">
<div <div
class="h-full bg-[var(--color-clothes)] transition-all" class="h-full bg-[var(--color-primary)] transition-all"
:style="{ width: `${store.progress}%` }" :style="{ width: `${store.progress}%` }"
/> />
</div> </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> <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 <SwipeCard
v-for="(c, idx) in visibleStack" v-for="(c, idx) in visibleStack"
:key="c.id" :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" :character="c"
:top-most="idx === visibleStack.length - 1" :depth="idx"
: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()" @smash="store.smash()"
@pass="store.pass()" @pass="store.pass()"
/> />

View File

@@ -2,11 +2,13 @@
import { onMounted, ref } from 'vue' import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { api, type CollectionSummary } from '@/api/client' import { api, type CollectionSummary } from '@/api/client'
import { useAuthStore } from '@/stores/auth'
import AdminPanel from '@/components/AdminPanel.vue' import AdminPanel from '@/components/AdminPanel.vue'
const router = useRouter() const router = useRouter()
const auth = useAuthStore()
const collections = ref<CollectionSummary[]>([]) const collections = ref<CollectionSummary[]>([])
const adminEnabled = ref(false)
const loading = ref(true) const loading = ref(true)
const error = ref<string | null>(null) const error = ref<string | null>(null)
@@ -14,9 +16,7 @@ async function load() {
loading.value = true loading.value = true
error.value = null error.value = null
try { try {
const [status, list] = await Promise.all([api.adminStatus(), api.listCollections()]) collections.value = await api.listCollections()
adminEnabled.value = status.admin_enabled
collections.value = list
} catch (e) { } catch (e) {
error.value = (e as Error).message error.value = (e as Error).message
} finally { } finally {
@@ -34,16 +34,16 @@ onMounted(load)
<template> <template>
<main class="mx-auto max-w-5xl px-6 py-12"> <main class="mx-auto max-w-5xl px-6 py-12">
<header class="mb-12 text-center"> <header class="mb-12 text-center">
<h1 class="display text-6xl text-[var(--color-fur)] md:text-8xl"> <h1 class="display text-6xl text-[var(--color-text)] md:text-8xl">
Smash <span class="text-[var(--color-clothes)]">or</span> Pass <span class="text-[var(--color-primary)]">Smash</span> or
<span class="text-[var(--color-iris)]">?</span> <span class="text-[var(--color-red-500)]">Pass</span> ?
</h1> </h1>
<p class="mt-3 text-[var(--color-text-muted)]"> <p class="mt-3 text-[var(--color-text-muted)]">
Choisissez une collection et lancez la partie. Choisissez une collection et lancez la partie.
</p> </p>
</header> </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> <section v-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement</section>
@@ -56,15 +56,15 @@ onMounted(load)
<li <li
v-for="c in collections" v-for="c in collections"
:key="c.id" :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)" @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)]"> <p class="text-sm text-[var(--color-text-muted)]">
{{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }} {{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }}
</p> </p>
<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 Jouer
</p> </p>
@@ -72,7 +72,7 @@ onMounted(load)
</ul> </ul>
</section> </section>
<div v-if="adminEnabled" class="mt-16"> <div v-if="auth.isAuthenticated" class="mt-16">
<AdminPanel :collections="collections" @refresh="load" /> <AdminPanel :collections="collections" @refresh="load" />
</div> </div>
</main> </main>

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

View File

@@ -1,5 +1,5 @@
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue' import { computed, onMounted } from 'vue'
import { useRouter } from 'vue-router' import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/game' import { useGameStore } from '@/stores/game'
@@ -21,15 +21,15 @@ function home() {
router.push('/') router.push('/')
} }
if (!store.collection) { onMounted(() => {
router.replace('/') if (!store.collection) router.replace('/')
} })
</script> </script>
<template> <template>
<main class="mx-auto max-w-5xl px-6 py-12"> <main class="mx-auto max-w-5xl px-6 py-12">
<header class="mb-8 text-center"> <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)]"> <p class="mt-2 text-[var(--color-text-muted)]">
{{ collectionName }} · {{ total }} personnage{{ total > 1 ? 's' : '' }} {{ collectionName }} · {{ total }} personnage{{ total > 1 ? 's' : '' }}
</p> </p>
@@ -37,13 +37,13 @@ if (!store.collection) {
<div class="mb-10 flex flex-wrap items-center justify-center gap-4"> <div class="mb-10 flex flex-wrap items-center justify-center gap-4">
<button <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" @click="replay"
> >
Rejouer Rejouer
</button> </button>
<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" @click="home"
> >
Accueil Accueil