Compare commits

...

5 Commits

Author SHA1 Message Date
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
33 changed files with 1124 additions and 69 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,26 @@
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:
jobs:
deploy:
runs-on: ubuntu-latest
steps:
- name: Set up SSH
run: |
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' "${{ secrets.XIP_DEPLOY_KEY }}" > ~/.ssh/id_ed25519
chmod 600 ~/.ssh/id_ed25519
ssh-keyscan -H "${{ secrets.XIP_DEPLOY_HOST }}" >> ~/.ssh/known_hosts 2>/dev/null || true
- name: Deploy over SSH
run: |
ssh -i ~/.ssh/id_ed25519 -o StrictHostKeyChecking=no \
"${{ secrets.XIP_DEPLOY_USER }}@${{ secrets.XIP_DEPLOY_HOST }}" \
'bash /opt/xip/scripts/deploy.sh'

1
.gitignore vendored
View File

@@ -2,6 +2,7 @@ node_modules/
dist/ dist/
.env .env
.env.local .env.local
.env.prod
*.log *.log
.DS_Store .DS_Store
Thumbs.db 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
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

@@ -25,7 +25,7 @@ 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"],
}) })

View File

@@ -1,6 +1,6 @@
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";
/** /**
@@ -105,7 +105,7 @@ export async function purchase(
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.

View File

@@ -36,3 +36,13 @@ export function isLocalhost(ip: string): boolean {
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,6 +1,6 @@
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).
@@ -41,7 +41,7 @@ export async function ensureWallet(ip: string): Promise<void> {
} }
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;
@@ -51,7 +51,7 @@ export async function getWallet(ip: string): Promise<WalletView> {
/** 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 },
@@ -83,7 +83,7 @@ export async function spend(
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);

View File

@@ -55,8 +55,10 @@ async function flushStats(): Promise<void> {
broadcastScheduled = false; broadcastScheduled = false;
lastBroadcastAt = Date.now(); lastBroadcastAt = Date.now();
if (clients.size === 0) return; if (clients.size === 0) return;
const distinctIps = new Set<string>();
for (const s of clients.values()) distinctIps.add(s.ip);
const snapshot = await buildSnapshot({ const snapshot = await buildSnapshot({
connectedTabs: clients.size, connectedTabs: distinctIps.size,
typingNow: countTyping(Date.now()), typingNow: countTyping(Date.now()),
}); });
const payload = JSON.stringify({ type: "stats", data: snapshot }); const payload = JSON.stringify({ type: "stats", data: snapshot });
@@ -78,6 +80,15 @@ setInterval(() => {
if (clients.size > 0) void flushStats(); if (clients.size > 0) void flushStats();
}, 1000); }, 1000);
// Periodic console log of connected IPs (every 10 s).
setInterval(() => {
if (clients.size === 0) return;
const ips = new Set<string>();
for (const s of clients.values()) ips.add(s.ip);
const lines = [...ips].map((ip) => ` ${ip}`).join("\n");
console.log(`[connectés] ${ips.size} IP(s):\n${lines}`);
}, 10_000);
/** Send an arbitrary frame to every connected tab. */ /** Send an arbitrary frame to every connected tab. */
export function broadcast(payload: object): void { export function broadcast(payload: object): void {
const str = JSON.stringify(payload); const str = JSON.stringify(payload);

View File

@@ -1,5 +1,5 @@
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";
@@ -24,7 +24,7 @@ alert.post("/", async (c) => {
} }
// 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 },
}); });

View File

@@ -1,6 +1,6 @@
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";
@@ -11,7 +11,7 @@ const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup
/** Does this IP own the entitlement needed for a rich tier? */ /** Does this IP own the entitlement needed for a rich tier? */
async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> { async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> {
if (isLocalhost(ip)) return true; if (isFree(ip)) return true;
const kind = mode === "js" ? "rich-js" : "rich-htmlcss"; const kind = mode === "js" ? "rich-js" : "rich-htmlcss";
const now = new Date(); const now = new Date();
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } });

View File

