Compare commits

..

1 Commits

Author SHA1 Message Date
raphael.thieffry
fdce9e4eb8 feat: live messages via SSE + real client IP
- backend: SSE endpoint /api/messages/stream backed by Redis pub/sub
- backend: read real client IP via getConnInfo (fallback for x-forwarded-for)
- backend: CORS allow any origin (dev: LAN access from phone)
- frontend: useMessages subscribes via EventSource, auto-reconnect, merges new messages/replies live
- frontend: vite host:true to expose dev server on LAN

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

1
backend/.gitignore vendored
View File

@@ -1 +0,0 @@
uploads/

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,38 +0,0 @@
import type { Context } from "hono";
import { getConnInfo } from "hono/bun";
/**
* Best-effort client IP.
* Prefer x-forwarded-for (set when behind a proxy), fall back to the raw socket
* address from Bun. In local dev (frontend:5173 → backend:3000, no proxy) this
* is typically 127.0.0.1 / ::1.
*/
export function getClientIp(c: Context): string {
const fwd = c.req.header("x-forwarded-for");
if (fwd) {
const first = fwd.split(",")[0]?.trim();
if (first) return first;
}
try {
const addr = getConnInfo(c).remote.address;
if (addr) return addr;
} catch {
/* getConnInfo only works under the Bun adapter */
}
return "127.0.0.1";
}
/**
* Is this IP the local machine? Drives the README rule "si localhost: pas de
* paywall (tout gratuit)". Covers IPv4 loopback, IPv6 loopback, and the
* IPv4-mapped-IPv6 form Bun sometimes reports.
*/
export function isLocalhost(ip: string): boolean {
return (
ip === "127.0.0.1" ||
ip === "::1" ||
ip === "::ffff:127.0.0.1" ||
ip === "localhost" ||
ip.startsWith("127.")
);
}

View File

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

View File

