setup admin connection
This commit is contained in:
@@ -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"
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
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 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)
|
|
||||||
|
|||||||
@@ -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"
|
||||||
|
|
||||||
|
|||||||
@@ -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"},
|
||||||
)
|
)
|
||||||
|
|||||||
@@ -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"])
|
||||||
|
|||||||
@@ -33,7 +33,3 @@ class CollectionOut(BaseModel):
|
|||||||
|
|
||||||
class CollectionCreate(BaseModel):
|
class CollectionCreate(BaseModel):
|
||||||
name: str
|
name: str
|
||||||
|
|
||||||
|
|
||||||
class AdminStatus(BaseModel):
|
|
||||||
admin_enabled: bool
|
|
||||||
|
|||||||
@@ -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 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-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`.
|
||||||
|
|
||||||
---
|
---
|
||||||
|
|
||||||
|
|||||||
@@ -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(),
|
||||||
|
}),
|
||||||
|
)
|
||||||
},
|
},
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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;
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -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 },
|
||||||
],
|
],
|
||||||
})
|
})
|
||||||
|
|
||||||
|
|||||||
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 = []
|
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
|
||||||
|
|||||||
@@ -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()"
|
||||||
/>
|
/>
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
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">
|
<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
|
||||||
|
|||||||
Reference in New Issue
Block a user