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

4
sop-front/.dockerignore Normal file
View File

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

8
sop-front/.editorconfig Normal file
View File

@@ -0,0 +1,8 @@
[*.{js,jsx,mjs,cjs,ts,tsx,mts,cts,vue,css,scss,sass,less,styl}]
charset = utf-8
indent_size = 2
indent_style = space
insert_final_newline = true
trim_trailing_whitespace = true
end_of_line = lf
max_line_length = 100

1
sop-front/.gitattributes vendored Normal file
View File

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

39
sop-front/.gitignore vendored Normal file
View File

@@ -0,0 +1,39 @@
# Logs
logs
*.log
npm-debug.log*
yarn-debug.log*
yarn-error.log*
pnpm-debug.log*
lerna-debug.log*
node_modules
.DS_Store
dist
dist-ssr
coverage
*.local
# Editor directories and files
.vscode/*
!.vscode/extensions.json
.idea
*.suo
*.ntvs*
*.njsproj
*.sln
*.sw?
*.tsbuildinfo
.eslintcache
# Cypress
/cypress/videos/
/cypress/screenshots/
# Vitest
__screenshots__/
# Vite
*.timestamp-*-*.mjs

10
sop-front/.oxlintrc.json Normal file
View File

@@ -0,0 +1,10 @@
{
"$schema": "./node_modules/oxlint/configuration_schema.json",
"plugins": ["eslint", "typescript", "unicorn", "oxc", "vue", "vitest"],
"env": {
"browser": true
},
"categories": {
"correctness": "error"
}
}

View File

@@ -0,0 +1,6 @@
{
"$schema": "https://json.schemastore.org/prettierrc",
"semi": false,
"singleQuote": true,
"printWidth": 100
}

10
sop-front/.vscode/extensions.json vendored Normal file
View File

@@ -0,0 +1,10 @@
{
"recommendations": [
"Vue.volar",
"vitest.explorer",
"dbaeumer.vscode-eslint",
"EditorConfig.EditorConfig",
"oxc.oxc-vscode",
"esbenp.prettier-vscode"
]
}

14
sop-front/Dockerfile Normal file
View File

@@ -0,0 +1,14 @@
FROM node:22-alpine AS build
WORKDIR /app
COPY package.json package-lock.json* ./
RUN npm install
COPY . .
ARG VITE_API_BASE_URL=http://localhost:8000
ENV VITE_API_BASE_URL=$VITE_API_BASE_URL
RUN npm run build-only
FROM nginx:alpine
COPY --from=build /app/dist /usr/share/nginx/html
COPY nginx.conf /etc/nginx/conf.d/default.conf
EXPOSE 80
CMD ["nginx", "-g", "daemon off;"]

149
sop-front/README.md Normal file
View File

@@ -0,0 +1,149 @@
# Smash or Pass — Frontend
Vue 3 + TypeScript + Vite + Pinia + Tailwind CSS v4. Talks to the [`sop-back`](../sop-back/) FastAPI server.
See [`../CLAUDE.md`](../CLAUDE.md) for full architecture notes.
---
## Stack
- Vue 3 with `<script setup lang="ts">` (TypeScript everywhere)
- Vite 8 (dev server + bundler)
- Vue Router (SPA: `/`, `/game/:id`, `/summary`)
- Pinia (game state: queue / smashed / passed)
- Tailwind CSS v4 via `@tailwindcss/vite`. Custom palette + fonts in [`src/assets/main.css`](src/assets/main.css) inside an `@theme { ... }` block.
---
## Project layout
```
sop-front/
├── index.html
├── vite.config.ts # Tailwind v4 plugin + alias '@'
├── nginx.conf # SPA fallback for production image
├── Dockerfile # multi-stage: vite build → nginx
└── src/
├── main.ts # imports ./assets/main.css
├── App.vue # only <RouterView />
├── router/index.ts
├── api/client.ts # typed fetch wrapper, reads VITE_API_BASE_URL
├── stores/game.ts # Pinia store
├── assets/main.css # Tailwind + @theme tokens (palette, fonts)
├── components/
│ ├── SwipeCard.vue # pointer-event drag, exposes triggerSmash/Pass
│ └── AdminPanel.vue
└── views/
├── HomeView.vue
├── GameView.vue
└── SummaryView.vue
```
---
## Configuration
One environment variable, **build-time** (Vite inlines it):
| Var | Default | Notes |
|---|---|---|
| `VITE_API_BASE_URL` | `http://localhost:8000` | Backend origin |
For dev, set in a `.env.local`:
```
VITE_API_BASE_URL=http://localhost:8000
```
For Docker builds, pass it as a build arg (already wired in the repo-root `docker-compose.yml`):
```yaml
frontend:
build:
context: ./sop-front
args:
VITE_API_BASE_URL: "https://api.example.com"
```
---
## Local development
```bash
cd sop-front
npm install
npm run dev
```
- App: http://localhost:5173
- Make sure the backend is running at `VITE_API_BASE_URL` (default `http://localhost:8000`).
### Other scripts
| Command | What |
|---|---|
| `npm run dev` | Vite dev server with HMR |
| `npm run build` | Type-check + production build into `dist/` |
| `npm run type-check` | `vue-tsc --build` only |
| `npm run preview` | Serve the built `dist/` locally |
| `npm run lint` | oxlint + eslint |
| `npm run format` | Prettier |
| `npm run test:unit` | Vitest |
---
## Customizing colors / fonts
**All design tokens live in [`src/assets/main.css`](src/assets/main.css)** under `@theme`. Add new colors there as `--color-<name>: #hex;` and they become available as Tailwind utilities (`bg-<name>`, `text-<name>`) and as `var(--color-<name>)`. Don't hardcode hex values in components.
Current palette comes from the user's reference image:
| Token | Hex | Role |
|---|---|---|
| `--color-clothes` | `#8324DE` | Primary purple (CTAs) |
| `--color-smash` / `--color-tongue` | `#B9D532` | Smash green |
| `--color-pass` / `--color-iris` | `#FF453B` | Pass red |
| `--color-bg` | `#16141A` | Dark grey background |
| `--color-surface` | `#1F1C24` | Card surface |
| `--color-fur` | `#FBF9FD` | Off-white text |
Fonts: **Bebas Neue** (display) + **Inter** (body), loaded via Google Fonts in `main.css`.
---
## Production deployment
### Docker (recommended)
A multi-stage Dockerfile builds the SPA with Vite and serves the static bundle with nginx. From the repo root:
```bash
docker compose up -d --build frontend
```
The image listens on port 80 inside the container; the compose file maps it to host `:8080`.
For a real deployment:
1. **Set `VITE_API_BASE_URL`** to the public backend URL **at build time** (build arg, not runtime env). Rebuild the image whenever it changes.
2. **Front it with HTTPS** — Caddy / Traefik / Cloudflare in front of port 80.
3. The bundled `nginx.conf` already does SPA fallback (`try_files $uri /index.html`).
4. Make sure the backend CORS `ALLOWED_ORIGINS` includes the frontend's public origin.
### Static hosting (no Docker)
```bash
npm install
VITE_API_BASE_URL=https://api.example.com npm run build
```
Upload the contents of `dist/` to any static host (S3+CloudFront, Netlify, Vercel, GitHub Pages, etc.). Configure SPA fallback so any unknown path serves `index.html`.
---
## Notes
- `VITE_API_BASE_URL` is **baked into the JS bundle** at build time. Changing it requires a rebuild.
- All API calls go through [`src/api/client.ts`](src/api/client.ts) — extend that wrapper rather than calling `fetch` directly from components.
- The admin panel renders only when `GET /admin/status` returns `admin_enabled: true`. That flag lives in the **backend's** `.env`.

1
sop-front/env.d.ts vendored Normal file
View File

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

View File

@@ -0,0 +1,32 @@
import { globalIgnores } from 'eslint/config'
import { defineConfigWithVueTs, vueTsConfigs } from '@vue/eslint-config-typescript'
import pluginVue from 'eslint-plugin-vue'
import pluginVitest from '@vitest/eslint-plugin'
import pluginOxlint from 'eslint-plugin-oxlint'
import skipFormatting from 'eslint-config-prettier/flat'
// To allow more languages other than `ts` in `.vue` files, uncomment the following lines:
// import { configureVueProject } from '@vue/eslint-config-typescript'
// configureVueProject({ scriptLangs: ['ts', 'tsx'] })
// More info at https://github.com/vuejs/eslint-config-typescript/#advanced-setup
export default defineConfigWithVueTs(
{
name: 'app/files-to-lint',
files: ['**/*.{vue,ts,mts,tsx}'],
},
globalIgnores(['**/dist/**', '**/dist-ssr/**', '**/coverage/**']),
...pluginVue.configs['flat/essential'],
vueTsConfigs.recommended,
{
...pluginVitest.configs.recommended,
files: ['src/**/__tests__/*'],
},
...pluginOxlint.buildFromOxlintConfigFile('.oxlintrc.json'),
skipFormatting,
)

