diff --git a/apps/rebreak-native/components/PostCard.tsx b/apps/rebreak-native/components/PostCard.tsx index ae83ac8..342c4ba 100644 --- a/apps/rebreak-native/components/PostCard.tsx +++ b/apps/rebreak-native/components/PostCard.tsx @@ -3,6 +3,7 @@ import { View, Text, Pressable, Image, Animated } from 'react-native'; import { Ionicons } from '@expo/vector-icons'; import { useQueryClient } from '@tanstack/react-query'; import { useTranslation } from 'react-i18next'; +import i18n from '../lib/i18n'; import { apiFetch } from '../lib/api'; import { resolveAvatar } from '../lib/resolveAvatar'; import { formatRelativeTime } from '../lib/formatTime'; @@ -11,6 +12,33 @@ import { RiveAvatar } from './RiveAvatar'; import { HeroShieldCheck } from './HeroShieldCheck'; import { useColors } from '../lib/theme'; +/** + * Domain-Approval-Posts werden vom Backend in 4 Sprachen parallel via Groq + * generiert und als JSON-encoded `{de:'...',en:'...',fr:'...',ar:'...'}` im + * `content`-Feld gespeichert. Diese Helper parsed das + pickt die aktuelle + * App-Locale; fällt auf DE zurück wenn locale fehlt; gibt plain content + * zurück wenn der content kein JSON ist (Legacy-Posts, User-Posts, Reposts). + */ +function resolveLocalizedJsonContent(raw: string | null | undefined, currentLang: string): string { + if (!raw) return ''; + // Quick-Reject: wenn nicht mit '{' anfängt, ist's mit Sicherheit kein JSON. + // Vermeidet JSON.parse-Overhead für 99.9% der Posts (normale Text-Posts). + if (raw[0] !== '{') return raw; + try { + const parsed = JSON.parse(raw); + if (parsed && typeof parsed === 'object' && !Array.isArray(parsed)) { + const langs = ['de', 'en', 'fr', 'ar']; + const hasLocaleKey = Object.keys(parsed).some((k) => langs.includes(k)); + if (hasLocaleKey) { + return parsed[currentLang] ?? parsed.de ?? parsed.en ?? raw; + } + } + } catch { + // not JSON, fall through + } + return raw; +} + type Props = { post: CommunityPost; onCommentPress: (postId: string) => void; @@ -76,10 +104,16 @@ function PostCardImpl({ post, onCommentPress }: Props) { const displayAuthor = post.repostOf ? post.repostOf.author : post.author; const rawContent = post.repostOf ? post.repostOf.content : post.content; - // i18n-aware content: wenn i18nKey gesetzt → übersetzten Text nehmen, - // sonst rawContent (Legacy-Verhalten unverändert). + // i18n-aware content. Drei Pfade in Prioritäts-Reihenfolge: + // 1. post.i18nKey gesetzt → t(`lyra_posts.${id}`) (Catalog-Lyra-Posts) + // 2. content ist JSON-encoded mit Locale-Keys → pick current locale + // (Domain-Approval-Posts, Server-generiert 4x parallel) + // 3. plain content (Legacy-Posts, Comments, User-Posts) → unverändert const i18nKey = post.repostOf ? undefined : post.i18nKey; - const displayContent = i18nKey ? t(`lyra_posts.${i18nKey}`) : rawContent; + const currentLang = (i18n.language ?? 'de').slice(0, 2); + const displayContent = i18nKey + ? t(`lyra_posts.${i18nKey}`) + : resolveLocalizedJsonContent(rawContent, currentLang); const displayImage = post.repostOf ? post.repostOf.imageUrl : post.imageUrl; // Image aspect-ratio: ermittelt aus onLoad event.source.{width,height}. diff --git a/backend/server/api/admin/domain-submissions/[id]/approve.post.ts b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts index bede243..bd65de0 100644 --- a/backend/server/api/admin/domain-submissions/[id]/approve.post.ts +++ b/backend/server/api/admin/domain-submissions/[id]/approve.post.ts @@ -37,80 +37,153 @@ export default defineEventHandler(async (event) => { // Für @mention: Leerzeichen entfernen (Regex matcht nur einzelne Wörter) const mentionName = rawName?.replace(/\s+/g, "") ?? null; const hasMention = !!mentionName; - const mentionRef = hasMention - ? `@${mentionName}` - : "einem Community-Mitglied"; - // Stats für Lyra-Text holen - let statsLine = ""; + // Stats für Lyra-Text holen (Zahlen sind locale-agnostic, locale-Format + // bauen wir per-Sprache zusammen). + let totalDomains = 0; + let monthlyAdded = 0; try { - const stats = await db.blocklistDomain.count({ - where: { isActive: true }, - }); + totalDomains = await db.blocklistDomain.count({ where: { isActive: true } }); const startOfMonth = new Date(); startOfMonth.setDate(1); startOfMonth.setHours(0, 0, 0, 0); - const monthlyAdded = await db.domainSubmission.count({ + monthlyAdded = await db.domainSubmission.count({ where: { status: "approved", reviewedAt: { gte: startOfMonth } }, }); - statsLine = `Damit schützen wir gemeinsam vor ${stats.toLocaleString("de-DE")} Domains${monthlyAdded > 0 ? ` (+${monthlyAdded} diesen Monat)` : ""}.`; } catch {} - // Prompt-Inhalt variiert je nach Type (web vs mail_domain) const isMailDomain = domainType === "mail_domain"; - const subjectLabel = isMailDomain - ? `der Mail-Absender "${domain}"` - : `die Domain "${domain}"`; - const actionLabel = isMailDomain - ? `zur ReBreak-Blockliste hinzugefügt – casino-affiliates nutzen oft unauffällige Absender-Domains` - : `zur ReBreak-Blockliste hinzugefügt`; - const groqUserPrompt = hasMention - ? `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel} – möglich gemacht durch ${mentionRef}. Erwähne ${mentionRef} genau einmal. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt, kein doppelter Dank.` - : `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf Deutsch): ${subjectLabel} wurde ${actionLabel}. Füge am Ende diesen Satz exakt ein: "${statsLine}" Warm, direkt.`; + + // Per-Locale-Prompt-Bausteine. 4 parallele Groq-Calls statt 1 — Latency + // bleibt ~gleich (parallel) + Output ist garantiert konsistent pro Sprache + // (vs. 1 Multi-Locale-Call der gerne JSON-Format-Fehler produziert). + type Lang = "de" | "en" | "fr" | "ar"; + const LANGS: Lang[] = ["de", "en", "fr", "ar"]; + const PROMPT_CFG: Record string; + thanksSegment: (mentionRef: string) => string; + }> = { + de: { + promptLang: "Deutsch", + anonRef: "einem Community-Mitglied", + subjectMail: `der Mail-Absender "${domain}"`, + subjectWeb: `die Domain "${domain}"`, + actionMail: `zur ReBreak-Blockliste hinzugefügt – casino-affiliates nutzen oft unauffällige Absender-Domains`, + actionWeb: `zur ReBreak-Blockliste hinzugefügt`, + statsLine: (t, m) => `Damit schützen wir gemeinsam vor ${t.toLocaleString("de-DE")} Domains${m > 0 ? ` (+${m} diesen Monat)` : ""}.`, + thanksSegment: (m) => `– möglich gemacht durch ${m}. Erwähne ${m} genau einmal`, + }, + en: { + promptLang: "English", + anonRef: "a community member", + subjectMail: `the mail sender "${domain}"`, + subjectWeb: `the domain "${domain}"`, + actionMail: `was added to the ReBreak blocklist — casino affiliates often use inconspicuous sender domains`, + actionWeb: `was added to the ReBreak blocklist`, + statsLine: (t, m) => `Together we now protect against ${t.toLocaleString("en-US")} domains${m > 0 ? ` (+${m} this month)` : ""}.`, + thanksSegment: (m) => `— made possible by ${m}. Mention ${m} exactly once`, + }, + fr: { + promptLang: "français", + anonRef: "un membre de la communauté", + subjectMail: `l'expéditeur de mail « ${domain} »`, + subjectWeb: `le domaine « ${domain} »`, + actionMail: `a été ajouté à la liste de blocage ReBreak — les affiliés casino utilisent souvent des domaines d'expéditeur discrets`, + actionWeb: `a été ajouté à la liste de blocage ReBreak`, + statsLine: (t, m) => `Ensemble, nous protégeons maintenant contre ${t.toLocaleString("fr-FR")} domaines${m > 0 ? ` (+${m} ce mois-ci)` : ""}.`, + thanksSegment: (m) => `— rendu possible par ${m}. Mentionne ${m} exactement une fois`, + }, + ar: { + promptLang: "العربية", + anonRef: "أحد أفراد المجتمع", + subjectMail: `المُرسِل البريدي «${domain}»`, + subjectWeb: `النطاق «${domain}»`, + actionMail: `تمت إضافته إلى قائمة الحظر في ReBreak — يستخدم المنتسبون للكازينوهات غالباً نطاقات مُرسِلين غير لافتة`, + actionWeb: `تمت إضافته إلى قائمة الحظر في ReBreak`, + statsLine: (t, m) => `معاً نحمي الآن من ${t.toLocaleString("ar-EG")} نطاق${m > 0 ? ` (+${m} هذا الشهر)` : ""}.`, + thanksSegment: (m) => `— بفضل ${m}. اذكر ${m} مرة واحدة فقط`, + }, + }; const groqApiKey = config.groqApiKey; (async () => { try { - const response = await $fetch<{ - choices: { message: { content: string } }[]; - }>("https://api.groq.com/openai/v1/chat/completions", { - method: "POST", - headers: { - Authorization: `Bearer ${groqApiKey}`, - "Content-Type": "application/json", - }, - body: { - model: "llama-3.3-70b-versatile", - max_tokens: 150, - messages: [ - { - role: "system", - content: `Du bist Lyra – Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text, kein Prefix, keine Anführungszeichen.`, + // 4 parallele Groq-Calls, eine pro Sprache. Promise.allSettled damit + // ein fehlgeschlagener Locale-Call die anderen 3 nicht killt — wir + // speichern dann nur die erfolgreichen. + const results = await Promise.allSettled( + LANGS.map(async (lang) => { + const cfg = PROMPT_CFG[lang]; + const subject = isMailDomain ? cfg.subjectMail : cfg.subjectWeb; + const action = isMailDomain ? cfg.actionMail : cfg.actionWeb; + const stats = cfg.statsLine(totalDomains, monthlyAdded); + const mentionRef = hasMention ? `@${mentionName}` : cfg.anonRef; + const thanksPart = hasMention ? ` ${cfg.thanksSegment(mentionRef)}` : ""; + const userPrompt = `Schreibe einen kurzen Community-Post (max. 2 Sätze, auf ${cfg.promptLang}): ${subject} ${action}${thanksPart}. Füge am Ende diesen Satz exakt ein: "${stats}" Warm, direkt, kein doppelter Dank.`; + const response = await $fetch<{ + choices: { message: { content: string } }[]; + }>("https://api.groq.com/openai/v1/chat/completions", { + method: "POST", + headers: { + Authorization: `Bearer ${groqApiKey}`, + "Content-Type": "application/json", }, - { - role: "user", - content: groqUserPrompt, + body: { + model: "llama-3.3-70b-versatile", + max_tokens: 200, + messages: [ + { + role: "system", + content: `Du bist Lyra – Recovery-Coach der ReBreak-Community. Tonalität: warm, persönlich, direkt. Schreibe NUR den Post-Text in der angefragten Sprache, kein Prefix, keine Anführungszeichen.`, + }, + { role: "user", content: userPrompt }, + ], }, - ], + }); + const content = response.choices?.[0]?.message?.content?.trim(); + if (!content) throw new Error("empty content"); + return [lang, content] as const; + }), + ); + + const localized: Partial> = {}; + for (const r of results) { + if (r.status === "fulfilled") { + const [lang, content] = r.value; + localized[lang] = content; + } + } + + // Mindestens DE muss klappen — sonst kein Post (sicheres Fallback, + // statt einen halben deutsch-leeren Post zu schreiben). + if (!localized.de) { + console.error(`[approve] Lyra-Post abgebrochen: DE-Generation fehlgeschlagen für ${domain}`); + return; + } + + const contentJson = JSON.stringify(localized); + const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; + await db.communityPost.create({ + data: { + userId: lyraBotUserId, + category: "domain_approved", + // JSON-encoded mit Locale-Keys — PostCard.tsx try-parsed + picked + // current-locale; fällt auf DE zurück wenn locale fehlt. + content: contentJson, + imageUrl: faviconUrl, + isAnonymous: false, + isModerated: false, }, }); - const content = response.choices?.[0]?.message?.content?.trim(); - if (content) { - const faviconUrl = `https://www.google.com/s2/favicons?domain=${encodeURIComponent(domain)}&sz=64`; - await db.communityPost.create({ - data: { - userId: lyraBotUserId, - category: "domain_approved", - content, - imageUrl: faviconUrl, - isAnonymous: false, - isModerated: false, - }, - }); - console.log( - `[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}`, - ); - } + console.log( + `[approve] Lyra-Post erstellt für domain=${domain}, submitter=${mentionName ?? "anonym"}, locales=${Object.keys(localized).join(",")}`, + ); } catch (err) { console.error(`[approve] Lyra-Post fehlgeschlagen:`, err); }