@@ -1,23 +1,18 @@
import Redis from "ioredis"; import Redis from "ioredis";
const globalForRedis = globalThis as unknown as { redis?: Redis }; const URL = process.env.REDIS_URL ?? "redis://localhost:6379";
const REDIS_URL = process.env.REDIS_URL ?? "redis://127.0.0.1:6379"; const globalForRedis = globalThis as unknown as {
redisPub?: Redis;
redisSub?: Redis;
};
export const redis = export const redisPub = globalForRedis.redisPub ?? new Redis(URL);
globalForRedis.redis ?? export const redisSub = globalForRedis.redisSub ?? new Redis(URL);
new Redis(REDIS_URL, {
lazyConnect: false,
maxRetriesPerRequest: 3,
// Keep the dev server alive even if Redis hiccups; stats are best-effort.
enableOfflineQueue: true,
});
redis.on("error", (err) => {
// Don't crash the app on Redis errors — stats are non-critical.
console.warn("⚠️ Redis error:", err.message);
});
if (process.env.NODE_ENV !== "production") { if (process.env.NODE_ENV !== "production") {
globalForRedis.redis = redis; globalForRedis.redisPub = redisPub;
globalForRedis.redisSub = redisSub;
} }
export const MESSAGES_CHANNEL = "xip:messages";

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -1,68 +1,67 @@
import { Hono } from "hono"; import { Hono, type Context } from "hono";
import { streamSSE } from "hono/streaming";
import { getConnInfo } from "hono/bun";
import { prisma } from "../lib/prisma"; import { prisma } from "../lib/prisma";
import { getClientIp, isLocalhost } from "../lib/ip"; import { redisPub, redisSub, MESSAGES_CHANNEL } from "../lib/redis";
import { recordMessage } from "../lib/stats";
import { broadcastNewMessage } from "../realtime";
import { getPerksForIp, getPerksForIps } from "../lib/perks";
const messages = new Hono(); const messages = new Hono();
const RICH_MAX = 64 * 1024; // 64 KB cap on rich markup function clientIp(c: Context): string {
const fwd = c.req.header("x-forwarded-for");
/** Does this IP own the entitlement needed for a rich tier? */ if (fwd) return fwd.split(",")[0].trim();
async function ownsRich(ip: string, mode: "htmlcss" | "js"): Promise<boolean> { try {
if (isLocalhost(ip)) return true; return getConnInfo(c).remote.address ?? "0.0.0.0";
const kind = mode === "js" ? "rich-js" : "rich-htmlcss"; } catch {
const now = new Date(); return "0.0.0.0";
const rows = await prisma.entitlement.findMany({ where: { ip, kind, active: true } }); }
return rows.some((e) => !e.expiresAt || e.expiresAt >= now);
} }
// GET /api/messages — top-level threads with replies, annotated with author perks. // GET /api/messages — top-level threads with replies
messages.get("/", async (c) => { messages.get("/", async (c) => {
const data = await prisma.message.findMany({ const data = await prisma.message.findMany({
where: { parentId: null }, where: { parentId: null },
orderBy: { createdAt: "desc" }, orderBy: { createdAt: "desc" },
take: 50, take: 50,
include: { include: {
attachments: { select: { id: true, filename: true, mimeType: true, size: true } }, replies: { orderBy: { createdAt: "asc" } },
replies: {
orderBy: { createdAt: "asc" },
include: {
attachments: { select: { id: true, filename: true, mimeType: true, size: true } },
},
},
}, },
}); });
return c.json(data);
// Collect every distinct author IP (threads + replies) and resolve perks once.
const ips = new Set<string>();
for (const m of data) {
ips.add(m.authorIp);
for (const r of m.replies) ips.add(r.authorIp);
}
const perks = await getPerksForIps([...ips]);
const annotated = data.map((m) => ({
...m,
authorPerks: perks[m.authorIp] ?? {},
replies: m.replies.map((r) => ({ ...r, authorPerks: perks[r.authorIp] ?? {} })),
}));
return c.json(annotated);
}); });
// POST /api/messages — create a message or reply (optionally rich + attachments) // GET /api/messages/stream — SSE live feed
messages.post("/", async (c) => { messages.get("/stream", (c) =>
const ip = getClientIp(c); streamSSE(c, async (stream) => {
const sub = redisSub.duplicate();
await sub.subscribe(MESSAGES_CHANNEL);
const body = await c.req.json<{ sub.on("message", (channel, payload) => {
content: string; if (channel !== MESSAGES_CHANNEL) return;
parentId?: string; stream.writeSSE({ event: "message", data: payload }).catch(() => {});
richMode?: "htmlcss" | "js"; });
richContent?: string;
attachmentIds?: string[]; await stream.writeSSE({ event: "ready", data: "ok" });
}>();
const ping = setInterval(() => {
stream
.writeSSE({ event: "ping", data: String(Date.now()) })
.catch(() => {});
}, 25_000);
await new Promise<void>((resolve) => {
stream.onAbort(() => {
clearInterval(ping);
sub.disconnect();
resolve();
});
});
})
);
// POST /api/messages — create a message or reply
messages.post("/", async (c) => {
const ip = clientIp(c);
const body = await c.req.json<{ content: string; parentId?: string }>();
if (!body.content || body.content.trim().length === 0) { if (!body.content || body.content.trim().length === 0) {
return c.json({ error: "Content is required" }, 400); return c.json({ error: "Content is required" }, 400);
@@ -71,52 +70,16 @@ messages.post("/", async (c) => {
return c.json({ error: "Content exceeds 267 characters" }, 400); return c.json({ error: "Content exceeds 267 characters" }, 400);
} }
// Rich content: validate tier ownership + size.
let richMode: "none" | "htmlcss" | "js" = "none";
let richContent: string | null = null;
if (body.richMode && body.richContent && body.richContent.trim().length > 0) {
if (body.richMode !== "htmlcss" && body.richMode !== "js") {
return c.json({ error: "richMode invalide" }, 400);
}
if (!(await ownsRich(ip, body.richMode))) {
return c.json({ error: "Fonctionnalité non débloquée" }, 402);
}
if (body.richContent.length > RICH_MAX) {
return c.json({ error: "Contenu riche trop volumineux" }, 413);
}
richMode = body.richMode;
richContent = body.richContent;
}
const content = body.content.trim();
const parentId = body.parentId ?? null;
const message = await prisma.message.create({ const message = await prisma.message.create({
data: { content, authorIp: ip, parentId, richMode, richContent }, data: {
content: body.content.trim(),
authorIp: ip,
parentId: body.parentId ?? null,
},
}); });
// Link any pre-uploaded attachments owned by this IP to the new message. await redisPub.publish(MESSAGES_CHANNEL, JSON.stringify(message));
let attachments: any[] = []; return c.json(message, 201);
if (Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0) {
await prisma.attachment.updateMany({
where: { id: { in: body.attachmentIds }, ip, messageId: null },
data: { messageId: message.id },
});
attachments = await prisma.attachment.findMany({
where: { messageId: message.id },
select: { id: true, filename: true, mimeType: true, size: true },
});
}
// Update persistent stats and push the message to every connected tab,
// annotated with the author's perks so it renders correctly everywhere.
void recordMessage(content.length, parentId !== null);
const authorPerks = await getPerksForIp(ip);
const enriched = { ...message, attachments, authorPerks };
const payload = parentId === null ? { ...enriched, replies: [] } : enriched;
broadcastNewMessage(payload);
return c.json(enriched, 201);
}); });
export default messages; export default messages;

View File

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

View File

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

View File

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

View File

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

View File

@@ -19,11 +19,8 @@ services:
redis: redis:
image: redis:7 image: redis:7
restart: unless-stopped restart: unless-stopped
command: ["redis-server", "--appendonly", "yes"]
ports: ports:
- "6379:6379" - "6379:6379"
volumes:
- redis_data:/data
healthcheck: healthcheck:
test: ["CMD", "redis-cli", "ping"] test: ["CMD", "redis-cli", "ping"]
interval: 5s interval: 5s
@@ -32,4 +29,3 @@ services:
volumes: volumes:
postgres_data: postgres_data:
redis_data:

View File

@@ -1,48 +1,49 @@
<!-- Bande publicitaire gauche (130 px) pilotée par l'inventaire de pubs réel --> <!-- Bande publicitaire gauche (130 px) -->
<template> <template>
<aside class="ad-band"> <aside class="ad-band">
<p class="ad-label">PUBLICITÉ</p> <p class="ad-label">PUBLICITÉ</p>
<component <!-- NOVA STORE -->
:is="ad.url ? 'a' : 'div'" <div class="ad-card">
v-for="ad in ads" <div class="ad-header ad-header--blue">
:key="ad.id" <p class="ad-brand ad-brand--blue">NOVA</p>
class="ad-card" <p class="ad-sub">STORE 2026</p>
:href="ad.url || undefined"
target="_blank"
rel="noopener noreferrer nofollow"
>
<div class="ad-header" :class="`ad-header--${ad.tone}`">
<p class="ad-brand" :class="`ad-brand--${ad.tone}`">{{ ad.brand }}</p>
<p v-if="ad.subtitle" class="ad-sub">{{ ad.subtitle }}</p>
</div> </div>
<div class="ad-body" :class="`ad-body--${ad.tone}`"> <div class="ad-body">
<span class="ad-icon">{{ ad.icon || '📢' }}</span> <span class="ad-icon">🛒</span>
</div>
<p class="ad-cta ad-cta--blue">DÉCOUVRIR</p>
<p class="ad-url">nova-store.io</p>
</div>
<!-- APEX GEAR -->
<div class="ad-card">
<div class="ad-header ad-header--green">
<p class="ad-brand ad-brand--green">APEX GEAR</p>
<p class="ad-sub">Gaming Setup</p>
</div>
<div class="ad-body ad-body--green">
<span class="ad-icon">🎮</span>
</div>
<p class="ad-cta ad-cta--green">ACHETER</p>
<p class="ad-url">apex-gear.com</p>
</div>
<!-- SHIELDVPN -->
<div class="ad-card">
<div class="ad-header ad-header--purple">
<p class="ad-brand ad-brand--purple">SHIELDVPN</p>
<p class="ad-sub">Sécurité totale</p>
</div>
<div class="ad-body ad-body--purple">
<span class="ad-icon">🔒</span>
</div>
<p class="ad-cta ad-cta--purple">ESSAI GRATUIT</p>
<p class="ad-url">shieldvpn.net</p>
</div> </div>
<p v-if="ad.cta" class="ad-cta" :class="`ad-cta--${ad.tone}`">{{ ad.cta }}</p>
<p v-if="ad.url" class="ad-url">{{ prettyUrl(ad.url) }}</p>
</component>
</aside> </aside>
</template> </template>
<script setup lang="ts">
import { onMounted, watch } from 'vue';
import { useAds } from '@/composables/useAds';
const { ads, fetchAds, reportImpression } = useAds('band');
function prettyUrl(url: string): string {
return url.replace(/^https?:\/\//, '').replace(/\/$/, '');
}
// Report one impression per ad each time the set (re)loads.
watch(ads, (list) => {
for (const a of list) reportImpression(a.id);
});
onMounted(fetchAds);
</script>
<style scoped> <style scoped>
.ad-band { .ad-band {
width: 130px; width: 130px;
@@ -81,8 +82,6 @@ onMounted(fetchAds);
.ad-header--blue { background: #161620; } .ad-header--blue { background: #161620; }
.ad-header--green { background: #101614; } .ad-header--green { background: #101614; }
.ad-header--purple { background: #16101a; } .ad-header--purple { background: #16101a; }
.ad-header--user { background: #1a1606; }
.ad-header--casino { background: #1a0606; }
.ad-brand { .ad-brand {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -93,8 +92,6 @@ onMounted(fetchAds);
.ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; } .ad-brand--blue { color: #5555cc; text-shadow: 0 0 8px #4444aa; }
.ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; } .ad-brand--green { color: #33aa55; text-shadow: 0 0 8px #225533; }
.ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; } .ad-brand--purple { color: #9944dd; text-shadow: 0 0 8px #6622aa; }
.ad-brand--user { color: #ffcc44; text-shadow: 0 0 8px #aa8822; }
.ad-brand--casino { color: #ff5533; text-shadow: 0 0 8px #aa2200; }
.ad-sub { .ad-sub {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
@@ -111,8 +108,6 @@ onMounted(fetchAds);
} }
.ad-body--green { background: #0e160e; } .ad-body--green { background: #0e160e; }
.ad-body--purple { background: #110e16; } .ad-body--purple { background: #110e16; }
.ad-body--user { background: #16140e; }
.ad-body--casino { background: #160e0e; }
.ad-icon { font-size: 24px; } .ad-icon { font-size: 24px; }
@@ -124,15 +119,10 @@ onMounted(fetchAds);
.ad-cta--blue { color: #3a3a88; } .ad-cta--blue { color: #3a3a88; }
.ad-cta--green { color: #33aa55; } .ad-cta--green { color: #33aa55; }
.ad-cta--purple { color: #9944dd; } .ad-cta--purple { color: #9944dd; }
.ad-cta--user { color: #ffcc44; }
.ad-cta--casino { color: #ff5533; }
.ad-url { .ad-url {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 8px; font-size: 8px;
color: #282840; color: #282840;
} }
/* Carte cliquable : pas de soulignement, héritage couleur */
a.ad-card { text-decoration: none; display: block; }
</style> </style>

View File

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

View File

@@ -7,26 +7,12 @@
<span class="online-dot" aria-hidden="true" /> <span class="online-dot" aria-hidden="true" />
<span class="online-count">{{ connectedCount }} connectés</span> <span class="online-count">{{ connectedCount }} connectés</span>
</div> </div>
<div class="channel-badge"># général</div>
<div class="header-right">
<span v-if="ip" class="me-ip" :title="'Ton pseudo = ton IP'">{{ ip }}</span>
<span class="balance" :class="{ 'balance--free': freeMode }" title="Tes crédits XIP">
<span class="balance-coin"></span>
<span class="balance-val">{{ displayBalance() }}</span>
<span class="balance-unit">cr</span>
</span>
<router-link to="/shop" class="shop-link">🛒 Shop</router-link>
<span class="channel-badge"># général</span>
</div>
</header> </header>
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import { useWallet } from '@/composables/useWallet';
defineProps<{ connectedCount: number }>(); defineProps<{ connectedCount: number }>();
const { ip, freeMode, displayBalance } = useWallet();
</script> </script>
<style scoped> <style scoped>
@@ -47,12 +33,6 @@ const { ip, freeMode, displayBalance } = useWallet();
gap: 8px; gap: 8px;
} }
.header-right {
display: flex;
align-items: center;
gap: 12px;
}
.xip-title { .xip-title {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 18px; font-size: 18px;
@@ -82,44 +62,6 @@ const { ip, freeMode, displayBalance } = useWallet();
color: #33ff66; color: #33ff66;
} }
.me-ip {
font-family: 'Courier New', monospace;
font-size: 11px;
color: #5566aa;
}
.balance {
display: inline-flex;
align-items: baseline;
gap: 4px;
background: #131322;
border: 1px solid #2a2a44;
border-radius: 12px;
padding: 3px 10px;
font-family: 'Courier New', monospace;
}
.balance-coin { color: #ffcc44; font-size: 11px; }
.balance-val { color: #ffdd66; font-size: 13px; font-weight: bold; text-shadow: 0 0 8px #ffaa0055; }
.balance-unit { color: #886633; font-size: 9px; }
.balance--free .balance-val { color: #33ff99; text-shadow: 0 0 8px #00ff6655; }
.balance--free .balance-coin { color: #33ff99; }
.shop-link {
font-family: Arial, sans-serif;
font-size: 12px;
font-weight: bold;
color: #00eeff;
text-decoration: none;
border: 1px solid #00eeff55;
border-radius: 12px;
padding: 4px 12px;
transition: background 0.15s, box-shadow 0.15s;
}
.shop-link:hover {
background: #00eeff14;
box-shadow: 0 0 10px #00ccff44;
}
.channel-badge { .channel-badge {
background: #131320; background: #131320;
border: 1px solid #222233; border: 1px solid #222233;

View File

@@ -1,14 +1,14 @@
<!-- Pub casino néon : overlay dans le feed, pilotée par l'inventaire de pubs --> <!-- Pub casino néon : overlay dans le feed (identique à la maquette SVG) -->
<template> <template>
<div v-if="ad" class="casino"> <div class="casino">
<div class="casino-head"> <div class="casino-head">
<p class="casino-title">♠ {{ ad.brand }} ♠</p> <p class="casino-title"> CASINO LUCKY </p>
<p class="casino-subtitle">OFFRE EXCLUSIVE</p> <p class="casino-subtitle">OFFRE EXCLUSIVE</p>
</div> </div>
<div class="casino-body"> <div class="casino-body">
<p class="bonus">+200%</p> <p class="bonus">+200%</p>
<p class="bonus-sub">{{ ad.subtitle || 'sur votre 1er dépôt 500 max' }}</p> <p class="bonus-sub">sur votre 1er dépôt &bull; 500&euro; max</p>
<div class="slots"> <div class="slots">
<span class="suit suit--diamond"></span> <span class="suit suit--diamond"></span>
@@ -18,29 +18,14 @@
<span class="suit suit--spade"></span> <span class="suit suit--spade"></span>
</div> </div>
<a class="casino-cta" :href="ad.url || '#'" target="_blank" rel="noopener noreferrer nofollow"> <button class="casino-cta">
{{ ad.cta || 'JOUER MAINTENANT' }} &rarr; JOUER MAINTENANT &rarr;
</a> </button>
<p class="disclaimer">18+ &bull; Jeu responsable &bull; {{ prettyUrl(ad.url) }}</p> <p class="disclaimer">18+ &bull; Jeu responsable &bull; casino-lucky.bet</p>
</div> </div>
</div> </div>
</template> </template>
<script setup lang="ts">
import { computed, onMounted, watch } from 'vue';
import { useAds } from '@/composables/useAds';
const { ads, fetchAds, reportImpression } = useAds('casino');
const ad = computed(() => ads.value[0] ?? null);
function prettyUrl(url?: string | null): string {
return (url || 'casino-lucky.bet').replace(/^https?:\/\//, '').replace(/\/$/, '');
}
watch(ad, (a) => { if (a) reportImpression(a.id); });
onMounted(fetchAds);
</script>
<style scoped> <style scoped>
.casino { .casino {
width: 248px; width: 248px;
@@ -122,9 +107,7 @@ onMounted(fetchAds);
/* ── CTA ── */ /* ── CTA ── */
.casino-cta { .casino-cta {
display: block;
width: 100%; width: 100%;
box-sizing: border-box;
padding: 8px 0; padding: 8px 0;
background: #220000; background: #220000;
border: 1.5px solid #ff2200; border: 1.5px solid #ff2200;
@@ -134,8 +117,6 @@ onMounted(fetchAds);
font-size: 13px; font-size: 13px;
font-weight: bold; font-weight: bold;
cursor: pointer; cursor: pointer;
text-align: center;
text-decoration: none;
text-shadow: 0 0 6px #ff2200; text-shadow: 0 0 6px #ff2200;
box-shadow: 0 0 8px #ff220044; box-shadow: 0 0 8px #ff220044;
transition: box-shadow 0.15s; transition: box-shadow 0.15s;

View File

@@ -0,0 +1,49 @@
<!-- Bouton hamburger (panneau latéral droit, 35 px) -->
<template>
<div class="menu-toggle">
<button class="hamburger" aria-label="Menu" @click="$emit('toggle')">
<span />
<span />
<span />
</button>
</div>
</template>
<script setup lang="ts">
defineEmits<{ toggle: [] }>();
</script>
<style scoped>
.menu-toggle {
width: 35px;
flex-shrink: 0;
background: #0c0c10;
border-left: 1px solid #1a1a22;
display: flex;
justify-content: center;
padding-top: 12px;
}
.hamburger {
display: flex;
flex-direction: column;
gap: 6px;
background: none;
border: none;
cursor: pointer;
padding: 4px;
}
.hamburger span {
display: block;
width: 18px;
height: 2px;
background: #3a3a55;
border-radius: 1px;
transition: background 0.15s;
}
.hamburger:hover span {
background: #6666aa;
}
</style>

View File

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

View File

@@ -1,28 +1,17 @@
<!-- Un message avec ses éventuelles réponses, perks d'auteur, rich content et pièces jointes --> <!-- Un message avec ses éventuelles réponses -->
<template> <template>
<div class="message-item"> <div class="message-item">
<!-- Auteur + horodatage --> <!-- Auteur + horodatage -->
<div class="message-meta"> <div class="message-meta">
<span class="ip-wrap"> <span
<span v-if="petsLeft(message)" class="pet">{{ petsLeft(message) }}</span> class="ip"
<span class="ip" :style="ipStyle(message)">{{ message.authorIp }}</span> :style="{ color: color, textShadow: glow }"
<span v-if="petsRight(message)" class="pet">{{ petsRight(message) }}</span> >{{ message.authorIp }}</span>
<span v-if="message.authorPerks?.badge" class="vip-badge">VIP</span>
</span>
<span class="ts">{{ fmt(message.createdAt) }}</span> <span class="ts">{{ fmt(message.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: message.authorIp })" type="button">↩ répondre</button>
</div> </div>
<!-- Contenu : riche (iframe sandbox) ou texte simple --> <!-- Contenu -->
<RichContent <p class="message-body">{{ message.content }}</p>
v-if="message.richMode && message.richMode !== 'none' && message.richContent"
:mode="message.richMode"
:content="message.richContent"
/>
<p v-else class="message-body">{{ message.content }}</p>
<!-- Pièces jointes -->
<MessageAttachments v-if="message.attachments?.length" :attachments="message.attachments" />
<!-- Réponses --> <!-- Réponses -->
<div <div
@@ -30,20 +19,12 @@
:key="reply.id" :key="reply.id"
class="reply" class="reply"
> >
<span class="ip-wrap"> <span
<span v-if="petsLeft(reply)" class="pet pet--sm">{{ petsLeft(reply) }}</span> class="ip reply-ip"
<span class="ip reply-ip" :style="ipStyle(reply)">{{ reply.authorIp }}</span> :style="{ color: getColor(reply.authorIp) }"
<span v-if="petsRight(reply)" class="pet pet--sm">{{ petsRight(reply) }}</span> >{{ reply.authorIp }}</span>
</span>
<span class="ts">{{ fmt(reply.createdAt) }}</span> <span class="ts">{{ fmt(reply.createdAt) }}</span>
<button class="reply-btn" @click="$emit('reply', { id: message.id, authorIp: reply.authorIp })" type="button">↩</button> <p class="message-body reply-body">{{ reply.content }}</p>
<RichContent
v-if="reply.richMode && reply.richMode !== 'none' && reply.richContent"
:mode="reply.richMode"
:content="reply.richContent"
/>
<p v-else class="message-body reply-body">{{ reply.content }}</p>
<MessageAttachments v-if="reply.attachments?.length" :attachments="reply.attachments" />
</div> </div>
<div class="divider" /> <div class="divider" />
@@ -51,47 +32,22 @@
</template> </template>
<script setup lang="ts"> <script setup lang="ts">
import type { Message, Reply } from '@/composables/useMessages'; import { computed } from 'vue';
import { getIpColorWithPerks, getIpGlowWithPerks } from '@/composables/ipColor'; import type { Message } from '@/composables/useMessages';
import { usePerks } from '@/composables/usePerks'; import { getIpColor, getIpGlow } from '@/composables/ipColor';
import RichContent from './RichContent.vue';
import MessageAttachments from './MessageAttachments.vue';
defineProps<{ message: Message }>(); const props = defineProps<{ message: Message }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const { perksFor } = usePerks(); const color = computed(() => getIpColor(props.message.authorIp));
const glow = computed(() => getIpGlow(color.value));
/** Perks for an author: prefer the perks embedded in the payload, else the store. */ function getColor(ip: string) { return getIpColor(ip); }
function perksOf(m: Reply): any {
return m.authorPerks ?? perksFor(m.authorIp);
}
function ipStyle(m: Reply) {
const p = perksOf(m);
return {
color: getIpColorWithPerks(m.authorIp, p),
textShadow: getIpGlowWithPerks(m.authorIp, p),
};
}
function petsLeft(m: Reply): string {
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'left' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
function petsRight(m: Reply): string {
const pets = perksOf(m)?.pets ?? [];
return pets
.filter((x: any) => x.position === 'right' || x.position === 'both')
.map((x: any) => x.char)
.join('');
}
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',
});
} }
</script> </script>
@@ -107,15 +63,6 @@ function fmt(date: string): string {
padding: 0 25px; padding: 0 25px;
} }
.ip-wrap { display: inline-flex; align-items: baseline; gap: 4px; }
.pet { font-size: 12px; filter: drop-shadow(0 0 3px currentColor); }
.pet--sm { font-size: 11px; }
.vip-badge {
font-family: Arial, sans-serif; font-size: 8px; font-weight: bold;
color: #ffcc44; background: #2a2206; border: 1px solid #665511; border-radius: 4px;
padding: 0 4px; margin-left: 4px; letter-spacing: 0.5px;
}
.ip { .ip {
font-family: 'Courier New', monospace; font-family: 'Courier New', monospace;
font-size: 12px; font-size: 12px;
@@ -128,22 +75,12 @@ function fmt(date: string): string {
color: #303030; color: #303030;
} }
.reply-btn {
background: none; border: none; cursor: pointer;
font-family: Arial, sans-serif; font-size: 10px; color: #33335a;
padding: 0; opacity: 0; transition: opacity 0.12s, color 0.12s;
}
.message-item:hover .reply-btn,
.reply:hover .reply-btn { opacity: 1; }
.reply-btn:hover { color: #00ccff; }
.message-body { .message-body {
font-family: Arial, sans-serif; font-family: Arial, sans-serif;
font-size: 13px; font-size: 13px;
color: #c0c0c0; color: #c0c0c0;
padding: 3px 25px 0; padding: 3px 25px 0;
margin: 0; margin: 0;
word-break: break-word;
} }
.divider { .divider {

View File

@@ -7,15 +7,14 @@
v-for="msg in messages" v-for="msg in messages"
:key="msg.id" :key="msg.id"
:message="msg" :message="msg"
@reply="$emit('reply', $event)"
/> />
<div v-if="messages.length === 0" class="feed-empty"> <div v-if="messages.length === 0" class="feed-empty">
Aucun message pour l'instant. Aucun message pour l'instant.
</div> </div>
</div> </div>
<!-- Pub casino : overlay absolu sur la droite du feed (masqué si NoAds) --> <!-- Pub casino : overlay absolu sur la droite du feed -->
<InlineCasinoAd v-if="!hideAds" class="casino-overlay" /> <InlineCasinoAd class="casino-overlay" />
</div> </div>
</template> </template>
@@ -25,8 +24,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[] }>();
defineEmits<{ reply: [payload: { id: string; authorIp: string }] }>();
const listEl = ref<HTMLElement | null>(null); const listEl = ref<HTMLElement | null>(null);

View File

@@ -1,85 +0,0 @@
<!--
Rich message renderer SECURITY CRITICAL.
Renders paid HTML/CSS or JS messages inside a FIXED-SIZE sandboxed iframe.
Sandbox policy (never deviate):
- htmlcss tier: sandbox="" (empty) scripts are INERT (honours README "pas de script").
- js tier: sandbox="allow-scripts" ONLY script runs in a NULL origin and
cannot touch the parent (no allow-same-origin, ever).
We NEVER combine allow-scripts with allow-same-origin (that would re-grant parent
access and defeat isolation). A runtime assertion below guards against it.
-->
<template>
<div class="rich-frame-wrap">
<span class="rich-tag" :class="`rich-tag--${mode}`">
{{ mode === 'js' ? '⚡ JS' : '🎨 HTML/CSS' }} · bac à sable
</span>
<iframe
class="rich-frame"
:sandbox="sandboxTokens"
:srcdoc="srcdoc"
referrerpolicy="no-referrer"
loading="lazy"
title="Message riche (isolé)"
/>
</div>
</template>
<script setup lang="ts">
import { computed } from 'vue';
const props = defineProps<{ mode: 'htmlcss' | 'js'; content: string }>();
// htmlcss → no scripts at all; js → scripts only, NEVER same-origin.
const sandboxTokens = computed(() => (props.mode === 'js' ? 'allow-scripts' : ''));
// Defense-in-depth assertion: the iframe must never get allow-same-origin alongside scripts.
if (import.meta.env.DEV) {
const t = props.mode === 'js' ? 'allow-scripts' : '';
if (t.includes('allow-same-origin') && t.includes('allow-scripts')) {
throw new Error('SECURITY: rich iframe must never combine allow-scripts + allow-same-origin');
}
}
const srcdoc = computed(() => {
// In-document CSP as a second layer (the sandbox is the primary boundary).
const csp =
props.mode === 'js'
? "default-src 'none'; script-src 'unsafe-inline'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;"
: "default-src 'none'; script-src 'none'; style-src 'unsafe-inline'; img-src data: https:; font-src data:;";
return `<!doctype html><html><head><meta charset="utf-8"><meta http-equiv="Content-Security-Policy" content="${csp}"><style>html,body{margin:0;padding:8px;color:#ddd;font-family:Arial,sans-serif;background:#0a0a12;overflow:auto;height:100%;box-sizing:border-box}</style></head><body>${props.content}</body></html>`;
});
</script>
<style scoped>
.rich-frame-wrap {
position: relative;
margin: 6px 25px 0;
}
.rich-tag {
position: absolute;
top: -7px;
left: 8px;
z-index: 1;
font-family: Arial, sans-serif;
font-size: 8px;
font-weight: bold;
padding: 1px 6px;
border-radius: 6px;
}
.rich-tag--htmlcss { color: #00ddaa; background: #062019; border: 1px solid #0a4435; }
.rich-tag--js { color: #ffcc44; background: #201a06; border: 1px solid #443a0a; }
/* Fixed size per README ("taille fixe") — contains any layout-breaking CSS. */
.rich-frame {
width: 480px;
max-width: 100%;
height: 270px;
border: 1px solid #222234;
border-radius: 8px;
background: #0a0a12;
display: block;
}
</style>

View File

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

View File

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

View File

@@ -13,21 +13,3 @@ 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 color === '#666688' ? 'none' : `0 0 8px ${color}80`;
} }
/** Gold skin (Style Doré) — overrides the djb2 palette for owners. */
const GOLD = '#ffdd44';
interface PerkLike {
skin?: 'gold';
}
/** Perk-aware color: gold for Style Doré owners, else the deterministic palette. */
export function getIpColorWithPerks(ip: string, perks?: PerkLike | null): string {
if (perks?.skin === 'gold') return GOLD;
return getIpColor(ip);
}
export function getIpGlowWithPerks(ip: string, perks?: PerkLike | null): string {
if (perks?.skin === 'gold') return `0 0 10px ${GOLD}cc`;
return getIpGlow(getIpColor(ip));
}

View File

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

View File

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

View File

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

View File

@@ -1,9 +1,4 @@
import { ref, onMounted } from 'vue'; import { ref, onMounted, onBeforeUnmount } from 'vue';
import { useRealtime } from './useRealtime';
import { useWallet, applyWalletFrame } from './useWallet';
import { setPerks, applyPerksFrame, type Perks } from './usePerks';
import { bumpAdsRevision } from './useAds';
import { handleAlertFrame } from './useAlert';
export interface Reply { export interface Reply {
id: string; id: string;
@@ -11,17 +6,6 @@ export interface Reply {
authorIp: string; authorIp: string;
createdAt: string; createdAt: string;
parentId?: string | null; parentId?: string | null;
authorPerks?: Perks;
richMode?: 'none' | 'htmlcss' | 'js';
richContent?: string | null;
attachments?: Attachment[];
}
export interface Attachment {
id: string;
filename: string;
mimeType: string;
size: number;
} }
export interface Message extends Reply { export interface Message extends Reply {
@@ -35,165 +19,84 @@ export function useMessages() {
const messages = ref<Message[]>([]); const messages = ref<Message[]>([]);
const loading = ref(false); const loading = ref(false);
const sending = ref(false); const sending = ref(false);
const connected = ref(false);
/** Seed the perks store from a message + its replies. */ let source: EventSource | null = null;
function harvestPerks(m: Message): void { let reconnectTimer: ReturnType<typeof setTimeout> | null = null;
setPerks(m.authorIp, m.authorPerks);
for (const r of m.replies ?? []) setPerks(r.authorIp, r.authorPerks);
}
async function fetchMessages(): Promise<void> { async function fetchMessages(): Promise<void> {
loading.value = true; loading.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`); const res = await fetch(`${API_URL}/api/messages`);
if (res.ok) { if (res.ok) {
// API returns newest→oldest; reverse for chronological display. messages.value = ((await res.json()) as Message[]).reverse();
const list = ((await res.json()) as Message[]).reverse();
list.forEach(harvestPerks);
messages.value = list;
} }
} finally { } finally {
loading.value = false; loading.value = false;
} }
} }
/** Add a message pushed over the WebSocket (new thread or reply), with dedup. */ function applyIncoming(payload: Reply & { parentId: string | null }): void {
function addIncoming(raw: Message & { parentId: string | null }): void { if (payload.parentId) {
if (!raw || !raw.id) return; const parent = messages.value.find((m) => m.id === payload.parentId);
if (!parent) return;
// Always record the author's perks, even for replies. if (parent.replies.some((r) => r.id === payload.id)) return;
setPerks(raw.authorIp, raw.authorPerks); parent.replies.push(payload);
} else {
if (raw.parentId == null) { if (messages.value.some((m) => m.id === payload.id)) return;
// New top-level thread. messages.value.push({ ...payload, replies: [] });
if (messages.value.some((m) => m.id === raw.id)) return; }
messages.value.push({ ...raw, replies: raw.replies ?? [] });
return;
} }
// Reply: attach to its parent thread if we have it. function connect(): void {
const parent = messages.value.find((m) => m.id === raw.parentId); if (source) source.close();
if (!parent) return; // thread not loaded; reconnect-resync will reconcile source = new EventSource(`${API_URL}/api/messages/stream`);
if (parent.replies.some((r) => r.id === raw.id)) return;
parent.replies.push({ source.addEventListener('ready', () => {
id: raw.id, connected.value = true;
content: raw.content,
authorIp: raw.authorIp,
createdAt: raw.createdAt,
parentId: raw.parentId,
authorPerks: raw.authorPerks,
richMode: raw.richMode,
richContent: raw.richContent,
attachments: raw.attachments,
}); });
}
const { fetchWallet, ip: myIp } = useWallet(); source.addEventListener('message', (e) => {
// The viewer's own perks (drives NoAds gating, rich-composer unlocks, etc.).
const myPerks = ref<Perks>({});
async function fetchMyPerks(): Promise<void> {
try { try {
const res = await fetch(`${API_URL}/api/shop/me`); applyIncoming(JSON.parse((e as MessageEvent).data));
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 { } catch {
/* ignore */ /* ignore malformed payload */
} }
}
const { stats, connected, sendTyping } = useRealtime({
onMessage: addIncoming,
onReconnect: () => {
fetchMessages();
fetchWallet();
fetchMyPerks();
},
onWallet: applyWalletFrame,
onPerks: (data: { ip: string; perks: Perks }) => {
applyPerksFrame(data);
// If it's about us, update myPerks too (viewer-scoped perks like NoAds).
if (myIp.value && data.ip === myIp.value) myPerks.value = data.perks ?? {};
},
onAds: () => bumpAdsRevision(), // a user ad entered rotation → refetch
onAlert: (data) => handleAlertFrame(data), // paid global audio alert
}); });
interface PostExtras { source.onerror = () => {
parentId?: string; connected.value = false;
richMode?: 'htmlcss' | 'js'; source?.close();
richContent?: string; source = null;
attachmentIds?: string[]; reconnectTimer = setTimeout(connect, 2000);
};
} }
async function postMessage(content: string, extras: PostExtras = {}): Promise<boolean> { async function postMessage(content: string, parentId?: string): Promise<boolean> {
const hasRich = !!extras.richContent && !!extras.richMode; if (!content.trim()) return false;
const hasFiles = !!extras.attachmentIds?.length;
// Allow empty text only when there's rich content or an attachment.
if (!content.trim() && !hasRich && !hasFiles) return false;
sending.value = true; sending.value = true;
try { try {
const res = await fetch(`${API_URL}/api/messages`, { const res = await fetch(`${API_URL}/api/messages`, {
method: 'POST', method: 'POST',
headers: { 'Content-Type': 'application/json' }, headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ body: JSON.stringify({ content: content.trim(), parentId }),
content: content.trim() || ' ',
parentId: extras.parentId,
richMode: extras.richMode,
richContent: extras.richContent,
attachmentIds: extras.attachmentIds,
}),
}); });
if (!res.ok) return false; return res.ok;
// The created message comes back via the WebSocket broadcast, so no
// re-fetch here. Fallback: if the socket is down, add it locally.
if (!connected.value) {
const created = (await res.json()) as Message;
addIncoming(
created.parentId == null ? { ...created, replies: [] } : created
);
}
return true;
} finally { } finally {
sending.value = false; sending.value = false;
} }
} }
onMounted(() => { onMounted(async () => {
fetchMessages(); await fetchMessages();
fetchWallet(); connect();
fetchMyPerks();
}); });
return { onBeforeUnmount(() => {
messages, if (reconnectTimer) clearTimeout(reconnectTimer);
loading, source?.close();
sending, source = null;
postMessage, });
stats,
connected, return { messages, loading, sending, connected, postMessage, fetchMessages };
sendTyping,
myPerks,
fetchMyPerks,
};
} }

View File

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

View File

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

View File

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

View File

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

View File

@@ -2,16 +2,11 @@ import { createApp } from 'vue';
import { createRouter, createWebHistory } from 'vue-router'; import { createRouter, createWebHistory } from 'vue-router';
import App from './App.vue'; import App from './App.vue';
import HomePage from './views/HomePage.vue'; import HomePage from './views/HomePage.vue';
import ShopPage from './views/ShopPage.vue';
import './style.css'; import './style.css';
const router = createRouter({ const router = createRouter({
history: createWebHistory(), history: createWebHistory(),
routes: [ routes: [{ path: '/', component: HomePage }],
{ path: '/', component: HomePage },
{ path: '/shop', component: ShopPage },
{ path: '/shop/p/:id', component: ShopPage },
],
}); });
createApp(App).use(router).mount('#app'); createApp(App).use(router).mount('#app');

View File

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

View File

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

View File

@@ -11,12 +11,6 @@ export default defineConfig({
}, },
server: { server: {
port: 5173, port: 5173,
// Le projet vit sur /mnt/c (disque Windows) mais Vite tourne dans WSL : host: true,
// 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.
watch: {
usePolling: true,
interval: 300,
},
}, },
}); });