13
sop-front/index.html Normal file
View File

@@ -0,0 +1,13 @@
<!DOCTYPE html>
<html lang="fr">
<head>
<meta charset="UTF-8">
<link rel="icon" href="/favicon.ico">
<meta name="viewport" content="width=device-width, initial-scale=1.0">
<title>Smash or Pass ?</title>
</head>
<body>
<div id="app"></div>
<script type="module" src="/src/main.ts"></script>
</body>
</html>

10
sop-front/nginx.conf Normal file
View File

@@ -0,0 +1,10 @@
server {
listen 80;
server_name _;
root /usr/share/nginx/html;
index index.html;
location / {
try_files $uri $uri/ /index.html;
}
}

7022
sop-front/package-lock.json generated Normal file

File diff suppressed because it is too large Load Diff

52
sop-front/package.json Normal file
View File

@@ -0,0 +1,52 @@
{
"name": "sop-front",
"version": "0.0.0",
"private": true,
"type": "module",
"scripts": {
"dev": "vite",
"build": "run-p type-check \"build-only {@}\" --",
"preview": "vite preview",
"test:unit": "vitest",
"build-only": "vite build",
"type-check": "vue-tsc --build",
"lint": "run-s lint:*",
"lint:oxlint": "oxlint . --fix",
"lint:eslint": "eslint . --fix --cache",
"format": "prettier --write --experimental-cli src/"
},
"dependencies": {
"pinia": "^3.0.4",
"vue": "^3.5.32",
"vue-router": "^5.0.4"
},
"devDependencies": {
"@tailwindcss/vite": "^4.0.0",
"tailwindcss": "^4.0.0",
"@tsconfig/node24": "^24.0.4",
"@types/jsdom": "^28.0.1",
"@types/node": "^24.12.2",
"@vitejs/plugin-vue": "^6.0.6",
"@vitest/eslint-plugin": "^1.6.16",
"@vue/eslint-config-typescript": "^14.7.0",
"@vue/test-utils": "^2.4.6",
"@vue/tsconfig": "^0.9.1",
"eslint": "^10.2.1",
"eslint-config-prettier": "^10.1.8",
"eslint-plugin-oxlint": "~1.60.0",
"eslint-plugin-vue": "~10.8.0",
"jiti": "^2.6.1",
"jsdom": "^29.0.2",
"npm-run-all2": "^8.0.4",
"oxlint": "~1.60.0",
"prettier": "3.8.3",
"typescript": "~6.0.0",
"vite": "^8.0.8",
"vite-plugin-vue-devtools": "^8.1.1",
"vitest": "^4.1.4",
"vue-tsc": "^3.2.6"
},
"engines": {
"node": "^20.19.0 || >=22.12.0"
}
}