@@ -1,7 +1,7 @@
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();
@@ -10,7 +10,7 @@ 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 },
}); });

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:

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,3 +1,8 @@
<template> <template>
<RouterView /> <RouterView />
<StyleContextMenu />
</template> </template>
<script setup lang="ts">
import StyleContextMenu from '@/components/StyleContextMenu.vue';
</script>

View File

@@ -9,8 +9,11 @@
:key="ad.id" :key="ad.id"
class="ad-card" class="ad-card"
:href="ad.url || undefined" :href="ad.url || undefined"
:style="cardStyle"
target="_blank" target="_blank"
rel="noopener noreferrer nofollow" rel="noopener noreferrer nofollow"
title="Clic droit pour personnaliser le cadre"
@contextmenu.prevent="onRightClick"
> >
<div class="ad-header" :class="`ad-header--${ad.tone}`"> <div class="ad-header" :class="`ad-header--${ad.tone}`">
<p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p> <p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p>
@@ -26,16 +29,38 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { onMounted, watch } from 'vue'; import { computed, onMounted, watch } from 'vue';
import { useAds } from '@/composables/useAds'; import { useAds } from '@/composables/useAds';
import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, AD_FRAME_PRESETS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
const { ads, fetchAds, reportImpression } = useAds('band'); const { ads, fetchAds, reportImpression } = useAds('band');
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const cardStyle = computed(() => {
const p = AD_FRAME_PRESETS[prefs.adFrame];
return { border: p.border, background: p.bg };
});
function onRightClick(e: MouseEvent): void {
if (!myPerks.value.elementSkin) return;
e.stopPropagation();
openContextMenu({
x: e.clientX,
y: e.clientY,
title: 'Cadre pub',
items: Object.entries(AD_FRAME_PRESETS).map(([k, v]) => ({ value: k, label: v.label })),
current: prefs.adFrame,
onSelect: (v) => { prefs.adFrame = v as typeof prefs.adFrame; },
});
}
function prettyUrl(url: string): string { function prettyUrl(url: string): string {
return url.replace(/^https?:\/\//, '').replace(/\/$/, ''); return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
} }
// Report one impression per ad each time the set (re)loads.
watch(ads, (list) => { watch(ads, (list) => {
for (const a of list) reportImpression(a.id); for (const a of list) reportImpression(a.id);
}); });

View File

@@ -3,7 +3,7 @@
<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>
@@ -30,7 +30,7 @@
:key="reply.id" :key="reply.id"
class="reply" class="reply"
> >
<span class="ip-wrap"> <span class="ip-wrap" @contextmenu.prevent="openIpMenu($event, reply.authorIp)" :title="reply.authorIp === myIp ? 'Clic droit pour personnaliser' : undefined">
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span> <span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span>
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span> <span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span>
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span> <span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span>
@@ -52,30 +52,41 @@
<script setup lang="ts"> <script setup lang="ts">
import type { Message, Reply } from '@/composables/useMessages'; import type { Message, Reply } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlowWithPerks } from '@/composables/ipColor'; import { getIpColorWithPerks, getIpGlowWithPerks, getIpColor, getIpGlow } from '@/composables/ipColor';
import { usePerks } from '@/composables/usePerks'; import { usePerks } from '@/composables/usePerks';
import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, IP_COLOR_OPTIONS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import RichContent from './RichContent.vue'; import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue'; import MessageAttachments from './MessageAttachments.vue';
defineProps<{ message: Message }>(); const props = defineProps<{ message: Message; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { perksFor } = usePerks(); const { perksFor } = usePerks();
const { myPerks } = useMyPerks();
const { prefs } = useCustomStyles();
/** Perks for an author: prefer the perks embedded in the payload, else the store. */
function perksOf(m: Reply): any { function perksOf(m: Reply): any {
return m.authorPerks ?? perksFor(m.authorIp); return m.authorPerks ?? perksFor(m.authorIp);
} }
function ipStyle(m: Reply) { function ipStyle(m: Reply) {
const ip = m.authorIp;
const colorOverride = prefs.ipColors[ip];
if (colorOverride && colorOverride !== 'auto') {
return { color: colorOverride, textShadow: getIpGlow(colorOverride) };
}
const p = perksOf(m); const p = perksOf(m);
return { return {
color: getIpColorWithPerks(m.authorIp, p), color: getIpColorWithPerks(ip, p),
textShadow: getIpGlowWithPerks(m.authorIp, p), textShadow: getIpGlowWithPerks(ip, p),
}; };
} }
function petsLeft(m: Reply): string { function petsLeft(m: Reply): string {
const ip = m.authorIp;
if (ip in prefs.ipPets) return prefs.ipPets[ip]; // '' = aucun pet
const pets = perksOf(m)?.pets ?? []; const pets = perksOf(m)?.pets ?? [];
return pets return pets
.filter((x: any) => x.position === 'left' || x.position === 'both') .filter((x: any) => x.position === 'left' || x.position === 'both')
@@ -83,6 +94,8 @@ function petsLeft(m: Reply): string {
.join(''); .join('');
} }
function petsRight(m: Reply): string { function petsRight(m: Reply): string {
const ip = m.authorIp;
if (ip in prefs.ipPets) return ''; // override = pet gauche uniquement
const pets = perksOf(m)?.pets ?? []; const pets = perksOf(m)?.pets ?? [];
return pets return pets
.filter((x: any) => x.position === 'right' || x.position === 'both') .filter((x: any) => x.position === 'right' || x.position === 'both')
@@ -90,6 +103,60 @@ function petsRight(m: Reply): string {
.join(''); .join('');
} }
function openIpMenu(e: MouseEvent, ip: string): void {
if (ip !== props.myIp) return;
const hasElementSkin = !!myPerks.value.elementSkin;
const ownedPets = myPerks.value.pets ?? [];
const hasPets = ownedPets.length > 0;
// Nothing to show if no perk unlocks customization.
if (!hasElementSkin && !hasPets) return;
const currentColor = prefs.ipColors[ip] ?? 'auto';
const currentPet = ip in prefs.ipPets ? prefs.ipPets[ip] : '__inherit__';
const items: import('@/composables/useContextMenu').ContextMenuItem[] = [];
if (hasElementSkin) {
items.push({ value: '__h_color', label: 'Couleur', isHeader: true });
items.push(...IP_COLOR_OPTIONS.map((o) => ({ value: `color:${o.value}`, label: o.label, swatch: o.swatch })));
}
if (hasPets) {
items.push({ value: '__h_pet', label: 'Pet', isHeader: true });
items.push({ value: 'pet:__inherit__', label: ' défaut' });
// Show only the pets the user actually owns.
const seen = new Set<string>();
for (const p of ownedPets) {
if (!seen.has(p.char)) {
seen.add(p.char);
items.push({ value: `pet:${p.char}`, label: p.char });
}
}
}
openContextMenu({
x: e.clientX,
y: e.clientY,
title: ip,
items,
current: currentColor !== 'auto' ? `color:${currentColor}` : `pet:${currentPet}`,
onSelect: (v) => {
if (v.startsWith('color:')) {
prefs.ipColors[ip] = v.slice(6);
} else if (v.startsWith('pet:')) {
const pet = v.slice(4);
if (pet === '__inherit__') {
delete prefs.ipPets[ip];
} else {
prefs.ipPets[ip] = pet;
}
}
},
});
}
function fmt(date: string): string { function fmt(date: string): string {
return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' }); return new Date(date).toLocaleTimeString('fr-FR', { hour: '2-digit', minute: '2-digit' });
} }

View File

@@ -7,6 +7,7 @@
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
:my-ip="myIp"
@reply="$emit('reply', $event)" @reply="$emit('reply', $event)"
/> />
<div v-if="messages.length === 0" class="feed-empty"> <div v-if="messages.length === 0" class="feed-empty">
@@ -25,7 +26,7 @@ import type { Message } from '@/composables/useMessages';
import MessageItem from './MessageItem.vue'; import MessageItem from './MessageItem.vue';
import InlineCasinoAd from './InlineCasinoAd.vue'; import InlineCasinoAd from './InlineCasinoAd.vue';
const props = defineProps<{ messages: Message[]; hideAds?: boolean }>(); const props = defineProps<{ messages: Message[]; hideAds?: boolean; myIp?: string }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>(); defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const listEl = ref<HTMLElement | null>(null); const listEl = ref<HTMLElement | null>(null);

View File

@@ -1,12 +1,14 @@
<!-- 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"
:style="btnStyle"
aria-label="Envoyer" aria-label="Envoyer"
title="Clic droit pour personnaliser"
@click="$emit('send')" @click="$emit('send')"
@contextmenu.prevent="onRightClick"
> >
<!-- Flèche droite SVG (identique au SVG de la maquette) -->
<svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true"> <svg width="18" height="18" viewBox="0 0 18 18" fill="none" aria-hidden="true">
<polygon points="4,5 15,9 4,13 7,9" fill="currentColor" /> <polygon points="4,5 15,9 4,13 7,9" fill="currentColor" />
</svg> </svg>
@@ -14,38 +16,52 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { computed } from 'vue';
import { openContextMenu } from '@/composables/useContextMenu';
import { useCustomStyles, SEND_BUTTON_PRESETS } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
defineProps<{ disabled?: boolean }>(); defineProps<{ disabled?: boolean }>();
defineEmits<{ send: [] }>(); defineEmits<{ send: [] }>();
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const btnStyle = computed(() => {
const p = SEND_BUTTON_PRESETS[prefs.sendButton];
return { background: p.bg, color: p.color, borderRadius: p.radius };
});
function onRightClick(e: MouseEvent): void {
if (!myPerks.value.elementSkin) return;
openContextMenu({
x: e.clientX,
y: e.clientY,
title: 'Bouton d\'envoi',
items: Object.entries(SEND_BUTTON_PRESETS).map(([k, v]) => ({
value: k,
label: v.label,
swatch: v.color,
})),
current: prefs.sendButton,
onSelect: (v) => { prefs.sendButton = v as typeof prefs.sendButton; },
});
}
</script> </script>
<style scoped> <style scoped>
.send-btn { .send-btn {
width: 42px; width: 42px;
height: 42px; height: 42px;
border-radius: 50%;
flex-shrink: 0; flex-shrink: 0;
background: #004488; border: 1px solid #ffffff10;
border: 1px solid #004466;
color: #00ddff;
cursor: pointer; cursor: pointer;
display: flex; display: flex;
align-items: center; align-items: center;
justify-content: center; justify-content: center;
box-shadow: 0 0 12px #00448866; transition: filter 0.15s;
transition: background 0.15s, box-shadow 0.15s;
}
.send-btn:hover:not(:disabled) {
background: #005599;
box-shadow: 0 0 20px #00ddff55;
}
.send-btn:active:not(:disabled) {
background: #003377;
}
.send-btn:disabled {
opacity: 0.35;
cursor: not-allowed;
} }
.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; }
</style> </style>

View File

@@ -0,0 +1,130 @@
<!-- 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.value === state.current }"
@click="pick(item.value)"
>
<span v-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;
}
</style>

