Compare commits
10 Commits
feat/marke
...
3c4a292db2
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
3c4a292db2 | ||
|
|
8471381048 | ||
|
|
09a9f6f321 | ||
|
|
48a99514b2 | ||
|
|
21e35107c7 | ||
|
|
024909b162 | ||
|
|
02bba16285 | ||
|
|
1a76e9076c | ||
| ccacd16edb | |||
|
|
fdce9e4eb8 |
14
.env.prod.example
Normal file
14
.env.prod.example
Normal 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
3
.gitattributes
vendored
Normal 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
|
||||||
35
.gitea/workflows/deploy.yml
Normal file
35
.gitea/workflows/deploy.yml
Normal 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'
|
||||||
1
.gitignore
vendored
1
.gitignore
vendored
@@ -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
64
DEPLOY.md
Normal 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
22
backend/Dockerfile
Normal 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"]
|
||||||
14
backend/docker-entrypoint.sh
Normal file
14
backend/docker-entrypoint.sh
Normal 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
|
||||||
@@ -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"],
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -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.
|
||||||
@@ -132,8 +132,6 @@ export async function purchase(
|
|||||||
break;
|
break;
|
||||||
}
|
}
|
||||||
case "pet": {
|
case "pet": {
|
||||||
if ((await countActiveEntitlements(ip, "pet")) >= 3)
|
|
||||||
throw new PurchaseError("Maximum 3 pets actifs", 409);
|
|
||||||
const char = options.petChar ?? "♥";
|
const char = options.petChar ?? "♥";
|
||||||
grants.push({
|
grants.push({
|
||||||
kind: "pet",
|
kind: "pet",
|
||||||
|
|||||||
@@ -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);
|
||||||
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 },
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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 } });
|
||||||
|
|||||||
@@ -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
42
deploy/nginx.conf
Normal 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
67
docker-compose.prod.yml
Normal 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
17
frontend/Dockerfile
Normal 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
|
||||||
@@ -4,7 +4,8 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"scripts": {
|
"scripts": {
|
||||||
"dev": "vite",
|
"dev": "vite",
|
||||||
"build": "vue-tsc && vite build",
|
"build": "vite build",
|
||||||
|
"typecheck": "vue-tsc --noEmit",
|
||||||
"preview": "vite preview"
|
"preview": "vite preview"
|
||||||
},
|
},
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
|||||||
@@ -1,3 +1,8 @@
|
|||||||
<template>
|
<template>
|
||||||
<RouterView />
|
<RouterView />
|
||||||
|
<StyleContextMenu />
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
|
<script setup lang="ts">
|
||||||
|
import StyleContextMenu from '@/components/StyleContextMenu.vue';
|
||||||
|
</script>
|
||||||
|
|||||||
@@ -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);
|
||||||
});
|
});
|
||||||
@@ -90,11 +115,11 @@ onMounted(fetchAds);
|
|||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
margin: 0;
|
margin: 0;
|
||||||
}
|
}
|
||||||
.ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; }
|
.ad-brand--blue { color: #4455aa; }
|
||||||
.ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; }
|
.ad-brand--green { color: #336644; }
|
||||||
.ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; }
|
.ad-brand--purple { color: #6633aa; }
|
||||||
.ad-brand--user { color: #ffcc44; text-shadow: 0 0 8px #aa8822; }
|
.ad-brand--user { color: #998833; }
|
||||||
.ad-brand--casino { color: #ff5533; text-shadow: 0 0 8px #aa2200; }
|
.ad-brand--casino { color: #884433; }
|
||||||
|
|
||||||
.ad-sub {
|
.ad-sub {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
|
|||||||
@@ -57,8 +57,7 @@ const { ip, freeMode, displayBalance } = useWallet();
|
|||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 18px;
|
font-size: 18px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #00eeff;
|
color: #7ab8cc;
|
||||||
text-shadow: 0 0 10px #00ccff99;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.chat-label {
|
.chat-label {
|
||||||
@@ -72,14 +71,13 @@ const { ip, freeMode, displayBalance } = useWallet();
|
|||||||
width: 8px;
|
width: 8px;
|
||||||
height: 8px;
|
height: 8px;
|
||||||
border-radius: 50%;
|
border-radius: 50%;
|
||||||
background: #00ff88;
|
background: #44aa66;
|
||||||
box-shadow: 0 0 6px #00ff44;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.online-count {
|
.online-count {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
color: #33ff66;
|
color: #557766;
|
||||||
}
|
}
|
||||||
|
|
||||||
.me-ip {
|
.me-ip {
|
||||||
@@ -98,26 +96,25 @@ const { ip, freeMode, displayBalance } = useWallet();
|
|||||||
padding: 3px 10px;
|
padding: 3px 10px;
|
||||||
font-family: 'Courier New', monospace;
|
font-family: 'Courier New', monospace;
|
||||||
}
|
}
|
||||||
.balance-coin { color: #ffcc44; font-size: 11px; }
|
.balance-coin { color: #aa8833; font-size: 11px; }
|
||||||
.balance-val { color: #ffdd66; font-size: 13px; font-weight: bold; text-shadow: 0 0 8px #ffaa0055; }
|
.balance-val { color: #ccaa44; font-size: 13px; font-weight: bold; }
|
||||||
.balance-unit { color: #886633; font-size: 9px; }
|
.balance-unit { color: #886633; font-size: 9px; }
|
||||||
.balance--free .balance-val { color: #33ff99; text-shadow: 0 0 8px #00ff6655; }
|
.balance--free .balance-val { color: #44aa77; }
|
||||||
.balance--free .balance-coin { color: #33ff99; }
|
.balance--free .balance-coin { color: #44aa77; }
|
||||||
|
|
||||||
.shop-link {
|
.shop-link {
|
||||||
font-family: Arial, sans-serif;
|
font-family: Arial, sans-serif;
|
||||||
font-size: 12px;
|
font-size: 12px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #00eeff;
|
color: #6699aa;
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
border: 1px solid #00eeff55;
|
border: 1px solid #33445566;
|
||||||
border-radius: 12px;
|
border-radius: 12px;
|
||||||
padding: 4px 12px;
|
padding: 4px 12px;
|
||||||
transition: background 0.15s, box-shadow 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.shop-link:hover {
|
.shop-link:hover {
|
||||||
background: #00eeff14;
|
background: #1a2530;
|
||||||
box-shadow: 0 0 10px #00ccff44;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
.channel-badge {
|
.channel-badge {
|
||||||
|
|||||||
@@ -47,7 +47,7 @@ onMounted(fetchAds);
|
|||||||
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 ── */
|
||||||
@@ -64,7 +64,7 @@ onMounted(fetchAds);
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -87,7 +87,7 @@ onMounted(fetchAds);
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -117,7 +117,7 @@ onMounted(fetchAds);
|
|||||||
font-size: 30px;
|
font-size: 30px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
color: #ffffff;
|
color: #ffffff;
|
||||||
text-shadow: 0 0 10px #ffdd00;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
/* ── CTA ── */
|
/* ── CTA ── */
|
||||||
@@ -136,13 +136,11 @@ onMounted(fetchAds);
|
|||||||
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 {
|
.disclaimer {
|
||||||
|
|||||||
@@ -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' });
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -95,8 +95,8 @@ const items = computed<Chip[]>(() => {
|
|||||||
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;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -110,15 +110,14 @@ const items = computed<Chip[]>(() => {
|
|||||||
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 {
|
||||||
@@ -126,17 +125,14 @@ const items = computed<Chip[]>(() => {
|
|||||||
font-size: 11px;
|
font-size: 11px;
|
||||||
font-weight: bold;
|
font-weight: bold;
|
||||||
letter-spacing: 2px;
|
letter-spacing: 2px;
|
||||||
color: #00ff88;
|
color: #448866;
|
||||||
text-shadow: 0 0 8px #00ff4466;
|
|
||||||
}
|
}
|
||||||
.ticker.is-off .ticker-dot {
|
.ticker.is-off .ticker-dot {
|
||||||
background: #ff3344;
|
background: #884444;
|
||||||
box-shadow: 0 0 8px #ff2233;
|
|
||||||
animation: none;
|
animation: none;
|
||||||
}
|
}
|
||||||
.ticker.is-off .ticker-badge-txt {
|
.ticker.is-off .ticker-badge-txt {
|
||||||
color: #ff5566;
|
color: #885555;
|
||||||
text-shadow: none;
|
|
||||||
}
|
}
|
||||||
@keyframes blink {
|
@keyframes blink {
|
||||||
0%, 100% { opacity: 1; }
|
0%, 100% { opacity: 1; }
|
||||||
@@ -206,10 +202,10 @@ const items = computed<Chip[]>(() => {
|
|||||||
color: #50506e;
|
color: #50506e;
|
||||||
}
|
}
|
||||||
|
|
||||||
.chip--cyan .chip-val { color: #00eeff; text-shadow: 0 0 9px #00ccff55; }
|
.chip--cyan .chip-val { color: #5599aa; }
|
||||||
.chip--green .chip-val { color: #33ff77; text-shadow: 0 0 9px #00ff4455; }
|
.chip--green .chip-val { color: #447755; }
|
||||||
.chip--magenta .chip-val { color: #ff44cc; text-shadow: 0 0 9px #ff22aa55; }
|
.chip--magenta .chip-val { color: #885588; }
|
||||||
.chip--orange .chip-val { color: #ffaa44; text-shadow: 0 0 9px #ff880055; }
|
.chip--orange .chip-val { color: #997744; }
|
||||||
|
|
||||||
/* Accessibilité : pas de défilement si l'utilisateur le refuse */
|
/* Accessibilité : pas de défilement si l'utilisateur le refuse */
|
||||||
@media (prefers-reduced-motion: reduce) {
|
@media (prefers-reduced-motion: reduce) {
|
||||||
|
|||||||
130
frontend/src/components/StyleContextMenu.vue
Normal file
130
frontend/src/components/StyleContextMenu.vue
Normal 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>
|
||||||
313
frontend/src/components/shop/MesPersos.vue
Normal file
313
frontend/src/components/shop/MesPersos.vue
Normal 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 : <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 : <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>
|
||||||
@@ -81,7 +81,22 @@
|
|||||||
<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 -->
|
||||||
|
<template v-if="product.kind === 'pet'">
|
||||||
|
<button
|
||||||
|
class="buy"
|
||||||
|
:disabled="disabled"
|
||||||
|
@click="onBuy"
|
||||||
|
type="button"
|
||||||
|
>{{ buyLabel }}</button>
|
||||||
|
<button
|
||||||
|
class="buy buy--perso"
|
||||||
|
@click="$emit('goPerso')"
|
||||||
|
type="button"
|
||||||
|
>✨ Mes Persos</button>
|
||||||
|
</template>
|
||||||
<button
|
<button
|
||||||
|
v-else
|
||||||
class="buy"
|
class="buy"
|
||||||
:disabled="disabled"
|
:disabled="disabled"
|
||||||
@click="onBuy"
|
@click="onBuy"
|
||||||
@@ -103,7 +118,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) : {}; }
|
||||||
@@ -165,16 +183,14 @@ const ownedAlready = computed(() => {
|
|||||||
return false;
|
return false;
|
||||||
});
|
});
|
||||||
|
|
||||||
const petFull = computed(() => props.product.kind === 'pet' && props.petCount >= 3);
|
|
||||||
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
|
const soldOut = computed(() => props.product.stockLimit != null && props.product.stockSold >= props.product.stockLimit);
|
||||||
|
|
||||||
const disabled = computed(() => props.buying || ownedAlready.value || petFull.value || soldOut.value);
|
const disabled = computed(() => props.buying || ownedAlready.value || soldOut.value);
|
||||||
|
|
||||||
const buyLabel = computed(() => {
|
const buyLabel = computed(() => {
|
||||||
if (props.buying) return '...';
|
if (props.buying) return '...';
|
||||||
if (soldOut.value) return 'Épuisé';
|
if (soldOut.value) return 'Épuisé';
|
||||||
if (ownedAlready.value) return 'Possédé ✓';
|
if (ownedAlready.value) return 'Possédé ✓';
|
||||||
if (petFull.value) return 'Max 3 pets';
|
|
||||||
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
|
return props.freeMode ? 'Obtenir (gratuit)' : 'Acheter';
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -231,7 +247,7 @@ function onBuy(): void {
|
|||||||
letter-spacing: 0.5px;
|
letter-spacing: 0.5px;
|
||||||
padding: 3px 9px;
|
padding: 3px 9px;
|
||||||
border-radius: 8px;
|
border-radius: 8px;
|
||||||
box-shadow: 0 0 10px #ff226688;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
|
|
||||||
.card-head { display: flex; gap: 12px; align-items: flex-start; }
|
.card-head { display: flex; gap: 12px; align-items: flex-start; }
|
||||||
@@ -245,7 +261,7 @@ function onBuy(): void {
|
|||||||
}
|
}
|
||||||
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
|
.prev-ip { font-family: 'Courier New', monospace; font-size: 12px; font-weight: bold; }
|
||||||
.prev-plain { color: #666688; }
|
.prev-plain { color: #666688; }
|
||||||
.prev-gold { color: #ffdd44; text-shadow: 0 0 8px #ffaa00cc; }
|
.prev-gold { color: #aa8833; }
|
||||||
.prev-arrow { color: #444466; }
|
.prev-arrow { color: #444466; }
|
||||||
|
|
||||||
.opts { display: flex; flex-direction: column; gap: 8px; }
|
.opts { display: flex; flex-direction: column; gap: 8px; }
|
||||||
@@ -272,7 +288,7 @@ function onBuy(): void {
|
|||||||
aspect-ratio: 1; background: #0c0c16; border: 1px solid #20203a; border-radius: 6px;
|
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;
|
font-size: 18px; color: #ccccee; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
||||||
}
|
}
|
||||||
.pet-cell.active { border-color: #ff44cc; box-shadow: 0 0 8px #ff44cc55; }
|
.pet-cell.active { border-color: #8844aa; }
|
||||||
.pet-pos { display: flex; gap: 6px; }
|
.pet-pos { display: flex; gap: 6px; }
|
||||||
|
|
||||||
.stock { display: flex; flex-direction: column; gap: 4px; }
|
.stock { display: flex; flex-direction: column; gap: 4px; }
|
||||||
@@ -280,17 +296,24 @@ function onBuy(): void {
|
|||||||
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
|
.stock-fill { height: 100%; background: linear-gradient(90deg, #ffaa00, #ff4444); }
|
||||||
.stock-txt { font-size: 10px; color: #886644; }
|
.stock-txt { font-size: 10px; color: #886644; }
|
||||||
|
|
||||||
.card-foot { display: flex; align-items: center; justify-content: space-between; margin-top: auto; padding-top: 6px; }
|
.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 { display: flex; align-items: baseline; gap: 6px; }
|
||||||
.price-old { font-size: 12px; color: #555; text-decoration: line-through; }
|
.price-old { font-size: 12px; color: #555; text-decoration: line-through; }
|
||||||
.price-now { font-size: 20px; font-weight: bold; color: #ffdd66; font-family: 'Courier New', monospace; text-shadow: 0 0 10px #ffaa0044; }
|
.price-now { font-size: 20px; font-weight: bold; color: #ccaa44; font-family: 'Courier New', monospace; }
|
||||||
.price-unit { font-size: 11px; color: #886633; }
|
.price-unit { font-size: 11px; color: #886633; }
|
||||||
|
|
||||||
.buy {
|
.buy {
|
||||||
background: #004488; border: 1px solid #0066aa; color: #00ddff;
|
background: #004488; border: 1px solid #0066aa; color: #00ddff;
|
||||||
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
|
font-size: 13px; font-weight: bold; padding: 9px 18px; border-radius: 20px; cursor: pointer;
|
||||||
box-shadow: 0 0 12px #00448855; transition: background 0.15s, box-shadow 0.15s;
|
transition: background 0.15s;
|
||||||
}
|
}
|
||||||
.buy:hover:not(:disabled) { background: #005599; box-shadow: 0 0 18px #00ddff55; }
|
.buy:hover:not(:disabled) { background: #1a4466; }
|
||||||
.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>
|
||||||
|
|||||||
@@ -1,5 +1,5 @@
|
|||||||
/** 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
|
||||||
@@ -11,7 +11,7 @@ export function getIpColor(ip: string): string {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getIpGlow(color: string): string {
|
export function getIpGlow(color: string): string {
|
||||||
return color === '#666688' ? 'none' : `0 0 8px ${color}80`;
|
return 'none';
|
||||||
}
|
}
|
||||||
|
|
||||||
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
|
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
|
||||||
@@ -28,6 +28,5 @@ export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
|
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
|
||||||
if (perks?.skin === 'gold') return `0 0 10px ${GOLD}cc`;
|
return 'none';
|
||||||
return getIpGlow(getIpColor(ip));
|
|
||||||
}
|
}
|
||||||
|
|||||||
58
frontend/src/composables/useContextMenu.ts
Normal file
58
frontend/src/composables/useContextMenu.ts
Normal 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 };
|
||||||
|
}
|
||||||
79
frontend/src/composables/useCustomStyles.ts
Normal file
79
frontend/src/composables/useCustomStyles.ts
Normal 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 };
|
||||||
|
}
|
||||||
@@ -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;
|
||||||
@@ -31,6 +39,37 @@ export interface Message extends Reply {
|
|||||||
|
|
||||||
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
const API_URL = import.meta.env.VITE_API_URL ?? 'http://localhost:3000';
|
||||||
|
|
||||||
|
/** Refresh the viewer's own perks from the server (callable from anywhere). */
|
||||||
|
export async function refreshMyPerks(): Promise<void> {
|
||||||
|
try {
|
||||||
|
const res = await fetch(`${API_URL}/api/shop/me`);
|
||||||
|
if (!res.ok) return;
|
||||||
|
const { entitlements } = (await res.json()) as {
|
||||||
|
entitlements: { kind: string; metaJson?: string | null }[];
|
||||||
|
};
|
||||||
|
const p: Perks = {};
|
||||||
|
const pets: { char: string; position: 'left' | 'right' | 'both' }[] = [];
|
||||||
|
for (const e of entitlements) {
|
||||||
|
let meta: any = {};
|
||||||
|
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';
|
||||||
|
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' });
|
||||||
|
if (e.kind === 'element-skin') p.elementSkin = true;
|
||||||
|
if (e.kind === 'rich-htmlcss') p.richHtmlcss = true;
|
||||||
|
if (e.kind === 'rich-js') p.richJs = true;
|
||||||
|
if (e.kind === 'no-file-limit') p.noFileLimit = true;
|
||||||
|
if (e.kind === 'audio-alert') p.audioAlert = true;
|
||||||
|
}
|
||||||
|
if (pets.length) p.pets = pets;
|
||||||
|
myPerks.value = p;
|
||||||
|
const { ip } = useWallet();
|
||||||
|
if (ip.value) setPerks(ip.value, p);
|
||||||
|
} catch {
|
||||||
|
/* ignore */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
export function useMessages() {
|
export function useMessages() {
|
||||||
const messages = ref<Message[]>([]);
|
const messages = ref<Message[]>([]);
|
||||||
const loading = ref(false);
|
const loading = ref(false);
|
||||||
@@ -91,35 +130,10 @@ 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 {
|
return refreshMyPerks();
|
||||||
const res = await fetch(`${API_URL}/api/shop/me`);
|
|
||||||
if (!res.ok) return;
|
|
||||||
const { entitlements } = (await res.json()) as {
|
|
||||||
entitlements: { kind: string; metaJson?: string | null }[];
|
|
||||||
};
|
|
||||||
const p: Perks = {};
|
|
||||||
const pets: { char: string; position: 'left' | 'right' | 'both' }[] = [];
|
|
||||||
for (const e of entitlements) {
|
|
||||||
let meta: any = {};
|
|
||||||
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';
|
|
||||||
if (e.kind === 'pet' && meta.char) pets.push({ char: meta.char, position: meta.position ?? 'left' });
|
|
||||||
if (e.kind === 'element-skin') p.elementSkin = true;
|
|
||||||
if (e.kind === 'rich-htmlcss') p.richHtmlcss = true;
|
|
||||||
if (e.kind === 'rich-js') p.richJs = true;
|
|
||||||
if (e.kind === 'no-file-limit') p.noFileLimit = true;
|
|
||||||
if (e.kind === 'audio-alert') p.audioAlert = true;
|
|
||||||
}
|
|
||||||
if (pets.length) p.pets = pets.slice(0, 3);
|
|
||||||
myPerks.value = p;
|
|
||||||
if (myIp.value) setPerks(myIp.value, p);
|
|
||||||
} catch {
|
|
||||||
/* ignore */
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const { stats, connected, sendTyping } = useRealtime({
|
const { stats, connected, sendTyping } = useRealtime({
|
||||||
@@ -193,7 +207,8 @@ export function useMessages() {
|
|||||||
stats,
|
stats,
|
||||||
connected,
|
connected,
|
||||||
sendTyping,
|
sendTyping,
|
||||||
myPerks,
|
get myPerks() { return myPerks; },
|
||||||
|
myIp,
|
||||||
fetchMyPerks,
|
fetchMyPerks,
|
||||||
};
|
};
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -1,5 +1,6 @@
|
|||||||
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. */
|
/** Marketplace client: catalogue, my entitlements, purchase flow. */
|
||||||
|
|
||||||
@@ -96,8 +97,8 @@ export function useShop() {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
lastSuccess.value = `Acheté : ${productId}`;
|
lastSuccess.value = `Acheté : ${productId}`;
|
||||||
// Refresh wallet + my entitlements (WS also pushes wallet, this is belt-and-braces).
|
// Refresh wallet + my entitlements + myPerks (WS also pushes wallet, this is belt-and-braces).
|
||||||
await Promise.all([fetchWallet(), fetchMe(), fetchProducts()]);
|
await Promise.all([fetchWallet(), fetchMe(), fetchProducts(), refreshMyPerks()]);
|
||||||
return true;
|
return true;
|
||||||
} catch {
|
} catch {
|
||||||
lastError.value = 'Réseau indisponible';
|
lastError.value = 'Réseau indisponible';
|
||||||
|
|||||||
@@ -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('');
|
||||||
|
|
||||||
@@ -273,7 +281,7 @@ async function submit(): Promise<void> {
|
|||||||
}
|
}
|
||||||
.icon-btn:hover { background: #1c1c2e; }
|
.icon-btn:hover { background: #1c1c2e; }
|
||||||
.icon-btn--alert { border-color: #aa3344; }
|
.icon-btn--alert { border-color: #aa3344; }
|
||||||
.icon-btn--alert:hover { background: #2a1418; box-shadow: 0 0 10px #ff224455; }
|
.icon-btn--alert:hover { background: #1e1218; }
|
||||||
|
|
||||||
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
|
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
|
||||||
.input-field {
|
.input-field {
|
||||||
|
|||||||
@@ -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');
|
||||||
|
|
||||||
@@ -148,17 +156,17 @@ onUnmounted(() => { if (timer) clearInterval(timer); });
|
|||||||
border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px;
|
border: 1px solid #00aaff44; border-radius: 10px; padding: 4px 10px;
|
||||||
}
|
}
|
||||||
.sh-back:hover { background: #00aaff14; }
|
.sh-back:hover { background: #00aaff14; }
|
||||||
.sh-title { font-size: 18px; font-weight: bold; color: #00eeff; text-shadow: 0 0 10px #00ccff99; }
|
.sh-title { font-size: 18px; font-weight: bold; color: #6699aa; }
|
||||||
.sh-sub { font-size: 13px; color: #8888aa; }
|
.sh-sub { font-size: 13px; color: #8888aa; }
|
||||||
.sh-right { display: flex; align-items: center; gap: 12px; }
|
.sh-right { display: flex; align-items: center; gap: 12px; }
|
||||||
.sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; }
|
.sh-ip { font-family: 'Courier New', monospace; font-size: 11px; color: #5566aa; }
|
||||||
.sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ffdd66; text-shadow: 0 0 10px #ffaa0044; }
|
.sh-balance { font-family: 'Courier New', monospace; font-size: 15px; font-weight: bold; color: #ccaa44; }
|
||||||
.sh-balance.free { color: #33ff99; text-shadow: 0 0 10px #00ff6644; }
|
.sh-balance.free { color: #44aa77; }
|
||||||
.sh-cr { font-size: 10px; color: #886633; }
|
.sh-cr { font-size: 10px; color: #886633; }
|
||||||
.sh-topup {
|
.sh-topup {
|
||||||
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
background: #1a2a1a; border: 1px solid #33aa55; color: #44dd77;
|
||||||
font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer;
|
font-size: 12px; font-weight: bold; padding: 6px 14px; border-radius: 16px; cursor: pointer;
|
||||||
box-shadow: 0 0 10px #33aa5533;
|
box-shadow: none;
|
||||||
}
|
}
|
||||||
.sh-topup:hover { background: #234a23; }
|
.sh-topup:hover { background: #234a23; }
|
||||||
|
|
||||||
|
|||||||
@@ -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
26
scripts/deploy.sh
Normal 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" --env-file "$ENV_FILE" ps
|
||||||
|
|
||||||
|
echo "==> Deploy complete."
|
||||||
Reference in New Issue
Block a user