Binary file not shown.

After

Width:  |  Height:  |  Size: 4.2 KiB

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

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

View File

@@ -0,0 +1,76 @@
const API_BASE = import.meta.env.VITE_API_BASE_URL ?? 'http://localhost:8000'
export interface Character {
id: number
name: string
filename: string
s3_url: string
collection_id: number
}
export interface CollectionSummary {
id: number
name: string
created_at: string
character_count: number
}
export interface CollectionDetail {
id: number
name: string
created_at: string
characters: Character[]
}
async function handle<T>(res: Response): Promise<T> {
if (!res.ok) {
let detail = res.statusText
try {
const body = await res.json()
if (body && typeof body.detail === 'string') detail = body.detail
} catch {
/* ignore */
}
throw new Error(detail)
}
if (res.status === 204) return undefined as T
return (await res.json()) as T
}
export const api = {
base: API_BASE,
async adminStatus(): Promise<{ admin_enabled: boolean }> {
return handle(await fetch(`${API_BASE}/admin/status`))
},
async listCollections(): Promise<CollectionSummary[]> {
return handle(await fetch(`${API_BASE}/collections`))
},
async getCollection(id: number): Promise<CollectionDetail> {
return handle(await fetch(`${API_BASE}/collections/${id}`))
},
async createCollection(name: string, files: File[]): Promise<CollectionDetail> {
const fd = new FormData()
fd.append('name', name)
for (const f of files) fd.append('files', f)
return handle(await fetch(`${API_BASE}/admin/collections`, { method: 'POST', body: fd }))
},
async addCharacters(collectionId: number, files: File[]): Promise<CollectionDetail> {
const fd = new FormData()
for (const f of files) fd.append('files', f)
return handle(
await fetch(`${API_BASE}/admin/collections/${collectionId}/characters`, {
method: 'POST',
body: fd,
}),
)
},
async deleteCollection(id: number): Promise<void> {
return handle(await fetch(`${API_BASE}/admin/collections/${id}`, { method: 'DELETE' }))
},
}

View File

