feat(admin): migrate lyra-posts feature from legacy nuxt-rebreak

- Add apps/admin/pages/lyra.vue — LLM-generierter oder manueller Bot-Post als Lyra/ReBreak
- Add apps/admin/server/api/admin/lyra-generate.post.ts — Proxy zu backend
- Add apps/admin/server/api/admin/lyra-post.post.ts — Proxy zu backend
- Add apps/admin/server/api/admin/lyra-profile.get.ts — Proxy zu backend
- Add apps/admin/server/api/admin/set-lyra-avatar.post.ts — Proxy zu backend
- Update apps/admin/pages/index.vue — Lyra-Posts Quick-Link auf Dashboard
Auth via admin-auth Middleware + server-side adminSecret Proxy-Pattern.
BenAvatar (Rive, legacy) entfernt, Avatar-Anzeige bleibt via lyra-profile.

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
chahinebrini 2026-05-11 02:11:51 +02:00
parent 5d6c322129
commit b1b3b5eb36
6 changed files with 436 additions and 0 deletions

View File

@ -75,5 +75,12 @@ const quickLinks = [
icon: "heroicons:flag",
to: "/moderation",
},
{
label: "Lyra-Posts",
value: "→",
hint: "Als Lyra oder ReBreak posten",
icon: "heroicons:sparkles",
to: "/lyra",
},
]
</script>

297
apps/admin/pages/lyra.vue Normal file
View File

