diff --git a/backend/src/lib/perks.ts b/backend/src/lib/perks.ts index 3212bc9..c7f4dd1 100644 --- a/backend/src/lib/perks.ts +++ b/backend/src/lib/perks.ts @@ -25,6 +25,9 @@ export interface Perks { richHtmlcss?: boolean; richJs?: boolean; noFileLimit?: boolean; + ipColors?: boolean; + audioAlert?: boolean; + sendSkins?: { id: string; char: string; label?: string }[]; } const perksKey = (ip: string) => `xip:perks:${ip}`; @@ -88,6 +91,22 @@ export async function getPerksForIp(ip: string): Promise { case "no-file-limit": perks.noFileLimit = true; break; + case "ip-colors": + perks.ipColors = true; + break; + case "audio-alert": + perks.audioAlert = true; + break; + } + + // Send-button skins use a prefixed kind (send-skin-rocket, …), so they + // can't be matched by the switch above. + if (e.kind.startsWith("send-skin-")) { + (perks.sendSkins ??= []).push({ + id: e.kind, + char: meta.char ?? "?", + label: meta.label, + }); } } if (pets.length) perks.pets = pets.slice(0, 3); diff --git a/backend/src/routes/messages.ts b/backend/src/routes/messages.ts index e919f8a..429f185 100644 --- a/backend/src/routes/messages.ts +++ b/backend/src/routes/messages.ts @@ -66,17 +66,25 @@ messages.post("/", async (c) => { const ip = getClientIp(c); const body = await c.req.json<{ - content: string; + content?: string; parentId?: string; richMode?: "htmlcss" | "js"; richContent?: string; attachmentIds?: string[]; }>(); - if (!body.content || body.content.trim().length === 0) { - return c.json({ error: "Content is required" }, 400); + // A message is valid if it has ANY of: plain text, rich content, or attachments. + // (Rich-only and file-only messages are legitimate — no need for placeholder text.) + const hasContent = typeof body.content === "string" && body.content.trim().length > 0; + const hasRich = + !!body.richMode && !!body.richContent && body.richContent.trim().length > 0; + const hasAttachments = + Array.isArray(body.attachmentIds) && body.attachmentIds.length > 0; + + if (!hasContent && !hasRich && !hasAttachments) { + return c.json({ error: "Message vide" }, 400); } - if (body.content.length > 267) { + if (hasContent && body.content!.trim().length > 267) { return c.json({ error: "Content exceeds 267 characters" }, 400); } @@ -97,7 +105,7 @@ messages.post("/", async (c) => { richContent = body.richContent; } - const content = body.content.trim(); + const content = (body.content ?? "").trim(); const parentId = body.parentId ?? null; const message = await prisma.message.create({ diff --git a/backend/src/routes/shop.ts b/backend/src/routes/shop.ts index 21c832c..5e0f213 100644 --- a/backend/src/routes/shop.ts +++ b/backend/src/routes/shop.ts @@ -11,6 +11,7 @@ import { type PurchaseOptions, } from "../lib/catalog"; import { broadcast, broadcastToIp } from "../realtime"; +import { getPerksForIp } from "../lib/perks"; const shop = new Hono(); @@ -30,11 +31,12 @@ shop.get("/products/:id", async (c) => { // GET /api/shop/me — my balance + owned entitlements shop.get("/me", async (c) => { const ip = getClientIp(c); - const [wallet, entitlements] = await Promise.all([ + const [wallet, entitlements, myPerks] = await Promise.all([ getWallet(ip), getEntitlements(ip), + getPerksForIp(ip), ]); - return c.json({ wallet, entitlements }); + return c.json({ wallet, entitlements, myPerks }); }); // POST /api/shop/purchase { productId, options } diff --git a/frontend/src/components/ChatComposer.vue b/frontend/src/components/ChatComposer.vue new file mode 100644 index 0000000..5783fb1 --- /dev/null +++ b/frontend/src/components/ChatComposer.vue @@ -0,0 +1,243 @@ + +