@@ -0,0 +1,59 @@
@import 'tailwindcss';
@import url('https://fonts.googleapis.com/css2?family=Bebas+Neue&family=Inter:wght@400;500;600;700&display=swap');
/* ============================================================
Smash or Pass — design tokens
Palette source: provided reference image.
============================================================ */
@theme {
/* Brand palette */
--color-tongue: #b9d532; /* smash / accent green */
--color-iris: #ff453b; /* pass / red */
--color-paws: #63586e; /* muted */
--color-fur: #fbf9fd; /* off-white */
--color-rayures: #f0d9ff; /* soft lilac */
--color-body: #bfa2db; /* light purple */
--color-body2: #8363a2; /* mid purple */
--color-clothes: #8324de; /* primary purple */
/* Semantic roles */
--color-bg: #16141a; /* dark grey background */
--color-surface: #1f1c24; /* card surface */
--color-surface-2: #2a2630; /* elevated surface */
--color-border: #3a3340;
--color-text: #fbf9fd;
--color-text-muted: #b9b1c2;
--color-primary: #8324de;
--color-primary-hover: #9442e8;
--color-smash: #b9d532;
--color-pass: #ff453b;
/* Typography */
--font-sans: 'Inter', system-ui, sans-serif;
--font-display: 'Bebas Neue', 'Inter', sans-serif;
/* Radii */
--radius-card: 1.25rem;
}
html,
body,
#app {
height: 100%;
}
body {
background-color: var(--color-bg);
color: var(--color-text);
font-family: var(--font-sans);
-webkit-font-smoothing: antialiased;
}
h1,
h2,
h3,
.display {
font-family: var(--font-display);
letter-spacing: 0.02em;
}

View File

@@ -0,0 +1,129 @@
<script setup lang="ts">
import { ref } from 'vue'
import { api, type CollectionSummary } from '@/api/client'
const props = defineProps<{ collections: CollectionSummary[] }>()
const emit = defineEmits<{ (e: 'refresh'): void }>()
const name = ref('')
const files = ref<File[]>([])
const busy = ref(false)
const error = ref<string | null>(null)
const fileInput = ref<HTMLInputElement | null>(null)
function onFileChange(e: Event) {
const input = e.target as HTMLInputElement
files.value = input.files ? Array.from(input.files) : []
}
async function submit() {
error.value = null
if (!name.value.trim()) {
error.value = 'Nom de collection requis'
return
}
if (files.value.length === 0) {
error.value = 'Au moins une image est requise'
return
}
busy.value = true
try {
await api.createCollection(name.value.trim(), files.value)
name.value = ''
files.value = []
if (fileInput.value) fileInput.value.value = ''
emit('refresh')
} catch (e) {
error.value = (e as Error).message
} finally {
busy.value = false
}
}
async function remove(id: number) {
if (!confirm('Supprimer cette collection ?')) return
try {
await api.deleteCollection(id)
emit('refresh')
} catch (e) {
error.value = (e as Error).message
}
}
</script>
<template>
<section
class="rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6"
>
<header class="mb-4 flex items-center gap-2">
<span
class="inline-block rounded-md bg-[var(--color-clothes)] px-2 py-0.5 text-xs font-bold uppercase text-white"
>
Admin
</span>
<h2 class="text-2xl">Créer une collection</h2>
</header>
<form class="space-y-4" @submit.prevent="submit">
<div>
<label class="mb-1 block text-sm text-[var(--color-text-muted)]">
Nom de la collection
</label>
<input
v-model="name"
type="text"
placeholder="ex: Personnages Disney"
class="w-full rounded-lg border border-[var(--color-border)] bg-[var(--color-surface-2)] px-3 py-2 text-[var(--color-text)] outline-none focus:border-[var(--color-primary)]"
/>
</div>
<div>
<label class="mb-1 block text-sm text-[var(--color-text-muted)]">
Images ({{ files.length }} sélectionnée{{ files.length > 1 ? 's' : '' }})
</label>
<input
ref="fileInput"
type="file"
accept="image/*"
multiple
class="block w-full text-sm text-[var(--color-text-muted)] file:mr-4 file:rounded-lg file:border-0 file:bg-[var(--color-clothes)] file:px-4 file:py-2 file:font-semibold file:text-white hover:file:bg-[var(--color-primary-hover)]"
@change="onFileChange"
/>
</div>
<p v-if="error" class="text-sm text-[var(--color-iris)]">{{ error }}</p>
<button
type="submit"
:disabled="busy"
class="rounded-lg bg-[var(--color-clothes)] px-5 py-2 font-semibold text-white transition hover:bg-[var(--color-primary-hover)] disabled:opacity-50"
>
{{ busy ? 'Envoi...' : 'Créer la collection' }}
</button>
</form>
<div v-if="props.collections.length" class="mt-8">
<h3 class="mb-2 text-lg">Gérer les collections</h3>
<ul class="space-y-2">
<li
v-for="c in props.collections"
:key="c.id"
class="flex items-center justify-between rounded-lg bg-[var(--color-surface-2)] px-3 py-2"
>
<span>
<span class="font-semibold">{{ c.name }}</span>
<span class="ml-2 text-sm text-[var(--color-text-muted)]">
{{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }}
</span>
</span>
<button
class="rounded-md border border-[var(--color-iris)] px-3 py-1 text-sm text-[var(--color-iris)] hover:bg-[var(--color-iris)] hover:text-white"
@click="remove(c.id)"
>
Supprimer
</button>
</li>
</ul>
</div>
</section>
</template>

