Compare commits

25 Commits

Author SHA1 Message Date
3e261bcee2 ci: validate auto-deploy after CI hardening (runners + SSH remote)
All checks were successful
Deploy XIP / deploy (push) Successful in 25s
Empty commit to exercise the full pipeline end-to-end: a freshly re-registered runner picks up the job, deploy.sh fetches origin over the new read-only SSH deploy key, and force_pull:false reuses the cached job image.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 07:20:18 +02:00
76962ed7f1 ci(deploy): retry compose up once to survive container-recreate races
All checks were successful
Deploy XIP / deploy (push) Successful in 24s
A raced or interrupted previous deploy (e.g. a manual compose up overlapping the automated one) could leave a half-removed container behind, so the next recreate failed with 'removal of container ... is already in progress' (seen on the b25eb44 run, idx15). Prune stopped containers and retry once.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-06-01 06:58:40 +02:00
b57a195d5c Update README.md
All checks were successful
Deploy XIP / deploy (push) Successful in 26s
2026-06-01 05:41:56 +02:00
91bb8853b9 docs: renseigne les auteurs du groupe dans le README
All checks were successful
Deploy XIP / deploy (push) Successful in 35s
2026-06-01 05:38:41 +02:00
b25eb448ec fix(front): declare *.vue module shim pour le build TS
Some checks failed
Deploy XIP / deploy (push) Failing after 1m17s
Le build Docker (vue-tsc en install isole) echouait avec TS2307
"Cannot find module './App.vue'..." faute de declaration de module
pour les fichiers .vue. Ajout du shim standard dans vite-env.d.ts.
2026-06-01 05:33:43 +02:00
cfa2eadec9 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).
2026-05-31 23:59:34 +02:00
9dd72b9b2d refactor(shop): découpe MesPersos en sous-sections + nettoyage ProductCard
All checks were successful
Deploy XIP / deploy (push) Successful in 35s
- MesPersos.vue (347L) éclaté en 5 sous-composants autonomes sous
  shop/persos/ (BgPrefsSection, SendButtonPrefsSection, SendSkinPrefsSection,
  IpColorPrefsSection, PetsPrefsSection). MesPersos n'est plus qu'un wrapper.
- CSS partagé des sections déplacé en classes globales .pf-* dans style.css
  (plus de duplication entre les sections).
- ProductCard : metaJson typé via parseMeta<ProductMeta>(), suppression des
  casts `any` (find/designs) — comportement identique.
- vue-tsc --noEmit : 0 erreur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 20:03:23 +02:00
aca608e520 feat: thème WhatsApp + fix envoi rich/compact + nav shop + refactor
All checks were successful
Deploy XIP / deploy (push) Successful in 43s
Theming
- Thème global piloté par variables CSS (:root + [data-theme]) appliqué via un
  attribut data-theme sur la racine app. Ajout du thème "WhatsApp" (bulles +
  palette verte, bulle sortante #005c4b) sans nouveau composant message.
- useTheme: type Theme étendu + THEME_LAYOUT (whatsapp = layout bulles).
- MessageList: sélection du composant par layout avec garde de repli
  (fini le <component :is="undefined">).
- Fix du thème "compact" cassé : nouveau MessageItemCompact.vue (variante dense).
- Surfaces migrées en variables : fond app/chat, header, bouton d'envoi, bulles.

Corrections
- Bug envoi rich/fichier : le backend exigeait un content texte non vide même
  en mode HTML/CSS/JS. Validation par présence (texte OU rich OU piece jointe) ;
  le front n'envoie plus d'espace bidon. Plus besoin de faux texte.
- Shop : suppression de "Tout voir", navigation forcee par categorie
  (defaut: Publicite).

Refactor (lisibilite)
- Parite perks backend (ip-colors, audio-alert, send-skin-*) ; /api/shop/me
  renvoie myPerks precalcule ; le front consomme directement (suppression de la
  derivation dupliquee + nettoyage d'un artefact de merge dans useMessages).
- Coherence composable-singleton : myPerks lu via useMyPerks() partout.
- Extraction du composer de HomePage vers ChatComposer.vue (HomePage = layout).
- Helper type parseMeta<T>() pour les metaJson (moins de any).
- vue-tsc --noEmit : 0 erreur.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 19:51:24 +02:00
raphael.thieffry
c0b82222bd systeme de themes qui fonctionnouille
All checks were successful
Deploy XIP / deploy (push) Successful in 34s
2026-05-31 18:41:39 +02:00
raphael.thieffry
942fcaa4d1 external sources
All checks were successful
Deploy XIP / deploy (push) Successful in 46s
2026-05-31 17:10:41 +02:00
arussac
366d4e8f8b feat: add geolocation support for messages and replies
All checks were successful
Deploy XIP / deploy (push) Successful in 53s
2026-05-31 16:42:32 +02:00
arussac
9354e2022a tr
All checks were successful
Deploy XIP / deploy (push) Successful in 34s
2026-05-31 15:51:07 +02:00
arussac
0c08e2080f tqff
All checks were successful
Deploy XIP / deploy (push) Successful in 45s
2026-05-31 15:42:13 +02:00
arussac
d50f06d65a Merge branch 'main' of https://git.kerboul.me/anto/XIP
All checks were successful
Deploy XIP / deploy (push) Successful in 51s
2026-05-31 15:36:49 +02:00
arussac
d88b71b2c6 push 2026-05-31 15:35:59 +02:00
Kerboul
3c4a292db2 ci: serialise les deploys + secrets via env, SSH robuste
All checks were successful
Deploy XIP / deploy (push) Successful in 35s
- concurrency group deploy-xip-prod (evite la course docker compose --build)
- passe HOST/USER/KEY par env (cle multi-ligne preservee)
- SSH sans known_hosts (StrictHostKeyChecking=no)

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:33:49 +02:00
Kerboul
8471381048 fix(deploy): passer --env-file au docker compose ps final
Some checks failed
Deploy XIP / deploy (push) Failing after 35s
Sans --env-file, l'interpolation ${POSTGRES_PASSWORD:?} echoue et fait sortir
deploy.sh en non-zero (set -e) -> le job CI serait marque en echec a tort.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:28:56 +02:00
Kerboul
09a9f6f321 fix(frontend): ne pas bloquer le build prod sur vue-tsc
Some checks failed
Deploy XIP / deploy (push) Failing after 1s
Sépare le type-check (script 'typecheck') du build de prod ('vite build').
Le build Docker ne doit pas échouer sur des erreurs TS strictes (TS6133/TS2307)
alors que le bundle est sain.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:24:58 +02:00
arussac
48a99514b2 Merge branch 'main' of https://git.kerboul.me/anto/XIP
Some checks failed
Deploy XIP / deploy (push) Failing after 1s
2026-05-31 15:16:10 +02:00
arussac
21e35107c7 feat: update styles and enhance pet purchase flow in marketplace components 2026-05-31 15:15:48 +02:00
Kerboul
024909b162 feat(deploy): CI/CD Gitea Actions + stack Docker prod pour xip.kerboul.me
Some checks failed
Deploy XIP / deploy (push) Failing after 21s
- docker-compose.prod.yml: postgres + redis + backend (bun) + web (nginx single-origin)
- backend/Dockerfile + entrypoint: prisma migrate deploy + seed idempotent au boot
- frontend/Dockerfile: build Vite (VITE_API_URL=https://xip.kerboul.me) servi par nginx
- deploy/nginx.conf: proxy /api + /ws vers le backend, SPA fallback
- .gitea/workflows/deploy.yml: auto-deploy SSH sur push main (runner CT121 -> CT502)
- scripts/deploy.sh: pull + rebuild de la stack
- mode open-bar (XIP_OPEN_BAR): paywall off pour tous en prod, via isFree() centralise

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
2026-05-31 15:14:53 +02:00
arussac
02bba16285 feat: enhance customization options with new 'Mes Persos' panel and improved context menus 2026-05-31 15:04:51 +02:00
arussac
1a76e9076c feat: implement right-click context menu for style customization and enhance real-time stats tracking 2026-05-31 14:47:40 +02:00
ccacd16edb Merge remote-tracking branch 'origin/main'
# Conflicts:
#	backend/src/lib/redis.ts
#	backend/src/routes/messages.ts
#	frontend/src/composables/useMessages.ts
#	frontend/vite.config.ts
2026-05-31 14:20:29 +02:00
raphael.thieffry
fdce9e4eb8 feat: live messages via SSE + real client IP
- backend: SSE endpoint /api/messages/stream backed by Redis pub/sub
- backend: read real client IP via getConnInfo (fallback for x-forwarded-for)
- backend: CORS allow any origin (dev: LAN access from phone)
- frontend: useMessages subscribes via EventSource, auto-reconnect, merges new messages/replies live
- frontend: vite host:true to expose dev server on LAN

Co-Authored-By: Claude Opus 4.7 <noreply@anthropic.com>
2026-05-30 13:53:12 +02:00
118 changed files with 10407 additions and 6690 deletions

14
.env.prod.example Normal file
View File

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

3
.gitattributes vendored Normal file
View File

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

View File

@@ -0,0 +1,35 @@
name: Deploy XIP
# 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.
on:
push:
branches: [main]
workflow_dispatch:
# 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).
concurrency:
group: deploy-xip-prod
cancel-in-progress: false
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Deploy over SSH to xip-app
env:
# Secrets via env (not inlined in the script) so the multi-line key
# keeps its newlines and never breaks shell quoting.
DEPLOY_HOST: ${{ secrets.XIP_DEPLOY_HOST }}
DEPLOY_USER: ${{ secrets.XIP_DEPLOY_USER }}
DEPLOY_KEY: ${{ secrets.XIP_DEPLOY_KEY }}
run: |
set -e
command -v ssh >/dev/null 2>&1 || (apt-get update && apt-get install -y --no-install-recommends openssh-client)
mkdir -p ~/.ssh
printf '%s\n' "$DEPLOY_KEY" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh -i ~/.ssh/id_ed25519 \
-o StrictHostKeyChecking=no -o UserKnownHostsFile=/dev/null \
"$DEPLOY_USER@$DEPLOY_HOST" 'bash /opt/xip/scripts/deploy.sh'

15
.gitignore vendored
View File

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

64
DEPLOY.md Normal file
View File

@@ -0,0 +1,64 @@
# Déploiement XIP
Production : **https://xip.kerboul.me** — déploiement continu sur push `main`.
## Architecture (pattern Vireli, cluster SENTINEL)
```
Cloudflare (*.kerboul.me) ─► VPS WireGuard ─► Traefik (CT102, Cerberus)
│ Host(`xip.kerboul.me`) → http://192.168.1.242:80
CT502 « xip-app » (Echelon, Docker host)
┌───────────────────────────────────────┐
│ web (nginx:80) │
│ ├── / → SPA Vue (statique) │
│ ├── /api/ → backend:3000 │
│ └── /ws → backend:3000 (WS) │
│ backend (bun:3000, Hono + Prisma) │
│ postgres:16 redis:7 │
└───────────────────────────────────────┘
```
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.
Traefik termine le TLS (Let's Encrypt, DNS challenge Cloudflare).
## CI/CD (Gitea Actions)
`.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`
(`git reset --hard origin/main` + `docker compose up -d --build`).
Migrations Prisma + seed (idempotent) tournent au démarrage du conteneur backend
(`backend/docker-entrypoint.sh`).
### Secrets du repo (Gitea → Settings → Actions → Secrets)
| Secret | Rôle |
|--------|------|
| `XIP_DEPLOY_HOST` | IP du CT502 (192.168.1.242) |
| `XIP_DEPLOY_USER` | utilisateur de déploiement (`deploy`) |
| `XIP_DEPLOY_KEY` | clé privée SSH autorisée sur le CT502 |
## Fichiers
| Fichier | Rôle |
|---------|------|
| `docker-compose.prod.yml` | stack prod (postgres, redis, backend, web) |
| `backend/Dockerfile` + `docker-entrypoint.sh` | image backend, migrate+seed au boot |
| `frontend/Dockerfile` | build Vite → nginx |
| `deploy/nginx.conf` | reverse proxy single-origin |
| `scripts/deploy.sh` | script de (re)déploiement sur le CT |
| `.env.prod` (non commité) | secrets : voir `.env.prod.example` |
## Paywall
`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
(gratuit uniquement en localhost). Logique centralisée dans `backend/src/lib/ip.ts`
(`isFree()`).
## Redéploiement manuel
```bash
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.

121
README.md
View File

@@ -1,35 +1,106 @@
# 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)**.
---
## Auteurs
- Ethan Puyaubreau
- Raphaël Thieffry
- Antonin Russac

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/

22
backend/Dockerfile Normal file
View File

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

View File

@@ -0,0 +1,14 @@
#!/bin/sh
# Startup sequence for the XIP backend container.
# Runs on every (re)start so new migrations land automatically on deploy.
set -e
cd /app
echo "[xip] Applying database migrations (prisma migrate deploy)…"
bunx prisma migrate deploy
echo "[xip] Seeding catalogue + ads (idempotent upserts)…"
bun run prisma/seed.ts || echo "[xip] seed step failed (non-fatal) — continuing"
echo "[xip] Starting backend on :${PORT:-3000}"
exec bun run src/index.ts

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,211 +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 }),
}, },
] as const; // ── Cosmetics: IP color + send button skins ──────────────────────────────
{
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ────────────────── id: "ip-colors",
const ADS = [ category: "cosmetiques",
{ brand: "NOVA", subtitle: "STORE 2026", url: "https://nova-store.io", cta: "DÉCOUVRIR", icon: "🛒", tone: "blue", kind: "band", weight: 1 }, name: "Palette IP",
{ brand: "APEX GEAR", subtitle: "Gaming Setup", url: "https://apex-gear.com", cta: "ACHETER", icon: "🎮", tone: "green", kind: "band", weight: 1 }, subtitle: "Personnalise la couleur de ton adresse IP dans le chat",
{ brand: "SHIELDVPN", subtitle: "Sécurité totale", url: "https://shieldvpn.net", cta: "ESSAI GRATUIT", icon: "🔒", tone: "purple", kind: "band", weight: 1 }, kind: "unlock",
{ brand: "CASINO LUCKY", subtitle: "OFFRE EXCLUSIVE · +200% · 500€ max", url: "https://casino-lucky.bet", cta: "JOUER MAINTENANT", icon: "♠", tone: "casino", kind: "casino", weight: 1 }, basePrice: 299,
] as const; sortOrder: 46,
metaJson: JSON.stringify({}),
async function seedProducts() { },
for (const p of PRODUCTS) { {
await prisma.product.upsert({ id: "send-skin-honker",
where: { id: p.id }, category: "cosmetiques",
create: p as any, name: "Doigt d'honneur",
update: p as any, subtitle: "Bouton d'envoi qui exprime tout",
}); kind: "send-skin",
} basePrice: 149,
console.log(`${PRODUCTS.length} produits upsertés.`); sortOrder: 47,
} metaJson: JSON.stringify({ char: "🖕", label: "Doigt d'honneur" }),
},
async function seedAds() { {
for (const a of ADS) { id: "send-skin-skull",
// Idempotent on brand: only seed the canonical (non-user) ads once. category: "cosmetiques",
const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } }); name: "Crâne",
if (existing) { subtitle: "Envoyer avec style... macabre",
await prisma.ad.update({ where: { id: existing.id }, data: a as any }); kind: "send-skin",
} else { basePrice: 149,
await prisma.ad.create({ data: a as any }); sortOrder: 48,
} metaJson: JSON.stringify({ char: "💀", label: "Crâne" }),
} },
console.log(`${ADS.length} pubs upsertées.`); {
} id: "send-skin-rocket",
category: "cosmetiques",
async function seedMessages() { name: "Fusée",
const count = await prisma.message.count(); subtitle: "Tes messages décollent",
if (count > 0) { kind: "send-skin",
console.log("⏭️ Messages déjà présents, seed messages ignoré."); basePrice: 149,
return; sortOrder: 49,
} metaJson: JSON.stringify({ char: "🚀", label: "Fusée" }),
const root1 = await prisma.message.create({ },
data: { {
content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.", id: "send-skin-ghost",
authorIp: "1.2.3.4", category: "cosmetiques",
}, name: "Fantôme",
}); subtitle: "Boo !",
await prisma.message.create({ kind: "send-skin",
data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" }, basePrice: 149,
}); sortOrder: 50,
await prisma.message.create({ metaJson: JSON.stringify({ char: "👻", label: "Fantôme" }),
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id }, },
}); {
console.log("✅ 3 messages de démo créés."); id: "send-skin-bomb",
} category: "cosmetiques",
name: "Bombe",
async function main() { subtitle: "Message explosif",
await seedProducts(); kind: "send-skin",
await seedAds(); basePrice: 149,
await seedMessages(); sortOrder: 51,
} metaJson: JSON.stringify({ char: "💣", label: "Bombe" }),
},
main() {
.catch(console.error) id: "send-skin-sword",
.finally(() => prisma.$disconnect()); category: "cosmetiques",
name: "Épée",
subtitle: "Tranche le silence",
kind: "send-skin",
basePrice: 149,
sortOrder: 52,
metaJson: JSON.stringify({ char: "⚔️", label: "Épée" }),
},
] as const;
// ── Ad inventory (the 4 hardcoded joke ads, now real data) ──────────────────
const ADS = [
{ 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: "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 },
] as const;
async function seedProducts() {
for (const p of PRODUCTS) {
await prisma.product.upsert({
where: { id: p.id },
create: p as any,
update: p as any,
});
}
console.log(`${PRODUCTS.length} produits upsertés.`);
}
async function seedAds() {
for (const a of ADS) {
// Idempotent on brand: only seed the canonical (non-user) ads once.
const existing = await prisma.ad.findFirst({ where: { brand: a.brand, ownerIp: null } });
if (existing) {
await prisma.ad.update({ where: { id: existing.id }, data: a as any });
} else {
await prisma.ad.create({ data: a as any });
}
}
console.log(`${ADS.length} pubs upsertées.`);
}
async function seedMessages() {
const count = await prisma.message.count();
if (count > 0) {
console.log("⏭️ Messages déjà présents, seed messages ignoré.");
return;
}
const root1 = await prisma.message.create({
data: {
content: "Bienvenue sur XIP — le réseau social sans filtre ni compte.",
authorIp: "1.2.3.4",
},
});
await prisma.message.create({
data: { content: "Pas de compte, ton IP c'est toi.", authorIp: "5.6.7.8" },
});
await prisma.message.create({
data: { content: "Réponse au premier message !", authorIp: "9.10.11.12", parentId: root1.id },
});
console.log("✅ 3 messages de démo créés.");
}
async function main() {
await seedProducts();
await seedAds();
await seedMessages();
}
main()
.catch(console.error)
.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: ["http://localhost:5173"], 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,308 +1,314 @@
import { prisma } from "./prisma"; import { prisma } from "./prisma";
import { spend, getWallet, InsufficientCreditsError } from "./wallet"; import { spend, getWallet, InsufficientCreditsError } from "./wallet";
import { isLocalhost } 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 = isLocalhost(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": {
if ((await countActiveEntitlements(ip, "pet")) >= 3) const char = options.petChar ?? "♥";
throw new PurchaseError("Maximum 3 pets actifs", 409); grants.push({
const char = options.petChar ?? ""; kind: "pet",
grants.push({ meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" },
kind: "pet", });
meta: { design: options.petDesign ?? "coeur", char, position: options.petPosition ?? "left" }, visiblePerkChanged = true;
}); break;
visiblePerkChanged = true; }
break; case "ad-frame": {
} if ((await countActiveEntitlements(ip, "ad-frame")) >= 1)
case "ad-frame": { throw new PurchaseError("Tu as déjà un cadre de pub actif", 409);
if ((await countActiveEntitlements(ip, "ad-frame")) >= 1) const days = options.durationDays ?? 7;
throw new PurchaseError("Tu as déjà un cadre de pub actif", 409); grants.push({
const days = options.durationDays ?? 7; kind: "ad-frame",
grants.push({ expiresAt: new Date(Date.now() + days * DAY_MS),
kind: "ad-frame", meta: { format: options.format ?? "static", url: options.url ?? "", days },
expiresAt: new Date(Date.now() + days * DAY_MS), });
meta: { format: options.format ?? "static", url: options.url ?? "", days }, break;
}); }
break; case "rich": {
} const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss";
case "rich": { if ((await countActiveEntitlements(ip, kind)) >= 1)
const kind = product.id === "rich-js" ? "rich-js" : "rich-htmlcss"; throw new PurchaseError("Déjà débloqué", 409);
if ((await countActiveEntitlements(ip, kind)) >= 1) grants.push({ kind });
throw new PurchaseError("Déjà débloqué", 409); break;
grants.push({ kind }); }
break; case "unlock": {
} // no-file-limit, element-skin, etc. — slug == kind.
case "unlock": { if ((await countActiveEntitlements(ip, product.id)) >= 1)
// no-file-limit, element-skin, etc. — slug == kind. throw new PurchaseError("Déjà débloqué", 409);
if ((await countActiveEntitlements(ip, product.id)) >= 1) grants.push({ kind: product.id });
throw new PurchaseError("Déjà débloqué", 409); if (product.id === "element-skin" || product.id === "ip-colors") visiblePerkChanged = false; // viewer-scoped
grants.push({ kind: product.id }); break;
if (product.id === "element-skin") visiblePerkChanged = false; // viewer-scoped }
break; case "consumable": {
} // audio-alert: grant the entitlement once; firing is a separate action.
case "consumable": { if ((await countActiveEntitlements(ip, "audio-alert")) < 1) {
// audio-alert: grant the entitlement once; firing is a separate action. grants.push({ kind: "audio-alert" });
if ((await countActiveEntitlements(ip, "audio-alert")) < 1) { } else {
grants.push({ kind: "audio-alert" }); // Already owned — buying again is a harmless top-up; just record it.
} else { grants.push({ kind: "audio-alert" });
// Already owned — buying again is a harmless top-up; just record it. }
grants.push({ kind: "audio-alert" }); break;
} }
break; case "send-skin": {
} if ((await countActiveEntitlements(ip, product.id)) >= 1)
case "bundle": { throw new PurchaseError("Déjà débloqué", 409);
// Cosmetic bundle: Style Doré + 1 pet. let skinMeta: any = {};
if ((await countActiveEntitlements(ip, "style-dore")) < 1) try { skinMeta = product.metaJson ? JSON.parse(product.metaJson) : {}; } catch {}
grants.push({ kind: "style-dore", meta: { variant: "gold" } }); grants.push({ kind: product.id, meta: skinMeta });
if ((await countActiveEntitlements(ip, "pet")) < 3) { break;
const char = options.petChar ?? "★"; }
grants.push({ case "bundle": {
kind: "pet", // Cosmetic bundle: Style Doré + 1 pet.
meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" }, if ((await countActiveEntitlements(ip, "style-dore")) < 1)
}); grants.push({ kind: "style-dore", meta: { variant: "gold" } });
} if ((await countActiveEntitlements(ip, "pet")) < 3) {
if (grants.length === 0) const char = options.petChar ?? "★";
throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409); grants.push({
visiblePerkChanged = true; kind: "pet",
break; meta: { design: options.petDesign ?? "etoile", char, position: options.petPosition ?? "left" },
} });
default: }
throw new PurchaseError("Type de produit non géré", 400); if (grants.length === 0)
} throw new PurchaseError("Tu possèdes déjà ce que contient le pack", 409);
visiblePerkChanged = true;
// Stock check for limited products (Style Doré). Done transactionally with the break;
// spend so we can never oversell the 50-unit cap under concurrency. }
let balance = 0; default:
try { throw new PurchaseError("Type de produit non géré", 400);
balance = await prisma.$transaction(async (tx) => { }
if (product.stockLimit != null) {
const fresh = await tx.product.findUnique({ where: { id: product.id } }); // Stock check for limited products (Style Doré). Done transactionally with the
if (!fresh) throw new PurchaseError("Produit introuvable", 404); // spend so we can never oversell the 50-unit cap under concurrency.
if (fresh.stockSold >= fresh.stockLimit) let balance = 0;
throw new PurchaseError("Stock épuisé", 409); try {
await tx.product.update({ balance = await prisma.$transaction(async (tx) => {
where: { id: product.id }, if (product.stockLimit != null) {
data: { stockSold: { increment: 1 } }, const fresh = await tx.product.findUnique({ where: { id: product.id } });
}); if (!fresh) throw new PurchaseError("Produit introuvable", 404);
} if (fresh.stockLimit != null && fresh.stockSold >= fresh.stockLimit)
throw new PurchaseError("Stock épuisé", 409);
// Spend (skips real deduction for localhost free mode). await tx.product.update({
if (!free && price > 0) { where: { id: product.id },
const w = await tx.wallet.upsert({ data: { stockSold: { increment: 1 } },
where: { ip }, });
create: { ip, balance: 0 }, }
update: {},
}); // Spend (skips real deduction for localhost free mode).
if (w.balance < price) throw new InsufficientCreditsError(); if (!free && price > 0) {
const updated = await tx.wallet.update({ const w = await tx.wallet.upsert({
where: { ip }, where: { ip },
data: { balance: { decrement: price } }, create: { ip, balance: 0 },
}); update: {},
await tx.purchase.create({ });
data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) }, if (w.balance < price) throw new InsufficientCreditsError();
}); const updated = await tx.wallet.update({
// Grant entitlements inside the tx too. where: { ip },
for (const g of grants) { data: { balance: { decrement: price } },
await tx.entitlement.create({ });
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, await tx.purchase.create({
}); data: { ip, type: "purchase", amount: -price, productId: product.id, metaJson: JSON.stringify(options) },
} });
return updated.balance; // Grant entitlements inside the tx too.
} for (const g of grants) {
await tx.entitlement.create({
// Free (localhost) or zero-price: record purchase + grants, no deduction. data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
await tx.purchase.create({ });
data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) }, }
}); return updated.balance;
for (const g of grants) { }
await tx.entitlement.create({
data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null }, // Free (localhost) or zero-price: record purchase + grants, no deduction.
}); await tx.purchase.create({
} data: { ip, type: "purchase", amount: 0, productId: product.id, metaJson: JSON.stringify(options) },
const w = await tx.wallet.findUnique({ where: { ip } }); });
return w?.balance ?? 0; for (const g of grants) {
}); await tx.entitlement.create({
} catch (e) { data: { ip, kind: g.kind, expiresAt: g.expiresAt ?? null, metaJson: g.meta ? JSON.stringify(g.meta) : null },
if (e instanceof InsufficientCreditsError) });
throw new PurchaseError("Crédits insuffisants", 402); }
throw e; const w = await tx.wallet.findUnique({ where: { ip } });
} return w?.balance ?? 0;
});
// Bump the global credits-spent money counter (outside the tx; best-effort). } catch (e) {
if (!free && price > 0) { if (e instanceof InsufficientCreditsError)
const { redis } = await import("./redis"); throw new PurchaseError("Crédits insuffisants", 402);
void redis.incrby("xip:money:credits_spent", price).catch(() => {}); throw e;
} }
// Ad-frame purchase => create a real Ad row that enters rotation (Phase 7). // Bump the global credits-spent money counter (outside the tx; best-effort).
const adGrant = grants.find((g) => g.kind === "ad-frame"); if (!free && price > 0) {
if (adGrant) { const { redis } = await import("./redis");
await prisma.ad void redis.incrby("xip:money:credits_spent", price).catch(() => {});
.create({ }
data: {
brand: "VOTRE PUB", // Ad-frame purchase => create a real Ad row that enters rotation (Phase 7).
subtitle: "Espace acheté", const adGrant = grants.find((g) => g.kind === "ad-frame");
url: adGrant.meta?.url || null, if (adGrant) {
cta: "VOIR", await prisma.ad
icon: "📣", .create({
tone: "user", data: {
kind: "band", brand: "VOTRE PUB",
weight: 3, subtitle: "Espace acheté",
active: true, url: adGrant.meta?.url || null,
ownerIp: ip, cta: "VOIR",
format: adGrant.meta?.format ?? "static", icon: "📣",
expiresAt: adGrant.expiresAt ?? null, tone: "user",
}, kind: "band",
}) weight: 3,
.catch(() => {}); active: true,
adCreated = true; ownerIp: ip,
} format: adGrant.meta?.format ?? "static",
expiresAt: adGrant.expiresAt ?? null,
const balanceView = free ? (await getWallet(ip)).balance : balance; },
})
return { .catch(() => {});
result: { adCreated = true;
ok: true, }
productId: product.id,
pricePaid: free ? 0 : price, const balanceView = free ? (await getWallet(ip)).balance : balance;
balance: balanceView,
entitlementKinds: grants.map((g) => g.kind), return {
}, result: {
visiblePerkChanged, ok: true,
adCreated, productId: product.id,
}; pricePaid: free ? 0 : price,
} balance: balanceView,
entitlementKinds: grants.map((g) => g.kind),
/** Recompute + cache perks after a purchase (caller broadcasts). */ },
export async function refreshPerks(ip: string) { visiblePerkChanged,
await invalidatePerks(ip); adCreated,
return getPerksForIp(ip); };
} }
/** Recompute + cache perks after a purchase (caller broadcasts). */
export async function refreshPerks(ip: string) {
await invalidatePerks(ip);
return getPerksForIp(ip);
}