View File

@@ -0,0 +1,313 @@
<!-- Mes Personnalisations visible depuis le shop, onglet "Mes Persos" -->
<template>
<div class="persos">
<!-- Image de fond du chat -->
<section class="section">
<h2 class="section-title">🖼 Fond du chat</h2>
<p class="section-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})` }" />
</section>
<!-- ── Bouton d'envoi -->
<section class="section" :class="{ locked: !myPerks.elementSkin }">
<h2 class="section-title">
Bouton d'envoi
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
</h2>
<div class="style-grid">
<button
v-for="[k, p] in Object.entries(SEND_BUTTON_PRESETS)"
:key="k"
class="style-tile"
:class="{ 'style-tile--active': prefs.sendButton === k }"
:disabled="!myPerks.elementSkin"
@click="prefs.sendButton = k as SendButtonKey"
type="button"
>
<span class="style-swatch" :style="{ background: p.bg, color: p.color, borderRadius: p.radius }"></span>
<span class="style-label">{{ p.label }}</span>
</button>
</div>
</section>
<!-- Couleur de l'IP ────────────────────────────────────────── -->
<section class="section" :class="{ locked: !myPerks.elementSkin }">
<h2 class="section-title">
🎨 Couleur de mon IP
<span v-if="!myPerks.elementSkin" class="lock-badge">🔒 Skin d'éléments requis</span>
</h2>
<p v-if="myIp" class="section-sub">IP&nbsp;: <code class="code-ip" :style="ipPreviewStyle">{{ myIp }}</code></p>
<div class="style-grid">
<button
v-for="opt in IP_COLOR_OPTIONS"
:key="opt.value"
class="style-tile"
:class="{ 'style-tile--active': currentIpColor === opt.value }"
:disabled="!myPerks.elementSkin"
@click="setIpColor(opt.value)"
type="button"
>
<span v-if="opt.swatch" class="color-dot" :style="{ background: opt.swatch }" />
<span v-else class="color-dot color-dot--auto" />
<span class="style-label">{{ opt.label }}</span>
</button>
</div>
</section>
<!-- Pets -->
<section class="section" :class="{ locked: !hasPets }">
<h2 class="section-title">
Mes pets
<span v-if="!hasPets" class="lock-badge">Achetez un Pet dans le shop</span>
</h2>
<template v-if="hasPets">
<div class="pet-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="section-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="section-sub">Aucun pet possédé pour l'instant.</p>
</section>
</div>
</template>
<script setup lang="ts">
import { ref, computed, watch } from 'vue';
import { useCustomStyles, SEND_BUTTON_PRESETS, IP_COLOR_OPTIONS, type SendButtonKey } from '@/composables/useCustomStyles';
import { useMyPerks } from '@/composables/useMessages';
import { getIpColorWithPerks, getIpGlow } from '@/composables/ipColor';
import { useWallet } from '@/composables/useWallet';
const { prefs } = useCustomStyles();
const { myPerks } = useMyPerks();
const { ip: myIp } = useWallet();
// ── Background ──────────────────────────────────────────────────────────────
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 = ''; }
// ── IP color ────────────────────────────────────────────────────────────────
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) };
});
// ── Pets ────────────────────────────────────────────────────────────────────
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>
.persos {
display: flex;
flex-direction: column;
gap: 24px;
padding: 4px 0;
max-width: 640px;
}
.section {
background: #101018;
border: 1px solid #20203a;
border-radius: 10px;
padding: 18px 20px;
}
.section.locked {
opacity: 0.6;
}
.section-title {
font-size: 14px;
font-weight: bold;
color: #ccccee;
margin: 0 0 6px;
display: flex;
align-items: center;
gap: 10px;
}
.section-sub {
font-size: 11px;
color: #5a5a80;
margin: 0 0 12px;
}
.lock-badge {
font-size: 10px;
font-weight: normal;
color: #886644;
background: #1a1408;
border: 1px solid #44330066;
border-radius: 8px;
padding: 2px 8px;
}
/* Background */
.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 tiles */
.style-grid {
display: flex;
flex-wrap: wrap;
gap: 8px;
}
.style-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;
}
.style-tile:hover:not(:disabled) { background: #1a1a2e; border-color: #333355; }
.style-tile--active { border-color: #00ddff; background: #0a1a20; }
.style-tile:disabled { cursor: not-allowed; opacity: 0.5; }
.style-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;
}
.style-label {
font-size: 10px;
color: #8888aa;
white-space: nowrap;
}
.style-tile--active .style-label { color: #00ddff; }
/* Color dots */
.color-dot {
width: 20px; height: 20px;
border-radius: 50%;
border: 1px solid #ffffff22;
}
.color-dot--auto {
background: conic-gradient(#00ddff, #ff00cc, #00ee77, #ffdd44, #00ddff);
}
/* IP code */
.code-ip {
font-family: 'Courier New', monospace;
font-size: 12px;
font-weight: bold;
}
/* Pet grid */
.pet-grid {
display: flex;
flex-wrap: wrap;
gap: 6px;
}
.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

@@ -81,7 +81,15 @@
<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: redirige vers Mes Persos au lieu d'acheter -->
<button <button
v-if="product.kind === 'pet'"
class="buy buy--perso"
@click="$emit('goPerso')"
type="button"
>✨ Mes Persos</button>
<button
v-else
class="buy" class="buy"
:disabled="disabled" :disabled="disabled"
@click="onBuy" @click="onBuy"
@@ -103,7 +111,10 @@ const props = defineProps<{
freeMode: boolean; freeMode: boolean;
}>(); }>();
const emit = defineEmits<{ buy: [productId: string, options: PurchaseOptions] }>(); const emit = defineEmits<{
buy: [productId: string, options: PurchaseOptions];
goPerso: [];
}>();
const meta = computed<any>(() => { const meta = computed<any>(() => {
try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; } try { return props.product.metaJson ? JSON.parse(props.product.metaJson) : {}; }
@@ -293,4 +304,11 @@ function onBuy(): void {
} }
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; } .buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; }
.buy:disabled { opacity: 0.4; cursor: not-allowed; box-shadow: none; } .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> </style>

View File

@@ -0,0 +1,58 @@
/**
* 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
isHeader?: boolean; // non-interactive section heading
}
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,79 @@
/**
* 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;
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', 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

@@ -5,6 +5,14 @@ import { setPerks, applyPerksFrame, type Perks } from './usePerks';
import { bumpAdsRevision } from './useAds'; import { bumpAdsRevision } from './useAds';
import { handleAlertFrame } from './useAlert'; import { handleAlertFrame } from './useAlert';
// Module-level singleton so any component can read the viewer's own perks
// without prop-drilling (e.g. SendButton, AdBand).
export const myPerks = ref<Perks>({});
export function useMyPerks() {
return { myPerks };
}
export interface Reply { export interface Reply {
id: string; id: string;
content: string; content: string;
@@ -91,7 +99,7 @@ export function useMessages() {
const { fetchWallet, ip: myIp } = useWallet(); const { fetchWallet, ip: myIp } = useWallet();
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.). // The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
const myPerks = ref<Perks>({}); // myPerks is module-level; this ref is the same reference.
async function fetchMyPerks(): Promise<void> { async function fetchMyPerks(): Promise<void> {
try { try {
@@ -193,7 +201,8 @@ export function useMessages() {
stats, stats,
connected, connected,
sendTyping, sendTyping,
myPerks, get myPerks() { return myPerks; },
myIp,
fetchMyPerks, fetchMyPerks,
}; };
} }

View File

@@ -4,13 +4,10 @@
<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">
@@ -90,7 +87,6 @@
<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 SendButton from '@/components/SendButton.vue'; import SendButton from '@/components/SendButton.vue';
@@ -98,10 +94,22 @@ import StatsTicker from '@/components/StatsTicker.vue';
import { useMessages } from '@/composables/useMessages'; import { useMessages } from '@/composables/useMessages';
import { useAttachments } from '@/composables/useAttachments'; import { useAttachments } from '@/composables/useAttachments';
import { useAlert } from '@/composables/useAlert'; import { useAlert } from '@/composables/useAlert';
import { useCustomStyles } from '@/composables/useCustomStyles';
const { messages, sending, postMessage, stats, connected, sendTyping, myPerks } = useMessages(); const { messages, sending, postMessage, stats, connected, sendTyping, myPerks, myIp } = useMessages();
const { uploadFile, kb } = useAttachments(); const { uploadFile, kb } = useAttachments();
const { fireAlert } = useAlert(); const { fireAlert } = useAlert();
const { prefs: stylePrefs } = useCustomStyles();
const chatBgStyle = computed(() => {
if (!stylePrefs.chatBgUrl) return {};
return {
backgroundImage: `url(${stylePrefs.chatBgUrl})`,
backgroundSize: 'cover',
backgroundPosition: 'center',
backgroundRepeat: 'no-repeat',
};
});
const draft = ref(''); const draft = ref('');

View File

@@ -47,19 +47,25 @@
<div v-if="lastError" class="toast toast--err">{{ lastError }}</div> <div v-if="lastError" class="toast toast--err">{{ lastError }}</div>
<div v-else-if="lastSuccess" class="toast toast--ok"> Achat effectué</div> <div v-else-if="lastSuccess" class="toast toast--ok"> Achat effectué</div>
<div class="grid"> <!-- Mes Persos panel -->
<ProductCard <MesPersos v-if="activeCat === 'perso'" />
v-for="p in visibleProducts"
:key="p.id" <template v-else>
:product="p" <div class="grid">
:buying="buying === p.id" <ProductCard
:owns="owns" v-for="p in visibleProducts"
:pet-count="petCount()" :key="p.id"
:free-mode="freeMode" :product="p"
@buy="onBuy" :buying="buying === p.id"
/> :owns="owns"
</div> :pet-count="petCount()"
<p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p> :free-mode="freeMode"
@buy="onBuy"
@go-perso="activeCat = 'perso'"
/>
</div>
<p v-if="visibleProducts.length === 0" class="empty">Aucun produit dans cette catégorie.</p>
</template>
</main> </main>
</div> </div>
</div> </div>
@@ -70,6 +76,7 @@ import { ref, computed, onMounted, onUnmounted } from 'vue';
import { useShop, type PurchaseOptions } from '@/composables/useShop'; import { useShop, type PurchaseOptions } from '@/composables/useShop';
import { useWallet } from '@/composables/useWallet'; import { useWallet } from '@/composables/useWallet';
import ProductCard from '@/components/shop/ProductCard.vue'; import ProductCard from '@/components/shop/ProductCard.vue';
import MesPersos from '@/components/shop/MesPersos.vue';
const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, purchase } = useShop(); const { products, loading, buying, lastError, lastSuccess, fetchProducts, fetchMe, owns, petCount, purchase } = useShop();
const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet(); const { ip, freeMode, displayBalance, fetchWallet, topUp: walletTopUp } = useWallet();
@@ -81,6 +88,7 @@ const categories = [
{ id: 'cosmetiques', label: 'Cosmétiques' }, { id: 'cosmetiques', label: 'Cosmétiques' },
{ id: 'premium', label: 'Premium' }, { id: 'premium', label: 'Premium' },
{ id: 'promotions', label: 'Promotions' }, { id: 'promotions', label: 'Promotions' },
{ id: 'perso', label: '✨ Mes Persos' },
]; ];
const activeCat = ref('all'); const activeCat = ref('all');

View File

@@ -11,6 +11,7 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
host: true,
// Le projet vit sur /mnt/c (disque Windows) mais Vite tourne dans WSL : // Le projet vit sur /mnt/c (disque Windows) mais Vite tourne dans WSL :
// l'inotify natif ne reçoit pas les événements de fichiers Windows, donc le // l'inotify natif ne reçoit pas les événements de fichiers Windows, donc le
// HMR ne se déclenche jamais. Le polling règle ça de façon fiable. // HMR ne se déclenche jamais. Le polling règle ça de façon fiable.

26
scripts/deploy.sh Normal file
View File

@@ -0,0 +1,26 @@
#!/usr/bin/env bash
# Pull the latest main and (re)build the XIP stack on the deploy host.
# Invoked over SSH by the Gitea Actions workflow on every push to main,
# and runnable by hand on the CT for manual redeploys.
set -euo pipefail
APP_DIR="${XIP_APP_DIR:-/opt/xip}"
COMPOSE_FILE="docker-compose.prod.yml"
ENV_FILE=".env.prod"
cd "$APP_DIR"
echo "==> Fetching latest origin/main…"
git fetch --all --prune
git reset --hard origin/main
echo "==> Building + starting the stack…"
docker compose -f "$COMPOSE_FILE" --env-file "$ENV_FILE" up -d --build --remove-orphans
echo "==> Pruning dangling images…"
docker image prune -f >/dev/null 2>&1 || true
echo "==> Current state:"
docker compose -f "$COMPOSE_FILE" ps
echo "==> Deploy complete."