View File

@@ -0,0 +1,119 @@
<script setup lang="ts">
import { computed, ref } from 'vue'
import type { Character } from '@/api/client'
const props = defineProps<{ character: Character; topMost: boolean }>()
const emit = defineEmits<{
(e: 'smash'): void
(e: 'pass'): void
}>()
const dragX = ref(0)
const dragY = ref(0)
const dragging = ref(false)
const exiting = ref<'left' | 'right' | null>(null)
const startX = ref(0)
const startY = ref(0)
const pointerId = ref<number | null>(null)
const SWIPE_THRESHOLD = 120
const transform = computed(() => {
if (exiting.value === 'right') {
return 'translate(800px, 0) rotate(30deg)'
}
if (exiting.value === 'left') {
return 'translate(-800px, 0) rotate(-30deg)'
}
const rot = dragX.value / 20
return `translate(${dragX.value}px, ${dragY.value}px) rotate(${rot}deg)`
})
const smashOpacity = computed(() => Math.min(1, Math.max(0, dragX.value / SWIPE_THRESHOLD)))
const passOpacity = computed(() => Math.min(1, Math.max(0, -dragX.value / SWIPE_THRESHOLD)))
function onPointerDown(e: PointerEvent) {
if (!props.topMost || exiting.value) return
dragging.value = true
startX.value = e.clientX
startY.value = e.clientY
pointerId.value = e.pointerId
;(e.currentTarget as HTMLElement).setPointerCapture(e.pointerId)
}
function onPointerMove(e: PointerEvent) {
if (!dragging.value) return
dragX.value = e.clientX - startX.value
dragY.value = (e.clientY - startY.value) * 0.3
}
function onPointerUp() {
if (!dragging.value) return
dragging.value = false
if (dragX.value > SWIPE_THRESHOLD) {
finish('right')
} else if (dragX.value < -SWIPE_THRESHOLD) {
finish('left')
} else {
dragX.value = 0
dragY.value = 0
}
}
function finish(dir: 'left' | 'right') {
exiting.value = dir
setTimeout(() => {
if (dir === 'right') emit('smash')
else emit('pass')
}, 250)
}
defineExpose({
triggerSmash: () => finish('right'),
triggerPass: () => finish('left'),
})
</script>
<template>
<div
class="absolute inset-0 select-none touch-none"
:class="{ 'transition-transform duration-300 ease-out': !dragging }"
:style="{ transform }"
@pointerdown="onPointerDown"
@pointermove="onPointerMove"
@pointerup="onPointerUp"
@pointercancel="onPointerUp"
>
<div
class="relative h-full w-full overflow-hidden rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] shadow-2xl"
>
<img
:src="character.s3_url"
:alt="character.name"
class="h-full w-full object-cover"
draggable="false"
/>
<div
class="pointer-events-none absolute inset-x-0 bottom-0 bg-gradient-to-t from-black/80 to-transparent p-4"
>
<p class="text-xl font-semibold text-[var(--color-fur)]">{{ character.name }}</p>
</div>
<div
class="pointer-events-none absolute left-6 top-6 rounded-lg border-4 border-[var(--color-smash)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-smash)]"
style="transform: rotate(-12deg)"
:style="{ opacity: smashOpacity }"
>
Smash
</div>
<div
class="pointer-events-none absolute right-6 top-6 rounded-lg border-4 border-[var(--color-pass)] px-3 py-1 text-3xl font-black uppercase text-[var(--color-pass)]"
style="transform: rotate(12deg)"
:style="{ opacity: passOpacity }"
>
Pass
</div>
</div>
</div>
</template>

14
sop-front/src/main.ts Normal file
View File

@@ -0,0 +1,14 @@
import './assets/main.css'
import { createApp } from 'vue'
import { createPinia } from 'pinia'
import App from './App.vue'
import router from './router'
const app = createApp(App)
app.use(createPinia())
app.use(router)
app.mount('#app')