@ -0,0 +1,297 @@
<template>
<div>
<div class="mb-8">
<h1 class="text-xl font-semibold text-white mb-1">Lyra-Posts</h1>
<p class="text-sm text-gray-500">
Community-Posts als Lyra oder ReBreak-Account erstellen (KI-generiert oder manuell).
</p>
</div>
<!-- Author Selection -->
<div class="mb-6">
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Als wer posten?</p>
<div class="grid grid-cols-2 gap-3 max-w-sm">
<button
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
:class="
author === 'lyra'
? 'border-purple-700 bg-purple-950/40'
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
"
@click="selectAuthor('lyra')"
>
<div class="w-8 h-8 rounded-full bg-purple-950/60 border border-purple-800/50 flex items-center justify-center shrink-0">
<img v-if="lyraAvatar" :src="lyraAvatar" alt="Lyra" class="w-8 h-8 rounded-full object-cover" />
<UIcon v-else name="heroicons:cpu-chip" class="h-4 w-4 text-purple-400" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-white leading-tight">{{ lyraNickname }}</p>
<p class="text-xs text-gray-500">KI-Coach</p>
</div>
</button>
<button
class="flex items-center gap-3 px-4 py-3 rounded-lg border transition-colors text-left"
:class="
author === 'rebreak'
? 'border-blue-700 bg-blue-950/40'
: 'border-gray-800 bg-gray-900 hover:border-gray-700'
"
@click="selectAuthor('rebreak')"
>
<div class="w-8 h-8 rounded-full bg-blue-950/60 border border-blue-800/50 flex items-center justify-center shrink-0">
<img v-if="rebreakAvatar" :src="rebreakAvatar" alt="ReBreak" class="w-8 h-8 rounded-full object-cover" />
<UIcon v-else name="heroicons:shield-check" class="h-4 w-4 text-blue-400" />
</div>
<div class="min-w-0">
<p class="text-sm font-semibold text-white leading-tight">{{ rebreakNickname }}</p>
<p class="text-xs text-gray-500">Offiziell</p>
</div>
</button>
</div>
</div>
<!-- Topic Selection -->
<div class="mb-6">
<p class="text-xs text-gray-500 mb-2 uppercase tracking-wide font-medium">Thema (fuer KI-Generierung)</p>
<div class="flex flex-wrap gap-2">
<button
v-for="t in lyraTopics"
:key="t.value"
class="px-3 py-1.5 rounded-full text-xs font-medium transition-colors border"
:class="
lyraTopic === t.value
? 'bg-purple-600 border-purple-600 text-white'
: 'bg-gray-900 border-gray-700 text-gray-400 hover:border-gray-600 hover:text-gray-300'
"
@click="lyraTopic = t.value"
>
{{ t.emoji }} {{ t.label }}
</button>
</div>
</div>
<!-- Context + Generate -->
<div class="flex gap-3 items-end mb-6">
<div class="flex-1">
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium mb-1.5 block">
Optionaler Kontext
</label>
<input
v-model="lyraContext"
placeholder="z.B. 'DNS-Blocker Update verfuegbar'"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 focus:outline-none focus:border-gray-600 transition-colors"
/>
</div>
<UButton
:loading="lyraGenerating"
:disabled="lyraPosting"
color="neutral"
variant="outline"
icon="heroicons:sparkles"
@click="generateContent"
>
Generieren
</UButton>
</div>
<!-- Editable Content -->
<div class="mb-6">
<div class="flex items-center justify-between mb-1.5">
<label class="text-xs text-gray-500 uppercase tracking-wide font-medium">
Post-Text
<span class="text-gray-600 normal-case font-normal ml-1">(direkt editierbar)</span>
</label>
<button
v-if="lyraContent"
class="text-xs text-gray-600 hover:text-gray-400 transition-colors"
@click="lyraContent = ''"
>
leeren
</button>
</div>
<textarea
v-model="lyraContent"
placeholder="Text hier eingeben oder via 'Generieren' erstellen..."
rows="5"
class="w-full bg-gray-900 border border-gray-800 rounded-lg px-3 py-2 text-sm text-white placeholder-gray-600 resize-none focus:outline-none focus:border-gray-600 transition-colors"
/>
</div>
<!-- Preview -->
<div v-if="lyraContent.trim()" class="mb-6 rounded-lg border border-gray-800 bg-gray-900 p-5">
<p class="text-xs text-gray-600 uppercase tracking-wide font-semibold mb-4">Vorschau</p>
<div class="flex items-start gap-3">
<div class="w-9 h-9 rounded-full bg-gray-800 border border-gray-700 flex items-center justify-center shrink-0 overflow-hidden">
<img
v-if="currentAuthorAvatar"
:src="currentAuthorAvatar"
:alt="currentAuthorNickname"
class="w-9 h-9 object-cover"
/>
<UIcon
v-else
:name="author === 'rebreak' ? 'heroicons:shield-check' : 'heroicons:cpu-chip'"
class="h-4 w-4 text-gray-500"
/>
</div>
<div class="flex-1 min-w-0">
<div class="flex items-center gap-2 mb-1.5">
<span class="text-sm font-semibold text-white">{{ currentAuthorNickname }}</span>
<span class="text-xs text-purple-400 bg-purple-950/60 px-1.5 py-0.5 rounded-full flex items-center gap-0.5">
<UIcon name="heroicons:cpu-chip" class="h-3 w-3" />
KI
</span>
</div>
<p class="text-sm text-gray-200 leading-relaxed whitespace-pre-wrap">{{ lyraContent }}</p>
</div>
</div>
</div>
<!-- Error -->
<div
v-if="lyraError"
class="mb-4 rounded-lg border border-red-900 bg-red-950/40 px-4 py-3 flex items-center gap-2"
>
<UIcon name="heroicons:exclamation-triangle" class="h-4 w-4 text-red-400 shrink-0" />
<p class="text-sm text-red-300">{{ lyraError }}</p>
</div>
<!-- Post Button -->
<UButton
block
:loading="lyraPosting"
:disabled="!lyraContent.trim() || lyraGenerating"
icon="heroicons:paper-airplane"
@click="postAsBot"
>
Als {{ currentAuthorNickname }} posten
</UButton>
<!-- Success -->
<div
v-if="lyraSuccess"
class="mt-4 rounded-lg border border-emerald-900 bg-emerald-950/40 p-4 flex items-start gap-3"
>
<UIcon name="heroicons:check-circle" class="h-5 w-5 text-emerald-400 shrink-0 mt-0.5" />
<div>
<p class="text-sm font-semibold text-emerald-300">Post veroeffentlicht</p>
<p class="text-xs text-gray-500 mt-0.5">
Erscheint sofort in der Community unter dem Profil von {{ currentAuthorNickname }}.
</p>
</div>
</div>
</div>
</template>
<script setup lang="ts">
definePageMeta({ middleware: "admin-auth" });
const lyraTopics = [
{ value: "motivation", label: "Motivation", emoji: "💪" },
{ value: "tipp", label: "CBT-Tipp", emoji: "💡" },
{ value: "zitat", label: "Zitat", emoji: "📖" },
{ value: "witzig", label: "Witzig", emoji: "😄" },
{ value: "news", label: "News/Warnung", emoji: "⚠️" },
{ value: "feature", label: "Feature", emoji: "✨" },
] as const;
const author = ref<"lyra" | "rebreak">("lyra");
const lyraTopic = ref<string>("motivation");
const lyraContext = ref("");
const lyraContent = ref("");
const lyraGenerating = ref(false);
const lyraPosting = ref(false);
const lyraSuccess = ref(false);
const lyraError = ref("");
const lyraNickname = ref("Lyra");
const lyraAvatar = ref<string | null>(null);
const rebreakNickname = ref("ReBreak");
const rebreakAvatar = ref<string | null>(null);
const currentAuthorNickname = computed(() =>
author.value === "rebreak" ? rebreakNickname.value : lyraNickname.value,
);
const currentAuthorAvatar = computed(() =>
author.value === "rebreak" ? rebreakAvatar.value : lyraAvatar.value,
);
async function loadProfile(a: "lyra" | "rebreak") {
try {
const profile = await $fetch<{ nickname: string; avatar: string | null }>(
`/api/admin/lyra-profile`,
{ query: { author: a } },
);
if (a === "lyra") {
lyraNickname.value = profile.nickname;
lyraAvatar.value = profile.avatar;
} else {
rebreakNickname.value = profile.nickname;
rebreakAvatar.value = profile.avatar;
}
} catch {
// Fallback-Namen bleiben gesetzt
}
}
onMounted(async () => {
await Promise.all([loadProfile("lyra"), loadProfile("rebreak")]);
});
function selectAuthor(a: "lyra" | "rebreak") {
author.value = a;
lyraContent.value = "";
lyraSuccess.value = false;
lyraError.value = "";
}
async function generateContent() {
lyraError.value = "";
lyraSuccess.value = false;
lyraGenerating.value = true;
try {
const res = await $fetch<{ success: boolean; content: string }>(
"/api/admin/lyra-generate",
{
method: "POST",
body: {
author: author.value,
topic: lyraTopic.value,
context: lyraContext.value || undefined,
},
},
);
lyraContent.value = res.content;
} catch (err: any) {
lyraError.value =
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Generieren";
} finally {
lyraGenerating.value = false;
}
}
async function postAsBot() {
if (!lyraContent.value.trim()) return;
lyraError.value = "";
lyraSuccess.value = false;
lyraPosting.value = true;
try {
await $fetch("/api/admin/lyra-post", {
method: "POST",
body: {
author: author.value,
customContent: lyraContent.value.trim(),
},
});
lyraSuccess.value = true;
lyraContent.value = "";
lyraContext.value = "";
} catch (err: any) {
lyraError.value =
err?.data?.statusMessage ?? err?.statusMessage ?? err?.message ?? "Fehler beim Posten";
} finally {
lyraPosting.value = false;
}
}
</script>

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-generate.post.ts
//
// Proxy: leitet LLM-Generierungsrequest an das Backend weiter.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; content: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/lyra-generate`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-post.post.ts
//
// Proxy: sendet manuellen Bot-Post an das Backend (generiert via LLM oder custom content).
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; postId: string; author: string; content: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/lyra-post`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/lyra-profile.get.ts
//
// Proxy: holt Nickname + Avatar des Lyra- bzw. ReBreak-Bot-Accounts.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ nickname: string; avatar: string | null }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const query = getQuery(event);
try {
return await $fetch(`${apiBase}/api/admin/lyra-profile`, {
method: "GET",
headers: { "x-admin-secret": adminSecret },
query,
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});

View File

@ -0,0 +1,33 @@
// apps/admin/server/api/admin/set-lyra-avatar.post.ts
//
// Proxy: leitet Avatar-Upload (base64 PNG) an das Backend weiter.
// Admin-Secret bleibt server-only (NIE im Client-Bundle).
export default defineEventHandler(async (event): Promise<{ success: boolean; avatar: string }> => {
const config = useRuntimeConfig();
const apiBase = config.public.apiBase;
const adminSecret = config.adminSecret;
if (!adminSecret) {
throw createError({
statusCode: 500,
statusMessage: "ADMIN_SECRET nicht konfiguriert (Infisical-Var fehlt)",
});
}
const body = await readBody(event).catch(() => ({}));
try {
return await $fetch(`${apiBase}/api/admin/set-lyra-avatar`, {
method: "POST",
headers: { "x-admin-secret": adminSecret },
body: body ?? {},
});
} catch (err: any) {
throw createError({
statusCode: err?.statusCode ?? 502,
statusMessage:
err?.statusMessage ?? err?.message ?? "Backend-Request fehlgeschlagen",
});
}
});