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:
parent
5d6c322129
commit
b1b3b5eb36
@ -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
297
apps/admin/pages/lyra.vue
Normal 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>
|
||||
33
apps/admin/server/api/admin/lyra-generate.post.ts
Normal file
33
apps/admin/server/api/admin/lyra-generate.post.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
33
apps/admin/server/api/admin/lyra-post.post.ts
Normal file
33
apps/admin/server/api/admin/lyra-post.post.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
33
apps/admin/server/api/admin/lyra-profile.get.ts
Normal file
33
apps/admin/server/api/admin/lyra-profile.get.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
33
apps/admin/server/api/admin/set-lyra-avatar.post.ts
Normal file
33
apps/admin/server/api/admin/set-lyra-avatar.post.ts
Normal 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",
|
||||
});
|
||||
}
|
||||
});
|
||||
Loading…
x
Reference in New Issue
Block a user