View File

@@ -0,0 +1,15 @@
import { createRouter, createWebHistory } from 'vue-router'
import HomeView from '@/views/HomeView.vue'
import GameView from '@/views/GameView.vue'
import SummaryView from '@/views/SummaryView.vue'
const router = createRouter({
history: createWebHistory(import.meta.env.BASE_URL),
routes: [
{ path: '/', name: 'home', component: HomeView },
{ path: '/game/:id', name: 'game', component: GameView, props: true },
{ path: '/summary', name: 'summary', component: SummaryView },
],
})
export default router

View File

@@ -0,0 +1,48 @@
import { defineStore } from 'pinia'
import type { Character, CollectionDetail } from '@/api/client'
interface GameState {
collection: CollectionDetail | null
queue: Character[]
smashed: Character[]
passed: Character[]
}
export const useGameStore = defineStore('game', {
state: (): GameState => ({
collection: null,
queue: [],
smashed: [],
passed: [],
}),
getters: {
isFinished: (s) => s.collection !== null && s.queue.length === 0,
progress: (s) => {
const total = s.collection?.characters.length ?? 0
if (total === 0) return 0
return ((s.smashed.length + s.passed.length) / total) * 100
},
},
actions: {
start(collection: CollectionDetail) {
this.collection = collection
this.queue = [...collection.characters]
this.smashed = []
this.passed = []
},
smash() {
const c = this.queue.shift()
if (c) this.smashed.push(c)
},
pass() {
const c = this.queue.shift()
if (c) this.passed.push(c)
},
reset() {
this.collection = null
this.queue = []
this.smashed = []
this.passed = []
},
},
})

View File

