feat: conformite enonce - explorer, favoris, stats perso, tests, slots
Some checks failed
Deploy XIP / deploy (push) Failing after 37s

Fonctionnel
- Backend messages : GET /api/messages/:id (detail) + recherche (q),
  pagination par curseur (before/limit) avec enveloppe { items, nextCursor,
  hasMore } ; le flux temps reel garde l'ancien format quand aucun parametre.
- Explorer (/explorer) : catalogue distant, recherche debouncee + annulable
  (AbortController), filtre, defilement infini, etat garde (keep-alive).
- Details par id : /message/:id et /shop/p/:id (consomment route.params).
- Favoris (/favoris) : liste perso persistee en localStorage, notation
  (note/rating/statut) via modale, refletee partout (bouton favori).
- Mes stats (/mes-stats) : agregats derives des favoris (note moyenne, top
  pays/auteurs, statuts), auto-mis a jour, route gardee si liste vide.
- Routeur : pages secondaires en lazy-load + repli, garde beforeEnter.

Technique
- Slots : PrefSection (slot defaut + slot nomme) enveloppe les 5 sections
  "Mes Persos" ; Modal (Teleport + slots).
- v-model custom : SearchBox (defineModel + debounce).
- Directive custom : v-click-outside.
- Tests Vitest : 25 tests (etat, fonctions, composants), ~86% du code metier.
- Retrait d'Ionic (inutilise). Script typecheck backend ; tsconfig @types/bun.
- Correctif type : garde stockLimit nullable dans l'achat (catalog.ts).
- README complet (URL, stack, run, tests, secrets, deploiement, mention IA).
This commit is contained in:
2026-05-31 23:57:00 +02:00
committed by kerboul
parent 9dd72b9b2d
commit cfa2eadec9
111 changed files with 9634 additions and 7875 deletions

View File

@@ -1,14 +1,14 @@
# Copy to `.env.prod` on the deploy host (CT502) and fill with real secrets. # Copy to `.env.prod` on the deploy host (CT502) and fill with real secrets.
# `.env.prod` is gitignored — never commit real credentials. # `.env.prod` is gitignored — never commit real credentials.
# Database # Database
POSTGRES_DB=xip POSTGRES_DB=xip
POSTGRES_USER=xip POSTGRES_USER=xip
POSTGRES_PASSWORD=change-me-to-a-strong-secret POSTGRES_PASSWORD=change-me-to-a-strong-secret
# Public origin (baked into the frontend build + used by the WS URL) # Public origin (baked into the frontend build + used by the WS URL)
PUBLIC_URL=https://xip.kerboul.me PUBLIC_URL=https://xip.kerboul.me
# Paywall: "true" = open bar (everything free for everyone), "false" = paywall on # Paywall: "true" = open bar (everything free for everyone), "false" = paywall on
# (free only on localhost, per the README). # (free only on localhost, per the README).
XIP_OPEN_BAR=true XIP_OPEN_BAR=true

6
.gitattributes vendored
View File

@@ -1,3 +1,3 @@
# Shell scripts must keep LF endings or they break with "bad interpreter" on Linux. # Shell scripts must keep LF endings or they break with "bad interpreter" on Linux.
*.sh text eol=lf *.sh text eol=lf
docker-entrypoint.sh text eol=lf docker-entrypoint.sh text eol=lf

View File

@@ -1,35 +1,35 @@
name: Deploy XIP name: Deploy XIP
# Auto-deploy on every push to main. The runner SSHes into the xip-app CT # Auto-deploy on every push to main. The runner SSHes into the xip-app CT
# (Echelon CT502) and runs scripts/deploy.sh, which pulls + rebuilds the stack. # (Echelon CT502) and runs scripts/deploy.sh, which pulls + rebuilds the stack.
on: on:
push: push:
branches: [main] branches: [main]
workflow_dispatch: workflow_dispatch:
# Serialize deploys: never run two deploys against the CT at the same time # Serialize deploys: never run two deploys against the CT at the same time
# (concurrent `docker compose up --build` on the same project races and fails). # (concurrent `docker compose up --build` on the same project races and fails).
concurrency: concurrency:
group: deploy-xip-prod group: deploy-xip-prod
cancel-in-progress: false cancel-in-progress: false
jobs: jobs:
deploy: deploy:
runs-on: ubuntu-latest runs-on: ubuntu-latest
steps: steps:
- name: Deploy over SSH to xip-app - name: Deploy over SSH to xip-app
env: env:
# Secrets via env (not inlined in the script) so the multi-line key # Secrets via env (not inlined in the script) so the multi-line key
# keeps its newlines and never breaks shell quoting. # keeps its newlines and never breaks shell quoting.
DEPLOY_HOST: ${{ secrets.XIP_DEPLOY_HOST }} DEPLOY_HOST: ${{ secrets.XIP_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.XIP_DEPLOY_USER }} DEPLOY_USER: ${{ secrets.XIP_DEPLOY_USER }}
DEPLOY_KEY: ${{ secrets.XIP_DEPLOY_KEY }} DEPLOY_KEY: ${{ secrets.XIP_DEPLOY_KEY }}
run: | run: |
set -e set -e
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client) command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client)
mkdir -p ~/.ssh mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519 printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519 chmod 600 ~/.ssh/id_ed25519
ssh -i ~/.ssh/id_ed25519 \ ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \ -o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$DEPLOY_USER@$DEPLOY_HOST" 'bash /opt/xip/scripts/deploy.sh' "$DEPLOY_USER@$DEPLOY_HOST" 'bash /opt/xip/scripts/deploy.sh'

16
.gitignore vendored
View File

@@ -1,8 +1,8 @@
node_modules/ node_modules/
dist/ dist/
.env .env
.env.local .env.local
.env.prod .env.prod
*.log *.log
.DS_Store .DS_Store
Thumbs.db Thumbs.db

128
DEPLOY.md
View File

@@ -1,64 +1,64 @@
# Déploiement XIP # Déploiement XIP
Production : **https://xip.kerboul.me** — déploiement continu sur push `main`. Production : **https://xip.kerboul.me** — déploiement continu sur push `main`.
## Architecture (pattern Vireli, cluster SENTINEL) ## Architecture (pattern Vireli, cluster SENTINEL)
``` ```
Cloudflare (*.kerboul.me) ─► VPS WireGuard ─► Traefik (CT102, Cerberus) Cloudflare (*.kerboul.me) ─► VPS WireGuard ─► Traefik (CT102, Cerberus)
│ Host(`xip.kerboul.me`) → http://192.168.1.242:80 │ Host(`xip.kerboul.me`) → http://192.168.1.242:80
CT502 « xip-app » (Echelon, Docker host) CT502 « xip-app » (Echelon, Docker host)
┌───────────────────────────────────────┐ ┌───────────────────────────────────────┐
│ web (nginx:80) │ │ web (nginx:80) │
│ ├── / → SPA Vue (statique) │ │ ├── / → SPA Vue (statique) │
│ ├── /api/ → backend:3000 │ │ ├── /api/ → backend:3000 │
│ └── /ws → backend:3000 (WS) │ │ └── /ws → backend:3000 (WS) │
│ backend (bun:3000, Hono + Prisma) │ │ backend (bun:3000, Hono + Prisma) │
│ postgres:16 redis:7 │ │ postgres:16 redis:7 │
└───────────────────────────────────────┘ └───────────────────────────────────────┘
``` ```
Origine unique : le front (buildé avec `VITE_API_URL=https://xip.kerboul.me`) Origine unique : le front (buildé avec `VITE_API_URL=https://xip.kerboul.me`)
appelle `/api` et `wss://xip.kerboul.me/ws`, nginx proxifie vers le backend. appelle `/api` et `wss://xip.kerboul.me/ws`, nginx proxifie vers le backend.
Traefik termine le TLS (Let's Encrypt, DNS challenge Cloudflare). Traefik termine le TLS (Let's Encrypt, DNS challenge Cloudflare).
## CI/CD (Gitea Actions) ## CI/CD (Gitea Actions)
`.gitea/workflows/deploy.yml` se déclenche sur push `main` (+ `workflow_dispatch`). `.gitea/workflows/deploy.yml` se déclenche sur push `main` (+ `workflow_dispatch`).
Le runner (CT121) se connecte en SSH au CT502 et exécute `scripts/deploy.sh` Le runner (CT121) se connecte en SSH au CT502 et exécute `scripts/deploy.sh`
(`git reset --hard origin/main` + `docker compose up -d --build`). (`git reset --hard origin/main` + `docker compose up -d --build`).
Migrations Prisma + seed (idempotent) tournent au démarrage du conteneur backend Migrations Prisma + seed (idempotent) tournent au démarrage du conteneur backend
(`backend/docker-entrypoint.sh`). (`backend/docker-entrypoint.sh`).
### Secrets du repo (Gitea → Settings → Actions → Secrets) ### Secrets du repo (Gitea → Settings → Actions → Secrets)
| Secret | Rôle | | Secret | Rôle |
|--------|------| |--------|------|
| `XIP_DEPLOY_HOST` | IP du CT502 (192.168.1.242) | | `XIP_DEPLOY_HOST` | IP du CT502 (192.168.1.242) |
| `XIP_DEPLOY_USER` | utilisateur de déploiement (`deploy`) | | `XIP_DEPLOY_USER` | utilisateur de déploiement (`deploy`) |
| `XIP_DEPLOY_KEY` | clé privée SSH autorisée sur le CT502 | | `XIP_DEPLOY_KEY` | clé privée SSH autorisée sur le CT502 |
## Fichiers ## Fichiers
| Fichier | Rôle | | Fichier | Rôle |
|---------|------| |---------|------|
| `docker-compose.prod.yml` | stack prod (postgres, redis, backend, web) | | `docker-compose.prod.yml` | stack prod (postgres, redis, backend, web) |
| `backend/Dockerfile` + `docker-entrypoint.sh` | image backend, migrate+seed au boot | | `backend/Dockerfile` + `docker-entrypoint.sh` | image backend, migrate+seed au boot |
| `frontend/Dockerfile` | build Vite → nginx | | `frontend/Dockerfile` | build Vite → nginx |
| `deploy/nginx.conf` | reverse proxy single-origin | | `deploy/nginx.conf` | reverse proxy single-origin |
| `scripts/deploy.sh` | script de (re)déploiement sur le CT | | `scripts/deploy.sh` | script de (re)déploiement sur le CT |
| `.env.prod` (non commité) | secrets : voir `.env.prod.example` | | `.env.prod` (non commité) | secrets : voir `.env.prod.example` |
## Paywall ## Paywall
`XIP_OPEN_BAR=true` (dans `.env.prod`) = **open bar** : toutes les fonctionnalités `XIP_OPEN_BAR=true` (dans `.env.prod`) = **open bar** : toutes les fonctionnalités
payantes gratuites pour tout le monde. Mettre `false` pour réactiver le paywall payantes gratuites pour tout le monde. Mettre `false` pour réactiver le paywall
(gratuit uniquement en localhost). Logique centralisée dans `backend/src/lib/ip.ts` (gratuit uniquement en localhost). Logique centralisée dans `backend/src/lib/ip.ts`
(`isFree()`). (`isFree()`).
## Redéploiement manuel ## Redéploiement manuel
```bash ```bash
ssh deploy@192.168.1.242 'bash /opt/xip/scripts/deploy.sh' ssh deploy@192.168.1.242 'bash /opt/xip/scripts/deploy.sh'
``` ```

22
LICENSE
View File

@@ -1,11 +1,11 @@
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
Version 2, December 2004 Version 2, December 2004
Copyright (C) 2004 Sam Hocevar <sam@hocevar.net> Copyright (C) 2004 Sam Hocevar <sam@hocevar.net>
Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed. Everyone is permitted to copy and distribute verbatim or modified copies of this license document, and changing it is allowed as long as the name is changed.
DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE DO WHAT THE FUCK YOU WANT TO PUBLIC LICENSE
TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION TERMS AND CONDITIONS FOR COPYING, DISTRIBUTION AND MODIFICATION
0. You just DO WHAT THE FUCK YOU WANT TO. 0. You just DO WHAT THE FUCK YOU WANT TO.

125
README.md
View File

@@ -1,35 +1,110 @@
# XIP # XIP — Réseau social « sans modération »
Réseau social à consommer sans modération SPA satirique : un chat public en temps réel où **ton pseudo = ton adresse IP**,
noyé sous les pubs et le merchandising. Catalogue de messages distant, liste
perso de favoris, statistiques dérivées, marketplace à crédits fictifs, thèmes
(dont WhatsApp).
## Concept 🌐 **Application déployée : https://xip.kerboul.me**
Faire un réseau social open sans contrôles ni modération. ---
Pas de compte, Pseudo = IP.
Merchandising à fond. ## Stack
Envahit par des Pubs.
| Couche | Technologies |
|--------|--------------|
| Frontend | Vue 3 (`<script setup>`, Composition API), Vite, TypeScript, vue-router |
| Backend | Bun, Hono, Prisma, PostgreSQL, Redis (WebSocket temps réel) |
| Tests | Vitest, @vue/test-utils, happy-dom |
| Déploiement | Docker, nginx, Traefik, CI Gitea Actions (auto-deploy sur push `main`) |
Pas de framework de composants prêts-à-l'emploi : tout le découpage et le CSS
sont faits à la main.
---
## Fonctionnalités ## Fonctionnalités
**Gratuit :** - **Chat temps réel** (WebSocket) : messages, réponses en thread, présence,
- Envoyer des messages stats live qui défilent.
- contenant du texte (267 charactères) - **Explorer** (`/explorer`) : catalogue distant paginé (défilement infini),
- contenant des fichiers (JPEG, .exe, ...) 1 Mo max **recherche debouncée et annulable** (AbortController), filtre.
- Répondre à un message (sous forme de sous-thread) - **Détail** d'un message (`/message/:id`) et d'un produit (`/shop/p/:id`) par
- Récupérer mes messages identifiant d'URL.
- **Favoris** (`/favoris`) : liste personnelle persistée en localStorage,
notation (note + statut + commentaire), reflétée partout (★).
- **Mes stats** (`/mes-stats`) : synthèse dérivée des favoris (note moyenne,
répartition par pays, top auteurs…), mise à jour automatique ; page gardée
(inaccessible si aucun favori).
- **Marketplace** à crédits fictifs : cosmétiques (couleur d'IP, pets, skin du
bouton d'envoi), abonnement NoAds, messages riches (HTML/CSS, et JS en iframe
sandbox), pièces jointes.
- **Thèmes** dynamiques (Classique, Bulles, Compact, **WhatsApp**), persistés.
- **Géolocalisation** des IP (drapeau + ville) sur chaque message.
**Payant :** ---
- Acheter des fonctionnalités (Marketplace)
- mettre du CSS & HTML dans les messages (taille fixe), pas de script
- pas de limite de taille de fichiers
- mettre du javascript (très très cher)
- "Skins" de ton IP
- "Skins" des éléments (boutons, text area, encadré pub, ...)
- Choisir sa pub
- Retirer les pubs
- payer alerte audio générale (consommable, cooldown, durée max mais volume à fond, possibilité de fournir le mp3)
**Si localhost :** ## Lancer en local
- Pas de paywall (tout gratuit)
Prérequis : [Bun](https://bun.sh) + Docker (pour Postgres/Redis).
```bash
bun install
bun run dev:stack
```
`dev:stack` lève Postgres + Redis (docker compose), applique les migrations
Prisma, seede la base, puis démarre le backend (http://localhost:3000) et le
frontend (http://localhost:5173).
En local, le **paywall est désactivé** (tout gratuit).
---
## Tests
```bash
bun run --cwd frontend test # exécution
bun run --cwd frontend test:cov # avec couverture
```
Couvre la logique d'état (favoris, wallet, perks), des fonctions réutilisables
(parseMeta, debounce, couleur d'IP) et l'interaction de composants (ThemePicker,
SearchBox). **Couverture ≈ 86 %** sur le code métier ciblé.
## Vérifications
```bash
bun run --cwd frontend typecheck # vue-tsc, 0 erreur
bun run --cwd frontend build # build de production
bun run --cwd backend typecheck # tsc, 0 erreur
```
---
## Configuration / secrets
Aucun secret dans le code. Copier les exemples committés et renseigner les vraies
valeurs (les `.env` réels sont gitignorés) :
- `backend/.env.example``backend/.env` (dév local)
- `.env.prod.example``.env.prod` (production)
---
## Déploiement
Déploiement continu : tout push sur `main` déclenche la CI Gitea qui rebuild et
redéploie la stack Docker derrière Traefik. Détails dans **[DEPLOY.md](DEPLOY.md)**.
---
## Mention IA
Ce projet a été développé avec une assistance IA importante (génération et
refactorisation de code comprises). Le code a été relu, corrigé et intégré par
l'équipe.
## Auteurs
<!-- à compléter : noms du groupe -->

View File

@@ -1,4 +1,4 @@
DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/xip" DATABASE_URL="postgresql://USER:PASSWORD@localhost:5432/xip"
REDIS_URL="redis://localhost:6379" REDIS_URL="redis://localhost:6379"
PORT=3000 PORT=3000
NODE_ENV=development NODE_ENV=development

2
backend/.gitignore vendored
View File

@@ -1 +1 @@
uploads/ uploads/

View File

@@ -1,22 +1,22 @@
# XIP backend — Bun + Hono + Prisma. # XIP backend — Bun + Hono + Prisma.
# Build context is the repo ROOT (see docker-compose.prod.yml) so we can copy backend/. # Build context is the repo ROOT (see docker-compose.prod.yml) so we can copy backend/.
FROM oven/bun:1-debian AS deps FROM oven/bun:1-debian AS deps
WORKDIR /app WORKDIR /app
COPY backend/package.json ./ COPY backend/package.json ./
RUN bun install RUN bun install
FROM oven/bun:1-debian AS runtime FROM oven/bun:1-debian AS runtime
WORKDIR /app WORKDIR /app
ENV NODE_ENV=production ENV NODE_ENV=production
# Prisma's query engine needs openssl + CA certs (generate downloads it over HTTPS). # Prisma's query engine needs openssl + CA certs (generate downloads it over HTTPS).
RUN apt-get update \ RUN apt-get update \
&& apt-get install -y --no-install-recommends openssl ca-certificates \ && apt-get install -y --no-install-recommends openssl ca-certificates \
&& rm -rf /var/lib/apt/lists/* && rm -rf /var/lib/apt/lists/*
COPY --from=deps /app/node_modules ./node_modules COPY --from=deps /app/node_modules ./node_modules
COPY backend/ ./ COPY backend/ ./
# Generate the Prisma client from the schema (no DB connection required). # Generate the Prisma client from the schema (no DB connection required).
RUN bunx prisma generate \ RUN bunx prisma generate \
&& chmod +x docker-entrypoint.sh && chmod +x docker-entrypoint.sh
EXPOSE 3000 EXPOSE 3000
# Entrypoint applies migrations + seeds (idempotent) then starts the server. # Entrypoint applies migrations + seeds (idempotent) then starts the server.
ENTRYPOINT ["/app/docker-entrypoint.sh"] ENTRYPOINT ["/app/docker-entrypoint.sh"]

View File

@@ -1,20 +1,21 @@
{ {
"name": "xip-backend", "name": "xip-backend",
"version": "0.0.1", "version": "0.0.1",
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "bun --hot run src/index.ts", "dev": "bun --hot run src/index.ts",
"start": "bun run src/index.ts", "start": "bun run src/index.ts",
"build": "bun build src/index.ts --outdir dist --target bun" "build": "bun build src/index.ts --outdir dist --target bun",
}, "typecheck": "tsc --noEmit"
"dependencies": { },
"@prisma/client": "^5.22.0", "dependencies": {
"hono": "^4.6.0", "@prisma/client": "^5.22.0",
"ioredis": "^5.4.0" "hono": "^4.6.0",
}, "ioredis": "^5.4.0"
"devDependencies": { },
"@types/bun": "latest", "devDependencies": {
"prisma": "^5.22.0", "@types/bun": "latest",
"typescript": "^5.6.0" "prisma": "^5.22.0",
} "typescript": "^5.6.0"
} }
}

View File

@@ -1,13 +1,13 @@
-- CreateTable -- CreateTable
CREATE TABLE "messages" ( CREATE TABLE "messages" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"content" VARCHAR(267) NOT NULL, "content" VARCHAR(267) NOT NULL,
"authorIp" TEXT NOT NULL, "authorIp" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"parentId" TEXT, "parentId" TEXT,
CONSTRAINT "messages_pkey" PRIMARY KEY ("id") CONSTRAINT "messages_pkey" PRIMARY KEY ("id")
); );
-- AddForeignKey -- AddForeignKey
ALTER TABLE "messages" ADD CONSTRAINT "messages_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE; ALTER TABLE "messages" ADD CONSTRAINT "messages_parentId_fkey" FOREIGN KEY ("parentId") REFERENCES "messages"("id") ON DELETE SET NULL ON UPDATE CASCADE;

View File

@@ -1,112 +1,112 @@
-- AlterTable -- AlterTable
ALTER TABLE "messages" ADD COLUMN "richContent" TEXT, ALTER TABLE "messages" ADD COLUMN "richContent" TEXT,
ADD COLUMN "richMode" TEXT NOT NULL DEFAULT 'none'; ADD COLUMN "richMode" TEXT NOT NULL DEFAULT 'none';
-- CreateTable -- CreateTable
CREATE TABLE "wallets" ( CREATE TABLE "wallets" (
"ip" TEXT NOT NULL, "ip" TEXT NOT NULL,
"balance" INTEGER NOT NULL DEFAULT 0, "balance" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
"updatedAt" TIMESTAMP(3) NOT NULL, "updatedAt" TIMESTAMP(3) NOT NULL,
CONSTRAINT "wallets_pkey" PRIMARY KEY ("ip") CONSTRAINT "wallets_pkey" PRIMARY KEY ("ip")
); );
-- CreateTable -- CreateTable
CREATE TABLE "products" ( CREATE TABLE "products" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"category" TEXT NOT NULL, "category" TEXT NOT NULL,
"name" TEXT NOT NULL, "name" TEXT NOT NULL,
"subtitle" TEXT, "subtitle" TEXT,
"kind" TEXT NOT NULL, "kind" TEXT NOT NULL,
"basePrice" INTEGER NOT NULL, "basePrice" INTEGER NOT NULL,
"promoPrice" INTEGER, "promoPrice" INTEGER,
"badge" TEXT, "badge" TEXT,
"stockLimit" INTEGER, "stockLimit" INTEGER,
"stockSold" INTEGER NOT NULL DEFAULT 0, "stockSold" INTEGER NOT NULL DEFAULT 0,
"active" BOOLEAN NOT NULL DEFAULT true, "active" BOOLEAN NOT NULL DEFAULT true,
"sortOrder" INTEGER NOT NULL DEFAULT 0, "sortOrder" INTEGER NOT NULL DEFAULT 0,
"metaJson" TEXT, "metaJson" TEXT,
CONSTRAINT "products_pkey" PRIMARY KEY ("id") CONSTRAINT "products_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "purchases" ( CREATE TABLE "purchases" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"ip" TEXT NOT NULL, "ip" TEXT NOT NULL,
"productId" TEXT, "productId" TEXT,
"type" TEXT NOT NULL, "type" TEXT NOT NULL,
"amount" INTEGER NOT NULL, "amount" INTEGER NOT NULL,
"metaJson" TEXT, "metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "purchases_pkey" PRIMARY KEY ("id") CONSTRAINT "purchases_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "entitlements" ( CREATE TABLE "entitlements" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"ip" TEXT NOT NULL, "ip" TEXT NOT NULL,
"kind" TEXT NOT NULL, "kind" TEXT NOT NULL,
"active" BOOLEAN NOT NULL DEFAULT true, "active" BOOLEAN NOT NULL DEFAULT true,
"expiresAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3),
"metaJson" TEXT, "metaJson" TEXT,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id") CONSTRAINT "entitlements_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "ads" ( CREATE TABLE "ads" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"brand" TEXT NOT NULL, "brand" TEXT NOT NULL,
"subtitle" TEXT, "subtitle" TEXT,
"url" TEXT, "url" TEXT,
"cta" TEXT, "cta" TEXT,
"icon" TEXT, "icon" TEXT,
"tone" TEXT NOT NULL, "tone" TEXT NOT NULL,
"kind" TEXT NOT NULL, "kind" TEXT NOT NULL,
"weight" INTEGER NOT NULL DEFAULT 1, "weight" INTEGER NOT NULL DEFAULT 1,
"active" BOOLEAN NOT NULL DEFAULT true, "active" BOOLEAN NOT NULL DEFAULT true,
"ownerIp" TEXT, "ownerIp" TEXT,
"format" TEXT, "format" TEXT,
"imageUrl" TEXT, "imageUrl" TEXT,
"expiresAt" TIMESTAMP(3), "expiresAt" TIMESTAMP(3),
"impressions" INTEGER NOT NULL DEFAULT 0, "impressions" INTEGER NOT NULL DEFAULT 0,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "ads_pkey" PRIMARY KEY ("id") CONSTRAINT "ads_pkey" PRIMARY KEY ("id")
); );
-- CreateTable -- CreateTable
CREATE TABLE "attachments" ( CREATE TABLE "attachments" (
"id" TEXT NOT NULL, "id" TEXT NOT NULL,
"messageId" TEXT, "messageId" TEXT,
"ip" TEXT NOT NULL, "ip" TEXT NOT NULL,
"filename" TEXT NOT NULL, "filename" TEXT NOT NULL,
"mimeType" TEXT NOT NULL, "mimeType" TEXT NOT NULL,
"size" INTEGER NOT NULL, "size" INTEGER NOT NULL,
"storagePath" TEXT NOT NULL, "storagePath" TEXT NOT NULL,
"createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP, "createdAt" TIMESTAMP(3) NOT NULL DEFAULT CURRENT_TIMESTAMP,
CONSTRAINT "attachments_pkey" PRIMARY KEY ("id") CONSTRAINT "attachments_pkey" PRIMARY KEY ("id")
); );
-- CreateIndex -- CreateIndex
CREATE INDEX "purchases_ip_idx" ON "purchases"("ip"); CREATE INDEX "purchases_ip_idx" ON "purchases"("ip");
-- CreateIndex -- CreateIndex
CREATE INDEX "entitlements_ip_idx" ON "entitlements"("ip"); CREATE INDEX "entitlements_ip_idx" ON "entitlements"("ip");
-- CreateIndex -- CreateIndex
CREATE INDEX "entitlements_ip_kind_idx" ON "entitlements"("ip", "kind"); CREATE INDEX "entitlements_ip_kind_idx" ON "entitlements"("ip", "kind");
-- CreateIndex -- CreateIndex
CREATE INDEX "ads_kind_active_idx" ON "ads"("kind", "active"); CREATE INDEX "ads_kind_active_idx" ON "ads"("kind", "active");
-- CreateIndex -- CreateIndex
CREATE INDEX "attachments_messageId_idx" ON "attachments"("messageId"); CREATE INDEX "attachments_messageId_idx" ON "attachments"("messageId");
-- AddForeignKey -- AddForeignKey
ALTER TABLE "attachments" ADD CONSTRAINT "attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE; ALTER TABLE "attachments" ADD CONSTRAINT "attachments_messageId_fkey" FOREIGN KEY ("messageId") REFERENCES "messages"("id") ON DELETE CASCADE ON UPDATE CASCADE;

View File

@@ -1,130 +1,130 @@
// This is your Prisma schema file // This is your Prisma schema file
// Learn more: https://pris.ly/d/prisma-schema // Learn more: https://pris.ly/d/prisma-schema
generator client { generator client {
provider = "prisma-client-js" provider = "prisma-client-js"
} }
datasource db { datasource db {
provider = "postgresql" provider = "postgresql"
url = env("DATABASE_URL") url = env("DATABASE_URL")
} }
model Message { model Message {
id String @id @default(uuid()) id String @id @default(uuid())
content String @db.VarChar(267) content String @db.VarChar(267)
authorIp String authorIp String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
parentId String? parentId String?
// Rich-message tiers (paid): "none" | "htmlcss" | "js". richContent holds the raw // Rich-message tiers (paid): "none" | "htmlcss" | "js". richContent holds the raw
// markup/script, rendered ONLY inside a sandboxed iframe on the client. // markup/script, rendered ONLY inside a sandboxed iframe on the client.
richMode String @default("none") richMode String @default("none")
richContent String? @db.Text richContent String? @db.Text
parent Message? @relation("ThreadReplies", fields: [parentId], references: [id]) parent Message? @relation("ThreadReplies", fields: [parentId], references: [id])
replies Message[] @relation("ThreadReplies") replies Message[] @relation("ThreadReplies")
attachments Attachment[] attachments Attachment[]
@@map("messages") @@map("messages")
} }
// ── Economy: fictional "crédits XIP", keyed on IP (no accounts) ────────────── // ── Economy: fictional "crédits XIP", keyed on IP (no accounts) ──────────────
model Wallet { model Wallet {
ip String @id ip String @id
balance Int @default(0) // centi-credits (9.99 "€" => 999) balance Int @default(0) // centi-credits (9.99 "€" => 999)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
updatedAt DateTime @updatedAt updatedAt DateTime @updatedAt
@@map("wallets") @@map("wallets")
} }
// Seeded catalogue of purchasable features (faithful to the shop mockups). // Seeded catalogue of purchasable features (faithful to the shop mockups).
model Product { model Product {
id String @id // slug: "cadre-pub","noads","style-dore","pet","bundle-cosmetic","rich-htmlcss","rich-js","no-file-limit","audio-alert" id String @id // slug: "cadre-pub","noads","style-dore","pet","bundle-cosmetic","rich-htmlcss","rich-js","no-file-limit","audio-alert"
category String // "publicite" | "abonnements" | "cosmetiques" | "promotions" | "premium" category String // "publicite" | "abonnements" | "cosmetiques" | "promotions" | "premium"
name String name String
subtitle String? subtitle String?
kind String // "ad-frame" | "subscription" | "ip-skin" | "pet" | "bundle" | "rich" | "unlock" | "consumable" kind String // "ad-frame" | "subscription" | "ip-skin" | "pet" | "bundle" | "rich" | "unlock" | "consumable"
basePrice Int // centi-credits basePrice Int // centi-credits
promoPrice Int? promoPrice Int?
badge String? badge String?
stockLimit Int? // e.g. 50 for style-dore; null = unlimited stockLimit Int? // e.g. 50 for style-dore; null = unlimited
stockSold Int @default(0) stockSold Int @default(0)
active Boolean @default(true) active Boolean @default(true)
sortOrder Int @default(0) sortOrder Int @default(0)
metaJson String? @db.Text // options config (durations, formats, pet designs, plans…) metaJson String? @db.Text // options config (durations, formats, pet designs, plans…)
@@map("products") @@map("products")
} }
// Append-only ledger: every credit movement (top-up, purchase, grant). // Append-only ledger: every credit movement (top-up, purchase, grant).
model Purchase { model Purchase {
id String @id @default(uuid()) id String @id @default(uuid())
ip String ip String
productId String? productId String?
type String // "topup" | "purchase" | "grant" type String // "topup" | "purchase" | "grant"
amount Int // signed centi-credits: negative = spend, positive = grant amount Int // signed centi-credits: negative = spend, positive = grant
metaJson String? @db.Text metaJson String? @db.Text
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([ip]) @@index([ip])
@@map("purchases") @@map("purchases")
} }
// What an IP owns. Drives perks (skin/pets/noads), rich unlocks, ad frames, etc. // What an IP owns. Drives perks (skin/pets/noads), rich unlocks, ad frames, etc.
model Entitlement { model Entitlement {
id String @id @default(uuid()) id String @id @default(uuid())
ip String ip String
kind String // "noads" | "style-dore" | "pet" | "rich-htmlcss" | "rich-js" | "no-file-limit" | "ad-frame" | "audio-alert" | "element-skin" kind String // "noads" | "style-dore" | "pet" | "rich-htmlcss" | "rich-js" | "no-file-limit" | "ad-frame" | "audio-alert" | "element-skin"
active Boolean @default(true) active Boolean @default(true)
expiresAt DateTime? // subscriptions / ad-frame duration; null = permanent expiresAt DateTime? // subscriptions / ad-frame duration; null = permanent
metaJson String? @db.Text // pet: {design,position}; ad-frame: {format,url,days}; etc. metaJson String? @db.Text // pet: {design,position}; ad-frame: {format,url,days}; etc.
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([ip]) @@index([ip])
@@index([ip, kind]) @@index([ip, kind])
@@map("entitlements") @@map("entitlements")
} }
// ── Real ad inventory (replaces the hardcoded AdBand / InlineCasinoAd) ─────── // ── Real ad inventory (replaces the hardcoded AdBand / InlineCasinoAd) ───────
model Ad { model Ad {
id String @id @default(uuid()) id String @id @default(uuid())
brand String brand String
subtitle String? subtitle String?
url String? url String?
cta String? cta String?
icon String? icon String?
tone String // "blue" | "green" | "purple" | "casino" | "user" tone String // "blue" | "green" | "purple" | "casino" | "user"
kind String // "band" | "casino" kind String // "band" | "casino"
weight Int @default(1) weight Int @default(1)
active Boolean @default(true) active Boolean @default(true)
ownerIp String? // set when bought via "Cadre de Pub" ownerIp String? // set when bought via "Cadre de Pub"
format String? // "static" | "gif" format String? // "static" | "gif"
imageUrl String? imageUrl String?
expiresAt DateTime? expiresAt DateTime?
impressions Int @default(0) impressions Int @default(0)
createdAt DateTime @default(now()) createdAt DateTime @default(now())
@@index([kind, active]) @@index([kind, active])
@@map("ads") @@map("ads")
} }
// ── File attachments (free <=1 Mo; paid "no-file-limit" lifts the cap) ─────── // ── File attachments (free <=1 Mo; paid "no-file-limit" lifts the cap) ───────
model Attachment { model Attachment {
id String @id @default(uuid()) id String @id @default(uuid())
messageId String? messageId String?
ip String ip String
filename String filename String
mimeType String mimeType String
size Int size Int
storagePath String storagePath String
createdAt DateTime @default(now()) createdAt DateTime @default(now())
message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade) message Message? @relation(fields: [messageId], references: [id], onDelete: Cascade)
@@index([messageId]) @@index([messageId])
@@map("attachments") @@map("attachments")
} }

View File

@@ -1,282 +1,282 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const prisma = new PrismaClient(); const prisma = new PrismaClient();
// ── Marketplace catalogue (faithful to the shop mockups) ──────────────────── // ── Marketplace catalogue (faithful to the shop mockups) ────────────────────
// Prices are centi-credits (mockup € → credits): 9.99 → 999. // Prices are centi-credits (mockup € → credits): 9.99 → 999.
const PRODUCTS = [ const PRODUCTS = [
{ {
id: "cadre-pub", id: "cadre-pub",
category: "publicite", category: "publicite",
name: "Cadre de Pub", name: "Cadre de Pub",
subtitle: "1 000 impressions garanties · 130×180 px · lien cliquable", subtitle: "1 000 impressions garanties · 130×180 px · lien cliquable",
kind: "ad-frame", kind: "ad-frame",
basePrice: 1500, basePrice: 1500,
promoPrice: 999, promoPrice: 999,
badge: "-33% FLASH PROMO", badge: "-33% FLASH PROMO",
sortOrder: 10, sortOrder: 10,
metaJson: JSON.stringify({ metaJson: JSON.stringify({
durations: [ durations: [
{ days: 7, extra: 0 }, { days: 7, extra: 0 },
{ days: 14, extra: 800 }, { days: 14, extra: 800 },
{ days: 30, extra: 2000 }, { days: 30, extra: 2000 },
], ],
formats: [ formats: [
{ id: "static", label: "Image statique", extra: 0 }, { id: "static", label: "Image statique", extra: 0 },
{ id: "gif", label: "GIF animé", extra: 300 }, { id: "gif", label: "GIF animé", extra: 300 },
], ],
}), }),
}, },
{ {
id: "noads", id: "noads",
category: "abonnements", category: "abonnements",
name: "Abonnement NoAds", name: "Abonnement NoAds",
subtitle: "Supprime toutes les pubs du chat", subtitle: "Supprime toutes les pubs du chat",
kind: "subscription", kind: "subscription",
basePrice: 499, basePrice: 499,
badge: "POPULAIRE", badge: "POPULAIRE",
sortOrder: 20, sortOrder: 20,
metaJson: JSON.stringify({ metaJson: JSON.stringify({
plans: [ plans: [
{ id: "monthly", label: "Mensuel", price: 499 }, { id: "monthly", label: "Mensuel", price: 499 },
{ id: "annual", label: "Annuel", price: 3999 }, { id: "annual", label: "Annuel", price: 3999 },
], ],
}), }),
}, },
{ {
id: "style-dore", id: "style-dore",
category: "cosmetiques", category: "cosmetiques",
name: "Style Doré", name: "Style Doré",
subtitle: "Ton IP en or brillant, visible de tous", subtitle: "Ton IP en or brillant, visible de tous",
kind: "ip-skin", kind: "ip-skin",
basePrice: 999, basePrice: 999,
badge: "LIMITÉ 50 ex.", badge: "LIMITÉ 50 ex.",
stockLimit: 50, stockLimit: 50,
sortOrder: 30, sortOrder: 30,
metaJson: JSON.stringify({ variant: "gold" }), metaJson: JSON.stringify({ variant: "gold" }),
}, },
{ {
id: "pet", id: "pet",
category: "cosmetiques", category: "cosmetiques",
name: "Pet de Nom", name: "Pet de Nom",
subtitle: "Un petit élément décoratif autour de ton IP", subtitle: "Un petit élément décoratif autour de ton IP",
kind: "pet", kind: "pet",
basePrice: 799, basePrice: 799,
badge: "NOUVEAU", badge: "NOUVEAU",
sortOrder: 40, sortOrder: 40,
metaJson: JSON.stringify({ metaJson: JSON.stringify({
designs: [ designs: [
{ id: "coeur", char: "♥" }, { id: "coeur", char: "♥" },
{ id: "etoile", char: "★" }, { id: "etoile", char: "★" },
{ id: "diamant", char: "♦" }, { id: "diamant", char: "♦" },
{ id: "trefle", char: "♣" }, { id: "trefle", char: "♣" },
{ id: "couronne", char: "♚" }, { id: "couronne", char: "♚" },
{ id: "crane", char: "☠" }, { id: "crane", char: "☠" },
{ id: "eclair", char: "⚡" }, { id: "eclair", char: "⚡" },
{ id: "fleur", char: "✿" }, { id: "fleur", char: "✿" },
{ id: "note", char: "♫" }, { id: "note", char: "♫" },
{ id: "feu", char: "🔥" }, { id: "feu", char: "🔥" },
], ],
positions: ["left", "right", "both"], positions: ["left", "right", "both"],
}), }),
}, },
{ {
id: "bundle-cosmetic", id: "bundle-cosmetic",
category: "promotions", category: "promotions",
name: "Pack Cosmétique", name: "Pack Cosmétique",
subtitle: "Style Doré + 1 Pet au choix", subtitle: "Style Doré + 1 Pet au choix",
kind: "bundle", kind: "bundle",
basePrice: 1798, basePrice: 1798,
promoPrice: 1499, promoPrice: 1499,
badge: "-3 CR", badge: "-3 CR",
sortOrder: 50, sortOrder: 50,
metaJson: JSON.stringify({ includes: ["style-dore", "pet"] }), metaJson: JSON.stringify({ includes: ["style-dore", "pet"] }),
}, },
{ {
// id == entitlement kind, so the "unlock" branch grants "element-skin". // id == entitlement kind, so the "unlock" branch grants "element-skin".
id: "element-skin", id: "element-skin",
category: "cosmetiques", category: "cosmetiques",
name: "Skin d'éléments", name: "Skin d'éléments",
subtitle: "Relooke ta barre de saisie et ton bouton d'envoi", subtitle: "Relooke ta barre de saisie et ton bouton d'envoi",
kind: "unlock", kind: "unlock",
basePrice: 599, basePrice: 599,
sortOrder: 45, sortOrder: 45,
metaJson: JSON.stringify({}), metaJson: JSON.stringify({}),
}, },
{ {
id: "rich-htmlcss", id: "rich-htmlcss",
category: "premium", category: "premium",
name: "Messages HTML / CSS", name: "Messages HTML / CSS",
subtitle: "Mets en forme tes messages (sans script)", subtitle: "Mets en forme tes messages (sans script)",
kind: "rich", kind: "rich",
basePrice: 2999, basePrice: 2999,
sortOrder: 60, sortOrder: 60,
metaJson: JSON.stringify({}), metaJson: JSON.stringify({}),
}, },
{ {
id: "rich-js", id: "rich-js",
category: "premium", category: "premium",
name: "Messages JavaScript", name: "Messages JavaScript",
subtitle: "Scripts interactifs (isolés). TRÈS cher.", subtitle: "Scripts interactifs (isolés). TRÈS cher.",
kind: "rich", kind: "rich",
basePrice: 19999, basePrice: 19999,
badge: "TRÈS TRÈS CHER", badge: "TRÈS TRÈS CHER",
sortOrder: 70, sortOrder: 70,
metaJson: JSON.stringify({}), metaJson: JSON.stringify({}),
}, },
{ {
id: "no-file-limit", id: "no-file-limit",
category: "premium", category: "premium",
name: "Fichiers illimités", name: "Fichiers illimités",
subtitle: "Plus de limite de 1 Mo sur tes pièces jointes", subtitle: "Plus de limite de 1 Mo sur tes pièces jointes",
kind: "unlock", kind: "unlock",
basePrice: 1499, basePrice: 1499,
sortOrder: 80, sortOrder: 80,
metaJson: JSON.stringify({}), metaJson: JSON.stringify({}),
}, },
{ {
id: "audio-alert", id: "audio-alert",
category: "premium", category: "premium",
name: "Alerte audio générale", name: "Alerte audio générale",
subtitle: "Fais hurler un son chez tout le monde (cooldown)", subtitle: "Fais hurler un son chez tout le monde (cooldown)",
kind: "consumable", kind: "consumable",
basePrice: 999, basePrice: 999,
badge: "CONSOMMABLE", badge: "CONSOMMABLE",
sortOrder: 90, sortOrder: 90,
metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }), metaJson: JSON.stringify({ cooldownMs: 60000, maxDurationMs: 5000 }),
}, },
// ── Cosmetics: IP color + send button skins ────────────────────────────── // ── Cosmetics: IP color + send button skins ──────────────────────────────
{ {
id: "ip-colors", id: "ip-colors",
category: "cosmetiques", category: "cosmetiques",
name: "Palette IP", name: "Palette IP",
subtitle: "Personnalise la couleur de ton adresse IP dans le chat", subtitle: "Personnalise la couleur de ton adresse IP dans le chat",
kind: "unlock", kind: "unlock",
basePrice: 299, basePrice: 299,
sortOrder: 46, sortOrder: 46,
metaJson: JSON.stringify({}), metaJson: JSON.stringify({}),
}, },
{ {
id: "send-skin-honker", id: "send-skin-honker",
category: "cosmetiques", category: "cosmetiques",
name: "Doigt d'honneur", name: "Doigt d'honneur",
subtitle: "Bouton d'envoi qui exprime tout", subtitle: "Bouton d'envoi qui exprime tout",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 47, sortOrder: 47,
metaJson: JSON.stringify({ char: "🖕", label: "Doigt d'honneur" }), metaJson: JSON.stringify({ char: "🖕", label: "Doigt d'honneur" }),
}, },
{ {
id: "send-skin-skull", id: "send-skin-skull",
category: "cosmetiques", category: "cosmetiques",
name: "Crâne", name: "Crâne",
subtitle: "Envoyer avec style... macabre", subtitle: "Envoyer avec style... macabre",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 48, sortOrder: 48,
metaJson: JSON.stringify({ char: "💀", label: "Crâne" }), metaJson: JSON.stringify({ char: "💀", label: "Crâne" }),
}, },
{ {
id: "send-skin-rocket", id: "send-skin-rocket",
category: "cosmetiques", category: "cosmetiques",
name: "Fusée", name: "Fusée",
subtitle: "Tes messages décollent", subtitle: "Tes messages décollent",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 49, sortOrder: 49,
metaJson: JSON.stringify({ char: "🚀", label: "Fusée" }), metaJson: JSON.stringify({ char: "🚀", label: "Fusée" }),
}, },
{ {
id: "send-skin-ghost", id: "send-skin-ghost",
category: "cosmetiques", category: "cosmetiques",
name: "Fantôme", name: "Fantôme",
subtitle: "Boo !", subtitle: "Boo !",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 50, sortOrder: 50,
metaJson: JSON.stringify({ char: "👻", label: "Fantôme" }), metaJson: JSON.stringify({ char: "👻", label: "Fantôme" }),
}, },
{ {
id: "send-skin-bomb", id: "send-skin-bomb",
category: "cosmetiques", category: "cosmetiques",
name: "Bombe", name: "Bombe",
subtitle: "Message explosif", subtitle: "Message explosif",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 51, sortOrder: 51,
metaJson: JSON.stringify({ char: "💣", label: "Bombe" }), metaJson: JSON.stringify({ char: "💣", label: "Bombe" }),
}, },
{ {
id: "send-skin-sword", id: "send-skin-sword",
category: "cosmetiques", category: "cosmetiques",
name: "Épée", name: "Épée",
subtitle: "Tranche le silence", subtitle: "Tranche le silence",
kind: "send-skin", kind: "send-skin",
basePrice: 149, basePrice: 149,
sortOrder: 52, sortOrder: 52,
metaJson: JSON.stringify({ char: "⚔️", label: "Épée" }), metaJson: JSON.stringify({ char: "⚔️", label: "Épée" }),
}, },
] as const; ] as const;
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ────────────────── // ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────
const ADS = [ const ADS = [
{ brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 }, { brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 },
{ brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 }, { brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 },
{ brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 }, { brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 },
{ brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 }, { brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 },
] as const; ] as const;
async function seedProducts() { async function seedProducts() {
for (const p of PRODUCTS) { for (const p of PRODUCTS) {
await prisma.product.upsert({ await prisma.product.upsert({
where: { id: p.id }, where: { id: p.id },
create: p as any, create: p as any,
update: p as any, update: p as any,
}); });
} }
console.log(`${PRODUCTS.length} produits upsertés.`); console.log(`${PRODUCTS.length} produits upsertés.`);
} }
async function seedAds() { async function seedAds() {
for (const a of ADS) { for (const a of ADS) {
// Idempotent on brand: only seed the canonical (non-user) ads once. // Idempotent on brand: only seed the canonical (non-user) ads once.
const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } }); const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } });
if (existing) { if (existing) {
await prisma.ad.update({ where: { id: existing.id }, data: a as any }); await prisma.ad.update({ where: { id: existing.id }, data: a as any });
} else { } else {
await prisma.ad.create({ data: a as any }); await prisma.ad.create({ data: a as any });
} }
} }
console.log(`${ADS.length} pubs upsertées.`); console.log(`${ADS.length} pubs upsertées.`);
} }
async function seedMessages() { async function seedMessages() {
const count = await prisma.message.count(); const count = await prisma.message.count();
if (count > 0) { if (count > 0) {
console.log("⏭️ Messages déjà présents, seed messages ignoré."); console.log("⏭️ Messages déjà présents, seed messages ignoré.");
return; return;
} }
const root1 = await prisma.message.create({ const root1 = await prisma.message.create({
data: { data: {
content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.", content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.",
authorIp: "1.2.3.4", authorIp: "1.2.3.4",
}, },
}); });
await prisma.message.create({ await prisma.message.create({
data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" }, data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" },
}); });
await prisma.message.create({ await prisma.message.create({
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id }, data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
}); });
console.log("✅ 3 messages de démo créés."); console.log("✅ 3 messages de démo créés.");
} }
async function main() { async function main() {
await seedProducts(); await seedProducts();
await seedAds(); await seedAds();
await seedMessages(); await seedMessages();
} }
main() main()
.catch(console.error) .catch(console.error)
.finally(() => prisma.$disconnect()); .finally(() => prisma.$disconnect());

View File

@@ -1,57 +1,57 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { cors } from "hono/cors"; import { cors } from "hono/cors";
import { logger } from "hono/logger"; import { logger } from "hono/logger";
import messagesRoute from "./routes/messages"; import messagesRoute from "./routes/messages";
import walletRoute from "./routes/wallet"; import walletRoute from "./routes/wallet";
import shopRoute from "./routes/shop"; import shopRoute from "./routes/shop";
import perksRoute from "./routes/perks"; import perksRoute from "./routes/perks";
import uploadsRoute from "./routes/uploads"; import uploadsRoute from "./routes/uploads";
import adsRoute from "./routes/ads"; import adsRoute from "./routes/ads";
import alertRoute from "./routes/alert"; import alertRoute from "./routes/alert";
import { wsHandler, websocket } from "./realtime"; import { wsHandler, websocket } from "./realtime";
import { recordIp, initStats } from "./lib/stats"; import { recordIp, initStats } from "./lib/stats";
import { initImpressionTotal, reconcileImpressions } from "./lib/ads"; import { initImpressionTotal, reconcileImpressions } from "./lib/ads";
import { getClientIp } from "./lib/ip"; import { getClientIp } from "./lib/ip";
const app = new Hono(); const app = new Hono();
// Backfill persistent counters from the DB on first boot (idempotent). // Backfill persistent counters from the DB on first boot (idempotent).
void initStats(); void initStats();
void initImpressionTotal(); void initImpressionTotal();
// Periodically fold Redis impression counters into the DB. // Periodically fold Redis impression counters into the DB.
setInterval(() => void reconcileImpressions(), 30_000); setInterval(() => void reconcileImpressions(), 30_000);
app.use("*", logger()); app.use("*", logger());
app.use( app.use(
"*", "*",
cors({ cors({
origin: (origin) => origin ?? "*", origin: (origin) => origin ?? "*",
allowMethods: ["GET", "POST", "OPTIONS"], allowMethods: ["GET", "POST", "OPTIONS"],
allowHeaders: ["Content-Type"], allowHeaders: ["Content-Type"],
}) })
); );
// Count every IP that passes through the server (HyperLogLog, approximate). // Count every IP that passes through the server (HyperLogLog, approximate).
app.use("*", async (c, next) => { app.use("*", async (c, next) => {
void recordIp(getClientIp(c)); void recordIp(getClientIp(c));
await next(); await next();
}); });
app.get("/health", (c) => c.json({ status: "ok" })); app.get("/health", (c) => c.json({ status: "ok" }));
// Realtime stats + live message feed. // Realtime stats + live message feed.
app.get("/ws", wsHandler); app.get("/ws", wsHandler);
app.route("/api/messages", messagesRoute); app.route("/api/messages", messagesRoute);
app.route("/api/wallet", walletRoute); app.route("/api/wallet", walletRoute);
app.route("/api/shop", shopRoute); app.route("/api/shop", shopRoute);
app.route("/api/perks", perksRoute); app.route("/api/perks", perksRoute);
app.route("/api/uploads", uploadsRoute); app.route("/api/uploads", uploadsRoute);
app.route("/api/ads", adsRoute); app.route("/api/ads", adsRoute);
app.route("/api/alert", alertRoute); app.route("/api/alert", alertRoute);
export default { export default {
port: Number(process.env.PORT) || 3000, port: Number(process.env.PORT) || 3000,
fetch: app.fetch, fetch: app.fetch,
websocket, websocket,
}; };

View File

@@ -1,71 +1,71 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { redis } from "./redis"; import { redis } from "./redis";
/** /**
* Ad inventory access + impression counting. * Ad inventory access + impression counting.
* *
* Active, non-expired ads are served by kind ("band" | "casino"). Impressions * Active, non-expired ads are served by kind ("band" | "casino"). Impressions
* are counted cheaply in Redis (xip:ad:impressions:<id> + a global total) and * are counted cheaply in Redis (xip:ad:impressions:<id> + a global total) and
* periodically reconciled into Ad.impressions for durability. * periodically reconciled into Ad.impressions for durability.
*/ */
const IMP_PREFIX = "xip:ad:impressions:"; const IMP_PREFIX = "xip:ad:impressions:";
const IMP_TOTAL = "xip:money:impressions_total"; const IMP_TOTAL = "xip:money:impressions_total";
export async function listActiveAds(kind: "band" | "casino") { export async function listActiveAds(kind: "band" | "casino") {
const now = new Date(); const now = new Date();
const ads = await prisma.ad.findMany({ const ads = await prisma.ad.findMany({
where: { kind, active: true }, where: { kind, active: true },
orderBy: { createdAt: "asc" }, orderBy: { createdAt: "asc" },
}); });
return ads.filter((a) => !a.expiresAt || a.expiresAt >= now); return ads.filter((a) => !a.expiresAt || a.expiresAt >= now);
} }
/** Record N impressions for a set of ad ids (best-effort, Redis only). */ /** Record N impressions for a set of ad ids (best-effort, Redis only). */
export async function recordImpressions(ids: string[]): Promise<void> { export async function recordImpressions(ids: string[]): Promise<void> {
if (!ids.length) return; if (!ids.length) return;
const pipe = redis.pipeline(); const pipe = redis.pipeline();
for (const id of ids) pipe.incr(IMP_PREFIX + id); for (const id of ids) pipe.incr(IMP_PREFIX + id);
pipe.incrby(IMP_TOTAL, ids.length); pipe.incrby(IMP_TOTAL, ids.length);
await pipe.exec().catch(() => {}); await pipe.exec().catch(() => {});
} }
/** Total impressions across all ads (for the money counter). */ /** Total impressions across all ads (for the money counter). */
export async function getImpressionTotal(): Promise<number> { export async function getImpressionTotal(): Promise<number> {
const v = await redis.get(IMP_TOTAL).catch(() => "0"); const v = await redis.get(IMP_TOTAL).catch(() => "0");
return Number(v ?? 0); return Number(v ?? 0);
} }
/** /**
* Periodically fold the Redis per-ad impression counters into the DB so the * Periodically fold the Redis per-ad impression counters into the DB so the
* Ad.impressions column stays roughly current (and survives a Redis flush). * Ad.impressions column stays roughly current (and survives a Redis flush).
*/ */
export async function reconcileImpressions(): Promise<void> { export async function reconcileImpressions(): Promise<void> {
try { try {
const ads = await prisma.ad.findMany({ select: { id: true } }); const ads = await prisma.ad.findMany({ select: { id: true } });
for (const { id } of ads) { for (const { id } of ads) {
const key = IMP_PREFIX + id; const key = IMP_PREFIX + id;
const v = await redis.get(key).catch(() => null); const v = await redis.get(key).catch(() => null);
const n = Number(v ?? 0); const n = Number(v ?? 0);
if (n > 0) { if (n > 0) {
await prisma.ad.update({ where: { id }, data: { impressions: n } }).catch(() => {}); await prisma.ad.update({ where: { id }, data: { impressions: n } }).catch(() => {});
} }
} }
} catch { } catch {
/* best-effort */ /* best-effort */
} }
} }
/** Backfill the Redis impression total from the DB on first boot. */ /** Backfill the Redis impression total from the DB on first boot. */
export async function initImpressionTotal(): Promise<void> { export async function initImpressionTotal(): Promise<void> {
const exists = await redis.exists(IMP_TOTAL).catch(() => 0); const exists = await redis.exists(IMP_TOTAL).catch(() => 0);
if (exists) return; if (exists) return;
const agg = await prisma.ad.aggregate({ _sum: { impressions: true } }).catch(() => null); const agg = await prisma.ad.aggregate({ _sum: { impressions: true } }).catch(() => null);
const sum = agg?._sum.impressions ?? 0; const sum = agg?._sum.impressions ?? 0;
if (sum > 0) await redis.set(IMP_TOTAL, String(sum)).catch(() => {}); if (sum > 0) await redis.set(IMP_TOTAL, String(sum)).catch(() => {});
// Also seed per-ad keys so reconcile doesn't clobber DB values with 0. // Also seed per-ad keys so reconcile doesn't clobber DB values with 0.
const ads = await prisma.ad.findMany({ select: { id: true, impressions: true } }).catch(() => []); const ads = await prisma.ad.findMany({ select: { id: true, impressions: true } }).catch(() => []);
for (const a of ads) { for (const a of ads) {
if (a.impressions > 0) await redis.set(IMP_PREFIX + a.id, String(a.impressions)).catch(() => {}); if (a.impressions > 0) await redis.set(IMP_PREFIX + a.id, String(a.impressions)).catch(() => {});
} }
} }

View File

@@ -1,314 +1,314 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { spend, getWallet, InsufficientCreditsError } from "./wallet"; import { spend, getWallet, InsufficientCreditsError } from "./wallet";
import { isFree } from "./ip"; import { isFree } from "./ip";
import { invalidatePerks, getPerksForIp } from "./perks"; import { invalidatePerks, getPerksForIp } from "./perks";
/** /**
* Marketplace catalogue + purchase engine. * Marketplace catalogue + purchase engine.
* *
* Prices are centi-credits (mockup € → credits). The server is the ONLY * Prices are centi-credits (mockup € → credits). The server is the ONLY
* authority on price, stock, and per-IP limits — the client never decides. * authority on price, stock, and per-IP limits — the client never decides.
*/ */
export interface PurchaseOptions { export interface PurchaseOptions {
plan?: "monthly" | "annual"; // subscription plan?: "monthly" | "annual"; // subscription
durationDays?: number; // ad-frame durationDays?: number; // ad-frame
format?: "static" | "gif"; // ad-frame format?: "static" | "gif"; // ad-frame
url?: string; // ad-frame destination url?: string; // ad-frame destination
petDesign?: string; // pet slug petDesign?: string; // pet slug
petChar?: string; // pet glyph petChar?: string; // pet glyph
petPosition?: "left" | "right" | "both"; petPosition?: "left" | "right" | "both";
} }
export interface PurchaseResult { export interface PurchaseResult {
ok: true; ok: true;
productId: string; productId: string;
pricePaid: number; pricePaid: number;
balance: number; balance: number;
entitlementKinds: string[]; entitlementKinds: string[];
} }
export class PurchaseError extends Error { export class PurchaseError extends Error {
status: number; status: number;
constructor(message: string, status = 400) { constructor(message: string, status = 400) {
super(message); super(message);
this.name = "PurchaseError"; this.name = "PurchaseError";
this.status = status; this.status = status;
} }
} }
const DAY_MS = 24 * 60 * 60 * 1000; const DAY_MS = 24 * 60 * 60 * 1000;
/** Effective unit price for a product given options (promo + add-ons). */ /** Effective unit price for a product given options (promo + add-ons). */
function effectivePrice(product: any, options: PurchaseOptions): number { function effectivePrice(product: any, options: PurchaseOptions): number {
let price = product.promoPrice ?? product.basePrice; let price = product.promoPrice ?? product.basePrice;
let meta: any = {}; let meta: any = {};
try { try {
meta = product.metaJson ? JSON.parse(product.metaJson) : {}; meta = product.metaJson ? JSON.parse(product.metaJson) : {};
} catch { } catch {
meta = {}; meta = {};
} }
if (product.kind === "subscription") { if (product.kind === "subscription") {
const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly")); const plan = (meta.plans ?? []).find((p: any) => p.id === (options.plan ?? "monthly"));
if (plan) price = plan.price; if (plan) price = plan.price;
} }
if (product.kind === "ad-frame") { if (product.kind === "ad-frame") {
const dur = (meta.durations ?? []).find( const dur = (meta.durations ?? []).find(
(d: any) => d.days === (options.durationDays ?? 7) (d: any) => d.days === (options.durationDays ?? 7)
); );
const fmt = (meta.formats ?? []).find( const fmt = (meta.formats ?? []).find(
(f: any) => f.id === (options.format ?? "static") (f: any) => f.id === (options.format ?? "static")
); );
price += (dur?.extra ?? 0) + (fmt?.extra ?? 0); price += (dur?.extra ?? 0) + (fmt?.extra ?? 0);
} }
return price; return price;
} }
export async function listProducts(category?: string) { export async function listProducts(category?: string) {
return prisma.product.findMany({ return prisma.product.findMany({
where: { active: true, ...(category ? { category } : {}) }, where: { active: true, ...(category ? { category } : {}) },
orderBy: [{ sortOrder: "asc" }, { name: "asc" }], orderBy: [{ sortOrder: "asc" }, { name: "asc" }],
}); });
} }
export function getProduct(id: string) { export function getProduct(id: string) {
return prisma.product.findUnique({ where: { id } }); return prisma.product.findUnique({ where: { id } });
} }
/** All entitlements an IP owns (active), for "Mes achats". */ /** All entitlements an IP owns (active), for "Mes achats". */
export async function getEntitlements(ip: string) { export async function getEntitlements(ip: string) {
const now = new Date(); const now = new Date();
const rows = await prisma.entitlement.findMany({ const rows = await prisma.entitlement.findMany({
where: { ip, active: true }, where: { ip, active: true },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
}); });
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now); return rows.filter((e) => !e.expiresAt || e.expiresAt >= now);
} }
async function countActiveEntitlements(ip: string, kind: string): Promise<number> { async function countActiveEntitlements(ip: string, kind: string): Promise<number> {
const now = new Date(); const now = new Date();
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length; return rows.filter((e) => !e.expiresAt || e.expiresAt >= now).length;
} }
/** /**
* Buy a product. Enforces per-IP limits + stock, spends credits atomically, * Buy a product. Enforces per-IP limits + stock, spends credits atomically,
* grants the entitlement(s). Returns the new balance and granted kinds. * grants the entitlement(s). Returns the new balance and granted kinds.
* Side-effect: caller should bust perks cache + broadcast (done in the route). * Side-effect: caller should bust perks cache + broadcast (done in the route).
*/ */
export async function purchase( export async function purchase(
ip: string, ip: string,
productId: string, productId: string,
options: PurchaseOptions = {} options: PurchaseOptions = {}
): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> { ): Promise<{ result: PurchaseResult; visiblePerkChanged: boolean; adCreated: boolean }> {
const product = await getProduct(productId); const product = await getProduct(productId);
if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404); if (!product || !product.active) throw new PurchaseError("Produit introuvable", 404);
const free = isFree(ip); const free = isFree(ip);
const price = effectivePrice(product, options); const price = effectivePrice(product, options);
// Resolve which entitlement kind(s) this grants + per-IP limit checks. // Resolve which entitlement kind(s) this grants + per-IP limit checks.
const grants: { kind: string; expiresAt?: Date; meta?: any }[] = []; const grants: { kind: string; expiresAt?: Date; meta?: any }[] = [];
let visiblePerkChanged = false; let visiblePerkChanged = false;
let adCreated = false; let adCreated = false;
switch (product.kind) { switch (product.kind) {
case "subscription": { case "subscription": {
// NoAds: 1 active max. // NoAds: 1 active max.
if ((await countActiveEntitlements(ip, "noads")) >= 1) if ((await countActiveEntitlements(ip, "noads")) >= 1)
throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409); throw new PurchaseError("Tu as déjà un abonnement NoAds actif", 409);
const plan = options.plan ?? "monthly"; const plan = options.plan ?? "monthly";
const days = plan === "annual" ? 365 : 30; const days = plan === "annual" ? 365 : 30;
grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } }); grants.push({ kind: "noads", expiresAt: new Date(Date.now() + days * DAY_MS), meta: { plan } });
break; break;
} }
case "ip-skin": { case "ip-skin": {
// Style Doré: 1 active max + global stock cap. // Style Doré: 1 active max + global stock cap.
if ((await countActiveEntitlements(ip, "style-dore")) >= 1) if ((await countActiveEntitlements(ip, "style-dore")) >= 1)
throw new PurchaseError("Tu possèdes déjà le Style Doré", 409); throw new PurchaseError("Tu possèdes déjà le Style Doré", 409);
grants.push({ kind: "style-dore", meta: { variant: "gold" } }); grants.push({ kind: "style-dore", meta: { variant: "gold" } });
visiblePerkChanged = true; visiblePerkChanged = true;
break; break;
} }
case "pet": { case "pet": {
const char = options.petChar ?? "♥"; const char = options.petChar ?? "♥";
grants.push({ grants.push({
kind: "pet", kind: "pet",
meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" }, meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" },
}); });
visiblePerkChanged = true; visiblePerkChanged = true;
break; break;
} }
case "ad-frame": { case "ad-frame": {
if ((await countActiveEntitlements(ip, "ad-frame")) >= 1) if ((await countActiveEntitlements(ip, "ad-frame")) >= 1)
throw new PurchaseError("Tu as déjà un cadre de pub actif", 409); throw new PurchaseError("Tu as déjà un cadre de pub actif", 409);
const days = options.durationDays ?? 7; const days = options.durationDays ?? 7;
grants.push({ grants.push({
kind: "ad-frame", kind: "ad-frame",
expiresAt: new Date(Date.now() + days * DAY_MS), expiresAt: new Date(Date.now() + days * DAY_MS),
meta: { format: options.format ?? "static", url: options.url ?? "", days }, meta: { format: options.format ?? "static", url: options.url ?? "", days },
}); });
break; break;
} }
case "rich": { case "rich": {
const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss"; const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss";
if ((await countActiveEntitlements(ip, kind)) >= 1) if ((await countActiveEntitlements(ip, kind)) >= 1)
throw new PurchaseError("Déjà débloqué", 409); throw new PurchaseError("Déjà débloqué", 409);
grants.push({ kind }); grants.push({ kind });
break; break;
} }
case "unlock": { case "unlock": {
// no-file-limit, element-skin, etc. — slug == kind. // no-file-limit, element-skin, etc. — slug == kind.
if ((await countActiveEntitlements(ip, product.id)) >= 1) if ((await countActiveEntitlements(ip, product.id)) >= 1)
throw new PurchaseError("Déjà débloqué", 409); throw new PurchaseError("Déjà débloqué", 409);
grants.push({ kind: product.id }); grants.push({ kind: product.id });
if (product.id === "element-skin" || product.id === "ip-colors") visiblePerkChanged = false; // viewer-scoped if (product.id === "element-skin" || product.id === "ip-colors") visiblePerkChanged = false; // viewer-scoped
break; break;
} }
case "consumable": { case "consumable": {
// audio-alert: grant the entitlement once; firing is a separate action. // audio-alert: grant the entitlement once; firing is a separate action.
if ((await countActiveEntitlements(ip, "audio-alert")) < 1) { if ((await countActiveEntitlements(ip, "audio-alert")) < 1) {
grants.push({ kind: "audio-alert" }); grants.push({ kind: "audio-alert" });
} else { } else {
// Already owned — buying again is a harmless top-up; just record it. // Already owned — buying again is a harmless top-up; just record it.
grants.push({ kind: "audio-alert" }); grants.push({ kind: "audio-alert" });
} }
break; break;
} }
case "send-skin": { case "send-skin": {
if ((await countActiveEntitlements(ip, product.id)) >= 1) if ((await countActiveEntitlements(ip, product.id)) >= 1)
throw new PurchaseError("Déjà débloqué", 409); throw new PurchaseError("Déjà débloqué", 409);
let skinMeta: any = {}; let skinMeta: any = {};
try { skinMeta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch {} try { skinMeta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch {}
grants.push({ kind: product.id, meta: skinMeta }); grants.push({ kind: product.id, meta: skinMeta });
break; break;
} }
case "bundle": { case "bundle": {
// Cosmetic bundle: Style Doré + 1 pet. // Cosmetic bundle: Style Doré + 1 pet.
if ((await countActiveEntitlements(ip, "style-dore")) < 1) if ((await countActiveEntitlements(ip, "style-dore")) < 1)
grants.push({ kind: "style-dore", meta: { variant: "gold" } }); grants.push({ kind: "style-dore", meta: { variant: "gold" } });
if ((await countActiveEntitlements(ip, "pet")) < 3) { if ((await countActiveEntitlements(ip, "pet")) < 3) {
const char = options.petChar ?? "★"; const char = options.petChar ?? "★";
grants.push({ grants.push({
kind: "pet", kind: "pet",
meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" }, meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" },
}); });
} }
if (grants.length === 0) if (grants.length === 0)
throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409); throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409);
visiblePerkChanged = true; visiblePerkChanged = true;
break; break;
} }
default: default:
throw new PurchaseError("Type de produit non géré", 400); throw new PurchaseError("Type de produit non géré", 400);
} }
// Stock check for limited products (Style Doré). Done transactionally with the // Stock check for limited products (Style Doré). Done transactionally with the
// spend so we can never oversell the 50-unit cap under concurrency. // spend so we can never oversell the 50-unit cap under concurrency.
let balance = 0; let balance = 0;
try { try {
balance = await prisma.$transaction(async (tx) => { balance = await prisma.$transaction(async (tx) => {
if (product.stockLimit != null) { if (product.stockLimit != null) {
const fresh = await tx.product.findUnique({ where: { id: product.id } }); const fresh = await tx.product.findUnique({ where: { id: product.id } });
if (!fresh) throw new PurchaseError("Produit introuvable", 404); if (!fresh) throw new PurchaseError("Produit introuvable", 404);
if (fresh.stockSold >= fresh.stockLimit) if (fresh.stockLimit != null && fresh.stockSold >= fresh.stockLimit)
throw new PurchaseError("Stock épuisé", 409); throw new PurchaseError("Stock épuisé", 409);
await tx.product.update({ await tx.product.update({
where: { id: product.id }, where: { id: product.id },
data: { stockSold: { increment: 1 } }, data: { stockSold: { increment: 1 } },
}); });
} }
// Spend (skips real deduction for localhost free mode). // Spend (skips real deduction for localhost free mode).
if (!free && price > 0) { if (!free && price > 0) {
const w = await tx.wallet.upsert({ const w = await tx.wallet.upsert({
where: { ip }, where: { ip },
create: { ip, balance: 0 }, create: { ip, balance: 0 },
update: {}, update: {},
}); });
if (w.balance < price) throw new InsufficientCreditsError(); if (w.balance < price) throw new InsufficientCreditsError();
const updated = await tx.wallet.update({ const updated = await tx.wallet.update({
where: { ip }, where: { ip },
data: { balance: { decrement: price } }, data: { balance: { decrement: price } },
}); });
await tx.purchase.create({ await tx.purchase.create({
data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) }, data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) },
}); });
// Grant entitlements inside the tx too. // Grant entitlements inside the tx too.
for (const g of grants) { for (const g of grants) {
await tx.entitlement.create({ await tx.entitlement.create({
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
}); });
} }
return updated.balance; return updated.balance;
} }
// Free (localhost) or zero-price: record purchase + grants, no deduction. // Free (localhost) or zero-price: record purchase + grants, no deduction.
await tx.purchase.create({ await tx.purchase.create({
data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) }, data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) },
}); });
for (const g of grants) { for (const g of grants) {
await tx.entitlement.create({ await tx.entitlement.create({
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
}); });
} }
const w = await tx.wallet.findUnique({ where: { ip } }); const w = await tx.wallet.findUnique({ where: { ip } });
return w?.balance ?? 0; return w?.balance ?? 0;
}); });
} catch (e) { } catch (e) {
if (e instanceof InsufficientCreditsError) if (e instanceof InsufficientCreditsError)
throw new PurchaseError("Crédits insuffisants", 402); throw new PurchaseError("Crédits insuffisants", 402);
throw e; throw e;
} }
// Bump the global credits-spent money counter (outside the tx; best-effort). // Bump the global credits-spent money counter (outside the tx; best-effort).
if (!free && price > 0) { if (!free && price > 0) {
const { redis } = await import("./redis"); const { redis } = await import("./redis");
void redis.incrby("xip:money:credits_spent", price).catch(() => {}); void redis.incrby("xip:money:credits_spent", price).catch(() => {});
} }
// Ad-frame purchase => create a real Ad row that enters rotation (Phase 7). // Ad-frame purchase => create a real Ad row that enters rotation (Phase 7).
const adGrant = grants.find((g) => g.kind === "ad-frame"); const adGrant = grants.find((g) => g.kind === "ad-frame");
if (adGrant) { if (adGrant) {
await prisma.ad await prisma.ad
.create({ .create({
data: { data: {
brand: "VOTRE PUB", brand: "VOTRE PUB",
subtitle: "Espace acheté", subtitle: "Espace acheté",
url: adGrant.meta?.url || null, url: adGrant.meta?.url || null,
cta: "VOIR", cta: "VOIR",
icon: "📣", icon: "📣",
tone: "user", tone: "user",
kind: "band", kind: "band",
weight: 3, weight: 3,
active: true, active: true,
ownerIp: ip, ownerIp: ip,
format: adGrant.meta?.format ?? "static", format: adGrant.meta?.format ?? "static",
expiresAt: adGrant.expiresAt ?? null, expiresAt: adGrant.expiresAt ?? null,
}, },
}) })
.catch(() => {}); .catch(() => {});
adCreated = true; adCreated = true;
} }
const balanceView = free ? (await getWallet(ip)).balance : balance; const balanceView = free ? (await getWallet(ip)).balance : balance;
return { return {
result: { result: {
ok: true, ok: true,
productId: product.id, productId: product.id,
pricePaid: free ? 0 : price, pricePaid: free ? 0 : price,
balance: balanceView, balance: balanceView,
entitlementKinds: grants.map((g) => g.kind), entitlementKinds: grants.map((g) => g.kind),
}, },
visiblePerkChanged, visiblePerkChanged,
adCreated, adCreated,
}; };
} }
/** Recompute + cache perks after a purchase (caller broadcasts). */ /** Recompute + cache perks after a purchase (caller broadcasts). */
export async function refreshPerks(ip: string) { export async function refreshPerks(ip: string) {
await invalidatePerks(ip); await invalidatePerks(ip);
return getPerksForIp(ip); return getPerksForIp(ip);
} }

View File

@@ -1,95 +1,95 @@
/** /**
* Best-effort IP geolocation using ip-api.com (free, no key required). * Best-effort IP geolocation using ip-api.com (free, no key required).
* Results are cached in Redis for 24 h so repeated lookups don't burn the * Results are cached in Redis for 24 h so repeated lookups don't burn the
* rate-limit (45 req/min on the free tier). * rate-limit (45 req/min on the free tier).
* *
* Private / loopback addresses always resolve to "Local" without a network call. * Private / loopback addresses always resolve to "Local" without a network call.
*/ */
import { redis } from "./redis"; import { redis } from "./redis";
import { isLocalhost } from "./ip"; import { isLocalhost } from "./ip";
export interface GeoInfo { export interface GeoInfo {
country: string; country: string;
countryCode: string; // ISO 3166-1 alpha-2, or "" for local countryCode: string; // ISO 3166-1 alpha-2, or "" for local
city: string; city: string;
lat?: number; lat?: number;
lon?: number; lon?: number;
} }
const GEO_TTL = 60 * 60 * 24; // 24 h const GEO_TTL = 60 * 60 * 24; // 24 h
const geoKey = (ip: string) => `xip:geo:v2:${ip}`; const geoKey = (ip: string) => `xip:geo:v2:${ip}`;
function isPrivate(ip: string): boolean { function isPrivate(ip: string): boolean {
if (isLocalhost(ip)) return true; if (isLocalhost(ip)) return true;
// RFC-1918 private ranges and link-local // RFC-1918 private ranges and link-local
return ( return (
ip.startsWith("10.") || ip.startsWith("10.") ||
ip.startsWith("192.168.") || ip.startsWith("192.168.") ||
/^172\.(1[6-9]|2\d|3[01])\./.test(ip) || /^172\.(1[6-9]|2\d|3[01])\./.test(ip) ||
ip.startsWith("169.254.") || ip.startsWith("169.254.") ||
ip.startsWith("fc") || ip.startsWith("fc") ||
ip.startsWith("fd") ip.startsWith("fd")
); );
} }
/** Resolve geo for a batch of IPs. Uses the ip-api.com /batch endpoint. /** Resolve geo for a batch of IPs. Uses the ip-api.com /batch endpoint.
* Private IPs are resolved locally; real IPs are fetched and cached. */ * Private IPs are resolved locally; real IPs are fetched and cached. */
export async function getGeoForIps(ips: string[]): Promise<Record<string, GeoInfo | null>> { export async function getGeoForIps(ips: string[]): Promise<Record<string, GeoInfo | null>> {
const result: Record<string, GeoInfo | null> = {}; const result: Record<string, GeoInfo | null> = {};
const toFetch: string[] = []; const toFetch: string[] = [];
for (const ip of ips) { for (const ip of ips) {
if (isPrivate(ip)) { if (isPrivate(ip)) {
result[ip] = { country: "Local", countryCode: "", city: "" }; result[ip] = { country: "Local", countryCode: "", city: "" };
continue; continue;
} }
const cached = await redis.get(geoKey(ip)).catch(() => null); const cached = await redis.get(geoKey(ip)).catch(() => null);
if (cached) { if (cached) {
try { try {
result[ip] = JSON.parse(cached) as GeoInfo; result[ip] = JSON.parse(cached) as GeoInfo;
continue; continue;
} catch { /* fall through */ } } catch { /* fall through */ }
} }
toFetch.push(ip); toFetch.push(ip);
} }
if (toFetch.length === 0) return result; if (toFetch.length === 0) return result;
// Batch lookup via ip-api.com // Batch lookup via ip-api.com
try { try {
const res = await fetch("http://ip-api.com/batch?fields=status,query,country,countryCode,city,lat,lon", { const res = await fetch("http://ip-api.com/batch?fields=status,query,country,countryCode,city,lat,lon", {
method: "POST", method: "POST",
headers: { "Content-Type": "application/json" }, headers: { "Content-Type": "application/json" },
body: JSON.stringify(toFetch.map((ip) => ({ query: ip }))), body: JSON.stringify(toFetch.map((ip) => ({ query: ip }))),
signal: AbortSignal.timeout(5000), signal: AbortSignal.timeout(5000),
}); });
if (res.ok) { if (res.ok) {
const list = (await res.json()) as any[]; const list = (await res.json()) as any[];
for (const item of list) { for (const item of list) {
if (item.status === "success") { if (item.status === "success") {
const info: GeoInfo = { const info: GeoInfo = {
country: item.country ?? "", country: item.country ?? "",
countryCode: item.countryCode ?? "", countryCode: item.countryCode ?? "",
city: item.city ?? "", city: item.city ?? "",
lat: item.lat, lat: item.lat,
lon: item.lon, lon: item.lon,
}; };
result[item.query] = info; result[item.query] = info;
await redis.set(geoKey(item.query), JSON.stringify(info), "EX", GEO_TTL).catch(() => {}); await redis.set(geoKey(item.query), JSON.stringify(info), "EX", GEO_TTL).catch(() => {});
} else { } else {
result[item.query] = null; result[item.query] = null;
} }
} }
} }
} catch { } catch {
for (const ip of toFetch) if (!(ip in result)) result[ip] = null; for (const ip of toFetch) if (!(ip in result)) result[ip] = null;
} }
return result; return result;
} }
/** Single-IP variant used by the POST /messages broadcast path. */ /** Single-IP variant used by the POST /messages broadcast path. */
export async function getGeoForIp(ip: string): Promise<GeoInfo | null> { export async function getGeoForIp(ip: string): Promise<GeoInfo | null> {
const batch = await getGeoForIps([ip]); const batch = await getGeoForIps([ip]);
return batch[ip] ?? null; return batch[ip] ?? null;
} }

View File

@@ -1,48 +1,48 @@
import type { Context } from "hono"; import type { Context } from "hono";
import { getConnInfo } from "hono/bun"; import { getConnInfo } from "hono/bun";
/** /**
* Best-effort client IP. * Best-effort client IP.
* Prefer x-forwarded-for (set when behind a proxy), fall back to the raw socket * Prefer x-forwarded-for (set when behind a proxy), fall back to the raw socket
* address from Bun. In local dev (frontend:5173 → backend:3000, no proxy) this * address from Bun. In local dev (frontend:5173 → backend:3000, no proxy) this
* is typically 127.0.0.1 / ::1. * is typically 127.0.0.1 / ::1.
*/ */
export function getClientIp(c: Context): string { export function getClientIp(c: Context): string {
const fwd = c.req.header("x-forwarded-for"); const fwd = c.req.header("x-forwarded-for");
if (fwd) { if (fwd) {
const first = fwd.split(",")[0]?.trim(); const first = fwd.split(",")[0]?.trim();
if (first) return first; if (first) return first;
} }
try { try {
const addr = getConnInfo(c).remote.address; const addr = getConnInfo(c).remote.address;
if (addr) return addr; if (addr) return addr;
} catch { } catch {
/* getConnInfo only works under the Bun adapter */ /* getConnInfo only works under the Bun adapter */
} }
return "127.0.0.1"; return "127.0.0.1";
} }
/** /**
* Is this IP the local machine? Drives the README rule "si localhost: pas de * Is this IP the local machine? Drives the README rule "si localhost: pas de
* paywall (tout gratuit)". Covers IPv4 loopback, IPv6 loopback, and the * paywall (tout gratuit)". Covers IPv4 loopback, IPv6 loopback, and the
* IPv4-mapped-IPv6 form Bun sometimes reports. * IPv4-mapped-IPv6 form Bun sometimes reports.
*/ */
export function isLocalhost(ip: string): boolean { export function isLocalhost(ip: string): boolean {
return ( return (
ip === "127.0.0.1" || ip === "127.0.0.1" ||
ip === "::1" || ip === "::1" ||
ip === "::ffff:127.0.0.1" || ip === "::ffff:127.0.0.1" ||
ip === "localhost" || ip === "localhost" ||
ip.startsWith("127.") ip.startsWith("127.")
); );
} }
/** /**
* Free mode: the paywall is OFF. True on localhost (README rule "si localhost: * Free mode: the paywall is OFF. True on localhost (README rule "si localhost:
* pas de paywall"), OR whenever the deployment sets XIP_OPEN_BAR=true — the prod * pas de paywall"), OR whenever the deployment sets XIP_OPEN_BAR=true — the prod
* "open bar" where every paid feature is free for everyone. Every paywall gate * "open bar" where every paid feature is free for everyone. Every paywall gate
* in the app routes through this single helper. * in the app routes through this single helper.
*/ */
export function isFree(ip: string): boolean { export function isFree(ip: string): boolean {
return process.env.XIP_OPEN_BAR === "true" || isLocalhost(ip); return process.env.XIP_OPEN_BAR === "true" || isLocalhost(ip);
} }

View File

@@ -1,130 +1,130 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { redis } from "./redis"; import { redis } from "./redis";
/** /**
* Perks = the visible/functional consequences of an IP's active entitlements. * Perks = the visible/functional consequences of an IP's active entitlements.
* *
* - skin: 'gold' (Style Doré — everyone sees it) * - skin: 'gold' (Style Doré — everyone sees it)
* - pets: [{char, position}] (Pets de Nom — everyone sees them) * - pets: [{char, position}] (Pets de Nom — everyone sees them)
* - noads: true (NoAds subscription — viewer-scoped) * - noads: true (NoAds subscription — viewer-scoped)
* - badge: true (annual NoAds — exclusive badge) * - badge: true (annual NoAds — exclusive badge)
* - elementSkin: true (one cosmetic element variant — viewer-scoped) * - elementSkin: true (one cosmetic element variant — viewer-scoped)
* - richHtmlcss / richJs / noFileLimit (unlocks the composer / upload gate) * - richHtmlcss / richJs / noFileLimit (unlocks the composer / upload gate)
* *
* Cached in Redis (short TTL) and busted on purchase so the feed updates live. * Cached in Redis (short TTL) and busted on purchase so the feed updates live.
*/ */
export type PetPosition = "left" | "right" | "both"; export type PetPosition = "left" | "right" | "both";
export interface Perks { export interface Perks {
skin?: "gold"; skin?: "gold";
pets?: { char: string; position: PetPosition }[]; pets?: { char: string; position: PetPosition }[];
noads?: boolean; noads?: boolean;
badge?: boolean; badge?: boolean;
elementSkin?: boolean; elementSkin?: boolean;
richHtmlcss?: boolean; richHtmlcss?: boolean;
richJs?: boolean; richJs?: boolean;
noFileLimit?: boolean; noFileLimit?: boolean;
ipColors?: boolean; ipColors?: boolean;
audioAlert?: boolean; audioAlert?: boolean;
sendSkins?: { id: string; char: string; label?: string }[]; sendSkins?: { id: string; char: string; label?: string }[];
} }
const perksKey = (ip: string) => `xip:perks:${ip}`; const perksKey = (ip: string) => `xip:perks:${ip}`;
const TTL_SEC = 60; const TTL_SEC = 60;
/** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */ /** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */
export async function invalidatePerks(ip: string): Promise<void> { export async function invalidatePerks(ip: string): Promise<void> {
await redis.del(perksKey(ip)).catch(() => {}); await redis.del(perksKey(ip)).catch(() => {});
} }
/** Compute perks for one IP from its active, non-expired entitlements. */ /** Compute perks for one IP from its active, non-expired entitlements. */
export async function getPerksForIp(ip: string): Promise<Perks> { export async function getPerksForIp(ip: string): Promise<Perks> {
// Fast path: cache. // Fast path: cache.
const cached = await redis.get(perksKey(ip)).catch(() => null); const cached = await redis.get(perksKey(ip)).catch(() => null);
if (cached) { if (cached) {
try { try {
return JSON.parse(cached) as Perks; return JSON.parse(cached) as Perks;
} catch { } catch {
/* fall through to recompute */ /* fall through to recompute */
} }
} }
const now = new Date(); const now = new Date();
const rows = await prisma.entitlement const rows = await prisma.entitlement
.findMany({ where: { ip, active: true } }) .findMany({ where: { ip, active: true } })
.catch(() => []); .catch(() => []);
const perks: Perks = {}; const perks: Perks = {};
const pets: { char: string; position: PetPosition }[] = []; const pets: { char: string; position: PetPosition }[] = [];
for (const e of rows) { for (const e of rows) {
// Skip expired (subscriptions / ad-frames). // Skip expired (subscriptions / ad-frames).
if (e.expiresAt && e.expiresAt < now) continue; if (e.expiresAt && e.expiresAt < now) continue;
let meta: any = {}; let meta: any = {};
try { try {
meta = e.metaJson ? JSON.parse(e.metaJson) : {}; meta = e.metaJson ? JSON.parse(e.metaJson) : {};
} catch { } catch {
meta = {}; meta = {};
} }
switch (e.kind) { switch (e.kind) {
case "style-dore": case "style-dore":
perks.skin = "gold"; perks.skin = "gold";
break; break;
case "pet": case "pet":
if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" }); if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" });
break; break;
case "noads": case "noads":
perks.noads = true; perks.noads = true;
if (meta.plan === "annual") perks.badge = true; if (meta.plan === "annual") perks.badge = true;
break; break;
case "element-skin": case "element-skin":
perks.elementSkin = true; perks.elementSkin = true;
break; break;
case "rich-htmlcss": case "rich-htmlcss":
perks.richHtmlcss = true; perks.richHtmlcss = true;
break; break;
case "rich-js": case "rich-js":
perks.richJs = true; perks.richJs = true;
break; break;
case "no-file-limit": case "no-file-limit":
perks.noFileLimit = true; perks.noFileLimit = true;
break; break;
case "ip-colors": case "ip-colors":
perks.ipColors = true; perks.ipColors = true;
break; break;
case "audio-alert": case "audio-alert":
perks.audioAlert = true; perks.audioAlert = true;
break; break;
} }
// Send-button skins use a prefixed kind (send-skin-rocket, …), so they // Send-button skins use a prefixed kind (send-skin-rocket, …), so they
// can't be matched by the switch above. // can't be matched by the switch above.
if (e.kind.startsWith("send-skin-")) { if (e.kind.startsWith("send-skin-")) {
(perks.sendSkins ??= []).push({ (perks.sendSkins ??= []).push({
id: e.kind, id: e.kind,
char: meta.char ?? "?", char: meta.char ?? "?",
label: meta.label, label: meta.label,
}); });
} }
} }
if (pets.length) perks.pets = pets.slice(0, 3); if (pets.length) perks.pets = pets.slice(0, 3);
await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {}); await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {});
return perks; return perks;
} }
/** Batch perks for several IPs (used to annotate message lists). */ /** Batch perks for several IPs (used to annotate message lists). */
export async function getPerksForIps( export async function getPerksForIps(
ips: string[] ips: string[]
): Promise<Record<string, Perks>> { ): Promise<Record<string, Perks>> {
const uniq = [...new Set(ips.filter(Boolean))]; const uniq = [...new Set(ips.filter(Boolean))];
const out: Record<string, Perks> = {}; const out: Record<string, Perks> = {};
await Promise.all( await Promise.all(
uniq.map(async (ip) => { uniq.map(async (ip) => {
out[ip] = await getPerksForIp(ip); out[ip] = await getPerksForIp(ip);
}) })
); );
return out; return out;
} }

View File

@@ -1,10 +1,10 @@
import { PrismaClient } from "@prisma/client"; import { PrismaClient } from "@prisma/client";
const globalForPrisma = globalThis as unknown as { prisma: PrismaClient }; const globalForPrisma = globalThis as unknown as { prisma: PrismaClient };
export const prisma = export const prisma =
globalForPrisma.prisma ?? new PrismaClient({ log: ["error", "warn"] }); globalForPrisma.prisma ?? new PrismaClient({ log: ["error", "warn"] });
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalForPrisma.prisma = prisma; globalForPrisma.prisma = prisma;
} }

View File

@@ -1,207 +1,207 @@
import { redis } from "./redis"; import { redis } from "./redis";
import { prisma } from "./prisma"; import { prisma } from "./prisma";
/** /**
* XIP live stats. * XIP live stats.
* *
* Two kinds of metrics: * Two kinds of metrics:
* - PERSISTENT totals, stored in Redis (survive restarts): messages, replies, * - PERSISTENT totals, stored in Redis (survive restarts): messages, replies,
* characters sent, letters typed (even if never sent), unique IPs, longest message. * characters sent, letters typed (even if never sent), unique IPs, longest message.
* - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min. * - LIVE metrics, kept in process memory (sliding windows): letters/sec, messages/min.
* *
* The number of connected tabs and the "currently typing" count are owned by the * The number of connected tabs and the "currently typing" count are owned by the
* realtime module and injected when building a snapshot. * realtime module and injected when building a snapshot.
*/ */
const K = { const K = {
messages: "xip:stat:messages", messages: "xip:stat:messages",
replies: "xip:stat:replies", replies: "xip:stat:replies",
charsSent: "xip:stat:chars_sent", charsSent: "xip:stat:chars_sent",
lettersTyped: "xip:stat:letters_typed", lettersTyped: "xip:stat:letters_typed",
longest: "xip:stat:longest", longest: "xip:stat:longest",
ips: "xip:hll:ips", ips: "xip:hll:ips",
initialized: "xip:stat:initialized", initialized: "xip:stat:initialized",
creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog) creditsSpent: "xip:money:credits_spent", // centi-credits spent (set by wallet/catalog)
impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads) impressionsTotal: "xip:money:impressions_total", // ad impressions (set by lib/ads)
} as const; } as const;
// Satirical CPM: "€" earned per 1000 ad impressions. // Satirical CPM: "€" earned per 1000 ad impressions.
const FAKE_CPM = 12.5; const FAKE_CPM = 12.5;
// ── Sliding-window live metrics (per process) ────────────────────────────── // ── Sliding-window live metrics (per process) ──────────────────────────────
const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec const LETTERS_WINDOW_MS = 4000; // smoothing window for letters/sec
const MSGS_WINDOW_MS = 60000; // messages per minute const MSGS_WINDOW_MS = 60000; // messages per minute
let letterEvents: { ts: number; n: number }[] = []; let letterEvents: { ts: number; n: number }[] = [];
let messageEvents: number[] = []; let messageEvents: number[] = [];
function prune(now: number): void { function prune(now: number): void {
letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS); letterEvents = letterEvents.filter((e) => now - e.ts <= LETTERS_WINDOW_MS);
messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS); messageEvents = messageEvents.filter((ts) => now - ts <= MSGS_WINDOW_MS);
} }
export function getLettersPerSec(): number { export function getLettersPerSec(): number {
const now = Date.now(); const now = Date.now();
prune(now); prune(now);
const total = letterEvents.reduce((sum, e) => sum + e.n, 0); const total = letterEvents.reduce((sum, e) => sum + e.n, 0);
return total / (LETTERS_WINDOW_MS / 1000); return total / (LETTERS_WINDOW_MS / 1000);
} }
export function getMsgsPerMin(): number { export function getMsgsPerMin(): number {
const now = Date.now(); const now = Date.now();
prune(now); prune(now);
return messageEvents.length; return messageEvents.length;
} }
// ── First-boot backfill ───────────────────────────────────────────────────── // ── First-boot backfill ─────────────────────────────────────────────────────
/** /**
* Seed the persistent counters from the database the first time the server runs * Seed the persistent counters from the database the first time the server runs
* (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts). * (guarded by a Redis sentinel, so it's a no-op on hot reloads / restarts).
* Without this, totals would show 0 while seeded messages are already visible. * Without this, totals would show 0 while seeded messages are already visible.
* letters_typed is intentionally NOT backfilled — it has no DB source. * letters_typed is intentionally NOT backfilled — it has no DB source.
*/ */
export async function initStats(): Promise<void> { export async function initStats(): Promise<void> {
const first = await redis.set(K.initialized, "1", "NX").catch(() => null); const first = await redis.set(K.initialized, "1", "NX").catch(() => null);
if (first !== "OK") return; // already initialized if (first !== "OK") return; // already initialized
try { try {
const rows = await prisma.$queryRaw< const rows = await prisma.$queryRaw<
{ messages: bigint; replies: bigint; chars: bigint; longest: bigint }[] { messages: bigint; replies: bigint; chars: bigint; longest: bigint }[]
>` >`
SELECT SELECT
COUNT(*) AS messages, COUNT(*) AS messages,
COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies, COUNT(*) FILTER (WHERE "parentId" IS NOT NULL) AS replies,
COALESCE(SUM(LENGTH(content)), 0) AS chars, COALESCE(SUM(LENGTH(content)), 0) AS chars,
COALESCE(MAX(LENGTH(content)), 0) AS longest COALESCE(MAX(LENGTH(content)), 0) AS longest
FROM messages FROM messages
`; `;
const r = rows[0]; const r = rows[0];
if (r) { if (r) {
const pipe = redis.pipeline(); const pipe = redis.pipeline();
pipe.set(K.messages, String(Number(r.messages))); pipe.set(K.messages, String(Number(r.messages)));
pipe.set(K.replies, String(Number(r.replies))); pipe.set(K.replies, String(Number(r.replies)));
pipe.set(K.charsSent, String(Number(r.chars))); pipe.set(K.charsSent, String(Number(r.chars)));
pipe.set(K.longest, String(Number(r.longest))); pipe.set(K.longest, String(Number(r.longest)));
await pipe.exec(); await pipe.exec();
} }
const ips = await prisma.message.findMany({ const ips = await prisma.message.findMany({
distinct: ["authorIp"], distinct: ["authorIp"],
select: { authorIp: true }, select: { authorIp: true },
}); });
if (ips.length > 0) { if (ips.length > 0) {
await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp)); await redis.pfadd(K.ips, ...ips.map((m) => m.authorIp));
} }
console.log("📊 Stats backfilled from database."); console.log("📊 Stats backfilled from database.");
} catch (err) { } catch (err) {
// Non-fatal: release the sentinel so a later boot can retry. // Non-fatal: release the sentinel so a later boot can retry.
await redis.del(K.initialized).catch(() => {}); await redis.del(K.initialized).catch(() => {});
console.warn("⚠️ Stats backfill failed:", (err as Error).message); console.warn("⚠️ Stats backfill failed:", (err as Error).message);
} }
} }
// ── Mutations ────────────────────────────────────────────────────────────── // ── Mutations ──────────────────────────────────────────────────────────────
/** Record a freshly created message (top-level or reply). */ /** Record a freshly created message (top-level or reply). */
export async function recordMessage( export async function recordMessage(
contentLength: number, contentLength: number,
isReply: boolean isReply: boolean
): Promise<void> { ): Promise<void> {
messageEvents.push(Date.now()); messageEvents.push(Date.now());
const pipe = redis.pipeline(); const pipe = redis.pipeline();
pipe.incr(K.messages); pipe.incr(K.messages);
pipe.incrby(K.charsSent, contentLength); pipe.incrby(K.charsSent, contentLength);
if (isReply) pipe.incr(K.replies); if (isReply) pipe.incr(K.replies);
// Track longest message (read-modify-write is fine; contention is negligible). // Track longest message (read-modify-write is fine; contention is negligible).
pipe.get(K.longest); pipe.get(K.longest);
const res = await pipe.exec().catch(() => null); const res = await pipe.exec().catch(() => null);
if (res) { if (res) {
const current = Number(res[res.length - 1]?.[1] ?? 0); const current = Number(res[res.length - 1]?.[1] ?? 0);
if (contentLength > current) { if (contentLength > current) {
await redis.set(K.longest, String(contentLength)).catch(() => {}); await redis.set(K.longest, String(contentLength)).catch(() => {});
} }
} }
} }
/** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */ /** Record letters typed (sent or not). Feeds both the persistent total and letters/sec. */
export async function recordLettersTyped(delta: number): Promise<void> { export async function recordLettersTyped(delta: number): Promise<void> {
if (!Number.isFinite(delta) || delta <= 0) return; if (!Number.isFinite(delta) || delta <= 0) return;
const n = Math.min(delta, 1000); // guard against bogus client payloads const n = Math.min(delta, 1000); // guard against bogus client payloads
letterEvents.push({ ts: Date.now(), n }); letterEvents.push({ ts: Date.now(), n });
await redis.incrby(K.lettersTyped, n).catch(() => {}); await redis.incrby(K.lettersTyped, n).catch(() => {});
} }
/** Register an IP in the HyperLogLog of unique visitors. */ /** Register an IP in the HyperLogLog of unique visitors. */
export async function recordIp(ip: string): Promise<void> { export async function recordIp(ip: string): Promise<void> {
if (!ip) return; if (!ip) return;
await redis.pfadd(K.ips, ip).catch(() => {}); await redis.pfadd(K.ips, ip).catch(() => {});
} }
// ── Snapshot ───────────────────────────────────────────────────────────── // ── Snapshot ─────────────────────────────────────────────────────────────
export interface StatsSnapshot { export interface StatsSnapshot {
// live // live
connectedTabs: number; connectedTabs: number;
typingNow: number; typingNow: number;
lettersPerSec: number; lettersPerSec: number;
msgsPerMin: number; msgsPerMin: number;
// totals // totals
messages: number; messages: number;
replies: number; replies: number;
charsSent: number; charsSent: number;
lettersTyped: number; lettersTyped: number;
uniqueIps: number; uniqueIps: number;
longestMsg: number; longestMsg: number;
// derived // derived
abandonRate: number; // % of typed letters that were never sent abandonRate: number; // % of typed letters that were never sent
avgLength: number; // average sent-message length avgLength: number; // average sent-message length
moneyExtorted: number; // fake "€": impressions×CPM + credits spent moneyExtorted: number; // fake "€": impressions×CPM + credits spent
} }
export async function buildSnapshot(live: { export async function buildSnapshot(live: {
connectedTabs: number; connectedTabs: number;
typingNow: number; typingNow: number;
}): Promise<StatsSnapshot> { }): Promise<StatsSnapshot> {
const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] = const [messages, replies, charsSent, lettersTyped, longest, uniqueIps, creditsSpent, impressions] =
await Promise.all([ await Promise.all([
redis.get(K.messages).catch(() => "0"), redis.get(K.messages).catch(() => "0"),
redis.get(K.replies).catch(() => "0"), redis.get(K.replies).catch(() => "0"),
redis.get(K.charsSent).catch(() => "0"), redis.get(K.charsSent).catch(() => "0"),
redis.get(K.lettersTyped).catch(() => "0"), redis.get(K.lettersTyped).catch(() => "0"),
redis.get(K.longest).catch(() => "0"), redis.get(K.longest).catch(() => "0"),
redis.pfcount(K.ips).catch(() => 0), redis.pfcount(K.ips).catch(() => 0),
redis.get(K.creditsSpent).catch(() => "0"), redis.get(K.creditsSpent).catch(() => "0"),
redis.get(K.impressionsTotal).catch(() => "0"), redis.get(K.impressionsTotal).catch(() => "0"),
]); ]);
const nMessages = Number(messages ?? 0); const nMessages = Number(messages ?? 0);
const nCharsSent = Number(charsSent ?? 0); const nCharsSent = Number(charsSent ?? 0);
const nLettersTyped = Number(lettersTyped ?? 0); const nLettersTyped = Number(lettersTyped ?? 0);
const abandonRate = const abandonRate =
nLettersTyped > 0 nLettersTyped > 0
? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100)) ? Math.max(0, Math.min(100, ((nLettersTyped - nCharsSent) / nLettersTyped) * 100))
: 0; : 0;
const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0; const avgLength = nMessages > 0 ? nCharsSent / nMessages : 0;
// Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€"). // Fake revenue: ad impressions × CPM + credits spent (centi-credits → "€").
const moneyExtorted = const moneyExtorted =
(Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100; (Number(impressions ?? 0) / 1000) * FAKE_CPM + Number(creditsSpent ?? 0) / 100;
return { return {
connectedTabs: live.connectedTabs, connectedTabs: live.connectedTabs,
typingNow: live.typingNow, typingNow: live.typingNow,
lettersPerSec: getLettersPerSec(), lettersPerSec: getLettersPerSec(),
msgsPerMin: getMsgsPerMin(), msgsPerMin: getMsgsPerMin(),
messages: nMessages, messages: nMessages,
replies: Number(replies ?? 0), replies: Number(replies ?? 0),
charsSent: nCharsSent, charsSent: nCharsSent,
lettersTyped: nLettersTyped, lettersTyped: nLettersTyped,
uniqueIps: Number(uniqueIps ?? 0), uniqueIps: Number(uniqueIps ?? 0),
longestMsg: Number(longest ?? 0), longestMsg: Number(longest ?? 0),
abandonRate, abandonRate,
avgLength, avgLength,
moneyExtorted, moneyExtorted,
}; };
} }

View File

@@ -1,48 +1,48 @@
import { mkdir } from "node:fs/promises"; import { mkdir } from "node:fs/promises";
import { resolve, extname } from "node:path"; import { resolve, extname } from "node:path";
/** /**
* Filesystem storage for uploads, under backend/uploads/. * Filesystem storage for uploads, under backend/uploads/.
* Files are stored under a UUID-prefixed name so a malicious client filename * Files are stored under a UUID-prefixed name so a malicious client filename
* can never traverse paths or overwrite another file. The raw bytes are never * can never traverse paths or overwrite another file. The raw bytes are never
* executed server-side — we only ever read them back to serve downloads. * executed server-side — we only ever read them back to serve downloads.
*/ */
const UPLOADS_DIR = resolve(import.meta.dir, "../../uploads"); const UPLOADS_DIR = resolve(import.meta.dir, "../../uploads");
let ensured = false; let ensured = false;
async function ensureDir(): Promise<void> { async function ensureDir(): Promise<void> {
if (ensured) return; if (ensured) return;
await mkdir(UPLOADS_DIR, { recursive: true }); await mkdir(UPLOADS_DIR, { recursive: true });
ensured = true; ensured = true;
} }
/** Keep only a safe, short suffix of the original name for readability. */ /** Keep only a safe, short suffix of the original name for readability. */
function safeSuffix(filename: string): string { function safeSuffix(filename: string): string {
const ext = extname(filename).slice(0, 12).replace(/[^a-zA-Z0-9.]/g, ""); const ext = extname(filename).slice(0, 12).replace(/[^a-zA-Z0-9.]/g, "");
return ext || ""; return ext || "";
} }
export interface StoredFile { export interface StoredFile {
storagePath: string; // relative name under uploads/ storagePath: string; // relative name under uploads/
absolutePath: string; absolutePath: string;
} }
/** Persist a File/Blob, returning its storage path. id should be a fresh uuid. */ /** Persist a File/Blob, returning its storage path. id should be a fresh uuid. */
export async function storeFile(id: string, file: File): Promise<StoredFile> { export async function storeFile(id: string, file: File): Promise<StoredFile> {
await ensureDir(); await ensureDir();
const name = `${id}${safeSuffix(file.name)}`; const name = `${id}${safeSuffix(file.name)}`;
const absolutePath = resolve(UPLOADS_DIR, name); const absolutePath = resolve(UPLOADS_DIR, name);
// Extra guard: the resolved path must stay inside UPLOADS_DIR. // Extra guard: the resolved path must stay inside UPLOADS_DIR.
if (!absolutePath.startsWith(UPLOADS_DIR)) { if (!absolutePath.startsWith(UPLOADS_DIR)) {
throw new Error("Invalid storage path"); throw new Error("Invalid storage path");
} }
await Bun.write(absolutePath, file); await Bun.write(absolutePath, file);
return { storagePath: name, absolutePath }; return { storagePath: name, absolutePath };
} }
export function absolutePathFor(storagePath: string): string { export function absolutePathFor(storagePath: string): string {
const abs = resolve(UPLOADS_DIR, storagePath); const abs = resolve(UPLOADS_DIR, storagePath);
if (!abs.startsWith(UPLOADS_DIR)) throw new Error("Invalid storage path"); if (!abs.startsWith(UPLOADS_DIR)) throw new Error("Invalid storage path");
return abs; return abs;
} }

View File

@@ -1,127 +1,127 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { redis } from "./redis"; import { redis } from "./redis";
import { isFree } from "./ip"; import { isFree } from "./ip";
/** /**
* Wallet engine — fictional "crédits XIP", keyed on IP (no accounts). * Wallet engine — fictional "crédits XIP", keyed on IP (no accounts).
* *
* Amounts are integer CENTI-CREDITS to avoid float drift (display divides by 100). * Amounts are integer CENTI-CREDITS to avoid float drift (display divides by 100).
* So 9.99 "crédits" is stored as 999. * So 9.99 "crédits" is stored as 999.
* *
* `spend()` is the single choke point for every paid action: it enforces the * `spend()` is the single choke point for every paid action: it enforces the
* balance and is the one place the localhost "free mode" bypass lives. * balance and is the one place the localhost "free mode" bypass lives.
*/ */
// Starting grant on first wallet touch, and the free top-up button amount. // Starting grant on first wallet touch, and the free top-up button amount.
export const SIGNUP_GRANT = 0; export const SIGNUP_GRANT = 0;
export const TOPUP_AMOUNT = 5000; // 50.00 crédits per free top-up export const TOPUP_AMOUNT = 5000; // 50.00 crédits per free top-up
// Sentinel reported as the balance for localhost (rendered as "∞" by the UI). // Sentinel reported as the balance for localhost (rendered as "∞" by the UI).
export const INFINITE = Number.MAX_SAFE_INTEGER; export const INFINITE = Number.MAX_SAFE_INTEGER;
// Redis keys (mirror + global money counter). // Redis keys (mirror + global money counter).
const walletKey = (ip: string) => `xip:wallet:${ip}`; const walletKey = (ip: string) => `xip:wallet:${ip}`;
const CREDITS_SPENT = "xip:money:credits_spent"; const CREDITS_SPENT = "xip:money:credits_spent";
export interface WalletView { export interface WalletView {
ip: string; ip: string;
balance: number; // centi-credits (or INFINITE for free mode) balance: number; // centi-credits (or INFINITE for free mode)
freeMode: boolean; freeMode: boolean;
} }
/** Lazily create the wallet row (with the signup grant) the first time we touch an IP. */ /** Lazily create the wallet row (with the signup grant) the first time we touch an IP. */
export async function ensureWallet(ip: string): Promise<void> { export async function ensureWallet(ip: string): Promise<void> {
await prisma.wallet await prisma.wallet
.upsert({ .upsert({
where: { ip }, where: { ip },
create: { ip, balance: SIGNUP_GRANT }, create: { ip, balance: SIGNUP_GRANT },
update: {}, update: {},
}) })
.catch(() => {}); .catch(() => {});
} }
export async function getWallet(ip: string): Promise<WalletView> { export async function getWallet(ip: string): Promise<WalletView> {
if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true }; if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true };
await ensureWallet(ip); await ensureWallet(ip);
const w = await prisma.wallet.findUnique({ where: { ip } }).catch(() => null); const w = await prisma.wallet.findUnique({ where: { ip } }).catch(() => null);
const balance = w?.balance ?? 0; const balance = w?.balance ?? 0;
void redis.set(walletKey(ip), String(balance)).catch(() => {}); void redis.set(walletKey(ip), String(balance)).catch(() => {});
return { ip, balance, freeMode: false }; return { ip, balance, freeMode: false };
} }
/** Free, instant, satirical top-up. No-op for localhost (already infinite). */ /** Free, instant, satirical top-up. No-op for localhost (already infinite). */
export async function topUp(ip: string, amount = TOPUP_AMOUNT): Promise<WalletView> { export async function topUp(ip: string, amount = TOPUP_AMOUNT): Promise<WalletView> {
if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true }; if (isFree(ip)) return { ip, balance: INFINITE, freeMode: true };
await ensureWallet(ip); await ensureWallet(ip);
const w = await prisma.wallet.update({ const w = await prisma.wallet.update({
where: { ip }, where: { ip },
data: { balance: { increment: amount } }, data: { balance: { increment: amount } },
}); });
await prisma.purchase await prisma.purchase
.create({ data: { ip, type: "topup", amount } }) .create({ data: { ip, type: "topup", amount } })
.catch(() => {}); .catch(() => {});
void redis.set(walletKey(ip), String(w.balance)).catch(() => {}); void redis.set(walletKey(ip), String(w.balance)).catch(() => {});
return { ip, balance: w.balance, freeMode: false }; return { ip, balance: w.balance, freeMode: false };
} }
export class InsufficientCreditsError extends Error { export class InsufficientCreditsError extends Error {
constructor() { constructor() {
super("Crédits insuffisants"); super("Crédits insuffisants");
this.name = "InsufficientCreditsError"; this.name = "InsufficientCreditsError";
} }
} }
/** /**
* Atomically spend credits. Returns the new balance. * Atomically spend credits. Returns the new balance.
* - localhost => free mode: records nothing, returns INFINITE. * - localhost => free mode: records nothing, returns INFINITE.
* - otherwise: transactional re-read + guard + decrement + ledger row. * - otherwise: transactional re-read + guard + decrement + ledger row.
* Throws InsufficientCreditsError if the balance can't cover `amount`. * Throws InsufficientCreditsError if the balance can't cover `amount`.
*/ */
export async function spend( export async function spend(
ip: string, ip: string,
amount: number, amount: number,
reason: string, reason: string,
meta?: Record<string, unknown> meta?: Record<string, unknown>
): Promise<number> { ): Promise<number> {
if (isFree(ip)) return INFINITE; if (isFree(ip)) return INFINITE;
if (amount <= 0) { if (amount <= 0) {
// Free item — still record the (zero) purchase for history, no balance change. // Free item — still record the (zero) purchase for history, no balance change.
const w = await getWallet(ip); const w = await getWallet(ip);
await prisma.purchase await prisma.purchase
.create({ .create({
data: { ip, type: "purchase", amount: 0, productId: reason, metaJson: meta ? JSON.stringify(meta) : null }, data: { ip, type: "purchase", amount: 0, productId: reason, metaJson: meta ? JSON.stringify(meta) : null },
}) })
.catch(() => {}); .catch(() => {});
return w.balance; return w.balance;
} }
const newBalance = await prisma.$transaction(async (tx) => { const newBalance = await prisma.$transaction(async (tx) => {
await tx.wallet.upsert({ await tx.wallet.upsert({
where: { ip }, where: { ip },
create: { ip, balance: SIGNUP_GRANT }, create: { ip, balance: SIGNUP_GRANT },
update: {}, update: {},
}); });
const w = await tx.wallet.findUnique({ where: { ip } }); const w = await tx.wallet.findUnique({ where: { ip } });
const current = w?.balance ?? 0; const current = w?.balance ?? 0;
if (current < amount) throw new InsufficientCreditsError(); if (current < amount) throw new InsufficientCreditsError();
const updated = await tx.wallet.update({ const updated = await tx.wallet.update({
where: { ip }, where: { ip },
data: { balance: { decrement: amount } }, data: { balance: { decrement: amount } },
}); });
await tx.purchase.create({ await tx.purchase.create({
data: { data: {
ip, ip,
type: "purchase", type: "purchase",
amount: -amount, amount: -amount,
productId: reason, productId: reason,
metaJson: meta ? JSON.stringify(meta) : null, metaJson: meta ? JSON.stringify(meta) : null,
}, },
}); });
return updated.balance; return updated.balance;
}); });
// Mirror to Redis + bump the global "credits spent" money counter. // Mirror to Redis + bump the global "credits spent" money counter.
void redis.set(walletKey(ip), String(newBalance)).catch(() => {}); void redis.set(walletKey(ip), String(newBalance)).catch(() => {});
void redis.incrby(CREDITS_SPENT, amount).catch(() => {}); void redis.incrby(CREDITS_SPENT, amount).catch(() => {});
return newBalance; return newBalance;
} }

View File

@@ -1,147 +1,147 @@
import { createBunWebSocket } from "hono/bun"; import { createBunWebSocket } from "hono/bun";
import type { WSContext } from "hono/ws"; import type { WSContext } from "hono/ws";
import { buildSnapshot, recordLettersTyped } from "./lib/stats"; import { buildSnapshot, recordLettersTyped } from "./lib/stats";
import { getClientIp } from "./lib/ip"; import { getClientIp } from "./lib/ip";
/** /**
* Realtime hub: one WebSocket connection = one open tab. * Realtime hub: one WebSocket connection = one open tab.
* *
* - Broadcasts a throttled stats snapshot to every tab. * - Broadcasts a throttled stats snapshot to every tab.
* - Broadcasts newly created messages so feeds update without polling. * - Broadcasts newly created messages so feeds update without polling.
* - Tracks "currently typing" presence and feeds the global letters-typed counter. * - Tracks "currently typing" presence and feeds the global letters-typed counter.
* - Knows each socket's client IP, so it can push wallet/perks frames to just * - Knows each socket's client IP, so it can push wallet/perks frames to just
* that IP's tabs (broadcastToIp) or to everyone (broadcast). * that IP's tabs (broadcastToIp) or to everyone (broadcast).
* *
* The Hono Bun adapter calls the events factory with the request Context, so we * The Hono Bun adapter calls the events factory with the request Context, so we
* derive the IP once per connection in the factory and stash it in ClientState. * derive the IP once per connection in the factory and stash it in ClientState.
*/ */
const { upgradeWebSocket, websocket } = createBunWebSocket(); const { upgradeWebSocket, websocket } = createBunWebSocket();
interface ClientState { interface ClientState {
lastTypingAt: number; lastTypingAt: number;
ip: string; ip: string;
} }
const clients = new Map<WSContext, ClientState>(); const clients = new Map<WSContext, ClientState>();
const TYPING_TTL_MS = 2500; // a tab counts as "typing" for this long after a keystroke const TYPING_TTL_MS = 2500; // a tab counts as "typing" for this long after a keystroke
const BROADCAST_MIN_INTERVAL_MS = 250; // throttle: at most one stats frame this often const BROADCAST_MIN_INTERVAL_MS = 250; // throttle: at most one stats frame this often
function countTyping(now: number): number { function countTyping(now: number): number {
let n = 0; let n = 0;
for (const s of clients.values()) { for (const s of clients.values()) {
if (now - s.lastTypingAt <= TYPING_TTL_MS) n++; if (now - s.lastTypingAt <= TYPING_TTL_MS) n++;
} }
return n; return n;
} }
function send(ws: WSContext, payload: string): void { function send(ws: WSContext, payload: string): void {
// readyState 1 === OPEN // readyState 1 === OPEN
if (ws.readyState === 1) { if (ws.readyState === 1) {
try { try {
ws.send(payload); ws.send(payload);
} catch { } catch {
/* ignore broken pipe */ /* ignore broken pipe */
} }
} }
} }
// ── Throttled stats broadcast ────────────────────────────────────────────── // ── Throttled stats broadcast ──────────────────────────────────────────────
let broadcastScheduled = false; let broadcastScheduled = false;
let lastBroadcastAt = 0; let lastBroadcastAt = 0;
async function flushStats(): Promise<void> { async function flushStats(): Promise<void> {
broadcastScheduled = false; broadcastScheduled = false;
lastBroadcastAt = Date.now(); lastBroadcastAt = Date.now();
if (clients.size === 0) return; if (clients.size === 0) return;
const distinctIps = new Set<string>(); const distinctIps = new Set<string>();
for (const s of clients.values()) distinctIps.add(s.ip); for (const s of clients.values()) distinctIps.add(s.ip);
const snapshot = await buildSnapshot({ const snapshot = await buildSnapshot({
connectedTabs: distinctIps.size, connectedTabs: distinctIps.size,
typingNow: countTyping(Date.now()), typingNow: countTyping(Date.now()),
}); });
const payload = JSON.stringify({ type: "stats", data: snapshot }); const payload = JSON.stringify({ type: "stats", data: snapshot });
for (const ws of clients.keys()) send(ws, payload); for (const ws of clients.keys()) send(ws, payload);
} }
function scheduleStats(): void { function scheduleStats(): void {
if (broadcastScheduled) return; if (broadcastScheduled) return;
broadcastScheduled = true; broadcastScheduled = true;
const wait = Math.max(0, BROADCAST_MIN_INTERVAL_MS - (Date.now() - lastBroadcastAt)); const wait = Math.max(0, BROADCAST_MIN_INTERVAL_MS - (Date.now() - lastBroadcastAt));
setTimeout(() => { setTimeout(() => {
void flushStats(); void flushStats();
}, wait); }, wait);
} }
// Periodic tick so time-decaying metrics (letters/sec, typing expiry, msgs/min) // Periodic tick so time-decaying metrics (letters/sec, typing expiry, msgs/min)
// keep updating even when nobody is interacting. // keep updating even when nobody is interacting.
setInterval(() => { setInterval(() => {
if (clients.size > 0) void flushStats(); if (clients.size > 0) void flushStats();
}, 1000); }, 1000);
// Periodic console log of connected IPs (every 10 s). // Periodic console log of connected IPs (every 10 s).
setInterval(() => { setInterval(() => {
if (clients.size === 0) return; if (clients.size === 0) return;
const ips = new Set<string>(); const ips = new Set<string>();
for (const s of clients.values()) ips.add(s.ip); for (const s of clients.values()) ips.add(s.ip);
const lines = [...ips].map((ip) => ` ${ip}`).join("\n"); const lines = [...ips].map((ip) => ` ${ip}`).join("\n");
console.log(`[connectés] ${ips.size} IP(s):\n${lines}`); console.log(`[connectés] ${ips.size} IP(s):\n${lines}`);
}, 10_000); }, 10_000);
/** Send an arbitrary frame to every connected tab. */ /** Send an arbitrary frame to every connected tab. */
export function broadcast(payload: object): void { export function broadcast(payload: object): void {
const str = JSON.stringify(payload); const str = JSON.stringify(payload);
for (const ws of clients.keys()) send(ws, str); for (const ws of clients.keys()) send(ws, str);
} }
/** Send a frame only to the tabs belonging to one IP (e.g. wallet updates). */ /** Send a frame only to the tabs belonging to one IP (e.g. wallet updates). */
export function broadcastToIp(ip: string, payload: object): void { export function broadcastToIp(ip: string, payload: object): void {
const str = JSON.stringify(payload); const str = JSON.stringify(payload);
for (const [ws, state] of clients) { for (const [ws, state] of clients) {
if (state.ip === ip) send(ws, str); if (state.ip === ip) send(ws, str);
} }
} }
/** Push a freshly created message to every connected tab. */ /** Push a freshly created message to every connected tab. */
export function broadcastNewMessage(message: unknown): void { export function broadcastNewMessage(message: unknown): void {
broadcast({ type: "message", data: message }); broadcast({ type: "message", data: message });
scheduleStats(); // totals changed too scheduleStats(); // totals changed too
} }
/** Hono route handler for GET /ws. The factory receives the request Context. */ /** Hono route handler for GET /ws. The factory receives the request Context. */
export const wsHandler = upgradeWebSocket((c) => { export const wsHandler = upgradeWebSocket((c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
return { return {
onOpen(_evt, ws) { onOpen(_evt, ws) {
clients.set(ws, { lastTypingAt: 0, ip }); clients.set(ws, { lastTypingAt: 0, ip });
scheduleStats(); scheduleStats();
}, },
onMessage(evt, ws) { onMessage(evt, ws) {
let msg: { type?: string; delta?: number } | null = null; let msg: { type?: string; delta?: number } | null = null;
try { try {
msg = JSON.parse(typeof evt.data === "string" ? evt.data : "{}"); msg = JSON.parse(typeof evt.data === "string" ? evt.data : "{}");
} catch { } catch {
return; return;
} }
if (!msg || typeof msg !== "object") return; if (!msg || typeof msg !== "object") return;
if (msg.type === "typing") { if (msg.type === "typing") {
const state = clients.get(ws); const state = clients.get(ws);
if (state) state.lastTypingAt = Date.now(); if (state) state.lastTypingAt = Date.now();
const delta = Number(msg.delta) || 0; const delta = Number(msg.delta) || 0;
if (delta > 0) void recordLettersTyped(delta); if (delta > 0) void recordLettersTyped(delta);
scheduleStats(); scheduleStats();
} }
}, },
onClose(_evt, ws) { onClose(_evt, ws) {
clients.delete(ws); clients.delete(ws);
scheduleStats(); scheduleStats();
}, },
onError(_evt, ws) { onError(_evt, ws) {
clients.delete(ws); clients.delete(ws);
}, },
}; };
}); });
export { websocket }; export { websocket };

View File

@@ -1,40 +1,40 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { listActiveAds, recordImpressions } from "../lib/ads"; import { listActiveAds, recordImpressions } from "../lib/ads";
const ads = new Hono(); const ads = new Hono();
// GET /api/ads?kind=band → active ad set for that slot (client rotates). // GET /api/ads?kind=band → active ad set for that slot (client rotates).
ads.get("/", async (c) => { ads.get("/", async (c) => {
const kind = c.req.query("kind") === "casino" ? "casino" : "band"; const kind = c.req.query("kind") === "casino" ? "casino" : "band";
const list = await listActiveAds(kind); const list = await listActiveAds(kind);
// Expose only what the UI needs. // Expose only what the UI needs.
return c.json( return c.json(
list.map((a) => ({ list.map((a) => ({
id: a.id, id: a.id,
brand: a.brand, brand: a.brand,
subtitle: a.subtitle, subtitle: a.subtitle,
url: a.url, url: a.url,
cta: a.cta, cta: a.cta,
icon: a.icon, icon: a.icon,
tone: a.tone, tone: a.tone,
kind: a.kind, kind: a.kind,
ownerIp: a.ownerIp, ownerIp: a.ownerIp,
imageUrl: a.imageUrl, imageUrl: a.imageUrl,
})) }))
); );
}); });
// POST /api/ads/impressions { ids: [...] } // POST /api/ads/impressions { ids: [...] }
ads.post("/impressions", async (c) => { ads.post("/impressions", async (c) => {
let body: { ids?: string[] } = {}; let body: { ids?: string[] } = {};
try { try {
body = await c.req.json(); body = await c.req.json();
} catch { } catch {
return c.json({ error: "JSON invalide" }, 400); return c.json({ error: "JSON invalide" }, 400);
} }
const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : []; const ids = Array.isArray(body.ids) ? body.ids.filter((x) => typeof x === "string") : [];
await recordImpressions(ids); await recordImpressions(ids);
return c.json({ ok: true, counted: ids.length }); return c.json({ ok: true, counted: ids.length });
}); });
export default ads; export default ads;

View File

@@ -1,67 +1,67 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getClientIp, isFree } from "../lib/ip"; import { getClientIp, isFree } from "../lib/ip";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { redis } from "../lib/redis"; import { redis } from "../lib/redis";
import { spend } from "../lib/wallet"; import { spend } from "../lib/wallet";
import { broadcast } from "../realtime"; import { broadcast } from "../realtime";
const alert = new Hono(); const alert = new Hono();
const COOLDOWN_MS = 60_000; // server-enforced global cooldown const COOLDOWN_MS = 60_000; // server-enforced global cooldown
const MAX_DURATION_MS = 5_000; // server clamps how long the sound may play const MAX_DURATION_MS = 5_000; // server clamps how long the sound may play
const ALERT_PRICE = 999; // centi-credits per fire (consumable) const ALERT_PRICE = 999; // centi-credits per fire (consumable)
const COOLDOWN_KEY = "xip:alert:cooldown"; const COOLDOWN_KEY = "xip:alert:cooldown";
// POST /api/alert { soundUrl? } // POST /api/alert { soundUrl? }
alert.post("/", async (c) => { alert.post("/", async (c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
let body: { soundUrl?: string } = {}; let body: { soundUrl?: string } = {};
try { try {
body = await c.req.json(); body = await c.req.json();
} catch { } catch {
/* no body is fine */ /* no body is fine */
} }
// Must own the audio-alert entitlement (localhost bypasses). // Must own the audio-alert entitlement (localhost bypasses).
if (!isFree(ip)) { if (!isFree(ip)) {
const owned = await prisma.entitlement.findFirst({ const owned = await prisma.entitlement.findFirst({
where: { ip, kind: "audio-alert", active: true }, where: { ip, kind: "audio-alert", active: true },
}); });
if (!owned) { if (!owned) {
return c.json({ error: "Débloque l'alerte audio dans le Shop" }, 402); return c.json({ error: "Débloque l'alerte audio dans le Shop" }, 402);
} }
} }
// Global cooldown via Redis NX+PX. // Global cooldown via Redis NX+PX.
const ok = await redis const ok = await redis
.set(COOLDOWN_KEY, ip, "PX", COOLDOWN_MS, "NX") .set(COOLDOWN_KEY, ip, "PX", COOLDOWN_MS, "NX")
.catch(() => null); .catch(() => null);
if (ok !== "OK") { if (ok !== "OK") {
const ttl = await redis.pttl(COOLDOWN_KEY).catch(() => 0); const ttl = await redis.pttl(COOLDOWN_KEY).catch(() => 0);
return c.json({ error: "Cooldown actif", retryInMs: Math.max(0, ttl) }, 429); return c.json({ error: "Cooldown actif", retryInMs: Math.max(0, ttl) }, 429);
} }
// Charge the consumable (skipped for localhost free mode). // Charge the consumable (skipped for localhost free mode).
try { try {
await spend(ip, ALERT_PRICE, "audio-alert"); await spend(ip, ALERT_PRICE, "audio-alert");
} catch { } catch {
await redis.del(COOLDOWN_KEY).catch(() => {}); await redis.del(COOLDOWN_KEY).catch(() => {});
return c.json({ error: "Crédits insuffisants" }, 402); return c.json({ error: "Crédits insuffisants" }, 402);
} }
// Validate a supplied mp3 URL (must be one of our own /api/uploads/ paths). // Validate a supplied mp3 URL (must be one of our own /api/uploads/ paths).
let soundUrl: string | undefined; let soundUrl: string | undefined;
if (typeof body.soundUrl === "string" && body.soundUrl.includes("/api/uploads/")) { if (typeof body.soundUrl === "string" && body.soundUrl.includes("/api/uploads/")) {
soundUrl = body.soundUrl; soundUrl = body.soundUrl;
} }
broadcast({ broadcast({
type: "alert", type: "alert",
data: { ip, soundUrl, maxDurationMs: MAX_DURATION_MS, volume: 1 }, data: { ip, soundUrl, maxDurationMs: MAX_DURATION_MS, volume: 1 },
}); });
return c.json({ ok: true }); return c.json({ ok: true });
}); });
export default alert; export default alert;

View File

@@ -1,142 +1,188 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { getClientIp, isFree } from "../lib/ip"; import { getClientIp, isFree } from "../lib/ip";
import { recordMessage } from "../lib/stats"; import { recordMessage } from "../lib/stats";
import { broadcastNewMessage } from "../realtime"; import { broadcastNewMessage } from "../realtime";
import { getPerksForIp, getPerksForIps } from "../lib/perks"; import { getPerksForIp, getPerksForIps } from "../lib/perks";
import { getGeoForIp, getGeoForIps } from "../lib/geo"; import { getGeoForIp, getGeoForIps } from "../lib/geo";
const messages = new Hono(); const messages = new Hono();
const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup
/** Does this IP own the entitlement needed for a rich tier? */ /** Does this IP own the entitlement needed for a rich tier? */
async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> { async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> {
if (isFree(ip)) return true; if (isFree(ip)) return true;
const kind = mode === "js" ? "rich-js" : "rich-htmlcss"; const kind = mode === "js" ? "rich-js" : "rich-htmlcss";
const now = new Date(); const now = new Date();
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
return rows.some((e) => !e.expiresAt || e.expiresAt >= now); return rows.some((e) => !e.expiresAt || e.expiresAt >= now);
} }
// GET /api/messages — top-level threads with replies, annotated with author perks. // What we always include with a thread: its attachments + replies (+ their attachments).
messages.get("/", async (c) => { const THREAD_INCLUDE = {
const data = await prisma.message.findMany({ attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
where: { parentId: null }, replies: {
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "asc" as const },
take: 50, include: {
include: { attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
attachments: { select: { id: true, filename: true, mimeType: true, size: true } }, },
replies: { },
orderBy: { createdAt: "asc" }, } as const;
include: {
attachments: { select: { id: true, filename: true, mimeType: true, size: true } }, /** Annotate a list of threads with each author's perks + geo (threads + replies). */
}, async function annotateThreads<T extends { authorIp: string; replies: { authorIp: string }[] }>(
}, threads: T[]
}, ) {
}); const ips = new Set<string>();
for (const m of threads) {
// Collect every distinct author IP (threads + replies) and resolve perks once. ips.add(m.authorIp);
const ips = new Set<string>(); for (const r of m.replies) ips.add(r.authorIp);
for (const m of data) { }
ips.add(m.authorIp); const [perks, geo] = await Promise.all([
for (const r of m.replies) ips.add(r.authorIp); getPerksForIps([...ips]),
} getGeoForIps([...ips]),
const [perks, geo] = await Promise.all([ ]);
getPerksForIps([...ips]), return threads.map((m) => ({
getGeoForIps([...ips]), ...m,
]); authorPerks: perks[m.authorIp] ?? {},
authorGeo: geo[m.authorIp] ?? null,
const annotated = data.map((m) => ({ replies: m.replies.map((r) => ({
...m, ...r,
authorPerks: perks[m.authorIp] ?? {}, authorPerks: perks[r.authorIp] ?? {},
authorGeo: geo[m.authorIp] ?? null, authorGeo: geo[r.authorIp] ?? null,
replies: m.replies.map((r) => ({ })),
...r, }));
authorPerks: perks[r.authorIp] ?? {}, }
authorGeo: geo[r.authorIp] ?? null,
})), // GET /api/messages — top-level threads with replies, annotated with author perks.
})); // Optional query params (all backward-compatible — no params = the original feed):
// q : keyword search on content (case-insensitive)
return c.json(annotated); // before : cursor — only threads strictly older than this ISO date (pagination)
}); // limit : page size (default 50, max 100)
// Returns { items, nextCursor, hasMore }.
// POST /api/messages — create a message or reply (optionally rich + attachments) messages.get("/", async (c) => {
messages.post("/", async (c) => { const q = c.req.query("q")?.trim();
const ip = getClientIp(c); const before = c.req.query("before");
const limit = Math.min(Math.max(Number(c.req.query("limit")) || 50, 1), 100);
const body = await c.req.json<{
content?: string; const where: any = { parentId: null };
parentId?: string; if (q) where.content = { contains: q, mode: "insensitive" };
richMode?: "htmlcss" | "js"; if (before) {
richContent?: string; const d = new Date(before);
attachmentIds?: string[]; if (!isNaN(d.getTime())) where.createdAt = { lt: d };
}>(); }
// A message is valid if it has ANY of: plain text, rich content, or attachments. // Fetch one extra row to know whether there's a next page.
// (Rich-only and file-only messages are legitimate — no need for placeholder text.) const rows = await prisma.message.findMany({
const hasContent = typeof body.content === "string" && body.content.trim().length > 0; where,
const hasRich = orderBy: { createdAt: "desc" },
!!body.richMode && !!body.richContent && body.richContent.trim().length > 0; take: limit + 1,
const hasAttachments = include: THREAD_INCLUDE,
Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0; });
if (!hasContent && !hasRich && !hasAttachments) { const hasMore = rows.length > limit;
return c.json({ error: "Message vide" }, 400); const page = hasMore ? rows.slice(0, limit) : rows;
} const items = await annotateThreads(page);
if (hasContent && body.content!.trim().length > 267) { const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null;
return c.json({ error: "Content exceeds 267 characters" }, 400);
} // Backward-compatible: with no query params, return the bare array the live
// chat feed (useMessages) already consumes. The explorer passes params and
// Rich content: validate tier ownership + size. // gets the paginated envelope.
let richMode: "none" | "htmlcss" | "js" = "none"; const isLegacy = !q && !before && c.req.query("limit") === undefined;
let richContent: string | null = null; return c.json(isLegacy ? items : { items, nextCursor, hasMore });
if (body.richMode && body.richContent && body.richContent.trim().length > 0) { });
if (body.richMode !== "htmlcss" && body.richMode !== "js") {
return c.json({ error: "richMode invalide" }, 400); // GET /api/messages/:id — a single top-level thread (with its replies), annotated.
} messages.get("/:id", async (c) => {
if (!(await ownsRich(ip, body.richMode))) { const id = c.req.param("id");
return c.json({ error: "Fonctionnalité non débloquée" }, 402); const message = await prisma.message.findUnique({
} where: { id },
if (body.richContent.length > RICH_MAX) { include: THREAD_INCLUDE,
return c.json({ error: "Contenu riche trop volumineux" }, 413); });
} if (!message || message.parentId !== null) {
richMode = body.richMode; return c.json({ error: "Message introuvable" }, 404);
richContent = body.richContent; }
} const [annotated] = await annotateThreads([message]);
return c.json(annotated);
const content = (body.content ?? "").trim(); });
const parentId = body.parentId ?? null;
// POST /api/messages — create a message or reply (optionally rich + attachments)
const message = await prisma.message.create({ messages.post("/", async (c) => {
data: { content, authorIp: ip, parentId, richMode, richContent }, const ip = getClientIp(c);
});
const body = await c.req.json<{
// Link any pre-uploaded attachments owned by this IP to the new message. content?: string;
let attachments: any[] = []; parentId?: string;
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0) { richMode?: "htmlcss" | "js";
await prisma.attachment.updateMany({ richContent?: string;
where: { id: { in: body.attachmentIds }, ip, messageId: null }, attachmentIds?: string[];
data: { messageId: message.id }, }>();
});
attachments = await prisma.attachment.findMany({ // A message is valid if it has ANY of: plain text, rich content, or attachments.
where: { messageId: message.id }, // (Rich-only and file-only messages are legitimate — no need for placeholder text.)
select: { id: true, filename: true, mimeType: true, size: true }, const hasContent = typeof body.content === "string" && body.content.trim().length > 0;
}); const hasRich =
} !!body.richMode && !!body.richContent && body.richContent.trim().length > 0;
const hasAttachments =
// Update persistent stats and push the message to every connected tab, Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0;
// annotated with the author's perks so it renders correctly everywhere.
void recordMessage(content.length, parentId !== null); if (!hasContent && !hasRich && !hasAttachments) {
const [authorPerks, authorGeo] = await Promise.all([ return c.json({ error: "Message vide" }, 400);
getPerksForIp(ip), }
getGeoForIp(ip), if (hasContent && body.content!.trim().length > 267) {
]); return c.json({ error: "Content exceeds 267 characters" }, 400);
const enriched = { ...message, attachments, authorPerks, authorGeo }; }
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
broadcastNewMessage(payload); // Rich content: validate tier ownership + size.
let richMode: "none" | "htmlcss" | "js" = "none";
return c.json(enriched, 201); let richContent: string | null = null;
}); if (body.richMode && body.richContent && body.richContent.trim().length > 0) {
if (body.richMode !== "htmlcss" && body.richMode !== "js") {
export default messages; return c.json({ error: "richMode invalide" }, 400);
}
if (!(await ownsRich(ip, body.richMode))) {
return c.json({ error: "Fonctionnalité non débloquée" }, 402);
}
if (body.richContent.length > RICH_MAX) {
return c.json({ error: "Contenu riche trop volumineux" }, 413);
}
richMode = body.richMode;
richContent = body.richContent;
}
const content = (body.content ?? "").trim();
const parentId = body.parentId ?? null;
const message = await prisma.message.create({
data: { content, authorIp: ip, parentId, richMode, richContent },
});
// Link any pre-uploaded attachments owned by this IP to the new message.
let attachments: any[] = [];
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0) {
await prisma.attachment.updateMany({
where: { id: { in: body.attachmentIds }, ip, messageId: null },
data: { messageId: message.id },
});
attachments = await prisma.attachment.findMany({
where: { messageId: message.id },
select: { id: true, filename: true, mimeType: true, size: true },
});
}
// Update persistent stats and push the message to every connected tab,
// annotated with the author's perks so it renders correctly everywhere.
void recordMessage(content.length, parentId !== null);
const [authorPerks, authorGeo] = await Promise.all([
getPerksForIp(ip),
getGeoForIp(ip),
]);
const enriched = { ...message, attachments, authorPerks, authorGeo };
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
broadcastNewMessage(payload);
return c.json(enriched, 201);
});
export default messages;

View File

@@ -1,14 +1,14 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getPerksForIps } from "../lib/perks"; import { getPerksForIps } from "../lib/perks";
const perks = new Hono(); const perks = new Hono();
// GET /api/perks?ips=a,b,c — batch perk lookup for authors already on screen. // GET /api/perks?ips=a,b,c — batch perk lookup for authors already on screen.
perks.get("/", async (c) => { perks.get("/", async (c) => {
const raw = c.req.query("ips") || ""; const raw = c.req.query("ips") || "";
const ips = raw.split(",").map((s) => s.trim()).filter(Boolean); const ips = raw.split(",").map((s) => s.trim()).filter(Boolean);
if (ips.length === 0) return c.json({}); if (ips.length === 0) return c.json({});
return c.json(await getPerksForIps(ips)); return c.json(await getPerksForIps(ips));
}); });
export default perks; export default perks;

View File

@@ -1,86 +1,86 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getClientIp } from "../lib/ip"; import { getClientIp } from "../lib/ip";
import { getWallet } from "../lib/wallet"; import { getWallet } from "../lib/wallet";
import { import {
listProducts, listProducts,
getProduct, getProduct,
getEntitlements, getEntitlements,
purchase, purchase,
refreshPerks, refreshPerks,
PurchaseError, PurchaseError,
type PurchaseOptions, type PurchaseOptions,
} from "../lib/catalog"; } from "../lib/catalog";
import { broadcast, broadcastToIp } from "../realtime"; import { broadcast, broadcastToIp } from "../realtime";
import { getPerksForIp } from "../lib/perks"; import { getPerksForIp } from "../lib/perks";
const shop = new Hono(); const shop = new Hono();
// GET /api/shop/products?category=cosmetiques // GET /api/shop/products?category=cosmetiques
shop.get("/products", async (c) => { shop.get("/products", async (c) => {
const category = c.req.query("category") || undefined; const category = c.req.query("category") || undefined;
return c.json(await listProducts(category)); return c.json(await listProducts(category));
}); });
// GET /api/shop/products/:id // GET /api/shop/products/:id
shop.get("/products/:id", async (c) => { shop.get("/products/:id", async (c) => {
const p = await getProduct(c.req.param("id")); const p = await getProduct(c.req.param("id"));
if (!p) return c.json({ error: "Produit introuvable" }, 404); if (!p) return c.json({ error: "Produit introuvable" }, 404);
return c.json(p); return c.json(p);
}); });
// GET /api/shop/me — my balance + owned entitlements // GET /api/shop/me — my balance + owned entitlements
shop.get("/me", async (c) => { shop.get("/me", async (c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
const [wallet, entitlements, myPerks] = await Promise.all([ const [wallet, entitlements, myPerks] = await Promise.all([
getWallet(ip), getWallet(ip),
getEntitlements(ip), getEntitlements(ip),
getPerksForIp(ip), getPerksForIp(ip),
]); ]);
return c.json({ wallet, entitlements, myPerks }); return c.json({ wallet, entitlements, myPerks });
}); });
// POST /api/shop/purchase { productId, options } // POST /api/shop/purchase { productId, options }
shop.post("/purchase", async (c) => { shop.post("/purchase", async (c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
let body: { productId?: string; options?: PurchaseOptions } = {}; let body: { productId?: string; options?: PurchaseOptions } = {};
try { try {
body = await c.req.json(); body = await c.req.json();
} catch { } catch {
return c.json({ error: "Corps JSON invalide" }, 400); return c.json({ error: "Corps JSON invalide" }, 400);
} }
if (!body.productId) return c.json({ error: "productId requis" }, 400); if (!body.productId) return c.json({ error: "productId requis" }, 400);
try { try {
const { result, visiblePerkChanged, adCreated } = await purchase( const { result, visiblePerkChanged, adCreated } = await purchase(
ip, ip,
body.productId, body.productId,
body.options ?? {} body.options ?? {}
); );
// Wallet update → only this IP's tabs. // Wallet update → only this IP's tabs.
const wallet = await getWallet(ip); const wallet = await getWallet(ip);
broadcastToIp(ip, { type: "wallet", data: wallet }); broadcastToIp(ip, { type: "wallet", data: wallet });
// Perks: always tell the buyer; if a *visible* perk changed, tell everyone // Perks: always tell the buyer; if a *visible* perk changed, tell everyone
// so existing messages by this IP re-render with the skin/pet. // so existing messages by this IP re-render with the skin/pet.
const perks = await refreshPerks(ip); const perks = await refreshPerks(ip);
if (visiblePerkChanged) { if (visiblePerkChanged) {
broadcast({ type: "perks", data: { ip, perks } }); broadcast({ type: "perks", data: { ip, perks } });
} else { } else {
broadcastToIp(ip, { type: "perks", data: { ip, perks } }); broadcastToIp(ip, { type: "perks", data: { ip, perks } });
} }
// New user ad entered rotation → nudge everyone to refetch ads. // New user ad entered rotation → nudge everyone to refetch ads.
if (adCreated) broadcast({ type: "ads", data: { reason: "new-user-ad" } }); if (adCreated) broadcast({ type: "ads", data: { reason: "new-user-ad" } });
return c.json(result, 201); return c.json(result, 201);
} catch (e) { } catch (e) {
if (e instanceof PurchaseError) { if (e instanceof PurchaseError) {
return c.json({ error: e.message }, e.status as 400); return c.json({ error: e.message }, e.status as 400);
} }
console.error("purchase error:", (e as Error).message); console.error("purchase error:", (e as Error).message);
return c.json({ error: "Achat impossible" }, 500); return c.json({ error: "Achat impossible" }, 500);
} }
}); });
export default shop; export default shop;

View File

@@ -1,93 +1,93 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { randomUUID } from "node:crypto"; import { randomUUID } from "node:crypto";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { getClientIp, isFree } from "../lib/ip"; import { getClientIp, isFree } from "../lib/ip";
import { storeFile, absolutePathFor } from "../lib/storage"; import { storeFile, absolutePathFor } from "../lib/storage";
const uploads = new Hono(); const uploads = new Hono();
const FREE_LIMIT = 1_000_000; // 1 Mo for the free tier (README) const FREE_LIMIT = 1_000_000; // 1 Mo for the free tier (README)
const ABSOLUTE_MAX = 50_000_000; // hard cap even for paid, to protect the dev box const ABSOLUTE_MAX = 50_000_000; // hard cap even for paid, to protect the dev box
async function ownsNoFileLimit(ip: string): Promise<boolean> { async function ownsNoFileLimit(ip: string): Promise<boolean> {
if (isFree(ip)) return true; if (isFree(ip)) return true;
const rows = await prisma.entitlement.findMany({ const rows = await prisma.entitlement.findMany({
where: { ip, kind: "no-file-limit", active: true }, where: { ip, kind: "no-file-limit", active: true },
}); });
return rows.length > 0; return rows.length > 0;
} }
// POST /api/uploads (multipart) — store a file, return its metadata. // POST /api/uploads (multipart) — store a file, return its metadata.
uploads.post("/", async (c) => { uploads.post("/", async (c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
let body: Record<string, unknown>; let body: Record<string, unknown>;
try { try {
body = await c.req.parseBody(); body = await c.req.parseBody();
} catch { } catch {
return c.json({ error: "Upload invalide" }, 400); return c.json({ error: "Upload invalide" }, 400);
} }
const file = body["file"]; const file = body["file"];
if (!(file instanceof File)) { if (!(file instanceof File)) {
return c.json({ error: "Aucun fichier" }, 400); return c.json({ error: "Aucun fichier" }, 400);
} }
if (file.size > ABSOLUTE_MAX) { if (file.size > ABSOLUTE_MAX) {
return c.json({ error: "Fichier trop volumineux (50 Mo max absolu)" }, 413); return c.json({ error: "Fichier trop volumineux (50 Mo max absolu)" }, 413);
} }
if (file.size > FREE_LIMIT && !(await ownsNoFileLimit(ip))) { if (file.size > FREE_LIMIT && !(await ownsNoFileLimit(ip))) {
return c.json( return c.json(
{ error: "Fichier > 1 Mo : débloque « Fichiers illimités » dans le Shop 💸" }, { error: "Fichier > 1 Mo : débloque « Fichiers illimités » dans le Shop 💸" },
413 413
); );
} }
const id = randomUUID(); const id = randomUUID();
let stored; let stored;
try { try {
stored = await storeFile(id, file); stored = await storeFile(id, file);
} catch { } catch {
return c.json({ error: "Échec d'écriture" }, 500); return c.json({ error: "Échec d'écriture" }, 500);
} }
const attachment = await prisma.attachment.create({ const attachment = await prisma.attachment.create({
data: { data: {
id, id,
ip, ip,
filename: file.name || "fichier", filename: file.name || "fichier",
mimeType: file.type || "application/octet-stream", mimeType: file.type || "application/octet-stream",
size: file.size, size: file.size,
storagePath: stored.storagePath, storagePath: stored.storagePath,
}, },
select: { id: true, filename: true, mimeType: true, size: true }, select: { id: true, filename: true, mimeType: true, size: true },
}); });
return c.json(attachment, 201); return c.json(attachment, 201);
}); });
// GET /uploads/:id — serve the stored bytes. Images inline; everything else is // GET /uploads/:id — serve the stored bytes. Images inline; everything else is
// forced to download (never rendered same-origin, never executed). // forced to download (never rendered same-origin, never executed).
uploads.get("/:id", async (c) => { uploads.get("/:id", async (c) => {
const id = c.req.param("id"); const id = c.req.param("id");
const att = await prisma.attachment.findUnique({ where: { id } }); const att = await prisma.attachment.findUnique({ where: { id } });
if (!att) return c.json({ error: "Introuvable" }, 404); if (!att) return c.json({ error: "Introuvable" }, 404);
let file; let file;
try { try {
file = Bun.file(absolutePathFor(att.storagePath)); file = Bun.file(absolutePathFor(att.storagePath));
} catch { } catch {
return c.json({ error: "Introuvable" }, 404); return c.json({ error: "Introuvable" }, 404);
} }
if (!(await file.exists())) return c.json({ error: "Introuvable" }, 404); if (!(await file.exists())) return c.json({ error: "Introuvable" }, 404);
const isImage = att.mimeType.startsWith("image/"); const isImage = att.mimeType.startsWith("image/");
const headers: Record<string, string> = { const headers: Record<string, string> = {
// Images may render inline; anything else downloads. Never serve as HTML. // Images may render inline; anything else downloads. Never serve as HTML.
"Content-Type": isImage ? att.mimeType : "application/octet-stream", "Content-Type": isImage ? att.mimeType : "application/octet-stream",
"Content-Disposition": `${isImage ? "inline" : "attachment"}; filename="${att.filename.replace(/"/g, "")}"`, "Content-Disposition": `${isImage ? "inline" : "attachment"}; filename="${att.filename.replace(/"/g, "")}"`,
"X-Content-Type-Options": "nosniff", "X-Content-Type-Options": "nosniff",
}; };
return new Response(file, { headers }); return new Response(file, { headers });
}); });
export default uploads; export default uploads;

View File

@@ -1,22 +1,22 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { getClientIp } from "../lib/ip"; import { getClientIp } from "../lib/ip";
import { getWallet, topUp } from "../lib/wallet"; import { getWallet, topUp } from "../lib/wallet";
import { broadcastToIp } from "../realtime"; import { broadcastToIp } from "../realtime";
const wallet = new Hono(); const wallet = new Hono();
// GET /api/wallet — current balance + freeMode for the calling IP. // GET /api/wallet — current balance + freeMode for the calling IP.
wallet.get("/", async (c) => { wallet.get("/", async (c) => {
return c.json(await getWallet(getClientIp(c))); return c.json(await getWallet(getClientIp(c)));
}); });
// POST /api/wallet/topup — free, instant, satirical recharge. // POST /api/wallet/topup — free, instant, satirical recharge.
wallet.post("/topup", async (c) => { wallet.post("/topup", async (c) => {
const ip = getClientIp(c); const ip = getClientIp(c);
const view = await topUp(ip); const view = await topUp(ip);
// Push the new balance to every tab of this IP. // Push the new balance to every tab of this IP.
broadcastToIp(ip, { type: "wallet", data: view }); broadcastToIp(ip, { type: "wallet", data: view });
return c.json(view); return c.json(view);
}); });
export default wallet; export default wallet;

View File

@@ -1,12 +1,12 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"module": "ESNext", "module": "ESNext",
"moduleResolution": "bundler", "moduleResolution": "bundler",
"strict": true, "strict": true,
"skipLibCheck": true, "skipLibCheck": true,
"noEmit": true, "noEmit": true,
"types": ["bun-types"] "types": ["bun"]
}, },
"include": ["src/**/*.ts", "prisma/**/*.ts"] "include": ["src/**/*.ts", "prisma/**/*.ts"]
} }

263
bun.lock
View File

@@ -23,21 +23,24 @@
"name": "xip-frontend", "name": "xip-frontend",
"version": "0.0.1", "version": "0.0.1",
"dependencies": { "dependencies": {
"@ionic/vue": "^8.3.0",
"@ionic/vue-router": "^8.3.0",
"ionicons": "^7.4.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0", "vue-router": "^4.4.0",
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"@vitest/coverage-v8": "^2.1.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^15.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.0",
"vue-tsc": "^2.1.0", "vue-tsc": "^2.1.0",
}, },
}, },
}, },
"packages": { "packages": {
"@ampproject/remapping": ["@ampproject/remapping@2.3.0", "", { "dependencies": { "@jridgewell/gen-mapping": "^0.3.5", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-30iZtAPgz+LTIYoeivqYo853f02jBYSd5uGnGpkFV0M3xOt9aN73erkgYAmZU43x4VfqcnLxW9Kpg3R5LC4YYw=="],
"@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="], "@babel/helper-string-parser": ["@babel/helper-string-parser@7.29.7", "", {}, "sha512-Pb5ijPrZ89GDH8223L4UP8i6QApWxs04RbPQJTeWDV0/keR2E36MeKnyr6LYmUUvqRRI+Iv87SuF1W6ErINzYw=="],
"@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="], "@babel/helper-validator-identifier": ["@babel/helper-validator-identifier@7.29.7", "", {}, "sha512-qehxGkRj55h/ff8EMaJ+cYhyaKlHIxqYDn682wQD7RNp9UujOQsHog2uS0r2vzr4pW+sXf90NeeayjcNaX3fFg=="],
@@ -46,6 +49,8 @@
"@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="], "@babel/types": ["@babel/types@7.29.7", "", { "dependencies": { "@babel/helper-string-parser": "^7.29.7", "@babel/helper-validator-identifier": "^7.29.7" } }, "sha512-4zBIxpPzowiZpusoFkyGVwakdRJUyuH5PxQ/PrqghfdFWWasvnCdPfQXHrenDai+gyLARulZjZowCOj6fjT4pA=="],
"@bcoe/v8-coverage": ["@bcoe/v8-coverage@0.2.3", "", {}, "sha512-0hYQ8SB4Db5zvZB4axdMHGwEaQjkZzFjQiN9LVYvIFB2nSUHW9tYpxWriPrWDASIxiaXax83REcLxuSdnGPZtw=="],
"@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="], "@esbuild/aix-ppc64": ["@esbuild/aix-ppc64@0.21.5", "", { "os": "aix", "cpu": "ppc64" }, "sha512-1SDgH6ZSPTlggy1yI6+Dbkiz8xzpHJEVAlF/AM1tHPLsf5STom9rwtjE4hKAF20FfXXNTFqEYXyJNWh1GiZedQ=="],
"@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="], "@esbuild/android-arm": ["@esbuild/android-arm@0.21.5", "", { "os": "android", "cpu": "arm" }, "sha512-vCPvzSjpPHEi1siZdlvAlsPxXl7WbOVUBBAowWug4rJHb68Ox8KualB+1ocNvT5fjv6wpkX6o/iEpbDrf68zcg=="],
@@ -92,16 +97,24 @@
"@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="], "@esbuild/win32-x64": ["@esbuild/win32-x64@0.21.5", "", { "os": "win32", "cpu": "x64" }, "sha512-tQd/1efJuzPC6rCFwEvLtci/xNFcTZknmXs98FYDfGE4wP9ClFV98nyKrzJKVPMhdDnjzLhdUyMX4PsQAPjwIw=="],
"@ionic/core": ["@ionic/core@8.8.8", "", { "dependencies": { "@stencil/core": "4.43.0", "ionicons": "^8.0.13", "tslib": "^2.1.0" } }, "sha512-GGvYtEzLtn1gBUC1/vb4pvA3gQzYskTNVIsvdTVIgnwLtdt70rwTibrZRSqmkyHeqpjg/u3+9XsM2c0kzc/V3w=="],
"@ionic/vue": ["@ionic/vue@8.8.8", "", { "dependencies": { "@ionic/core": "8.8.8", "@stencil/vue-output-target": "0.10.7", "ionicons": "^8.0.13" } }, "sha512-7Yfv6HUPpKXqYy9qWtx/8Cntn7DzskooUCSFoIjj35sUXRyTwEUWFnQM0AqGkxH+qtO5PeCPwq9VzBdVzqIgDA=="],
"@ionic/vue-router": ["@ionic/vue-router@8.8.8", "", { "dependencies": { "@ionic/vue": "8.8.8" } }, "sha512-mdofM1BXUCWO/J5ourldPQxULSV14rJ1ZrRgGHLFZ9UFEjgvYlPF4jq0Kk2j1hsrwuPpau/ehJM4GFmELGecoA=="],
"@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="], "@ioredis/commands": ["@ioredis/commands@1.10.0", "", {}, "sha512-UmeW7z4LfctwoQ5wkhVzgq8tXkreED2xZGpX+Bg+zA+WJFZCT6c062AfCK/Dfk81xZnnwdhJCUMkitihRaoC2Q=="],
"@isaacs/cliui": ["@isaacs/cliui@8.0.2", "", { "dependencies": { "string-width": "^5.1.2", "string-width-cjs": "npm:string-width@^4.2.0", "strip-ansi": "^7.0.1", "strip-ansi-cjs": "npm:strip-ansi@^6.0.1", "wrap-ansi": "^8.1.0", "wrap-ansi-cjs": "npm:wrap-ansi@^7.0.0" } }, "sha512-O8jcjabXaleOG9DQ0+ARXWZBTfnP4WNAqzuiJK7ll44AmxGKv/J2M4TPjxjY3znBCfvBXFzucm1twdyFybFqEA=="],
"@istanbuljs/schema": ["@istanbuljs/schema@0.1.6", "", {}, "sha512-+Sg6GCR/wy1oSmQDFq4LQDAhm3ETKnorxN+y5nbLULOR3P0c14f2Wurzj3/xqPXtasLFfHd5iRFQ7AJt4KH2cw=="],
"@jridgewell/gen-mapping": ["@jridgewell/gen-mapping@0.3.13", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.0", "@jridgewell/trace-mapping": "^0.3.24" } }, "sha512-2kkt/7niJ6MgEPxF0bYdQ6etZaA+fQvDcLKckhy1yIQOzaoKjBBjSj63/aLVjYE3qhRt5dvM+uUyfCg6UKCBbA=="],
"@jridgewell/resolve-uri": ["@jridgewell/resolve-uri@3.1.2", "", {}, "sha512-bRISgCIjP20/tbWSPWMEi54QVPRZExkuD9lJL+UIxUKtwVJA8wW1Trb1jMs1RFXo1CBTNZ/5hpC9QvmKWdopKw=="],
"@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="], "@jridgewell/sourcemap-codec": ["@jridgewell/sourcemap-codec@1.5.5", "", {}, "sha512-cYQ9310grqxueWbl+WuIUIaiUaDcj7WOq5fVhEljNVgRfOUhY9fy2zTvfoqWsnebh8Sl70VScFbICvJnLKB0Og=="],
"@jridgewell/trace-mapping": ["@jridgewell/trace-mapping@0.3.31", "", { "dependencies": { "@jridgewell/resolve-uri": "^3.1.0", "@jridgewell/sourcemap-codec": "^1.4.14" } }, "sha512-zzNR+SdQSDJzc8joaeP8QQoCQr8NuYx2dIIytl1QeBEZHJ9uW6hebsrYgbz8hJwUQao3TWCMtmfV8Nu1twOLAw=="],
"@one-ini/wasm": ["@one-ini/wasm@0.1.1", "", {}, "sha512-XuySG1E38YScSJoMlqovLru4KTUNSjgVTIjyh7qMX6aNN5HY5Ct5LhRJdxO79JtTzKfzV/bnWpz+zquYrISsvw=="],
"@pkgjs/parseargs": ["@pkgjs/parseargs@0.11.0", "", {}, "sha512-+1VkjdD0QBLPodGrJUeqarH8VAIvQODIbwh9XpP5Syisf7YoQgsJKPNFoqqLQlu+VQ/tVSshMR6loPMn8U+dPg=="],
"@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="], "@prisma/client": ["@prisma/client@5.22.0", "", { "peerDependencies": { "prisma": "*" }, "optionalPeers": ["prisma"] }, "sha512-M0SVXfyHnQREBKxCgyo7sffrKttwE6R8PMq330MIUF0pTwjUhLbW84pFDlf06B27XyCR++VtjugEnIHdr07SVA=="],
"@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="], "@prisma/debug": ["@prisma/debug@5.22.0", "", {}, "sha512-AUt44v3YJeggO2ZU5BkXI7M4hu9BF2zzH2iF2V5pyXT/lRTyWiElZ7It+bRH1EshoMRxHgpYg4VB6rCM+mG5jQ=="],
@@ -164,10 +177,6 @@
"@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="], "@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.60.4", "", { "os": "win32", "cpu": "x64" }, "sha512-QVTUovf40zgTqlFVrKA1uXMVvU2QWEFWfAH8Wdc48IxLvrJMQVMBRjuQyUpzZCDkakImib9eVazbWlC6ksWtJw=="],
"@stencil/core": ["@stencil/core@4.43.5", "", { "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.44.0", "@rollup/rollup-darwin-x64": "4.44.0", "@rollup/rollup-linux-arm64-gnu": "4.44.0", "@rollup/rollup-linux-arm64-musl": "4.44.0", "@rollup/rollup-linux-x64-gnu": "4.44.0", "@rollup/rollup-linux-x64-musl": "4.44.0", "@rollup/rollup-win32-arm64-msvc": "4.44.0", "@rollup/rollup-win32-x64-msvc": "4.44.0" }, "bin": { "stencil": "bin/stencil" } }, "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q=="],
"@stencil/vue-output-target": ["@stencil/vue-output-target@0.10.7", "", { "peerDependencies": { "@stencil/core": ">=2.0.0 || >=3 || >= 4.0.0-beta.0 || >= 4.0.0", "vue": "^3.4.38", "vue-router": "^4.5.0" }, "optionalPeers": ["@stencil/core", "vue-router"] }, "sha512-IYxDe+SLCkwhwsWRdynE31rTK1zN3hVwwojQ/V9lrN8Gnx4PTvrUQHiRno9jFo1dk+EaBZWX9gZSmXta0ZaZew=="],
"@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="], "@types/bun": ["@types/bun@1.3.14", "", { "dependencies": { "bun-types": "1.3.14" } }, "sha512-h1hFqFVcvAvD9j9K7ZW7vd82aSA+rTdznZa+5bwvCwqSB1jmmfLcbIWhOLx1/+boy/xmjgCs/OMUL8hRJSmnPw=="],
"@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="], "@types/estree": ["@types/estree@1.0.8", "", {}, "sha512-dWHzHa2WqEXI/O1E9OjrocMTKJl2mSrEolh1Iomrv6U+JuNwaHXsXx9bLu5gG7BUWFIN0skIQJQ/L1rIex4X6w=="],
@@ -176,6 +185,22 @@
"@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="], "@vitejs/plugin-vue": ["@vitejs/plugin-vue@5.2.4", "", { "peerDependencies": { "vite": "^5.0.0 || ^6.0.0", "vue": "^3.2.25" } }, "sha512-7Yx/SXSOcQq5HiiV3orevHUFn+pmMB4cgbEkDYgnkUWb0WfeQ/wa2yFv6D5ICiCQOVpjA7vYDXrC7AGO8yjDHA=="],
"@vitest/coverage-v8": ["@vitest/coverage-v8@2.1.9", "", { "dependencies": { "@ampproject/remapping": "^2.3.0", "@bcoe/v8-coverage": "^0.2.3", "debug": "^4.3.7", "istanbul-lib-coverage": "^3.2.2", "istanbul-lib-report": "^3.0.1", "istanbul-lib-source-maps": "^5.0.6", "istanbul-reports": "^3.1.7", "magic-string": "^0.30.12", "magicast": "^0.3.5", "std-env": "^3.8.0", "test-exclude": "^7.0.1", "tinyrainbow": "^1.2.0" }, "peerDependencies": { "@vitest/browser": "2.1.9", "vitest": "2.1.9" }, "optionalPeers": ["@vitest/browser"] }, "sha512-Z2cOr0ksM00MpEfyVE8KXIYPEcBFxdbLSs56L8PO0QQMxt/6bDj45uQfxoc96v05KW3clk7vvgP0qfDit9DmfQ=="],
"@vitest/expect": ["@vitest/expect@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-UJCIkTBenHeKT1TTlKMJWy1laZewsRIzYighyYiJKZreqtdxSos/S1t+ktRMQWu2CKqaarrkeszJx1cgC5tGZw=="],
"@vitest/mocker": ["@vitest/mocker@2.1.9", "", { "dependencies": { "@vitest/spy": "2.1.9", "estree-walker": "^3.0.3", "magic-string": "^0.30.12" }, "peerDependencies": { "msw": "^2.4.9", "vite": "^5.0.0" }, "optionalPeers": ["msw", "vite"] }, "sha512-tVL6uJgoUdi6icpxmdrn5YNo3g3Dxv+IHJBr0GXHaEdTcw3F+cPKnsXFhli6nO+f/6SDKPHEK1UN+k+TQv0Ehg=="],
"@vitest/pretty-format": ["@vitest/pretty-format@2.1.9", "", { "dependencies": { "tinyrainbow": "^1.2.0" } }, "sha512-KhRIdGV2U9HOUzxfiHmY8IFHTdqtOhIzCpd8WRdJiE7D/HUcZVD0EgQCVjm+Q9gkUXWgBvMmTtZgIG48wq7sOQ=="],
"@vitest/runner": ["@vitest/runner@2.1.9", "", { "dependencies": { "@vitest/utils": "2.1.9", "pathe": "^1.1.2" } }, "sha512-ZXSSqTFIrzduD63btIfEyOmNcBmQvgOVsPNPe0jYtESiXkhd8u2erDLnMxmGrDCwHCCHE7hxwRDCT3pt0esT4g=="],
"@vitest/snapshot": ["@vitest/snapshot@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "magic-string": "^0.30.12", "pathe": "^1.1.2" } }, "sha512-oBO82rEjsxLNJincVhLhaxxZdEtV0EFHMK5Kmx5sJ6H9L183dHECjiefOAdnqpIgT5eZwT04PoggUnW88vOBNQ=="],
"@vitest/spy": ["@vitest/spy@2.1.9", "", { "dependencies": { "tinyspy": "^3.0.2" } }, "sha512-E1B35FwzXXTs9FHNK6bDszs7mtydNi5MIfUWpceJ8Xbfb1gBMscAnwLbEu+B44ed6W3XjL9/ehLPHR1fkf1KLQ=="],
"@vitest/utils": ["@vitest/utils@2.1.9", "", { "dependencies": { "@vitest/pretty-format": "2.1.9", "loupe": "^3.1.2", "tinyrainbow": "^1.2.0" } }, "sha512-v0psaMSkNJ3A2NMrUEHFRzJtDPFn+/VWZ5WxImB21T9fjucJRmS7xCS3ppEnARb9y11OAzaD+P2Ps+b+BGX5iQ=="],
"@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="], "@volar/language-core": ["@volar/language-core@2.4.15", "", { "dependencies": { "@volar/source-map": "2.4.15" } }, "sha512-3VHw+QZU0ZG9IuQmzT68IyN4hZNd9GchGPhbD9+pa8CVv7rnoOZwo7T8weIbrRmihqy3ATpdfXFnqRrfPVK6CA=="],
"@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="], "@volar/source-map": ["@volar/source-map@2.4.15", "", {}, "sha512-CPbMWlUN6hVZJYGcU/GSoHu4EnCHiLaXI9n8c9la6RaI9W5JHX+NqG+GSQcB0JdC2FIBLdZJwGsfKyBB71VlTg=="],
@@ -206,43 +231,119 @@
"@vue/shared": ["@vue/shared@3.5.35", "", {}, "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="], "@vue/shared": ["@vue/shared@3.5.35", "", {}, "sha512-zSbjL7gRXwks2ZQLRGCajBtBXEOXW9Ddhn/HvSdrGkE2dqGnumzW8XtusRrxrE9LvqtiqDXQ+A60Hp6mvdYxfA=="],
"@vue/test-utils": ["@vue/test-utils@2.4.10", "", { "dependencies": { "js-beautify": "^1.14.9", "vue-component-type-helpers": "^3.0.0" }, "peerDependencies": { "@vue/compiler-dom": "3.x", "@vue/server-renderer": "3.x", "vue": "3.x" }, "optionalPeers": ["@vue/server-renderer"] }, "sha512-SmoZ5EA1kYiAFs9NkYdiFFQF+cSnUwnvlYEbY+DogWQZUiqOm/Y29eSbc5T6yi75SgSF9863SBeXniIEoPajCA=="],
"abbrev": ["abbrev@2.0.0", "", {}, "sha512-6/mh1E2u2YgEsCHdY0Yx5oW+61gZU+1vXaoiHHrpKeuRNNgFvS+/jrwHiQhB5apAf5oB7UB7E19ol2R2LKH8hQ=="],
"alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="], "alien-signals": ["alien-signals@1.0.13", "", {}, "sha512-OGj9yyTnJEttvzhTUWuscOvtqxq5vrhF7vL9oS0xJ2mK0ItPYP1/y+vCFebfxoEyAz0++1AIwJ5CMr+Fk3nDmg=="],
"balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="], "ansi-regex": ["ansi-regex@6.2.2", "", {}, "sha512-Bq3SmSpyFHaWjPk8If9yc6svM8c56dB5BAtW4Qbw5jHTwwXXcTLoRMkpDJp6VL0XzlWaCHTXrkFURMYmD0sLqg=="],
"brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="], "ansi-styles": ["ansi-styles@6.2.3", "", {}, "sha512-4Dj6M28JB+oAH8kFkTLUo+a2jwOFkuqb3yucU0CANcRRUbxS0cP0nZYCGjcc3BNXwRIsUVmDGgzawme7zvJHvg=="],
"assertion-error": ["assertion-error@2.0.1", "", {}, "sha512-Izi8RQcffqCeNVgFigKli1ssklIbpHnCYc6AknXGYoB6grJqyeby7jv12JUQgmTAnIDnbck1uxksT4dzN3PWBA=="],
"balanced-match": ["balanced-match@4.0.4", "", {}, "sha512-BLrgEcRTwX2o6gGxGOCNyMvGSp35YofuYzw9h1IMTRmKqttAZZVU67bdb9Pr2vUHA8+j3i2tJfjO6C6+4myGTA=="],
"brace-expansion": ["brace-expansion@5.0.6", "", { "dependencies": { "balanced-match": "^4.0.2" } }, "sha512-kLpxurY4Z4r9sgMsyG0Z9uzsBlgiU/EFKhj/h91/8yHu0edo7XuixOIH3VcJ8kkxs6/jPzoI6U9Vj3WqbMQ94g=="],
"bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="], "bun-types": ["bun-types@1.3.14", "", { "dependencies": { "@types/node": "*" } }, "sha512-4N0ig0fEomHt5R0KCFWjovxow98rIoRwKolrYdCcknNwMekCXRnWEUvgu5soYV8QXtVsrUD8B95MBOZGPvr6KQ=="],
"cac": ["cac@6.7.14", "", {}, "sha512-b6Ilus+c3RrdDk+JhLKUAQfzzgLEPy6wcXqS7f/xe1EETvsDP6GORG7SFuOs6cID5YkqchW/LXZbX5bc8j7ZcQ=="],
"chai": ["chai@5.3.3", "", { "dependencies": { "assertion-error": "^2.0.1", "check-error": "^2.1.1", "deep-eql": "^5.0.1", "loupe": "^3.1.0", "pathval": "^2.0.0" } }, "sha512-4zNhdJD/iOjSH0A05ea+Ke6MU5mmpQcbQsSOkgdaUMJ9zTlDTD/GYlwohmIE2u0gaxHYiVHEn1Fw9mZ/ktJWgw=="],
"check-error": ["check-error@2.1.3", "", {}, "sha512-PAJdDJusoxnwm1VwW07VWwUN1sl7smmC3OKggvndJFadxxDRyFJBX/ggnu/KE4kQAB7a3Dp8f/YXC1FlUprWmA=="],
"cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="], "cluster-key-slot": ["cluster-key-slot@1.1.1", "", {}, "sha512-rwHwUfXL40Chm1r08yrhU3qpUvdVlgkKNeyeGPOxnW8/SyVDvgRaed/Uz54AqWNaTCAThlj6QAs3TZcKI0xDEw=="],
"color-convert": ["color-convert@2.0.1", "", { "dependencies": { "color-name": "~1.1.4" } }, "sha512-RRECPsj7iu/xb5oKYcsFHSppFNnsj/52OVTRKb4zP5onXwVF3zVmmToNcOfGC+CRDpfK/U584fMg38ZHCaElKQ=="],
"color-name": ["color-name@1.1.4", "", {}, "sha512-dOy+3AuW3a2wNbZHIuMZpTcgjGuLU/uBL/ubcZF9OXbDo8ff4O8yVp5Bf0efS8uEoYo5q4Fx7dY9OgQGXgAsQA=="],
"commander": ["commander@10.0.1", "", {}, "sha512-y4Mg2tXshplEbSGzx7amzPwKKOCGuoSRP/CjEdwwk0FOGlUbq6lKuoyDZTNZkmxHdJtp54hdfY/JUrdL7Xfdug=="],
"config-chain": ["config-chain@1.1.13", "", { "dependencies": { "ini": "^1.3.4", "proto-list": "~1.2.1" } }, "sha512-qj+f8APARXHrM0hraqXYb2/bOVSV4PvJQlNZ/DVj0QrmNM2q2euizkeuVckQ57J+W0mRH6Hvi+k50M4Jul2VRQ=="],
"cross-spawn": ["cross-spawn@7.0.6", "", { "dependencies": { "path-key": "^3.1.0", "shebang-command": "^2.0.0", "which": "^2.0.1" } }, "sha512-uV2QOWP2nWzsy2aMp8aRibhi9dlzF5Hgh5SHaB9OiTGEyDTiJJyx0uy51QXdyWbtAHNua4XJzUKca3OzKUd3vA=="],
"csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="], "csstype": ["csstype@3.2.3", "", {}, "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ=="],
"de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="], "de-indent": ["de-indent@1.0.2", "", {}, "sha512-e/1zu3xH5MQryN2zdVaF0OrdNLUbvWxzMbi+iNA6Bky7l1RoP8a2fIbRocyHclXt/arDrrR6lL3TqFD9pMQTsg=="],
"debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="], "debug": ["debug@4.4.3", "", { "dependencies": { "ms": "^2.1.3" } }, "sha512-RGwwWnwQvkVfavKVt22FGLw+xYSdzARwm0ru6DhTVA3umU5hZc28V3kO4stgYryrTlLpuvgI9GiijltAjNbcqA=="],
"deep-eql": ["deep-eql@5.0.2", "", {}, "sha512-h5k/5U50IJJFpzfL6nO9jaaumfjO/f2NjK/oYB2Djzm4p9L+3T9qWpZqZ2hAbLPuuYq9wrU08WQyBTL5GbPk5Q=="],
"denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="], "denque": ["denque@2.1.0", "", {}, "sha512-HVQE3AAb/pxF8fQAoiqpvg9i3evqug3hoiwakOyZAwJm+6vZehbkYXZ0l4JxS+I3QxM97v5aaRNhj8v5oBhekw=="],
"entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="], "eastasianwidth": ["eastasianwidth@0.2.0", "", {}, "sha512-I88TYZWc9XiYHRQ4/3c5rjjfgkjhLyW2luGIheGERbNQ6OY7yTybanSpDXZa8y7VUP9YmDcYa+eyq4ca7iLqWA=="],
"editorconfig": ["editorconfig@1.0.7", "", { "dependencies": { "@one-ini/wasm": "0.1.1", "commander": "^10.0.0", "minimatch": "^9.0.1", "semver": "^7.5.3" }, "bin": { "editorconfig": "bin/editorconfig" } }, "sha512-e0GOtq/aTQhVdNyDU9e02+wz9oDDM+SIOQxWME2QRjzRX5yyLAuHDE+0aE8vHb9XRC8XD37eO2u57+F09JqFhw=="],
"emoji-regex": ["emoji-regex@9.2.2", "", {}, "sha512-L18DaJsXSUk2+42pv8mLs5jJT2hqFkFE4j21wOmgbUqsZ2hL72NsUU785g9RXgo3s0ZNgVl42TiHp3ZtOv/Vyg=="],
"entities": ["entities@4.5.0", "", {}, "sha512-V0hjH4dGPh9Ao5p0MoRY6BVqtwCjhz6vI5LT8AJ55H+4g9/4vbHx1I54fS0XuclLhDHArPQCiMjDxjaL8fPxhw=="],
"es-module-lexer": ["es-module-lexer@1.7.0", "", {}, "sha512-jEQoCwk8hyb2AZziIOLhDqpm5+2ww5uIE6lkO/6jcOCusfk6LhMHpXXfBLXTZ7Ydyt0j4VoUQv6uGNYbdW+kBA=="],
"esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="], "esbuild": ["esbuild@0.21.5", "", { "optionalDependencies": { "@esbuild/aix-ppc64": "0.21.5", "@esbuild/android-arm": "0.21.5", "@esbuild/android-arm64": "0.21.5", "@esbuild/android-x64": "0.21.5", "@esbuild/darwin-arm64": "0.21.5", "@esbuild/darwin-x64": "0.21.5", "@esbuild/freebsd-arm64": "0.21.5", "@esbuild/freebsd-x64": "0.21.5", "@esbuild/linux-arm": "0.21.5", "@esbuild/linux-arm64": "0.21.5", "@esbuild/linux-ia32": "0.21.5", "@esbuild/linux-loong64": "0.21.5", "@esbuild/linux-mips64el": "0.21.5", "@esbuild/linux-ppc64": "0.21.5", "@esbuild/linux-riscv64": "0.21.5", "@esbuild/linux-s390x": "0.21.5", "@esbuild/linux-x64": "0.21.5", "@esbuild/netbsd-x64": "0.21.5", "@esbuild/openbsd-x64": "0.21.5", "@esbuild/sunos-x64": "0.21.5", "@esbuild/win32-arm64": "0.21.5", "@esbuild/win32-ia32": "0.21.5", "@esbuild/win32-x64": "0.21.5" }, "bin": { "esbuild": "bin/esbuild" } }, "sha512-mg3OPMV4hXywwpoDxu3Qda5xCKQi+vCTZq8S9J/EpkhB2HzKXq4SNFZE3+NK93JYxc8VMSep+lOUSC/RVKaBqw=="],
"estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="], "estree-walker": ["estree-walker@3.0.3", "", { "dependencies": { "@types/estree": "^1.0.0" } }, "sha512-7RUKfXgSMMkzt6ZuXmqapOurLGPPfgj6l9uRZ7lRGolvk0y2yocc35LdcxKC5PQZdn2DMqioAQ2NoWcrTKmm6g=="],
"expect-type": ["expect-type@1.3.0", "", {}, "sha512-knvyeauYhqjOYvQ66MznSMs83wmHrCycNEN6Ao+2AeYEfxUIkuiVxdEa1qlGEPK+We3n0THiDciYSsCcgW/DoA=="],
"foreground-child": ["foreground-child@3.3.1", "", { "dependencies": { "cross-spawn": "^7.0.6", "signal-exit": "^4.0.1" } }, "sha512-gIXjKqtFuWEgzFRJA9WCQeSJLZDjgJUOMCMzxtvFq/37KojM1BFGufqsCy0r4qSQmYLsZYMeyRqzIWOMup03sw=="],
"fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="], "fsevents": ["fsevents@2.3.3", "", { "os": "darwin" }, "sha512-5xoDfX+fL7faATnagmWPpbFtwh/R77WmMMqqHGS65C3vvB0YHrgF+B1YmZ3441tMj5n63k0212XNoJwzlhffQw=="],
"glob": ["glob@10.5.0", "", { "dependencies": { "foreground-child": "^3.1.0", "jackspeak": "^3.1.2", "minimatch": "^9.0.4", "minipass": "^7.1.2", "package-json-from-dist": "^1.0.0", "path-scurry": "^1.11.1" }, "bin": { "glob": "dist/esm/bin.mjs" } }, "sha512-DfXN8DfhJ7NH3Oe7cFmu3NCu1wKbkReJ8TorzSAFbSKrlNaQSKfIzqYqVY8zlbs2NLBbWpRiU52GX2PbaBVNkg=="],
"happy-dom": ["happy-dom@15.11.7", "", { "dependencies": { "entities": "^4.5.0", "webidl-conversions": "^7.0.0", "whatwg-mimetype": "^3.0.0" } }, "sha512-KyrFvnl+J9US63TEzwoiJOQzZBJY7KgBushJA8X61DMbNsH+2ONkDuLDnCnwUiPTF42tLoEmrPyoqbenVA5zrg=="],
"has-flag": ["has-flag@4.0.0", "", {}, "sha512-EykJT/Q1KjTWctppgIAgfSO0tKVuZUjhgMr17kqTumMl6Afv3EISleU7qZUzoXDFTAHTDC4NOoG/ZxU3EvlMPQ=="],
"he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="], "he": ["he@1.2.0", "", { "bin": { "he": "bin/he" } }, "sha512-F/1DnUGPopORZi0ni+CvrCgHQ5FyEAHRLSApuYWMmrbSwoN2Mn/7k+Gl38gJnR7yyDZk6WLXwiGod1JOWNDKGw=="],
"hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="], "hono": ["hono@4.12.23", "", {}, "sha512-eIaZ9qDgu7XV0pxOCrg7/WhnQ6Ivm22UcxhXx/A3dcbqbbYgBEkc6e/J/s7j2tS96zoB0S9VBdLwQNCWwUo4LA=="],
"ionicons": ["ionicons@7.4.0", "", { "dependencies": { "@stencil/core": "^4.0.3" } }, "sha512-ZK94MMqgzMCPPMhmk8Ouu6goyVHFIlw/ACP6oe3FrikcI0N7CX0xcwVaEbUc0G/v3W0shI93vo+9ve/KpvcNhQ=="], "html-escaper": ["html-escaper@2.0.2", "", {}, "sha512-H2iMtd0I4Mt5eYiapRdIDjp+XzelXQ0tFE4JS7YFwFevXXMmOp9myNrUvCg0D6ws8iqkRPBfKHgbwig1SmlLfg=="],
"ini": ["ini@1.3.8", "", {}, "sha512-JV/yugV2uzW5iMRSiZAyDtQd+nxtUnjeLt0acNdw98kKLrvuRVyB80tsREOE7yvGVgalhZ6RNXCmEHkUKBKxew=="],
"ioredis": ["ioredis@5.11.0", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg=="], "ioredis": ["ioredis@5.11.0", "", { "dependencies": { "@ioredis/commands": "1.10.0", "cluster-key-slot": "1.1.1", "debug": "4.4.3", "denque": "2.1.0", "redis-errors": "1.2.0", "redis-parser": "3.0.0", "standard-as-callback": "2.1.0" } }, "sha512-EZBErytyVovD8f6pDfG3Kb37N6Y3lmDA9NNj+4+IP13CzzHGeX+OyeRM2Um13khRzoBSzzL+5lVnCX8V2RLeMg=="],
"is-fullwidth-code-point": ["is-fullwidth-code-point@3.0.0", "", {}, "sha512-zymm5+u+sCsSWyD9qNaejV3DFvhCKclKdizYaJUuHA83RLjb7nSuGnddCHGv0hk+KY7BMAlsWeK4Ueg6EV6XQg=="],
"isexe": ["isexe@2.0.0", "", {}, "sha512-RHxMLp9lnKHGHRng9QFhRCMbYAcVpn69smSGcq3f36xjgVVWThj4qqLbTLlq7Ssj8B+fIQ1EuCEGI2lKsyQeIw=="],
"istanbul-lib-coverage": ["istanbul-lib-coverage@3.2.2", "", {}, "sha512-O8dpsF+r0WV/8MNRKfnmrtCWhuKjxrq2w+jpzBL5UZKTi2LeVWnWOmWRxFlesJONmc+wLAGvKQZEOanko0LFTg=="],
"istanbul-lib-report": ["istanbul-lib-report@3.0.1", "", { "dependencies": { "istanbul-lib-coverage": "^3.0.0", "make-dir": "^4.0.0", "supports-color": "^7.1.0" } }, "sha512-GCfE1mtsHGOELCU8e/Z7YWzpmybrx/+dSTfLrvY8qRmaY6zXTKWn6WQIjaAFw069icm6GVMNkgu0NzI4iPZUNw=="],
"istanbul-lib-source-maps": ["istanbul-lib-source-maps@5.0.6", "", { "dependencies": { "@jridgewell/trace-mapping": "^0.3.23", "debug": "^4.1.1", "istanbul-lib-coverage": "^3.0.0" } }, "sha512-yg2d+Em4KizZC5niWhQaIomgf5WlL4vOOjZ5xGCmF8SnPE/mDWWXgvRExdcpCgh9lLRRa1/fSYp2ymmbJ1pI+A=="],
"istanbul-reports": ["istanbul-reports@3.2.0", "", { "dependencies": { "html-escaper": "^2.0.0", "istanbul-lib-report": "^3.0.0" } }, "sha512-HGYWWS/ehqTV3xN10i23tkPkpH46MLCIMFNCaaKNavAXTF1RkqxawEPtnjnGZ6XKSInBKkiOA5BKS+aZiY3AvA=="],
"jackspeak": ["jackspeak@3.4.3", "", { "dependencies": { "@isaacs/cliui": "^8.0.2" }, "optionalDependencies": { "@pkgjs/parseargs": "^0.11.0" } }, "sha512-OGlZQpz2yfahA/Rd1Y8Cd9SIEsqvXkLVoSw/cgwhnhFMDbsQFeZYoJJ7bIZBS9BcamUW96asq/npPWugM+RQBw=="],
"js-beautify": ["js-beautify@1.15.4", "", { "dependencies": { "config-chain": "^1.1.13", "editorconfig": "^1.0.4", "glob": "^10.4.2", "js-cookie": "^3.0.5", "nopt": "^7.2.1" }, "bin": { "css-beautify": "js/bin/css-beautify.js", "html-beautify": "js/bin/html-beautify.js", "js-beautify": "js/bin/js-beautify.js" } }, "sha512-9/KXeZUKKJwqCXUdBxFJ3vPh467OCckSBmYDwSK/EtV090K+iMJ7zx2S3HLVDIWFQdqMIsZWbnaGiba18aWhaA=="],
"js-cookie": ["js-cookie@3.0.8", "", {}, "sha512-yeJd4aNAdYZQjaon2bpD/Gb0B/omw7HQOsynXXcOiWVCacbBcPlgn8S/d1X6blFSaHao7ozqtW7NZW19xpCtIw=="],
"loupe": ["loupe@3.2.1", "", {}, "sha512-CdzqowRJCeLU72bHvWqwRBBlLcMEtIvGrlvef74kMnV2AolS9Y8xUv1I0U/MNAWMhBlKIoyuEgoJ0t/bbwHbLQ=="],
"lru-cache": ["lru-cache@10.4.3", "", {}, "sha512-JNAzZcXrCt42VGLuYz0zfAzDfAvJWW6AfYlDBQyDV5DClI2m5sAmK+OIO7s59XfsRsWHp02jAJrRadPRGTt6SQ=="],
"magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="], "magic-string": ["magic-string@0.30.21", "", { "dependencies": { "@jridgewell/sourcemap-codec": "^1.5.5" } }, "sha512-vd2F4YUyEXKGcLHoq+TEyCjxueSeHnFxyyjNp80yg0XV4vUhnDer/lvvlqM/arB5bXQN5K2/3oinyCRyx8T2CQ=="],
"minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="], "magicast": ["magicast@0.3.5", "", { "dependencies": { "@babel/parser": "^7.25.4", "@babel/types": "^7.25.4", "source-map-js": "^1.2.0" } }, "sha512-L0WhttDl+2BOsybvEOLK7fW3UA0OQ0IQ2d6Zl2x/a6vVRs3bAY0ECOSHHeL5jD+SbOpOCUEi0y1DgHEn9Qn1AQ=="],
"make-dir": ["make-dir@4.0.0", "", { "dependencies": { "semver": "^7.5.3" } }, "sha512-hXdUTZYIVOt1Ex//jAQi+wTZZpUpwBj/0QsOzqegb3rGMMeJiSEu5xLHnYfBrRV4RH2+OCSOO95Is/7x1WJ4bw=="],
"minimatch": ["minimatch@10.2.5", "", { "dependencies": { "brace-expansion": "^5.0.5" } }, "sha512-MULkVLfKGYDFYejP07QOurDLLQpcjk7Fw+7jXS2R2czRQzR56yHRveU5NDJEOviH+hETZKSkIk5c+T23GjFUMg=="],
"minipass": ["minipass@7.1.3", "", {}, "sha512-tEBHqDnIoM/1rXME1zgka9g6Q2lcoCkxHLuc7ODJ5BxbP5d4c2Z5cGgtXAku59200Cx7diuHTOYfSBD8n6mm8A=="],
"ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="], "ms": ["ms@2.1.3", "", {}, "sha512-6FlzubTLZG3J2a/NVCAleEhjzq5oxgHyaCU9yYXvcLsvoVaHJq/s5xXI6/XXP6tz7R9xAOtHnSO/tXtF3WRTlA=="],
@@ -250,25 +351,73 @@
"nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="], "nanoid": ["nanoid@3.3.12", "", { "bin": { "nanoid": "bin/nanoid.cjs" } }, "sha512-ZB9RH/39qpq5Vu6Y+NmUaFhQR6pp+M2Xt76XBnEwDaGcVAqhlvxrl3B2bKS5D3NH3QR76v3aSrKaF/Kiy7lEtQ=="],
"nopt": ["nopt@7.2.1", "", { "dependencies": { "abbrev": "^2.0.0" }, "bin": { "nopt": "bin/nopt.js" } }, "sha512-taM24ViiimT/XntxbPyJQzCG+p4EKOpgD3mxFwW38mGjVUrfERQOeY4EDHjdnptttfHuHQXFx+lTP08Q+mLa/w=="],
"package-json-from-dist": ["package-json-from-dist@1.0.1", "", {}, "sha512-UEZIS3/by4OC8vL3P2dTXRETpebLI2NiI5vIrjaD/5UtrkFX/tNbwjTSRAGC/+7CAo2pIcBaRgWmcBBHcsaCIw=="],
"path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="], "path-browserify": ["path-browserify@1.0.1", "", {}, "sha512-b7uo2UCUOYZcnF/3ID0lulOJi/bafxa1xPe7ZPsammBSpjSWQkjNxlt635YGS2MiR9GjvuXCtz2emr3jbsz98g=="],
"path-key": ["path-key@3.1.1", "", {}, "sha512-ojmeN0qd+y0jszEtoY48r0Peq5dwMEkIlCOu6Q5f41lfkswXuKtYrhgoTpLnyIcHm24Uhqx+5Tqm2InSwLhE6Q=="],
"path-scurry": ["path-scurry@1.11.1", "", { "dependencies": { "lru-cache": "^10.2.0", "minipass": "^5.0.0 || ^6.0.2 || ^7.0.0" } }, "sha512-Xa4Nw17FS9ApQFJ9umLiJS4orGjm7ZzwUrwamcGQuHSzDyth9boKDaycYdDcZDuqYATXw4HFXgaqWTctW/v1HA=="],
"pathe": ["pathe@1.1.2", "", {}, "sha512-whLdWMYL2TwI08hn8/ZqAbrVemu0LNaNNJZX73O6qaIdCTfXutsLhMkjdENX0qhsQ9uIimo4/aQOmXkoon2nDQ=="],
"pathval": ["pathval@2.0.1", "", {}, "sha512-//nshmD55c46FuFw26xV/xFAaB5HF9Xdap7HJBBnrKdAd6/GxDBaNA1870O79+9ueg61cZLSVc+OaFlfmObYVQ=="],
"picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="], "picocolors": ["picocolors@1.1.1", "", {}, "sha512-xceH2snhtb5M9liqDsmEw56le376mTZkEX/jEb/RxNFyegNul7eNslCXP9FDj/Lcu0X8KEyMceP2ntpaHrDEVA=="],
"postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="], "postcss": ["postcss@8.5.15", "", { "dependencies": { "nanoid": "^3.3.12", "picocolors": "^1.1.1", "source-map-js": "^1.2.1" } }, "sha512-FfR8sjd4em2T6fb3I2MwAJU7HWVMr9zba+enmQeeWFfCbm+UOC/0X4DS8XtpUTMwWMGbjKYP7xjfNekzyGmB3A=="],
"prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="], "prisma": ["prisma@5.22.0", "", { "dependencies": { "@prisma/engines": "5.22.0" }, "optionalDependencies": { "fsevents": "2.3.3" }, "bin": { "prisma": "build/index.js" } }, "sha512-vtpjW3XuYCSnMsNVBjLMNkTj6OZbudcPPTPYHqX0CJfpcdWciI1dM8uHETwmDxxiqEwCIE6WvXucWUetJgfu/A=="],
"proto-list": ["proto-list@1.2.4", "", {}, "sha512-vtK/94akxsTMhe0/cbfpR+syPuszcuwhqVjJq26CuNDgFGj682oRBXOP5MJpv2r7JtE8MsiepGIqvvOTBwn2vA=="],
"redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="], "redis-errors": ["redis-errors@1.2.0", "", {}, "sha512-1qny3OExCf0UvUV/5wpYKf2YwPcOqXzkwKKSmKHiE6ZMQs5heeE/c8eXK+PNllPvmjgAbfnsbpkGZWy8cBpn9w=="],
"redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="], "redis-parser": ["redis-parser@3.0.0", "", { "dependencies": { "redis-errors": "^1.0.0" } }, "sha512-DJnGAeenTdpMEH6uAJRK/uiyEIH9WVsUmoLwzudwGJUwZPp80PDBWPHXSAGNPwNvIXAbe7MSUB1zQFugFml66A=="],
"rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="], "rollup": ["rollup@4.60.4", "", { "dependencies": { "@types/estree": "1.0.8" }, "optionalDependencies": { "@rollup/rollup-android-arm-eabi": "4.60.4", "@rollup/rollup-android-arm64": "4.60.4", "@rollup/rollup-darwin-arm64": "4.60.4", "@rollup/rollup-darwin-x64": "4.60.4", "@rollup/rollup-freebsd-arm64": "4.60.4", "@rollup/rollup-freebsd-x64": "4.60.4", "@rollup/rollup-linux-arm-gnueabihf": "4.60.4", "@rollup/rollup-linux-arm-musleabihf": "4.60.4", "@rollup/rollup-linux-arm64-gnu": "4.60.4", "@rollup/rollup-linux-arm64-musl": "4.60.4", "@rollup/rollup-linux-loong64-gnu": "4.60.4", "@rollup/rollup-linux-loong64-musl": "4.60.4", "@rollup/rollup-linux-ppc64-gnu": "4.60.4", "@rollup/rollup-linux-ppc64-musl": "4.60.4", "@rollup/rollup-linux-riscv64-gnu": "4.60.4", "@rollup/rollup-linux-riscv64-musl": "4.60.4", "@rollup/rollup-linux-s390x-gnu": "4.60.4", "@rollup/rollup-linux-x64-gnu": "4.60.4", "@rollup/rollup-linux-x64-musl": "4.60.4", "@rollup/rollup-openbsd-x64": "4.60.4", "@rollup/rollup-openharmony-arm64": "4.60.4", "@rollup/rollup-win32-arm64-msvc": "4.60.4", "@rollup/rollup-win32-ia32-msvc": "4.60.4", "@rollup/rollup-win32-x64-gnu": "4.60.4", "@rollup/rollup-win32-x64-msvc": "4.60.4", "fsevents": "~2.3.2" }, "bin": { "rollup": "dist/bin/rollup" } }, "sha512-WHeFSbZYsPu3+bLoNRUuAO+wavNlocOPf3wSHTP7hcFKVnJeWsYlCDbr3mTS14FCizf9ccIxXA8sGL8zKeQN3g=="],
"semver": ["semver@7.8.1", "", { "bin": { "semver": "bin/semver.js" } }, "sha512-rkVq3IXh+4FDGch+KwzX3aV9W3kO54GyEgpvBzSyctDA6Xtd7RJQV1xmXbeQp5v7+VzLOfVqiutSE6GICgPFvg=="],
"shebang-command": ["shebang-command@2.0.0", "", { "dependencies": { "shebang-regex": "^3.0.0" } }, "sha512-kHxr2zZpYtdmrN1qDjrrX/Z1rR1kG8Dx+gkpK1G4eXmvXswmcE1hTWBWYUzlraYw1/yZp6YuDY77YtvbN0dmDA=="],
"shebang-regex": ["shebang-regex@3.0.0", "", {}, "sha512-7++dFhtcx3353uBaq8DDR4NuxBetBzC7ZQOhmTQInHEd6bSrXdiEyzCvG07Z44UYdLShWUyXt5M/yhz8ekcb1A=="],
"siginfo": ["siginfo@2.0.0", "", {}, "sha512-ybx0WO1/8bSBLEWXZvEd7gMW3Sn3JFlW3TvX1nREbDLRNQNaeNN8WK0meBwPdAaOI7TtRRRJn/Es1zhrrCHu7g=="],
"signal-exit": ["signal-exit@4.1.0", "", {}, "sha512-bzyZ1e88w9O1iNJbKnOlvYTrWPDl46O1bG0D3XInv+9tkPrxrN8jUUTiFlDkkmKWgn1M6CfIA13SuGqOa9Korw=="],
"source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="], "source-map-js": ["source-map-js@1.2.1", "", {}, "sha512-UXWMKhLOwVKb728IUtQPXxfYU+usdybtUrK/8uGE8CQMvrhOpwvzDBwj0QhSL7MQc7vIsISBG8VQ8+IDQxpfQA=="],
"stackback": ["stackback@0.0.2", "", {}, "sha512-1XMJE5fQo1jGH6Y/7ebnwPOBEkIEnT4QF32d5R1+VXdXveM0IBMJt8zfaxX1P3QhVwrYe+576+jkANtSS2mBbw=="],
"standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="], "standard-as-callback": ["standard-as-callback@2.1.0", "", {}, "sha512-qoRRSyROncaz1z0mvYqIE4lCd9p2R90i6GxW3uZv5ucSu8tU7B5HXUP1gG8pVZsYNVaXjk8ClXHPttLyxAL48A=="],
"tslib": ["tslib@2.8.1", "", {}, "sha512-oJFu94HQb+KVduSUQL7wnpmqnfmLsOA/nAh6b6EH0wCEoK0/mPeXU6c3wKDV83MkOuHPRHtSXKKU99IBazS/2w=="], "std-env": ["std-env@3.10.0", "", {}, "sha512-5GS12FdOZNliM5mAOxFRg7Ir0pWz8MdpYm6AY6VPkGpbA7ZzmbzNcBJQ0GPvvyWgcY7QAhCgf9Uy89I03faLkg=="],
"string-width": ["string-width@5.1.2", "", { "dependencies": { "eastasianwidth": "^0.2.0", "emoji-regex": "^9.2.2", "strip-ansi": "^7.0.1" } }, "sha512-HnLOCR3vjcY8beoNLtcjZ5/nxn2afmME6lhrDrebokqMap+XbeW8n9TXpPDOqdGK5qcI3oT0GKTW6wC7EMiVqA=="],
"string-width-cjs": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"strip-ansi": ["strip-ansi@7.2.0", "", { "dependencies": { "ansi-regex": "^6.2.2" } }, "sha512-yDPMNjp4WyfYBkHnjIRLfca1i6KMyGCtsVgoKe/z1+6vukgaENdgGBZt+ZmKPc4gavvEZ5OgHfHdrazhgNyG7w=="],
"strip-ansi-cjs": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"supports-color": ["supports-color@7.2.0", "", { "dependencies": { "has-flag": "^4.0.0" } }, "sha512-qpCAvRl9stuOHveKsn7HncJRvv501qIacKzQlO/+Lwxc9+0q2wLyv4Dfvt80/DPn2pqOBsJdDiogXGR9+OvwRw=="],
"test-exclude": ["test-exclude@7.0.2", "", { "dependencies": { "@istanbuljs/schema": "^0.1.2", "glob": "^10.4.1", "minimatch": "^10.2.2" } }, "sha512-u9E6A+ZDYdp7a4WnarkXPZOx8Ilz46+kby6p1yZ8zsGTz9gYa6FIS7lj2oezzNKmtdyyJNNmmXDppga5GB7kSw=="],
"tinybench": ["tinybench@2.9.0", "", {}, "sha512-0+DUvqWMValLmha6lr4kD8iAMK1HzV0/aKnCtWb9v9641TnP/MFb7Pc2bxoxQjTXAErryXVgUOfv2YqNllqGeg=="],
"tinyexec": ["tinyexec@0.3.2", "", {}, "sha512-KQQR9yN7R5+OSwaK0XQoj22pwHoTlgYqmUscPYoknOoWCWfj/5/ABTMRi69FrKU5ffPVh5QcFikpWJI/P1ocHA=="],
"tinypool": ["tinypool@1.1.1", "", {}, "sha512-Zba82s87IFq9A9XmjiX5uZA/ARWDrB03OHlq+Vw1fSdt0I+4/Kutwy8BP4Y/y/aORMo61FQ0vIb5j44vSo5Pkg=="],
"tinyrainbow": ["tinyrainbow@1.2.0", "", {}, "sha512-weEDEq7Z5eTHPDh4xjX789+fHfF+P8boiFB+0vbWzpbnbsEr/GRaohi/uMKxg8RZMXnl1ItAi/IUHWMsjDV7kQ=="],
"tinyspy": ["tinyspy@3.0.2", "", {}, "sha512-n1cw8k1k0x4pgA2+9XrOkFydTerNcJ1zWCO5Nn9scWHTD+5tp8dghT2x1uduQePZTZgd3Tupf+x9BxJjeJi77Q=="],
"typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="], "typescript": ["typescript@5.9.3", "", { "bin": { "tsc": "bin/tsc", "tsserver": "bin/tsserver" } }, "sha512-jl1vZzPDinLr9eUt3J/t7V6FgNEw9QjvBPdysz9KfQDD41fQrC2Y4vKQdiaUpFT4bXlb1RHhLpp8wtm6M5TgSw=="],
@@ -276,72 +425,76 @@
"vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="], "vite": ["vite@5.4.21", "", { "dependencies": { "esbuild": "^0.21.3", "postcss": "^8.4.43", "rollup": "^4.20.0" }, "optionalDependencies": { "fsevents": "~2.3.3" }, "peerDependencies": { "@types/node": "^18.0.0 || >=20.0.0", "less": "*", "lightningcss": "^1.21.0", "sass": "*", "sass-embedded": "*", "stylus": "*", "sugarss": "*", "terser": "^5.4.0" }, "optionalPeers": ["@types/node", "less", "lightningcss", "sass", "sass-embedded", "stylus", "sugarss", "terser"], "bin": { "vite": "bin/vite.js" } }, "sha512-o5a9xKjbtuhY6Bi5S3+HvbRERmouabWbyUcpXXUA1u+GNUKoROi9byOJ8M0nHbHYHkYICiMlqxkg1KkYmm25Sw=="],
"vite-node": ["vite-node@2.1.9", "", { "dependencies": { "cac": "^6.7.14", "debug": "^4.3.7", "es-module-lexer": "^1.5.4", "pathe": "^1.1.2", "vite": "^5.0.0" }, "bin": { "vite-node": "vite-node.mjs" } }, "sha512-AM9aQ/IPrW/6ENLQg3AGY4K1N2TGZdR5e4gu/MmmR2xR3Ll1+dib+nook92g4TV3PXVyeyxdWwtaCAiUL0hMxA=="],
"vitest": ["vitest@2.1.9", "", { "dependencies": { "@vitest/expect": "2.1.9", "@vitest/mocker": "2.1.9", "@vitest/pretty-format": "^2.1.9", "@vitest/runner": "2.1.9", "@vitest/snapshot": "2.1.9", "@vitest/spy": "2.1.9", "@vitest/utils": "2.1.9", "chai": "^5.1.2", "debug": "^4.3.7", "expect-type": "^1.1.0", "magic-string": "^0.30.12", "pathe": "^1.1.2", "std-env": "^3.8.0", "tinybench": "^2.9.0", "tinyexec": "^0.3.1", "tinypool": "^1.0.1", "tinyrainbow": "^1.2.0", "vite": "^5.0.0", "vite-node": "2.1.9", "why-is-node-running": "^2.3.0" }, "peerDependencies": { "@edge-runtime/vm": "*", "@types/node": "^18.0.0 || >=20.0.0", "@vitest/browser": "2.1.9", "@vitest/ui": "2.1.9", "happy-dom": "*", "jsdom": "*" }, "optionalPeers": ["@edge-runtime/vm", "@types/node", "@vitest/browser", "@vitest/ui", "happy-dom", "jsdom"], "bin": { "vitest": "vitest.mjs" } }, "sha512-MSmPM9REYqDGBI8439mA4mWhV5sKmDlBKWIYbA3lRb2PTHACE0mgKwA8yQ2xq9vxDTuk4iPrECBAEW2aoFXY0Q=="],
"vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="], "vscode-uri": ["vscode-uri@3.1.0", "", {}, "sha512-/BpdSx+yCQGnCvecbyXdxHDkuk55/G3xwnC0GqY4gmQ3j+A+g8kzzgB4Nk/SINjqn6+waqw3EgbVF2QKExkRxQ=="],
"vue": ["vue@3.5.35", "", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="], "vue": ["vue@3.5.35", "", { "dependencies": { "@vue/compiler-dom": "3.5.35", "@vue/compiler-sfc": "3.5.35", "@vue/runtime-dom": "3.5.35", "@vue/server-renderer": "3.5.35", "@vue/shared": "3.5.35" }, "peerDependencies": { "typescript": "*" }, "optionalPeers": ["typescript"] }, "sha512-cx89fnr+0kVGHiNFG6y6s0bdjypJRFNZn6x3WPstNdQR1bi1mbB7h4v5IBGTsPJU3nK1+0Iqj3Zf+hZWMieR4Q=="],
"vue-component-type-helpers": ["vue-component-type-helpers@3.3.3", "", {}, "sha512-x4nsFpy5Pe8fqPzp/5vkTPeTTDBpAx4WVtV47Ejt0+2FQrq4pRRsJs7JmYRqMFzTu/LW+pCWEjQ3YVCkPV7f9g=="],
"vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="], "vue-router": ["vue-router@4.6.4", "", { "dependencies": { "@vue/devtools-api": "^6.6.4" }, "peerDependencies": { "vue": "^3.5.0" } }, "sha512-Hz9q5sa33Yhduglwz6g9skT8OBPii+4bFn88w6J+J4MfEo4KRRpmiNG/hHHkdbRFlLBOqxN8y8gf2Fb0MTUgVg=="],
"vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="], "vue-tsc": ["vue-tsc@2.2.12", "", { "dependencies": { "@volar/typescript": "2.4.15", "@vue/language-core": "2.2.12" }, "peerDependencies": { "typescript": ">=5.0.0" }, "bin": { "vue-tsc": "./bin/vue-tsc.js" } }, "sha512-P7OP77b2h/Pmk+lZdJ0YWs+5tJ6J2+uOQPo7tlBnY44QqQSPYvS0qVT4wqDJgwrZaLe47etJLLQRFia71GYITw=="],
"webidl-conversions": ["webidl-conversions@7.0.0", "", {}, "sha512-VwddBukDzu71offAQR975unBIGqfKZpM+8ZX6ySk8nYhVoo5CYaZyzt3YBvYtRtO+aoGlqxPg/B87NGVZ/fu6g=="],
"whatwg-mimetype": ["whatwg-mimetype@3.0.0", "", {}, "sha512-nt+N2dzIutVRxARx1nghPKGv1xHikU7HKdfafKkLNLindmPU/ch3U31NOCGGA/dmPcmb1VlofO0vnKAcsm0o/Q=="],
"which": ["which@2.0.2", "", { "dependencies": { "isexe": "^2.0.0" }, "bin": { "node-which": "./bin/node-which" } }, "sha512-BLI3Tl1TW3Pvl70l3yq3Y64i+awpwXqsGBYWkkqMtnbXgrMD+yj7rhW0kuEDxzJaYXGjEW5ogapKNMEKNMjibA=="],
"why-is-node-running": ["why-is-node-running@2.3.0", "", { "dependencies": { "siginfo": "^2.0.0", "stackback": "0.0.2" }, "bin": { "why-is-node-running": "cli.js" } }, "sha512-hUrmaWBdVDcxvYqnyh09zunKzROWjbZTiNy8dBEjkS7ehEDQibXJ7XvlmtbwuTclUiIyN+CyXQD4Vmko8fNm8w=="],
"wrap-ansi": ["wrap-ansi@8.1.0", "", { "dependencies": { "ansi-styles": "^6.1.0", "string-width": "^5.0.1", "strip-ansi": "^7.0.1" } }, "sha512-si7QWI6zUMq56bESFvagtmzMdGOtoxfR+Sez11Mobfc7tm+VkUckk9bW2UeffTGVUbOksxmSw0AA2gs8g71NCQ=="],
"wrap-ansi-cjs": ["wrap-ansi@7.0.0", "", { "dependencies": { "ansi-styles": "^4.0.0", "string-width": "^4.1.0", "strip-ansi": "^6.0.0" } }, "sha512-YVGIj2kamLSTxw6NsZjoBxfSwsn0ycdesmc4p+Q21c5zPuZ1pl+NfxVdxPtdHvmNVOQ6XSYG4AUtyt/Fi7D16Q=="],
"xip-backend": ["xip-backend@workspace:backend"], "xip-backend": ["xip-backend@workspace:backend"],
"xip-frontend": ["xip-frontend@workspace:frontend"], "xip-frontend": ["xip-frontend@workspace:frontend"],
"@ionic/core/@stencil/core": ["@stencil/core@4.43.0", "", { "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.34.9", "@rollup/rollup-darwin-x64": "4.34.9", "@rollup/rollup-linux-arm64-gnu": "4.34.9", "@rollup/rollup-linux-arm64-musl": "4.34.9", "@rollup/rollup-linux-x64-gnu": "4.34.9", "@rollup/rollup-linux-x64-musl": "4.34.9", "@rollup/rollup-win32-arm64-msvc": "4.34.9", "@rollup/rollup-win32-x64-msvc": "4.34.9" }, "bin": { "stencil": "bin/stencil" } }, "sha512-6Uj2Z3lzLuufYAE7asZ6NLKgSwsB9uxl84Eh34PASnUjfj32GkrP4DtKK7fNeh1WFGGyffsTDka3gwtl+4reUg=="], "@vue/compiler-core/entities": ["entities@7.0.1", "", {}, "sha512-TWrgLOFUQTH994YUyl1yT4uyavY5nNB5muff+RtWaqNVCAK408b5ZnnbNAUEWLTCpum9w6arT70i1XdQ4UeOPA=="],
"@ionic/core/ionicons": ["ionicons@8.0.13", "", { "dependencies": { "@stencil/core": "^4.35.3" } }, "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ=="], "@vue/compiler-core/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@ionic/vue/ionicons": ["ionicons@8.0.13", "", { "dependencies": { "@stencil/core": "^4.35.3" } }, "sha512-2QQVyG2P4wszne79jemMjWYLp0DBbDhr4/yFroPCxvPP1wtMxgdIV3l5n+XZ5E9mgoXU79w7yTWpm2XzJsISxQ=="], "@vue/compiler-sfc/estree-walker": ["estree-walker@2.0.2", "", {}, "sha512-Rfkk/Mp/DL7JVje3u18FxFujQlTNR2q6QfMSMB7AvCBx91NGj/ba3kCfza0f6dVDbw7YlRf/nDrn7pQrCCyQ/w=="],
"@stencil/core/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA=="], "@vue/language-core/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@stencil/core/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ=="], "editorconfig/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@stencil/core/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ=="], "glob/minimatch": ["minimatch@9.0.9", "", { "dependencies": { "brace-expansion": "^2.0.2" } }, "sha512-OBwBN9AL4dqmETlpS2zasx+vTeWclWzkblfZk7KTA5j3jeOONz/tRCnZomUyvNg83wL5Zv9Ss6HMJXAgL8R2Yg=="],
"@stencil/core/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q=="], "string-width-cjs/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@stencil/core/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw=="], "string-width-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@stencil/core/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA=="], "strip-ansi-cjs/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@stencil/core/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w=="], "wrap-ansi-cjs/ansi-styles": ["ansi-styles@4.3.0", "", { "dependencies": { "color-convert": "^2.0.1" } }, "sha512-zbB9rCJAT1rbjiVDb2hqKFHNYLxgtk8NURxZ3IZwD3F6NtxbXZQCnnSi1Lkx+IDohdPlFp222wVALIheZJQSEg=="],
"@stencil/core/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ=="], "wrap-ansi-cjs/string-width": ["string-width@4.2.3", "", { "dependencies": { "emoji-regex": "^8.0.0", "is-fullwidth-code-point": "^3.0.0", "strip-ansi": "^6.0.1" } }, "sha512-wKyQRQpjJ0sIp62ErSZdGsjMJWsap5oRNihHhu6G7JVO/9jIB6UyevL+tXuOqrng8j/cxKTWyWUwvSTriiZz/g=="],
"@ionic/core/@stencil/core/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.34.9", "", { "os": "darwin", "cpu": "arm64" }, "sha512-0CY3/K54slrzLDjOA7TOjN1NuLKERBgk9nY5V34mhmuu673YNb+7ghaDUs6N0ujXR7fz5XaS5Aa6d2TNxZd0OQ=="], "wrap-ansi-cjs/strip-ansi": ["strip-ansi@6.0.1", "", { "dependencies": { "ansi-regex": "^5.0.1" } }, "sha512-Y38VPSHcqkFrCpFnQ9vuSXmquuv5oXOKpGeT6aGrr3o3Gc9AlVa6JBfUSOCnbxGGZF+/0ooI7KrPuUSztUdU5A=="],
"@ionic/core/@stencil/core/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.34.9", "", { "os": "darwin", "cpu": "x64" }, "sha512-eOojSEAi/acnsJVYRxnMkPFqcxSMFfrw7r2iD9Q32SGkb/Q9FpUY1UlAu1DH9T7j++gZ0lHjnm4OyH2vCI7l7Q=="], "@vue/language-core/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"@ionic/core/@stencil/core/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.34.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-6TZjPHjKZUQKmVKMUowF3ewHxctrRR09eYyvT5eFv8w/fXarEra83A2mHTVJLA5xU91aCNOUnM+DWFMSbQ0Nxw=="], "editorconfig/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"@ionic/core/@stencil/core/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.34.9", "", { "os": "linux", "cpu": "arm64" }, "sha512-LD2fytxZJZ6xzOKnMbIpgzFOuIKlxVOpiMAXawsAZ2mHBPEYOnLRK5TTEsID6z4eM23DuO88X0Tq1mErHMVq0A=="], "glob/minimatch/brace-expansion": ["brace-expansion@2.1.1", "", { "dependencies": { "balanced-match": "^1.0.0" } }, "sha512-WR1cURNjuvBLMZBMbqM0UoE+WAfdUcEV1ccD8PVBVOI+Z3ND4+SZbN8RsfT2bMuG1qwz5RFvPukSZm5fF2D5eA=="],
"@ionic/core/@stencil/core/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.34.9", "", { "os": "linux", "cpu": "x64" }, "sha512-FwBHNSOjUTQLP4MG7y6rR6qbGw4MFeQnIBrMe161QGaQoBQLqSUEKlHIiVgF3g/mb3lxlxzJOpIBhaP+C+KP2A=="], "string-width-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@ionic/core/@stencil/core/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.34.9", "", { "os": "linux", "cpu": "x64" }, "sha512-cYRpV4650z2I3/s6+5/LONkjIz8MBeqrk+vPXV10ORBnshpn8S32bPqQ2Utv39jCiDcO2eJTuSlPXpnvmaIgRA=="], "wrap-ansi-cjs/string-width/emoji-regex": ["emoji-regex@8.0.0", "", {}, "sha512-MSjYzcWNOA0ewAHpz0MxpYFvwg6yjy1NG3xteoqz644VCo/RPgnr1/GGt+ic3iJTzQ8Eu3TdM14SawnVUmGE6A=="],
"@ionic/core/@stencil/core/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.34.9", "", { "os": "win32", "cpu": "arm64" }, "sha512-z4mQK9dAN6byRA/vsSgQiPeuO63wdiDxZ9yg9iyX2QTzKuQM7T4xlBoeUP/J8uiFkqxkcWndWi+W7bXdPbt27Q=="], "wrap-ansi-cjs/strip-ansi/ansi-regex": ["ansi-regex@5.0.1", "", {}, "sha512-quJQXlTSUGL2LH9SUXo8VwsY4soanhgo6LNSm84E1LBcE8s3O0wpdiRzyR9z/ZZJMlMWv37qOOb9pdJlMUEKFQ=="],
"@ionic/core/@stencil/core/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.34.9", "", { "os": "win32", "cpu": "x64" }, "sha512-AyleYRPU7+rgkMWbEh71fQlrzRfeP6SyMnRf9XX4fCdDPAJumdSBqYEcWPMzVQ4ScAl7E4oFfK0GUVn77xSwbw=="], "@vue/language-core/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@ionic/core/ionicons/@stencil/core": ["@stencil/core@4.43.5", "", { "optionalDependencies": { "@rollup/rollup-darwin-arm64": "4.44.0", "@rollup/rollup-darwin-x64": "4.44.0", "@rollup/rollup-linux-arm64-gnu": "4.44.0", "@rollup/rollup-linux-arm64-musl": "4.44.0", "@rollup/rollup-linux-x64-gnu": "4.44.0", "@rollup/rollup-linux-x64-musl": "4.44.0", "@rollup/rollup-win32-arm64-msvc": "4.44.0", "@rollup/rollup-win32-x64-msvc": "4.44.0" }, "bin": { "stencil": "bin/stencil" } }, "sha512-cgWD+GeuvJpTe1WQn40p02+BJ2j0j1YJ17GdkF2qKIQ23s2e3Zivq5yISXS3dcuV6oUJFN93jprdk+nk/sq99Q=="], "editorconfig/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-darwin-arm64": ["@rollup/rollup-darwin-arm64@4.44.0", "", { "os": "darwin", "cpu": "arm64" }, "sha512-VGF3wy0Eq1gcEIkSCr8Ke03CWT+Pm2yveKLaDvq51pPpZza3JX/ClxXOCmTYYq3us5MvEuNRTaeyFThCKRQhOA=="], "glob/minimatch/brace-expansion/balanced-match": ["balanced-match@1.0.2", "", {}, "sha512-3oSeUO0TMV67hN1AmbXsK4yaqU7tjiHlbxRDZOpH0KW9+CeX4bRAaX0Anxt0tx2MrpRpWwQaPwIlISEJhYU5Pw=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-darwin-x64": ["@rollup/rollup-darwin-x64@4.44.0", "", { "os": "darwin", "cpu": "x64" }, "sha512-fBkyrDhwquRvrTxSGH/qqt3/T0w5Rg0L7ZIDypvBPc1/gzjJle6acCpZ36blwuwcKD/u6oCE/sRWlUAcxLWQbQ=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-linux-arm64-gnu": ["@rollup/rollup-linux-arm64-gnu@4.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-ZTR2mxBHb4tK4wGf9b8SYg0Y6KQPjGpR4UWwTFdnmjB4qRtoATZ5dWn3KsDwGa5Z2ZBOE7K52L36J9LueKBdOQ=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-linux-arm64-musl": ["@rollup/rollup-linux-arm64-musl@4.44.0", "", { "os": "linux", "cpu": "arm64" }, "sha512-GFWfAhVhWGd4r6UxmnKRTBwP1qmModHtd5gkraeW2G490BpFOZkFtem8yuX2NyafIP/mGpRJgTJ2PwohQkUY/Q=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-linux-x64-gnu": ["@rollup/rollup-linux-x64-gnu@4.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-iUVJc3c0o8l9Sa/qlDL2Z9UP92UZZW1+EmQ4xfjTc1akr0iUFZNfxrXJ/R1T90h/ILm9iXEY6+iPrmYB3pXKjw=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-linux-x64-musl": ["@rollup/rollup-linux-x64-musl@4.44.0", "", { "os": "linux", "cpu": "x64" }, "sha512-PQUobbhLTQT5yz/SPg116VJBgz+XOtXt8D1ck+sfJJhuEsMj2jSej5yTdp8CvWBSceu+WW+ibVL6dm0ptG5fcA=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-win32-arm64-msvc": ["@rollup/rollup-win32-arm64-msvc@4.44.0", "", { "os": "win32", "cpu": "arm64" }, "sha512-M0CpcHf8TWn+4oTxJfh7LQuTuaYeXGbk0eageVjQCKzYLsajWS/lFC94qlRqOlyC2KvRT90ZrfXULYmukeIy7w=="],
"@ionic/core/ionicons/@stencil/core/@rollup/rollup-win32-x64-msvc": ["@rollup/rollup-win32-x64-msvc@4.44.0", "", { "os": "win32", "cpu": "x64" }, "sha512-Q2Mgwt+D8hd5FIPUuPDsvPR7Bguza6yTkJxspDGkZj7tBRn2y4KSWYuIXpftFSjBra76TbKerCV7rgFPQrn+wQ=="],
} }
} }

View File

@@ -1,42 +1,42 @@
# Single-origin reverse proxy for XIP (Vireli pattern). # Single-origin reverse proxy for XIP (Vireli pattern).
# nginx serves the built SPA and proxies API + WebSocket to the bun backend. # nginx serves the built SPA and proxies API + WebSocket to the bun backend.
server { server {
listen 80; listen 80;
server_name _; server_name _;
# Uploads: backend allows up to 50 MB (ABSOLUTE_MAX). Give headroom. # Uploads: backend allows up to 50 MB (ABSOLUTE_MAX). Give headroom.
client_max_body_size 60m; client_max_body_size 60m;
# ── API (REST + uploads) ──────────────────────────────────────────────── # ── API (REST + uploads) ────────────────────────────────────────────────
location /api/ { location /api/ {
proxy_pass http://backend:3000; proxy_pass http://backend:3000;
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_set_header X-Forwarded-Proto $scheme; proxy_set_header X-Forwarded-Proto $scheme;
proxy_read_timeout 120s; proxy_read_timeout 120s;
} }
# ── WebSocket (live feed + realtime stats) ────────────────────────────── # ── WebSocket (live feed + realtime stats) ──────────────────────────────
location /ws { location /ws {
proxy_pass http://backend:3000; proxy_pass http://backend:3000;
proxy_http_version 1.1; proxy_http_version 1.1;
proxy_set_header Upgrade $http_upgrade; proxy_set_header Upgrade $http_upgrade;
proxy_set_header Connection "upgrade"; proxy_set_header Connection "upgrade";
proxy_set_header Host $host; proxy_set_header Host $host;
proxy_set_header X-Real-IP $remote_addr; proxy_set_header X-Real-IP $remote_addr;
proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for; proxy_set_header X-Forwarded-For $proxy_add_x_forwarded_for;
proxy_read_timeout 3600s; proxy_read_timeout 3600s;
} }
# ── Health passthrough ────────────────────────────────────────────────── # ── Health passthrough ──────────────────────────────────────────────────
location = /health { location = /health {
proxy_pass http://backend:3000; proxy_pass http://backend:3000;
} }
# ── Static SPA (Vue history fallback) ─────────────────────────────────── # ── Static SPA (Vue history fallback) ───────────────────────────────────
location / { location / {
root /usr/share/nginx/html; root /usr/share/nginx/html;
try_files $uri $uri/ /index.html; try_files $uri $uri/ /index.html;
} }
} }

View File

@@ -1,67 +1,67 @@
# Production stack for XIP — runs on the dedicated CT (xip-app, Echelon CT502). # Production stack for XIP — runs on the dedicated CT (xip-app, Echelon CT502).
# Postgres + Redis + bun backend + nginx (serves SPA, proxies /api and /ws). # Postgres + Redis + bun backend + nginx (serves SPA, proxies /api and /ws).
# Secrets come from .env.prod (gitignored), loaded via `--env-file .env.prod`. # Secrets come from .env.prod (gitignored), loaded via `--env-file .env.prod`.
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: ${POSTGRES_DB:-xip} POSTGRES_DB: ${POSTGRES_DB:-xip}
POSTGRES_USER: ${POSTGRES_USER:-xip} POSTGRES_USER: ${POSTGRES_USER:-xip}
POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env.prod} POSTGRES_PASSWORD: ${POSTGRES_PASSWORD:?POSTGRES_PASSWORD must be set in .env.prod}
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xip}"] test: ["CMD-SHELL", "pg_isready -U ${POSTGRES_USER:-xip}"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
redis: redis:
image: redis:7 image: redis:7
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 20 retries: 20
backend: backend:
build: build:
context: . context: .
dockerfile: backend/Dockerfile dockerfile: backend/Dockerfile
restart: unless-stopped restart: unless-stopped
environment: environment:
DATABASE_URL: postgresql://${POSTGRES_USER:-xip}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-xip} DATABASE_URL: postgresql://${POSTGRES_USER:-xip}:${POSTGRES_PASSWORD}@postgres:5432/${POSTGRES_DB:-xip}
REDIS_URL: redis://redis:6379 REDIS_URL: redis://redis:6379
PORT: "3000" PORT: "3000"
NODE_ENV: production NODE_ENV: production
# Prod "open bar": paywall disabled for everyone (see backend/src/lib/ip.ts). # Prod "open bar": paywall disabled for everyone (see backend/src/lib/ip.ts).
XIP_OPEN_BAR: ${XIP_OPEN_BAR:-true} XIP_OPEN_BAR: ${XIP_OPEN_BAR:-true}
volumes: volumes:
- uploads_data:/app/uploads - uploads_data:/app/uploads
depends_on: depends_on:
postgres: postgres:
condition: service_healthy condition: service_healthy
redis: redis:
condition: service_healthy condition: service_healthy
web: web:
build: build:
context: . context: .
dockerfile: frontend/Dockerfile dockerfile: frontend/Dockerfile
args: args:
VITE_API_URL: ${PUBLIC_URL:-https://xip.kerboul.me} VITE_API_URL: ${PUBLIC_URL:-https://xip.kerboul.me}
restart: unless-stopped restart: unless-stopped
ports: ports:
- "80:80" - "80:80"
depends_on: depends_on:
- backend - backend
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:
uploads_data: uploads_data:

View File

@@ -1,35 +1,35 @@
services: services:
postgres: postgres:
image: postgres:16 image: postgres:16
restart: unless-stopped restart: unless-stopped
environment: environment:
POSTGRES_DB: xip POSTGRES_DB: xip
POSTGRES_USER: xip POSTGRES_USER: xip
POSTGRES_PASSWORD: xip POSTGRES_PASSWORD: xip
ports: ports:
- "5432:5432" - "5432:5432"
volumes: volumes:
- postgres_data:/var/lib/postgresql/data - postgres_data:/var/lib/postgresql/data
healthcheck: healthcheck:
test: ["CMD-SHELL", "pg_isready -U xip"] test: ["CMD-SHELL", "pg_isready -U xip"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
redis: redis:
image: redis:7 image: redis:7
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"] command: ["redis-server", "--appendonly", "yes"]
ports: ports:
- "6379:6379" - "6379:6379"
volumes: volumes:
- redis_data:/data - redis_data:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
timeout: 5s timeout: 5s
retries: 10 retries: 10
volumes: volumes:
postgres_data: postgres_data:
redis_data: redis_data:

View File

@@ -1,17 +1,17 @@
# XIP frontend — Vue 3 + Vite, built to static assets and served by nginx. # XIP frontend — Vue 3 + Vite, built to static assets and served by nginx.
# Build context is the repo ROOT (see docker-compose.prod.yml). # Build context is the repo ROOT (see docker-compose.prod.yml).
FROM oven/bun:1-debian AS build FROM oven/bun:1-debian AS build
WORKDIR /app WORKDIR /app
COPY frontend/package.json ./ COPY frontend/package.json ./
RUN bun install RUN bun install
COPY frontend/ ./ COPY frontend/ ./
# Baked at build time. Must be the public absolute origin so the WebSocket URL # Baked at build time. Must be the public absolute origin so the WebSocket URL
# (derived as API_URL.replace(/^http/,'ws') + '/ws') becomes wss://xip.kerboul.me/ws. # (derived as API_URL.replace(/^http/,'ws') + '/ws') becomes wss://xip.kerboul.me/ws.
ARG VITE_API_URL=https://xip.kerboul.me ARG VITE_API_URL=https://xip.kerboul.me
ENV VITE_API_URL=$VITE_API_URL ENV VITE_API_URL=$VITE_API_URL
RUN bun run build RUN bun run build
FROM nginx:1.27-alpine AS runtime FROM nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html COPY --from=build /app/dist /usr/share/nginx/html
EXPOSE 80 EXPOSE 80

View File

@@ -1,12 +1,12 @@
<!DOCTYPE html> <!DOCTYPE html>
<html lang="fr"> <html lang="fr">
<head> <head>
<meta charset="UTF-8" /> <meta charset="UTF-8" />
<meta name="viewport" content="width=device-width, initial-scale=1.0" /> <meta name="viewport" content="width=device-width, initial-scale=1.0" />
<title>XIP</title> <title>XIP</title>
</head> </head>
<body> <body>
<div id="app"></div> <div id="app"></div>
<script type="module" src="/src/main.ts"></script> <script type="module" src="/src/main.ts"></script>
</body> </body>
</html> </html>

View File

@@ -4,21 +4,24 @@
"private": true, "private": true,
"scripts": { "scripts": {
"dev": "vite", "dev": "vite",
"build": "vite build", "build": "vue-tsc && vite build",
"preview": "vite preview",
"typecheck": "vue-tsc --noEmit", "typecheck": "vue-tsc --noEmit",
"preview": "vite preview" "test": "vitest run",
"test:cov": "vitest run --coverage"
}, },
"dependencies": { "dependencies": {
"@ionic/vue": "^8.3.0",
"@ionic/vue-router": "^8.3.0",
"ionicons": "^7.4.0",
"vue": "^3.5.0", "vue": "^3.5.0",
"vue-router": "^4.4.0" "vue-router": "^4.4.0"
}, },
"devDependencies": { "devDependencies": {
"@vitejs/plugin-vue": "^5.1.0", "@vitejs/plugin-vue": "^5.1.0",
"@vitest/coverage-v8": "^2.1.0",
"@vue/test-utils": "^2.4.6",
"happy-dom": "^15.0.0",
"typescript": "^5.6.0", "typescript": "^5.6.0",
"vite": "^5.4.0", "vite": "^5.4.0",
"vitest": "^2.1.0",
"vue-tsc": "^2.1.0" "vue-tsc": "^2.1.0"
} }
} }

View File

@@ -1,8 +1,72 @@
<!-- Composant racine : barre de navigation globale + zone de pages routées.
L'explorateur est gardé en cache (keep-alive) pour conserver son état
(recherche, scroll) lors d'un retour navigation. -->
<template> <template>
<RouterView /> <div class="app-shell">
<StyleContextMenu /> <nav class="app-nav">
<RouterLink to="/" class="brand">XIP</RouterLink>
<div class="nav-links">
<RouterLink to="/" class="nav-link">💬 Chat</RouterLink>
<RouterLink to="/explorer" class="nav-link">🔎 Explorer</RouterLink>
<RouterLink to="/favoris" class="nav-link"> Favoris</RouterLink>
<RouterLink to="/mes-stats" class="nav-link">📊 Mes stats</RouterLink>
<RouterLink to="/shop" class="nav-link">🛒 Shop</RouterLink>
</div>
</nav>
<main class="app-main">
<RouterView v-slot="{ Component }">
<keep-alive include="ExplorerPage">
<component :is="Component" />
</keep-alive>
</RouterView>
</main>
</div>
</template> </template>
<script setup lang="ts"> <style scoped>
import StyleContextMenu from '@/components/StyleContextMenu.vue'; .app-shell {
</script> display: flex;
flex-direction: column;
height: 100dvh;
width: 100vw;
overflow: hidden;
}
.app-nav {
flex-shrink: 0;
height: 40px;
display: flex;
align-items: center;
gap: 18px;
padding: 0 18px;
background: #0a0a12;
border-bottom: 1px solid #1a1a2a;
}
.brand {
font-family: Arial, sans-serif;
font-weight: 900;
font-size: 16px;
color: #00eeff;
text-decoration: none;
text-shadow: 0 0 10px #00ccff77;
}
.nav-links { display: flex; gap: 6px; }
.nav-link {
font-family: Arial, sans-serif;
font-size: 12px;
color: #7a7a9a;
text-decoration: none;
padding: 5px 11px;
border-radius: 8px;
transition: color 0.12s, background 0.12s;
}
.nav-link:hover { color: #ccccee; background: #15152480; }
.nav-link.router-link-exact-active { color: #00ddff; background: #00aaff18; }
.app-main {
flex: 1;
min-height: 0;
overflow: hidden;
}
</style>

View File

@@ -1,163 +1,163 @@
<!-- Bande publicitaire gauche (130 px) pilotée par l'inventaire de pubs réel --> <!-- Bande publicitaire gauche (130 px) pilotée par l'inventaire de pubs réel -->
<template> <template>
<aside class="ad-band"> <aside class="ad-band">
<p class="ad-label">PUBLICITÉ</p> <p class="ad-label">PUBLICITÉ</p>
<component <component
:is="ad.url ? 'a' : 'div'" :is="ad.url ? 'a' : 'div'"
v-for="ad in ads" v-for="ad in ads"
:key="ad.id" :key="ad.id"
class="ad-card" class="ad-card"
:href="ad.url || undefined" :href="ad.url || undefined"
:style="cardStyle" :style="cardStyle"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
title="Clic droit pour personnaliser le cadre" title="Clic droit pour personnaliser le cadre"
@contextmenu.prevent="onRightClick" @contextmenu.prevent="onRightClick"
> >
<div class="ad-header" :class="`ad-header--${ad.tone}`"> <div class="ad-header" :class="`ad-header--${ad.tone}`">
<p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p> <p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p>
<p v-if="ad.subtitle" class="ad-sub">{{ ad.subtitle }}</p> <p v-if="ad.subtitle" class="ad-sub">{{ ad.subtitle }}</p>
</div> </div>
<div class="ad-body" :class="`ad-body--${ad.tone}`"> <div class="ad-body" :class="`ad-body--${ad.tone}`">
<span class="ad-icon">{{ ad.icon || '📢' }}</span> <span class="ad-icon">{{ ad.icon || '📢' }}</span>
</div> </div>
<p v-if="ad.cta" class="ad-cta" :class="`ad-cta--${ad.tone}`">{{ ad.cta }}</p> <p v-if="ad.cta" class="ad-cta" :class="`ad-cta--${ad.tone}`">{{ ad.cta }}</p>
<p v-if="ad.url" class="ad-url">{{ prettyUrl(ad.url) }}</p> <p v-if="ad.url" class="ad-url">{{ prettyUrl(ad.url) }}</p>
</component> </component>
</aside> </aside>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { useAds } from '@/composables/useAds'; import { useAds } from '@/composables/useAds';
import { openContextMenu } from '@/composables/useContextMenu'; import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles'; import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
const { ads, fetchAds, reportImpression } = useAds('band'); const { ads, fetchAds, reportImpression } = useAds('band');
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();
const cardStyle = computed(() => { const cardStyle = computed(() => {
const p = AD_FRAME_PRESETS[prefs.adFrame]; const p = AD_FRAME_PRESETS[prefs.adFrame];
return { border: p.border, background: p.bg }; return { border: p.border, background: p.bg };
}); });
function onRightClick(e: MouseEvent): void { function onRightClick(e: MouseEvent): void {
if (!myPerks.value.elementSkin) return; if (!myPerks.value.elementSkin) return;
e.stopPropagation(); e.stopPropagation();
openContextMenu({ openContextMenu({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
title: 'Cadre pub', title: 'Cadre pub',
items: Object.entries(AD_FRAME_PRESETS).map(([k, v]) => ({ value: k, label: v.label })), items: Object.entries(AD_FRAME_PRESETS).map(([k, v]) => ({ value: k, label: v.label })),
current: prefs.adFrame, current: prefs.adFrame,
onSelect: (v) => { prefs.adFrame = v as typeof prefs.adFrame; }, onSelect: (v) => { prefs.adFrame = v as typeof prefs.adFrame; },
}); });
} }
function prettyUrl(url: string): string { function prettyUrl(url: string): string {
return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
} }
watch(ads, (list) => { watch(ads, (list) => {
for (const a of list) reportImpression(a.id); for (const a of list) reportImpression(a.id);
}); });
onMounted(fetchAds); onMounted(fetchAds);
</script> </script>
<style scoped> <style scoped>
.ad-band { .ad-band {
width: 130px; width: 130px;
flex-shrink: 0; flex-shrink: 0;
background: #0c0c10; background: #0c0c10;
border-right: 1px solid #1a1a22; border-right: 1px solid #1a1a22;
overflow-y: auto; overflow-y: auto;
overflow-x: hidden; overflow-x: hidden;
scrollbar-width: none; scrollbar-width: none;
} }
.ad-band::-webkit-scrollbar { display: none; } .ad-band::-webkit-scrollbar { display: none; }
.ad-label { .ad-label {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 8px; font-size: 8px;
color: #2a2a38; color: #2a2a38;
text-align: center; text-align: center;
padding: 5px 0 3px; padding: 5px 0 3px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
} }
/* ── Carte pub ── */ /* ── Carte pub ── */
.ad-card { .ad-card {
margin: 0 4px 4px; margin: 0 4px 4px;
background: #121218; background: #121218;
border: 1px solid #1e1e2a; border: 1px solid #1e1e2a;
border-radius: 3px; border-radius: 3px;
text-align: center; text-align: center;
padding-bottom: 10px; padding-bottom: 10px;
} }
.ad-header { .ad-header {
padding: 8px 4px 6px; padding: 8px 4px 6px;
border-radius: 3px 3px 0 0; border-radius: 3px 3px 0 0;
} }
.ad-header--blue { background: #161620; } .ad-header--blue { background: #161620; }
.ad-header--green { background: #101614; } .ad-header--green { background: #101614; }
.ad-header--purple { background: #16101a; } .ad-header--purple { background: #16101a; }
.ad-header--user { background: #1a1606; } .ad-header--user { background: #1a1606; }
.ad-header--casino { background: #1a0606; } .ad-header--casino { background: #1a0606; }
.ad-brand { .ad-brand {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: bold;
margin: 0; margin: 0;
} }
.ad-brand--blue { color: #4455aa; } .ad-brand--blue { color: #4455aa; }
.ad-brand--green { color: #336644; } .ad-brand--green { color: #336644; }
.ad-brand--purple { color: #6633aa; } .ad-brand--purple { color: #6633aa; }
.ad-brand--user { color: #998833; } .ad-brand--user { color: #998833; }
.ad-brand--casino { color: #884433; } .ad-brand--casino { color: #884433; }
.ad-sub { .ad-sub {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 9px; font-size: 9px;
color: #383870; color: #383870;
margin: 2px 0 0; margin: 2px 0 0;
} }
.ad-body { .ad-body {
background: #0e0e16; background: #0e0e16;
margin: 6px 10px; margin: 6px 10px;
border-radius: 2px; border-radius: 2px;
padding: 10px 0; padding: 10px 0;
} }
.ad-body--green { background: #0e160e; } .ad-body--green { background: #0e160e; }
.ad-body--purple { background: #110e16; } .ad-body--purple { background: #110e16; }
.ad-body--user { background: #16140e; } .ad-body--user { background: #16140e; }
.ad-body--casino { background: #160e0e; } .ad-body--casino { background: #160e0e; }
.ad-icon { font-size: 24px; } .ad-icon { font-size: 24px; }
.ad-cta { .ad-cta {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 10px; font-size: 10px;
margin: 6px 0 2px; margin: 6px 0 2px;
} }
.ad-cta--blue { color: #3a3a88; } .ad-cta--blue { color: #3a3a88; }
.ad-cta--green { color: #33aa55; } .ad-cta--green { color: #33aa55; }
.ad-cta--purple { color: #9944dd; } .ad-cta--purple { color: #9944dd; }
.ad-cta--user { color: #ffcc44; } .ad-cta--user { color: #ffcc44; }
.ad-cta--casino { color: #ff5533; } .ad-cta--casino { color: #ff5533; }
.ad-url { .ad-url {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 8px; font-size: 8px;
color: #282840; color: #282840;
} }
/* Carte cliquable : pas de soulignement, héritage couleur */ /* Carte cliquable : pas de soulignement, héritage couleur */
a.ad-card { text-decoration: none; display: block; } a.ad-card { text-decoration: none; display: block; }
</style> </style>

View File

@@ -1,50 +1,50 @@
<!-- Tweened number display (easeOutCubic) for live-updating stats --> <!-- Tweened number display (easeOutCubic) for live-updating stats -->
<template> <template>
<span>{{ formatted }}</span> <span>{{ formatted }}</span>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, onUnmounted } from 'vue'; import { ref, computed, watch, onUnmounted } from 'vue';
const props = withDefaults( const props = withDefaults(
defineProps<{ value: number; decimals?: number; duration?: number }>(), defineProps<{ value: number; decimals?: number; duration?: number }>(),
{ decimals: 0, duration: 600 }, { decimals: 0, duration: 600 },
); );
const display = ref(props.value); const display = ref(props.value);
let raf = 0; let raf = 0;
let startVal = props.value; let startVal = props.value;
let startTime = 0; let startTime = 0;
let target = props.value; let target = props.value;
function animate(to: number): void { function animate(to: number): void {
cancelAnimationFrame(raf); cancelAnimationFrame(raf);
startVal = display.value; startVal = display.value;
target = to; target = to;
startTime = performance.now(); startTime = performance.now();
const step = (now: number) => { const step = (now: number) => {
const t = Math.min(1, (now - startTime) / props.duration); const t = Math.min(1, (now - startTime) / props.duration);
const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic const eased = 1 - Math.pow(1 - t, 3); // easeOutCubic
display.value = startVal + (target - startVal) * eased; display.value = startVal + (target - startVal) * eased;
if (t < 1) raf = requestAnimationFrame(step); if (t < 1) raf = requestAnimationFrame(step);
else display.value = target; else display.value = target;
}; };
raf = requestAnimationFrame(step); raf = requestAnimationFrame(step);
} }
watch( watch(
() => props.value, () => props.value,
(v) => { (v) => {
if (Number.isFinite(v)) animate(v); if (Number.isFinite(v)) animate(v);
}, },
); );
const formatted = computed(() => const formatted = computed(() =>
display.value.toLocaleString('fr-FR', { display.value.toLocaleString('fr-FR', {
minimumFractionDigits: props.decimals, minimumFractionDigits: props.decimals,
maximumFractionDigits: props.decimals, maximumFractionDigits: props.decimals,
}), }),
); );
onUnmounted(() => cancelAnimationFrame(raf)); onUnmounted(() => cancelAnimationFrame(raf));
</script> </script>

View File

@@ -1,133 +1,133 @@
<!-- En-tête du chat --> <!-- En-tête du chat -->
<template> <template>
<header class="chat-header"> <header class="chat-header">
<div class="header-left"> <div class="header-left">
<span class="xip-title">XIP</span> <span class="xip-title">XIP</span>
<span class="chat-label">Chat</span> <span class="chat-label">Chat</span>
<span class="online-dot" aria-hidden="true" /> <span class="online-dot" aria-hidden="true" />
<span class="online-count">{{ connectedCount }} connectés</span> <span class="online-count">{{ connectedCount }} connectés</span>
</div> </div>
<div class="header-right"> <div class="header-right">
<ThemePicker v-model="theme" /> <ThemePicker v-model="theme" />
<span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span> <span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span>
<span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP"> <span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP">
<span class="balance-coin"></span> <span class="balance-coin"></span>
<span class="balance-val">{{ displayBalance() }}</span> <span class="balance-val">{{ displayBalance() }}</span>
<span class="balance-unit">cr</span> <span class="balance-unit">cr</span>
</span> </span>
<router-link to="/shop" class="shop-link">🛒 Shop</router-link> <router-link to="/shop" class="shop-link">🛒 Shop</router-link>
<span class="channel-badge"># général</span> <span class="channel-badge"># général</span>
</div> </div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { useTheme } from '@/composables/useTheme'; import { useTheme } from '@/composables/useTheme';
import ThemePicker from './ThemePicker.vue'; import ThemePicker from './ThemePicker.vue';
defineProps<{ connectedCount: number }>(); defineProps<{ connectedCount: number }>();
const { ip, freeMode, displayBalance } = useWallet(); const { ip, freeMode, displayBalance } = useWallet();
const { theme } = useTheme(); const { theme } = useTheme();
</script> </script>
<style scoped> <style scoped>
.chat-header { .chat-header {
height: 52px; height: 52px;
flex-shrink: 0; flex-shrink: 0;
background: var(--xip-header-bg); background: var(--xip-header-bg);
border-bottom: 1px solid var(--xip-header-border); border-bottom: 1px solid var(--xip-header-border);
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 16px 0 20px; padding: 0 16px 0 20px;
} }
.header-left { .header-left {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
} }
.header-right { .header-right {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 12px; gap: 12px;
} }
.xip-title { .xip-title {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 18px; font-size: 18px;
font-weight: bold; font-weight: bold;
color: #7ab8cc; color: #7ab8cc;
} }
.chat-label { .chat-label {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 14px; font-size: 14px;
font-weight: bold; font-weight: bold;
color: #aaaacc; color: #aaaacc;
} }
.online-dot { .online-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #44aa66; background: #44aa66;
} }
.online-count { .online-count {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 11px; font-size: 11px;
color: #557766; color: #557766;
} }
.me-ip { .me-ip {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 11px; font-size: 11px;
color: #5566aa; color: #5566aa;
} }
.balance { .balance {
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 4px; gap: 4px;
background: #131322; background: #131322;
border: 1px solid #2a2a44; border: 1px solid #2a2a44;
border-radius: 12px; border-radius: 12px;
padding: 3px 10px; padding: 3px 10px;
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
} }
.balance-coin { color: #aa8833; font-size: 11px; } .balance-coin { color: #aa8833; font-size: 11px; }
.balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; } .balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; }
.balance-unit { color: #886633; font-size: 9px; } .balance-unit { color: #886633; font-size: 9px; }
.balance--free .balance-val { color: #44aa77; } .balance--free .balance-val { color: #44aa77; }
.balance--free .balance-coin { color: #44aa77; } .balance--free .balance-coin { color: #44aa77; }
.shop-link { .shop-link {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 12px; font-size: 12px;
font-weight: bold; font-weight: bold;
color: #6699aa; color: #6699aa;
text-decoration: none; text-decoration: none;
border: 1px solid #33445566; border: 1px solid #33445566;
border-radius: 12px; border-radius: 12px;
padding: 4px 12px; padding: 4px 12px;
transition: background 0.15s; transition: background 0.15s;
} }
.shop-link:hover { .shop-link:hover {
background: #1a2530; background: #1a2530;
} }
.channel-badge { .channel-badge {
background: #131320; background: #131320;
border: 1px solid #222233; border: 1px solid #222233;
border-radius: 12px; border-radius: 12px;
padding: 4px 14px; padding: 4px 14px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 10px; font-size: 10px;
color: #5555aa; color: #5555aa;
} }
</style> </style>

View File

@@ -0,0 +1,41 @@
<!-- Bouton favori réutilisable : partout (chat, explorateur, détail).
Reflète et bascule l'état de la liste perso centralisée (useFavorites). -->
<template>
<button
class="fav-btn"
:class="{ 'fav-btn--on': active }"
:title="active ? 'Retirer des favoris' : 'Ajouter aux favoris'"
:aria-pressed="active"
type="button"
@click.stop="onClick"
>{{ active ? '' : '' }}</button>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useFavorites, type FavoriteSource } from '@/composables/useFavorites';
const props = defineProps<{ message: FavoriteSource }>();
const { isFav, toggle } = useFavorites();
const active = computed(() => isFav(props.message.id));
function onClick(): void {
toggle(props.message);
}
</script>
<style scoped>
.fav-btn {
background: none;
border: none;
cursor: pointer;
padding: 0;
font-size: 13px;
line-height: 1;
color: #44446a;
transition: color 0.12s, transform 0.12s;
}
.fav-btn:hover { color: #ffcc44; transform: scale(1.15); }
.fav-btn--on { color: #ffcc44; }
</style>

View File

@@ -1,152 +1,152 @@
<!-- Pub casino néon : overlay dans le feed, pilotée par l'inventaire de pubs --> <!-- Pub casino néon : overlay dans le feed, pilotée par l'inventaire de pubs -->
<template> <template>
<div v-if="ad" class="casino"> <div v-if="ad" class="casino">
<div class="casino-head"> <div class="casino-head">
<p class="casino-title">♠ {{ ad.brand }} ♠</p> <p class="casino-title">♠ {{ ad.brand }} ♠</p>
<p class="casino-subtitle">OFFRE EXCLUSIVE</p> <p class="casino-subtitle">OFFRE EXCLUSIVE</p>
</div> </div>
<div class="casino-body"> <div class="casino-body">
<p class="bonus">+200%</p> <p class="bonus">+200%</p>
<p class="bonus-sub">{{ ad.subtitle || 'sur votre 1er dépôt 500 max' }}</p> <p class="bonus-sub">{{ ad.subtitle || 'sur votre 1er dépôt 500 max' }}</p>
<div class="slots"> <div class="slots">
<span class="suit suit--diamond">♦</span> <span class="suit suit--diamond">♦</span>
<span class="seven">7</span> <span class="seven">7</span>
<span class="seven">7</span> <span class="seven">7</span>
<span class="seven">7</span> <span class="seven">7</span>
<span class="suit suit--spade">♠</span> <span class="suit suit--spade">♠</span>
</div> </div>
<a class="casino-cta" :href="ad.url || '#'" target="_blank" rel="noopener noreferrer nofollow"> <a class="casino-cta" :href="ad.url || '#'" target="_blank" rel="noopener noreferrer nofollow">
{{ ad.cta || 'JOUER MAINTENANT' }} &rarr; {{ ad.cta || 'JOUER MAINTENANT' }} &rarr;
</a> </a>
<p class="disclaimer">18+ &bull; Jeu responsable &bull; {{ prettyUrl(ad.url) }}</p> <p class="disclaimer">18+ &bull; Jeu responsable &bull; {{ prettyUrl(ad.url) }}</p>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, onMounted, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { useAds } from '@/composables/useAds'; import { useAds } from '@/composables/useAds';
const { ads, fetchAds, reportImpression } = useAds('casino'); const { ads, fetchAds, reportImpression } = useAds('casino');
const ad = computed(() => ads.value[0] ?? null); const ad = computed(() => ads.value[0] ?? null);
function prettyUrl(url?: string | null): string { function prettyUrl(url?: string | null): string {
return (url || 'casino-lucky.bet').replace(/^https?:\/\//, '').replace(/\/$/, ''); return (url || 'casino-lucky.bet').replace(/^https?:\/\//, '').replace(/\/$/, '');
} }
watch(ad, (a) => { if (a) reportImpression(a.id); }); watch(ad, (a) => { if (a) reportImpression(a.id); });
onMounted(fetchAds); onMounted(fetchAds);
</script> </script>
<style scoped> <style scoped>
.casino { .casino {
width: 248px; width: 248px;
background: #100400; background: #100400;
border: 2px solid #ff2200; border: 2px solid #ff2200;
border-radius: 6px; border-radius: 6px;
box-shadow: none; box-shadow: none;
} }
/* ── En-tête rouge ── */ /* ── En-tête rouge ── */
.casino-head { .casino-head {
background: #1a0400; background: #1a0400;
border-radius: 4px 4px 0 0; border-radius: 4px 4px 0 0;
border-bottom: 1px solid #440000; border-bottom: 1px solid #440000;
padding: 10px 8px; padding: 10px 8px;
text-align: center; text-align: center;
} }
.casino-title { .casino-title {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
color: #ff5533; color: #ff5533;
margin: 0; margin: 0;
} }
.casino-subtitle { .casino-subtitle {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 9px; font-size: 9px;
letter-spacing: 2px; letter-spacing: 2px;
color: #882200; color: #882200;
margin: 4px 0 0; margin: 4px 0 0;
} }
/* ── Corps ── */ /* ── Corps ── */
.casino-body { .casino-body {
padding: 12px; padding: 12px;
text-align: center; text-align: center;
} }
.bonus { .bonus {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 32px; font-size: 32px;
font-weight: bold; font-weight: bold;
color: #ffdd00; color: #ffdd00;
margin: 0; margin: 0;
} }
.bonus-sub { .bonus-sub {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 11px; font-size: 11px;
color: #cc6600; color: #cc6600;
margin: 4px 0 10px; margin: 4px 0 10px;
} }
/* ── Machines à sous ── */ /* ── Machines à sous ── */
.slots { .slots {
display: flex; display: flex;
justify-content: center; justify-content: center;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
margin-bottom: 12px; margin-bottom: 12px;
} }
.suit { .suit {
font-size: 24px; font-size: 24px;
} }
.suit--diamond { color: #ffaa44; } .suit--diamond { color: #ffaa44; }
.suit--spade { color: #ffaa44; } .suit--spade { color: #ffaa44; }
.seven { .seven {
font-size: 30px; font-size: 30px;
font-weight: bold; font-weight: bold;
color: #ffffff; color: #ffffff;
} }
/* ── CTA ── */ /* ── CTA ── */
.casino-cta { .casino-cta {
display: block; display: block;
width: 100%; width: 100%;
box-sizing: border-box; box-sizing: border-box;
padding: 8px 0; padding: 8px 0;
background: #220000; background: #220000;
border: 1.5px solid #ff2200; border: 1.5px solid #ff2200;
border-radius: 19px; border-radius: 19px;
color: #ff4422; color: #ff4422;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
text-align: center; text-align: center;
text-decoration: none; text-decoration: none;
transition: background 0.15s; transition: background 0.15s;
} }
.casino-cta:hover { .casino-cta:hover {
} }
.disclaimer { .disclaimer {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 7px; font-size: 7px;
color: #440000; color: #440000;
margin-top: 8px; margin-top: 8px;
} }
</style> </style>

View File

@@ -1,80 +1,80 @@
<!-- Renders a message's attachments: image previews inline, everything else as a download link --> <!-- Renders a message's attachments: image previews inline, everything else as a download link -->
<template> <template>
<div class="attachments"> <div class="attachments">
<template v-for="a in attachments" :key="a.id"> <template v-for="a in attachments" :key="a.id">
<a <a
v-if="isImage(a)" v-if="isImage(a)"
class="att-image" class="att-image"
:href="urlFor(a.id)" :href="urlFor(a.id)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
> >
<img :src="urlFor(a.id)" :alt="a.filename" loading="lazy" /> <img :src="urlFor(a.id)" :alt="a.filename" loading="lazy" />
</a> </a>
<a <a
v-else v-else
class="att-file" class="att-file"
:href="urlFor(a.id)" :href="urlFor(a.id)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
:download="a.filename" :download="a.filename"
> >
<span class="att-icon">{{ isExe(a) ? '' : '📎' }}</span> <span class="att-icon">{{ isExe(a) ? '' : '📎' }}</span>
<span class="att-name">{{ a.filename }}</span> <span class="att-name">{{ a.filename }}</span>
<span class="att-size">{{ kb(a.size) }}</span> <span class="att-size">{{ kb(a.size) }}</span>
<span v-if="isExe(a)" class="att-warn">exécutable</span> <span v-if="isExe(a)" class="att-warn">exécutable</span>
</a> </a>
</template> </template>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Attachment } from '@/composables/useMessages'; import type { Attachment } from '@/composables/useMessages';
import { useAttachments } from '@/composables/useAttachments'; import { useAttachments } from '@/composables/useAttachments';
defineProps<{ attachments: Attachment[] }>(); defineProps<{ attachments: Attachment[] }>();
const { kb, urlFor } = useAttachments(); const { kb, urlFor } = useAttachments();
function isImage(a: Attachment): boolean { function isImage(a: Attachment): boolean {
return a.mimeType.startsWith('image/'); return a.mimeType.startsWith('image/');
} }
function isExe(a: Attachment): boolean { function isExe(a: Attachment): boolean {
return /\.(exe|bat|cmd|msi|sh|app)$/i.test(a.filename) || a.mimeType === 'application/x-msdownload'; return /\.(exe|bat|cmd|msi|sh|app)$/i.test(a.filename) || a.mimeType === 'application/x-msdownload';
} }
</script> </script>
<style scoped> <style scoped>
.attachments { .attachments {
display: flex; display: flex;
flex-wrap: wrap; flex-wrap: wrap;
gap: 8px; gap: 8px;
padding: 6px 25px 0; padding: 6px 25px 0;
} }
.att-image img { .att-image img {
max-width: 220px; max-width: 220px;
max-height: 160px; max-height: 160px;
border-radius: 8px; border-radius: 8px;
border: 1px solid #222234; border: 1px solid #222234;
display: block; display: block;
} }
.att-file { .att-file {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
background: #141420; background: #141420;
border: 1px solid #222234; border: 1px solid #222234;
border-radius: 10px; border-radius: 10px;
padding: 7px 12px; padding: 7px 12px;
text-decoration: none; text-decoration: none;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
.att-file:hover { background: #1c1c2e; } .att-file:hover { background: #1c1c2e; }
.att-icon { font-size: 14px; } .att-icon { font-size: 14px; }
.att-name { font-size: 12px; color: #aaccdd; } .att-name { font-size: 12px; color: #aaccdd; }
.att-size { font-size: 10px; color: #555577; } .att-size { font-size: 10px; color: #555577; }
.att-warn { .att-warn {
font-size: 8px; font-weight: bold; color: #ff5544; font-size: 8px; font-weight: bold; color: #ff5544;
background: #2a0a08; border: 1px solid #662211; border-radius: 4px; padding: 1px 5px; background: #2a0a08; border: 1px solid #662211; border-radius: 4px; padding: 1px 5px;
} }
</style> </style>

View File

@@ -1,238 +1,240 @@
<!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes --> <!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes -->
<template> <template>
<div class="message-item"> <div class="message-item">
<!-- Auteur + horodatage --> <!-- Auteur + horodatage -->
<div class="message-meta"> <div class="message-meta">
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" :title="message.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined"> <span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, message.authorIp)" :title="message.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span> <span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span> <span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span>
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span> <span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span> <span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span> </span>
<span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag"> <span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag">
<a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link"> <a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="message.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :alt="message.authorGeo.countryCode" class="geo-flag" /> <img v-if="message.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :alt="message.authorGeo.countryCode" class="geo-flag" />
<span v-else>🏠</span> <span v-else>🏠</span>
{{ geoLabel(message.authorGeo) }} {{ geoLabel(message.authorGeo) }}
</a> </a>
</span> </span>
<span class="ts">{{ fmt(message.createdAt) }}</span> <span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button> <button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
</div> <FavButton :message="message" />
</div>
<!-- Contenu : riche (iframe sandbox) ou texte simple -->
<RichContent <!-- Contenu : riche (iframe sandbox) ou texte simple -->
v-if="message.richMode && message.richMode !== 'none' && message.richContent" <RichContent
:mode="message.richMode" v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:content="message.richContent" :mode="message.richMode"
/> :content="message.richContent"
<p v-else class="message-body">{{ message.content }}</p> />
<p v-else class="message-body">{{ message.content }}</p>
<!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" /> <!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses -->
<div <!-- Réponses -->
v-for="reply in message.replies" <div
:key="reply.id" v-for="reply in message.replies"
class="reply" :key="reply.id"
> class="reply"
<span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined"> >
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span> <span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span> <span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span> <span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
</span> <span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
<span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm"> </span>
<a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link"> <span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm">
<img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" /> <a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<span v-else>🏠</span> <img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" />
{{ geoLabel(reply.authorGeo) }} <span v-else>🏠</span>
</a> {{ geoLabel(reply.authorGeo) }}
</span> </a>
<span class="ts">{{ fmt(reply.createdAt) }}</span> </span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button> <span class="ts">{{ fmt(reply.createdAt) }}</span>
<RichContent <button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent" <RichContent
:mode="reply.richMode" v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:content="reply.richContent" :mode="reply.richMode"
/> :content="reply.richContent"
<p v-else class="message-body reply-body">{{ reply.content }}</p> />
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" /> <p v-else class="message-body reply-body">{{ reply.content }}</p>
</div> <MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div>
<div class="divider" />
</div> <div class="divider" />
</template> </div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useMessages'; <script setup lang="ts">
import { openContextMenu } from '@/composables/useContextMenu'; import type { Message } from '@/composables/useMessages';
import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles'; import { openContextMenu } from '@/composables/useContextMenu';
import { useMessageItem } from '@/composables/useMessageItem'; import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import RichContent from './RichContent.vue'; import { useMessageItem } from '@/composables/useMessageItem';
import MessageAttachments from './MessageAttachments.vue'; import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
const props = defineProps<{ message: Message; myIp?: string }>(); import FavButton from './FavButton.vue';
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const props = defineProps<{ message: Message; myIp?: string }>();
const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
function openIpMenu(e: MouseEvent, ip: string): void { const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem();
if (ip !== props.myIp) return;
function openIpMenu(e: MouseEvent, ip: string): void {
const hasElementSkin = !!myPerks.value.elementSkin; if (ip !== props.myIp) return;
const ownedPets = myPerks.value.pets ?? [];
const hasPets = ownedPets.length > 0; const hasElementSkin = !!myPerks.value.elementSkin;
const ownedPets = myPerks.value.pets ?? [];
// Nothing to show if no perk unlocks customization. const hasPets = ownedPets.length > 0;
if (!hasElementSkin && !hasPets) return;
// Nothing to show if no perk unlocks customization.
const currentColor = prefs.ipColors[ip] ?? 'auto'; if (!hasElementSkin && !hasPets) return;
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
const currentColor = prefs.ipColors[ip] ?? 'auto';
const items: import('@/composables/useContextMenu').ContextMenuItem[] = []; const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
if (hasElementSkin) { const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch }))); if (hasElementSkin) {
} items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
if (hasPets) { }
items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
items.push({ value: 'pet:__inherit__', label: ' défaut' }); if (hasPets) {
// Show only the pets the user actually owns. items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
const seen = new Set<string>(); items.push({ value: 'pet:__inherit__', label: ' défaut' });
for (const p of ownedPets) { // Show only the pets the user actually owns.
if (!seen.has(p.char)) { const seen = new Set<string>();
seen.add(p.char); for (const p of ownedPets) {
items.push({ value: `pet:${p.char}`, label: p.char }); if (!seen.has(p.char)) {
} seen.add(p.char);
} items.push({ value: `pet:${p.char}`, label: p.char });
} }
}
openContextMenu({ }
x: e.clientX,
y: e.clientY, openContextMenu({
title: ip, x: e.clientX,
items, y: e.clientY,
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`, title: ip,
onSelect: (v) => { items,
if (v.startsWith('color:')) { current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
prefs.ipColors[ip] = v.slice(6); onSelect: (v) => {
} else if (v.startsWith('pet:')) { if (v.startsWith('color:')) {
const pet = v.slice(4); prefs.ipColors[ip] = v.slice(6);
if (pet === '__inherit__') { } else if (v.startsWith('pet:')) {
delete prefs.ipPets[ip]; const pet = v.slice(4);
} else { if (pet === '__inherit__') {
prefs.ipPets[ip] = pet; delete prefs.ipPets[ip];
} } else {
} prefs.ipPets[ip] = pet;
}, }
}); }
} },
});
</script> }
<style scoped> </script>
.message-item {
padding: 4px 0; <style scoped>
} .message-item {
padding: 4px 0;
.message-meta { }
display: flex;
align-items: baseline; .message-meta {
gap: 8px; display: flex;
padding: 0 25px; align-items: baseline;
} gap: 8px;
padding: 0 25px;
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; } }
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
.pet--sm { font-size: 11px; } .ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
.vip-badge { .pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; .pet--sm { font-size: 11px; }
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px; .vip-badge {
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px; font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
} color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
.ip { }
font-family: 'Courier New', monospace;
font-size: 12px; .ip {
font-weight: bold; font-family: 'Courier New', monospace;
} font-size: 12px;
font-weight: bold;
.ts { }
font-family: 'Courier New', monospace;
font-size: 10px; .ts {
color: #303030; font-family: 'Courier New', monospace;
} font-size: 10px;
color: #303030;
.reply-btn { }
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a; .reply-btn {
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s; background: none; border: none; cursor: pointer;
} font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
.message-item:hover .reply-btn, padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
.reply:hover .reply-btn { opacity: 1; } }
.reply-btn:hover { color: #00ccff; } .message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.message-body { .reply-btn:hover { color: #00ccff; }
font-family: Arial, sans-serif;
font-size: 13px; .message-body {
color: #c0c0c0; font-family: Arial, sans-serif;
padding: 3px 25px 0; font-size: 13px;
margin: 0; color: #c0c0c0;
word-break: break-word; padding: 3px 25px 0;
} margin: 0;
word-break: break-word;
.divider { }
height: 1px;
background: #141420; .divider {
margin: 8px 25px 0; height: 1px;
} background: #141420;
margin: 8px 25px 0;
/* ── Réponses ── */ }
.reply {
margin: 6px 25px 0 45px; /* ── Réponses ── */
border-left: 2px solid #1a1a2a; .reply {
padding-left: 10px; margin: 6px 25px 0 45px;
} border-left: 2px solid #1a1a2a;
padding-left: 10px;
.reply-ip { }
font-size: 11px;
} .reply-ip {
font-size: 11px;
.reply-body { }
font-size: 12px;
padding-left: 0; .reply-body {
} font-size: 12px;
padding-left: 0;
.geo-tag { }
font-family: Arial, sans-serif;
font-size: 10px; .geo-tag {
color: #44445a; font-family: Arial, sans-serif;
white-space: nowrap; font-size: 10px;
} color: #44445a;
.geo-tag--sm { font-size: 9px; } white-space: nowrap;
.geo-link { }
color: inherit; .geo-tag--sm { font-size: 9px; }
text-decoration: none; .geo-link {
opacity: 0.7; color: inherit;
display: inline-flex; text-decoration: none;
align-items: center; opacity: 0.7;
gap: 4px; display: inline-flex;
transition: opacity 0.12s, color 0.12s; align-items: center;
} gap: 4px;
.geo-link:hover { transition: opacity 0.12s, color 0.12s;
color: #5588cc; }
opacity: 1; .geo-link:hover {
text-decoration: underline; color: #5588cc;
} opacity: 1;
.geo-flag { text-decoration: underline;
width: 16px; }
height: 12px; .geo-flag {
object-fit: cover; width: 16px;
border-radius: 2px; height: 12px;
vertical-align: middle; object-fit: cover;
flex-shrink: 0; border-radius: 2px;
} vertical-align: middle;
</style> flex-shrink: 0;
}
</style>

View File

@@ -1,154 +1,159 @@
<!-- Variante "bulles" du message style chat mobile --> <!-- Variante "bulles" du message style chat mobile -->
<template> <template>
<div class="bubble-item" :class="{ 'bubble-item--mine': isMine }"> <div class="bubble-item" :class="{ 'bubble-item--mine': isMine }">
<div class="bubble-header"> <div class="bubble-header">
<span class="bubble-ip" :style="ipStyle(message)"> <span class="bubble-ip" :style="ipStyle(message)">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span> <span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>
{{ message.authorIp }} {{ message.authorIp }}
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span> <span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span> <span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span> </span>
<span class="bubble-ts">{{ fmt(message.createdAt) }}</span> <span class="bubble-ts">{{ fmt(message.createdAt) }}</span>
<a <a
v-if="message.authorGeo && geoLabel(message.authorGeo)" v-if="message.authorGeo && geoLabel(message.authorGeo)"
:href="geoLink(message.authorGeo)" :href="geoLink(message.authorGeo)"
target="_blank" target="_blank"
rel="noopener noreferrer" rel="noopener noreferrer"
class="geo-link" class="geo-link"
> >
<img <img
v-if="message.authorGeo.countryCode" v-if="message.authorGeo.countryCode"
:src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`" :src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`"
:alt="message.authorGeo.countryCode" :alt="message.authorGeo.countryCode"
class="geo-flag" class="geo-flag"
/> />
<span v-else>🏠</span> <span v-else>🏠</span>
</a> </a>
</div> </div>
<!-- Contenu --> <!-- Contenu -->
<div class="bubble" :class="{ 'bubble--mine': isMine }"> <div class="bubble" :class="{ 'bubble--mine': isMine }">
<RichContent <RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent" v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode" :mode="message.richMode"
:content="message.richContent" :content="message.richContent"
/> />
<span v-else>{{ message.content }}</span> <span v-else>{{ message.content }}</span>
</div> </div>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" /> <MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses en thread --> <!-- Réponses en thread -->
<div v-if="message.replies?.length" class="bubble-thread"> <div v-if="message.replies?.length" class="bubble-thread">
<div v-for="reply in message.replies" :key="reply.id" class="bubble-reply"> <div v-for="reply in message.replies" :key="reply.id" class="bubble-reply">
<span class="bubble-reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span> <span class="bubble-reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span class="bubble-reply-ts">{{ fmt(reply.createdAt) }}</span> <span class="bubble-reply-ts">{{ fmt(reply.createdAt) }}</span>
<RichContent <RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent" v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode" :mode="reply.richMode"
:content="reply.richContent" :content="reply.richContent"
/> />
<span v-else class="bubble-reply-body">{{ reply.content }}</span> <span v-else class="bubble-reply-body">{{ reply.content }}</span>
</div> </div>
</div> </div>
<button <div class="bubble-actions">
class="bubble-reply-btn" <button
type="button" class="bubble-reply-btn"
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button"
></button> @click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
</div> ></button>
</template> <FavButton :message="message" />
</div>
<script setup lang="ts"> </div>
import { computed } from 'vue'; </template>
import type { Message } from '@/composables/useMessages';
import { useMessageItem } from '@/composables/useMessageItem'; <script setup lang="ts">
import RichContent from './RichContent.vue'; import { computed } from 'vue';
import MessageAttachments from './MessageAttachments.vue'; import type { Message } from '@/composables/useMessages';
import { useMessageItem } from '@/composables/useMessageItem';
const props = defineProps<{ message: Message; myIp?: string }>(); import RichContent from './RichContent.vue';
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); import MessageAttachments from './MessageAttachments.vue';
import FavButton from './FavButton.vue';
const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink } = useMessageItem();
const props = defineProps<{ message: Message; myIp?: string }>();
const isMine = computed(() => props.message.authorIp === props.myIp); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
</script>
const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink } = useMessageItem();
<style scoped>
.bubble-item { const isMine = computed(() => props.message.authorIp === props.myIp);
display: flex; </script>
flex-direction: column;
align-items: flex-start; <style scoped>
padding: 4px 12px; .bubble-item {
gap: 3px; display: flex;
position: relative; flex-direction: column;
} align-items: flex-start;
.bubble-item--mine { align-items: flex-end; } padding: 4px 12px;
gap: 3px;
.bubble-header { position: relative;
display: flex; }
align-items: center; .bubble-item--mine { align-items: flex-end; }
gap: 6px;
font-family: 'Courier New', monospace; .bubble-header {
font-size: 10px; display: flex;
} align-items: center;
.bubble-ip { font-weight: bold; font-size: 11px; } gap: 6px;
.bubble-ts { color: #303040; } font-family: 'Courier New', monospace;
.pet { font-size: 11px; } font-size: 10px;
.vip-badge { }
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold; .bubble-ip { font-weight: bold; font-size: 11px; }
color: #ffcc44; background: #2a2206; border: 1px solid #665511; .bubble-ts { color: #303040; }
border-radius: 4px; padding: 0 4px; margin-left: 2px; .pet { font-size: 11px; }
} .vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
.bubble { color: #ffcc44; background: #2a2206; border: 1px solid #665511;
background: var(--xip-bubble-other); border-radius: 4px; padding: 0 4px; margin-left: 2px;
border: 1px solid var(--xip-bubble-other-border); }
border-radius: 14px 14px 14px 4px;
padding: 7px 13px; .bubble {
font-family: Arial, sans-serif; background: var(--xip-bubble-other);
font-size: 13px; border: 1px solid var(--xip-bubble-other-border);
color: #e0e0e8; border-radius: 14px 14px 14px 4px;
max-width: 72%; padding: 7px 13px;
word-break: break-word; font-family: Arial, sans-serif;
line-height: 1.4; font-size: 13px;
} color: #e0e0e8;
.bubble--mine { max-width: 72%;
background: var(--xip-bubble-sent); word-break: break-word;
border-color: var(--xip-bubble-sent-border); line-height: 1.4;
border-radius: 14px 14px 4px 14px; }
color: #eef4f0; .bubble--mine {
} background: var(--xip-bubble-sent);
border-color: var(--xip-bubble-sent-border);
.bubble-thread { border-radius: 14px 14px 4px 14px;
display: flex; color: #eef4f0;
flex-direction: column; }
gap: 4px;
padding-left: 12px; .bubble-thread {
border-left: 2px solid #1a1a2e; display: flex;
margin-top: 2px; flex-direction: column;
} gap: 4px;
.bubble-reply { padding-left: 12px;
display: flex; border-left: 2px solid #1a1a2e;
align-items: baseline; margin-top: 2px;
gap: 6px; }
font-family: Arial, sans-serif; .bubble-reply {
font-size: 11px; display: flex;
color: #888; align-items: baseline;
} gap: 6px;
.bubble-reply-ip { font-family: 'Courier New', monospace; font-size: 10px; font-weight: bold; } font-family: Arial, sans-serif;
.bubble-reply-ts { font-family: 'Courier New', monospace; font-size: 9px; color: #303040; } font-size: 11px;
.bubble-reply-body { color: #888; } color: #888;
}
.bubble-reply-btn { .bubble-reply-ip { font-family: 'Courier New', monospace; font-size: 10px; font-weight: bold; }
background: none; border: none; cursor: pointer; .bubble-reply-ts { font-family: 'Courier New', monospace; font-size: 9px; color: #303040; }
font-size: 10px; color: #33335a; .bubble-reply-body { color: #888; }
padding: 0; opacity: 0; transition: opacity 0.12s;
} .bubble-actions { display: flex; align-items: center; gap: 8px; }
.bubble-item:hover .bubble-reply-btn { opacity: 1; } .bubble-reply-btn {
.bubble-reply-btn:hover { color: #00ccff; } background: none; border: none; cursor: pointer;
font-size: 10px; color: #33335a;
.geo-link { color: #44445a; text-decoration: none; display: inline-flex; align-items: center; } padding: 0; opacity: 0; transition: opacity 0.12s;
.geo-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; } }
</style> .bubble-item:hover .bubble-reply-btn { opacity: 1; }
.bubble-reply-btn:hover { color: #00ccff; }
.geo-link { color: #44445a; text-decoration: none; display: inline-flex; align-items: center; }
.geo-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; }
</style>

View File

@@ -23,6 +23,7 @@
type="button" type="button"
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
></button> ></button>
<FavButton :message="message" />
</div> </div>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" /> <MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
@@ -50,6 +51,7 @@ import type { Message } from '@/composables/useMessages';
import { useMessageItem } from '@/composables/useMessageItem'; import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue'; import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue'; import MessageAttachments from './MessageAttachments.vue';
import FavButton from './FavButton.vue';
defineProps<{ message: Message; myIp?: string }>(); defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();

View File

@@ -1,98 +1,98 @@
<!-- Zone de messages scrollable avec la pub casino en overlay --> <!-- Zone de messages scrollable avec la pub casino en overlay -->
<template> <template>
<div class="feed-wrapper"> <div class="feed-wrapper">
<div ref="listEl" class="feed-scroll"> <div ref="listEl" class="feed-scroll">
<TransitionGroup name="msg" tag="div"> <TransitionGroup name="msg" tag="div">
<component <component
:is="messageComponent" :is="messageComponent"
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
:my-ip="myIp" :my-ip="myIp"
@reply="$emit('reply', $event)" @reply="$emit('reply', $event)"
/> />
</TransitionGroup> </TransitionGroup>
<div v-if="messages.length === 0" class="feed-empty"> <div v-if="messages.length === 0" class="feed-empty">
Aucun message pour l'instant. Aucun message pour l'instant.
</div> </div>
</div> </div>
<!-- Pub casino : overlay absolu sur la droite du feed (masqué si NoAds) --> <!-- Pub casino : overlay absolu sur la droite du feed (masqué si NoAds) -->
<InlineCasinoAd v-if="!hideAds" class="casino-overlay" /> <InlineCasinoAd v-if="!hideAds" class="casino-overlay" />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, watch, nextTick } from 'vue'; import { ref, computed, watch, nextTick } from 'vue';
import type { Message } from '@/composables/useMessages'; import type { Message } from '@/composables/useMessages';
import { useTheme, THEME_LAYOUT, type Layout } from '@/composables/useTheme'; import { useTheme, THEME_LAYOUT, type Layout } from '@/composables/useTheme';
import MessageItem from './MessageItem.vue'; import MessageItem from './MessageItem.vue';
import MessageItemBubble from './MessageItemBubble.vue'; import MessageItemBubble from './MessageItemBubble.vue';
import MessageItemCompact from './MessageItemCompact.vue'; import MessageItemCompact from './MessageItemCompact.vue';
import InlineCasinoAd from './InlineCasinoAd.vue'; import InlineCasinoAd from './InlineCasinoAd.vue';
const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>(); const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { theme } = useTheme(); const { theme } = useTheme();
// One component per layout family. The `?? MessageItem` fallback guarantees a // One component per layout family. The `?? MessageItem` fallback guarantees a
// missing/unknown layout can never produce `<component :is="undefined">`. // missing/unknown layout can never produce `<component :is="undefined">`.
const LAYOUT_COMPONENT: Record<Layout, typeof MessageItem> = { const LAYOUT_COMPONENT: Record<Layout, typeof MessageItem> = {
classic: MessageItem, classic: MessageItem,
bubble: MessageItemBubble, bubble: MessageItemBubble,
compact: MessageItemCompact, compact: MessageItemCompact,
}; };
const messageComponent = computed( const messageComponent = computed(
() => LAYOUT_COMPONENT[THEME_LAYOUT[theme.value]] ?? MessageItem, () => LAYOUT_COMPONENT[THEME_LAYOUT[theme.value]] ?? MessageItem,
); );
const listEl = ref<HTMLElement | null>(null); const listEl = ref<HTMLElement | null>(null);
watch( watch(
() => props.messages.length, () => props.messages.length,
async () => { async () => {
await nextTick(); await nextTick();
listEl.value?.scrollTo({ top: listEl.value.scrollHeight, behavior: 'smooth' }); listEl.value?.scrollTo({ top: listEl.value.scrollHeight, behavior: 'smooth' });
}, },
); );
</script> </script>
<style scoped> <style scoped>
.feed-wrapper { .feed-wrapper {
flex: 1; flex: 1;
position: relative; position: relative;
overflow: hidden; overflow: hidden;
min-height: 0; min-height: 0;
} }
.feed-scroll { .feed-scroll {
height: 100%; height: 100%;
overflow-y: auto; overflow-y: auto;
padding: 8px 0; padding: 8px 0;
scrollbar-width: thin; scrollbar-width: thin;
scrollbar-color: #252535 #080810; scrollbar-color: #252535 #080810;
} }
.feed-scroll::-webkit-scrollbar { width: 8px; } .feed-scroll::-webkit-scrollbar { width: 8px; }
.feed-scroll::-webkit-scrollbar-track { background: #080810; } .feed-scroll::-webkit-scrollbar-track { background: #080810; }
.feed-scroll::-webkit-scrollbar-thumb { background: #252535; border-radius: 3px; } .feed-scroll::-webkit-scrollbar-thumb { background: #252535; border-radius: 3px; }
.feed-empty { .feed-empty {
padding: 48px 25px; padding: 48px 25px;
color: #2a2a44; color: #2a2a44;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 13px; font-size: 13px;
} }
.casino-overlay { .casino-overlay {
position: absolute; position: absolute;
right: 30px; right: 30px;
top: 20px; top: 20px;
pointer-events: none; pointer-events: none;
} }
.casino-overlay :deep(.casino-cta) { pointer-events: all; } .casino-overlay :deep(.casino-cta) { pointer-events: all; }
/* Transition d'entrée des nouveaux messages */ /* Transition d'entrée des nouveaux messages */
.msg-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; } .msg-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.msg-enter-from { opacity: 0; transform: translateY(6px); } .msg-enter-from { opacity: 0; transform: translateY(6px); }
</style> </style>

View File

@@ -0,0 +1,64 @@
<!-- Modale réutilisable rendue HORS de l'arbre DOM courant (Teleport to body).
Contenu injecté par le parent via slots (défaut = corps, #title = en-tête).
Ferme au clic extérieur (v-click-outside) ou sur Échap. -->
<template>
<Teleport to="body">
<Transition name="modal">
<div v-if="open" class="modal-backdrop">
<div class="modal-card" v-click-outside="close" role="dialog" aria-modal="true">
<header class="modal-head">
<h3 class="modal-title"><slot name="title">{{ title }}</slot></h3>
<button class="modal-x" type="button" title="Fermer" @click="close">✕</button>
</header>
<div class="modal-body">
<slot />
</div>
</div>
</div>
</Transition>
</Teleport>
</template>
<script setup lang="ts">
import { watch, onBeforeUnmount } from 'vue';
import { vClickOutside } from '@/directives/clickOutside';
const props = defineProps<{ open: boolean; title?: string }>();
const emit = defineEmits<{ 'update:open': [v: boolean] }>();
function close(): void { emit('update:open', false); }
function onKey(e: KeyboardEvent): void {
if (e.key === 'Escape') close();
}
watch(() => props.open, (v) => {
if (v) document.addEventListener('keydown', onKey);
else document.removeEventListener('keydown', onKey);
});
onBeforeUnmount(() => document.removeEventListener('keydown', onKey));
</script>
<style scoped>
.modal-backdrop {
position: fixed; inset: 0; z-index: 1000;
background: rgba(0, 0, 0, 0.6);
display: flex; align-items: center; justify-content: center;
padding: 20px;
}
.modal-card {
width: 100%; max-width: 460px; max-height: 85vh; overflow-y: auto;
background: #101018; border: 1px solid #2a2a44; border-radius: 12px;
box-shadow: 0 12px 40px rgba(0, 0, 0, 0.6);
}
.modal-head {
display: flex; align-items: center; justify-content: space-between;
padding: 14px 18px; border-bottom: 1px solid #20203a;
}
.modal-title { font-family: Arial, sans-serif; font-size: 15px; font-weight: bold; color: #ccccee; margin: 0; }
.modal-x { background: none; border: none; color: #55557a; cursor: pointer; font-size: 15px; }
.modal-x:hover { color: #aaa; }
.modal-body { padding: 18px; }
.modal-enter-active, .modal-leave-active { transition: opacity 0.18s ease; }
.modal-enter-from, .modal-leave-to { opacity: 0; }
</style>

View File

@@ -1,108 +1,108 @@
<!-- <!--
Rich message renderer. Rich message renderer.
Sandbox policy: Sandbox policy:
- htmlcss: sandbox="" (empty) + meta CSP scripts totalement inertes - htmlcss: sandbox="" (empty) + meta CSP scripts totalement inertes
- js: sandbox avec tous les tokens SAUF allow-same-origin - js: sandbox avec tous les tokens SAUF allow-same-origin
scripts libres, fetch vers l'extérieur OK, accès parent impossible scripts libres, fetch vers l'extérieur OK, accès parent impossible
(null origin = isolation réelle sans allow-same-origin) (null origin = isolation réelle sans allow-same-origin)
--> -->
<template> <template>
<div class="rich-frame-wrap"> <div class="rich-frame-wrap">
<span class="rich-tag" :class="`rich-tag--${mode}`"> <span class="rich-tag" :class="`rich-tag--${mode}`">
{{ mode === 'js' ? ' JS' : '🎨 HTML/CSS' }} · bac à sable {{ mode === 'js' ? ' JS' : '🎨 HTML/CSS' }} · bac à sable
</span> </span>
<iframe <iframe
ref="frameRef" ref="frameRef"
class="rich-frame" class="rich-frame"
:sandbox="sandboxTokens" :sandbox="sandboxTokens"
:srcdoc="srcdoc" :srcdoc="srcdoc"
referrerpolicy="no-referrer" referrerpolicy="no-referrer"
loading="lazy" loading="lazy"
title="Message riche (isolé)" title="Message riche (isolé)"
/> />
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, useTemplateRef, watchEffect } from 'vue'; import { computed, useTemplateRef, watchEffect } from 'vue';
const props = defineProps<{ mode: 'htmlcss' | 'js'; content: string }>(); const props = defineProps<{ mode: 'htmlcss' | 'js'; content: string }>();
const frameRef = useTemplateRef<HTMLIFrameElement>('frameRef'); const frameRef = useTemplateRef<HTMLIFrameElement>('frameRef');
// htmlcss → aucun script ; js → tout permis sauf accès au parent (pas de allow-same-origin) // htmlcss → aucun script ; js → tout permis sauf accès au parent (pas de allow-same-origin)
const sandboxTokens = computed(() => const sandboxTokens = computed(() =>
props.mode === 'js' props.mode === 'js'
? 'allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-presentation allow-pointer-lock' ? 'allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-presentation allow-pointer-lock'
: '' : ''
); );
// Garde de sécurité réactive — allow-scripts + allow-same-origin = catastrophe // Garde de sécurité réactive — allow-scripts + allow-same-origin = catastrophe
watchEffect(() => { watchEffect(() => {
const tokens = sandboxTokens.value; const tokens = sandboxTokens.value;
if (tokens.includes('allow-scripts') && tokens.includes('allow-same-origin')) { if (tokens.includes('allow-scripts') && tokens.includes('allow-same-origin')) {
throw new Error('SECURITY: rich iframe must never combine allow-scripts + allow-same-origin'); throw new Error('SECURITY: rich iframe must never combine allow-scripts + allow-same-origin');
} }
}); });
const srcdoc = computed(() => { const srcdoc = computed(() => {
// htmlcss : meta CSP en second couche (le sandbox="" bloque déjà les scripts) // htmlcss : meta CSP en second couche (le sandbox="" bloque déjà les scripts)
// js : pas de meta CSP — le sandbox null-origin est la vraie frontière // js : pas de meta CSP — le sandbox null-origin est la vraie frontière
const metaCsp = props.mode === 'htmlcss' const metaCsp = props.mode === 'htmlcss'
? `<meta http-equiv="Content-Security-Policy" ? `<meta http-equiv="Content-Security-Policy"
content="default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;">` content="default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;">`
: ''; : '';
return `<!doctype html> return `<!doctype html>
<html> <html>
<head> <head>
<meta charset="utf-8"> <meta charset="utf-8">
${metaCsp} ${metaCsp}
<style> <style>
html, body { html, body {
margin: 0; margin: 0;
padding: 8px; padding: 8px;
color: #ddd; color: #ddd;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
background: #0a0a12; background: #0a0a12;
overflow: auto; overflow: auto;
height: 100%; height: 100%;
box-sizing: border-box; box-sizing: border-box;
} }
</style> </style>
</head> </head>
<body>${props.content}</body> <body>${props.content}</body>
</html>`; </html>`;
}); });
</script> </script>
<style scoped> <style scoped>
.rich-frame-wrap { .rich-frame-wrap {
position: relative; position: relative;
margin: 6px 25px 0; margin: 6px 25px 0;
} }
.rich-tag { .rich-tag {
position: absolute; position: absolute;
top: -7px; top: -7px;
left: 8px; left: 8px;
z-index: 1; z-index: 1;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 8px; font-size: 8px;
font-weight: bold; font-weight: bold;
padding: 1px 6px; padding: 1px 6px;
border-radius: 6px; border-radius: 6px;
} }
.rich-tag--htmlcss { color: #00ddaa; background: #062019; border: 1px solid #0a4435; } .rich-tag--htmlcss { color: #00ddaa; background: #062019; border: 1px solid #0a4435; }
.rich-tag--js { color: #ffcc44; background: #201a06; border: 1px solid #443a0a; } .rich-tag--js { color: #ffcc44; background: #201a06; border: 1px solid #443a0a; }
.rich-frame { .rich-frame {
width: 480px; width: 480px;
max-width: 100%; max-width: 100%;
height: 270px; height: 270px;
border: 1px solid #222234; border: 1px solid #222234;
border-radius: 8px; border-radius: 8px;
background: #0a0a12; background: #0a0a12;
display: block; display: block;
} }
</style> </style>

View File

@@ -0,0 +1,34 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { mount } from '@vue/test-utils';
import SearchBox from './SearchBox.vue';
describe('SearchBox (interaction composant + v-model debouncé)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('német la valeur quaprès le délai de debounce', async () => {
const wrapper = mount(SearchBox, { props: { modelValue: '', delay: 300 } });
const input = wrapper.find('input');
await input.setValue('vue');
// Avant le délai : rien n'est émis vers le parent.
expect(wrapper.emitted('update:modelValue')).toBeFalsy();
vi.advanceTimersByTime(300);
await wrapper.vm.$nextTick();
const emitted = wrapper.emitted('update:modelValue');
expect(emitted).toBeTruthy();
expect(emitted![emitted!.length - 1]).toEqual(['vue']);
});
it('le bouton clear vide la valeur immédiatement', async () => {
const wrapper = mount(SearchBox, { props: { modelValue: 'déjà', delay: 300 } });
await wrapper.find('input').setValue('texte');
await wrapper.find('.search-clear').trigger('click');
const emitted = wrapper.emitted('update:modelValue');
expect(emitted).toBeTruthy();
expect(emitted![emitted!.length - 1]).toEqual(['']);
});
});

View File

@@ -0,0 +1,83 @@
<!-- Champ de recherche réutilisable avec liaison bidirectionnelle personnalisée
(v-model) + debounce interne. Se branche comme un input natif :
<SearchBox v-model="query" placeholder="…" />. Le parent ne reçoit la
nouvelle valeur qu'après une pause de frappe. -->
<template>
<div class="search-box">
<span class="search-icon">🔎</span>
<input
ref="inputEl"
class="search-input"
type="text"
:value="text"
:placeholder="placeholder"
@input="onInput"
@keydown.escape="clearNow"
/>
<button v-if="text" class="search-clear" type="button" title="Effacer" @click="clearNow">✕</button>
</div>
</template>
<script setup lang="ts">
import { ref, watch, onBeforeUnmount } from 'vue';
import { debounce } from '@/composables/useDebounce';
const props = withDefaults(
defineProps<{ placeholder?: string; delay?: number }>(),
{ placeholder: 'Rechercher', delay: 350 },
);
// Liaison bidirectionnelle personnalisée : la prop modelValue + l'event update.
const model = defineModel<string>({ default: '' });
// Copie locale réactive pour un affichage immédiat ; le modèle parent n'est
// mis à jour qu'après le debounce.
const text = ref(model.value);
// Si le parent change la valeur (ex. reset), refléter dans le champ.
watch(model, (v) => { if (v !== text.value) text.value = v; });
const pushModel = debounce((v: string) => { model.value = v; }, props.delay);
function onInput(e: Event): void {
text.value = (e.target as HTMLInputElement).value;
pushModel(text.value);
}
function clearNow(): void {
pushModel.cancel();
text.value = '';
model.value = '';
}
onBeforeUnmount(() => pushModel.cancel());
</script>
<style scoped>
.search-box {
display: flex;
align-items: center;
gap: 8px;
background: #141420;
border: 1px solid #222234;
border-radius: 23px;
padding: 8px 16px;
}
.search-box:focus-within { border-color: #333355; }
.search-icon { font-size: 13px; opacity: 0.6; }
.search-input {
flex: 1;
background: none;
border: none;
outline: none;
color: #aaaacc;
font-family: Arial, sans-serif;
font-size: 13px;
}
.search-input::placeholder { color: #2a2a44; }
.search-clear {
background: none; border: none; cursor: pointer;
color: #55557a; font-size: 12px;
}
.search-clear:hover { color: #aaa; }
</style>

View File

@@ -1,97 +1,97 @@
<!-- Bouton d'envoi — clic gauche : envoyer / clic droit : personnaliser le style --> <!-- Bouton d'envoi — clic gauche : envoyer / clic droit : personnaliser le style -->
<template> <template>
<button <button
class="send-btn" class="send-btn"
:disabled="disabled" :disabled="disabled"
:style="btnStyle" :style="btnStyle"
aria-label="Envoyer" aria-label="Envoyer"
title="Clic droit pour personnaliser" title="Clic droit pour personnaliser"
@click="$emit('send')" @click="$emit('send')"
@contextmenu.prevent="onRightClick" @contextmenu.prevent="onRightClick"
> >
<span v-if="activeSkinChar" class="skin-char">{{ activeSkinChar }}</span> <span v-if="activeSkinChar" class="skin-char">{{ activeSkinChar }}</span>
<svg v-else width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> <svg v-else width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<polygon points="4,5 15,9 4,13 7,9" fill="currentColor" /> <polygon points="4,5 15,9 4,13 7,9" fill="currentColor" />
</svg> </svg>
</button> </button>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { openContextMenu } from '@/composables/useContextMenu'; import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles'; import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
defineProps<{ disabled?: boolean }>(); defineProps<{ disabled?: boolean }>();
defineEmits<{ send: [] }>(); defineEmits<{ send: [] }>();
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();
const activeSkinChar = computed(() => { const activeSkinChar = computed(() => {
const skinId = prefs.sendSkin; const skinId = prefs.sendSkin;
if (!skinId) return null; if (!skinId) return null;
return myPerks.value.sendSkins?.find((s) => s.id === skinId)?.char ?? null; return myPerks.value.sendSkins?.find((s) => s.id === skinId)?.char ?? null;
}); });
const btnStyle = computed(() => { const btnStyle = computed(() => {
// On the default preset, defer to the theme's CSS variables (so e.g. the // On the default preset, defer to the theme's CSS variables (so e.g. the
// WhatsApp theme tints the button green). A chosen preset overrides the theme. // WhatsApp theme tints the button green). A chosen preset overrides the theme.
if (prefs.sendButton === 'default') return {}; if (prefs.sendButton === 'default') return {};
const p = SEND_BUTTON_PRESETS[prefs.sendButton]; const p = SEND_BUTTON_PRESETS[prefs.sendButton];
return { background: p.bg, color: p.color, borderRadius: p.radius }; return { background: p.bg, color: p.color, borderRadius: p.radius };
}); });
function onRightClick(e: MouseEvent): void { function onRightClick(e: MouseEvent): void {
const skins = myPerks.value.sendSkins ?? []; const skins = myPerks.value.sendSkins ?? [];
const items: import('@/composables/useContextMenu').ContextMenuItem[] = [ const items: import('@/composables/useContextMenu').ContextMenuItem[] = [
...Object.entries(SEND_BUTTON_PRESETS).map(([k, v]) => ({ ...Object.entries(SEND_BUTTON_PRESETS).map(([k, v]) => ({
value: k, value: k,
label: v.label, label: v.label,
swatch: v.color, swatch: v.color,
checked: prefs.sendButton === k, checked: prefs.sendButton === k,
})), })),
]; ];
if (skins.length > 0) { if (skins.length > 0) {
items.push({ value: '__skin_header__', label: 'Skin', isHeader: true }); items.push({ value: '__skin_header__', label: 'Skin', isHeader: true });
items.push({ value: '__default_skin__', label: 'Défaut', emoji: '▶', checked: prefs.sendSkin === '' }); items.push({ value: '__default_skin__', label: 'Défaut', emoji: '▶', checked: prefs.sendSkin === '' });
for (const s of skins) { for (const s of skins) {
items.push({ value: s.id, label: s.label ?? s.id.replace('send-skin-', ''), emoji: s.char, checked: prefs.sendSkin === s.id }); items.push({ value: s.id, label: s.label ?? s.id.replace('send-skin-', ''), emoji: s.char, checked: prefs.sendSkin === s.id });
} }
} }
openContextMenu({ openContextMenu({
x: e.clientX, x: e.clientX,
y: e.clientY, y: e.clientY,
title: 'Bouton d\'envoi', title: 'Bouton d\'envoi',
items, items,
current: '', current: '',
onSelect: (v) => { onSelect: (v) => {
if (v === '__default_skin__') { prefs.sendSkin = ''; } if (v === '__default_skin__') { prefs.sendSkin = ''; }
else if (v.startsWith('send-skin-')) { prefs.sendSkin = v; } else if (v.startsWith('send-skin-')) { prefs.sendSkin = v; }
else { prefs.sendButton = v as typeof prefs.sendButton; } else { prefs.sendButton = v as typeof prefs.sendButton; }
}, },
}); });
} }
</script> </script>
<style scoped> <style scoped>
.send-btn { .send-btn {
width: 42px; width: 42px;
height: 42px; height: 42px;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid #ffffff10; border: 1px solid #ffffff10;
border-radius: 50%; border-radius: 50%;
/* Defaults from the theme palette; a chosen preset overrides via inline style. */ /* Defaults from the theme palette; a chosen preset overrides via inline style. */
background: var(--xip-send-bg); background: var(--xip-send-bg);
color: var(--xip-send-fg); color: var(--xip-send-fg);
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
transition: filter 0.15s; transition: filter 0.15s;
} }
.send-btn:hover:not(:disabled) { filter: brightness(1.3); } .send-btn:hover:not(:disabled) { filter: brightness(1.3); }
.send-btn:active:not(:disabled) { filter: brightness(0.85); } .send-btn:active:not(:disabled) { filter: brightness(0.85); }
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; } .send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.skin-char { font-size: 18px; line-height: 1; } .skin-char { font-size: 18px; line-height: 1; }
</style> </style>

View File

@@ -1,216 +1,216 @@
<!-- Bandeau de stats permanent façon téléscripteur néon (casino / bourse). --> <!-- Bandeau de stats permanent façon téléscripteur néon (casino / bourse). -->
<template> <template>
<div class="ticker" :class="{ 'is-off': !connected }"> <div class="ticker" :class="{ 'is-off': !connected }">
<!-- Badge LIVE fixe à gauche --> <!-- Badge LIVE fixe à gauche -->
<div class="ticker-badge"> <div class="ticker-badge">
<span class="ticker-dot" /> <span class="ticker-dot" />
<span class="ticker-badge-txt">{{ connected ? 'LIVE' : '···' }}</span> <span class="ticker-badge-txt">{{ connected ? 'LIVE' : '···' }}</span>
</div> </div>
<!-- Piste défilante (2 groupes identiques pour une boucle sans couture) --> <!-- Piste défilante (2 groupes identiques pour une boucle sans couture) -->
<div class="ticker-viewport"> <div class="ticker-viewport">
<div class="ticker-track"> <div class="ticker-track">
<div <div
v-for="copy in 2" v-for="copy in 2"
:key="copy" :key="copy"
class="ticker-group" class="ticker-group"
:aria-hidden="copy === 2 ? 'true' : undefined" :aria-hidden="copy === 2 ? 'true' : undefined"
> >
<span <span
v-for="item in items" v-for="item in items"
:key="item.key + '-' + copy" :key="item.key + '-' + copy"
class="chip" class="chip"
:class="`chip--${item.tone}`" :class="`chip--${item.tone}`"
> >
<span class="chip-val"> <span class="chip-val">
<AnimatedNumber :value="item.value" :decimals="item.decimals ?? 0" /> <AnimatedNumber :value="item.value" :decimals="item.decimals ?? 0" />
<span v-if="item.unit" class="chip-unit">{{ item.unit }}</span> <span v-if="item.unit" class="chip-unit">{{ item.unit }}</span>
</span> </span>
<span class="chip-label">{{ item.label }}</span> <span class="chip-label">{{ item.label }}</span>
</span> </span>
</div> </div>
</div> </div>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import AnimatedNumber from './AnimatedNumber.vue'; import AnimatedNumber from './AnimatedNumber.vue';
import type { Stats } from '@/composables/useRealtime'; import type { Stats } from '@/composables/useRealtime';
const props = defineProps<{ stats: Stats | null; connected: boolean }>(); const props = defineProps<{ stats: Stats | null; connected: boolean }>();
type Tone = 'cyan' | 'green' | 'magenta' | 'orange' | 'plain'; type Tone = 'cyan' | 'green' | 'magenta' | 'orange' | 'plain';
interface Chip { interface Chip {
key: string; key: string;
label: string; label: string;
value: number; value: number;
tone: Tone; tone: Tone;
unit?: string; unit?: string;
decimals?: number; decimals?: number;
} }
const ZERO: Stats = { const ZERO: Stats = {
connectedTabs: 0, connectedTabs: 0,
typingNow: 0, typingNow: 0,
lettersPerSec: 0, lettersPerSec: 0,
msgsPerMin: 0, msgsPerMin: 0,
messages: 0, messages: 0,
replies: 0, replies: 0,
charsSent: 0, charsSent: 0,
lettersTyped: 0, lettersTyped: 0,
uniqueIps: 0, uniqueIps: 0,
longestMsg: 0, longestMsg: 0,
abandonRate: 0, abandonRate: 0,
avgLength: 0, avgLength: 0,
moneyExtorted: 0, moneyExtorted: 0,
}; };
const items = computed<Chip[]>(() => { const items = computed<Chip[]>(() => {
const s = props.stats ?? ZERO; const s = props.stats ?? ZERO;
return [ return [
{ key: 'tabs', label: 'onglets connectés', value: s.connectedTabs, tone: 'cyan' }, { key: 'tabs', label: 'onglets connectés', value: s.connectedTabs, tone: 'cyan' },
{ key: 'typing', label: 'écrivent là', value: s.typingNow, tone: 'green' }, { key: 'typing', label: 'écrivent là', value: s.typingNow, tone: 'green' },
{ key: 'lps', label: 'lettres / s', value: s.lettersPerSec, decimals: 1, tone: 'green' }, { key: 'lps', label: 'lettres / s', value: s.lettersPerSec, decimals: 1, tone: 'green' },
{ key: 'mpm', label: 'messages / min', value: s.msgsPerMin, tone: 'green' }, { key: 'mpm', label: 'messages / min', value: s.msgsPerMin, tone: 'green' },
{ key: 'msgs', label: 'messages', value: s.messages, tone: 'cyan' }, { key: 'msgs', label: 'messages', value: s.messages, tone: 'cyan' },
{ key: 'replies', label: 'réponses', value: s.replies, tone: 'plain' }, { key: 'replies', label: 'réponses', value: s.replies, tone: 'plain' },
{ key: 'chars', label: 'caractères envoyés', value: s.charsSent, tone: 'plain' }, { key: 'chars', label: 'caractères envoyés', value: s.charsSent, tone: 'plain' },
{ key: 'letters', label: 'lettres tapées', value: s.lettersTyped, tone: 'magenta' }, { key: 'letters', label: 'lettres tapées', value: s.lettersTyped, tone: 'magenta' },
{ key: 'ips', label: 'IP uniques', value: s.uniqueIps, tone: 'cyan' }, { key: 'ips', label: 'IP uniques', value: s.uniqueIps, tone: 'cyan' },
{ key: 'longest', label: 'le + long', value: s.longestMsg, unit: ' car', tone: 'plain' }, { key: 'longest', label: 'le + long', value: s.longestMsg, unit: ' car', tone: 'plain' },
{ key: 'abandon', label: "taux d'abandon", value: s.abandonRate, decimals: 1, unit: ' %', tone: 'orange' }, { key: 'abandon', label: "taux d'abandon", value: s.abandonRate, decimals: 1, unit: ' %', tone: 'orange' },
{ key: 'avg', label: 'longueur moy.', value: s.avgLength, decimals: 1, unit: ' car', tone: 'plain' }, { key: 'avg', label: 'longueur moy.', value: s.avgLength, decimals: 1, unit: ' car', tone: 'plain' },
{ key: 'money', label: 'argent extorqué', value: s.moneyExtorted, decimals: 2, unit: ' €', tone: 'orange' }, { key: 'money', label: 'argent extorqué', value: s.moneyExtorted, decimals: 2, unit: ' €', tone: 'orange' },
]; ];
}); });
</script> </script>
<style scoped> <style scoped>
.ticker { .ticker {
position: relative; position: relative;
flex-shrink: 0; flex-shrink: 0;
height: 40px; height: 40px;
display: flex; display: flex;
align-items: stretch; align-items: stretch;
background: #0a0a12; background: #0a0a12;
border-bottom: 1px solid #1a1a2a; border-bottom: 1px solid #1a1a2a;
box-shadow: 0 2px 8px #00000066; box-shadow: 0 2px 8px #00000066;
overflow: hidden; overflow: hidden;
} }
/* ── Badge LIVE fixe ── */ /* ── Badge LIVE fixe ── */
.ticker-badge { .ticker-badge {
position: relative; position: relative;
z-index: 2; z-index: 2;
flex-shrink: 0; flex-shrink: 0;
display: flex; display: flex;
align-items: center; align-items: center;
gap: 7px; gap: 7px;
padding: 0 14px; padding: 0 14px;
background: #0e0e18; background: #0e0e18;
border-right: 1px solid #1a1a2a; border-right: 1px solid #1a1a2a;
box-shadow: 4px 0 8px #0a0a12; box-shadow: 4px 0 8px #0a0a12;
} }
.ticker-dot { .ticker-dot {
width: 8px; width: 8px;
height: 8px; height: 8px;
border-radius: 50%; border-radius: 50%;
background: #44996655; background: #44996655;
animation: blink 1.5s ease-in-out infinite; animation: blink 1.5s ease-in-out infinite;
} }
.ticker-badge-txt { .ticker-badge-txt {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 11px; font-size: 11px;
font-weight: bold; font-weight: bold;
letter-spacing: 2px; letter-spacing: 2px;
color: #448866; color: #448866;
} }
.ticker.is-off .ticker-dot { .ticker.is-off .ticker-dot {
background: #884444; background: #884444;
animation: none; animation: none;
} }
.ticker.is-off .ticker-badge-txt { .ticker.is-off .ticker-badge-txt {
color: #885555; color: #885555;
} }
@keyframes blink { @keyframes blink {
0%, 100% { opacity: 1; } 0%, 100% { opacity: 1; }
50% { opacity: 0.3; } 50% { opacity: 0.3; }
} }
/* ── Piste défilante ── */ /* ── Piste défilante ── */
.ticker-viewport { .ticker-viewport {
flex: 1; flex: 1;
min-width: 0; min-width: 0;
overflow: hidden; overflow: hidden;
display: flex; display: flex;
align-items: center; align-items: center;
} }
.ticker-track { .ticker-track {
display: inline-flex; display: inline-flex;
white-space: nowrap; white-space: nowrap;
will-change: transform; will-change: transform;
animation: ticker-scroll 48s linear infinite; animation: ticker-scroll 48s linear infinite;
} }
.ticker:hover .ticker-track { .ticker:hover .ticker-track {
animation-play-state: paused; animation-play-state: paused;
} }
.ticker-group { .ticker-group {
display: inline-flex; display: inline-flex;
align-items: center; align-items: center;
} }
@keyframes ticker-scroll { @keyframes ticker-scroll {
from { transform: translateX(0); } from { transform: translateX(0); }
to { transform: translateX(-50%); } to { transform: translateX(-50%); }
} }
/* ── Chips ── */ /* ── Chips ── */
.chip { .chip {
position: relative; position: relative;
display: inline-flex; display: inline-flex;
align-items: baseline; align-items: baseline;
gap: 7px; gap: 7px;
padding: 0 22px; padding: 0 22px;
} }
.chip::after { .chip::after {
content: ''; content: '';
position: absolute; position: absolute;
right: 0; right: 0;
top: 50%; top: 50%;
transform: translateY(-50%); transform: translateY(-50%);
height: 16px; height: 16px;
width: 1px; width: 1px;
background: #ffffff14; background: #ffffff14;
} }
.chip-val { .chip-val {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 15px; font-size: 15px;
font-weight: bold; font-weight: bold;
color: #d8d8e8; color: #d8d8e8;
} }
.chip-unit { .chip-unit {
font-size: 10px; font-size: 10px;
font-weight: normal; font-weight: normal;
opacity: 0.6; opacity: 0.6;
} }
.chip-label { .chip-label {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 10px; font-size: 10px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase; text-transform: uppercase;
color: #50506e; color: #50506e;
} }
.chip--cyan .chip-val { color: #5599aa; } .chip--cyan .chip-val { color: #5599aa; }
.chip--green .chip-val { color: #447755; } .chip--green .chip-val { color: #447755; }
.chip--magenta .chip-val { color: #885588; } .chip--magenta .chip-val { color: #885588; }
.chip--orange .chip-val { color: #997744; } .chip--orange .chip-val { color: #997744; }
/* Accessibilité : pas de défilement si l'utilisateur le refuse */ /* Accessibilité : pas de défilement si l'utilisateur le refuse */
@media (prefers-reduced-motion: reduce) { @media (prefers-reduced-motion: reduce) {
.ticker-track { animation: none; } .ticker-track { animation: none; }
.ticker-viewport { overflow-x: auto; scrollbar-width: none; } .ticker-viewport { overflow-x: auto; scrollbar-width: none; }
.ticker-viewport::-webkit-scrollbar { display: none; } .ticker-viewport::-webkit-scrollbar { display: none; }
} }
</style> </style>

View File

@@ -1,138 +1,138 @@
<!-- Generic right-click style picker. Mounted once in App.vue via Teleport. --> <!-- Generic right-click style picker. Mounted once in App.vue via Teleport. -->
<template> <template>
<Teleport to="body"> <Teleport to="body">
<div <div
v-if="state.visible" v-if="state.visible"
ref="menuEl" ref="menuEl"
class="style-ctx-menu" class="style-ctx-menu"
:style="menuPos" :style="menuPos"
@click.stop @click.stop
> >
<div class="ctx-title">{{ state.title }}</div> <div class="ctx-title">{{ state.title }}</div>
<template v-for="item in state.items" :key="item.value"> <template v-for="item in state.items" :key="item.value">
<div v-if="item.isHeader" class="ctx-header">{{ item.label }}</div> <div v-if="item.isHeader" class="ctx-header">{{ item.label }}</div>
<button <button
v-else v-else
class="ctx-item" class="ctx-item"
:class="{ 'ctx-item--active': item.checked || item.value === state.current }" :class="{ 'ctx-item--active': item.checked || item.value === state.current }"
@click="pick(item.value)" @click="pick(item.value)"
> >
<span v-if="item.emoji" class="ctx-emoji">{{ item.emoji }}</span> <span v-if="item.emoji" class="ctx-emoji">{{ item.emoji }}</span>
<span v-else-if="item.swatch" class="ctx-swatch" :style="{ background: item.swatch }" /> <span v-else-if="item.swatch" class="ctx-swatch" :style="{ background: item.swatch }" />
{{ item.label }} {{ item.label }}
</button> </button>
</template> </template>
</div> </div>
</Teleport> </Teleport>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useContextMenu, closeContextMenu } from '@/composables/useContextMenu'; import { useContextMenu, closeContextMenu } from '@/composables/useContextMenu';
const { state } = useContextMenu(); const { state } = useContextMenu();
const menuEl = ref<HTMLElement | null>(null); const menuEl = ref<HTMLElement | null>(null);
const menuPos = computed(() => ({ const menuPos = computed(() => ({
top: `${Math.min(state.y, window.innerHeight - 260)}px`, top: `${Math.min(state.y, window.innerHeight - 260)}px`,
left: `${Math.min(state.x, window.innerWidth - 175)}px`, left: `${Math.min(state.x, window.innerWidth - 175)}px`,
})); }));
function pick(value: string): void { function pick(value: string): void {
state.onSelect(value); state.onSelect(value);
closeContextMenu(); closeContextMenu();
} }
function onMouseDown(e: MouseEvent): void { function onMouseDown(e: MouseEvent): void {
if (state.visible && menuEl.value && !menuEl.value.contains(e.target as Node)) { if (state.visible && menuEl.value && !menuEl.value.contains(e.target as Node)) {
closeContextMenu(); closeContextMenu();
} }
} }
function onKeyDown(e: KeyboardEvent): void { function onKeyDown(e: KeyboardEvent): void {
if (e.key === 'Escape') closeContextMenu(); if (e.key === 'Escape') closeContextMenu();
} }
onMounted(() => { onMounted(() => {
document.addEventListener('mousedown', onMouseDown); document.addEventListener('mousedown', onMouseDown);
document.addEventListener('keydown', onKeyDown); document.addEventListener('keydown', onKeyDown);
}); });
onUnmounted(() => { onUnmounted(() => {
document.removeEventListener('mousedown', onMouseDown); document.removeEventListener('mousedown', onMouseDown);
document.removeEventListener('keydown', onKeyDown); document.removeEventListener('keydown', onKeyDown);
}); });
</script> </script>
<style> <style>
.style-ctx-menu { .style-ctx-menu {
position: fixed; position: fixed;
z-index: 9999; z-index: 9999;
min-width: 160px; min-width: 160px;
background: #111118; background: #111118;
border: 1px solid #2a2a3a; border: 1px solid #2a2a3a;
border-radius: 6px; border-radius: 6px;
box-shadow: 0 8px 32px #000a, 0 0 0 1px #ffffff08; box-shadow: 0 8px 32px #000a, 0 0 0 1px #ffffff08;
padding: 4px 0; padding: 4px 0;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
.ctx-title { .ctx-title {
font-size: 10px; font-size: 10px;
color: #44445a; color: #44445a;
padding: 4px 12px 3px; padding: 4px 12px 3px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase; text-transform: uppercase;
border-bottom: 1px solid #1e1e2a; border-bottom: 1px solid #1e1e2a;
margin-bottom: 3px; margin-bottom: 3px;
} }
.ctx-header { .ctx-header {
font-size: 9px; font-size: 9px;
color: #33334a; color: #33334a;
padding: 6px 12px 2px; padding: 6px 12px 2px;
letter-spacing: 0.5px; letter-spacing: 0.5px;
text-transform: uppercase; text-transform: uppercase;
} }
.ctx-item { .ctx-item {
display: flex; display: flex;
align-items: center; align-items: center;
gap: 8px; gap: 8px;
width: 100%; width: 100%;
padding: 5px 12px; padding: 5px 12px;
background: none; background: none;
border: none; border: none;
color: #9999bb; color: #9999bb;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 12px; font-size: 12px;
cursor: pointer; cursor: pointer;
text-align: left; text-align: left;
transition: background 0.1s, color 0.1s; transition: background 0.1s, color 0.1s;
} }
.ctx-item:hover { .ctx-item:hover {
background: #1a1a28; background: #1a1a28;
color: #ffffff; color: #ffffff;
} }
.ctx-item--active { .ctx-item--active {
color: #00ddff; color: #00ddff;
} }
.ctx-item--active::after { .ctx-item--active::after {
content: '✓'; content: '✓';
margin-left: auto; margin-left: auto;
font-size: 10px; font-size: 10px;
} }
.ctx-swatch { .ctx-swatch {
width: 10px; width: 10px;
height: 10px; height: 10px;
border-radius: 50%; border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
border: 1px solid #ffffff22; border: 1px solid #ffffff22;
} }
.ctx-emoji { .ctx-emoji {
font-size: 14px; font-size: 14px;
line-height: 1; line-height: 1;
width: 16px; width: 16px;
text-align: center; text-align: center;
flex-shrink: 0; flex-shrink: 0;
} }
</style> </style>

View File

@@ -0,0 +1,27 @@
import { describe, it, expect } from 'vitest';
import { mount } from '@vue/test-utils';
import ThemePicker from './ThemePicker.vue';
import { THEMES } from '@/composables/useTheme';
describe('ThemePicker (interaction composant + v-model custom)', () => {
it('rend un bouton par thème disponible', () => {
const wrapper = mount(ThemePicker, { props: { modelValue: 'default' } });
expect(wrapper.findAll('button')).toHaveLength(Object.keys(THEMES).length);
});
it('émet update:modelValue avec le thème cliqué (WhatsApp)', async () => {
const wrapper = mount(ThemePicker, { props: { modelValue: 'default' } });
const keys = Object.keys(THEMES);
const idx = keys.indexOf('whatsapp');
await wrapper.findAll('button')[idx].trigger('click');
const emitted = wrapper.emitted('update:modelValue');
expect(emitted).toBeTruthy();
expect(emitted![0]).toEqual(['whatsapp']);
});
it('marque le thème actif', () => {
const wrapper = mount(ThemePicker, { props: { modelValue: 'bubble' } });
expect(wrapper.find('.theme-btn--active').exists()).toBe(true);
});
});

View File

@@ -1,41 +1,41 @@
<script setup lang="ts"> <script setup lang="ts">
import { THEMES, type Theme } from '@/composables/useTheme'; import { THEMES, type Theme } from '@/composables/useTheme';
const model = defineModel<Theme>({ required: true }); const model = defineModel<Theme>({ required: true });
</script> </script>
<template> <template>
<div class="theme-picker"> <div class="theme-picker">
<button <button
v-for="(info, key) in THEMES" v-for="(info, key) in THEMES"
:key="key" :key="key"
class="theme-btn" class="theme-btn"
:class="{ 'theme-btn--active': model === key }" :class="{ 'theme-btn--active': model === key }"
:title="info.label" :title="info.label"
type="button" type="button"
@click="model = key" @click="model = key"
>{{ info.emoji }}</button> >{{ info.emoji }}</button>
</div> </div>
</template> </template>
<style scoped> <style scoped>
.theme-picker { .theme-picker {
display: flex; display: flex;
gap: 4px; gap: 4px;
} }
.theme-btn { .theme-btn {
background: #131320; background: #131320;
border: 1px solid #222233; border: 1px solid #222233;
border-radius: 8px; border-radius: 8px;
padding: 3px 7px; padding: 3px 7px;
cursor: pointer; cursor: pointer;
font-size: 14px; font-size: 14px;
opacity: 0.5; opacity: 0.5;
transition: opacity 0.15s, border-color 0.15s; transition: opacity 0.15s, border-color 0.15s;
} }
.theme-btn:hover { opacity: 0.8; } .theme-btn:hover { opacity: 0.8; }
.theme-btn--active { .theme-btn--active {
opacity: 1; opacity: 1;
border-color: #5577aa; border-color: #5577aa;
} }
</style> </style>

View File

@@ -0,0 +1,16 @@
<!-- Conteneur de section « Mes Persos ». Démontre l'injection de contenu depuis
le parent : un slot par défaut (corps de la section) + un slot nommé #lock
(badge optionnel quand la fonctionnalité n'est pas débloquée). -->
<template>
<section class="pf-section" :class="{ 'pf-locked': locked }">
<h2 class="pf-title">
{{ title }}
<slot name="lock" />
</h2>
<slot />
</section>
</template>
<script setup lang="ts">
defineProps<{ title: string; locked?: boolean }>();
</script>

View File

@@ -1,330 +1,331 @@
<!-- One marketplace product card handles per-kind options inline (faithful to shop mockups) --> <!-- One marketplace product card handles per-kind options inline (faithful to shop mockups) -->
<template> <template>
<div class="card" :class="{ 'card--owned': ownedAlready }"> <div class="card" :class="{ 'card--owned': ownedAlready }">
<div v-if="product.badge" class="card-badge">{{ product.badge }}</div> <div v-if="product.badge" class="card-badge">{{ product.badge }}</div>
<div class="card-head"> <div class="card-head">
<span class="card-icon">{{ icon }}</span> <span class="card-icon">{{ icon }}</span>
<div> <div>
<p class="card-name">{{ product.name }}</p> <RouterLink :to="`/shop/p/${product.id}`" class="card-name">{{ product.name }}</RouterLink>
<p v-if="product.subtitle" class="card-sub">{{ product.subtitle }}</p> <p v-if="product.subtitle" class="card-sub">{{ product.subtitle }}</p>
</div> </div>
</div> </div>
<!-- Aperçu cosmétique : avant / après --> <!-- Aperçu cosmétique : avant / après -->
<div v-if="product.kind === 'ip-skin' || product.id === 'bundle-cosmetic'" class="preview"> <div v-if="product.kind === 'ip-skin' || product.id === 'bundle-cosmetic'" class="preview">
<span class="prev-ip prev-plain">192.168.1.45</span> <span class="prev-ip prev-plain">192.168.1.45</span>
<span class="prev-arrow"></span> <span class="prev-arrow"></span>
<span class="prev-ip prev-gold">192.168.1.45</span> <span class="prev-ip prev-gold">192.168.1.45</span>
</div> </div>
<!-- Options : abonnement NoAds --> <!-- Options : abonnement NoAds -->
<div v-if="product.kind === 'subscription'" class="opts"> <div v-if="product.kind === 'subscription'" class="opts">
<label v-for="p in plans" :key="p.id" class="opt-radio" :class="{ active: plan === p.id }"> <label v-for="p in plans" :key="p.id" class="opt-radio" :class="{ active: plan === p.id }">
<input type="radio" :value="p.id" v-model="plan" /> <input type="radio" :value="p.id" v-model="plan" />
<span>{{ p.label }}</span> <span>{{ p.label }}</span>
<span class="opt-price">{{ fmt(p.price) }} cr{{ p.id === 'monthly' ? '/mois' : '/an' }}</span> <span class="opt-price">{{ fmt(p.price) }} cr{{ p.id === 'monthly' ? '/mois' : '/an' }}</span>
</label> </label>
</div> </div>
<!-- Options : Cadre de Pub --> <!-- Options : Cadre de Pub -->
<div v-if="product.kind === 'ad-frame'" class="opts"> <div v-if="product.kind === 'ad-frame'" class="opts">
<div class="opt-row"> <div class="opt-row">
<span class="opt-label">Durée</span> <span class="opt-label">Durée</span>
<select v-model.number="durationDays" class="opt-select"> <select v-model.number="durationDays" class="opt-select">
<option v-for="d in durations" :key="d.days" :value="d.days"> <option v-for="d in durations" :key="d.days" :value="d.days">
{{ d.days }} j{{ d.extra ? ` (+${fmt(d.extra)})` : '' }} {{ d.days }} j{{ d.extra ? ` (+${fmt(d.extra)})` : '' }}
</option> </option>
</select> </select>
</div> </div>
<div class="opt-row"> <div class="opt-row">
<span class="opt-label">Format</span> <span class="opt-label">Format</span>
<select v-model="format" class="opt-select"> <select v-model="format" class="opt-select">
<option v-for="f in formats" :key="f.id" :value="f.id"> <option v-for="f in formats" :key="f.id" :value="f.id">
{{ f.label }}{{ f.extra ? ` (+${fmt(f.extra)})` : '' }} {{ f.label }}{{ f.extra ? ` (+${fmt(f.extra)})` : '' }}
</option> </option>
</select> </select>
</div> </div>
<input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" /> <input v-model="url" class="opt-input" type="text" placeholder="URL de destination (optionnel)" />
</div> </div>
<!-- Options : Pet (grille des designs non encore possédés) --> <!-- Options : Pet (grille des designs non encore possédés) -->
<div v-if="product.kind === 'pet'" class="opts"> <div v-if="product.kind === 'pet'" class="opts">
<div class="pet-grid"> <div class="pet-grid">
<button <button
v-for="d in availableDesigns" v-for="d in availableDesigns"
:key="d.id" :key="d.id"
class="pet-cell" class="pet-cell"
:class="{ active: petDesign === d.id }" :class="{ active: petDesign === d.id }"
@click="petDesign = d.id" @click="petDesign = d.id"
type="button" type="button"
>{{ d.char }}</button> >{{ d.char }}</button>
</div> </div>
</div> </div>
<!-- Preview : Skin de bouton --> <!-- Preview : Skin de bouton -->
<div v-if="product.kind === 'send-skin'" class="send-skin-preview"> <div v-if="product.kind === 'send-skin'" class="send-skin-preview">
<div class="skin-btn-demo">{{ meta.char }}</div> <div class="skin-btn-demo">{{ meta.char }}</div>
</div> </div>
<!-- Stock limité --> <!-- Stock limité -->
<div v-if="product.stockLimit" class="stock"> <div v-if="product.stockLimit" class="stock">
<div class="stock-bar"><div class="stock-fill" :style="{ width: stockPct + '%' }" /></div> <div class="stock-bar"><div class="stock-fill" :style="{ width: stockPct + '%' }" /></div>
<span class="stock-txt">{{ product.stockSold }} / {{ product.stockLimit }} vendus</span> <span class="stock-txt">{{ product.stockSold }} / {{ product.stockLimit }} vendus</span>
</div> </div>
<!-- Prix + CTA --> <!-- Prix + CTA -->
<div class="card-foot"> <div class="card-foot">
<div class="price"> <div class="price">
<span v-if="product.promoPrice != null" class="price-old">{{ fmt(product.basePrice) }}</span> <span v-if="product.promoPrice != null" class="price-old">{{ fmt(product.basePrice) }}</span>
<span class="price-now">{{ fmt(effectivePrice) }}</span> <span class="price-now">{{ fmt(effectivePrice) }}</span>
<span class="price-unit">cr</span> <span class="price-unit">cr</span>
</div> </div>
<!-- Pets: bouton acheter + lien Mes Persos --> <!-- Pets: bouton acheter + lien Mes Persos -->
<template v-if="product.kind === 'pet'"> <template v-if="product.kind === 'pet'">
<button <button
class="buy" class="buy"
:disabled="disabled" :disabled="disabled"
@click="onBuy" @click="onBuy"
type="button" type="button"
>{{ buyLabel }}</button> >{{ buyLabel }}</button>
<button <button
class="buy buy--perso" class="buy buy--perso"
@click="$emit('goPerso')" @click="$emit('goPerso')"
type="button" type="button"
> Mes Persos</button> > Mes Persos</button>
</template> </template>
<button <button
v-else v-else
class="buy" class="buy"
:disabled="disabled" :disabled="disabled"
@click="onBuy" @click="onBuy"
type="button" type="button"
>{{ buyLabel }}</button> >{{ buyLabel }}</button>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed, ref, watch } from 'vue'; import { computed, ref, watch } from 'vue';
import type { Product, PurchaseOptions } from '@/composables/useShop'; import type { Product, PurchaseOptions } from '@/composables/useShop';
import { parseMeta, type ProductMeta } from '@/composables/useMeta'; import { parseMeta, type ProductMeta } from '@/composables/useMeta';
const props = defineProps<{ const props = defineProps<{
product: Product; product: Product;
buying: boolean; buying: boolean;
owns: (kind: string) => boolean; owns: (kind: string) => boolean;
ownedPetChars: string[]; ownedPetChars: string[];
petCount: number; petCount: number;
freeMode: boolean; freeMode: boolean;
}>(); }>();
const emit = defineEmits<{ const emit = defineEmits<{
buy: [productId: string, options: PurchaseOptions]; buy: [productId: string, options: PurchaseOptions];
goPerso: []; goPerso: [];
}>(); }>();
const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson)); const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson));
// Subscription // Subscription
const plans = computed(() => meta.value.plans ?? []); const plans = computed(() => meta.value.plans ?? []);
const plan = ref<'monthly' | 'annual'>('monthly'); const plan = ref<'monthly' | 'annual'>('monthly');
// Ad-frame // Ad-frame
const durations = computed(() => meta.value.durations ?? []); const durations = computed(() => meta.value.durations ?? []);
const formats = computed(() => meta.value.formats ?? []); const formats = computed(() => meta.value.formats ?? []);
const durationDays = ref<number>(7); const durationDays = ref<number>(7);
const format = ref<'static' | 'gif'>('static'); const format = ref<'static' | 'gif'>('static');
const url = ref(''); const url = ref('');
// Pet // Pet
const designs = computed(() => meta.value.designs ?? []); const designs = computed(() => meta.value.designs ?? []);
const petDesign = ref<string>(''); const petDesign = ref<string>('');
const availableDesigns = computed(() => const availableDesigns = computed(() =>
designs.value.filter((d) => !props.ownedPetChars.includes(d.char)) designs.value.filter((d) => !props.ownedPetChars.includes(d.char))
); );
watch(availableDesigns, (ds) => { watch(availableDesigns, (ds) => {
if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) { if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) {
petDesign.value = ds[0].id; petDesign.value = ds[0].id;
} }
}, { immediate: true }); }, { immediate: true });
const icon = computed(() => { const icon = computed(() => {
if (props.product.id === 'ip-colors') return '🎨'; if (props.product.id === 'ip-colors') return '🎨';
if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️'; if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
switch (props.product.kind) { switch (props.product.kind) {
case 'ad-frame': return '📣'; case 'ad-frame': return '📣';
case 'subscription': return '🚫'; case 'subscription': return '🚫';
case 'ip-skin': return '👑'; case 'ip-skin': return '👑';
case 'pet': return '✨'; case 'pet': return '✨';
case 'bundle': return '🎁'; case 'bundle': return '🎁';
case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨'; case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨';
case 'consumable': return '🔊'; case 'consumable': return '🔊';
default: return '🛍️'; default: return '🛍️';
} }
}); });
const effectivePrice = computed(() => { const effectivePrice = computed(() => {
let price = props.product.promoPrice ?? props.product.basePrice; let price = props.product.promoPrice ?? props.product.basePrice;
if (props.product.kind === 'subscription') { if (props.product.kind === 'subscription') {
const p = plans.value.find((x) => x.id === plan.value); const p = plans.value.find((x) => x.id === plan.value);
if (p) price = p.price; if (p) price = p.price;
} }
if (props.product.kind === 'ad-frame') { if (props.product.kind === 'ad-frame') {
const d = durations.value.find((x) => x.days === durationDays.value); const d = durations.value.find((x) => x.days === durationDays.value);
const f = formats.value.find((x) => x.id === format.value); const f = formats.value.find((x) => x.id === format.value);
price += (d?.extra ?? 0) + (f?.extra ?? 0); price += (d?.extra ?? 0) + (f?.extra ?? 0);
} }
return price; return price;
}); });
// Ownership / limits → disable & label. // Ownership / limits → disable & label.
const ownedAlready = computed(() => { const ownedAlready = computed(() => {
const k = props.product.kind; const k = props.product.kind;
if (k === 'ip-skin') return props.owns('style-dore'); if (k === 'ip-skin') return props.owns('style-dore');
if (k === 'subscription') return props.owns('noads'); if (k === 'subscription') return props.owns('noads');
if (k === 'rich') return props.owns(props.product.id); if (k === 'rich') return props.owns(props.product.id);
if (k === 'unlock') return props.owns(props.product.id); if (k === 'unlock') return props.owns(props.product.id);
if (k === 'ad-frame') return props.owns('ad-frame'); if (k === 'ad-frame') return props.owns('ad-frame');
if (k === 'send-skin') return props.owns(props.product.id); if (k === 'send-skin') return props.owns(props.product.id);
return false; return false;
}); });
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit); const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value); const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
const buyLabel = computed(() => { const buyLabel = computed(() => {
if (props.buying) return '...'; if (props.buying) return '...';
if (soldOut.value) return 'Épuisé'; if (soldOut.value) return 'Épuisé';
if (ownedAlready.value) return 'Possédé ✓'; if (ownedAlready.value) return 'Possédé ✓';
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter'; return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
}); });
const stockPct = computed(() => const stockPct = computed(() =>
props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0 props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0
); );
function fmt(centi: number): string { function fmt(centi: number): string {
return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
} }
function onBuy(): void { function onBuy(): void {
const options: PurchaseOptions = {}; const options: PurchaseOptions = {};
if (props.product.kind === 'subscription') options.plan = plan.value; if (props.product.kind === 'subscription') options.plan = plan.value;
if (props.product.kind === 'ad-frame') { if (props.product.kind === 'ad-frame') {
options.durationDays = durationDays.value; options.durationDays = durationDays.value;
options.format = format.value; options.format = format.value;
options.url = url.value || undefined; options.url = url.value || undefined;
} }
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') { if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0]; const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0];
if (d) { options.petDesign = d.id; options.petChar = d.char; } if (d) { options.petDesign = d.id; options.petChar = d.char; }
} }
emit('buy', props.product.id, options); emit('buy', props.product.id, options);
} }
</script> </script>
<style scoped> <style scoped>
.card { .card {
position: relative; position: relative;
background: #101018; background: #101018;
border: 1px solid #20203a; border: 1px solid #20203a;
border-radius: 10px; border-radius: 10px;
padding: 16px; padding: 16px;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
gap: 12px; gap: 12px;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
.card--owned { opacity: 0.7; } .card--owned { opacity: 0.7; }
.card-badge { .card-badge {
position: absolute; position: absolute;
top: -9px; top: -9px;
right: 12px; right: 12px;
background: #ff2266; background: #ff2266;
color: #fff; color: #fff;
font-size: 9px; font-size: 9px;
font-weight: bold; font-weight: bold;
letter-spacing: 0.5px; letter-spacing: 0.5px;
padding: 3px 9px; padding: 3px 9px;
border-radius: 8px; border-radius: 8px;
box-shadow: none; box-shadow: none;
} }
.card-head { display: flex; gap: 12px; align-items: flex-start; } .card-head { display: flex; gap: 12px; align-items: flex-start; }
.card-icon { font-size: 28px; } .card-icon { font-size: 28px; }
.card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; } .card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; text-decoration: none; display: inline-block; }
.card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; } .card-name:hover { color: #00ddff; }
.card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; }
.preview {
display: flex; align-items: center; gap: 10px; .preview {
background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center; display: flex; align-items: center; gap: 10px;
} background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center;
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; } }
.prev-plain { color: #666688; } .prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.prev-gold { color: #aa8833; } .prev-plain { color: #666688; }
.prev-arrow { color: #444466; } .prev-gold { color: #aa8833; }
.prev-arrow { color: #444466; }
.opts { display: flex; flex-direction: column; gap: 8px; }
.opt-radio { .opts { display: flex; flex-direction: column; gap: 8px; }
display: flex; align-items: center; gap: 8px; .opt-radio {
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; display: flex; align-items: center; gap: 8px;
padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
} padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer;
.opt-radio.active { border-color: #00aaff; background: #0a1622; } }
.opt-radio input { accent-color: #00ccff; } .opt-radio.active { border-color: #00aaff; background: #0a1622; }
.opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; } .opt-radio input { accent-color: #00ccff; }
.opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; } .opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; }
.opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; } .opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; }
.opt-label { font-size: 11px; color: #8888aa; } .opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.opt-select, .opt-input { .opt-label { font-size: 11px; color: #8888aa; }
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; .opt-select, .opt-input {
color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
} color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none;
.opt-select { flex: 1; } }
.opt-input { width: 100%; } .opt-select { flex: 1; }
.opt-input { width: 100%; }
.pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.pet-cell { .pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; .pet-cell {
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center; aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
} font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center;
.pet-cell.active { border-color: #8844aa; } }
.pet-pos { display: flex; gap: 6px; } .pet-cell.active { border-color: #8844aa; }
.pet-pos { display: flex; gap: 6px; }
.send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
.skin-btn-demo { .send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
width: 52px; height: 52px; border-radius: 50%; .skin-btn-demo {
background: #151525; border: 1px solid #30306a; width: 52px; height: 52px; border-radius: 50%;
display: flex; align-items: center; justify-content: center; background: #151525; border: 1px solid #30306a;
font-size: 24px; display: flex; align-items: center; justify-content: center;
} font-size: 24px;
}
.stock { display: flex; flex-direction: column; gap: 4px; }
.stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; } .stock { display: flex; flex-direction: column; gap: 4px; }
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); } .stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; }
.stock-txt { font-size: 10px; color: #886644; } .stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
.stock-txt { font-size: 10px; color: #886644; }
.card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
.price { display: flex; align-items: baseline; gap: 6px; } .card-foot { display: flex; align-items: center; justify-content: space-between; flex-wrap: wrap; gap: 6px; margin-top: auto; padding-top: 6px; }
.price-old { font-size: 12px; color: #555; text-decoration: line-through; } .price { display: flex; align-items: baseline; gap: 6px; }
.price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; } .price-old { font-size: 12px; color: #555; text-decoration: line-through; }
.price-unit { font-size: 11px; color: #886633; } .price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; }
.price-unit { font-size: 11px; color: #886633; }
.buy {
background: #004488; border: 1px solid #0066aa; color: #00ddff; .buy {
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer; background: #004488; border: 1px solid #0066aa; color: #00ddff;
transition: background 0.15s; font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
} transition: background 0.15s;
.buy:hover:not(:disabled) { background: #1a4466; } }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } .buy:hover:not(:disabled) { background: #1a4466; }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; }
.buy--perso {
background: #1a1030; border: 1px solid #8844cc; color: #cc88ff; .buy--perso {
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer; background: #1a1030; border: 1px solid #8844cc; color: #cc88ff;
transition: background 0.15s; font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
} transition: background 0.15s;
.buy--perso:hover { background: #261844; } }
</style> .buy--perso:hover { background: #261844; }
</style>

View File

@@ -1,7 +1,6 @@
<!-- Mes Persos Fond du chat (image de fond personnalisée, viewer-side) --> <!-- Mes Persos Fond du chat (image de fond personnalisée, viewer-side) -->
<template> <template>
<section class="pf-section"> <PrefSection title="🖼️ Fond du chat">
<h2 class="pf-title">🖼 Fond du chat</h2>
<p class="pf-sub">URL d'une image (jpg, png, gif, webp…) ou laisse vide pour le fond par défaut.</p> <p class="pf-sub">URL d'une image (jpg, png, gif, webp…) ou laisse vide pour le fond par défaut.</p>
<div class="bg-row"> <div class="bg-row">
<input <input
@@ -15,12 +14,13 @@
<button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button> <button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button>
</div> </div>
<div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" /> <div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" />
</section> </PrefSection>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles'; import { useCustomStyles } from '@/composables/useCustomStyles';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();

View File

@@ -1,10 +1,9 @@
<!-- Mes Persos Couleur de mon IP (viewer-side, nécessite la Palette IP) --> <!-- Mes Persos Couleur de mon IP (viewer-side, nécessite la Palette IP) -->
<template> <template>
<section class="pf-section" :class="{ 'pf-locked': !myPerks.ipColors }"> <PrefSection title="🎨 Couleur de mon IP" :locked="!myPerks.ipColors">
<h2 class="pf-title"> <template v-if="!myPerks.ipColors" #lock>
🎨 Couleur de mon IP <span class="pf-lock">🔒 Palette IP requise</span>
<span v-if="!myPerks.ipColors" class="pf-lock">🔒 Palette IP requise</span> </template>
</h2>
<p v-if="myIp" class="pf-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p> <p v-if="myIp" class="pf-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
<div class="pf-grid"> <div class="pf-grid">
<button <button
@@ -21,7 +20,7 @@
<span class="pf-label">{{ opt.label }}</span> <span class="pf-label">{{ opt.label }}</span>
</button> </button>
</div> </div>
</section> </PrefSection>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -30,6 +29,7 @@ import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor'; import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();

View File

@@ -1,10 +1,9 @@
<!-- Mes Persos Pet actif affiché à gauche de l'IP (parmi les pets possédés) --> <!-- Mes Persos Pet actif affiché à gauche de l'IP (parmi les pets possédés) -->
<template> <template>
<section class="pf-section" :class="{ 'pf-locked': !hasPets }"> <PrefSection title="✨ Mes pets" :locked="!hasPets">
<h2 class="pf-title"> <template v-if="!hasPets" #lock>
✨ Mes pets <span class="pf-lock">Achetez un Pet dans le shop</span>
<span v-if="!hasPets" class="pf-lock">Achetez un Pet dans le shop</span> </template>
</h2>
<template v-if="hasPets"> <template v-if="hasPets">
<div class="pf-grid"> <div class="pf-grid">
<button <button
@@ -28,7 +27,7 @@
</p> </p>
</template> </template>
<p v-else class="pf-sub">Aucun pet possédé pour l'instant.</p> <p v-else class="pf-sub">Aucun pet possédé pour l'instant.</p>
</section> </PrefSection>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
@@ -36,6 +35,7 @@ import { computed } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles'; import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();

View File

@@ -1,10 +1,9 @@
<!-- Mes Persos Couleur du bouton d'envoi (preset, nécessite le skin d'éléments) --> <!-- Mes Persos Couleur du bouton d'envoi (preset, nécessite le skin d'éléments) -->
<template> <template>
<section class="pf-section" :class="{ 'pf-locked': !myPerks.elementSkin }"> <PrefSection title="➤ Bouton d'envoi" :locked="!myPerks.elementSkin">
<h2 class="pf-title"> <template v-if="!myPerks.elementSkin" #lock>
Bouton d'envoi <span class="pf-lock">🔒 Skin d'éléments requis</span>
<span v-if="!myPerks.elementSkin" class="pf-lock">🔒 Skin d'éléments requis</span> </template>
</h2>
<div class="pf-grid"> <div class="pf-grid">
<button <button
v-for="[k, p] in presetEntries" v-for="[k, p] in presetEntries"
@@ -19,12 +18,13 @@
<span class="pf-label">{{ p.label }}</span> <span class="pf-label">{{ p.label }}</span>
</button> </button>
</div> </div>
</section> </PrefSection>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useCustomStyles, SEND_BUTTON_PRESETS, type SendButtonKey } from '@/composables/useCustomStyles'; import { useCustomStyles, SEND_BUTTON_PRESETS, type SendButtonKey } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();

View File

@@ -1,10 +1,9 @@
<!-- Mes Persos Skin (emoji) du bouton d'envoi, parmi les skins possédés --> <!-- Mes Persos Skin (emoji) du bouton d'envoi, parmi les skins possédés -->
<template> <template>
<section class="pf-section" :class="{ 'pf-locked': !hasSendSkins }"> <PrefSection title="🖱️ Skin du bouton d'envoi" :locked="!hasSendSkins">
<h2 class="pf-title"> <template v-if="!hasSendSkins" #lock>
🖱️ Skin du bouton d'envoi <span class="pf-lock">Achetez un skin dans le shop</span>
<span v-if="!hasSendSkins" class="pf-lock">Achetez un skin dans le shop</span> </template>
</h2>
<template v-if="hasSendSkins"> <template v-if="hasSendSkins">
<div class="pf-grid"> <div class="pf-grid">
<button <button
@@ -30,13 +29,14 @@
</div> </div>
</template> </template>
<p v-else class="pf-sub">Aucun skin possédé pour l'instant.</p> <p v-else class="pf-sub">Aucun skin possédé pour l'instant.</p>
</section> </PrefSection>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue'; import { computed } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles'; import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();

View File

@@ -0,0 +1,18 @@
import { describe, it, expect } from 'vitest';
import { getIpColor, getIpColorWithPerks } from './ipColor';
describe('ipColor (fonction réutilisable)', () => {
it('est déterministe : même IP → même couleur', () => {
expect(getIpColor('1.2.3.4')).toBe(getIpColor('1.2.3.4'));
});
it('renvoie une couleur hex de la palette', () => {
expect(getIpColor('42.42.42.42')).toMatch(/^#[0-9a-f]{6}$/i);
});
it('le skin gold force la couleur or, sinon palette déterministe', () => {
expect(getIpColorWithPerks('1.2.3.4', { skin: 'gold' })).toBe('#ffdd44');
expect(getIpColorWithPerks('1.2.3.4', {})).toBe(getIpColor('1.2.3.4'));
expect(getIpColorWithPerks('1.2.3.4', null)).toBe(getIpColor('1.2.3.4'));
});
});

View File

@@ -1,33 +1,33 @@
/** Couleurs assignées de façon déterministe à chaque adresse IP */ /** Couleurs assignées de façon déterministe à chaque adresse IP */
const PALETTE = ['#7777aa', '#4499bb', '#aa4499', '#338866', '#aa6633'] as const; const PALETTE = ['#7777aa', '#4499bb', '#aa4499', '#338866', '#aa6633'] as const;
export function getIpColor(ip: string): string { export function getIpColor(ip: string): string {
// djb2 hash // djb2 hash
let hash = 5381; let hash = 5381;
for (let i = 0; i < ip.length; i++) { for (let i = 0; i < ip.length; i++) {
hash = ((hash << 5) + hash + ip.charCodeAt(i)) & 0xffffffff; hash = ((hash << 5) + hash + ip.charCodeAt(i)) & 0xffffffff;
} }
return PALETTE[Math.abs(hash) % PALETTE.length]; return PALETTE[Math.abs(hash) % PALETTE.length];
} }
// Glows are currently disabled globally; params kept for signature stability. // Glows are currently disabled globally; params kept for signature stability.
export function getIpGlow(_color: string): string { export function getIpGlow(_color: string): string {
return 'none'; return 'none';
} }
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */ /** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
const GOLD = '#ffdd44'; const GOLD = '#ffdd44';
interface PerkLike { interface PerkLike {
skin?: 'gold'; skin?: 'gold';
} }
/** Perk-aware color: gold for Style Doré owners, else the deterministic palette. */ /** Perk-aware color: gold for Style Doré owners, else the deterministic palette. */
export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string { export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string {
if (perks?.skin === 'gold') return GOLD; if (perks?.skin === 'gold') return GOLD;
return getIpColor(ip); return getIpColor(ip);
} }
export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string { export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string {
return 'none'; return 'none';
} }

View File

@@ -1,67 +1,67 @@
import { ref, watch } from 'vue'; import { ref, watch } from 'vue';
/** Ad inventory client: fetch ads by slot, report impressions (debounced). */ /** Ad inventory client: fetch ads by slot, report impressions (debounced). */
// Shared signal: bumped when the server broadcasts an `ads` frame (e.g. a user // Shared signal: bumped when the server broadcasts an `ads` frame (e.g. a user
// bought a Cadre de Pub). All useAds instances refetch when this changes. // bought a Cadre de Pub). All useAds instances refetch when this changes.
const adsRevision = ref(0); const adsRevision = ref(0);
export function bumpAdsRevision(): void { export function bumpAdsRevision(): void {
adsRevision.value++; adsRevision.value++;
} }
export interface Ad { export interface Ad {
id: string; id: string;
brand: string; brand: string;
subtitle?: string | null; subtitle?: string | null;
url?: string | null; url?: string | null;
cta?: string | null; cta?: string | null;
icon?: string | null; icon?: string | null;
tone: string; // blue | green | purple | casino | user tone: string; // blue | green | purple | casino | user
kind: string; // band | casino kind: string; // band | casino
ownerIp?: string | null; ownerIp?: string | null;
imageUrl?: string | null; imageUrl?: string | null;
} }
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export function useAds(kind: 'band' | 'casino') { export function useAds(kind: 'band' | 'casino') {
const ads = ref<Ad[]>([]); const ads = ref<Ad[]>([]);
async function fetchAds(): Promise<void> { async function fetchAds(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/ads?kind=${kind}`); const res = await fetch(`${API_URL}/api/ads?kind=${kind}`);
if (res.ok) ads.value = (await res.json()) as Ad[]; if (res.ok) ads.value = (await res.json()) as Ad[];
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
// Refetch whenever the server signals an inventory change. // Refetch whenever the server signals an inventory change.
watch(adsRevision, () => void fetchAds()); watch(adsRevision, () => void fetchAds());
// Debounced impression reporting (each ad id at most once per flush). // Debounced impression reporting (each ad id at most once per flush).
const pending = new Set<string>(); const pending = new Set<string>();
let timer: ReturnType<typeof setTimeout> | null = null; let timer: ReturnType<typeof setTimeout> | null = null;
function reportImpression(id: string): void { function reportImpression(id: string): void {
pending.add(id); pending.add(id);
if (timer) return; if (timer) return;
timer = setTimeout(flush, 800); timer = setTimeout(flush, 800);
} }
async function flush(): Promise<void> { async function flush(): Promise<void> {
timer = null; timer = null;
const ids = [...pending]; const ids = [...pending];
pending.clear(); pending.clear();
if (!ids.length) return; if (!ids.length) return;
try { try {
await fetch(`${API_URL}/api/ads/impressions`, { await fetch(`${API_URL}/api/ads/impressions`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ ids }), body: JSON.stringify({ ids }),
}); });
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
return { ads, fetchAds, reportImpression }; return { ads, fetchAds, reportImpression };
} }

View File

@@ -1,86 +1,86 @@
import { ref } from 'vue'; import { ref } from 'vue';
/** /**
* Global audio alert (paid, consumable). On an `alert` WS frame, every tab plays * Global audio alert (paid, consumable). On an `alert` WS frame, every tab plays
* the sound at full volume for at most maxDurationMs. If a custom mp3 URL is * the sound at full volume for at most maxDurationMs. If a custom mp3 URL is
* provided it's played; otherwise a synthesized siren is used (WebAudio). * provided it's played; otherwise a synthesized siren is used (WebAudio).
* *
* Browser autoplay policies block sound before a user gesture — we unlock the * Browser autoplay policies block sound before a user gesture — we unlock the
* AudioContext on the first click anywhere. * AudioContext on the first click anywhere.
*/ */
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
let audioCtx: AudioContext | null = null; let audioCtx: AudioContext | null = null;
const lastFiredAt = ref(0); const lastFiredAt = ref(0);
function unlock(): void { function unlock(): void {
if (!audioCtx) { if (!audioCtx) {
const AC = (window as any).AudioContext || (window as any).webkitAudioContext; const AC = (window as any).AudioContext || (window as any).webkitAudioContext;
if (AC) audioCtx = new AC(); if (AC) audioCtx = new AC();
} }
if (audioCtx && audioCtx.state === 'suspended') void audioCtx.resume(); if (audioCtx && audioCtx.state === 'suspended') void audioCtx.resume();
} }
// Unlock on the first interaction. // Unlock on the first interaction.
if (typeof window !== 'undefined') { if (typeof window !== 'undefined') {
window.addEventListener('pointerdown', unlock, { once: false }); window.addEventListener('pointerdown', unlock, { once: false });
} }
function playSiren(maxDurationMs: number): void { function playSiren(maxDurationMs: number): void {
if (!audioCtx) return; if (!audioCtx) return;
const dur = Math.min(maxDurationMs, 5000) / 1000; const dur = Math.min(maxDurationMs, 5000) / 1000;
const now = audioCtx.currentTime; const now = audioCtx.currentTime;
const osc = audioCtx.createOscillator(); const osc = audioCtx.createOscillator();
const gain = audioCtx.createGain(); const gain = audioCtx.createGain();
osc.type = 'sawtooth'; osc.type = 'sawtooth';
// Warble between two pitches like an air-raid siren. // Warble between two pitches like an air-raid siren.
osc.frequency.setValueAtTime(440, now); osc.frequency.setValueAtTime(440, now);
for (let t = 0; t < dur; t += 0.5) { for (let t = 0; t < dur; t += 0.5) {
osc.frequency.linearRampToValueAtTime(880, now + t + 0.25); osc.frequency.linearRampToValueAtTime(880, now + t + 0.25);
osc.frequency.linearRampToValueAtTime(440, now + t + 0.5); osc.frequency.linearRampToValueAtTime(440, now + t + 0.5);
} }
gain.gain.setValueAtTime(1, now); // volume à fond gain.gain.setValueAtTime(1, now); // volume à fond
gain.gain.setValueAtTime(1, now + dur - 0.05); gain.gain.setValueAtTime(1, now + dur - 0.05);
gain.gain.linearRampToValueAtTime(0, now + dur); gain.gain.linearRampToValueAtTime(0, now + dur);
osc.connect(gain).connect(audioCtx.destination); osc.connect(gain).connect(audioCtx.destination);
osc.start(now); osc.start(now);
osc.stop(now + dur); osc.stop(now + dur);
} }
function playMp3(url: string, maxDurationMs: number): void { function playMp3(url: string, maxDurationMs: number): void {
const audio = new Audio(url); const audio = new Audio(url);
audio.volume = 1; audio.volume = 1;
void audio.play().catch(() => { /* autoplay blocked */ }); void audio.play().catch(() => { /* autoplay blocked */ });
setTimeout(() => { audio.pause(); audio.currentTime = 0; }, Math.min(maxDurationMs, 5000)); setTimeout(() => { audio.pause(); audio.currentTime = 0; }, Math.min(maxDurationMs, 5000));
} }
/** Handle an incoming `alert` frame. */ /** Handle an incoming `alert` frame. */
export function handleAlertFrame(data: { soundUrl?: string; maxDurationMs?: number }): void { export function handleAlertFrame(data: { soundUrl?: string; maxDurationMs?: number }): void {
lastFiredAt.value = Date.now(); lastFiredAt.value = Date.now();
const max = data.maxDurationMs ?? 5000; const max = data.maxDurationMs ?? 5000;
unlock(); unlock();
if (data.soundUrl) playMp3(data.soundUrl, max); if (data.soundUrl) playMp3(data.soundUrl, max);
else playSiren(max); else playSiren(max);
} }
export function useAlert() { export function useAlert() {
async function fireAlert(soundUrl?: string): Promise<{ ok: boolean; error?: string }> { async function fireAlert(soundUrl?: string): Promise<{ ok: boolean; error?: string }> {
unlock(); unlock();
try { try {
const res = await fetch(`${API_URL}/api/alert`, { const res = await fetch(`${API_URL}/api/alert`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ soundUrl }), body: JSON.stringify({ soundUrl }),
}); });
if (!res.ok) { if (!res.ok) {
const d = await res.json().catch(() => ({})); const d = await res.json().catch(() => ({}));
return { ok: false, error: d.error || 'Alerte impossible' }; return { ok: false, error: d.error || 'Alerte impossible' };
} }
return { ok: true }; return { ok: true };
} catch { } catch {
return { ok: false, error: 'Réseau indisponible' }; return { ok: false, error: 'Réseau indisponible' };
} }
} }
return { fireAlert }; return { fireAlert };
} }

View File

@@ -1,43 +1,43 @@
/** Upload helper: posts a file to /api/uploads, returns its metadata. */ /** Upload helper: posts a file to /api/uploads, returns its metadata. */
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export interface UploadedAttachment { export interface UploadedAttachment {
id: string; id: string;
filename: string; filename: string;
mimeType: string; mimeType: string;
size: number; size: number;
} }
export type UploadResult = export type UploadResult =
| { ok: true; attachment: UploadedAttachment } | { ok: true; attachment: UploadedAttachment }
| { ok: false; error: string }; | { ok: false; error: string };
export function useAttachments() { export function useAttachments() {
async function uploadFile(file: File): Promise<UploadResult> { async function uploadFile(file: File): Promise<UploadResult> {
const form = new FormData(); const form = new FormData();
form.append('file', file); form.append('file', file);
try { try {
const res = await fetch(`${API_URL}/api/uploads`, { method: 'POST', body: form }); const res = await fetch(`${API_URL}/api/uploads`, { method: 'POST', body: form });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) return { ok: false, error: data.error || 'Upload refusé' }; if (!res.ok) return { ok: false, error: data.error || 'Upload refusé' };
return { ok: true, attachment: data as UploadedAttachment }; return { ok: true, attachment: data as UploadedAttachment };
} catch { } catch {
return { ok: false, error: 'Réseau indisponible' }; return { ok: false, error: 'Réseau indisponible' };
} }
} }
/** Human file size. */ /** Human file size. */
function kb(bytes: number): string { function kb(bytes: number): string {
if (bytes >= 1_000_000) return (bytes / 1_000_000).toFixed(1) + ' Mo'; if (bytes >= 1_000_000) return (bytes / 1_000_000).toFixed(1) + ' Mo';
if (bytes >= 1000) return Math.round(bytes / 1000) + ' Ko'; if (bytes >= 1000) return Math.round(bytes / 1000) + ' Ko';
return bytes + ' o'; return bytes + ' o';
} }
/** URL to fetch/download an attachment. */ /** URL to fetch/download an attachment. */
function urlFor(id: string): string { function urlFor(id: string): string {
return `${API_URL}/api/uploads/${id}`; return `${API_URL}/api/uploads/${id}`;
} }
return { uploadFile, kb, urlFor }; return { uploadFile, kb, urlFor };
} }

View File

@@ -1,60 +1,60 @@
/** /**
* Global singleton for the right-click style context menu. * Global singleton for the right-click style context menu.
* Any component calls openContextMenu() to display the floating picker, * Any component calls openContextMenu() to display the floating picker,
* and StyleContextMenu.vue (mounted once in App.vue) renders it. * and StyleContextMenu.vue (mounted once in App.vue) renders it.
*/ */
import { reactive } from 'vue'; import { reactive } from 'vue';
export interface ContextMenuItem { export interface ContextMenuItem {
value: string; value: string;
label: string; label: string;
swatch?: string; // optional color swatch dot swatch?: string; // optional color swatch dot
emoji?: string; // optional emoji shown instead of swatch emoji?: string; // optional emoji shown instead of swatch
isHeader?: boolean; // non-interactive section heading isHeader?: boolean; // non-interactive section heading
checked?: boolean; // explicit checkmark (for multi-group menus) checked?: boolean; // explicit checkmark (for multi-group menus)
} }
interface MenuState { interface MenuState {
visible: boolean; visible: boolean;
x: number; x: number;
y: number; y: number;
title: string; title: string;
items: ContextMenuItem[]; items: ContextMenuItem[];
current: string; current: string;
onSelect: (value: string) => void; onSelect: (value: string) => void;
} }
const state = reactive<MenuState>({ const state = reactive<MenuState>({
visible: false, visible: false,
x: 0, x: 0,
y: 0, y: 0,
title: '', title: '',
items: [], items: [],
current: '', current: '',
onSelect: () => {}, onSelect: () => {},
}); });
export function openContextMenu(opts: { export function openContextMenu(opts: {
x: number; x: number;
y: number; y: number;
title: string; title: string;
items: ContextMenuItem[]; items: ContextMenuItem[];
current: string; current: string;
onSelect: (value: string) => void; onSelect: (value: string) => void;
}): void { }): void {
state.visible = true; state.visible = true;
state.x = opts.x; state.x = opts.x;
state.y = opts.y; state.y = opts.y;
state.title = opts.title; state.title = opts.title;
state.items = opts.items; state.items = opts.items;
state.current = opts.current; state.current = opts.current;
state.onSelect = opts.onSelect; state.onSelect = opts.onSelect;
} }
export function closeContextMenu(): void { export function closeContextMenu(): void {
state.visible = false; state.visible = false;
} }
export function useContextMenu() { export function useContextMenu() {
return { state }; return { state };
} }

View File

@@ -1,80 +1,80 @@
/** /**
* Viewer-side visual customisations, persisted in localStorage. * Viewer-side visual customisations, persisted in localStorage.
* None of these affect other users — they're purely local display overrides. * None of these affect other users — they're purely local display overrides.
*/ */
import { reactive, watch } from 'vue'; import { reactive, watch } from 'vue';
const STORAGE_KEY = 'xip_custom_styles_v1'; const STORAGE_KEY = 'xip_custom_styles_v1';
// ── Preset catalogues ──────────────────────────────────────────────────────── // ── Preset catalogues ────────────────────────────────────────────────────────
export const SEND_BUTTON_PRESETS = { export const SEND_BUTTON_PRESETS = {
default: { bg: '#004488', color: '#00ddff', radius: '50%', label: 'Cyan (défaut)' }, default: { bg: '#004488', color: '#00ddff', radius: '50%', label: 'Cyan (défaut)' },
green: { bg: '#1a4a1a', color: '#00ee77', radius: '50%', label: 'Vert' }, green: { bg: '#1a4a1a', color: '#00ee77', radius: '50%', label: 'Vert' },
purple: { bg: '#2a1040', color: '#cc44ff', radius: '50%', label: 'Violet' }, purple: { bg: '#2a1040', color: '#cc44ff', radius: '50%', label: 'Violet' },
red: { bg: '#3a0a0a', color: '#ff5533', radius: '50%', label: 'Rouge' }, red: { bg: '#3a0a0a', color: '#ff5533', radius: '50%', label: 'Rouge' },
square: { bg: '#1a1a1a', color: '#ffffff', radius: '4px', label: 'Blanc carré' }, square: { bg: '#1a1a1a', color: '#ffffff', radius: '4px', label: 'Blanc carré' },
} as const; } as const;
export type SendButtonKey = keyof typeof SEND_BUTTON_PRESETS; export type SendButtonKey = keyof typeof SEND_BUTTON_PRESETS;
export const AD_FRAME_PRESETS = { export const AD_FRAME_PRESETS = {
default: { border: '1px solid #1e1e2a', bg: '#121218', label: 'Défaut' }, default: { border: '1px solid #1e1e2a', bg: '#121218', label: 'Défaut' },
neon: { border: '1px solid #00ddff66', bg: '#0a1220', label: 'Néon bleu' }, neon: { border: '1px solid #00ddff66', bg: '#0a1220', label: 'Néon bleu' },
gold: { border: '1px solid #ffdd4466', bg: '#141208', label: 'Or' }, gold: { border: '1px solid #ffdd4466', bg: '#141208', label: 'Or' },
minimal: { border: '1px solid transparent', bg: '#0c0c10', label: 'Minimal' }, minimal: { border: '1px solid transparent', bg: '#0c0c10', label: 'Minimal' },
} as const; } as const;
export type AdFrameKey = keyof typeof AD_FRAME_PRESETS; export type AdFrameKey = keyof typeof AD_FRAME_PRESETS;
export const IP_COLOR_OPTIONS: { value: string; label: string; swatch?: string }[] = [ export const IP_COLOR_OPTIONS: { value: string; label: string; swatch?: string }[] = [
{ value: 'auto', label: 'Auto (palette)' }, { value: 'auto', label: 'Auto (palette)' },
{ value: '#00ddff', label: 'Cyan', swatch: '#00ddff' }, { value: '#00ddff', label: 'Cyan', swatch: '#00ddff' },
{ value: '#ff00cc', label: 'Rose', swatch: '#ff00cc' }, { value: '#ff00cc', label: 'Rose', swatch: '#ff00cc' },
{ value: '#00ee77', label: 'Vert', swatch: '#00ee77' }, { value: '#00ee77', label: 'Vert', swatch: '#00ee77' },
{ value: '#ffdd44', label: 'Or', swatch: '#ffdd44' }, { value: '#ffdd44', label: 'Or', swatch: '#ffdd44' },
{ value: '#ff5533', label: 'Rouge', swatch: '#ff5533' }, { value: '#ff5533', label: 'Rouge', swatch: '#ff5533' },
{ value: '#ffffff', label: 'Blanc', swatch: '#ffffff' }, { value: '#ffffff', label: 'Blanc', swatch: '#ffffff' },
]; ];
export const PET_OPTIONS: { value: string; label: string }[] = [ export const PET_OPTIONS: { value: string; label: string }[] = [
{ value: '', label: 'Aucun' }, { value: '', label: 'Aucun' },
{ value: '🐱', label: '🐱 Chat' }, { value: '🐱', label: '🐱 Chat' },
{ value: '🐶', label: '🐶 Chien' }, { value: '🐶', label: '🐶 Chien' },
{ value: '✨', label: '✨ Sparkle' }, { value: '✨', label: '✨ Sparkle' },
{ value: '🔥', label: '🔥 Feu' }, { value: '🔥', label: '🔥 Feu' },
{ value: '👾', label: '👾 Ghost' }, { value: '👾', label: '👾 Ghost' },
{ value: '⚡', label: '⚡ Éclair' }, { value: '⚡', label: '⚡ Éclair' },
{ value: '🌙', label: '🌙 Lune' }, { value: '🌙', label: '🌙 Lune' },
]; ];
// ── Preferences shape ──────────────────────────────────────────────────────── // ── Preferences shape ────────────────────────────────────────────────────────
export interface CustomStylePrefs { export interface CustomStylePrefs {
sendButton: SendButtonKey; sendButton: SendButtonKey;
sendSkin: string; // send-skin product id, or '' for default arrow sendSkin: string; // send-skin product id, or '' for default arrow
adFrame: AdFrameKey; adFrame: AdFrameKey;
ipColors: Record<string, string>; // ip → hex or 'auto' ipColors: Record<string, string>; // ip → hex or 'auto'
ipPets: Record<string, string>; // ip → emoji or '' ipPets: Record<string, string>; // ip → emoji or ''
chatBgUrl: string; // URL or '' for no background chatBgUrl: string; // URL or '' for no background
} }
function defaults(): CustomStylePrefs { function defaults(): CustomStylePrefs {
return { sendButton: 'default', sendSkin: '', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' }; return { sendButton: 'default', sendSkin: '', adFrame: 'default', ipColors: {}, ipPets: {}, chatBgUrl: '' };
} }
function load(): CustomStylePrefs { function load(): CustomStylePrefs {
try { try {
const raw = localStorage.getItem(STORAGE_KEY); const raw = localStorage.getItem(STORAGE_KEY);
if (raw) return { ...defaults(), ...JSON.parse(raw) }; if (raw) return { ...defaults(), ...JSON.parse(raw) };
} catch { /* ignore */ } } catch { /* ignore */ }
return defaults(); return defaults();
} }
const prefs = reactive<CustomStylePrefs>(load()); const prefs = reactive<CustomStylePrefs>(load());
watch(prefs, (v) => { watch(prefs, (v) => {
localStorage.setItem(STORAGE_KEY, JSON.stringify(v)); localStorage.setItem(STORAGE_KEY, JSON.stringify(v));
}, { deep: true }); }, { deep: true });
export function useCustomStyles() { export function useCustomStyles() {
return { prefs }; return { prefs };
} }

View File

@@ -0,0 +1,26 @@
import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest';
import { debounce } from './useDebounce';
describe('debounce (fonction réutilisable)', () => {
beforeEach(() => vi.useFakeTimers());
afterEach(() => vi.useRealTimers());
it('ne déclenche quune fois après la pause', () => {
const spy = vi.fn();
const d = debounce(spy, 300);
d('a'); d('b'); d('c');
expect(spy).not.toHaveBeenCalled();
vi.advanceTimersByTime(300);
expect(spy).toHaveBeenCalledTimes(1);
expect(spy).toHaveBeenCalledWith('c'); // garde le dernier appel
});
it('cancel() annule lappel en attente', () => {
const spy = vi.fn();
const d = debounce(spy, 200);
d('x');
d.cancel();
vi.advanceTimersByTime(500);
expect(spy).not.toHaveBeenCalled();
});
});

View File

@@ -0,0 +1,33 @@
/**
* Petit utilitaire de debounce réutilisable : retourne une version de `fn` qui
* n'est appelée qu'après `delay` ms sans nouvel appel. Expose `.cancel()` pour
* annuler un appel en attente (utile au démontage d'un composant).
*/
export interface Debounced<A extends unknown[]> {
(...args: A): void;
cancel(): void;
}
export function debounce<A extends unknown[]>(
fn: (...args: A) => void,
delay = 300,
): Debounced<A> {
let timer: ReturnType<typeof setTimeout> | null = null;
const debounced = (...args: A): void => {
if (timer) clearTimeout(timer);
timer = setTimeout(() => {
timer = null;
fn(...args);
}, delay);
};
debounced.cancel = (): void => {
if (timer) {
clearTimeout(timer);
timer = null;
}
};
return debounced;
}

View File

@@ -0,0 +1,71 @@
import { describe, it, expect, beforeEach } from 'vitest';
import { useFavorites, type FavoriteSource } from './useFavorites';
const sample: FavoriteSource = {
id: 'm1',
content: 'Bonjour le monde',
authorIp: '1.2.3.4',
createdAt: '2026-01-01T10:00:00.000Z',
authorGeo: { country: 'France', countryCode: 'FR', city: 'Paris' },
};
describe('useFavorites (logique détat)', () => {
beforeEach(() => {
localStorage.clear();
useFavorites().clear();
});
it('ajoute et retire un favori (toggle)', () => {
const fav = useFavorites();
expect(fav.isFav('m1')).toBe(false);
const added = fav.toggle(sample);
expect(added).toBe(true);
expect(fav.isFav('m1')).toBe(true);
expect(fav.all.value).toHaveLength(1);
const removed = fav.toggle(sample);
expect(removed).toBe(false);
expect(fav.isFav('m1')).toBe(false);
expect(fav.all.value).toHaveLength(0);
});
it('stocke un snapshot avec valeurs par défaut', () => {
const fav = useFavorites();
fav.toggle(sample);
const item = fav.all.value[0];
expect(item.content).toBe('Bonjour le monde');
expect(item.authorGeo?.countryCode).toBe('FR');
expect(item.rating).toBe(0);
expect(item.status).toBe('a-lire');
});
it('édite note / rating / statut', () => {
const fav = useFavorites();
fav.toggle(sample);
fav.setNote('m1', 'super message');
fav.setRating('m1', 4);
fav.setStatus('m1', 'top');
const item = fav.all.value[0];
expect(item.note).toBe('super message');
expect(item.rating).toBe(4);
expect(item.status).toBe('top');
});
it('borne la note entre 0 et 5', () => {
const fav = useFavorites();
fav.toggle(sample);
fav.setRating('m1', 9);
expect(fav.all.value[0].rating).toBe(5);
fav.setRating('m1', -3);
expect(fav.all.value[0].rating).toBe(0);
});
it('persiste dans localStorage', () => {
const fav = useFavorites();
fav.toggle(sample);
const raw = localStorage.getItem('xip-favoris');
expect(raw).toBeTruthy();
expect(JSON.parse(raw!).items[0].id).toBe('m1');
});
});

View File

@@ -0,0 +1,124 @@
import { reactive, computed } from 'vue';
/**
* Liste personnelle « Favoris » — état applicatif centralisé (singleton
* module-level), persisté en localStorage, sans serveur. Chaque favori est un
* SNAPSHOT du message au moment de l'ajout : la page Favoris et la synthèse
* restent valides même si le message a quitté le flux temps réel.
*/
const STORAGE_KEY = 'xip-favoris';
export type FavStatus = 'a-lire' | 'lu' | 'top';
export interface FavoriteGeo {
country: string;
countryCode: string;
city: string;
}
/** Données minimales d'un message dont on a besoin pour le favori. */
export interface FavoriteSource {
id: string;
content: string;
authorIp: string;
createdAt: string;
authorGeo?: FavoriteGeo | null;
}
export interface FavoriteItem extends FavoriteSource {
note: string; // annotation libre
rating: number; // 05
status: FavStatus;
addedAt: string; // ISO
}
interface FavState {
items: FavoriteItem[];
}
function load(): FavState {
try {
const raw = localStorage.getItem(STORAGE_KEY);
if (raw) {
const parsed = JSON.parse(raw);
if (Array.isArray(parsed?.items)) return { items: parsed.items };
}
} catch {
/* ignore corrupted storage */
}
return { items: [] };
}
const state = reactive<FavState>(load());
function persist(): void {
try {
localStorage.setItem(STORAGE_KEY, JSON.stringify(state));
} catch {
/* quota / unavailable — non-fatal */
}
}
function find(id: string): FavoriteItem | undefined {
return state.items.find((f) => f.id === id);
}
export function useFavorites() {
const all = computed(() => state.items);
const count = computed(() => state.items.length);
function isFav(id: string): boolean {
return state.items.some((f) => f.id === id);
}
/** Ajoute le message en favori s'il ne l'est pas, sinon le retire. Retourne le nouvel état. */
function toggle(msg: FavoriteSource): boolean {
const existing = find(msg.id);
if (existing) {
state.items = state.items.filter((f) => f.id !== msg.id);
persist();
return false;
}
state.items.push({
id: msg.id,
content: msg.content,
authorIp: msg.authorIp,
createdAt: msg.createdAt,
authorGeo: msg.authorGeo ?? null,
note: '',
rating: 0,
status: 'a-lire',
addedAt: new Date().toISOString(),
});
persist();
return true;
}
function remove(id: string): void {
state.items = state.items.filter((f) => f.id !== id);
persist();
}
function setNote(id: string, note: string): void {
const f = find(id);
if (f) { f.note = note; persist(); }
}
function setRating(id: string, rating: number): void {
const f = find(id);
if (f) { f.rating = Math.max(0, Math.min(5, Math.round(rating))); persist(); }
}
function setStatus(id: string, status: FavStatus): void {
const f = find(id);
if (f) { f.status = status; persist(); }
}
function clear(): void {
state.items = [];
persist();
}
return { all, count, isFav, toggle, remove, setNote, setRating, setStatus, clear };
}

View File

@@ -1,62 +1,62 @@
import { type GeoInfo } from '@/composables/useMessages'; import { type GeoInfo } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks, getIpGlow } from '@/composables/ipColor'; import { getIpColorWithPerks, getIpGlowWithPerks, getIpGlow } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks'; import { usePerks } from '@/composables/usePerks';
import { useCustomStyles } from '@/composables/useCustomStyles'; import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages'; import { useMyPerks } from '@/composables/useMessages';
export function useMessageItem() { export function useMessageItem() {
const { perksFor } = usePerks(); const { perksFor } = usePerks();
const { myPerks } = useMyPerks(); const { myPerks } = useMyPerks();
const { prefs } = useCustomStyles(); const { prefs } = useCustomStyles();
function perksOf(m: { authorIp: string; authorPerks?: any }) { function perksOf(m: { authorIp: string; authorPerks?: any }) {
return m.authorPerks ?? perksFor(m.authorIp); return m.authorPerks ?? perksFor(m.authorIp);
} }
function ipStyle(m: { authorIp: string; authorPerks?: any }) { function ipStyle(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp; const ip = m.authorIp;
const override = prefs.ipColors[ip]; const override = prefs.ipColors[ip];
if (override && override !== 'auto') { if (override && override !== 'auto') {
return { color: override, textShadow: getIpGlow(override) }; return { color: override, textShadow: getIpGlow(override) };
} }
const p = perksOf(m); const p = perksOf(m);
return { color: getIpColorWithPerks(ip, p), textShadow: getIpGlowWithPerks(ip, p) }; return { color: getIpColorWithPerks(ip, p), textShadow: getIpGlowWithPerks(ip, p) };
} }
function petsLeft(m: { authorIp: string; authorPerks?: any }) { function petsLeft(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp; const ip = m.authorIp;
if (ip in prefs.ipPets) return prefs.ipPets[ip]; if (ip in prefs.ipPets) return prefs.ipPets[ip];
return (perksOf(m)?.pets ?? []) return (perksOf(m)?.pets ?? [])
.filter((x: any) => x.position === 'left' || x.position === 'both') .filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char).join(''); .map((x: any) => x.char).join('');
} }
function petsRight(m: { authorIp: string; authorPerks?: any }) { function petsRight(m: { authorIp: string; authorPerks?: any }) {
const ip = m.authorIp; const ip = m.authorIp;
if (ip in prefs.ipPets) return ''; if (ip in prefs.ipPets) return '';
return (perksOf(m)?.pets ?? []) return (perksOf(m)?.pets ?? [])
.filter((x: any) => x.position === 'right' || x.position === 'both') .filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char).join(''); .map((x: any) => x.char).join('');
} }
function fmt(date: string) { function fmt(date: string) {
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
} }
function geoLabel(geo?: GeoInfo | null): string { function geoLabel(geo?: GeoInfo | null): string {
if (!geo) return ''; if (!geo) return '';
if (!geo.countryCode) return 'Local'; if (!geo.countryCode) return 'Local';
const place = geo.city || geo.country; const place = geo.city || geo.country;
if (geo.lat != null && geo.lon != null) { if (geo.lat != null && geo.lon != null) {
return `${place} · ${geo.lat.toFixed(4)}, ${geo.lon.toFixed(4)}`; return `${place} · ${geo.lat.toFixed(4)}, ${geo.lon.toFixed(4)}`;
} }
return place; return place;
} }
function geoLink(geo?: GeoInfo | null): string { function geoLink(geo?: GeoInfo | null): string {
if (!geo || geo.lat == null || geo.lon == null) return 'https://maps.google.com'; if (!geo || geo.lat == null || geo.lon == null) return 'https://maps.google.com';
return `https://www.google.com/maps/search/?api=1&query=${geo.lat},${geo.lon}`; return `https://www.google.com/maps/search/?api=1&query=${geo.lat},${geo.lon}`;
} }
return { perksOf, ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs }; return { perksOf, ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs };
} }

View File

@@ -1,212 +1,212 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted } from 'vue';
import { useRealtime } from './useRealtime'; import { useRealtime } from './useRealtime';
import { useWallet, applyWalletFrame } from './useWallet'; import { useWallet, applyWalletFrame } from './useWallet';
import { setPerks, applyPerksFrame, type Perks } from './usePerks'; import { setPerks, applyPerksFrame, type Perks } from './usePerks';
import { bumpAdsRevision } from './useAds'; import { bumpAdsRevision } from './useAds';
import { handleAlertFrame } from './useAlert'; import { handleAlertFrame } from './useAlert';
// Module-level singleton so any component can read the viewer's own perks // Module-level singleton so any component can read the viewer's own perks
// without prop-drilling (e.g. SendButton, AdBand). // without prop-drilling (e.g. SendButton, AdBand).
export const myPerks = ref<Perks>({}); export const myPerks = ref<Perks>({});
export function useMyPerks() { export function useMyPerks() {
return { myPerks }; return { myPerks };
} }
export interface GeoInfo { export interface GeoInfo {
country: string; country: string;
countryCode: string; countryCode: string;
city: string; city: string;
lat?: number; lat?: number;
lon?: number; lon?: number;
} }
export interface Reply { export interface Reply {
id: string; id: string;
content: string; content: string;
authorIp: string; authorIp: string;
createdAt: string; createdAt: string;
parentId?: string | null; parentId?: string | null;
authorPerks?: Perks; authorPerks?: Perks;
authorGeo?: GeoInfo | null; authorGeo?: GeoInfo | null;
richMode?: 'none' | 'htmlcss' | 'js'; richMode?: 'none' | 'htmlcss' | 'js';
richContent?: string | null; richContent?: string | null;
attachments?: Attachment[]; attachments?: Attachment[];
} }
export interface Attachment { export interface Attachment {
id: string; id: string;
filename: string; filename: string;
mimeType: string; mimeType: string;
size: number; size: number;
} }
export interface Message extends Reply { export interface Message extends Reply {
parentId: string | null; parentId: string | null;
replies: Reply[]; replies: Reply[];
} }
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
/** /**
* Refresh the viewer's own perks from the server (callable from anywhere). * Refresh the viewer's own perks from the server (callable from anywhere).
* The backend computes the perks (entitlement.kind → Perks) and returns them * The backend computes the perks (entitlement.kind → Perks) and returns them
* precomputed as `myPerks`, so we just adopt them — no client-side re-derivation. * precomputed as `myPerks`, so we just adopt them — no client-side re-derivation.
*/ */
export async function refreshMyPerks(): Promise<void> { export async function refreshMyPerks(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/shop/me`); const res = await fetch(`${API_URL}/api/shop/me`);
if (!res.ok) return; if (!res.ok) return;
const { myPerks: p } = (await res.json()) as { myPerks?: Perks }; const { myPerks: p } = (await res.json()) as { myPerks?: Perks };
myPerks.value = p ?? {}; myPerks.value = p ?? {};
const { ip } = useWallet(); const { ip } = useWallet();
if (ip.value) setPerks(ip.value, myPerks.value); if (ip.value) setPerks(ip.value, myPerks.value);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
export function useMessages() { export function useMessages() {
const messages = ref<Message[]>([]); const messages = ref<Message[]>([]);
const loading = ref(false); const loading = ref(false);
const sending = ref(false); const sending = ref(false);
/** Seed the perks store from a message + its replies. */ /** Seed the perks store from a message + its replies. */
function harvestPerks(m: Message): void { function harvestPerks(m: Message): void {
setPerks(m.authorIp, m.authorPerks); setPerks(m.authorIp, m.authorPerks);
for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks); for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks);
} }
async function fetchMessages(): Promise<void> { async function fetchMessages(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`); const res = await fetch(`${API_URL}/api/messages`);
if (res.ok) { if (res.ok) {
// API returns newest→oldest; reverse for chronological display. // API returns newest→oldest; reverse for chronological display.
const list = ((await res.json()) as Message[]).reverse(); const list = ((await res.json()) as Message[]).reverse();
list.forEach(harvestPerks); list.forEach(harvestPerks);
messages.value = list; messages.value = list;
} }
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/** Add a message pushed over the WebSocket (new thread or reply), with dedup. */ /** Add a message pushed over the WebSocket (new thread or reply), with dedup. */
function addIncoming(raw: Message & { parentId: string | null }): void { function addIncoming(raw: Message & { parentId: string | null }): void {
if (!raw || !raw.id) return; if (!raw || !raw.id) return;
// Always record the author's perks, even for replies. // Always record the author's perks, even for replies.
setPerks(raw.authorIp, raw.authorPerks); setPerks(raw.authorIp, raw.authorPerks);
if (raw.parentId == null) { if (raw.parentId == null) {
// New top-level thread. // New top-level thread.
if (messages.value.some((m) => m.id === raw.id)) return; if (messages.value.some((m) => m.id === raw.id)) return;
messages.value.push({ ...raw, replies: raw.replies ?? [] }); messages.value.push({ ...raw, replies: raw.replies ?? [] });
return; return;
} }
// Reply: attach to its parent thread if we have it. // Reply: attach to its parent thread if we have it.
const parent = messages.value.find((m) => m.id === raw.parentId); const parent = messages.value.find((m) => m.id === raw.parentId);
if (!parent) return; // thread not loaded; reconnect-resync will reconcile if (!parent) return; // thread not loaded; reconnect-resync will reconcile
if (parent.replies.some((r) => r.id === raw.id)) return; if (parent.replies.some((r) => r.id === raw.id)) return;
parent.replies.push({ parent.replies.push({
id: raw.id, id: raw.id,
content: raw.content, content: raw.content,
authorIp: raw.authorIp, authorIp: raw.authorIp,
createdAt: raw.createdAt, createdAt: raw.createdAt,
parentId: raw.parentId, parentId: raw.parentId,
authorPerks: raw.authorPerks, authorPerks: raw.authorPerks,
authorGeo: raw.authorGeo, authorGeo: raw.authorGeo,
richMode: raw.richMode, richMode: raw.richMode,
richContent: raw.richContent, richContent: raw.richContent,
attachments: raw.attachments, attachments: raw.attachments,
}); });
} }
const { fetchWallet, ip: myIp } = useWallet(); const { fetchWallet, ip: myIp } = useWallet();
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.). // The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
// myPerks is module-level; this ref is the same reference. // myPerks is module-level; this ref is the same reference.
async function fetchMyPerks(): Promise<void> { async function fetchMyPerks(): Promise<void> {
return refreshMyPerks(); return refreshMyPerks();
} }
const { stats, connected, sendTyping } = useRealtime({ const { stats, connected, sendTyping } = useRealtime({
onMessage: addIncoming, onMessage: addIncoming,
onReconnect: () => { onReconnect: () => {
fetchMessages(); fetchMessages();
fetchWallet(); fetchWallet();
fetchMyPerks(); fetchMyPerks();
}, },
onWallet: applyWalletFrame, onWallet: applyWalletFrame,
onPerks: (data: { ip: string; perks: Perks }) => { onPerks: (data: { ip: string; perks: Perks }) => {
applyPerksFrame(data); applyPerksFrame(data);
// If it's about us, update myPerks too (viewer-scoped perks like NoAds). // If it's about us, update myPerks too (viewer-scoped perks like NoAds).
if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {}; if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {};
}, },
onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch
onAlert: (data) => handleAlertFrame(data), // paid global audio alert onAlert: (data) => handleAlertFrame(data), // paid global audio alert
}); });
interface PostExtras { interface PostExtras {
parentId?: string; parentId?: string;
richMode?: 'htmlcss' | 'js'; richMode?: 'htmlcss' | 'js';
richContent?: string; richContent?: string;
attachmentIds?: string[]; attachmentIds?: string[];
} }
async function postMessage(content: string, extras: PostExtras = {}): Promise<boolean> { async function postMessage(content: string, extras: PostExtras = {}): Promise<boolean> {
const hasRich = !!extras.richContent && !!extras.richMode; const hasRich = !!extras.richContent && !!extras.richMode;
const hasFiles = !!extras.attachmentIds?.length; const hasFiles = !!extras.attachmentIds?.length;
// Allow empty text only when there's rich content or an attachment. // Allow empty text only when there's rich content or an attachment.
if (!content.trim() && !hasRich && !hasFiles) return false; if (!content.trim() && !hasRich && !hasFiles) return false;
sending.value = true; sending.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`, { const res = await fetch(`${API_URL}/api/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({
content: content.trim(), content: content.trim(),
parentId: extras.parentId, parentId: extras.parentId,
richMode: extras.richMode, richMode: extras.richMode,
richContent: extras.richContent, richContent: extras.richContent,
attachmentIds: extras.attachmentIds, attachmentIds: extras.attachmentIds,
}), }),
}); });
if (!res.ok) return false; if (!res.ok) return false;
// The created message comes back via the WebSocket broadcast, so no // The created message comes back via the WebSocket broadcast, so no
// re-fetch here. Fallback: if the socket is down, add it locally. // re-fetch here. Fallback: if the socket is down, add it locally.
if (!connected.value) { if (!connected.value) {
const created = (await res.json()) as Message; const created = (await res.json()) as Message;
addIncoming( addIncoming(
created.parentId == null ? { ...created, replies: [] } : created created.parentId == null ? { ...created, replies: [] } : created
); );
} }
return true; return true;
} finally { } finally {
sending.value = false; sending.value = false;
} }
} }
onMounted(() => { onMounted(() => {
fetchMessages(); fetchMessages();
fetchWallet(); fetchWallet();
fetchMyPerks(); fetchMyPerks();
}); });
// Note: viewer-own perks live in the module-level `myPerks` singleton; read // Note: viewer-own perks live in the module-level `myPerks` singleton; read
// them via `useMyPerks()` rather than off this return (consistency rule). // them via `useMyPerks()` rather than off this return (consistency rule).
return { return {
messages, messages,
loading, loading,
sending, sending,
postMessage, postMessage,
stats, stats,
connected, connected,
sendTyping, sendTyping,
myIp, myIp,
fetchMyPerks, fetchMyPerks,
}; };
} }

View File

@@ -0,0 +1,20 @@
import { describe, it, expect } from 'vitest';
import { parseMeta, type ProductMeta } from './useMeta';
describe('parseMeta (fonction réutilisable)', () => {
it('parse un JSON valide', () => {
const meta = parseMeta<ProductMeta>('{"plans":[{"id":"m","label":"Mensuel","price":499}]}');
expect(meta.plans?.[0].price).toBe(499);
});
it('renvoie le fallback sur null/undefined', () => {
expect(parseMeta(null)).toEqual({});
expect(parseMeta(undefined)).toEqual({});
expect(parseMeta(null, { plans: [] })).toEqual({ plans: [] });
});
it('renvoie le fallback sur JSON invalide (sans lever)', () => {
expect(parseMeta('{ pas du json }')).toEqual({});
expect(() => parseMeta('oops')).not.toThrow();
});
});

View File

@@ -0,0 +1,25 @@
import { describe, it, expect } from 'vitest';
import { usePerks, setPerks, applyPerksFrame, type Perks } from './usePerks';
describe('usePerks (logique détat)', () => {
it('renvoie un objet vide pour une IP inconnue', () => {
expect(usePerks().perksFor('0.0.0.0')).toEqual({});
});
it('enregistre et relit les perks dune IP', () => {
const perks: Perks = { skin: 'gold', pets: [{ char: '🔥', position: 'left' }] };
setPerks('1.1.1.1', perks);
expect(usePerks().perksFor('1.1.1.1')).toEqual(perks);
});
it('ignore un setPerks sans IP ou sans perks', () => {
setPerks('', { skin: 'gold' });
setPerks('2.2.2.2', null);
expect(usePerks().perksFor('2.2.2.2')).toEqual({});
});
it('applique un frame WS perks { ip, perks }', () => {
applyPerksFrame({ ip: '3.3.3.3', perks: { noads: true } });
expect(usePerks().perksFor('3.3.3.3')).toEqual({ noads: true });
});
});

View File

@@ -1,43 +1,43 @@
import { reactive } from 'vue'; import { reactive } from 'vue';
/** /**
* Perks store (module-level singleton): maps an author IP → its visible perks. * Perks store (module-level singleton): maps an author IP → its visible perks.
* Seeded from message payloads (authorPerks), updated live by WS `perks` frames, * Seeded from message payloads (authorPerks), updated live by WS `perks` frames,
* and read by MessageItem to colour names / render pets for every author. * and read by MessageItem to colour names / render pets for every author.
*/ */
export type PetPosition = 'left' | 'right' | 'both'; export type PetPosition = 'left' | 'right' | 'both';
export interface Perks { export interface Perks {
skin?: 'gold'; skin?: 'gold';
pets?: { char: string; position: PetPosition }[]; pets?: { char: string; position: PetPosition }[];
noads?: boolean; noads?: boolean;
badge?: boolean; badge?: boolean;
elementSkin?: boolean; elementSkin?: boolean;
richHtmlcss?: boolean; richHtmlcss?: boolean;
richJs?: boolean; richJs?: boolean;
ipColors?: boolean; ipColors?: boolean;
sendSkins?: { id: string; char: string; label?: string }[]; sendSkins?: { id: string; char: string; label?: string }[];
noFileLimit?: boolean; noFileLimit?: boolean;
audioAlert?: boolean; audioAlert?: boolean;
} }
const map = reactive<Record<string, Perks>>({}); const map = reactive<Record<string, Perks>>({});
/** Merge perks for one IP (from a message payload or a perks frame). */ /** Merge perks for one IP (from a message payload or a perks frame). */
export function setPerks(ip: string, perks: Perks | undefined | null): void { export function setPerks(ip: string, perks: Perks | undefined | null): void {
if (!ip || !perks) return; if (!ip || !perks) return;
map[ip] = perks; map[ip] = perks;
} }
/** Apply a WS `perks` frame: { ip, perks }. */ /** Apply a WS `perks` frame: { ip, perks }. */
export function applyPerksFrame(data: { ip: string; perks: Perks }): void { export function applyPerksFrame(data: { ip: string; perks: Perks }): void {
if (data?.ip) map[data.ip] = data.perks ?? {}; if (data?.ip) map[data.ip] = data.perks ?? {};
} }
export function usePerks() { export function usePerks() {
function perksFor(ip: string): Perks { function perksFor(ip: string): Perks {
return map[ip] ?? {}; return map[ip] ?? {};
} }
return { perksFor, setPerks }; return { perksFor, setPerks };
} }

View File

@@ -1,125 +1,125 @@
import { ref, onMounted, onUnmounted } from 'vue'; import { ref, onMounted, onUnmounted } from 'vue';
/** Mirror of the backend StatsSnapshot. */ /** Mirror of the backend StatsSnapshot. */
export interface Stats { export interface Stats {
// live // live
connectedTabs: number; connectedTabs: number;
typingNow: number; typingNow: number;
lettersPerSec: number; lettersPerSec: number;
msgsPerMin: number; msgsPerMin: number;
// totals // totals
messages: number; messages: number;
replies: number; replies: number;
charsSent: number; charsSent: number;
lettersTyped: number; lettersTyped: number;
uniqueIps: number; uniqueIps: number;
longestMsg: number; longestMsg: number;
// derived // derived
abandonRate: number; abandonRate: number;
avgLength: number; avgLength: number;
moneyExtorted: number; moneyExtorted: number;
} }
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const WS_URL = API_URL.replace(/^http/, 'ws') + '/ws'; const WS_URL = API_URL.replace(/^http/, 'ws') + '/ws';
const TYPING_FLUSH_MS = 400; // batch keystroke deltas before sending const TYPING_FLUSH_MS = 400; // batch keystroke deltas before sending
const RECONNECT_DELAY_MS = 1500; const RECONNECT_DELAY_MS = 1500;
interface RealtimeHooks { interface RealtimeHooks {
onMessage?: (raw: any) => void; onMessage?: (raw: any) => void;
/** Called when the socket reconnects after a drop — use to resync state. */ /** Called when the socket reconnects after a drop — use to resync state. */
onReconnect?: () => void; onReconnect?: () => void;
/** Wallet update for THIS tab's IP (balance changed). */ /** Wallet update for THIS tab's IP (balance changed). */
onWallet?: (data: any) => void; onWallet?: (data: any) => void;
/** A visible perk changed for some IP (skin/pet) — update that author everywhere. */ /** A visible perk changed for some IP (skin/pet) — update that author everywhere. */
onPerks?: (data: any) => void; onPerks?: (data: any) => void;
/** Ad inventory changed (e.g. a user bought a Cadre de Pub). */ /** Ad inventory changed (e.g. a user bought a Cadre de Pub). */
onAds?: (data: any) => void; onAds?: (data: any) => void;
/** A paid global audio alert was fired. */ /** A paid global audio alert was fired. */
onAlert?: (data: any) => void; onAlert?: (data: any) => void;
} }
export function useRealtime(hooks: RealtimeHooks = {}) { export function useRealtime(hooks: RealtimeHooks = {}) {
const stats = ref<Stats | null>(null); const stats = ref<Stats | null>(null);
const connected = ref(false); const connected = ref(false);
let ws: WebSocket | null = null; let ws: WebSocket | null = null;
let reconnectTimer: ReturnType<typeof setTimeout> | null = null; let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
let typingTimer: ReturnType<typeof setTimeout> | null = null; let typingTimer: ReturnType<typeof setTimeout> | null = null;
let typingBuffer = 0; let typingBuffer = 0;
let everConnected = false; let everConnected = false;
let closedByUs = false; let closedByUs = false;
function connect(): void { function connect(): void {
try { try {
ws = new WebSocket(WS_URL); ws = new WebSocket(WS_URL);
} catch { } catch {
scheduleReconnect(); scheduleReconnect();
return; return;
} }
ws.onopen = () => { ws.onopen = () => {
connected.value = true; connected.value = true;
if (everConnected) hooks.onReconnect?.(); if (everConnected) hooks.onReconnect?.();
everConnected = true; everConnected = true;
}; };
ws.onmessage = (ev) => { ws.onmessage = (ev) => {
let msg: { type?: string; data?: any }; let msg: { type?: string; data?: any };
try { try {
msg = JSON.parse(ev.data); msg = JSON.parse(ev.data);
} catch { } catch {
return; return;
} }
if (msg.type === 'stats') stats.value = msg.data as Stats; if (msg.type === 'stats') stats.value = msg.data as Stats;
else if (msg.type === 'message') hooks.onMessage?.(msg.data); else if (msg.type === 'message') hooks.onMessage?.(msg.data);
else if (msg.type === 'wallet') hooks.onWallet?.(msg.data); else if (msg.type === 'wallet') hooks.onWallet?.(msg.data);
else if (msg.type === 'perks') hooks.onPerks?.(msg.data); else if (msg.type === 'perks') hooks.onPerks?.(msg.data);
else if (msg.type === 'ads') hooks.onAds?.(msg.data); else if (msg.type === 'ads') hooks.onAds?.(msg.data);
else if (msg.type === 'alert') hooks.onAlert?.(msg.data); else if (msg.type === 'alert') hooks.onAlert?.(msg.data);
}; };
ws.onclose = () => { ws.onclose = () => {
connected.value = false; connected.value = false;
if (!closedByUs) scheduleReconnect(); if (!closedByUs) scheduleReconnect();
}; };
ws.onerror = () => { ws.onerror = () => {
ws?.close(); ws?.close();
}; };
} }
function scheduleReconnect(): void { function scheduleReconnect(): void {
if (reconnectTimer || closedByUs) return; if (reconnectTimer || closedByUs) return;
reconnectTimer = setTimeout(() => { reconnectTimer = setTimeout(() => {
reconnectTimer = null; reconnectTimer = null;
connect(); connect();
}, RECONNECT_DELAY_MS); }, RECONNECT_DELAY_MS);
} }
/** Report keystrokes (delta ≥ 0). Marks this tab as "typing" and feeds the global counter. */ /** Report keystrokes (delta ≥ 0). Marks this tab as "typing" and feeds the global counter. */
function sendTyping(delta: number): void { function sendTyping(delta: number): void {
typingBuffer += Math.max(0, delta); typingBuffer += Math.max(0, delta);
if (typingTimer) return; if (typingTimer) return;
typingTimer = setTimeout(flushTyping, TYPING_FLUSH_MS); typingTimer = setTimeout(flushTyping, TYPING_FLUSH_MS);
} }
function flushTyping(): void { function flushTyping(): void {
typingTimer = null; typingTimer = null;
if (ws && ws.readyState === WebSocket.OPEN) { if (ws && ws.readyState === WebSocket.OPEN) {
ws.send(JSON.stringify({ type: 'typing', delta: typingBuffer })); ws.send(JSON.stringify({ type: 'typing', delta: typingBuffer }));
} }
typingBuffer = 0; typingBuffer = 0;
} }
onMounted(connect); onMounted(connect);
onUnmounted(() => { onUnmounted(() => {
closedByUs = true; closedByUs = true;
if (reconnectTimer) clearTimeout(reconnectTimer); if (reconnectTimer) clearTimeout(reconnectTimer);
if (typingTimer) clearTimeout(typingTimer); if (typingTimer) clearTimeout(typingTimer);
ws?.close(); ws?.close();
}); });
return { stats, connected, sendTyping }; return { stats, connected, sendTyping };
} }

View File

@@ -1,133 +1,133 @@
import { ref } from 'vue'; import { ref } from 'vue';
import { useWallet } from './useWallet'; import { useWallet } from './useWallet';
import { refreshMyPerks } from './useMessages'; import { refreshMyPerks } from './useMessages';
import { parseMeta, type ProductMeta } from './useMeta'; import { parseMeta, type ProductMeta } from './useMeta';
/** Marketplace client: catalogue, my entitlements, purchase flow. */ /** Marketplace client: catalogue, my entitlements, purchase flow. */
export interface Product { export interface Product {
id: string; id: string;
category: string; category: string;
name: string; name: string;
subtitle?: string | null; subtitle?: string | null;
kind: string; kind: string;
basePrice: number; // centi-credits basePrice: number; // centi-credits
promoPrice?: number | null; promoPrice?: number | null;
badge?: string | null; badge?: string | null;
stockLimit?: number | null; stockLimit?: number | null;
stockSold: number; stockSold: number;
sortOrder: number; sortOrder: number;
metaJson?: string | null; metaJson?: string | null;
} }
export interface Entitlement { export interface Entitlement {
id: string; id: string;
ip: string; ip: string;
kind: string; kind: string;
active: boolean; active: boolean;
expiresAt?: string | null; expiresAt?: string | null;
metaJson?: string | null; metaJson?: string | null;
createdAt: string; createdAt: string;
} }
export interface PurchaseOptions { export interface PurchaseOptions {
plan?: 'monthly' | 'annual'; plan?: 'monthly' | 'annual';
durationDays?: number; durationDays?: number;
format?: 'static' | 'gif'; format?: 'static' | 'gif';
url?: string; url?: string;
petDesign?: string; petDesign?: string;
petChar?: string; petChar?: string;
petPosition?: 'left' | 'right' | 'both'; petPosition?: 'left' | 'right' | 'both';
} }
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
export function useShop() { export function useShop() {
const products = ref<Product[]>([]); const products = ref<Product[]>([]);
const entitlements = ref<Entitlement[]>([]); const entitlements = ref<Entitlement[]>([]);
const loading = ref(false); const loading = ref(false);
const buying = ref<string | null>(null); // productId currently being purchased const buying = ref<string | null>(null); // productId currently being purchased
const lastError = ref<string | null>(null); const lastError = ref<string | null>(null);
const lastSuccess = ref<string | null>(null); const lastSuccess = ref<string | null>(null);
const { fetchWallet } = useWallet(); const { fetchWallet } = useWallet();
async function fetchProducts(): Promise<void> { async function fetchProducts(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
const res = await fetch(`${API_URL}/api/shop/products`); const res = await fetch(`${API_URL}/api/shop/products`);
if (res.ok) products.value = (await res.json()) as Product[]; if (res.ok) products.value = (await res.json()) as Product[];
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
async function fetchMe(): Promise<void> { async function fetchMe(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/shop/me`); const res = await fetch(`${API_URL}/api/shop/me`);
if (res.ok) { if (res.ok) {
const data = await res.json(); const data = await res.json();
entitlements.value = data.entitlements ?? []; entitlements.value = data.entitlements ?? [];
} }
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
function owns(kind: string): boolean { function owns(kind: string): boolean {
return entitlements.value.some((e) => e.kind === kind && e.active); return entitlements.value.some((e) => e.kind === kind && e.active);
} }
function petCount(): number { function petCount(): number {
return entitlements.value.filter((e) => e.kind === 'pet' && e.active).length; return entitlements.value.filter((e) => e.kind === 'pet' && e.active).length;
} }
function ownedPetChars(): string[] { function ownedPetChars(): string[] {
return entitlements.value return entitlements.value
.filter((e) => e.kind === 'pet' && e.active) .filter((e) => e.kind === 'pet' && e.active)
.map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '') .map((e) => parseMeta<ProductMeta>(e.metaJson).char ?? '')
.filter(Boolean); .filter(Boolean);
} }
async function purchase(productId: string, options: PurchaseOptions = {}): Promise<boolean> { async function purchase(productId: string, options: PurchaseOptions = {}): Promise<boolean> {
buying.value = productId; buying.value = productId;
lastError.value = null; lastError.value = null;
lastSuccess.value = null; lastSuccess.value = null;
try { try {
const res = await fetch(`${API_URL}/api/shop/purchase`, { const res = await fetch(`${API_URL}/api/shop/purchase`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ productId, options }), body: JSON.stringify({ productId, options }),
}); });
const data = await res.json().catch(() => ({})); const data = await res.json().catch(() => ({}));
if (!res.ok) { if (!res.ok) {
lastError.value = data.error || 'Achat impossible'; lastError.value = data.error || 'Achat impossible';
return false; return false;
} }
lastSuccess.value = `Acheté : ${productId}`; lastSuccess.value = `Acheté : ${productId}`;
// Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces). // Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces).
await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]); await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]);
return true; return true;
} catch { } catch {
lastError.value = 'Réseau indisponible'; lastError.value = 'Réseau indisponible';
return false; return false;
} finally { } finally {
buying.value = null; buying.value = null;
} }
} }
return { return {
products, products,
entitlements, entitlements,
loading, loading,
buying, buying,
lastError, lastError,
lastSuccess, lastSuccess,
fetchProducts, fetchProducts,
fetchMe, fetchMe,
owns, owns,
petCount, petCount,
ownedPetChars, ownedPetChars,
purchase, purchase,
}; };
} }

View File

@@ -0,0 +1,26 @@
import { describe, it, expect } from 'vitest';
import { useWallet, applyWalletFrame } from './useWallet';
describe('useWallet (logique détat)', () => {
it('affiche un solde réel converti depuis les centi-crédits', () => {
applyWalletFrame({ ip: '8.8.8.8', balance: 5000, freeMode: false });
const { displayBalance, freeMode, balanceRaw } = useWallet();
expect(freeMode.value).toBe(false);
expect(balanceRaw.value).toBe(5000);
// 5000 centi-crédits = 50,00 — séparateur dépendant de la locale ICU.
expect(displayBalance()).not.toBe('∞');
expect(displayBalance()).toContain('50');
});
it('affiche ∞ en mode gratuit (localhost / open bar)', () => {
applyWalletFrame({ ip: '::1', balance: Number.MAX_SAFE_INTEGER, freeMode: true });
const { displayBalance, freeMode } = useWallet();
expect(freeMode.value).toBe(true);
expect(displayBalance()).toBe('∞');
});
it('met à jour lIP courante via le frame WS', () => {
applyWalletFrame({ ip: '9.9.9.9', balance: 0, freeMode: false });
expect(useWallet().ip.value).toBe('9.9.9.9');
});
});

View File

@@ -1,72 +1,72 @@
import { ref } from 'vue'; import { ref } from 'vue';
/** /**
* Wallet store (module-level singleton so the header, shop, and composer all * Wallet store (module-level singleton so the header, shop, and composer all
* share one balance). Credits are CENTI-CREDITS server-side; `displayBalance` * share one balance). Credits are CENTI-CREDITS server-side; `displayBalance`
* converts to a human "crédits" number. Live updates arrive via the WS `wallet` * converts to a human "crédits" number. Live updates arrive via the WS `wallet`
* frame, routed here through useMessages' realtime hook (applyWalletFrame). * frame, routed here through useMessages' realtime hook (applyWalletFrame).
*/ */
export interface WalletView { export interface WalletView {
ip: string; ip: string;
balance: number; // centi-credits, or a huge sentinel in free mode balance: number; // centi-credits, or a huge sentinel in free mode
freeMode: boolean; freeMode: boolean;
} }
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const ip = ref<string>(''); const ip = ref<string>('');
const balanceRaw = ref<number>(0); // centi-credits const balanceRaw = ref<number>(0); // centi-credits
const freeMode = ref<boolean>(false); const freeMode = ref<boolean>(false);
const loaded = ref<boolean>(false); const loaded = ref<boolean>(false);
function apply(view: WalletView): void { function apply(view: WalletView): void {
ip.value = view.ip; ip.value = view.ip;
balanceRaw.value = view.balance; balanceRaw.value = view.balance;
freeMode.value = view.freeMode; freeMode.value = view.freeMode;
loaded.value = true; loaded.value = true;
} }
/** Called by the realtime `wallet` frame handler. */ /** Called by the realtime `wallet` frame handler. */
export function applyWalletFrame(data: WalletView): void { export function applyWalletFrame(data: WalletView): void {
apply(data); apply(data);
} }
async function fetchWallet(): Promise<void> { async function fetchWallet(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/wallet`); const res = await fetch(`${API_URL}/api/wallet`);
if (res.ok) apply((await res.json()) as WalletView); if (res.ok) apply((await res.json()) as WalletView);
} catch { } catch {
/* offline — keep last known */ /* offline — keep last known */
} }
} }
async function topUp(): Promise<void> { async function topUp(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/wallet/topup`, { method: 'POST' }); const res = await fetch(`${API_URL}/api/wallet/topup`, { method: 'POST' });
if (res.ok) apply((await res.json()) as WalletView); if (res.ok) apply((await res.json()) as WalletView);
} catch { } catch {
/* ignore */ /* ignore */
} }
} }
/** Human-readable balance ("∞" in free mode, else credits with 2 decimals). */ /** Human-readable balance ("∞" in free mode, else credits with 2 decimals). */
function displayBalance(): string { function displayBalance(): string {
if (freeMode.value) return '∞'; if (freeMode.value) return '∞';
return (balanceRaw.value / 100).toLocaleString('fr-FR', { return (balanceRaw.value / 100).toLocaleString('fr-FR', {
minimumFractionDigits: 2, minimumFractionDigits: 2,
maximumFractionDigits: 2, maximumFractionDigits: 2,
}); });
} }
export function useWallet() { export function useWallet() {
return { return {
ip, ip,
balanceRaw, balanceRaw,
freeMode, freeMode,
loaded, loaded,
fetchWallet, fetchWallet,
topUp, topUp,
displayBalance, displayBalance,
}; };
} }

View File

@@ -0,0 +1,28 @@
import type { Directive } from 'vue';
/**
* Directive `v-click-outside` : exécute le handler fourni quand un clic se
* produit en dehors de l'élément. Utile pour fermer modales / menus.
* Usage : <div v-click-outside="onClose">…</div>
*/
type Handler = (e: MouseEvent) => void;
const map = new WeakMap<HTMLElement, (e: MouseEvent) => void>();
export const vClickOutside: Directive<HTMLElement, Handler> = {
mounted(el, binding) {
const listener = (e: MouseEvent) => {
if (!el.contains(e.target as Node)) binding.value?.(e);
};
map.set(el, listener);
// `capture` + microtask delay évite de capter le clic qui a ouvert l'élément.
setTimeout(() => document.addEventListener('click', listener, true), 0);
},
unmounted(el) {
const listener = map.get(el);
if (listener) {
document.removeEventListener('click', listener, true);
map.delete(el);
}
},
};

View File

@@ -2,16 +2,34 @@ import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue'; import App from './App.vue';
import HomePage from './views/HomePage.vue'; import HomePage from './views/HomePage.vue';
import ShopPage from './views/ShopPage.vue'; import { useFavorites } from './composables/useFavorites';
import { vClickOutside } from './directives/clickOutside';
import './style.css'; import './style.css';
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [
// Chat : page d'accueil, chargée d'emblée (premier rendu rapide).
{ path: '/', component: HomePage }, { path: '/', component: HomePage },
{ path: '/shop', component: ShopPage }, // Vues secondaires : chargées à la demande (code-splitting) pour ne pas
{ path: '/shop/p/:id', component: ShopPage }, // pénaliser le premier rendu.
{ path: '/explorer', component: () => import('./views/ExplorerPage.vue') },
{ path: '/message/:id', component: () => import('./views/MessageDetailPage.vue') },
{ path: '/favoris', component: () => import('./views/FavorisPage.vue') },
{
path: '/mes-stats',
component: () => import('./views/MesStatsPage.vue'),
// Garde : pas de stats tant que la liste perso est vide.
beforeEnter: () => (useFavorites().all.value.length > 0 ? true : '/favoris'),
},
{ path: '/shop', component: () => import('./views/ShopPage.vue') },
{ path: '/shop/p/:id', component: () => import('./views/ProductDetailPage.vue') },
// Repli : toute URL inconnue renvoie au chat.
{ path: '/:pathMatch(.*)*', redirect: '/' },
], ],
}); });
createApp(App).use(router).mount('#app'); createApp(App)
.use(router)
.directive('click-outside', vClickOutside)
.mount('#app');

View File

@@ -1,116 +1,116 @@
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Lato'; font-family: 'Lato';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2'); src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Lato'; font-family: 'Lato';
font-style: normal; font-style: normal;
font-weight: 400; font-weight: 400;
src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjx4wXg.woff2) format('woff2'); src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjx4wXg.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
/* latin-ext */ /* latin-ext */
@font-face { @font-face {
font-family: 'Lato'; font-family: 'Lato';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwaPGR_p.woff2) format('woff2'); src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwaPGR_p.woff2) format('woff2');
unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF; unicode-range: U+0100-02BA, U+02BD-02C5, U+02C7-02CC, U+02CE-02D7, U+02DD-02FF, U+0304, U+0308, U+0329, U+1D00-1DBF, U+1E00-1E9F, U+1EF2-1EFF, U+2020, U+20A0-20AB, U+20AD-20C0, U+2113, U+2C60-2C7F, U+A720-A7FF;
} }
/* latin */ /* latin */
@font-face { @font-face {
font-family: 'Lato'; font-family: 'Lato';
font-style: normal; font-style: normal;
font-weight: 700; font-weight: 700;
src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwiPGQ.woff2) format('woff2'); src: url(https://fonts.gstatic.com/s/lato/v25/S6u9w4BMUTPHh6UVSwiPGQ.woff2) format('woff2');
unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD; unicode-range: U+0000-00FF, U+0131, U+0152-0153, U+02BB-02BC, U+02C6, U+02DA, U+02DC, U+0304, U+0308, U+0329, U+2000-206F, U+20AC, U+2122, U+2191, U+2193, U+2212, U+2215, U+FEFF, U+FFFD;
} }
*, *::before, *::after { *, *::before, *::after {
box-sizing: border-box; box-sizing: border-box;
margin: 0; margin: 0;
padding: 0; padding: 0;
} }
/* ── Thèmes : palette par variables CSS, basculée via [data-theme] sur la racine app ── /* ── Thèmes : palette par variables CSS, basculée via [data-theme] sur la racine app ──
Le défaut = palette XIP sombre/néon. Chaque thème ne redéfinit que les surfaces Le défaut = palette XIP sombre/néon. Chaque thème ne redéfinit que les surfaces
à fort impact (fond, header, bulles, bouton d'envoi). */ à fort impact (fond, header, bulles, bouton d'envoi). */
:root { :root {
--xip-app-bg: #080808; --xip-app-bg: #080808;
--xip-bg: #090910; --xip-bg: #090910;
--xip-header-bg: #0e0e16; --xip-header-bg: #0e0e16;
--xip-header-border: #1a1a2a; --xip-header-border: #1a1a2a;
--xip-bubble-other: #141422; --xip-bubble-other: #141422;
--xip-bubble-other-border: #222236; --xip-bubble-other-border: #222236;
--xip-bubble-sent: #0e1f30; --xip-bubble-sent: #0e1f30;
--xip-bubble-sent-border: #1a3a55; --xip-bubble-sent-border: #1a3a55;
--xip-accent: #00ddff; --xip-accent: #00ddff;
--xip-send-bg: #004488; --xip-send-bg: #004488;
--xip-send-fg: #00ddff; --xip-send-fg: #00ddff;
} }
[data-theme="whatsapp"] { [data-theme="whatsapp"] {
--xip-app-bg: #0b141a; --xip-app-bg: #0b141a;
--xip-bg: #0b141a; --xip-bg: #0b141a;
--xip-header-bg: #202c33; --xip-header-bg: #202c33;
--xip-header-border: #2a3942; --xip-header-border: #2a3942;
--xip-bubble-other: #202c33; --xip-bubble-other: #202c33;
--xip-bubble-other-border: #2a3942; --xip-bubble-other-border: #2a3942;
--xip-bubble-sent: #005c4b; /* vert sortant signature WhatsApp */ --xip-bubble-sent: #005c4b; /* vert sortant signature WhatsApp */
--xip-bubble-sent-border: #047857; --xip-bubble-sent-border: #047857;
--xip-accent: #00a884; --xip-accent: #00a884;
--xip-send-bg: #00a884; --xip-send-bg: #00a884;
--xip-send-fg: #ffffff; --xip-send-fg: #ffffff;
} }
html, html,
body, body,
#app { #app {
height: 100%; height: 100%;
overflow: hidden; overflow: hidden;
background: var(--xip-app-bg); background: var(--xip-app-bg);
font-family: 'Lato', sans-serif; font-family: 'Lato', sans-serif;
} }
/* ── Styles partagés des sections « Mes Persos » (shop/persos/*) ── /* ── Styles partagés des sections « Mes Persos » (shop/persos/*) ──
Globaux (non scopés) pour être réutilisés par chaque sous-section sans Globaux (non scopés) pour être réutilisés par chaque sous-section sans
dupliquer le CSS. Préfixe .pf- (persos-form) pour éviter les collisions. */ dupliquer le CSS. Préfixe .pf- (persos-form) pour éviter les collisions. */
.pf-section { .pf-section {
background: #101018; background: #101018;
border: 1px solid #20203a; border: 1px solid #20203a;
border-radius: 10px; border-radius: 10px;
padding: 18px 20px; padding: 18px 20px;
} }
.pf-section.pf-locked { opacity: 0.6; } .pf-section.pf-locked { opacity: 0.6; }
.pf-title { .pf-title {
font-size: 14px; font-weight: bold; color: #ccccee; font-size: 14px; font-weight: bold; color: #ccccee;
margin: 0 0 6px; display: flex; align-items: center; gap: 10px; margin: 0 0 6px; display: flex; align-items: center; gap: 10px;
} }
.pf-sub { font-size: 11px; color: #5a5a80; margin: 0 0 12px; } .pf-sub { font-size: 11px; color: #5a5a80; margin: 0 0 12px; }
.pf-lock { .pf-lock {
font-size: 10px; font-weight: normal; color: #886644; font-size: 10px; font-weight: normal; color: #886644;
background: #1a1408; border: 1px solid #44330066; border-radius: 8px; padding: 2px 8px; background: #1a1408; border: 1px solid #44330066; border-radius: 8px; padding: 2px 8px;
} }
.pf-grid { display: flex; flex-wrap: wrap; gap: 8px; } .pf-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.pf-tile { .pf-tile {
display: flex; flex-direction: column; align-items: center; gap: 6px; display: flex; flex-direction: column; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 8px; background: #141420; border: 1px solid #222234; border-radius: 8px;
padding: 10px 14px; cursor: pointer; transition: border-color 0.1s, background 0.1s; padding: 10px 14px; cursor: pointer; transition: border-color 0.1s, background 0.1s;
} }
.pf-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; } .pf-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; }
.pf-tile--active { border-color: #00ddff; background: #0a1a20; } .pf-tile--active { border-color: #00ddff; background: #0a1a20; }
.pf-tile:disabled { cursor: not-allowed; opacity: 0.5; } .pf-tile:disabled { cursor: not-allowed; opacity: 0.5; }
.pf-swatch { .pf-swatch {
width: 34px; height: 34px; border-radius: inherit; width: 34px; height: 34px; border-radius: inherit;
display: flex; align-items: center; justify-content: center; display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: bold; border: 1px solid #ffffff10; font-size: 14px; font-weight: bold; border: 1px solid #ffffff10;
} }
.pf-label { font-size: 10px; color: #8888aa; white-space: nowrap; } .pf-label { font-size: 10px; color: #8888aa; white-space: nowrap; }
.pf-tile--active .pf-label { color: #00ddff; } .pf-tile--active .pf-label { color: #00ddff; }
.pf-dot { width: 20px; height: 20px; border-radius: 50%; border: 1px solid #ffffff22; } .pf-dot { width: 20px; height: 20px; border-radius: 50%; border: 1px solid #ffffff22; }
.pf-dot--auto { background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff); } .pf-dot--auto { background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff); }

View File

@@ -0,0 +1,192 @@
<!-- Explorateur du catalogue distant de messages : recherche debouncée +
annulable (AbortController), filtre, défilement infini par curseur.
Gardé en cache (keep-alive) pour conserver recherche + scroll au retour. -->
<template>
<div class="explorer">
<header class="exp-head">
<h1 class="exp-title">🔎 Explorer les messages</h1>
<div class="exp-controls">
<SearchBox v-model="query" placeholder="Rechercher dans les messages…" class="exp-search" />
<select v-model="filter" class="exp-filter" title="Filtrer">
<option value="all">Tous</option>
<option value="rich">Messages riches</option>
<option value="files">Avec pièce jointe</option>
<option value="geo">Géolocalisés</option>
</select>
</div>
</header>
<div class="exp-scroll">
<p v-if="error" class="exp-msg exp-msg--err">{{ error }}</p>
<ul class="exp-list">
<li v-for="m in visible" :key="m.id" class="exp-card">
<RouterLink :to="`/message/${m.id}`" class="exp-card-link">
<div class="exp-card-head">
<span class="exp-ip" :style="{ color: ipColor(m.authorIp) }">{{ m.authorIp }}</span>
<img
v-if="m.authorGeo?.countryCode"
:src="`https://flagcdn.com/16x12/${m.authorGeo.countryCode.toLowerCase()}.png`"
:alt="m.authorGeo.countryCode"
class="exp-flag"
/>
<span class="exp-ts">{{ fmtDate(m.createdAt) }}</span>
</div>
<p class="exp-content">{{ preview(m) }}</p>
<div class="exp-tags">
<span v-if="m.richMode && m.richMode !== 'none'" class="exp-tag">riche</span>
<span v-if="m.attachments?.length" class="exp-tag">📎 {{ m.attachments.length }}</span>
<span v-if="m.replies?.length" class="exp-tag"> {{ m.replies.length }}</span>
</div>
</RouterLink>
<FavButton :message="m" class="exp-fav" />
</li>
</ul>
<p v-if="loading" class="exp-msg">Chargement</p>
<p v-if="!loading && visible.length === 0" class="exp-msg">Aucun message trouvé.</p>
<!-- Sentinelle de défilement infini -->
<div ref="sentinel" class="exp-sentinel" />
<p v-if="!hasMore && visible.length > 0" class="exp-msg exp-end"> fin du catalogue </p>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch, onMounted, onActivated, onDeactivated, nextTick } from 'vue';
import type { Message } from '@/composables/useMessages';
import { getIpColor } from '@/composables/ipColor';
import SearchBox from '@/components/SearchBox.vue';
import FavButton from '@/components/FavButton.vue';
// Nom requis pour le keep-alive (App.vue `include="ExplorerPage"`).
defineOptions({ name: 'ExplorerPage' });
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const PAGE = 20;
const query = ref('');
const filter = ref<'all' | 'rich' | 'files' | 'geo'>('all');
const items = ref<Message[]>([]);
const cursor = ref<string | null>(null);
const hasMore = ref(true);
const loading = ref(false);
const error = ref<string | null>(null);
let controller: AbortController | null = null;
/** Filtre client appliqué par-dessus la recherche serveur. */
const visible = computed(() => {
switch (filter.value) {
case 'rich': return items.value.filter((m) => m.richMode && m.richMode !== 'none');
case 'files': return items.value.filter((m) => (m.attachments?.length ?? 0) > 0);
case 'geo': return items.value.filter((m) => !!m.authorGeo?.countryCode);
default: return items.value;
}
});
async function load(reset: boolean): Promise<void> {
// Annule toute requête en vol (recherche/page précédente).
controller?.abort();
controller = new AbortController();
const mine = controller;
if (reset) { items.value = []; cursor.value = null; hasMore.value = true; }
if (!hasMore.value && !reset) return;
loading.value = true;
error.value = null;
try {
const params = new URLSearchParams({ limit: String(PAGE) });
if (query.value.trim()) params.set('q', query.value.trim());
if (cursor.value && !reset) params.set('before', cursor.value);
const res = await fetch(`${API_URL}/api/messages?${params}`, { signal: mine.signal });
if (!res.ok) throw new Error('Erreur réseau');
const data = (await res.json()) as { items: Message[]; nextCursor: string | null; hasMore: boolean };
// Si une requête plus récente a démarré entre-temps, on ignore ce résultat.
if (mine.signal.aborted) return;
items.value = reset ? data.items : [...items.value, ...data.items];
cursor.value = data.nextCursor;
hasMore.value = data.hasMore;
} catch (e) {
if ((e as Error).name !== 'AbortError') error.value = 'Impossible de charger les messages.';
} finally {
if (mine === controller) loading.value = false;
}
}
// Nouvelle recherche → on repart de zéro (la valeur arrive déjà debouncée du SearchBox).
watch(query, () => { void load(true); });
// ── Défilement infini ──
const sentinel = ref<HTMLElement | null>(null);
let observer: IntersectionObserver | null = null;
function setupObserver(): void {
if (observer || !sentinel.value) return;
observer = new IntersectionObserver((entries) => {
if (entries[0]?.isIntersecting && !loading.value && hasMore.value) void load(false);
}, { rootMargin: '200px' });
observer.observe(sentinel.value);
}
onMounted(async () => {
await load(true);
await nextTick();
setupObserver();
});
onActivated(() => setupObserver());
onDeactivated(() => { observer?.disconnect(); observer = null; });
function ipColor(ip: string): string { return getIpColor(ip); }
function fmtDate(d: string): string {
return new Date(d).toLocaleString('fr-FR', { day: '2-digit', month: '2-digit', hour: '2-digit', minute: '2-digit' });
}
function preview(m: Message): string {
if (m.richMode && m.richMode !== 'none') return m.content?.trim() || '[message riche]';
return m.content || '[vide]';
}
</script>
<style scoped>
.explorer { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
.exp-head { flex-shrink: 0; padding: 16px 20px 12px; border-bottom: 1px solid #1a1a2a; }
.exp-title { font-family: Arial, sans-serif; font-size: 17px; color: #ccccee; margin: 0 0 12px; }
.exp-controls { display: flex; gap: 10px; }
.exp-search { flex: 1; }
.exp-filter {
background: #141420; border: 1px solid #222234; border-radius: 23px;
color: #aaaacc; font-size: 12px; padding: 0 14px; outline: none; cursor: pointer;
}
.exp-scroll { flex: 1; overflow-y: auto; padding: 14px 20px; }
.exp-list { list-style: none; display: grid; grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); gap: 12px; }
.exp-card {
position: relative;
background: #101018; border: 1px solid #20203a; border-radius: 10px; padding: 12px 14px;
}
.exp-card-link { text-decoration: none; display: block; }
.exp-card-head { display: flex; align-items: center; gap: 8px; margin-bottom: 6px; }
.exp-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.exp-flag { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; }
.exp-ts { margin-left: auto; font-size: 10px; color: #44445a; font-family: 'Courier New', monospace; }
.exp-content {
font-family: Arial, sans-serif; font-size: 13px; color: #c0c0c0; margin: 0;
display: -webkit-box; -webkit-line-clamp: 3; -webkit-box-orient: vertical; overflow: hidden;
}
.exp-tags { display: flex; gap: 6px; margin-top: 8px; }
.exp-tag {
font-family: Arial, sans-serif; font-size: 9px; color: #6688aa;
background: #0c1622; border: 1px solid #16324a; border-radius: 6px; padding: 1px 6px;
}
.exp-fav { position: absolute; top: 10px; right: 10px; font-size: 15px; }
.exp-msg { text-align: center; color: #55557a; font-family: Arial, sans-serif; font-size: 12px; padding: 16px; }
.exp-msg--err { color: #ff7788; }
.exp-end { color: #33334d; }
.exp-sentinel { height: 1px; }
</style>

View File

@@ -0,0 +1,162 @@
<!-- Liste personnelle « Favoris » : éléments enregistrés (localStorage),
éditables (note, commentaire, statut) via une modale, retirables. -->
<template>
<div class="favs">
<header class="favs-head">
<h1 class="favs-title"> Mes favoris <span class="favs-count">{{ all.length }}</span></h1>
<div class="favs-actions">
<RouterLink v-if="all.length" to="/mes-stats" class="btn-stats">📊 Voir mes stats</RouterLink>
<button v-if="all.length" class="btn-clear" type="button" @click="clear">Tout vider</button>
</div>
</header>
<div class="favs-scroll">
<div v-if="all.length === 0" class="favs-empty">
<p>Aucun favori pour l'instant.</p>
<RouterLink to="/explorer" class="btn-explore">🔎 Explorer des messages</RouterLink>
</div>
<ul v-else class="favs-list">
<li v-for="f in all" :key="f.id" class="fav-card">
<div class="fav-main">
<div class="fav-meta">
<RouterLink :to="`/message/${f.id}`" class="fav-ip" :style="{ color: ipColor(f.authorIp) }">{{ f.authorIp }}</RouterLink>
<span class="fav-status" :class="`fav-status--${f.status}`">{{ statusLabel(f.status) }}</span>
<span v-if="f.rating" class="fav-rating">{{ ''.repeat(f.rating) }}<span class="dim">{{ ''.repeat(5 - f.rating) }}</span></span>
</div>
<p class="fav-content">{{ f.content }}</p>
<p v-if="f.note" class="fav-note">📝 {{ f.note }}</p>
</div>
<div class="fav-buttons">
<button class="fav-edit" type="button" @click="openEdit(f.id)">✏️</button>
<button class="fav-del" type="button" @click="remove(f.id)">🗑️</button>
</div>
</li>
</ul>
</div>
<!-- Modale d'édition (Teleport + slots) -->
<Modal v-model:open="editOpen" :title="`Annoter ${editing?.authorIp ?? ''}`">
<div v-if="editing" class="edit">
<p class="edit-content">« {{ editing.content }} »</p>
<label class="edit-label">Note</label>
<div class="stars">
<button
v-for="n in 5"
:key="n"
class="star"
:class="{ on: n <= draftRating }"
type="button"
@click="draftRating = n === draftRating ? 0 : n"
></button>
</div>
<label class="edit-label">Statut</label>
<select v-model="draftStatus" class="edit-select">
<option value="a-lire">À lire</option>
<option value="lu">Lu</option>
<option value="top">Coup de cœur</option>
</select>
<label class="edit-label">Commentaire</label>
<textarea v-model="draftNote" class="edit-note" rows="3" placeholder="Ton annotation…" />
<div class="edit-foot">
<button class="btn-save" type="button" @click="save">Enregistrer</button>
</div>
</div>
</Modal>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import { useFavorites, type FavStatus } from '@/composables/useFavorites';
import { getIpColor } from '@/composables/ipColor';
import Modal from '@/components/Modal.vue';
const { all, remove, clear, setNote, setRating, setStatus } = useFavorites();
const editOpen = ref(false);
const editingId = ref<string | null>(null);
const editing = computed(() => all.value.find((f) => f.id === editingId.value) ?? null);
const draftNote = ref('');
const draftRating = ref(0);
const draftStatus = ref<FavStatus>('a-lire');
function openEdit(id: string): void {
const f = all.value.find((x) => x.id === id);
if (!f) return;
editingId.value = id;
draftNote.value = f.note;
draftRating.value = f.rating;
draftStatus.value = f.status;
editOpen.value = true;
}
function save(): void {
if (!editingId.value) return;
setNote(editingId.value, draftNote.value);
setRating(editingId.value, draftRating.value);
setStatus(editingId.value, draftStatus.value);
editOpen.value = false;
}
function ipColor(ip: string): string { return getIpColor(ip); }
function statusLabel(s: FavStatus): string {
return s === 'lu' ? 'Lu' : s === 'top' ? 'Coup de cœur' : 'À lire';
}
</script>
<style scoped>
.favs { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
.favs-head {
flex-shrink: 0; display: flex; align-items: center; justify-content: space-between;
padding: 16px 20px; border-bottom: 1px solid #1a1a2a;
}
.favs-title { font-family: Arial, sans-serif; font-size: 17px; color: #ccccee; margin: 0; }
.favs-count { font-size: 13px; color: #ffcc44; margin-left: 6px; }
.favs-actions { display: flex; gap: 10px; }
.btn-stats { font-size: 12px; color: #00ddff; text-decoration: none; border: 1px solid #00aaff44; border-radius: 14px; padding: 6px 12px; }
.btn-stats:hover { background: #00aaff14; }
.btn-clear { font-size: 12px; color: #ff6655; background: #2a1010; border: 1px solid #882222; border-radius: 14px; padding: 6px 12px; cursor: pointer; }
.favs-scroll { flex: 1; overflow-y: auto; padding: 16px 20px; }
.favs-empty { text-align: center; color: #55557a; font-family: Arial, sans-serif; padding: 50px 0; }
.btn-explore { display: inline-block; margin-top: 14px; color: #00ddff; text-decoration: none; border: 1px solid #00aaff44; border-radius: 16px; padding: 8px 18px; }
.btn-explore:hover { background: #00aaff14; }
.favs-list { list-style: none; display: flex; flex-direction: column; gap: 10px; max-width: 720px; margin: 0 auto; }
.fav-card {
display: flex; gap: 12px; align-items: flex-start;
background: #101018; border: 1px solid #20203a; border-radius: 10px; padding: 12px 14px;
}
.fav-main { flex: 1; min-width: 0; }
.fav-meta { display: flex; align-items: center; gap: 10px; margin-bottom: 5px; flex-wrap: wrap; }
.fav-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; text-decoration: none; }
.fav-status { font-size: 9px; padding: 1px 7px; border-radius: 6px; font-family: Arial, sans-serif; }
.fav-status--a-lire { color: #8888aa; background: #16162a; }
.fav-status--lu { color: #33aa77; background: #0e2018; }
.fav-status--top { color: #ffcc44; background: #2a2206; }
.fav-rating { font-size: 11px; color: #ffcc44; }
.fav-rating .dim { color: #333; }
.fav-content { font-family: Arial, sans-serif; font-size: 13px; color: #c0c0c0; margin: 0; word-break: break-word; }
.fav-note { font-family: Arial, sans-serif; font-size: 11px; color: #6688aa; margin: 6px 0 0; font-style: italic; }
.fav-buttons { display: flex; flex-direction: column; gap: 6px; }
.fav-edit, .fav-del { background: #141420; border: 1px solid #222234; border-radius: 8px; cursor: pointer; padding: 4px 8px; font-size: 13px; }
.fav-edit:hover, .fav-del:hover { background: #1c1c2e; }
/* Modale d'édition */
.edit-content { font-family: Arial, sans-serif; font-size: 12px; color: #8899aa; font-style: italic; margin: 0 0 16px; }
.edit-label { display: block; font-family: Arial, sans-serif; font-size: 11px; color: #6a6a90; margin: 12px 0 5px; text-transform: uppercase; letter-spacing: 0.5px; }
.stars { display: flex; gap: 4px; }
.star { background: none; border: none; cursor: pointer; font-size: 22px; color: #333; padding: 0; }
.star.on { color: #ffcc44; }
.edit-select { width: 100%; background: #141420; border: 1px solid #222234; border-radius: 6px; color: #ccccdd; font-size: 13px; padding: 8px 10px; outline: none; }
.edit-note { width: 100%; box-sizing: border-box; background: #141420; border: 1px solid #222234; border-radius: 6px; color: #ccccdd; font-family: Arial, sans-serif; font-size: 13px; padding: 8px 10px; outline: none; resize: vertical; }
.edit-foot { margin-top: 18px; text-align: right; }
.btn-save { background: #004488; border: 1px solid #0066aa; color: #00ddff; font-size: 13px; font-weight: bold; padding: 8px 18px; border-radius: 18px; cursor: pointer; }
.btn-save:hover { background: #005599; }
</style>

View File

@@ -64,8 +64,8 @@ function cancelReply(): void {
.xip-app { .xip-app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100vw; width: 100%;
height: 100dvh; height: 100%;
background: var(--xip-app-bg); background: var(--xip-app-bg);
overflow: hidden; overflow: hidden;
} }

View File

@@ -0,0 +1,169 @@
<!-- Synthèse dérivée de la liste personnelle (favoris). Tous les agrégats sont
des `computed` sur useFavorites().all mise à jour automatique à chaque
ajout / retrait / modification. Accès gardé : redirige si aucun favori. -->
<template>
<div class="stats">
<header class="stats-head">
<h1 class="stats-title">📊 Mes statistiques</h1>
<RouterLink to="/favoris" class="btn-back"> Mes favoris</RouterLink>
</header>
<div class="stats-scroll">
<!-- Cartes chiffres -->
<div class="cards">
<div class="card">
<div class="card-label">Favoris</div>
<div class="card-val"><AnimatedNumber :value="total" /></div>
</div>
<div class="card">
<div class="card-label">Note moyenne</div>
<div class="card-val card-val--gold"><AnimatedNumber :value="avgRating" :decimals="1" /><span class="unit">/5</span></div>
</div>
<div class="card">
<div class="card-label">Longueur moyenne</div>
<div class="card-val"><AnimatedNumber :value="avgLength" :decimals="0" /><span class="unit">car.</span></div>
</div>
<div class="card">
<div class="card-label">Pays distincts</div>
<div class="card-val card-val--cyan"><AnimatedNumber :value="countryCount" /></div>
</div>
</div>
<!-- Répartition par statut -->
<section class="block">
<h2 class="block-title">Par statut</h2>
<div class="bars">
<div v-for="s in statusBreakdown" :key="s.key" class="bar-row">
<span class="bar-label">{{ s.label }}</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: pct(s.count) + '%', background: s.color }" /></div>
<span class="bar-count">{{ s.count }}</span>
</div>
</div>
</section>
<!-- Top pays -->
<section v-if="topCountries.length" class="block">
<h2 class="block-title">Top pays</h2>
<div class="bars">
<div v-for="c in topCountries" :key="c.key" class="bar-row">
<span class="bar-label">
<img v-if="c.code" :src="`https://flagcdn.com/16x12/${c.code.toLowerCase()}.png`" :alt="c.code" class="flag" />
{{ c.label }}
</span>
<div class="bar-track"><div class="bar-fill" :style="{ width: pct(c.count) + '%' }" /></div>
<span class="bar-count">{{ c.count }}</span>
</div>
</div>
</section>
<!-- Top auteurs -->
<section v-if="topAuthors.length" class="block">
<h2 class="block-title">Top auteurs (IP)</h2>
<ol class="authors">
<li v-for="a in topAuthors" :key="a.key" class="author-row">
<span class="author-ip" :style="{ color: ipColor(a.key) }">{{ a.key }}</span>
<span class="author-count">{{ a.count }} favori(s)</span>
</li>
</ol>
</section>
</div>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useFavorites } from '@/composables/useFavorites';
import { getIpColor } from '@/composables/ipColor';
import AnimatedNumber from '@/components/AnimatedNumber.vue';
const { all } = useFavorites();
const total = computed(() => all.value.length);
const avgRating = computed(() => {
const rated = all.value.filter((f) => f.rating > 0);
if (!rated.length) return 0;
return rated.reduce((s, f) => s + f.rating, 0) / rated.length;
});
const avgLength = computed(() => {
if (!all.value.length) return 0;
return all.value.reduce((s, f) => s + (f.content?.length ?? 0), 0) / all.value.length;
});
function tally<T extends string>(keyOf: (f: (typeof all.value)[number]) => T | null) {
const map = new Map<T, number>();
for (const f of all.value) {
const k = keyOf(f);
if (k == null) continue;
map.set(k, (map.get(k) ?? 0) + 1);
}
return map;
}
const statusBreakdown = computed(() => {
const m = tally((f) => f.status);
return [
{ key: 'a-lire', label: 'À lire', color: '#5566aa', count: m.get('a-lire') ?? 0 },
{ key: 'lu', label: 'Lu', color: '#33aa77', count: m.get('lu') ?? 0 },
{ key: 'top', label: 'Coup de cœur', color: '#ffcc44', count: m.get('top') ?? 0 },
];
});
const countryAgg = computed(() => {
const counts = new Map<string, { code: string; count: number }>();
for (const f of all.value) {
const g = f.authorGeo;
const label = g?.country || (g && !g.countryCode ? 'Local' : 'Inconnu');
const code = g?.countryCode ?? '';
const cur = counts.get(label) ?? { code, count: 0 };
cur.count++;
counts.set(label, cur);
}
return [...counts.entries()].map(([label, v]) => ({ key: label, label, code: v.code, count: v.count }));
});
const countryCount = computed(() => countryAgg.value.length);
const topCountries = computed(() => [...countryAgg.value].sort((a, b) => b.count - a.count).slice(0, 5));
const topAuthors = computed(() => {
const m = tally((f) => f.authorIp);
return [...m.entries()].map(([key, count]) => ({ key, count })).sort((a, b) => b.count - a.count).slice(0, 5);
});
const maxCount = computed(() => Math.max(1, ...all.value.length ? [total.value] : [1]));
function pct(n: number): number { return Math.round((n / maxCount.value) * 100); }
function ipColor(ip: string): string { return getIpColor(ip); }
</script>
<style scoped>
.stats { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
.stats-head { flex-shrink: 0; display: flex; align-items: center; justify-content: space-between; padding: 16px 20px; border-bottom: 1px solid #1a1a2a; }
.stats-title { font-family: Arial, sans-serif; font-size: 17px; color: #ccccee; margin: 0; }
.btn-back { font-size: 12px; color: #00ddff; text-decoration: none; border: 1px solid #00aaff44; border-radius: 14px; padding: 6px 12px; }
.btn-back:hover { background: #00aaff14; }
.stats-scroll { flex: 1; overflow-y: auto; padding: 20px; max-width: 760px; margin: 0 auto; width: 100%; }
.cards { display: grid; grid-template-columns: repeat(auto-fit, minmax(150px, 1fr)); gap: 14px; margin-bottom: 24px; }
.card { background: #101018; border: 1px solid #20203a; border-radius: 10px; padding: 16px; text-align: center; }
.card-label { font-family: Arial, sans-serif; font-size: 10px; color: #6a6a90; text-transform: uppercase; letter-spacing: 1px; margin-bottom: 6px; }
.card-val { font-family: 'Courier New', monospace; font-size: 26px; font-weight: bold; color: #d8d8e8; }
.card-val--gold { color: #ffcc44; }
.card-val--cyan { color: #00ddff; }
.unit { font-size: 12px; color: #55557a; margin-left: 3px; }
.block { background: #101018; border: 1px solid #20203a; border-radius: 10px; padding: 16px 18px; margin-bottom: 16px; }
.block-title { font-family: Arial, sans-serif; font-size: 13px; color: #aaaacc; margin: 0 0 12px; }
.bars { display: flex; flex-direction: column; gap: 8px; }
.bar-row { display: flex; align-items: center; gap: 10px; }
.bar-label { font-family: Arial, sans-serif; font-size: 12px; color: #9999bb; width: 130px; display: flex; align-items: center; gap: 6px; }
.flag { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; }
.bar-track { flex: 1; height: 8px; background: #16162a; border-radius: 4px; overflow: hidden; }
.bar-fill { height: 100%; background: #00aaff; border-radius: 4px; transition: width 0.3s; }
.bar-count { font-family: 'Courier New', monospace; font-size: 12px; color: #ccccdd; width: 28px; text-align: right; }
.authors { list-style: none; counter-reset: rank; display: flex; flex-direction: column; gap: 8px; }
.author-row { display: flex; align-items: center; justify-content: space-between; counter-increment: rank; }
.author-row::before { content: counter(rank); color: #44445a; font-family: 'Courier New', monospace; font-size: 11px; margin-right: 10px; }
.author-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; flex: 1; }
.author-count { font-family: Arial, sans-serif; font-size: 11px; color: #6a6a90; }
</style>

View File

@@ -0,0 +1,125 @@
<!-- Détail d'un message à partir de l'identifiant présent dans l'URL (/message/:id). -->
<template>
<div class="detail">
<div class="detail-bar">
<button class="back" type="button" @click="goBack">← Retour</button>
</div>
<div class="detail-body">
<p v-if="loading" class="state">Chargement…</p>
<p v-else-if="error" class="state state--err">{{ error }}</p>
<article v-else-if="message" class="thread">
<header class="thread-head">
<span class="thread-ip" :style="{ color: ipColor(message.authorIp) }">{{ message.authorIp }}</span>
<span v-if="message.authorGeo" class="thread-geo">
<img
v-if="message.authorGeo.countryCode"
:src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`"
:alt="message.authorGeo.countryCode"
class="flag"
/>
{{ geoText(message.authorGeo) }}
</span>
<span class="thread-ts">{{ fmtDate(message.createdAt) }}</span>
<FavButton :message="message" class="thread-fav" />
</header>
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<p v-else class="thread-content">{{ message.content }}</p>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<section v-if="message.replies?.length" class="replies">
<h2 class="replies-title">{{ message.replies.length }} réponse(s)</h2>
<div v-for="r in message.replies" :key="r.id" class="reply">
<span class="reply-ip" :style="{ color: ipColor(r.authorIp) }">{{ r.authorIp }}</span>
<span class="reply-ts">{{ fmtDate(r.createdAt) }}</span>
<p class="reply-content">{{ r.content }}</p>
</div>
</section>
</article>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute, useRouter } from 'vue-router';
import type { Message, GeoInfo } from '@/composables/useMessages';
import { getIpColor } from '@/composables/ipColor';
import RichContent from '@/components/RichContent.vue';
import MessageAttachments from '@/components/MessageAttachments.vue';
import FavButton from '@/components/FavButton.vue';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const route = useRoute();
const router = useRouter();
const message = ref<Message | null>(null);
const loading = ref(true);
const error = ref<string | null>(null);
async function fetchMessage(id: string): Promise<void> {
loading.value = true;
error.value = null;
message.value = null;
try {
const res = await fetch(`${API_URL}/api/messages/${encodeURIComponent(id)}`);
if (res.status === 404) { error.value = 'Ce message nexiste pas (ou plus).'; return; }
if (!res.ok) throw new Error();
message.value = (await res.json()) as Message;
} catch {
error.value = 'Impossible de charger ce message.';
} finally {
loading.value = false;
}
}
// Recharge quand l'id de l'URL change (navigation entre détails).
watch(() => route.params.id, (id) => { if (typeof id === 'string') void fetchMessage(id); }, { immediate: true });
function goBack(): void {
if (window.history.length > 1) router.back();
else router.push('/explorer');
}
function ipColor(ip: string): string { return getIpColor(ip); }
function fmtDate(d: string): string { return new Date(d).toLocaleString('fr-FR'); }
function geoText(g: GeoInfo): string {
if (!g.countryCode) return 'Local';
return [g.city, g.country].filter(Boolean).join(', ');
}
</script>
<style scoped>
.detail { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
.detail-bar { flex-shrink: 0; padding: 12px 20px; border-bottom: 1px solid #1a1a2a; }
.back {
background: #141420; border: 1px solid #222234; border-radius: 16px;
color: #00ddff; font-size: 12px; padding: 6px 14px; cursor: pointer;
}
.back:hover { background: #1c1c2e; }
.detail-body { flex: 1; overflow-y: auto; padding: 24px 20px; }
.state { text-align: center; color: #55557a; font-family: Arial, sans-serif; padding: 40px; }
.state--err { color: #ff7788; }
.thread { max-width: 640px; margin: 0 auto; background: #101018; border: 1px solid #20203a; border-radius: 12px; padding: 20px; }
.thread-head { display: flex; align-items: center; gap: 10px; margin-bottom: 14px; }
.thread-ip { font-family: 'Courier New', monospace; font-size: 14px; font-weight: bold; }
.thread-geo { font-family: Arial, sans-serif; font-size: 11px; color: #55557a; display: inline-flex; align-items: center; gap: 4px; }
.flag { width: 16px; height: 12px; object-fit: cover; border-radius: 2px; }
.thread-ts { margin-left: auto; font-size: 11px; color: #44445a; font-family: 'Courier New', monospace; }
.thread-fav { font-size: 17px; }
.thread-content { font-family: Arial, sans-serif; font-size: 15px; color: #d8d8e8; line-height: 1.5; margin: 0; word-break: break-word; }
.replies { margin-top: 20px; border-top: 1px solid #20203a; padding-top: 14px; }
.replies-title { font-family: Arial, sans-serif; font-size: 12px; color: #6688aa; margin: 0 0 12px; }
.reply { border-left: 2px solid #1a1a2a; padding-left: 12px; margin-bottom: 12px; }
.reply-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.reply-ts { font-size: 10px; color: #44445a; margin-left: 8px; font-family: 'Courier New', monospace; }
.reply-content { font-family: Arial, sans-serif; font-size: 13px; color: #c0c0c0; margin: 4px 0 0; }
</style>

View File

@@ -0,0 +1,78 @@
<!-- Détail d'un produit du shop à partir de l'identifiant de l'URL (/shop/p/:id). -->
<template>
<div class="pdetail">
<div class="pdetail-bar">
<RouterLink to="/shop" class="back">← Boutique</RouterLink>
</div>
<div class="pdetail-body">
<p v-if="loading" class="state">Chargement…</p>
<p v-else-if="!product" class="state state--err">Produit introuvable.</p>
<div v-else class="pdetail-card">
<ProductCard
:product="product"
:buying="buying === product.id"
:owns="owns"
:owned-pet-chars="ownedPetChars()"
:pet-count="petCount()"
:free-mode="freeMode"
@buy="onBuy"
@go-perso="$router.push('/shop')"
/>
<p v-if="lastError" class="toast toast--err">{{ lastError }}</p>
<p v-else-if="lastSuccess" class="toast toast--ok">✓ Acheté</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useRoute } from 'vue-router';
import { useShop, type Product, type PurchaseOptions } from '@/composables/useShop';
import { useWallet } from '@/composables/useWallet';
import ProductCard from '@/components/shop/ProductCard.vue';
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
const route = useRoute();
const { buying, lastError, lastSuccess, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
const { freeMode, fetchWallet } = useWallet();
const product = ref<Product | null>(null);
const loading = ref(true);
async function load(id: string): Promise<void> {
loading.value = true;
product.value = null;
try {
const [res] = await Promise.all([
fetch(`${API_URL}/api/shop/products/${encodeURIComponent(id)}`),
fetchMe(),
fetchWallet(),
]);
if (res.ok) product.value = (await res.json()) as Product;
} finally {
loading.value = false;
}
}
watch(() => route.params.id, (id) => { if (typeof id === 'string') void load(id); }, { immediate: true });
async function onBuy(productId: string, options: PurchaseOptions): Promise<void> {
await purchase(productId, options);
}
</script>
<style scoped>
.pdetail { height: 100%; display: flex; flex-direction: column; background: var(--xip-app-bg, #08080e); }
.pdetail-bar { flex-shrink: 0; padding: 12px 20px; border-bottom: 1px solid #1a1a2a; }
.back { background: #141420; border: 1px solid #222234; border-radius: 16px; color: #00ddff; font-size: 12px; padding: 6px 14px; text-decoration: none; }
.back:hover { background: #1c1c2e; }
.pdetail-body { flex: 1; overflow-y: auto; padding: 24px 20px; display: flex; justify-content: center; }
.pdetail-card { width: 100%; max-width: 340px; }
.state { color: #55557a; font-family: Arial, sans-serif; padding: 40px; text-align: center; }
.state--err { color: #ff7788; }
.toast { margin-top: 12px; padding: 8px 12px; border-radius: 8px; font-size: 13px; text-align: center; }
.toast--err { background: #2a0e12; border: 1px solid #aa3344; color: #ff8899; }
.toast--ok { background: #0e2a16; border: 1px solid #33aa55; color: #66ffaa; }
</style>

View File

@@ -1,228 +1,228 @@
<template> <template>
<div class="shop"> <div class="shop">
<!-- Header --> <!-- Header -->
<header class="shop-header"> <header class="shop-header">
<div class="sh-left"> <div class="sh-left">
<router-link to="/" class="sh-back"> Chat</router-link> <router-link to="/" class="sh-back"> Chat</router-link>
<span class="sh-title">XIP</span> <span class="sh-title">XIP</span>
<span class="sh-sub">Marketplace</span> <span class="sh-sub">Marketplace</span>
</div> </div>
<div class="sh-right"> <div class="sh-right">
<span v-if="ip" class="sh-ip">Connecté&nbsp;: {{ ip }}</span> <span v-if="ip" class="sh-ip">Connecté&nbsp;: {{ ip }}</span>
<span class="sh-balance" :class="{ free: freeMode }"> <span class="sh-balance" :class="{ free: freeMode }">
{{ displayBalance() }} <span class="sh-cr">cr</span> {{ displayBalance() }} <span class="sh-cr">cr</span>
</span> </span>
<button class="sh-topup" @click="topUp" type="button">💸 Recharger</button> <button class="sh-topup" @click="topUp" type="button">💸 Recharger</button>
</div> </div>
</header> </header>
<!-- Flash promo banner --> <!-- Flash promo banner -->
<div class="flash"> <div class="flash">
OFFRES FLASH Cadre de Pub -33%, Pack Cosmétique -3 cr expire dans OFFRES FLASH Cadre de Pub -33%, Pack Cosmétique -3 cr expire dans
<span class="flash-timer">{{ countdown }}</span> <span class="flash-timer">{{ countdown }}</span>
</div> </div>
<div class="shop-body"> <div class="shop-body">
<!-- Category nav --> <!-- Category nav -->
<nav class="shop-nav"> <nav class="shop-nav">
<button <button
v-for="cat in categories" v-for="cat in categories"
:key="cat.id" :key="cat.id"
class="nav-item" class="nav-item"
:class="{ active: activeCat === cat.id }" :class="{ active: activeCat === cat.id }"
@click="activeCat = cat.id" @click="activeCat = cat.id"
type="button" type="button"
>{{ cat.label }}</button> >{{ cat.label }}</button>
<div class="nav-wallet"> <div class="nav-wallet">
<p class="nav-wallet-label">Ton solde</p> <p class="nav-wallet-label">Ton solde</p>
<p class="nav-wallet-val" :class="{ free: freeMode }">{{ displayBalance() }} cr</p> <p class="nav-wallet-val" :class="{ free: freeMode }">{{ displayBalance() }} cr</p>
<button class="nav-topup" @click="topUp" type="button">+ Recharger gratuitement</button> <button class="nav-topup" @click="topUp" type="button">+ Recharger gratuitement</button>
<p v-if="freeMode" class="nav-free-note">Mode localhost : tout gratuit 🎉</p> <p v-if="freeMode" class="nav-free-note">Mode localhost : tout gratuit 🎉</p>
</div> </div>
</nav> </nav>
<!-- Product grid --> <!-- Product grid -->
<main class="shop-main"> <main class="shop-main">
<div v-if="lastError" class="toast toast--err">{{ lastError }}</div> <div v-if="lastError" class="toast toast--err">{{ lastError }}</div>
<div v-else-if="lastSuccess" class="toast toast--ok"> Achat effectué</div> <div v-else-if="lastSuccess" class="toast toast--ok"> Achat effectué</div>
<!-- Mes Persos panel --> <!-- Mes Persos panel -->
<MesPersos v-if="activeCat === 'perso'" /> <MesPersos v-if="activeCat === 'perso'" />
<template v-else> <template v-else>
<div class="grid"> <div class="grid">
<ProductCard <ProductCard
v-for="p in visibleProducts" v-for="p in visibleProducts"
:key="p.id" :key="p.id"
:product="p" :product="p"
:buying="buying === p.id" :buying="buying === p.id"
:owns="owns" :owns="owns"
:pet-count="petCount()" :pet-count="petCount()"
:owned-pet-chars="ownedPetChars()" :owned-pet-chars="ownedPetChars()"
:free-mode="freeMode" :free-mode="freeMode"
@buy="onBuy" @buy="onBuy"
@go-perso="activeCat = 'perso'" @go-perso="activeCat = 'perso'"
/> />
</div> </div>
<p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p> <p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p>
</template> </template>
</main> </main>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed, onMounted, onUnmounted } from 'vue'; import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useShop, type PurchaseOptions } from '@/composables/useShop'; import { useShop, type PurchaseOptions } from '@/composables/useShop';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import { parseMeta, type ProductMeta } from '@/composables/useMeta'; import { parseMeta, type ProductMeta } from '@/composables/useMeta';
import ProductCard from '@/components/shop/ProductCard.vue'; import ProductCard from '@/components/shop/ProductCard.vue';
import MesPersos from '@/components/shop/MesPersos.vue'; import MesPersos from '@/components/shop/MesPersos.vue';
const { products, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop(); const { products, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, ownedPetChars, purchase } = useShop();
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet(); const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
// Navigation forcée par catégorie : pas de « Tout voir », on entre directement // Navigation forcée par catégorie : pas de « Tout voir », on entre directement
// dans une rubrique organisée. // dans une rubrique organisée.
const categories = [ const categories = [
{ id: 'publicite', label: 'Publicité' }, { id: 'publicite', label: 'Publicité' },
{ id: 'abonnements', label: 'Abonnements' }, { id: 'abonnements', label: 'Abonnements' },
{ id: 'cosmetiques', label: 'Cosmétiques' }, { id: 'cosmetiques', label: 'Cosmétiques' },
{ id: 'premium', label: 'Premium' }, { id: 'premium', label: 'Premium' },
{ id: 'promotions', label: 'Promotions' }, { id: 'promotions', label: 'Promotions' },
{ id: 'perso', label: '✨ Mes Persos' }, { id: 'perso', label: '✨ Mes Persos' },
]; ];
const activeCat = ref('publicite'); const activeCat = ref('publicite');
const visibleProducts = computed(() => { const visibleProducts = computed(() => {
const chars = ownedPetChars(); const chars = ownedPetChars();
return products.value return products.value
.filter((p) => p.category === activeCat.value) .filter((p) => p.category === activeCat.value)
.filter((p) => { .filter((p) => {
if (p.kind !== 'pet') return true; if (p.kind !== 'pet') return true;
const designs = parseMeta<ProductMeta>(p.metaJson).designs ?? []; const designs = parseMeta<ProductMeta>(p.metaJson).designs ?? [];
return designs.some((d) => !chars.includes(d.char)); return designs.some((d) => !chars.includes(d.char));
}); });
}); });
async function onBuy(productId: string, options: PurchaseOptions): Promise<void> { async function onBuy(productId: string, options: PurchaseOptions): Promise<void> {
await purchase(productId, options); await purchase(productId, options);
} }
async function topUp(): Promise<void> { async function topUp(): Promise<void> {
await walletTopUp(); await walletTopUp();
} }
// Cosmetic countdown timer (purely decorative, like the mockups). // Cosmetic countdown timer (purely decorative, like the mockups).
const countdown = ref('02:47:33'); const countdown = ref('02:47:33');
let timer: ReturnType<typeof setInterval> | null = null; let timer: ReturnType<typeof setInterval> | null = null;
let remaining = 2 * 3600 + 47 * 60 + 33; let remaining = 2 * 3600 + 47 * 60 + 33;
function tick(): void { function tick(): void {
remaining = remaining > 0 ? remaining - 1 : 0; remaining = remaining > 0 ? remaining - 1 : 0;
const h = Math.floor(remaining / 3600); const h = Math.floor(remaining / 3600);
const m = Math.floor((remaining % 3600) / 60); const m = Math.floor((remaining % 3600) / 60);
const s = remaining % 60; const s = remaining % 60;
const pad = (n: number) => String(n).padStart(2, '0'); const pad = (n: number) => String(n).padStart(2, '0');
countdown.value = `${pad(h)}:${pad(m)}:${pad(s)}`; countdown.value = `${pad(h)}:${pad(m)}:${pad(s)}`;
} }
onMounted(() => { onMounted(() => {
fetchProducts(); fetchProducts();
fetchMe(); fetchMe();
fetchWallet(); fetchWallet();
timer = setInterval(tick, 1000); timer = setInterval(tick, 1000);
}); });
onUnmounted(() => { if (timer) clearInterval(timer); }); onUnmounted(() => { if (timer) clearInterval(timer); });
</script> </script>
<style scoped> <style scoped>
.shop { .shop {
width: 100vw; width: 100%;
height: 100dvh; height: 100%;
background: #08080e; background: #08080e;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
overflow: hidden; overflow: hidden;
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
} }
/* Header */ /* Header */
.shop-header { .shop-header {
flex-shrink: 0; flex-shrink: 0;
height: 56px; height: 56px;
background: #0e0e18; background: #0e0e18;
border-bottom: 1px solid #1a1a2e; border-bottom: 1px solid #1a1a2e;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: space-between; justify-content: space-between;
padding: 0 20px; padding: 0 20px;
} }
.sh-left { display: flex; align-items: center; gap: 12px; } .sh-left { display: flex; align-items: center; gap: 12px; }
.sh-back { .sh-back {
color: #00ddff; text-decoration: none; font-size: 12px; font-weight: bold; color: #00ddff; text-decoration: none; font-size: 12px; font-weight: bold;
border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px; border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px;
} }
.sh-back:hover { background: #00aaff14; } .sh-back:hover { background: #00aaff14; }
.sh-title { font-size: 18px; font-weight: bold; color: #6699aa; } .sh-title { font-size: 18px; font-weight: bold; color: #6699aa; }
.sh-sub { font-size: 13px; color: #8888aa; } .sh-sub { font-size: 13px; color: #8888aa; }
.sh-right { display: flex; align-items: center; gap: 12px; } .sh-right { display: flex; align-items: center; gap: 12px; }
.sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; } .sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; }
.sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ccaa44; } .sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ccaa44; }
.sh-balance.free { color: #44aa77; } .sh-balance.free { color: #44aa77; }
.sh-cr { font-size: 10px; color: #886633; } .sh-cr { font-size: 10px; color: #886633; }
.sh-topup { .sh-topup {
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77; background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer; font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer;
box-shadow: none; box-shadow: none;
} }
.sh-topup:hover { background: #234a23; } .sh-topup:hover { background: #234a23; }
/* Flash banner */ /* Flash banner */
.flash { .flash {
flex-shrink: 0; flex-shrink: 0;
background: linear-gradient(90deg, #2a0a0a, #1a0a1a); background: linear-gradient(90deg, #2a0a0a, #1a0a1a);
border-bottom: 1px solid #44113344; border-bottom: 1px solid #44113344;
color: #ff8866; font-size: 12px; text-align: center; padding: 7px; color: #ff8866; font-size: 12px; text-align: center; padding: 7px;
} }
.flash-timer { font-family: 'Courier New', monospace; color: #ffcc44; font-weight: bold; } .flash-timer { font-family: 'Courier New', monospace; color: #ffcc44; font-weight: bold; }
/* Body */ /* Body */
.shop-body { flex: 1; display: flex; min-height: 0; } .shop-body { flex: 1; display: flex; min-height: 0; }
.shop-nav { .shop-nav {
width: 200px; flex-shrink: 0; background: #0b0b14; border-right: 1px solid #1a1a2a; width: 200px; flex-shrink: 0; background: #0b0b14; border-right: 1px solid #1a1a2a;
padding: 14px 10px; display: flex; flex-direction: column; gap: 4px; overflow-y: auto; padding: 14px 10px; display: flex; flex-direction: column; gap: 4px; overflow-y: auto;
} }
.nav-item { .nav-item {
text-align: left; background: none; border: none; color: #8888aa; text-align: left; background: none; border: none; color: #8888aa;
font-size: 13px; padding: 9px 12px; border-radius: 7px; cursor: pointer; font-size: 13px; padding: 9px 12px; border-radius: 7px; cursor: pointer;
} }
.nav-item:hover { background: #14142080; color: #aaaacc; } .nav-item:hover { background: #14142080; color: #aaaacc; }
.nav-item.active { background: #00aaff18; color: #00ddff; font-weight: bold; } .nav-item.active { background: #00aaff18; color: #00ddff; font-weight: bold; }
.nav-wallet { .nav-wallet {
margin-top: auto; background: #0e0e1a; border: 1px solid #20203a; border-radius: 8px; padding: 12px; margin-top: auto; background: #0e0e1a; border: 1px solid #20203a; border-radius: 8px; padding: 12px;
} }
.nav-wallet-label { font-size: 10px; color: #6a6a90; margin: 0 0 4px; text-transform: uppercase; letter-spacing: 1px; } .nav-wallet-label { font-size: 10px; color: #6a6a90; margin: 0 0 4px; text-transform: uppercase; letter-spacing: 1px; }
.nav-wallet-val { font-family: 'Courier New', monospace; font-size: 20px; font-weight: bold; color: #ffdd66; margin: 0 0 10px; } .nav-wallet-val { font-family: 'Courier New', monospace; font-size: 20px; font-weight: bold; color: #ffdd66; margin: 0 0 10px; }
.nav-wallet-val.free { color: #33ff99; } .nav-wallet-val.free { color: #33ff99; }
.nav-topup { width: 100%; background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77; font-size: 11px; font-weight: bold; padding: 8px; border-radius: 14px; cursor: pointer; } .nav-topup { width: 100%; background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77; font-size: 11px; font-weight: bold; padding: 8px; border-radius: 14px; cursor: pointer; }
.nav-topup:hover { background: #234a23; } .nav-topup:hover { background: #234a23; }
.nav-free-note { font-size: 10px; color: #33aa66; margin: 8px 0 0; text-align: center; } .nav-free-note { font-size: 10px; color: #33aa66; margin: 8px 0 0; text-align: center; }
.shop-main { flex: 1; overflow-y: auto; padding: 20px; } .shop-main { flex: 1; overflow-y: auto; padding: 20px; }
.grid { .grid {
display: grid; display: grid;
grid-template-columns: repeat(auto-fill, minmax(280px, 1fr)); grid-template-columns: repeat(auto-fill, minmax(280px, 1fr));
gap: 16px; gap: 16px;
align-items: start; align-items: start;
} }
.empty { color: #44446a; text-align: center; padding: 40px; } .empty { color: #44446a; text-align: center; padding: 40px; }
.toast { .toast {
margin-bottom: 14px; padding: 10px 14px; border-radius: 8px; font-size: 13px; margin-bottom: 14px; padding: 10px 14px; border-radius: 8px; font-size: 13px;
} }
.toast--err { background: #2a0e12; border: 1px solid #aa3344; color: #ff8899; } .toast--err { background: #2a0e12; border: 1px solid #aa3344; color: #ff8899; }
.toast--ok { background: #0e2a16; border: 1px solid #33aa55; color: #66ffaa; } .toast--ok { background: #0e2a16; border: 1px solid #33aa55; color: #66ffaa; }
</style> </style>

View File

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

View File

@@ -1,24 +1,24 @@
{ {
"compilerOptions": { "compilerOptions": {
"target": "ES2020", "target": "ES2020",
"useDefineForClassFields": true, "useDefineForClassFields": true,
"module": "ESNext", "module": "ESNext",
"lib": ["ES2020", "DOM", "DOM.Iterable"], "lib": ["ES2020", "DOM", "DOM.Iterable"],
"skipLibCheck": true, "skipLibCheck": true,
"moduleResolution": "bundler", "moduleResolution": "bundler",
"allowImportingTsExtensions": true, "allowImportingTsExtensions": true,
"resolveJsonModule": true, "resolveJsonModule": true,
"isolatedModules": true, "isolatedModules": true,
"noEmit": true, "noEmit": true,
"jsx": "preserve", "jsx": "preserve",
"strict": true, "strict": true,
"noUnusedLocals": true, "noUnusedLocals": true,
"noUnusedParameters": true, "noUnusedParameters": true,
"noFallthroughCasesInSwitch": true, "noFallthroughCasesInSwitch": true,
"paths": { "paths": {
"@/*": ["./src/*"] "@/*": ["./src/*"]
} }
}, },
"include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"], "include": ["src/**/*.ts", "src/**/*.d.ts", "src/**/*.tsx", "src/**/*.vue"],
"references": [{ "path": "./tsconfig.node.json" }] "references": [{ "path": "./tsconfig.node.json" }]
} }

Some files were not shown because too many files have changed in this diff Show More