Transforme XIP en réseau social satirique complet : monnaie fictive, marketplace, cosmétiques visibles de tous, messages riches sandboxés, pubs pilotées par les données, et tous les compteurs mock rendus réels. Backend (Bun + Hono + Prisma + Redis) - Économie par IP : modèles Wallet/Purchase/Entitlement, lib/wallet.ts avec spend() atomique (point unique du paywall) + recharge gratuite. - isLocalhost() → mode gratuit (README « si localhost: pas de paywall »). - Marketplace : lib/catalog.ts (achat transactionnel, stock limité, limites par IP) + routes/shop.ts ; 10 produits seedés (idempotent). - Perks : lib/perks.ts (cache Redis busté à l'achat) ; authorPerks injecté dans les payloads messages + endpoint batch /api/perks ; frame WS « perks » global pour MAJ live des messages déjà affichés. - Messages riches : Message.richMode/richContent, gating par entitlement. - Pubs réelles : modèle Ad seedé avec les 4 pubs (ex-hardcodées), rotation par API, comptage d'impressions réel + réconciliation. - WebSocket : IP capturée par connexion → broadcastToIp / broadcast ; frames wallet/perks/ads/alert. - Pièces jointes : lib/storage.ts (UUID, jamais exécuté) + routes/uploads.ts (limite 1 Mo sauf déblocage/localhost, Content-Disposition: attachment). - Alerte audio : routes/alert.ts (cooldown serveur Redis NX, clamp durée). - Compteur « argent extorqué » réel : impressions×CPM + crédits dépensés. Frontend (Vue 3 + Vite) - /shop : ShopPage + ProductCard fidèles aux maquettes ; composables useWallet/useShop/usePerks/useAds/useAttachments/useAlert. - UI de réponse (bannière + sous-threads), solde + lien Shop dans le header. - Perks rendus : Style Doré (or), Pets autour de l'IP, NoAds masque les pubs. - RichContent.vue : iframe sandbox verrouillée (htmlcss sans script ; js allow-scripts seul, jamais allow-same-origin) + CSP. - AdBand/InlineCasinoAd pilotés par l'API ; barre de saisie avec 📎, compteur de caractères, composer riche et bouton alerte. Infra - Migration economy_ads_attachments_rich ; seed idempotent (produits+pubs). - vite.config : usePolling (HMR fiable sur /mnt/c via WSL). - backend/.gitignore : uploads/. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
309 lines
10 KiB
Vue
309 lines
10 KiB
Vue
<template>
|
|
<div class="xip-app">
|
|
<!-- Bandeau de stats temps réel, toujours visible -->
|
|
<StatsTicker :stats="stats" :connected="connected" />
|
|
|
|
<div class="xip-root">
|
|
<!-- Bande pub gauche — masquée si l'utilisateur a NoAds -->
|
|
<AdBand v-if="!myPerks.noads" />
|
|
|
|
<!-- Zone chat centrale -->
|
|
<div class="xip-center">
|
|
<ChatHeader :connected-count="stats?.connectedTabs ?? 0" />
|
|
<MessageList :messages="messages" :hide-ads="!!myPerks.noads" @reply="startReply" />
|
|
|
|
<!-- 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="lime"<\/script>' : '<h1 style="color:#0ff">Salut</h1>'"
|
|
rows="4"
|
|
/>
|
|
</div>
|
|
|
|
<!-- Barre de saisie -->
|
|
<div class="input-bar">
|
|
<!-- Bouton mode riche (si débloqué) -->
|
|
<button
|
|
v-if="myPerks.richHtmlcss || myPerks.richJs"
|
|
class="icon-btn"
|
|
:title="richMenuTitle"
|
|
@click="cycleRichMode"
|
|
type="button"
|
|
>{{ richMode === 'none' ? '🎨' : richMode === 'htmlcss' ? '🎨' : '⚡' }}</button>
|
|
|
|
<!-- Bouton pièce jointe -->
|
|
<button class="icon-btn" title="Joindre un fichier" @click="pickFile" type="button">📎</button>
|
|
<input ref="fileInput" type="file" hidden @change="onFileSelected" />
|
|
|
|
<!-- Bouton alerte audio (si débloqué) -->
|
|
<button
|
|
v-if="myPerks.audioAlert"
|
|
class="icon-btn icon-btn--alert"
|
|
:title="alertMsg || 'Déclencher l\'alerte audio générale'"
|
|
@click="triggerAlert"
|
|
type="button"
|
|
>🔊</button>
|
|
|
|
<div class="field-wrap">
|
|
<input
|
|
v-model="draft"
|
|
class="input-field"
|
|
type="text"
|
|
placeholder="Entrez un message..."
|
|
:maxlength="267"
|
|
@input="onInput"
|
|
@keydown.enter.exact.prevent="submit"
|
|
/>
|
|
<span class="char-counter" :class="{ warn: draft.length > 240 }">{{ draft.length }}/267</span>
|
|
</div>
|
|
<SendButton :disabled="!canSend || sending" @send="submit" />
|
|
</div>
|
|
|
|
<!-- Pièces jointes en attente -->
|
|
<div v-if="pendingFiles.length" class="pending-files">
|
|
<span v-for="f in pendingFiles" :key="f.id" class="pending-chip">
|
|
📎 {{ f.filename }} ({{ kb(f.size) }})
|
|
<button @click="removePending(f.id)" type="button">✕</button>
|
|
</span>
|
|
</div>
|
|
<div v-if="uploadError" class="upload-error">{{ uploadError }}</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</template>
|
|
|
|
<script setup lang="ts">
|
|
import { ref, computed } from 'vue';
|
|
import AdBand from '@/components/AdBand.vue';
|
|
import ChatHeader from '@/components/ChatHeader.vue';
|
|
import MessageList from '@/components/MessageList.vue';
|
|
import SendButton from '@/components/SendButton.vue';
|
|
import StatsTicker from '@/components/StatsTicker.vue';
|
|
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 draft = ref('');
|
|
|
|
// ── Alerte audio ──
|
|
const alertMsg = ref('');
|
|
async function triggerAlert(): Promise<void> {
|
|
const res = await fireAlert();
|
|
alertMsg.value = res.ok ? '' : res.error || '';
|
|
if (alertMsg.value) setTimeout(() => { alertMsg.value = ''; }, 3000);
|
|
}
|
|
|
|
// ── 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> {
|
|
if (!canSend.value) return;
|
|
const ok = await postMessage(draft.value, {
|
|
parentId: replyingTo.value?.id,
|
|
richMode: richMode.value !== 'none' && richDraft.value.trim() ? richMode.value : undefined,
|
|
richContent: richMode.value !== 'none' && richDraft.value.trim() ? richDraft.value : undefined,
|
|
attachmentIds: pendingFiles.value.map((f) => f.id),
|
|
});
|
|
if (ok) {
|
|
draft.value = '';
|
|
richDraft.value = '';
|
|
richMode.value = 'none';
|
|
pendingFiles.value = [];
|
|
replyingTo.value = null;
|
|
uploadError.value = null;
|
|
prevLen = 0;
|
|
}
|
|
}
|
|
</script>
|
|
|
|
<style scoped>
|
|
.xip-app {
|
|
display: flex;
|
|
flex-direction: column;
|
|
width: 100vw;
|
|
height: 100dvh;
|
|
background: #080808;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.xip-root {
|
|
flex: 1;
|
|
min-height: 0;
|
|
display: flex;
|
|
overflow: hidden;
|
|
}
|
|
|
|
.xip-center {
|
|
flex: 1;
|
|
min-width: 0;
|
|
display: flex;
|
|
flex-direction: column;
|
|
background: #090910;
|
|
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 ── */
|
|
.input-bar {
|
|
min-height: 70px;
|
|
flex-shrink: 0;
|
|
background: #0e0e16;
|
|
border-top: 1px solid #1a1a26;
|
|
display: flex;
|
|
align-items: center;
|
|
padding: 0 20px;
|
|
gap: 10px;
|
|
}
|
|
.icon-btn {
|
|
flex-shrink: 0;
|
|
width: 36px; height: 36px;
|
|
background: #141420; border: 1px solid #222234; border-radius: 50%;
|
|
font-size: 16px; cursor: pointer; display: flex; align-items: center; justify-content: center;
|
|
transition: background 0.15s;
|
|
}
|
|
.icon-btn:hover { background: #1c1c2e; }
|
|
.icon-btn--alert { border-color: #aa3344; }
|
|
.icon-btn--alert:hover { background: #2a1418; box-shadow: 0 0 10px #ff224455; }
|
|
|
|
.field-wrap { flex: 1; position: relative; display: flex; align-items: center; }
|
|
.input-field {
|
|
flex: 1;
|
|
background: #141420;
|
|
border: 1px solid #222234;
|
|
border-radius: 23px;
|
|
padding: 12px 60px 12px 22px;
|
|
color: #aaaacc;
|
|
font-family: Arial, sans-serif;
|
|
font-size: 13px;
|
|
outline: none;
|
|
transition: border-color 0.15s;
|
|
}
|
|
.input-field::placeholder { color: #2a2a44; }
|
|
.input-field:focus { border-color: #333355; }
|
|
.char-counter {
|
|
position: absolute; right: 16px;
|
|
font-family: 'Courier New', monospace; font-size: 10px; color: #33334d; pointer-events: none;
|
|
}
|
|
.char-counter.warn { color: #ff8844; }
|
|
|
|
/* ── Pièces jointes en attente ── */
|
|
.pending-files { flex-shrink: 0; display: flex; flex-wrap: wrap; gap: 8px; padding: 0 20px 10px; }
|
|
.pending-chip {
|
|
display: inline-flex; align-items: center; gap: 6px;
|
|
background: #141420; border: 1px solid #222234; border-radius: 12px;
|
|
padding: 4px 10px; font-size: 11px; color: #aaccbb; font-family: Arial, sans-serif;
|
|
}
|
|
.pending-chip button { background: none; border: none; color: #66f; cursor: pointer; }
|
|
.upload-error { flex-shrink: 0; padding: 0 20px 10px; color: #ff7788; font-size: 11px; font-family: Arial, sans-serif; }
|
|
</style>
|