@@ -0,0 +1,109 @@
<script setup lang="ts">
import { computed, onMounted, ref, watch } from 'vue'
import { useRouter } from 'vue-router'
import { api } from '@/api/client'
import { useGameStore } from '@/stores/game'
import SwipeCard from '@/components/SwipeCard.vue'
const props = defineProps<{ id: string }>()
const router = useRouter()
const store = useGameStore()
const loading = ref(true)
const error = ref<string | null>(null)
const topCard = ref<InstanceType<typeof SwipeCard> | null>(null)
const visibleStack = computed(() => store.queue.slice(0, 3).reverse())
async function load() {
loading.value = true
try {
const collection = await api.getCollection(Number(props.id))
if (collection.characters.length === 0) {
error.value = 'Cette collection est vide.'
return
}
store.start(collection)
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
watch(
() => store.isFinished,
(finished) => {
if (finished) router.push({ name: 'summary' })
},
)
onMounted(load)
</script>
<template>
<main class="mx-auto flex min-h-screen max-w-2xl flex-col px-4 py-6">
<header class="mb-4 flex items-center justify-between">
<button
class="text-sm text-[var(--color-text-muted)] hover:text-[var(--color-fur)]"
@click="router.push('/')"
>
Retour
</button>
<h1 class="display text-2xl text-[var(--color-fur)]">
{{ store.collection?.name ?? '' }}
</h1>
<span class="text-sm text-[var(--color-text-muted)]">
{{ (store.collection?.characters.length ?? 0) - store.queue.length }} /
{{ store.collection?.characters.length ?? 0 }}
</span>
</header>
<div class="mb-4 h-1 w-full overflow-hidden rounded-full bg-[var(--color-surface-2)]">
<div
class="h-full bg-[var(--color-clothes)] transition-all"
:style="{ width: `${store.progress}%` }"
/>
</div>
<p v-if="error" class="text-center text-[var(--color-iris)]">{{ error }}</p>
<p v-else-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement</p>
<div v-else class="relative mx-auto aspect-[3/4] w-full max-w-md flex-1">
<SwipeCard
v-for="(c, idx) in visibleStack"
:key="c.id"
:ref="idx === visibleStack.length - 1 ? (el) => (topCard = el as never) : undefined"
:character="c"
:top-most="idx === visibleStack.length - 1"
:style="{
transform:
idx === visibleStack.length - 1
? undefined
: `scale(${1 - (visibleStack.length - 1 - idx) * 0.04}) translateY(${(visibleStack.length - 1 - idx) * 12}px)`,
opacity: idx === visibleStack.length - 1 ? 1 : 0.6,
zIndex: idx,
}"
@smash="store.smash()"
@pass="store.pass()"
/>
</div>
<div v-if="!loading && !error" class="mt-6 flex items-center justify-center gap-8">
<button
aria-label="Pass"
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-[var(--color-pass)] bg-[var(--color-surface)] text-3xl text-[var(--color-pass)] transition hover:bg-[var(--color-pass)] hover:text-white"
@click="topCard?.triggerPass()"
>
</button>
<button
aria-label="Smash"
class="flex h-16 w-16 items-center justify-center rounded-full border-2 border-[var(--color-smash)] bg-[var(--color-surface)] text-3xl text-[var(--color-smash)] transition hover:bg-[var(--color-smash)] hover:text-black"
@click="topCard?.triggerSmash()"
>
</button>
</div>
</main>
</template>

View File

@@ -0,0 +1,79 @@
<script setup lang="ts">
import { onMounted, ref } from 'vue'
import { useRouter } from 'vue-router'
import { api, type CollectionSummary } from '@/api/client'
import AdminPanel from '@/components/AdminPanel.vue'
const router = useRouter()
const collections = ref<CollectionSummary[]>([])
const adminEnabled = ref(false)
const loading = ref(true)
const error = ref<string | null>(null)
async function load() {
loading.value = true
error.value = null
try {
const [status, list] = await Promise.all([api.adminStatus(), api.listCollections()])
adminEnabled.value = status.admin_enabled
collections.value = list
} catch (e) {
error.value = (e as Error).message
} finally {
loading.value = false
}
}
function play(id: number) {
router.push({ name: 'game', params: { id } })
}
onMounted(load)
</script>
<template>
<main class="mx-auto max-w-5xl px-6 py-12">
<header class="mb-12 text-center">
<h1 class="display text-6xl text-[var(--color-fur)] md:text-8xl">
Smash <span class="text-[var(--color-clothes)]">or</span> Pass
<span class="text-[var(--color-iris)]">?</span>
</h1>
<p class="mt-3 text-[var(--color-text-muted)]">
Choisissez une collection et lancez la partie.
</p>
</header>
<p v-if="error" class="mb-4 text-center text-[var(--color-iris)]">{{ error }}</p>
<section v-if="loading" class="text-center text-[var(--color-text-muted)]">Chargement</section>
<section v-else>
<div v-if="collections.length === 0" class="text-center text-[var(--color-text-muted)]">
Aucune collection disponible pour le moment.
</div>
<ul v-else class="grid grid-cols-1 gap-4 sm:grid-cols-2 lg:grid-cols-3">
<li
v-for="c in collections"
:key="c.id"
class="group cursor-pointer rounded-[var(--radius-card)] border border-[var(--color-border)] bg-[var(--color-surface)] p-6 transition hover:border-[var(--color-clothes)] hover:bg-[var(--color-surface-2)]"
@click="play(c.id)"
>
<h2 class="mb-2 text-2xl text-[var(--color-fur)]">{{ c.name }}</h2>
<p class="text-sm text-[var(--color-text-muted)]">
{{ c.character_count }} personnage{{ c.character_count > 1 ? 's' : '' }}
</p>
<p
class="mt-4 inline-block rounded-md bg-[var(--color-clothes)] px-3 py-1 text-sm font-semibold text-white opacity-0 transition group-hover:opacity-100"
>
Jouer
</p>
</li>
</ul>
</section>
<div v-if="adminEnabled" class="mt-16">
<AdminPanel :collections="collections" @refresh="load" />
</div>
</main>
</template>

View File

@@ -0,0 +1,91 @@
<script setup lang="ts">
import { computed } from 'vue'
import { useRouter } from 'vue-router'
import { useGameStore } from '@/stores/game'
const router = useRouter()
const store = useGameStore()
const collectionName = computed(() => store.collection?.name ?? '')
const total = computed(() => store.smashed.length + store.passed.length)
function replay() {
const id = store.collection?.id
store.reset()
if (id) router.push({ name: 'game', params: { id } })
else router.push('/')
}
function home() {
store.reset()
router.push('/')
}
if (!store.collection) {
router.replace('/')
}
</script>
<template>
<main class="mx-auto max-w-5xl px-6 py-12">
<header class="mb-8 text-center">
<h1 class="display text-5xl text-[var(--color-fur)] md:text-7xl">Résultats</h1>
<p class="mt-2 text-[var(--color-text-muted)]">
{{ collectionName }} · {{ total }} personnage{{ total > 1 ? 's' : '' }}
</p>
</header>
<div class="mb-10 flex flex-wrap items-center justify-center gap-4">
<button
class="rounded-lg bg-[var(--color-clothes)] px-5 py-2 font-semibold text-white transition hover:bg-[var(--color-primary-hover)]"
@click="replay"
>
Rejouer
</button>
<button
class="rounded-lg border border-[var(--color-border)] px-5 py-2 font-semibold text-[var(--color-fur)] transition hover:border-[var(--color-clothes)]"
@click="home"
>
Accueil
</button>
</div>
<div class="grid grid-cols-1 gap-8 md:grid-cols-2">
<section>
<h2 class="mb-4 text-2xl text-[var(--color-smash)]">
Smashed ({{ store.smashed.length }})
</h2>
<p v-if="store.smashed.length === 0" class="text-sm text-[var(--color-text-muted)]">
Aucun.
</p>
<ul v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<li
v-for="c in store.smashed"
:key="c.id"
class="overflow-hidden rounded-lg border-2 border-[var(--color-smash)] bg-[var(--color-surface)]"
>
<img :src="c.s3_url" :alt="c.name" class="aspect-square w-full object-cover" />
<p class="truncate px-2 py-1 text-xs">{{ c.name }}</p>
</li>
</ul>
</section>
<section>
<h2 class="mb-4 text-2xl text-[var(--color-pass)]">Passed ({{ store.passed.length }})</h2>
<p v-if="store.passed.length === 0" class="text-sm text-[var(--color-text-muted)]">
Aucun.
</p>
<ul v-else class="grid grid-cols-2 gap-3 sm:grid-cols-3">
<li
v-for="c in store.passed"
:key="c.id"
class="overflow-hidden rounded-lg border-2 border-[var(--color-pass)] bg-[var(--color-surface)] opacity-75"
>
<img :src="c.s3_url" :alt="c.name" class="aspect-square w-full object-cover" />
<p class="truncate px-2 py-1 text-xs">{{ c.name }}</p>
</li>
</ul>
</section>
</div>
</main>
</template>

View File

@@ -0,0 +1,18 @@
{
"extends": "@vue/tsconfig/tsconfig.dom.json",
"include": ["env.d.ts", "src/**/*", "src/**/*.vue"],
"exclude": ["src/**/__tests__/*"],
"compilerOptions": {
// Extra safety for array and object lookups, but may have false positives.
"noUncheckedIndexedAccess": true,
// Path mapping for cleaner imports.
"paths": {
"@/*": ["./src/*"]
},
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo"
}
}

14
sop-front/tsconfig.json Normal file
View File

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

View File

@@ -0,0 +1,27 @@
// TSConfig for modules that run in Node.js environment via either transpilation or type-stripping.
{
"extends": "@tsconfig/node24/tsconfig.json",
"include": [
"vite.config.*",
"vitest.config.*",
"cypress.config.*",
"playwright.config.*",
"eslint.config.*"
],
"compilerOptions": {
// Most tools use transpilation instead of Node.js's native type-stripping.
// Bundler mode provides a smoother developer experience.
"module": "preserve",
"moduleResolution": "bundler",
// Include Node.js types and avoid accidentally including other `@types/*` packages.
"types": ["node"],
// Disable emitting output during `vue-tsc --build`, which is used for type-checking only.
"noEmit": true,
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo"
}
}

View File

@@ -0,0 +1,19 @@
{
"extends": "./tsconfig.app.json",
// Override to include only test files and clear exclusions.
// Application code imported in tests is automatically included via module resolution.
"include": ["src/**/__tests__/*", "env.d.ts"],
"exclude": [],
"compilerOptions": {
// Vitest runs in a different environment than the application code.
// Adjust lib and types accordingly.
"lib": [],
"types": ["node", "jsdom"],
// `vue-tsc --build` produces a .tsbuildinfo file for incremental type-checking.
// Specified here to keep it out of the root directory.
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.vitest.tsbuildinfo"
}
}

24
sop-front/vite.config.ts Normal file
View File

@@ -0,0 +1,24 @@
import { fileURLToPath, URL } from 'node:url'
import { defineConfig } from 'vite'
import vue from '@vitejs/plugin-vue'
import vueDevTools from 'vite-plugin-vue-devtools'
import tailwindcss from '@tailwindcss/vite'
// https://vite.dev/config/
export default defineConfig({
plugins: [
vue(),
vueDevTools(),
tailwindcss(),
],
server: {
host: '0.0.0.0',
port: 5173,
},
resolve: {
alias: {
'@': fileURLToPath(new URL('./src', import.meta.url))
},
},
})

View File

@@ -0,0 +1,14 @@
import { fileURLToPath } from 'node:url'
import { mergeConfig, defineConfig, configDefaults } from 'vitest/config'
import viteConfig from './vite.config'
export default mergeConfig(
viteConfig,
defineConfig({
test: {
environment: 'jsdom',
exclude: [...configDefaults.exclude, 'e2e/**'],
root: fileURLToPath(new URL('./', import.meta.url)),
},
}),
)