95
backend/src/lib/geo.ts Normal file
View File

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

View File

@@ -1,38 +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:
* 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
* in the app routes through this single helper.
*/
export function isFree(ip: string): boolean {
return process.env.XIP_OPEN_BAR === "true" || isLocalhost(ip);
}

View File

@@ -1,111 +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;
audioAlert?: boolean;
const perksKey = (ip: string) => `xip:perks:${ip}`; sendSkins?: { id: string; char: string; label?: string }[];
const TTL_SEC = 60; }
/** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */ const perksKey = (ip: string) => `xip:perks:${ip}`;
export async function invalidatePerks(ip: string): Promise<void> { const TTL_SEC = 60;
await redis.del(perksKey(ip)).catch(() => {});
} /** Drop the cached perks for an IP. Call BEFORE broadcasting a perks change. */
export async function invalidatePerks(ip: string): Promise<void> {
/** Compute perks for one IP from its active, non-expired entitlements. */ await redis.del(perksKey(ip)).catch(() => {});
export async function getPerksForIp(ip: string): Promise<Perks> { }
// Fast path: cache.
const cached = await redis.get(perksKey(ip)).catch(() => null); /** Compute perks for one IP from its active, non-expired entitlements. */
if (cached) { export async function getPerksForIp(ip: string): Promise<Perks> {
try { // Fast path: cache.
return JSON.parse(cached) as Perks; const cached = await redis.get(perksKey(ip)).catch(() => null);
} catch { if (cached) {
/* fall through to recompute */ try {
} return JSON.parse(cached) as Perks;
} } catch {
/* fall through to recompute */
const now = new Date(); }
const rows = await prisma.entitlement }
.findMany({ where: { ip, active: true } })
.catch(() => []); const now = new Date();
const rows = await prisma.entitlement
const perks: Perks = {}; .findMany({ where: { ip, active: true } })
const pets: { char: string; position: PetPosition }[] = []; .catch(() => []);
for (const e of rows) { const perks: Perks = {};
// Skip expired (subscriptions / ad-frames). const pets: { char: string; position: PetPosition }[] = [];
if (e.expiresAt && e.expiresAt < now) continue;
let meta: any = {}; for (const e of rows) {
try { // Skip expired (subscriptions / ad-frames).
meta = e.metaJson ? JSON.parse(e.metaJson) : {}; if (e.expiresAt && e.expiresAt < now) continue;
} catch { let meta: any = {};
meta = {}; try {
} meta = e.metaJson ? JSON.parse(e.metaJson) : {};
} catch {
switch (e.kind) { meta = {};
case "style-dore": }
perks.skin = "gold";
break; switch (e.kind) {
case "pet": case "style-dore":
if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" }); perks.skin = "gold";
break; break;
case "noads": case "pet":
perks.noads = true; if (meta.char) pets.push({ char: meta.char, position: meta.position ?? "left" });
if (meta.plan === "annual") perks.badge = true; break;
break; case "noads":
case "element-skin": perks.noads = true;
perks.elementSkin = true; if (meta.plan === "annual") perks.badge = true;
break; break;
case "rich-htmlcss": case "element-skin":
perks.richHtmlcss = true; perks.elementSkin = true;
break; break;
case "rich-js": case "rich-htmlcss":
perks.richJs = true; perks.richHtmlcss = true;
break; break;
case "no-file-limit": case "rich-js":
perks.noFileLimit = true; perks.richJs = true;
break; break;
} case "no-file-limit":
} perks.noFileLimit = true;
if (pets.length) perks.pets = pets.slice(0, 3); break;
case "ip-colors":
await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {}); perks.ipColors = true;
return perks; break;
} case "audio-alert":
perks.audioAlert = true;
/** Batch perks for several IPs (used to annotate message lists). */ break;
export async function getPerksForIps( }
ips: string[]
): Promise<Record<string, Perks>> { // Send-button skins use a prefixed kind (send-skin-rocket, …), so they
const uniq = [...new Set(ips.filter(Boolean))]; // can't be matched by the switch above.
const out: Record<string, Perks> = {}; if (e.kind.startsWith("send-skin-")) {
await Promise.all( (perks.sendSkins ??= []).push({
uniq.map(async (ip) => { id: e.kind,
out[ip] = await getPerksForIp(ip); char: meta.char ?? "?",
}) label: meta.label,
); });
return out; }
} }
if (pets.length) perks.pets = pets.slice(0, 3);
await redis.set(perksKey(ip), JSON.stringify(perks), "EX", TTL_SEC).catch(() => {});
return perks;
}
/** Batch perks for several IPs (used to annotate message lists). */
export async function getPerksForIps(
ips: string[]
): Promise<Record<string, Perks>> {
const uniq = [...new Set(ips.filter(Boolean))];
const out: Record<string, Perks> = {};
await Promise.all(
uniq.map(async (ip) => {
out[ip] = await getPerksForIp(ip);
})
);
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 { isLocalhost } 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 (isLocalhost(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 (isLocalhost(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 (isLocalhost(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,136 +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 snapshot = await buildSnapshot({ const distinctIps = new Set<string>();
connectedTabs: clients.size, for (const s of clients.values()) distinctIps.add(s.ip);
typingNow: countTyping(Date.now()), const snapshot = await buildSnapshot({
}); connectedTabs: distinctIps.size,
const payload = JSON.stringify({ type: "stats", data: snapshot }); typingNow: countTyping(Date.now()),
for (const ws of clients.keys()) send(ws, payload); });
} const payload = JSON.stringify({ type: "stats", data: snapshot });
for (const ws of clients.keys()) send(ws, payload);
function scheduleStats(): void { }
if (broadcastScheduled) return;
broadcastScheduled = true; function scheduleStats(): void {
const wait = Math.max(0, BROADCAST_MIN_INTERVAL_MS - (Date.now() - lastBroadcastAt)); if (broadcastScheduled) return;
setTimeout(() => { broadcastScheduled = true;
void flushStats(); const wait = Math.max(0, BROADCAST_MIN_INTERVAL_MS - (Date.now() - lastBroadcastAt));
}, wait); setTimeout(() => {
} void flushStats();
}, wait);
// Periodic tick so time-decaying metrics (letters/sec, typing expiry, msgs/min) }
// keep updating even when nobody is interacting.
setInterval(() => { // Periodic tick so time-decaying metrics (letters/sec, typing expiry, msgs/min)
if (clients.size > 0) void flushStats(); // keep updating even when nobody is interacting.
}, 1000); setInterval(() => {
if (clients.size > 0) void flushStats();
/** Send an arbitrary frame to every connected tab. */ }, 1000);
export function broadcast(payload: object): void {
const str = JSON.stringify(payload); // Periodic console log of connected IPs (every 10 s).
for (const ws of clients.keys()) send(ws, str); setInterval(() => {
} if (clients.size === 0) return;
const ips = new Set<string>();
/** Send a frame only to the tabs belonging to one IP (e.g. wallet updates). */ for (const s of clients.values()) ips.add(s.ip);
export function broadcastToIp(ip: string, payload: object): void { const lines = [...ips].map((ip) => ` ${ip}`).join("\n");
const str = JSON.stringify(payload); console.log(`[connectés] ${ips.size} IP(s):\n${lines}`);
for (const [ws, state] of clients) { }, 10_000);
if (state.ip === ip) send(ws, str);
} /** Send an arbitrary frame to every connected tab. */
} export function broadcast(payload: object): void {
const str = JSON.stringify(payload);
/** Push a freshly created message to every connected tab. */ for (const ws of clients.keys()) send(ws, str);
export function broadcastNewMessage(message: unknown): void { }
broadcast({ type: "message", data: message });
scheduleStats(); // totals changed too /** Send a frame only to the tabs belonging to one IP (e.g. wallet updates). */
} export function broadcastToIp(ip: string, payload: object): void {
const str = JSON.stringify(payload);
/** Hono route handler for GET /ws. The factory receives the request Context. */ for (const [ws, state] of clients) {
export const wsHandler = upgradeWebSocket((c) => { if (state.ip === ip) send(ws, str);
const ip = getClientIp(c); }
return { }
onOpen(_evt, ws) {
clients.set(ws, { lastTypingAt: 0, ip }); /** Push a freshly created message to every connected tab. */
scheduleStats(); export function broadcastNewMessage(message: unknown): void {
}, broadcast({ type: "message", data: message });
onMessage(evt, ws) { scheduleStats(); // totals changed too
let msg: { type?: string; delta?: number } | null = null; }
try {
msg = JSON.parse(typeof evt.data === "string" ? evt.data : "{}"); /** Hono route handler for GET /ws. The factory receives the request Context. */
} catch { export const wsHandler = upgradeWebSocket((c) => {
return; const ip = getClientIp(c);
} return {
if (!msg || typeof msg !== "object") return; onOpen(_evt, ws) {
clients.set(ws, { lastTypingAt: 0, ip });
if (msg.type === "typing") { scheduleStats();
const state = clients.get(ws); },
if (state) state.lastTypingAt = Date.now(); onMessage(evt, ws) {
const delta = Number(msg.delta) || 0; let msg: { type?: string; delta?: number } | null = null;
if (delta > 0) void recordLettersTyped(delta); try {
scheduleStats(); msg = JSON.parse(typeof evt.data === "string" ? evt.data : "{}");
} } catch {
}, return;
onClose(_evt, ws) { }
clients.delete(ws); if (!msg || typeof msg !== "object") return;
scheduleStats();
}, if (msg.type === "typing") {
onError(_evt, ws) { const state = clients.get(ws);
clients.delete(ws); if (state) state.lastTypingAt = Date.now();
}, const delta = Number(msg.delta) || 0;
}; if (delta > 0) void recordLettersTyped(delta);
}); scheduleStats();
}
export { websocket }; },
onClose(_evt, ws) {
clients.delete(ws);
scheduleStats();
},
onError(_evt, ws) {
clients.delete(ws);
},
};
});
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, isLocalhost } 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 (!isLocalhost(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,122 +1,188 @@
import { Hono } from "hono"; import { Hono } from "hono";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { getClientIp, isLocalhost } 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";
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? */
async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> { /** Does this IP own the entitlement needed for a rich tier? */
if (isLocalhost(ip)) return true; async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> {
const kind = mode === "js" ? "rich-js" : "rich-htmlcss"; if (isFree(ip)) return true;
const now = new Date(); const kind = mode === "js" ? "rich-js" : "rich-htmlcss";
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); const now = new Date();
return rows.some((e) => !e.expiresAt || e.expiresAt >= now); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });
} return rows.some((e) => !e.expiresAt || e.expiresAt >= now);
}
// GET /api/messages — top-level threads with replies, annotated with author perks.
messages.get("/", async (c) => { // What we always include with a thread: its attachments + replies (+ their attachments).
const data = await prisma.message.findMany({ const THREAD_INCLUDE = {
where: { parentId: null }, attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
orderBy: { createdAt: "desc" }, replies: {
take: 50, orderBy: { createdAt: "asc" as const },
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" }, },
include: { } as const;
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>();
// Collect every distinct author IP (threads + replies) and resolve perks once. for (const m of threads) {
const ips = new Set<string>(); ips.add(m.authorIp);
for (const m of data) { for (const r of m.replies) ips.add(r.authorIp);
ips.add(m.authorIp); }
for (const r of m.replies) ips.add(r.authorIp); const [perks, geo] = await Promise.all([
} getPerksForIps([...ips]),
const perks = await getPerksForIps([...ips]); getGeoForIps([...ips]),
]);
const annotated = data.map((m) => ({ return threads.map((m) => ({
...m, ...m,
authorPerks: perks[m.authorIp] ?? {}, authorPerks: perks[m.authorIp] ?? {},
replies: m.replies.map((r) => ({ ...r, authorPerks: perks[r.authorIp] ?? {} })), authorGeo: geo[m.authorIp] ?? null,
})); replies: m.replies.map((r) => ({
...r,
return c.json(annotated); authorPerks: perks[r.authorIp] ?? {},
}); authorGeo: geo[r.authorIp] ?? null,
})),
// POST /api/messages — create a message or reply (optionally rich + attachments) }));
messages.post("/", async (c) => { }
const ip = getClientIp(c);
// GET /api/messages — top-level threads with replies, annotated with author perks.
const body = await c.req.json<{ // Optional query params (all backward-compatible — no params = the original feed):
content: string; // q : keyword search on content (case-insensitive)
parentId?: string; // before : cursor — only threads strictly older than this ISO date (pagination)
richMode?: "htmlcss" | "js"; // limit : page size (default 50, max 100)
richContent?: string; // Returns { items, nextCursor, hasMore }.
attachmentIds?: string[]; messages.get("/", async (c) => {
}>(); const q = c.req.query("q")?.trim();
const before = c.req.query("before");
if (!body.content || body.content.trim().length === 0) { const limit = Math.min(Math.max(Number(c.req.query("limit")) || 50, 1), 100);
return c.json({ error: "Content is required" }, 400);
} const where: any = { parentId: null };
if (body.content.length > 267) { if (q) where.content = { contains: q, mode: "insensitive" };
return c.json({ error: "Content exceeds 267 characters" }, 400); if (before) {
} const d = new Date(before);
if (!isNaN(d.getTime())) where.createdAt = { lt: d };
// Rich content: validate tier ownership + size. }
let richMode: "none" | "htmlcss" | "js" = "none";
let richContent: string | null = null; // Fetch one extra row to know whether there's a next page.
if (body.richMode && body.richContent && body.richContent.trim().length > 0) { const rows = await prisma.message.findMany({
if (body.richMode !== "htmlcss" && body.richMode !== "js") { where,
return c.json({ error: "richMode invalide" }, 400); orderBy: { createdAt: "desc" },
} take: limit + 1,
if (!(await ownsRich(ip, body.richMode))) { include: THREAD_INCLUDE,
return c.json({ error: "Fonctionnalité non débloquée" }, 402); });
}
if (body.richContent.length > RICH_MAX) { const hasMore = rows.length > limit;
return c.json({ error: "Contenu riche trop volumineux" }, 413); const page = hasMore ? rows.slice(0, limit) : rows;
} const items = await annotateThreads(page);
richMode = body.richMode; const nextCursor = hasMore ? page[page.length - 1]!.createdAt.toISOString() : null;
richContent = body.richContent;
} // Backward-compatible: with no query params, return the bare array the live
// chat feed (useMessages) already consumes. The explorer passes params and
const content = body.content.trim(); // gets the paginated envelope.
const parentId = body.parentId ?? null; const isLegacy = !q && !before && c.req.query("limit") === undefined;
return c.json(isLegacy ? items : { items, nextCursor, hasMore });
const message = await prisma.message.create({ });
data: { content, authorIp: ip, parentId, richMode, richContent },
}); // GET /api/messages/:id — a single top-level thread (with its replies), annotated.
messages.get("/:id", async (c) => {
// Link any pre-uploaded attachments owned by this IP to the new message. const id = c.req.param("id");
let attachments: any[] = []; const message = await prisma.message.findUnique({
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0) { where: { id },
await prisma.attachment.updateMany({ include: THREAD_INCLUDE,
where: { id: { in: body.attachmentIds }, ip, messageId: null }, });
data: { messageId: message.id }, if (!message || message.parentId !== null) {
}); return c.json({ error: "Message introuvable" }, 404);
attachments = await prisma.attachment.findMany({ }
where: { messageId: message.id }, const [annotated] = await annotateThreads([message]);
select: { id: true, filename: true, mimeType: true, size: true }, return c.json(annotated);
}); });
}
// POST /api/messages — create a message or reply (optionally rich + attachments)
// Update persistent stats and push the message to every connected tab, messages.post("/", async (c) => {
// annotated with the author's perks so it renders correctly everywhere. const ip = getClientIp(c);
void recordMessage(content.length, parentId !== null);
const authorPerks = await getPerksForIp(ip); const body = await c.req.json<{
const enriched = { ...message, attachments, authorPerks }; content?: string;
const payload = parentId === null ? { ...enriched, replies: [] } : enriched; parentId?: string;
broadcastNewMessage(payload); richMode?: "htmlcss" | "js";
richContent?: string;
return c.json(enriched, 201); attachmentIds?: string[];
}); }>();
export default messages; // A message is valid if it has ANY of: plain text, rich content, or attachments.
// (Rich-only and file-only messages are legitimate — no need for placeholder text.)
const hasContent = typeof body.content === "string" && body.content.trim().length > 0;
const hasRich =
!!body.richMode && !!body.richContent && body.richContent.trim().length > 0;
const hasAttachments =
Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0;
if (!hasContent && !hasRich && !hasAttachments) {
return c.json({ error: "Message vide" }, 400);
}
if (hasContent && body.content!.trim().length > 267) {
return c.json({ error: "Content exceeds 267 characters" }, 400);
}
// Rich content: validate tier ownership + size.
let richMode: "none" | "htmlcss" | "js" = "none";
let richContent: string | null = null;
if (body.richMode && body.richContent && body.richContent.trim().length > 0) {
if (body.richMode !== "htmlcss" && body.richMode !== "js") {
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,84 +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";
const shop = new Hono();
const shop = new Hono();
// GET /api/shop/products?category=cosmetiques
shop.get("/products", async (c) => { // GET /api/shop/products?category=cosmetiques
const category = c.req.query("category") || undefined; shop.get("/products", async (c) => {
return c.json(await listProducts(category)); const category = c.req.query("category") || undefined;
}); return c.json(await listProducts(category));
});
// GET /api/shop/products/:id
shop.get("/products/:id", async (c) => { // GET /api/shop/products/:id
const p = await getProduct(c.req.param("id")); shop.get("/products/:id", async (c) => {
if (!p) return c.json({ error: "Produit introuvable" }, 404); const p = await getProduct(c.req.param("id"));
return c.json(p); if (!p) return c.json({ error: "Produit introuvable" }, 404);
}); return c.json(p);
});
// GET /api/shop/me — my balance + owned entitlements
shop.get("/me", async (c) => { // GET /api/shop/me — my balance + owned entitlements
const ip = getClientIp(c); shop.get("/me", async (c) => {
const [wallet, entitlements] = await Promise.all([ const ip = getClientIp(c);
getWallet(ip), const [wallet, entitlements, myPerks] = await Promise.all([
getEntitlements(ip), getWallet(ip),
]); getEntitlements(ip),
return c.json({ wallet, entitlements }); getPerksForIp(ip),
}); ]);
return c.json({ wallet, entitlements, myPerks });
// POST /api/shop/purchase { productId, options } });
shop.post("/purchase", async (c) => {
const ip = getClientIp(c); // POST /api/shop/purchase { productId, options }
let body: { productId?: string; options?: PurchaseOptions } = {}; shop.post("/purchase", async (c) => {
try { const ip = getClientIp(c);
body = await c.req.json(); let body: { productId?: string; options?: PurchaseOptions } = {};
} catch { try {
return c.json({ error: "Corps JSON invalide" }, 400); body = await c.req.json();
} } catch {
if (!body.productId) return c.json({ error: "productId requis" }, 400); return c.json({ error: "Corps JSON invalide" }, 400);
}
try { if (!body.productId) return c.json({ error: "productId requis" }, 400);
const { result, visiblePerkChanged, adCreated } = await purchase(
ip, try {
body.productId, const { result, visiblePerkChanged, adCreated } = await purchase(
body.options ?? {} ip,
); body.productId,
body.options ?? {}
// Wallet update → only this IP's tabs. );
const wallet = await getWallet(ip);
broadcastToIp(ip, { type: "wallet", data: wallet }); // Wallet update → only this IP's tabs.
const wallet = await getWallet(ip);
// Perks: always tell the buyer; if a *visible* perk changed, tell everyone broadcastToIp(ip, { type: "wallet", data: wallet });
// so existing messages by this IP re-render with the skin/pet.
const perks = await refreshPerks(ip); // Perks: always tell the buyer; if a *visible* perk changed, tell everyone
if (visiblePerkChanged) { // so existing messages by this IP re-render with the skin/pet.
broadcast({ type: "perks", data: { ip, perks } }); const perks = await refreshPerks(ip);
} else { if (visiblePerkChanged) {
broadcastToIp(ip, { type: "perks", data: { ip, perks } }); broadcast({ type: "perks", data: { ip, perks } });
} } else {
broadcastToIp(ip, { type: "perks", data: { ip, perks } });
// New user ad entered rotation → nudge everyone to refetch ads. }
if (adCreated) broadcast({ type: "ads", data: { reason: "new-user-ad" } });
// New user ad entered rotation → nudge everyone to refetch ads.
return c.json(result, 201); if (adCreated) broadcast({ type: "ads", data: { reason: "new-user-ad" } });
} catch (e) {
if (e instanceof PurchaseError) { return c.json(result, 201);
return c.json({ error: e.message }, e.status as 400); } catch (e) {
} if (e instanceof PurchaseError) {
console.error("purchase error:", (e as Error).message); return c.json({ error: e.message }, e.status as 400);
return c.json({ error: "Achat impossible" }, 500); }
} console.error("purchase error:", (e as Error).message);
}); 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, isLocalhost } 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 (isLocalhost(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=="],
} }
} }

42
deploy/nginx.conf Normal file
View File

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

67
docker-compose.prod.yml Normal file
View File

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

17
frontend/Dockerfile Normal file
View File

@@ -0,0 +1,17 @@
# XIP frontend — Vue 3 + Vite, built to static assets and served by nginx.
# Build context is the repo ROOT (see docker-compose.prod.yml).
FROM oven/bun:1-debian AS build
WORKDIR /app
COPY frontend/package.json ./
RUN bun install
COPY frontend/ ./
# 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.
ARG VITE_API_URL=https://xip.kerboul.me
ENV VITE_API_URL=$VITE_API_URL
RUN bun run build
FROM nginx:1.27-alpine AS runtime
COPY deploy/nginx.conf /etc/nginx/conf.d/default.conf
COPY --from=build /app/dist /usr/share/nginx/html
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

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

@@ -0,0 +1,243 @@
<!--
Barre de composition : texte simple, éditeur riche (HTML/CSS · JS), pièces
jointes, alerte audio, bouton d'envoi. Possède son propre état ; lit les
composables partagés directement (pas de prop-drilling). La réponse en cours
est passée par prop `replyingTo` ; on émet `clear-reply` une fois le message parti.
-->
<template>
<div class="composer">
<!-- Éditeur riche (HTML/CSS ou JS) -->
<div v-if="richMode !== 'none'" class="rich-composer">
<div class="rich-head">
<span class="rich-badge" :class="`rich-badge--${richMode}`">
{{ richMode === 'js' ? ' JavaScript' : '🎨 HTML / CSS' }}
</span>
<button class="rich-close" @click="richMode = 'none'" type="button">✕ texte simple</button>
</div>
<textarea
v-model="richDraft"
class="rich-textarea"
:placeholder="richMode === 'js' ? '<script>document.body.style.background=&quot;lime&quot;<\/script>' : '<h1 style=&quot;color:#0ff&quot;>Salut</h1>'"
rows="4"
/>
</div>
<!-- Barre de saisie -->
<div class="input-bar">
<!-- Bouton mode riche (si débloqué) -->
<button
v-if="myPerks.richHtmlcss || myPerks.richJs"
class="icon-btn"
:title="richMenuTitle"
@click="cycleRichMode"
type="button"
>{{ richMode === 'js' ? '' : '🎨' }}</button>
<!-- Bouton pièce jointe -->
<button class="icon-btn" title="Joindre un fichier" @click="pickFile" type="button">📎</button>
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
<!-- Bouton alerte audio (si débloqué) -->
<button
v-if="myPerks.audioAlert"
class="icon-btn icon-btn--alert"
:title="alertMsg || 'Déclencher l\'alerte audio générale'"
@click="triggerAlert"
type="button"
>🔊</button>
<div v-show="richMode === 'none'" class="field-wrap">
<input
v-model="draft"
class="input-field"
type="text"
placeholder="Entrez un message..."
:maxlength="267"
@input="onInput"
@keydown.enter.exact.prevent="submit"
/>
<span class="char-counter" :class="{ warn: draft.length > 240 }">{{ draft.length }}/267</span>
</div>
<SendButton :disabled="!canSend || sending" @send="submit" />
</div>
<!-- Pièces jointes en attente -->
<div v-if="pendingFiles.length" class="pending-files">
<span v-for="f in pendingFiles" :key="f.id" class="pending-chip">
📎 {{ f.filename }} ({{ kb(f.size) }})
<button @click="removePending(f.id)" type="button">✕</button>
</span>
</div>
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
</div>
</template>
<script setup lang="ts">
import { ref, computed } from 'vue';
import SendButton from './SendButton.vue';
import { useMessages, useMyPerks } from '@/composables/useMessages';
import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert';
const props = defineProps<{ replyingTo: { id: string; authorIp: string } | null }>();
const emit = defineEmits<{ 'clear-reply': [] }>();
const { sending, postMessage, sendTyping } = useMessages();
const { myPerks } = useMyPerks();
const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert();
const draft = ref('');
// ── Alerte audio ──
const alertMsg = ref('');
async function triggerAlert(): Promise<void> {
const res = await fireAlert();
alertMsg.value = res.ok ? '' : res.error || '';
if (alertMsg.value) setTimeout(() => { alertMsg.value = ''; }, 3000);
}
// ── Mode riche ──
const richMode = ref<'none' | 'htmlcss' | 'js'>('none');
const richDraft = ref('');
const richMenuTitle = computed(() =>
myPerks.value.richJs ? 'Message riche : texte / HTML-CSS / JS' : 'Message riche : texte / HTML-CSS'
);
function cycleRichMode(): void {
// Cycle through the tiers the user owns.
if (richMode.value === 'none') richMode.value = myPerks.value.richHtmlcss ? 'htmlcss' : 'js';
else if (richMode.value === 'htmlcss') richMode.value = myPerks.value.richJs ? 'js' : 'none';
else richMode.value = 'none';
}
// ── Pièces jointes ──
const fileInput = ref<HTMLInputElement | null>(null);
const pendingFiles = ref<{ id: string; filename: string; size: number }[]>([]);
const uploadError = ref<string | null>(null);
function pickFile(): void {
uploadError.value = null;
fileInput.value?.click();
}
async function onFileSelected(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const res = await uploadFile(file);
if (res.ok) {
pendingFiles.value.push({ id: res.attachment.id, filename: res.attachment.filename, size: res.attachment.size });
} else {
uploadError.value = res.error;
}
}
function removePending(id: string): void {
pendingFiles.value = pendingFiles.value.filter((f) => f.id !== id);
}
// ── Frappe (stats) ──
let prevLen = 0;
function onInput(): void {
const len = draft.value.length;
const delta = len - prevLen;
prevLen = len;
sendTyping(delta > 0 ? delta : 0);
}
// ── Envoi ──
const canSend = computed(() =>
!!draft.value.trim() || (richMode.value !== 'none' && !!richDraft.value.trim()) || pendingFiles.value.length > 0
);
async function submit(): Promise<void> {
if (!canSend.value) return;
const ok = await postMessage(draft.value, {
parentId: props.replyingTo?.id,
richMode: richMode.value !== 'none' && richDraft.value.trim() ? richMode.value : undefined,
richContent: richMode.value !== 'none' && richDraft.value.trim() ? richDraft.value : undefined,
attachmentIds: pendingFiles.value.map((f) => f.id),
});
if (ok) {
draft.value = '';
richDraft.value = '';
richMode.value = 'none';
pendingFiles.value = [];
uploadError.value = null;
prevLen = 0;
emit('clear-reply');
}
}
</script>
<style scoped>
/* ── Éditeur riche ── */
.rich-composer {
flex-shrink: 0;
background: #0c0c16;
border-top: 1px solid #1a1a26;
padding: 8px 20px;
}
.rich-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.rich-badge { font-size: 11px; font-weight: bold; padding: 2px 8px; border-radius: 8px; }
.rich-badge--htmlcss { color: #00ddaa; background: #062019; }
.rich-badge--js { color: #ffcc44; background: #201a06; }
.rich-close { background: none; border: none; color: #557; cursor: pointer; font-size: 11px; }
.rich-close:hover { color: #aac; }
.rich-textarea {
width: 100%; box-sizing: border-box; resize: vertical;
background: #141420; border: 1px solid #222234; border-radius: 8px;
color: #aaccbb; font-family: 'Courier New', monospace; font-size: 12px; padding: 8px 10px; outline: none;
}
/* ── Barre de saisie ── */
.input-bar {
min-height: 70px;
flex-shrink: 0;
background: #0e0e16;
border-top: 1px solid #1a1a26;
display: flex;
align-items: center;
padding: 0 20px;
gap: 10px;
}
.icon-btn {
flex-shrink: 0;
width: 36px; height: 36px;
background: #141420; border: 1px solid #222234; border-radius: 50%;
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.icon-btn:hover { background: #1c1c2e; }
.icon-btn--alert { border-color: #aa3344; }
.icon-btn--alert:hover { background: #1e1218; }
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
.input-field {
flex: 1;
background: #141420;
border: 1px solid #222234;
border-radius: 23px;
padding: 12px 60px 12px 22px;
color: #aaaacc;
font-family: Arial, sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.input-field::placeholder { color: #2a2a44; }
.input-field:focus { border-color: #333355; }
.char-counter {
position: absolute; right: 16px;
font-family: 'Courier New', monospace; font-size: 10px; color: #33334d; pointer-events: none;
}
.char-counter.warn { color: #ff8844; }
/* ── Pièces jointes en attente ── */
.pending-files { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px; padding: 8px 20px 10px; }
.pending-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 12px;
padding: 4px 10px; font-size: 11px; color: #aaccbb; font-family: Arial, sans-serif;
}
.pending-chip button { background: none; border: none; color: #66f; cursor: pointer; }
.upload-error { flex-shrink: 0; padding: 0 20px 10px; color: #ff7788; font-size: 11px; font-family: Arial, sans-serif; }
</style>

View File

@@ -1,132 +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">
<span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span> <ThemePicker v-model="theme" />
<span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP"> <span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span>
<span class="balance-coin"></span> <span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP">
<span class="balance-val">{{ displayBalance() }}</span> <span class="balance-coin"></span>
<span class="balance-unit">cr</span> <span class="balance-val">{{ displayBalance() }}</span>
</span> <span class="balance-unit">cr</span>
<router-link to="/shop" class="shop-link">🛒 Shop</router-link> </span>
<span class="channel-badge"># général</span> <router-link to="/shop" class="shop-link">🛒 Shop</router-link>
</div> <span class="channel-badge"># général</span>
</header> </div>
</template> </header>
</template>
<script setup lang="ts">
import { useWallet } from '@/composables/useWallet'; <script setup lang="ts">
import { useWallet } from '@/composables/useWallet';
defineProps<{ connectedCount: number }>(); import { useTheme } from '@/composables/useTheme';
import ThemePicker from './ThemePicker.vue';
const { ip, freeMode, displayBalance } = useWallet();
</script> defineProps<{ connectedCount: number }>();
<style scoped> const { ip, freeMode, displayBalance } = useWallet();
.chat-header { const { theme } = useTheme();
height: 52px; </script>
flex-shrink: 0;
background: #0e0e16; <style scoped>
border-bottom: 1px solid #1a1a2a; .chat-header {
display: flex; height: 52px;
align-items: center; flex-shrink: 0;
justify-content: space-between; background: var(--xip-header-bg);
padding: 0 16px 0 20px; border-bottom: 1px solid var(--xip-header-border);
} display: flex;
align-items: center;
.header-left { justify-content: space-between;
display: flex; padding: 0 16px 0 20px;
align-items: center; }
gap: 8px;
} .header-left {
display: flex;
.header-right { align-items: center;
display: flex; gap: 8px;
align-items: center; }
gap: 12px;
} .header-right {
display: flex;
.xip-title { align-items: center;
font-family: Arial, sans-serif; gap: 12px;
font-size: 18px; }
font-weight: bold;
color: #00eeff; .xip-title {
text-shadow: 0 0 10px #00ccff99; font-family: Arial, sans-serif;
} font-size: 18px;
font-weight: bold;
.chat-label { color: #7ab8cc;
font-family: Arial, sans-serif; }
font-size: 14px;
font-weight: bold; .chat-label {
color: #aaaacc; font-family: Arial, sans-serif;
} font-size: 14px;
font-weight: bold;
.online-dot { color: #aaaacc;
width: 8px; }
height: 8px;
border-radius: 50%; .online-dot {
background: #00ff88; width: 8px;
box-shadow: 0 0 6px #00ff44; height: 8px;
} border-radius: 50%;
background: #44aa66;
.online-count { }
font-family: Arial, sans-serif;
font-size: 11px; .online-count {
color: #33ff66; font-family: Arial, sans-serif;
} font-size: 11px;
color: #557766;
.me-ip { }
font-family: 'Courier New', monospace;
font-size: 11px; .me-ip {
color: #5566aa; font-family: 'Courier New', monospace;
} font-size: 11px;
color: #5566aa;
.balance { }
display: inline-flex;
align-items: baseline; .balance {
gap: 4px; display: inline-flex;
background: #131322; align-items: baseline;
border: 1px solid #2a2a44; gap: 4px;
border-radius: 12px; background: #131322;
padding: 3px 10px; border: 1px solid #2a2a44;
font-family: 'Courier New', monospace; border-radius: 12px;
} padding: 3px 10px;
.balance-coin { color: #ffcc44; font-size: 11px; } font-family: 'Courier New', monospace;
.balance-val { color: #ffdd66; font-size: 13px; font-weight: bold; text-shadow: 0 0 8px #ffaa0055; } }
.balance-unit { color: #886633; font-size: 9px; } .balance-coin { color: #aa8833; font-size: 11px; }
.balance--free .balance-val { color: #33ff99; text-shadow: 0 0 8px #00ff6655; } .balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; }
.balance--free .balance-coin { color: #33ff99; } .balance-unit { color: #886633; font-size: 9px; }
.balance--free .balance-val { color: #44aa77; }
.shop-link { .balance--free .balance-coin { color: #44aa77; }
font-family: Arial, sans-serif;
font-size: 12px; .shop-link {
font-weight: bold; font-family: Arial, sans-serif;
color: #00eeff; font-size: 12px;
text-decoration: none; font-weight: bold;
border: 1px solid #00eeff55; color: #6699aa;
border-radius: 12px; text-decoration: none;
padding: 4px 12px; border: 1px solid #33445566;
transition: background 0.15s, box-shadow 0.15s; border-radius: 12px;
} padding: 4px 12px;
.shop-link:hover { transition: background 0.15s;
background: #00eeff14; }
box-shadow: 0 0 10px #00ccff44; .shop-link:hover {
} background: #1a2530;
}
.channel-badge {
background: #131320; .channel-badge {
border: 1px solid #222233; background: #131320;
border-radius: 12px; border: 1px solid #222233;
padding: 4px 14px; border-radius: 12px;
font-family: Arial, sans-serif; padding: 4px 14px;
font-size: 10px; font-family: Arial, sans-serif;
color: #5555aa; font-size: 10px;
} 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,154 +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: 0 0 18px #ff220055; 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;
text-shadow: 0 0 8px #ff2200;
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;
text-shadow: 0 0 14px #99660099;
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;
text-shadow: 0 0 10px #ffdd00;
} }
/* ── 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;
text-shadow: 0 0 6px #ff2200; transition: background 0.15s;
box-shadow: 0 0 8px #ff220044; }
transition: box-shadow 0.15s;
} .casino-cta:hover {
.casino-cta:hover { }
box-shadow: 0 0 16px #ff220088;
} .disclaimer {
font-family: Arial, sans-serif;
.disclaimer { font-size: 7px;
font-family: Arial, sans-serif; color: #440000;
font-size: 7px; margin-top: 8px;
color: #440000; }
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,170 +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"> <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 class="ts">{{ fmt(message.createdAt) }}</span> <span v-if="message.authorGeo && geoLabel(message.authorGeo)" class="geo-tag">
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button> <a :href="geoLink(message.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
</div> <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>
<!-- Contenu : riche (iframe sandbox) ou texte simple --> {{ geoLabel(message.authorGeo) }}
<RichContent </a>
v-if="message.richMode && message.richMode !== 'none' && message.richContent" </span>
:mode="message.richMode" <span class="ts">{{ fmt(message.createdAt) }}</span>
:content="message.richContent" <button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
/> <FavButton :message="message" />
<p v-else class="message-body">{{ message.content }}</p> </div>
<!-- Pièces jointes --> <!-- Contenu : riche (iframe sandbox) ou texte simple -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" /> <RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
<!-- Réponses --> :mode="message.richMode"
<div :content="message.richContent"
v-for="reply in message.replies" />
:key="reply.id" <p v-else class="message-body">{{ message.content }}</p>
class="reply"
> <!-- Pièces jointes -->
<span class="ip-wrap"> <MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span> <!-- Réponses -->
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span> <div
</span> v-for="reply in message.replies"
<span class="ts">{{ fmt(reply.createdAt) }}</span> :key="reply.id"
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button> class="reply"
<RichContent >
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent" <span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
:mode="reply.richMode" <span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
:content="reply.richContent" <span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
/> <span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
<p v-else class="message-body reply-body">{{ reply.content }}</p> </span>
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" /> <span v-if="reply.authorGeo && geoLabel(reply.authorGeo)" class="geo-tag geo-tag--sm">
</div> <a :href="geoLink(reply.authorGeo)" target="_blank" rel="noopener noreferrer" class="geo-link">
<img v-if="reply.authorGeo.countryCode" :src="`https://flagcdn.com/16x12/${reply.authorGeo.countryCode.toLowerCase()}.png`" :alt="reply.authorGeo.countryCode" class="geo-flag" />
<div class="divider" /> <span v-else>🏠</span>
</div> {{ geoLabel(reply.authorGeo) }}
</template> </a>
</span>
<script setup lang="ts"> <span class="ts">{{ fmt(reply.createdAt) }}</span>
import type { Message, Reply } from '@/composables/useMessages'; <button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button>
import { getIpColorWithPerks, getIpGlowWithPerks } from '@/composables/ipColor'; <RichContent
import { usePerks } from '@/composables/usePerks'; v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
import RichContent from './RichContent.vue'; :mode="reply.richMode"
import MessageAttachments from './MessageAttachments.vue'; :content="reply.richContent"
/>
defineProps<{ message: Message }>(); <p v-else class="message-body reply-body">{{ reply.content }}</p>
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); <MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div>
const { perksFor } = usePerks();
<div class="divider" />
/** Perks for an author: prefer the perks embedded in the payload, else the store. */ </div>
function perksOf(m: Reply): any { </template>
return m.authorPerks ?? perksFor(m.authorIp);
} <script setup lang="ts">
import type { Message } from '@/composables/useMessages';
function ipStyle(m: Reply) { import { openContextMenu } from '@/composables/useContextMenu';
const p = perksOf(m); import { IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
return { import { useMessageItem } from '@/composables/useMessageItem';
color: getIpColorWithPerks(m.authorIp, p), import RichContent from './RichContent.vue';
textShadow: getIpGlowWithPerks(m.authorIp, p), import MessageAttachments from './MessageAttachments.vue';
}; import FavButton from './FavButton.vue';
}
const props = defineProps<{ message: Message; myIp?: string }>();
function petsLeft(m: Reply): string { defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const pets = perksOf(m)?.pets ?? [];
return pets const { ipStyle, petsLeft, petsRight, fmt, geoLabel, geoLink, myPerks, prefs } = useMessageItem();
.filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char) function openIpMenu(e: MouseEvent, ip: string): void {
.join(''); if (ip !== props.myIp) return;
}
function petsRight(m: Reply): string { const hasElementSkin = !!myPerks.value.elementSkin;
const pets = perksOf(m)?.pets ?? []; const ownedPets = myPerks.value.pets ?? [];
return pets const hasPets = ownedPets.length > 0;
.filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char) // Nothing to show if no perk unlocks customization.
.join(''); if (!hasElementSkin && !hasPets) return;
}
const currentColor = prefs.ipColors[ip] ?? 'auto';
function fmt(date: string): string { const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
} const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
</script>
if (hasElementSkin) {
<style scoped> items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
.message-item { items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
padding: 4px 0; }
}
if (hasPets) {
.message-meta { items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
display: flex; items.push({ value: 'pet:__inherit__', label: ' défaut' });
align-items: baseline; // Show only the pets the user actually owns.
gap: 8px; const seen = new Set<string>();
padding: 0 25px; for (const p of ownedPets) {
} if (!seen.has(p.char)) {
seen.add(p.char);
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; } items.push({ value: `pet:${p.char}`, label: p.char });
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); } }
.pet--sm { font-size: 11px; } }
.vip-badge { }
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px; openContextMenu({
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px; x: e.clientX,
} y: e.clientY,
title: ip,
.ip { items,
font-family: 'Courier New', monospace; current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
font-size: 12px; onSelect: (v) => {
font-weight: bold; if (v.startsWith('color:')) {
} prefs.ipColors[ip] = v.slice(6);
} else if (v.startsWith('pet:')) {
.ts { const pet = v.slice(4);
font-family: 'Courier New', monospace; if (pet === '__inherit__') {
font-size: 10px; delete prefs.ipPets[ip];
color: #303030; } else {
} prefs.ipPets[ip] = pet;
}
.reply-btn { }
background: none; border: none; cursor: pointer; },
font-family: Arial, sans-serif; font-size: 10px; color: #33335a; });
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s; }
}
.message-item:hover .reply-btn, </script>
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; } <style scoped>
.message-item {
.message-body { padding: 4px 0;
font-family: Arial, sans-serif; }
font-size: 13px;
color: #c0c0c0; .message-meta {
padding: 3px 25px 0; display: flex;
margin: 0; align-items: baseline;
word-break: break-word; gap: 8px;
} padding: 0 25px;
}
.divider {
height: 1px; .ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
background: #141420; .pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
margin: 8px 25px 0; .pet--sm { font-size: 11px; }
} .vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
/* ── Réponses ── */ color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
.reply { padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
margin: 6px 25px 0 45px; }
border-left: 2px solid #1a1a2a;
padding-left: 10px; .ip {
} font-family: 'Courier New', monospace;
font-size: 12px;
.reply-ip { font-weight: bold;
font-size: 11px; }
}
.ts {
.reply-body { font-family: 'Courier New', monospace;
font-size: 12px; font-size: 10px;
padding-left: 0; color: #303030;
} }
</style>
.reply-btn {
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
}
.message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; }
.message-body {
font-family: Arial, sans-serif;
font-size: 13px;
color: #c0c0c0;
padding: 3px 25px 0;
margin: 0;
word-break: break-word;
}
.divider {
height: 1px;
background: #141420;
margin: 8px 25px 0;
}
/* ── Réponses ── */
.reply {
margin: 6px 25px 0 45px;
border-left: 2px solid #1a1a2a;
padding-left: 10px;
}
.reply-ip {
font-size: 11px;
}
.reply-body {
font-size: 12px;
padding-left: 0;
}
.geo-tag {
font-family: Arial, sans-serif;
font-size: 10px;
color: #44445a;
white-space: nowrap;
}
.geo-tag--sm { font-size: 9px; }
.geo-link {
color: inherit;
text-decoration: none;
opacity: 0.7;
display: inline-flex;
align-items: center;
gap: 4px;
transition: opacity 0.12s, color 0.12s;
}
.geo-link:hover {
color: #5588cc;
opacity: 1;
text-decoration: underline;
}
.geo-flag {
width: 16px;
height: 12px;
object-fit: cover;
border-radius: 2px;
vertical-align: middle;
flex-shrink: 0;
}
</style>

View File

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

@@ -0,0 +1,97 @@
<!-- Variante "compact" du message une ligne dense (IP + contenu inline) -->
<template>
<div class="compact-item">
<div class="compact-line">
<span class="compact-ip" :style="ipStyle(message)">
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span>{{ message.authorIp }}<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span>
</span>
<img
v-if="message.authorGeo?.countryCode"
:src="`https://flagcdn.com/16x12/${message.authorGeo.countryCode.toLowerCase()}.png`"
:alt="message.authorGeo.countryCode"
class="compact-flag"
/>
<RichContent
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<span v-else class="compact-body">{{ message.content }}</span>
<span class="compact-ts">{{ fmt(message.createdAt) }}</span>
<button
class="compact-reply-btn"
type="button"
@click="$emit('reply', { id: message.id, authorIp: message.authorIp })"
></button>
<FavButton :message="message" />
</div>
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses, inline et indentées -->
<div
v-for="reply in message.replies"
:key="reply.id"
class="compact-line compact-line--reply"
>
<span class="compact-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<span v-else class="compact-body">{{ reply.content }}</span>
<span class="compact-ts">{{ fmt(reply.createdAt) }}</span>
</div>
</div>
</template>
<script setup lang="ts">
import type { Message } from '@/composables/useMessages';
import { useMessageItem } from '@/composables/useMessageItem';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
import FavButton from './FavButton.vue';
defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { ipStyle, petsLeft, petsRight, fmt } = useMessageItem();
</script>
<style scoped>
.compact-item {
padding: 1px 14px;
border-bottom: 1px solid #0e0e18;
}
.compact-line {
display: flex;
align-items: baseline;
gap: 8px;
font-family: 'Courier New', monospace;
font-size: 12px;
line-height: 1.7;
}
.compact-line--reply { padding-left: 24px; opacity: 0.85; }
.compact-ip { font-weight: bold; flex-shrink: 0; }
.pet { font-size: 11px; }
.compact-flag { width: 14px; height: 10px; object-fit: cover; border-radius: 2px; flex-shrink: 0; }
.compact-body {
font-family: 'Lato', Arial, sans-serif;
color: #c0c0c0;
flex: 1;
min-width: 0;
word-break: break-word;
}
.compact-ts { color: #303040; font-size: 10px; flex-shrink: 0; }
.compact-reply-btn {
background: none; border: none; cursor: pointer;
font-size: 11px; color: #33335a; padding: 0; flex-shrink: 0;
opacity: 0; transition: opacity 0.12s;
}
.compact-item:hover .compact-reply-btn { opacity: 1; }
.compact-reply-btn:hover { color: var(--xip-accent); }
</style>

View File

@@ -1,81 +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">
<!-- Messages --> <div ref="listEl" class="feed-scroll">
<div ref="listEl" class="feed-scroll"> <TransitionGroup name="msg" tag="div">
<MessageItem <component
v-for="msg in messages" :is="messageComponent"
:key="msg.id" v-for="msg in messages"
:message="msg" :key="msg.id"
@reply="$emit('reply', $event)" :message="msg"
/> :my-ip="myIp"
<div v-if="messages.length === 0" class="feed-empty"> @reply="$emit('reply', $event)"
Aucun message pour l'instant. />
</div> </TransitionGroup>
</div> <div v-if="messages.length === 0" class="feed-empty">
Aucun message pour l'instant.
<!-- Pub casino : overlay absolu sur la droite du feed (masqué si NoAds) --> </div>
<InlineCasinoAd v-if="!hideAds" class="casino-overlay" /> </div>
</div>
</template> <!-- Pub casino : overlay absolu sur la droite du feed (masqué si NoAds) -->
<InlineCasinoAd v-if="!hideAds" class="casino-overlay" />
<script setup lang="ts"> </div>
import { ref, watch, nextTick } from 'vue'; </template>
import type { Message } from '@/composables/useMessages';
import MessageItem from './MessageItem.vue'; <script setup lang="ts">
import InlineCasinoAd from './InlineCasinoAd.vue'; import { ref, computed, watch, nextTick } from 'vue';
import type { Message } from '@/composables/useMessages';
const props = defineProps<{ messages: Message[]; hideAds?: boolean }>(); import { useTheme, THEME_LAYOUT, type Layout } from '@/composables/useTheme';
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); import MessageItem from './MessageItem.vue';
import MessageItemBubble from './MessageItemBubble.vue';
const listEl = ref<HTMLElement | null>(null); import MessageItemCompact from './MessageItemCompact.vue';
import InlineCasinoAd from './InlineCasinoAd.vue';
// Auto-scroll vers le bas à chaque nouveau message
watch( const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
() => props.messages.length, defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
async () => {
await nextTick(); const { theme } = useTheme();
if (listEl.value) {
listEl.value.scrollTop = listEl.value.scrollHeight; // One component per layout family. The `?? MessageItem` fallback guarantees a
} // missing/unknown layout can never produce `<component :is="undefined">`.
}, const LAYOUT_COMPONENT: Record<Layout, typeof MessageItem> = {
); classic: MessageItem,
</script> bubble: MessageItemBubble,
compact: MessageItemCompact,
<style scoped> };
.feed-wrapper { const messageComponent = computed(
flex: 1; () => LAYOUT_COMPONENT[THEME_LAYOUT[theme.value]] ?? MessageItem,
position: relative; );
overflow: hidden;
min-height: 0; const listEl = ref<HTMLElement | null>(null);
}
watch(
.feed-scroll { () => props.messages.length,
height: 100%; async () => {
overflow-y: auto; await nextTick();
padding: 8px 0; listEl.value?.scrollTo({ top: listEl.value.scrollHeight, behavior: 'smooth' });
scrollbar-width: thin; },
scrollbar-color: #252535 #080810; );
} </script>
.feed-scroll::-webkit-scrollbar { width: 8px; }
.feed-scroll::-webkit-scrollbar-track { background: #080810; } <style scoped>
.feed-scroll::-webkit-scrollbar-thumb { background: #252535; border-radius: 3px; } .feed-wrapper {
flex: 1;
.feed-empty { position: relative;
padding: 48px 25px; overflow: hidden;
color: #2a2a44; min-height: 0;
font-family: Arial, sans-serif; }
font-size: 13px;
} .feed-scroll {
height: 100%;
/* Positionné en absolu sur la droite du wrapper */ overflow-y: auto;
.casino-overlay { padding: 8px 0;
position: absolute; scrollbar-width: thin;
right: 30px; scrollbar-color: #252535 #080810;
top: 20px; }
pointer-events: none; .feed-scroll::-webkit-scrollbar { width: 8px; }
} .feed-scroll::-webkit-scrollbar-track { background: #080810; }
.casino-overlay :deep(.casino-cta) { .feed-scroll::-webkit-scrollbar-thumb { background: #252535; border-radius: 3px; }
pointer-events: all;
} .feed-empty {
</style> padding: 48px 25px;
color: #2a2a44;
font-family: Arial, sans-serif;
font-size: 13px;
}
.casino-overlay {
position: absolute;
right: 30px;
top: 20px;
pointer-events: none;
}
.casino-overlay :deep(.casino-cta) { pointer-events: all; }
/* Transition d'entrée des nouveaux messages */
.msg-enter-active { transition: opacity 0.2s ease, transform 0.2s ease; }
.msg-enter-from { opacity: 0; transform: translateY(6px); }
</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,85 +1,108 @@
<!-- <!--
Rich message renderer SECURITY CRITICAL. Rich message renderer.
Renders paid HTML/CSS or JS messages inside a FIXED-SIZE sandboxed iframe. Sandbox policy:
- htmlcss: sandbox="" (empty) + meta CSP scripts totalement inertes
Sandbox policy (never deviate): - js: sandbox avec tous les tokens SAUF allow-same-origin
- htmlcss tier: sandbox="" (empty) scripts are INERT (honours README "pas de script"). scripts libres, fetch vers l'extérieur OK, accès parent impossible
- js tier: sandbox="allow-scripts" ONLY script runs in a NULL origin and (null origin = isolation réelle sans allow-same-origin)
cannot touch the parent (no allow-same-origin, ever). -->
<template>
We NEVER combine allow-scripts with allow-same-origin (that would re-grant parent <div class="rich-frame-wrap">
access and defeat isolation). A runtime assertion below guards against it. <span class="rich-tag" :class="`rich-tag--${mode}`">
--> {{ mode === 'js' ? ' JS' : '🎨 HTML/CSS' }} · bac à sable
<template> </span>
<div class="rich-frame-wrap"> <iframe
<span class="rich-tag" :class="`rich-tag--${mode}`"> ref="frameRef"
{{ mode === 'js' ? '⚡ JS' : '🎨 HTML/CSS' }} · bac à sable class="rich-frame"
</span> :sandbox="sandboxTokens"
<iframe :srcdoc="srcdoc"
class="rich-frame" referrerpolicy="no-referrer"
:sandbox="sandboxTokens" loading="lazy"
:srcdoc="srcdoc" title="Message riche (isolé)"
referrerpolicy="no-referrer" />
loading="lazy" </div>
title="Message riche (isolé)" </template>
/>
</div> <script setup lang="ts">
</template> import { computed, useTemplateRef, watchEffect } from 'vue';
<script setup lang="ts"> const props = defineProps<{ mode: 'htmlcss' | 'js'; content: string }>();
import { computed } from 'vue';
const frameRef = useTemplateRef<HTMLIFrameElement>('frameRef');
const props = defineProps<{ mode: 'htmlcss' | 'js'; content: string }>();
// htmlcss → aucun script ; js → tout permis sauf accès au parent (pas de allow-same-origin)
// htmlcss → no scripts at all; js → scripts only, NEVER same-origin. const sandboxTokens = computed(() =>
const sandboxTokens = computed(() => (props.mode === 'js' ? 'allow-scripts' : '')); props.mode === 'js'
? 'allow-scripts allow-forms allow-modals allow-downloads allow-popups allow-presentation allow-pointer-lock'
// Defense-in-depth assertion: the iframe must never get allow-same-origin alongside scripts. : ''
if (import.meta.env.DEV) { );
const t = props.mode === 'js' ? 'allow-scripts' : '';
if (t.includes('allow-same-origin') && t.includes('allow-scripts')) { // Garde de sécurité réactive — allow-scripts + allow-same-origin = catastrophe
throw new Error('SECURITY: rich iframe must never combine allow-scripts + allow-same-origin'); watchEffect(() => {
} const tokens = sandboxTokens.value;
} if (tokens.includes('allow-scripts') && tokens.includes('allow-same-origin')) {
throw new Error('SECURITY: rich iframe must never combine allow-scripts + allow-same-origin');
const srcdoc = computed(() => { }
// In-document CSP as a second layer (the sandbox is the primary boundary). });
const csp =
props.mode === 'js' const srcdoc = computed(() => {
? "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;" // htmlcss : meta CSP en second couche (le sandbox="" bloque déjà les scripts)
: "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;"; // js : pas de meta CSP — le sandbox null-origin est la vraie frontière
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><style>html,body{margin:0;padding:8px;color:#ddd;font-family:Arial,sans-serif;background:#0a0a12;overflow:auto;height:100%;box-sizing:border-box}</style></head><body>${props.content}</body></html>`; const metaCsp = props.mode === 'htmlcss'
}); ? `<meta http-equiv="Content-Security-Policy"
</script> content="default-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;">`
: '';
<style scoped>
.rich-frame-wrap { return `<!doctype html>
position: relative; <html>
margin: 6px 25px 0; <head>
} <meta charset="utf-8">
.rich-tag { ${metaCsp}
position: absolute; <style>
top: -7px; html, body {
left: 8px; margin: 0;
z-index: 1; padding: 8px;
font-family: Arial, sans-serif; color: #ddd;
font-size: 8px; font-family: Arial, sans-serif;
font-weight: bold; background: #0a0a12;
padding: 1px 6px; overflow: auto;
border-radius: 6px; height: 100%;
} box-sizing: border-box;
.rich-tag--htmlcss { color: #00ddaa; background: #062019; border: 1px solid #0a4435; } }
.rich-tag--js { color: #ffcc44; background: #201a06; border: 1px solid #443a0a; } </style>
</head>
/* Fixed size per README ("taille fixe") — contains any layout-breaking CSS. */ <body>${props.content}</body>
.rich-frame { </html>`;
width: 480px; });
max-width: 100%; </script>
height: 270px;
border: 1px solid #222234; <style scoped>
border-radius: 8px; .rich-frame-wrap {
background: #0a0a12; position: relative;
display: block; margin: 6px 25px 0;
} }
</style> .rich-tag {
position: absolute;
top: -7px;
left: 8px;
z-index: 1;
font-family: Arial, sans-serif;
font-size: 8px;
font-weight: bold;
padding: 1px 6px;
border-radius: 6px;
}
.rich-tag--htmlcss { color: #00ddaa; background: #062019; border: 1px solid #0a4435; }
.rich-tag--js { color: #ffcc44; background: #201a06; border: 1px solid #443a0a; }
.rich-frame {
width: 480px;
max-width: 100%;
height: 270px;
border: 1px solid #222234;
border-radius: 8px;
background: #0a0a12;
display: block;
}
</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,51 +1,97 @@
<!-- Bouton d'envoi circulaire avec flèche cyan --> <!-- 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"
aria-label="Envoyer" :style="btnStyle"
@click="$emit('send')" aria-label="Envoyer"
> title="Clic droit pour personnaliser"
<!-- Flèche droite SVG (identique au SVG de la maquette) --> @click="$emit('send')"
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> @contextmenu.prevent="onRightClick"
<polygon points="4,5 15,9 4,13 7,9" fill="currentColor" /> >
</svg> <span v-if="activeSkinChar" class="skin-char">{{ activeSkinChar }}</span>
</button> <svg v-else width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
</template> <polygon points="4,5 15,9 4,13 7,9" fill="currentColor" />
</svg>
<script setup lang="ts"> </button>
defineProps<{ disabled?: boolean }>(); </template>
defineEmits<{ send: [] }>();
</script> <script setup lang="ts">
import { computed } from 'vue';
<style scoped> import { openContextMenu } from '@/composables/useContextMenu';
.send-btn { import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
width: 42px; import { useMyPerks } from '@/composables/useMessages';
height: 42px;
border-radius: 50%; defineProps<{ disabled?: boolean }>();
flex-shrink: 0; defineEmits<{ send: [] }>();
background: #004488;
border: 1px solid #004466; const { prefs } = useCustomStyles();
color: #00ddff; const { myPerks } = useMyPerks();
cursor: pointer;
display: flex; const activeSkinChar = computed(() => {
align-items: center; const skinId = prefs.sendSkin;
justify-content: center; if (!skinId) return null;
box-shadow: 0 0 12px #00448866; return myPerks.value.sendSkins?.find((s) => s.id === skinId)?.char ?? null;
transition: background 0.15s, box-shadow 0.15s; });
}
const btnStyle = computed(() => {
.send-btn:hover:not(:disabled) { // On the default preset, defer to the theme's CSS variables (so e.g. the
background: #005599; // WhatsApp theme tints the button green). A chosen preset overrides the theme.
box-shadow: 0 0 20px #00ddff55; if (prefs.sendButton === 'default') return {};
} const p = SEND_BUTTON_PRESETS[prefs.sendButton];
return { background: p.bg, color: p.color, borderRadius: p.radius };
.send-btn:active:not(:disabled) { });
background: #003377;
} function onRightClick(e: MouseEvent): void {
const skins = myPerks.value.sendSkins ?? [];
.send-btn:disabled { const items: import('@/composables/useContextMenu').ContextMenuItem[] = [
opacity: 0.35; ...Object.entries(SEND_BUTTON_PRESETS).map(([k, v]) => ({
cursor: not-allowed; value: k,
} label: v.label,
</style> swatch: v.color,
checked: prefs.sendButton === k,
})),
];
if (skins.length > 0) {
items.push({ value: '__skin_header__', label: 'Skin', isHeader: true });
items.push({ value: '__default_skin__', label: 'Défaut', emoji: '▶', checked: prefs.sendSkin === '' });
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 });
}
}
openContextMenu({
x: e.clientX,
y: e.clientY,
title: 'Bouton d\'envoi',
items,
current: '',
onSelect: (v) => {
if (v === '__default_skin__') { prefs.sendSkin = ''; }
else if (v.startsWith('send-skin-')) { prefs.sendSkin = v; }
else { prefs.sendButton = v as typeof prefs.sendButton; }
},
});
}
</script>
<style scoped>
.send-btn {
width: 42px;
height: 42px;
flex-shrink: 0;
border: 1px solid #ffffff10;
border-radius: 50%;
/* Defaults from the theme palette; a chosen preset overrides via inline style. */
background: var(--xip-send-bg);
color: var(--xip-send-fg);
cursor: pointer;
display: flex;
align-items: center;
justify-content: center;
transition: filter 0.15s;
}
.send-btn:hover:not(:disabled) { filter: brightness(1.3); }
.send-btn:active:not(:disabled) { filter: brightness(0.85); }
.send-btn:disabled { opacity: 0.35; cursor: not-allowed; }
.skin-char { font-size: 18px; line-height: 1; }
</style>

View File

@@ -1,220 +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 #00eeff33; border-bottom: 1px solid #1a1a2a;
box-shadow: inset 0 -1px 0 #00eeff14, 0 2px 14px #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 #00eeff33; border-right: 1px solid #1a1a2a;
box-shadow: 6px 0 12px #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: #00ff88; background: #44996655;
box-shadow: 0 0 8px #00ff66; 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: #00ff88; }
text-shadow: 0 0 8px #00ff4466; .ticker.is-off .ticker-dot {
} background: #884444;
.ticker.is-off .ticker-dot { animation: none;
background: #ff3344; }
box-shadow: 0 0 8px #ff2233; .ticker.is-off .ticker-badge-txt {
animation: none; color: #885555;
} }
.ticker.is-off .ticker-badge-txt { @keyframes blink {
color: #ff5566; 0%, 100% { opacity: 1; }
text-shadow: none; 50% { opacity: 0.3; }
} }
@keyframes blink {
0%, 100% { opacity: 1; } /* ── Piste défilante ── */
50% { opacity: 0.3; } .ticker-viewport {
} flex: 1;
min-width: 0;
/* ── Piste défilante ── */ overflow: hidden;
.ticker-viewport { display: flex;
flex: 1; align-items: center;
min-width: 0; }
overflow: hidden; .ticker-track {
display: flex; display: inline-flex;
align-items: center; white-space: nowrap;
} will-change: transform;
.ticker-track { animation: ticker-scroll 48s linear infinite;
display: inline-flex; }
white-space: nowrap; .ticker:hover .ticker-track {
will-change: transform; animation-play-state: paused;
animation: ticker-scroll 48s linear infinite; }
} .ticker-group {
.ticker:hover .ticker-track { display: inline-flex;
animation-play-state: paused; align-items: center;
} }
.ticker-group { @keyframes ticker-scroll {
display: inline-flex; from { transform: translateX(0); }
align-items: center; to { transform: translateX(-50%); }
} }
@keyframes ticker-scroll {
from { transform: translateX(0); } /* ── Chips ── */
to { transform: translateX(-50%); } .chip {
} position: relative;
display: inline-flex;
/* ── Chips ── */ align-items: baseline;
.chip { gap: 7px;
position: relative; padding: 0 22px;
display: inline-flex; }
align-items: baseline; .chip::after {
gap: 7px; content: '';
padding: 0 22px; position: absolute;
} right: 0;
.chip::after { top: 50%;
content: ''; transform: translateY(-50%);
position: absolute; height: 16px;
right: 0; width: 1px;
top: 50%; background: #ffffff14;
transform: translateY(-50%); }
height: 16px; .chip-val {
width: 1px; font-family: 'Courier New', monospace;
background: #ffffff14; font-size: 15px;
} font-weight: bold;
.chip-val { color: #d8d8e8;
font-family: 'Courier New', monospace; }
font-size: 15px; .chip-unit {
font-weight: bold; font-size: 10px;
color: #d8d8e8; font-weight: normal;
} opacity: 0.6;
.chip-unit { }
font-size: 10px; .chip-label {
font-weight: normal; font-family: Arial, sans-serif;
opacity: 0.6; font-size: 10px;
} letter-spacing: 0.5px;
.chip-label { text-transform: uppercase;
font-family: Arial, sans-serif; color: #50506e;
font-size: 10px; }
letter-spacing: 0.5px;
text-transform: uppercase; .chip--cyan .chip-val { color: #5599aa; }
color: #50506e; .chip--green .chip-val { color: #447755; }
} .chip--magenta .chip-val { color: #885588; }
.chip--orange .chip-val { color: #997744; }
.chip--cyan .chip-val { color: #00eeff; text-shadow: 0 0 9px #00ccff55; }
.chip--green .chip-val { color: #33ff77; text-shadow: 0 0 9px #00ff4455; } /* Accessibilité : pas de défilement si l'utilisateur le refuse */
.chip--magenta .chip-val { color: #ff44cc; text-shadow: 0 0 9px #ff22aa55; } @media (prefers-reduced-motion: reduce) {
.chip--orange .chip-val { color: #ffaa44; text-shadow: 0 0 9px #ff880055; } .ticker-track { animation: none; }
.ticker-viewport { overflow-x: auto; scrollbar-width: none; }
/* Accessibilité : pas de défilement si l'utilisateur le refuse */ .ticker-viewport::-webkit-scrollbar { display: none; }
@media (prefers-reduced-motion: reduce) { }
.ticker-track { animation: none; } </style>
.ticker-viewport { overflow-x: auto; scrollbar-width: none; }
.ticker-viewport::-webkit-scrollbar { display: none; }
}
</style>

View File

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

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

View File

@@ -0,0 +1,29 @@
<!-- Mes Personnalisations onglet "Mes Persos" du shop.
Assemble les sections de préférences (chacune autonome, lit ses composables). -->
<template>
<div class="persos">
<BgPrefsSection />
<SendButtonPrefsSection />
<SendSkinPrefsSection />
<IpColorPrefsSection />
<PetsPrefsSection />
</div>
</template>
<script setup lang="ts">
import BgPrefsSection from './persos/BgPrefsSection.vue';
import SendButtonPrefsSection from './persos/SendButtonPrefsSection.vue';
import SendSkinPrefsSection from './persos/SendSkinPrefsSection.vue';
import IpColorPrefsSection from './persos/IpColorPrefsSection.vue';
import PetsPrefsSection from './persos/PetsPrefsSection.vue';
</script>
<style scoped>
.persos {
display: flex;
flex-direction: column;
gap: 24px;
padding: 4px 0;
max-width: 640px;
}
</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,296 +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 + position) --> <!-- 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 designs" 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 class="pet-pos"> </div>
<label v-for="pos in positions" :key="pos" class="opt-radio opt-radio--sm" :class="{ active: petPosition === pos }">
<input type="radio" :value="pos" v-model="petPosition" /> <!-- Preview : Skin de bouton -->
<span>{{ posLabel(pos) }}</span> <div v-if="product.kind === 'send-skin'" class="send-skin-preview">
</label> <div class="skin-btn-demo">{{ meta.char }}</div>
</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 -->
<button <template v-if="product.kind === 'pet'">
class="buy" <button
:disabled="disabled" class="buy"
@click="onBuy" :disabled="disabled"
type="button" @click="onBuy"
>{{ buyLabel }}</button> type="button"
</div> >{{ buyLabel }}</button>
</div> <button
</template> class="buy buy--perso"
@click="$emit('goPerso')"
<script setup lang="ts"> type="button"
import { computed, ref } from 'vue'; > Mes Persos</button>
import type { Product, PurchaseOptions } from '@/composables/useShop'; </template>
<button
const props = defineProps<{ v-else
product: Product; class="buy"
buying: boolean; :disabled="disabled"
owns: (kind: string) => boolean; @click="onBuy"
petCount: number; type="button"
freeMode: boolean; >{{ buyLabel }}</button>
}>(); </div>
</div>
const emit = defineEmits<{ buy: [productId: string, options: PurchaseOptions] }>(); </template>
const meta = computed<any>(() => { <script setup lang="ts">
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; } import { computed, ref, watch } from 'vue';
catch { return {}; } import type { Product, PurchaseOptions } from '@/composables/useShop';
}); import { parseMeta, type ProductMeta } from '@/composables/useMeta';
// Subscription const props = defineProps<{
const plans = computed(() => meta.value.plans ?? []); product: Product;
const plan = ref<'monthly' | 'annual'>('monthly'); buying: boolean;
owns: (kind: string) => boolean;
// Ad-frame ownedPetChars: string[];
const durations = computed(() => meta.value.durations ?? []); petCount: number;
const formats = computed(() => meta.value.formats ?? []); freeMode: boolean;
const durationDays = ref<number>(7); }>();
const format = ref<'static' | 'gif'>('static');
const url = ref(''); const emit = defineEmits<{
buy: [productId: string, options: PurchaseOptions];
// Pet goPerso: [];
const designs = computed(() => meta.value.designs ?? []); }>();
const positions = computed<string[]>(() => meta.value.positions ?? ['left', 'right', 'both']);
const petDesign = ref<string>(''); const meta = computed(() => parseMeta<ProductMeta>(props.product.metaJson));
const petPosition = ref<'left' | 'right' | 'both'>('left');
// Subscription
const icon = computed(() => { const plans = computed(() => meta.value.plans ?? []);
switch (props.product.kind) { const plan = ref<'monthly' | 'annual'>('monthly');
case 'ad-frame': return '📣';
case 'subscription': return '🚫'; // Ad-frame
case 'ip-skin': return '👑'; const durations = computed(() => meta.value.durations ?? []);
case 'pet': return '✨'; const formats = computed(() => meta.value.formats ?? []);
case 'bundle': return '🎁'; const durationDays = ref<number>(7);
case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨'; const format = ref<'static' | 'gif'>('static');
case 'consumable': return '🔊'; const url = ref('');
default: return '🛍️';
} // Pet
}); const designs = computed(() => meta.value.designs ?? []);
const petDesign = ref<string>('');
const effectivePrice = computed(() => { const availableDesigns = computed(() =>
let price = props.product.promoPrice ?? props.product.basePrice; designs.value.filter((d) => !props.ownedPetChars.includes(d.char))
if (props.product.kind === 'subscription') { );
const p = plans.value.find((x: any) => x.id === plan.value); watch(availableDesigns, (ds) => {
if (p) price = p.price; if (ds.length > 0 && !ds.find((d) => d.id === petDesign.value)) {
} petDesign.value = ds[0].id;
if (props.product.kind === 'ad-frame') { }
const d = durations.value.find((x: any) => x.days === durationDays.value); }, { immediate: true });
const f = formats.value.find((x: any) => x.id === format.value);
price += (d?.extra ?? 0) + (f?.extra ?? 0); const icon = computed(() => {
} if (props.product.id === 'ip-colors') return '🎨';
return price; if (props.product.kind === 'send-skin') return meta.value.char ?? '🖱️';
}); switch (props.product.kind) {
case 'ad-frame': return '📣';
// Ownership / limits → disable & label. case 'subscription': return '🚫';
const ownedAlready = computed(() => { case 'ip-skin': return '👑';
const k = props.product.kind; case 'pet': return '✨';
if (k === 'ip-skin') return props.owns('style-dore'); case 'bundle': return '🎁';
if (k === 'subscription') return props.owns('noads'); case 'rich': return props.product.id === 'rich-js' ? '⚡' : '🎨';
if (k === 'rich') return props.owns(props.product.id); case 'consumable': return '🔊';
if (k === 'unlock') return props.owns(props.product.id); default: return '🛍️';
if (k === 'ad-frame') return props.owns('ad-frame'); }
return false; });
});
const effectivePrice = computed(() => {
const petFull = computed(() => props.product.kind === 'pet' && props.petCount >= 3); let price = props.product.promoPrice ?? props.product.basePrice;
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit); if (props.product.kind === 'subscription') {
const p = plans.value.find((x) => x.id === plan.value);
const disabled = computed(() => props.buying || ownedAlready.value || petFull.value || soldOut.value); if (p) price = p.price;
}
const buyLabel = computed(() => { if (props.product.kind === 'ad-frame') {
if (props.buying) return '...'; const d = durations.value.find((x) => x.days === durationDays.value);
if (soldOut.value) return 'Épuisé'; const f = formats.value.find((x) => x.id === format.value);
if (ownedAlready.value) return 'Possédé ✓'; price += (d?.extra ?? 0) + (f?.extra ?? 0);
if (petFull.value) return 'Max 3 pets'; }
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter'; return price;
}); });
const stockPct = computed(() => // Ownership / limits → disable & label.
props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0 const ownedAlready = computed(() => {
); const k = props.product.kind;
if (k === 'ip-skin') return props.owns('style-dore');
function fmt(centi: number): string { if (k === 'subscription') return props.owns('noads');
return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 }); if (k === 'rich') return props.owns(props.product.id);
} if (k === 'unlock') return props.owns(props.product.id);
function posLabel(p: string): string { if (k === 'ad-frame') return props.owns('ad-frame');
return p === 'left' ? 'Gauche' : p === 'right' ? 'Droite' : 'Les deux'; if (k === 'send-skin') return props.owns(props.product.id);
} return false;
});
function onBuy(): void {
const options: PurchaseOptions = {}; const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
if (props.product.kind === 'subscription') options.plan = plan.value;
if (props.product.kind === 'ad-frame') { const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
options.durationDays = durationDays.value;
options.format = format.value; const buyLabel = computed(() => {
options.url = url.value || undefined; if (props.buying) return '...';
} if (soldOut.value) return 'Épuisé';
if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') { if (ownedAlready.value) return 'Possédé ✓';
const d = designs.value.find((x: any) => x.id === petDesign.value) ?? designs.value[0]; return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
if (d) { options.petDesign = d.id; options.petChar = d.char; } });
options.petPosition = petPosition.value;
} const stockPct = computed(() =>
emit('buy', props.product.id, options); props.product.stockLimit ? Math.round((props.product.stockSold / props.product.stockLimit) * 100) : 0
} );
</script>
function fmt(centi: number): string {
<style scoped> return (centi / 100).toLocaleString('fr-FR', { minimumFractionDigits: 2, maximumFractionDigits: 2 });
.card { }
position: relative;
background: #101018; function onBuy(): void {
border: 1px solid #20203a; const options: PurchaseOptions = {};
border-radius: 10px; if (props.product.kind === 'subscription') options.plan = plan.value;
padding: 16px; if (props.product.kind === 'ad-frame') {
display: flex; options.durationDays = durationDays.value;
flex-direction: column; options.format = format.value;
gap: 12px; options.url = url.value || undefined;
font-family: Arial, sans-serif; }
} if (props.product.kind === 'pet' || props.product.id === 'bundle-cosmetic') {
.card--owned { opacity: 0.7; } const d = availableDesigns.value.find((x) => x.id === petDesign.value) ?? availableDesigns.value[0];
if (d) { options.petDesign = d.id; options.petChar = d.char; }
.card-badge { }
position: absolute; emit('buy', props.product.id, options);
top: -9px; }
right: 12px; </script>
background: #ff2266;
color: #fff; <style scoped>
font-size: 9px; .card {
font-weight: bold; position: relative;
letter-spacing: 0.5px; background: #101018;
padding: 3px 9px; border: 1px solid #20203a;
border-radius: 8px; border-radius: 10px;
box-shadow: 0 0 10px #ff226688; padding: 16px;
} display: flex;
flex-direction: column;
.card-head { display: flex; gap: 12px; align-items: flex-start; } gap: 12px;
.card-icon { font-size: 28px; } font-family: Arial, sans-serif;
.card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; } }
.card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; } .card--owned { opacity: 0.7; }
.preview { .card-badge {
display: flex; align-items: center; gap: 10px; position: absolute;
background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center; top: -9px;
} right: 12px;
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; } background: #ff2266;
.prev-plain { color: #666688; } color: #fff;
.prev-gold { color: #ffdd44; text-shadow: 0 0 8px #ffaa00cc; } font-size: 9px;
.prev-arrow { color: #444466; } font-weight: bold;
letter-spacing: 0.5px;
.opts { display: flex; flex-direction: column; gap: 8px; } padding: 3px 9px;
.opt-radio { border-radius: 8px;
display: flex; align-items: center; gap: 8px; box-shadow: none;
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; }
padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer;
} .card-head { display: flex; gap: 12px; align-items: flex-start; }
.opt-radio.active { border-color: #00aaff; background: #0a1622; } .card-icon { font-size: 28px; }
.opt-radio input { accent-color: #00ccff; } .card-name { font-size: 15px; font-weight: bold; color: #d8d8ee; margin: 0; text-decoration: none; display: inline-block; }
.opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; } .card-name:hover { color: #00ddff; }
.opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; } .card-sub { font-size: 11px; color: #6a6a90; margin: 3px 0 0; line-height: 1.4; }
.opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.opt-label { font-size: 11px; color: #8888aa; } .preview {
.opt-select, .opt-input { display: flex; align-items: center; gap: 10px;
background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; background: #0a0a12; border-radius: 6px; padding: 10px; justify-content: center;
color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none; }
} .prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
.opt-select { flex: 1; } .prev-plain { color: #666688; }
.opt-input { width: 100%; } .prev-gold { color: #aa8833; }
.prev-arrow { color: #444466; }
.pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
.pet-cell { .opts { display: flex; flex-direction: column; gap: 8px; }
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px; .opt-radio {
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center; display: flex; align-items: center; gap: 8px;
} background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
.pet-cell.active { border-color: #ff44cc; box-shadow: 0 0 8px #ff44cc55; } padding: 8px 10px; font-size: 12px; color: #aaaacc; cursor: pointer;
.pet-pos { display: flex; gap: 6px; } }
.opt-radio.active { border-color: #00aaff; background: #0a1622; }
.stock { display: flex; flex-direction: column; gap: 4px; } .opt-radio input { accent-color: #00ccff; }
.stock-bar { height: 6px; background: #1a1a2a; border-radius: 3px; overflow: hidden; } .opt-radio--sm { padding: 5px 8px; font-size: 11px; flex: 1; justify-content: center; }
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); } .opt-price { margin-left: auto; color: #ffdd66; font-family: 'Courier New', monospace; }
.stock-txt { font-size: 10px; color: #886644; } .opt-row { display: flex; align-items: center; justify-content: space-between; gap: 8px; }
.opt-label { font-size: 11px; color: #8888aa; }
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; } .opt-select, .opt-input {
.price { display: flex; align-items: baseline; gap: 6px; } background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
.price-old { font-size: 12px; color: #555; text-decoration: line-through; } color: #ccccdd; font-size: 12px; padding: 6px 8px; outline: none;
.price-now { font-size: 20px; font-weight: bold; color: #ffdd66; font-family: 'Courier New', monospace; text-shadow: 0 0 10px #ffaa0044; } }
.price-unit { font-size: 11px; color: #886633; } .opt-select { flex: 1; }
.opt-input { width: 100%; }
.buy {
background: #004488; border: 1px solid #0066aa; color: #00ddff; .pet-grid { display: grid; grid-template-columns: repeat(5, 1fr); gap: 6px; }
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer; .pet-cell {
box-shadow: 0 0 12px #00448855; transition: background 0.15s, box-shadow 0.15s; 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;
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; } }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } .pet-cell.active { border-color: #8844aa; }
</style> .pet-pos { display: flex; gap: 6px; }
.send-skin-preview { display: flex; justify-content: center; padding: 8px 0; }
.skin-btn-demo {
width: 52px; height: 52px; border-radius: 50%;
background: #151525; border: 1px solid #30306a;
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-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; }
.price-old { font-size: 12px; color: #555; text-decoration: line-through; }
.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;
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--perso {
background: #1a1030; border: 1px solid #8844cc; color: #cc88ff;
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
transition: background 0.15s;
}
.buy--perso:hover { background: #261844; }
</style>

View File

@@ -0,0 +1,55 @@
<!-- Mes Persos Fond du chat (image de fond personnalisée, viewer-side) -->
<template>
<PrefSection title="🖼️ Fond du chat">
<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">
<input
v-model="bgDraft"
class="bg-input"
type="text"
placeholder="https://example.com/image.jpg"
@keydown.enter="applyBg"
/>
<button class="btn-apply" @click="applyBg" type="button">Appliquer</button>
<button v-if="prefs.chatBgUrl" class="btn-reset" @click="resetBg" type="button">✕ Retirer</button>
</div>
<div v-if="prefs.chatBgUrl" class="bg-preview" :style="{ backgroundImage: `url(${prefs.chatBgUrl})` }" />
</PrefSection>
</template>
<script setup lang="ts">
import { ref, watch } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles();
const bgDraft = ref(prefs.chatBgUrl);
watch(() => prefs.chatBgUrl, (v) => { bgDraft.value = v; });
function applyBg(): void { prefs.chatBgUrl = bgDraft.value.trim(); }
function resetBg(): void { prefs.chatBgUrl = ''; bgDraft.value = ''; }
</script>
<style scoped>
.bg-row { display: flex; gap: 8px; align-items: center; margin-bottom: 12px; }
.bg-input {
flex: 1; background: #141420; border: 1px solid #222234; border-radius: 6px;
color: #aaaacc; font-family: Arial, sans-serif; font-size: 12px; padding: 8px 12px; outline: none;
}
.bg-input:focus { border-color: #333355; }
.btn-apply {
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
font-size: 12px; font-weight: bold; padding: 7px 14px; border-radius: 14px; cursor: pointer;
}
.btn-apply:hover { background: #234a23; }
.btn-reset {
background: #2a1010; border: 1px solid #882222; color: #ff6655;
font-size: 11px; padding: 7px 12px; border-radius: 14px; cursor: pointer;
}
.btn-reset:hover { background: #3a1818; }
.bg-preview {
width: 100%; height: 80px; background-size: cover; background-position: center;
border-radius: 6px; border: 1px solid #222234;
}
</style>

View File

@@ -0,0 +1,56 @@
<!-- Mes Persos Couleur de mon IP (viewer-side, nécessite la Palette IP) -->
<template>
<PrefSection title="🎨 Couleur de mon IP" :locked="!myPerks.ipColors">
<template v-if="!myPerks.ipColors" #lock>
<span class="pf-lock">🔒 Palette IP requise</span>
</template>
<p v-if="myIp" class="pf-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
<div class="pf-grid">
<button
v-for="opt in IP_COLOR_OPTIONS"
:key="opt.value"
class="pf-tile"
:class="{ 'pf-tile--active': currentIpColor === opt.value }"
:disabled="!myPerks.ipColors"
@click="setIpColor(opt.value)"
type="button"
>
<span v-if="opt.swatch" class="pf-dot" :style="{ background: opt.swatch }" />
<span v-else class="pf-dot pf-dot--auto" />
<span class="pf-label">{{ opt.label }}</span>
</button>
</div>
</PrefSection>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import { useWallet } from '@/composables/useWallet';
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const { ip: myIp } = useWallet();
const currentIpColor = computed(() => prefs.ipColors[myIp.value] ?? 'auto');
function setIpColor(value: string): void {
if (!myIp.value) return;
prefs.ipColors[myIp.value] = value;
}
const ipPreviewStyle = computed(() => {
if (!myIp.value) return {};
const color = currentIpColor.value === 'auto'
? getIpColorWithPerks(myIp.value, myPerks.value)
: currentIpColor.value;
return { color, textShadow: getIpGlow(color) };
});
</script>
<style scoped>
.code-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
</style>

View File

@@ -0,0 +1,74 @@
<!-- Mes Persos Pet actif affiché à gauche de l'IP (parmi les pets possédés) -->
<template>
<PrefSection title="✨ Mes pets" :locked="!hasPets">
<template v-if="!hasPets" #lock>
<span class="pf-lock">Achetez un Pet dans le shop</span>
</template>
<template v-if="hasPets">
<div class="pf-grid">
<button
v-for="pet in ownedPets"
:key="pet.char"
class="pet-cell"
:class="{ 'pet-cell--active': activePet === pet.char }"
@click="togglePet(pet.char)"
type="button"
>{{ pet.char }}</button>
<button
class="pet-cell pet-cell--none"
:class="{ 'pet-cell--active': activePet === '' }"
@click="togglePet('')"
type="button"
>✕ Aucun</button>
</div>
<p class="pf-sub" style="margin-top:6px">
Actif&nbsp;: <strong>{{ activePet || 'aucun' }}</strong>
— s'affiche à gauche de ton IP dans le chat.
</p>
</template>
<p v-else class="pf-sub">Aucun pet possédé pour l'instant.</p>
</PrefSection>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import { useWallet } from '@/composables/useWallet';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const { ip: myIp } = useWallet();
const ownedPets = computed(() => {
const seen = new Set<string>();
return (myPerks.value.pets ?? []).filter((p) => {
if (seen.has(p.char)) return false;
seen.add(p.char);
return true;
});
});
const hasPets = computed(() => ownedPets.value.length > 0);
const activePet = computed(() =>
myIp.value && myIp.value in prefs.ipPets ? prefs.ipPets[myIp.value] : (ownedPets.value[0]?.char ?? '')
);
function togglePet(char: string): void {
if (!myIp.value) return;
prefs.ipPets[myIp.value] = char;
}
</script>
<style scoped>
.pet-cell {
width: 42px; height: 42px;
background: #141420; border: 1px solid #222234; border-radius: 8px;
font-size: 20px; cursor: pointer; transition: border-color 0.1s, background 0.1s;
display: flex; align-items: center; justify-content: center;
}
.pet-cell:hover { background: #1a1a2e; border-color: #333355; }
.pet-cell--active { border-color: #00ddff; background: #0a1a20; }
.pet-cell--none { font-size: 11px; color: #666; width: auto; padding: 0 10px; }
.pet-cell--none.pet-cell--active { color: #00ddff; }
</style>

View File

@@ -0,0 +1,33 @@
<!-- Mes Persos Couleur du bouton d'envoi (preset, nécessite le skin d'éléments) -->
<template>
<PrefSection title="➤ Bouton d'envoi" :locked="!myPerks.elementSkin">
<template v-if="!myPerks.elementSkin" #lock>
<span class="pf-lock">🔒 Skin d'éléments requis</span>
</template>
<div class="pf-grid">
<button
v-for="[k, p] in presetEntries"
:key="k"
class="pf-tile"
:class="{ 'pf-tile--active': prefs.sendButton === k }"
:disabled="!myPerks.elementSkin"
@click="prefs.sendButton = k as SendButtonKey"
type="button"
>
<span class="pf-swatch" :style="{ background: p.bg, color: p.color, borderRadius: p.radius }">➤</span>
<span class="pf-label">{{ p.label }}</span>
</button>
</div>
</PrefSection>
</template>
<script setup lang="ts">
import { useCustomStyles, SEND_BUTTON_PRESETS, type SendButtonKey } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const presetEntries = Object.entries(SEND_BUTTON_PRESETS);
</script>

View File

@@ -0,0 +1,45 @@
<!-- Mes Persos Skin (emoji) du bouton d'envoi, parmi les skins possédés -->
<template>
<PrefSection title="🖱️ Skin du bouton d'envoi" :locked="!hasSendSkins">
<template v-if="!hasSendSkins" #lock>
<span class="pf-lock">Achetez un skin dans le shop</span>
</template>
<template v-if="hasSendSkins">
<div class="pf-grid">
<button
class="pf-tile"
:class="{ 'pf-tile--active': prefs.sendSkin === '' }"
@click="prefs.sendSkin = ''"
type="button"
>
<span class="pf-swatch" style="font-size:14px">►</span>
<span class="pf-label">Défaut</span>
</button>
<button
v-for="s in myPerks.sendSkins"
:key="s.id"
class="pf-tile"
:class="{ 'pf-tile--active': prefs.sendSkin === s.id }"
@click="prefs.sendSkin = s.id"
type="button"
>
<span class="pf-swatch" style="font-size:20px">{{ s.char }}</span>
<span class="pf-label">{{ s.label ?? s.id.replace('send-skin-', '') }}</span>
</button>
</div>
</template>
<p v-else class="pf-sub">Aucun skin possédé pour l'instant.</p>
</PrefSection>
</template>
<script setup lang="ts">
import { computed } from 'vue';
import { useCustomStyles } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import PrefSection from '@/components/shop/PrefSection.vue';
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const hasSendSkins = computed(() => (myPerks.value.sendSkins?.length ?? 0) > 0);
</script>

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 = ['#666688', '#00ddff', '#ff00cc', '#00ee77', '#ff8844'] 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];
} }
export function getIpGlow(color: string): string { // Glows are currently disabled globally; params kept for signature stability.
return color === '#666688' ? 'none' : `0 0 8px ${color}80`; export function getIpGlow(_color: string): string {
} return 'none';
}
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
const GOLD = '#ffdd44'; /** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
const GOLD = '#ffdd44';
interface PerkLike {
skin?: 'gold'; interface PerkLike {
} skin?: 'gold';
}
/** Perk-aware color: gold for Style Doré owners, else the deterministic palette. */
export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string { /** Perk-aware color: gold for Style Doré owners, else the deterministic palette. */
if (perks?.skin === 'gold') return GOLD; export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string {
return getIpColor(ip); if (perks?.skin === 'gold') return GOLD;
} return getIpColor(ip);
}
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
if (perks?.skin === 'gold') return `0 0 10px ${GOLD}cc`; export function getIpGlowWithPerks(_ip: string, _perks?: PerkLike | null): string {
return getIpGlow(getIpColor(ip)); 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

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

View File

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

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

View File

@@ -1,199 +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';
export interface Reply { // Module-level singleton so any component can read the viewer's own perks
id: string; // without prop-drilling (e.g. SendButton, AdBand).
content: string; export const myPerks = ref<Perks>({});
authorIp: string;
createdAt: string; export function useMyPerks() {
parentId?: string | null; return { myPerks };
authorPerks?: Perks; }
richMode?: 'none' | 'htmlcss' | 'js';
richContent?: string | null; export interface GeoInfo {
attachments?: Attachment[]; country: string;
} countryCode: string;
city: string;
export interface Attachment { lat?: number;
id: string; lon?: number;
filename: string; }
mimeType: string;
size: number; export interface Reply {
} id: string;
content: string;
export interface Message extends Reply { authorIp: string;
parentId: string | null; createdAt: string;
replies: Reply[]; parentId?: string | null;
} authorPerks?: Perks;
authorGeo?: GeoInfo | null;
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000'; richMode?: 'none' | 'htmlcss' | 'js';
richContent?: string | null;
export function useMessages() { attachments?: Attachment[];
const messages = ref<Message[]>([]); }
const loading = ref(false);
const sending = ref(false); export interface Attachment {
id: string;
/** Seed the perks store from a message + its replies. */ filename: string;
function harvestPerks(m: Message): void { mimeType: string;
setPerks(m.authorIp, m.authorPerks); size: number;
for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks); }
}
export interface Message extends Reply {
async function fetchMessages(): Promise<void> { parentId: string | null;
loading.value = true; replies: Reply[];
try { }
const res = await fetch(`${API_URL}/api/messages`);
if (res.ok) { const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
// API returns newest→oldest; reverse for chronological display.
const list = ((await res.json()) as Message[]).reverse(); /**
list.forEach(harvestPerks); * Refresh the viewer's own perks from the server (callable from anywhere).
messages.value = list; * The backend computes the perks (entitlement.kind → Perks) and returns them
} * precomputed as `myPerks`, so we just adopt them — no client-side re-derivation.
} finally { */
loading.value = false; export async function refreshMyPerks(): Promise<void> {
} try {
} const res = await fetch(`${API_URL}/api/shop/me`);
if (!res.ok) return;
/** Add a message pushed over the WebSocket (new thread or reply), with dedup. */ const { myPerks: p } = (await res.json()) as { myPerks?: Perks };
function addIncoming(raw: Message & { parentId: string | null }): void { myPerks.value = p ?? {};
if (!raw || !raw.id) return; const { ip } = useWallet();
if (ip.value) setPerks(ip.value, myPerks.value);
// Always record the author's perks, even for replies. } catch {
setPerks(raw.authorIp, raw.authorPerks); /* ignore */
}
if (raw.parentId == null) { }
// New top-level thread.
if (messages.value.some((m) => m.id === raw.id)) return; export function useMessages() {
messages.value.push({ ...raw, replies: raw.replies ?? [] }); const messages = ref<Message[]>([]);
return; const loading = ref(false);
} const sending = ref(false);
// Reply: attach to its parent thread if we have it. /** Seed the perks store from a message + its replies. */
const parent = messages.value.find((m) => m.id === raw.parentId); function harvestPerks(m: Message): void {
if (!parent) return; // thread not loaded; reconnect-resync will reconcile setPerks(m.authorIp, m.authorPerks);
if (parent.replies.some((r) => r.id === raw.id)) return; for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks);
parent.replies.push({ }
id: raw.id,
content: raw.content, async function fetchMessages(): Promise<void> {
authorIp: raw.authorIp, loading.value = true;
createdAt: raw.createdAt, try {
parentId: raw.parentId, const res = await fetch(`${API_URL}/api/messages`);
authorPerks: raw.authorPerks, if (res.ok) {
richMode: raw.richMode, // API returns newest→oldest; reverse for chronological display.
richContent: raw.richContent, const list = ((await res.json()) as Message[]).reverse();
attachments: raw.attachments, list.forEach(harvestPerks);
}); messages.value = list;
} }
} finally {
const { fetchWallet, ip: myIp } = useWallet(); loading.value = false;
}
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.). }
const myPerks = ref<Perks>({});
/** Add a message pushed over the WebSocket (new thread or reply), with dedup. */
async function fetchMyPerks(): Promise<void> { function addIncoming(raw: Message & { parentId: string | null }): void {
try { if (!raw || !raw.id) return;
const res = await fetch(`${API_URL}/api/shop/me`);
if (!res.ok) return; // Always record the author's perks, even for replies.
const { entitlements } = (await res.json()) as { setPerks(raw.authorIp, raw.authorPerks);
entitlements: { kind: string; metaJson?: string | null }[];
}; if (raw.parentId == null) {
const p: Perks = {}; // New top-level thread.
const pets: { char: string; position: 'left' | 'right' | 'both' }[] = []; if (messages.value.some((m) => m.id === raw.id)) return;
for (const e of entitlements) { messages.value.push({ ...raw, replies: raw.replies ?? [] });
let meta: any = {}; return;
try { meta = e.metaJson ? JSON.parse(e.metaJson) : {}; } catch { /* */ } }
if (e.kind === 'noads') { p.noads = true; if (meta.plan === 'annual') p.badge = true; }
if (e.kind === 'style-dore') p.skin = 'gold'; // Reply: attach to its parent thread if we have it.
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' }); const parent = messages.value.find((m) => m.id === raw.parentId);
if (e.kind === 'element-skin') p.elementSkin = true; if (!parent) return; // thread not loaded; reconnect-resync will reconcile
if (e.kind === 'rich-htmlcss') p.richHtmlcss = true; if (parent.replies.some((r) => r.id === raw.id)) return;
if (e.kind === 'rich-js') p.richJs = true; parent.replies.push({
if (e.kind === 'no-file-limit') p.noFileLimit = true; id: raw.id,
if (e.kind === 'audio-alert') p.audioAlert = true; content: raw.content,
} authorIp: raw.authorIp,
if (pets.length) p.pets = pets.slice(0, 3); createdAt: raw.createdAt,
myPerks.value = p; parentId: raw.parentId,
if (myIp.value) setPerks(myIp.value, p); authorPerks: raw.authorPerks,
} catch { authorGeo: raw.authorGeo,
/* ignore */ richMode: raw.richMode,
} richContent: raw.richContent,
} attachments: raw.attachments,
});
const { stats, connected, sendTyping } = useRealtime({ }
onMessage: addIncoming,
onReconnect: () => { const { fetchWallet, ip: myIp } = useWallet();
fetchMessages();
fetchWallet(); // The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
fetchMyPerks(); // myPerks is module-level; this ref is the same reference.
},
onWallet: applyWalletFrame, async function fetchMyPerks(): Promise<void> {
onPerks: (data: { ip: string; perks: Perks }) => { return refreshMyPerks();
applyPerksFrame(data); }
// If it's about us, update myPerks too (viewer-scoped perks like NoAds).
if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {}; const { stats, connected, sendTyping } = useRealtime({
}, onMessage: addIncoming,
onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch onReconnect: () => {
onAlert: (data) => handleAlertFrame(data), // paid global audio alert fetchMessages();
}); fetchWallet();
fetchMyPerks();
interface PostExtras { },
parentId?: string; onWallet: applyWalletFrame,
richMode?: 'htmlcss' | 'js'; onPerks: (data: { ip: string; perks: Perks }) => {
richContent?: string; applyPerksFrame(data);
attachmentIds?: string[]; // If it's about us, update myPerks too (viewer-scoped perks like NoAds).
} if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {};
},
async function postMessage(content: string, extras: PostExtras = {}): Promise<boolean> { onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch
const hasRich = !!extras.richContent && !!extras.richMode; onAlert: (data) => handleAlertFrame(data), // paid global audio alert
const hasFiles = !!extras.attachmentIds?.length; });
// Allow empty text only when there's rich content or an attachment.
if (!content.trim() && !hasRich && !hasFiles) return false; interface PostExtras {
sending.value = true; parentId?: string;
try { richMode?: 'htmlcss' | 'js';
const res = await fetch(`${API_URL}/api/messages`, { richContent?: string;
method: 'POST', attachmentIds?: string[];
headers: { 'Content-Type': 'application/json' }, }
body: JSON.stringify({
content: content.trim() || ' ', async function postMessage(content: string, extras: PostExtras = {}): Promise<boolean> {
parentId: extras.parentId, const hasRich = !!extras.richContent && !!extras.richMode;
richMode: extras.richMode, const hasFiles = !!extras.attachmentIds?.length;
richContent: extras.richContent, // Allow empty text only when there's rich content or an attachment.
attachmentIds: extras.attachmentIds, if (!content.trim() && !hasRich && !hasFiles) return false;
}), sending.value = true;
}); try {
if (!res.ok) return false; const res = await fetch(`${API_URL}/api/messages`, {
// The created message comes back via the WebSocket broadcast, so no method: 'POST',
// re-fetch here. Fallback: if the socket is down, add it locally. headers: { 'Content-Type': 'application/json' },
if (!connected.value) { body: JSON.stringify({
const created = (await res.json()) as Message; content: content.trim(),
addIncoming( parentId: extras.parentId,
created.parentId == null ? { ...created, replies: [] } : created richMode: extras.richMode,
); richContent: extras.richContent,
} attachmentIds: extras.attachmentIds,
return true; }),
} finally { });
sending.value = false; if (!res.ok) return false;
} // The created message comes back via the WebSocket broadcast, so no
} // re-fetch here. Fallback: if the socket is down, add it locally.
if (!connected.value) {
onMounted(() => { const created = (await res.json()) as Message;
fetchMessages(); addIncoming(
fetchWallet(); created.parentId == null ? { ...created, replies: [] } : created
fetchMyPerks(); );
}); }
return true;
return { } finally {
messages, sending.value = false;
loading, }
sending, }
postMessage,
stats, onMounted(() => {
connected, fetchMessages();
sendTyping, fetchWallet();
myPerks, fetchMyPerks();
fetchMyPerks, });
};
} // Note: viewer-own perks live in the module-level `myPerks` singleton; read
// them via `useMyPerks()` rather than off this return (consistency rule).
return {
messages,
loading,
sending,
postMessage,
stats,
connected,
sendTyping,
myIp,
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,28 @@
/**
* Safe JSON parser for the `metaJson` strings carried by products and
* entitlements. Returns the fallback on any parse error instead of throwing,
* so callers can drop their repetitive try/catch + `any` casts.
*/
export function parseMeta<T = Record<string, unknown>>(
json: string | null | undefined,
fallback: T = {} as T,
): T {
if (!json) return fallback;
try {
return JSON.parse(json) as T;
} catch {
return fallback;
}
}
/** Shape of a product's metaJson (all fields optional — depends on kind). */
export interface ProductMeta {
designs?: { id: string; char: string }[];
positions?: string[];
plans?: { id: string; label: string; price: number }[];
durations?: { days: number; extra: number }[];
formats?: { id: string; label: string; extra: number }[];
char?: string;
label?: string;
includes?: string[];
}

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

View File

@@ -0,0 +1,55 @@
import { ref, provide, inject, type InjectionKey, type Ref } from 'vue';
export type Theme = 'default' | 'bubble' | 'compact' | 'whatsapp';
/** Which message layout a theme uses (drives the dynamic <component :is>). */
export type Layout = 'classic' | 'bubble' | 'compact';
export interface ThemeContext {
theme: Ref<Theme>;
setTheme: (t: Theme) => void;
}
export const THEME_KEY: InjectionKey<ThemeContext> = Symbol('xip-theme');
const THEMES: Record<Theme, { label: string; emoji: string }> = {
default: { label: 'Classique', emoji: '📋' },
bubble: { label: 'Bulles', emoji: '💬' },
compact: { label: 'Compact', emoji: '📐' },
whatsapp: { label: 'WhatsApp', emoji: '💚' },
};
/**
* A theme = a message layout (component) + a CSS-variable palette (applied via a
* `data-theme` attribute on the app root). WhatsApp reuses the bubble layout with
* a green palette — no dedicated message component needed.
*/
const THEME_LAYOUT: Record<Theme, Layout> = {
default: 'classic',
bubble: 'bubble',
compact: 'compact',
whatsapp: 'bubble',
};
export function provideTheme() {
const saved = (localStorage.getItem('xip-theme') ?? 'default') as Theme;
const theme = ref<Theme>(THEMES[saved] ? saved : 'default');
function setTheme(t: Theme) {
theme.value = t;
localStorage.setItem('xip-theme', t);
}
const ctx: ThemeContext = { theme, setTheme };
provide(THEME_KEY, ctx);
return ctx;
}
export function useTheme(): ThemeContext {
return inject(THEME_KEY, {
theme: ref<Theme>('default'),
setTheme: () => {},
});
}
export { THEMES, THEME_LAYOUT };

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,13 +1,116 @@
*, *::before, *::after { /* latin-ext */
box-sizing: border-box; @font-face {
margin: 0; font-family: 'Lato';
padding: 0; font-style: normal;
} font-weight: 400;
src: url(https://fonts.gstatic.com/s/lato/v25/S6uyw4BMUTPHjxAwXjeu.woff2) format('woff2');
html, 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;
body, }
#app { /* latin */
height: 100%; @font-face {
overflow: hidden; font-family: 'Lato';
background: #080808; font-style: normal;
} font-weight: 400;
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;
}
/* latin-ext */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
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;
}
/* latin */
@font-face {
font-family: 'Lato';
font-style: normal;
font-weight: 700;
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;
}
*, *::before, *::after {
box-sizing: border-box;
margin: 0;
padding: 0;
}
/* ── 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
à fort impact (fond, header, bulles, bouton d'envoi). */
:root {
--xip-app-bg: #080808;
--xip-bg: #090910;
--xip-header-bg: #0e0e16;
--xip-header-border: #1a1a2a;
--xip-bubble-other: #141422;
--xip-bubble-other-border: #222236;
--xip-bubble-sent: #0e1f30;
--xip-bubble-sent-border: #1a3a55;
--xip-accent: #00ddff;
--xip-send-bg: #004488;
--xip-send-fg: #00ddff;
}
[data-theme="whatsapp"] {
--xip-app-bg: #0b141a;
--xip-bg: #0b141a;
--xip-header-bg: #202c33;
--xip-header-border: #2a3942;
--xip-bubble-other: #202c33;
--xip-bubble-other-border: #2a3942;
--xip-bubble-sent: #005c4b; /* vert sortant signature WhatsApp */
--xip-bubble-sent-border: #047857;
--xip-accent: #00a884;
--xip-send-bg: #00a884;
--xip-send-fg: #ffffff;
}
html,
body,
#app {
height: 100%;
overflow: hidden;
background: var(--xip-app-bg);
font-family: 'Lato', sans-serif;
}
/* ── Styles partagés des sections « Mes Persos » (shop/persos/*) ──
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. */
.pf-section {
background: #101018;
border: 1px solid #20203a;
border-radius: 10px;
padding: 18px 20px;
}
.pf-section.pf-locked { opacity: 0.6; }
.pf-title {
font-size: 14px; font-weight: bold; color: #ccccee;
margin: 0 0 6px; display: flex; align-items: center; gap: 10px;
}
.pf-sub { font-size: 11px; color: #5a5a80; margin: 0 0 12px; }
.pf-lock {
font-size: 10px; font-weight: normal; color: #886644;
background: #1a1408; border: 1px solid #44330066; border-radius: 8px; padding: 2px 8px;
}
.pf-grid { display: flex; flex-wrap: wrap; gap: 8px; }
.pf-tile {
display: flex; flex-direction: column; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 8px;
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--active { border-color: #00ddff; background: #0a1a20; }
.pf-tile:disabled { cursor: not-allowed; opacity: 0.5; }
.pf-swatch {
width: 34px; height: 34px; border-radius: inherit;
display: flex; align-items: center; justify-content: center;
font-size: 14px; font-weight: bold; border: 1px solid #ffffff10;
}
.pf-label { font-size: 10px; color: #8888aa; white-space: nowrap; }
.pf-tile--active .pf-label { color: #00ddff; }
.pf-dot { width: 20px; height: 20px; border-radius: 50%; border: 1px solid #ffffff22; }
.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

@@ -1,16 +1,13 @@
<template> <template>
<div class="xip-app"> <div class="xip-app" :data-theme="theme">
<!-- Bandeau de stats temps réel, toujours visible --> <!-- Bandeau de stats temps réel, toujours visible -->
<StatsTicker :stats="stats" :connected="connected" /> <StatsTicker :stats="stats" :connected="connected" />
<div class="xip-root"> <div class="xip-root">
<!-- Bande pub gauche masquée si l'utilisateur a NoAds -->
<AdBand v-if="!myPerks.noads" />
<!-- Zone chat centrale --> <!-- Zone chat centrale -->
<div class="xip-center"> <div class="xip-center" :style="chatBgStyle">
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" /> <ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
<MessageList :messages="messages" :hide-ads="!!myPerks.noads" @reply="startReply" /> <MessageList :messages="messages" :hide-ads="!!myPerks.noads" :my-ip="myIp" @reply="startReply" />
<!-- Bannière de réponse --> <!-- Bannière de réponse -->
<div v-if="replyingTo" class="reply-banner"> <div v-if="replyingTo" class="reply-banner">
@@ -20,69 +17,8 @@
<button class="reply-cancel" @click="cancelReply" type="button"></button> <button class="reply-cancel" @click="cancelReply" type="button"></button>
</div> </div>
<!-- Composer riche (HTML/CSS ou JS) --> <!-- Composer (texte / riche / pièces jointes / envoi) -->
<div v-if="richMode !== 'none'" class="rich-composer"> <ChatComposer :replying-to="replyingTo" @clear-reply="cancelReply" />
<div class="rich-head">
<span class="rich-badge" :class="`rich-badge--${richMode}`">
{{ richMode === 'js' ? ' JavaScript' : '🎨 HTML / CSS' }}
</span>
<button class="rich-close" @click="richMode = 'none'" type="button">✕ texte simple</button>
</div>
<textarea
v-model="richDraft"
class="rich-textarea"
:placeholder="richMode === 'js' ? '<script>document.body.style.background=&quot;lime&quot;<\/script>' : '<h1 style=&quot;color:#0ff&quot;>Salut</h1>'"
rows="4"
/>
</div>
<!-- Barre de saisie -->
<div class="input-bar">
<!-- Bouton mode riche (si débloqué) -->
<button
v-if="myPerks.richHtmlcss || myPerks.richJs"
class="icon-btn"
:title="richMenuTitle"
@click="cycleRichMode"
type="button"
>{{ richMode === 'none' ? '🎨' : richMode === 'htmlcss' ? '🎨' : '' }}</button>
<!-- Bouton pièce jointe -->
<button class="icon-btn" title="Joindre un fichier" @click="pickFile" type="button">📎</button>
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
<!-- Bouton alerte audio (si débloqué) -->
<button
v-if="myPerks.audioAlert"
class="icon-btn icon-btn--alert"
:title="alertMsg || 'Déclencher l\'alerte audio générale'"
@click="triggerAlert"
type="button"
>🔊</button>
<div class="field-wrap">
<input
v-model="draft"
class="input-field"
type="text"
placeholder="Entrez un message..."
:maxlength="267"
@input="onInput"
@keydown.enter.exact.prevent="submit"
/>
<span class="char-counter" :class="{ warn: draft.length > 240 }">{{ draft.length }}/267</span>
</div>
<SendButton :disabled="!canSend || sending" @send="submit" />
</div>
<!-- Pièces jointes en attente -->
<div v-if="pendingFiles.length" class="pending-files">
<span v-for="f in pendingFiles" :key="f.id" class="pending-chip">
📎 {{ f.filename }} ({{ kb(f.size) }})
<button @click="removePending(f.id)" type="button">✕</button>
</span>
</div>
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
</div> </div>
</div> </div>
</div> </div>
@@ -90,30 +26,31 @@
<script setup lang="ts"> <script setup lang="ts">
import { ref, computed } from 'vue'; import { ref, computed } from 'vue';
import AdBand from '@/components/AdBand.vue'; import ChatHeader from '@/components/ChatHeader.vue';
import ChatHeader from '@/components/ChatHeader.vue'; import MessageList from '@/components/MessageList.vue';
import MessageList from '@/components/MessageList.vue'; import ChatComposer from '@/components/ChatComposer.vue';
import SendButton from '@/components/SendButton.vue'; import StatsTicker from '@/components/StatsTicker.vue';
import StatsTicker from '@/components/StatsTicker.vue'; import { useMessages, useMyPerks } from '@/composables/useMessages';
import { useMessages } from '@/composables/useMessages'; import { provideTheme } from '@/composables/useTheme';
import { useAttachments } from '@/composables/useAttachments'; import { useCustomStyles } from '@/composables/useCustomStyles';
import { useAlert } from '@/composables/useAlert';
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks } = useMessages(); const { theme } = provideTheme();
const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert();
const draft = ref(''); const { messages, stats, connected, myIp } = useMessages();
const { myPerks } = useMyPerks();
const { prefs: stylePrefs } = useCustomStyles();
// ── Alerte audio ── const chatBgStyle = computed(() => {
const alertMsg = ref(''); if (!stylePrefs.chatBgUrl) return {};
async function triggerAlert(): Promise<void> { return {
const res = await fireAlert(); backgroundImage: `url(${stylePrefs.chatBgUrl})`,
alertMsg.value = res.ok ? '' : res.error || ''; backgroundSize: 'cover',
if (alertMsg.value) setTimeout(() => { alertMsg.value = ''; }, 3000); backgroundPosition: 'center',
} backgroundRepeat: 'no-repeat',
};
});
// ── Réponse ── // ── Réponse (la bannière vit ici ; le composer envoie avec parentId) ──
const replyingTo = ref<{ id: string; authorIp: string } | null>(null); const replyingTo = ref<{ id: string; authorIp: string } | null>(null);
function startReply(payload: { id: string; authorIp: string }): void { function startReply(payload: { id: string; authorIp: string }): void {
replyingTo.value = payload; replyingTo.value = payload;
@@ -121,85 +58,15 @@ function startReply(payload: { id: string; authorIp: string }): void {
function cancelReply(): void { function cancelReply(): void {
replyingTo.value = null; replyingTo.value = null;
} }
// ── Mode riche ──
const richMode = ref<'none' | 'htmlcss' | 'js'>('none');
const richDraft = ref('');
const richMenuTitle = computed(() =>
myPerks.value.richJs ? 'Message riche : texte / HTML-CSS / JS' : 'Message riche : texte / HTML-CSS'
);
function cycleRichMode(): void {
// Cycle through the tiers the user owns.
if (richMode.value === 'none') richMode.value = myPerks.value.richHtmlcss ? 'htmlcss' : 'js';
else if (richMode.value === 'htmlcss') richMode.value = myPerks.value.richJs ? 'js' : 'none';
else richMode.value = 'none';
}
// ── Pièces jointes ──
const fileInput = ref<HTMLInputElement | null>(null);
const pendingFiles = ref<{ id: string; filename: string; size: number }[]>([]);
const uploadError = ref<string | null>(null);
function pickFile(): void {
uploadError.value = null;
fileInput.value?.click();
}
async function onFileSelected(e: Event): Promise<void> {
const input = e.target as HTMLInputElement;
const file = input.files?.[0];
input.value = '';
if (!file) return;
const res = await uploadFile(file);
if (res.ok) {
pendingFiles.value.push({ id: res.attachment.id, filename: res.attachment.filename, size: res.attachment.size });
} else {
uploadError.value = res.error;
}
}
function removePending(id: string): void {
pendingFiles.value = pendingFiles.value.filter((f) => f.id !== id);
}
// ── Frappe (stats) ──
let prevLen = 0;
function onInput(): void {
const len = draft.value.length;
const delta = len - prevLen;
prevLen = len;
sendTyping(delta > 0 ? delta : 0);
}
// ── Envoi ──
const canSend = computed(() =>
!!draft.value.trim() || (richMode.value !== 'none' && !!richDraft.value.trim()) || pendingFiles.value.length > 0
);
async function submit(): Promise<void> {
if (!canSend.value) return;
const ok = await postMessage(draft.value, {
parentId: replyingTo.value?.id,
richMode: richMode.value !== 'none' && richDraft.value.trim() ? richMode.value : undefined,
richContent: richMode.value !== 'none' && richDraft.value.trim() ? richDraft.value : undefined,
attachmentIds: pendingFiles.value.map((f) => f.id),
});
if (ok) {
draft.value = '';
richDraft.value = '';
richMode.value = 'none';
pendingFiles.value = [];
replyingTo.value = null;
uploadError.value = null;
prevLen = 0;
}
}
</script> </script>
<style scoped> <style scoped>
.xip-app { .xip-app {
display: flex; display: flex;
flex-direction: column; flex-direction: column;
width: 100vw; width: 100%;
height: 100dvh; height: 100%;
background: #080808; background: var(--xip-app-bg);
overflow: hidden; overflow: hidden;
} }
@@ -215,7 +82,7 @@ async function submit(): Promise<void> {
min-width: 0; min-width: 0;
display: flex; display: flex;
flex-direction: column; flex-direction: column;
background: #090910; background: var(--xip-bg);
overflow: hidden; overflow: hidden;
} }
@@ -233,76 +100,4 @@ async function submit(): Promise<void> {
.reply-ip { font-family: 'Courier New', monospace; color: #00ccff; font-weight: bold; } .reply-ip { font-family: 'Courier New', monospace; color: #00ccff; font-weight: bold; }
.reply-cancel { background: none; border: none; color: #557; cursor: pointer; font-size: 13px; } .reply-cancel { background: none; border: none; color: #557; cursor: pointer; font-size: 13px; }
.reply-cancel:hover { color: #aac; } .reply-cancel:hover { color: #aac; }
/* ── Composer riche ── */
.rich-composer {
flex-shrink: 0;
background: #0c0c16;
border-top: 1px solid #1a1a26;
padding: 8px 20px;
}
.rich-head { display: flex; align-items: center; justify-content: space-between; margin-bottom: 6px; }
.rich-badge { font-size: 11px; font-weight: bold; padding: 2px 8px; border-radius: 8px; }
.rich-badge--htmlcss { color: #00ddaa; background: #062019; }
.rich-badge--js { color: #ffcc44; background: #201a06; }
.rich-close { background: none; border: none; color: #557; cursor: pointer; font-size: 11px; }
.rich-close:hover { color: #aac; }
.rich-textarea {
width: 100%; box-sizing: border-box; resize: vertical;
background: #141420; border: 1px solid #222234; border-radius: 8px;
color: #aaccbb; font-family: 'Courier New', monospace; font-size: 12px; padding: 8px 10px; outline: none;
}
/* ── Barre de saisie ── */
.input-bar {
min-height: 70px;
flex-shrink: 0;
background: #0e0e16;
border-top: 1px solid #1a1a26;
display: flex;
align-items: center;
padding: 0 20px;
gap: 10px;
}
.icon-btn {
flex-shrink: 0;
width: 36px; height: 36px;
background: #141420; border: 1px solid #222234; border-radius: 50%;
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
transition: background 0.15s;
}
.icon-btn:hover { background: #1c1c2e; }
.icon-btn--alert { border-color: #aa3344; }
.icon-btn--alert:hover { background: #2a1418; box-shadow: 0 0 10px #ff224455; }
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
.input-field {
flex: 1;
background: #141420;
border: 1px solid #222234;
border-radius: 23px;
padding: 12px 60px 12px 22px;
color: #aaaacc;
font-family: Arial, sans-serif;
font-size: 13px;
outline: none;
transition: border-color 0.15s;
}
.input-field::placeholder { color: #2a2a44; }
.input-field:focus { border-color: #333355; }
.char-counter {
position: absolute; right: 16px;
font-family: 'Courier New', monospace; font-size: 10px; color: #33334d; pointer-events: none;
}
.char-counter.warn { color: #ff8844; }
/* ── Pièces jointes en attente ── */
.pending-files { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px; padding: 0 20px 10px; }
.pending-chip {
display: inline-flex; align-items: center; gap: 6px;
background: #141420; border: 1px solid #222234; border-radius: 12px;
padding: 4px 10px; font-size: 11px; color: #aaccbb; font-family: Arial, sans-serif;
}
.pending-chip button { background: none; border: none; color: #66f; cursor: pointer; }
.upload-error { flex-shrink: 0; padding: 0 20px 10px; color: #ff7788; font-size: 11px; font-family: Arial, sans-serif; }
</style> </style>

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>

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