admin pages (list + detail) + login page (Supabase email/password)
- /admin/feedback (page.server.ts + page.svelte): list with status/mode badges, counts, JSON-editor create form. flex()→fdb() rename done. - /admin/feedback/[id] (page.server.ts + page.svelte): tabbed detail (Chat / Submissions / Edit), 5s admin polling, hide-toggle, close/reopen, CSV/JSON export, delete. flex()→fdb() rename done. - /login: simple email + password form posting to /api/auth/sign-in. Pre-redirect if already authed (locals.userId in load). Honours ?redirect= query. Pages otherwise byte-identical ports of the flexsiebels versions — schema helper rename happens in /server/fdb.ts. bun run check: 0 errors, 13 warnings (known false-positive 'data/inst captured at init'; same pattern flexsiebels has).
This commit is contained in:
33
src/routes/admin/feedback/+page.server.ts
Normal file
33
src/routes/admin/feedback/+page.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
import { fdb } from '$lib/server/fdb';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (!locals.userId) throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`);
|
||||
|
||||
const { data: instances, error: listError } = await fdb()
|
||||
.from('feedback_instances')
|
||||
.select('id, slug, title, description, form_definition, chat_enabled, status, closed_at, created_at, updated_at')
|
||||
.eq('owner_user_id', locals.userId)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(500);
|
||||
if (listError) throw listError;
|
||||
|
||||
const ids = (instances || []).map((i: { id: string }) => i.id);
|
||||
const counts: Record<string, { submissions: number; posts: number }> = {};
|
||||
for (const id of ids) counts[id] = { submissions: 0, posts: 0 };
|
||||
if (ids.length > 0) {
|
||||
const [s, p] = await Promise.all([
|
||||
fdb().from('feedback_submissions').select('instance_id').in('instance_id', ids),
|
||||
fdb().from('feedback_posts').select('instance_id').in('instance_id', ids),
|
||||
]);
|
||||
if (s.error) throw s.error;
|
||||
if (p.error) throw p.error;
|
||||
for (const row of s.data || []) counts[(row as { instance_id: string }).instance_id].submissions++;
|
||||
for (const row of p.data || []) counts[(row as { instance_id: string }).instance_id].posts++;
|
||||
}
|
||||
|
||||
return {
|
||||
instances: (instances || []).map((i) => ({ ...i, counts: counts[(i as { id: string }).id] })),
|
||||
};
|
||||
};
|
||||
205
src/routes/admin/feedback/+page.svelte
Normal file
205
src/routes/admin/feedback/+page.svelte
Normal file
@@ -0,0 +1,205 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/feedback.css';
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let creating = $state(false);
|
||||
let createError = $state<string | null>(null);
|
||||
|
||||
let title = $state('');
|
||||
let description = $state('');
|
||||
let chatEnabled = $state(true);
|
||||
let formJson = $state('');
|
||||
|
||||
const SAMPLE_FORM = JSON.stringify(
|
||||
{
|
||||
intro: 'Kurzes Feedback nach der Schulung — danke für deine Zeit.',
|
||||
outro: 'Danke!',
|
||||
questions: [
|
||||
{ id: 'overall', type: 'scale', label: 'Wie war die Schulung insgesamt?', required: true, min: 1, max: 5, min_label: 'schwach', max_label: 'super' },
|
||||
{ id: 'helpful', type: 'long_text', label: 'Was war besonders hilfreich?', placeholder: 'optional' },
|
||||
{ id: 'improve', type: 'long_text', label: 'Was sollten wir verbessern?', placeholder: 'optional' },
|
||||
{ id: 'recommend', type: 'boolean', label: 'Würdest du die Schulung weiterempfehlen?', required: true },
|
||||
],
|
||||
},
|
||||
null,
|
||||
2,
|
||||
);
|
||||
|
||||
function pasteSample(): void {
|
||||
formJson = SAMPLE_FORM;
|
||||
}
|
||||
|
||||
async function copyLink(slug: string): Promise<void> {
|
||||
try {
|
||||
const url = `${location.origin}/f/${slug}`;
|
||||
await navigator.clipboard.writeText(url);
|
||||
} catch {
|
||||
// fall through
|
||||
}
|
||||
}
|
||||
|
||||
function modeLabel(i: { form_definition: unknown | null; chat_enabled: boolean }): string {
|
||||
const hasForm = i.form_definition != null;
|
||||
if (hasForm && i.chat_enabled) return 'Form + Chat';
|
||||
if (hasForm) return 'Form';
|
||||
if (i.chat_enabled) return 'Chat';
|
||||
return '—';
|
||||
}
|
||||
|
||||
async function createInstance(e: SubmitEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
creating = true;
|
||||
createError = null;
|
||||
|
||||
let parsedForm: unknown = null;
|
||||
const trimmed = formJson.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
parsedForm = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
createError = `JSON-Parse-Fehler: ${err instanceof Error ? err.message : 'invalid'}`;
|
||||
creating = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch('/api/admin/feedback', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title,
|
||||
description: description || undefined,
|
||||
form_definition: parsedForm,
|
||||
chat_enabled: chatEnabled,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
|
||||
createError = j.error ?? `Fehler ${res.status}`;
|
||||
if (j.details) createError += ' — ' + JSON.stringify(j.details);
|
||||
return;
|
||||
}
|
||||
const j = (await res.json()) as { instance: { id: string } };
|
||||
await goto(`/admin/feedback/${j.instance.id}`);
|
||||
} catch (err) {
|
||||
createError = err instanceof Error ? err.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
creating = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Feedback — Admin</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<h1>Feedback Instances</h1>
|
||||
<p>Forms und Live-Chat-Masken — per langem Slug zugänglich.</p>
|
||||
</header>
|
||||
|
||||
<section class="fb-section">
|
||||
<h2>Neue Instance</h2>
|
||||
<form onsubmit={createInstance}>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-new-title">Titel</label>
|
||||
<input
|
||||
id="fb-new-title"
|
||||
class="fb-input"
|
||||
maxlength="200"
|
||||
required
|
||||
bind:value={title}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-new-desc">Beschreibung (optional)</label>
|
||||
<textarea
|
||||
id="fb-new-desc"
|
||||
class="fb-textarea"
|
||||
maxlength="2000"
|
||||
rows="2"
|
||||
bind:value={description}
|
||||
></textarea>
|
||||
</div>
|
||||
|
||||
<div class="fb-question">
|
||||
<label class="fb-option-row" style="display:inline-flex;">
|
||||
<input type="checkbox" bind:checked={chatEnabled} />
|
||||
<span>Live-Chat aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
|
||||
<div class="fb-question">
|
||||
<div style="display:flex; align-items:center; justify-content:space-between; margin-bottom:0.4rem;">
|
||||
<label class="fb-question__label" for="fb-new-form">Form-Definition (JSON, optional)</label>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={pasteSample}>Beispiel einfügen</button>
|
||||
</div>
|
||||
<textarea
|
||||
id="fb-new-form"
|
||||
class="fb-textarea"
|
||||
rows="14"
|
||||
placeholder={'{\n "questions": [\n { "id": "q1", "type": "short_text", "label": "Dein Name?" }\n ]\n}'}
|
||||
bind:value={formJson}
|
||||
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
|
||||
></textarea>
|
||||
<div class="fb-question__help">
|
||||
Leer lassen für Chat-only. Schema-Doku: docs/plans/feedback-feature.md §5.
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if createError}
|
||||
<div class="fb-banner fb-banner--error">{createError}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="fb-btn" disabled={creating}>
|
||||
{creating ? 'Erstelle …' : 'Instance erstellen'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
|
||||
<section class="fb-section">
|
||||
<h2>Bestehende Instances ({data.instances.length})</h2>
|
||||
{#if data.instances.length === 0}
|
||||
<p style="color: var(--fb-muted);">Noch keine Instance erstellt.</p>
|
||||
{:else}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{#each data.instances as i (i.id)}
|
||||
<div style="border: 1px solid var(--fb-border); border-radius: 6px; padding: 0.75rem;">
|
||||
<div style="display: flex; justify-content: space-between; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<div style="min-width: 0; flex: 1;">
|
||||
<div style="display: flex; align-items: center; gap: 0.5rem; flex-wrap: wrap;">
|
||||
<a href="/admin/feedback/{i.id}" style="font-weight: 600; color: var(--fb-fg); text-decoration: none;">{i.title}</a>
|
||||
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border: 1px solid var(--fb-border); border-radius: 999px; color: var(--fb-muted);">
|
||||
{modeLabel(i)}
|
||||
</span>
|
||||
{#if i.status === 'closed'}
|
||||
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #fef3c7; color: #78350f;">closed</span>
|
||||
{:else}
|
||||
<span style="font-size: 0.78rem; padding: 0.1rem 0.45rem; border-radius: 999px; background: #dcfce7; color: #14532d;">open</span>
|
||||
{/if}
|
||||
</div>
|
||||
<div style="font-size: 0.85rem; color: var(--fb-muted); margin-top: 0.2rem;">
|
||||
{i.counts.submissions} Submissions · {i.counts.posts} Posts · seit {new Date(i.created_at).toLocaleDateString()}
|
||||
</div>
|
||||
<div style="font-size: 0.78rem; color: var(--fb-muted); margin-top: 0.2rem; word-break: break-all;">
|
||||
/f/{i.slug}
|
||||
</div>
|
||||
</div>
|
||||
<div style="display: flex; gap: 0.4rem; flex-wrap: wrap;">
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={() => copyLink(i.slug)}>Link kopieren</button>
|
||||
<a class="fb-btn fb-btn--ghost" href="/f/{i.slug}" target="_blank" rel="noopener">Öffnen</a>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
33
src/routes/admin/feedback/[id]/+page.server.ts
Normal file
33
src/routes/admin/feedback/[id]/+page.server.ts
Normal file
@@ -0,0 +1,33 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { error, redirect } from '@sveltejs/kit';
|
||||
import { fdb } from '$lib/server/fdb';
|
||||
import { getInstanceById } from '$lib/server/feedback';
|
||||
|
||||
export const load: PageServerLoad = async ({ params, locals, url }) => {
|
||||
if (!locals.userId) throw redirect(303, `/login?redirect=${encodeURIComponent(url.pathname)}`);
|
||||
|
||||
const inst = await getInstanceById(params.id);
|
||||
if (!inst) error(404, 'Feedback instance not found');
|
||||
if (inst.owner_user_id !== locals.userId) error(403, 'Not your instance');
|
||||
|
||||
const [s, p] = await Promise.all([
|
||||
fdb().from('feedback_submissions')
|
||||
.select('id, display_name, client_session_id, answers, created_at')
|
||||
.eq('instance_id', inst.id)
|
||||
.order('created_at', { ascending: false })
|
||||
.limit(2000),
|
||||
fdb().from('feedback_posts')
|
||||
.select('id, display_name, client_session_id, body, hidden, created_at')
|
||||
.eq('instance_id', inst.id)
|
||||
.order('created_at', { ascending: true })
|
||||
.limit(2000),
|
||||
]);
|
||||
if (s.error) throw s.error;
|
||||
if (p.error) throw p.error;
|
||||
|
||||
return {
|
||||
instance: inst,
|
||||
submissions: s.data || [],
|
||||
posts: p.data || [],
|
||||
};
|
||||
};
|
||||
344
src/routes/admin/feedback/[id]/+page.svelte
Normal file
344
src/routes/admin/feedback/[id]/+page.svelte
Normal file
@@ -0,0 +1,344 @@
|
||||
<script lang="ts">
|
||||
import '$lib/styles/feedback.css';
|
||||
import { onMount, onDestroy } from 'svelte';
|
||||
import { goto, invalidateAll } from '$app/navigation';
|
||||
import type { PageData } from './$types';
|
||||
import type { FeedbackFormDefinition } from '$lib/server/schemas';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let inst = $state(data.instance);
|
||||
let submissions = $state(data.submissions);
|
||||
let posts = $state(data.posts);
|
||||
|
||||
let activeTab = $state<'chat' | 'submissions' | 'edit'>('chat');
|
||||
let actionError = $state<string | null>(null);
|
||||
let actionInFlight = $state(false);
|
||||
|
||||
let editTitle = $state(inst.title);
|
||||
let editDescription = $state(inst.description ?? '');
|
||||
let editChatEnabled = $state(inst.chat_enabled);
|
||||
let editFormJson = $state(inst.form_definition ? JSON.stringify(inst.form_definition, null, 2) : '');
|
||||
|
||||
let pollHandle: ReturnType<typeof setInterval> | null = null;
|
||||
|
||||
const formDef = $derived(inst.form_definition as FeedbackFormDefinition | null);
|
||||
const questions = $derived(formDef?.questions ?? []);
|
||||
|
||||
async function refresh(): Promise<void> {
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}`, { headers: { Accept: 'application/json' } });
|
||||
if (!res.ok) return;
|
||||
const j = (await res.json()) as { instance: typeof inst; submissions: typeof submissions; posts: typeof posts };
|
||||
inst = j.instance;
|
||||
submissions = j.submissions;
|
||||
posts = j.posts;
|
||||
} catch {
|
||||
// transient
|
||||
}
|
||||
}
|
||||
|
||||
async function toggleHide(postId: string, currentlyHidden: boolean): Promise<void> {
|
||||
actionError = null;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}/posts/${postId}/hide`, {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ hidden: !currentlyHidden }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
actionError = j.error ?? `Fehler ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
}
|
||||
}
|
||||
|
||||
async function setStatus(next: 'open' | 'closed'): Promise<void> {
|
||||
actionError = null;
|
||||
actionInFlight = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ status: next }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
actionError = j.error ?? `Fehler ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function saveEdits(): Promise<void> {
|
||||
actionError = null;
|
||||
actionInFlight = true;
|
||||
|
||||
let parsedForm: unknown = null;
|
||||
const trimmed = editFormJson.trim();
|
||||
if (trimmed) {
|
||||
try {
|
||||
parsedForm = JSON.parse(trimmed);
|
||||
} catch (err) {
|
||||
actionError = `JSON-Parse-Fehler: ${err instanceof Error ? err.message : 'invalid'}`;
|
||||
actionInFlight = false;
|
||||
return;
|
||||
}
|
||||
}
|
||||
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}`, {
|
||||
method: 'PATCH',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({
|
||||
title: editTitle,
|
||||
description: editDescription || null,
|
||||
chat_enabled: editChatEnabled,
|
||||
form_definition: parsedForm,
|
||||
}),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string; details?: unknown };
|
||||
actionError = j.error ?? `Fehler ${res.status}`;
|
||||
if (j.details) actionError += ' — ' + JSON.stringify(j.details);
|
||||
return;
|
||||
}
|
||||
await refresh();
|
||||
activeTab = inst.chat_enabled ? 'chat' : 'submissions';
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function destroy(): Promise<void> {
|
||||
const ok = confirm(`Wirklich Instance "${inst.title}" inkl. aller Submissions/Posts löschen?`);
|
||||
if (!ok) return;
|
||||
actionInFlight = true;
|
||||
try {
|
||||
const res = await fetch(`/api/admin/feedback/${inst.id}`, { method: 'DELETE' });
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
actionError = j.error ?? `Fehler ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await goto('/admin/feedback');
|
||||
} catch (e) {
|
||||
actionError = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
actionInFlight = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function copyLink(): Promise<void> {
|
||||
try {
|
||||
await navigator.clipboard.writeText(`${location.origin}/f/${inst.slug}`);
|
||||
} catch {
|
||||
// silent
|
||||
}
|
||||
}
|
||||
|
||||
function fmtDateTime(iso: string): string {
|
||||
return new Date(iso).toLocaleString();
|
||||
}
|
||||
|
||||
function summarizeAnswer(v: unknown): string {
|
||||
if (v === null || v === undefined) return '—';
|
||||
if (typeof v === 'boolean') return v ? 'Ja' : 'Nein';
|
||||
if (Array.isArray(v)) return v.join(', ');
|
||||
return String(v);
|
||||
}
|
||||
|
||||
function answerCellFor(qid: string, sub: { answers: Record<string, unknown> }): string {
|
||||
return summarizeAnswer(sub.answers?.[qid]);
|
||||
}
|
||||
|
||||
async function exportCsv(): Promise<void> {
|
||||
window.location.href = `/api/admin/feedback/${inst.id}/export?format=csv`;
|
||||
}
|
||||
|
||||
async function exportJson(): Promise<void> {
|
||||
window.location.href = `/api/admin/feedback/${inst.id}/export?format=json`;
|
||||
}
|
||||
|
||||
onMount(() => {
|
||||
pollHandle = setInterval(refresh, 5000);
|
||||
return () => {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
};
|
||||
});
|
||||
|
||||
onDestroy(() => {
|
||||
if (pollHandle) clearInterval(pollHandle);
|
||||
void invalidateAll;
|
||||
});
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>{inst.title} — Feedback Admin</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<div style="display: flex; gap: 0.5rem; align-items: center; flex-wrap: wrap;">
|
||||
<a href="/admin/feedback" style="color: var(--fb-muted); text-decoration: none;">← alle Instances</a>
|
||||
</div>
|
||||
<h1 style="margin-top: 0.5rem;">{inst.title}</h1>
|
||||
{#if inst.description}<p>{inst.description}</p>{/if}
|
||||
|
||||
<div style="display: flex; gap: 0.5rem; flex-wrap: wrap; align-items: center; margin-top: 0.5rem;">
|
||||
<span style="font-size: 0.85rem; padding: 0.15rem 0.5rem; border-radius: 999px; {inst.status === 'closed' ? 'background:#fef3c7; color:#78350f;' : 'background:#dcfce7; color:#14532d;'}">
|
||||
{inst.status}
|
||||
</span>
|
||||
<span style="font-size: 0.85rem; color: var(--fb-muted);">/f/{inst.slug}</span>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={copyLink}>Link kopieren</button>
|
||||
<a class="fb-btn fb-btn--ghost" href="/f/{inst.slug}" target="_blank" rel="noopener">Vorschau</a>
|
||||
{#if inst.status === 'open'}
|
||||
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('closed')}>
|
||||
Schließen
|
||||
</button>
|
||||
{:else}
|
||||
<button type="button" class="fb-btn fb-btn--ghost" disabled={actionInFlight} onclick={() => setStatus('open')}>
|
||||
Wieder öffnen
|
||||
</button>
|
||||
{/if}
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportCsv}>CSV-Export</button>
|
||||
<button type="button" class="fb-btn fb-btn--ghost" onclick={exportJson}>JSON-Export</button>
|
||||
<button type="button" class="fb-btn fb-btn--danger" disabled={actionInFlight} onclick={destroy}>Löschen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if actionError}
|
||||
<div class="fb-banner fb-banner--error">{actionError}</div>
|
||||
{/if}
|
||||
|
||||
<div style="display: flex; gap: 0.25rem; border-bottom: 1px solid var(--fb-border); margin-bottom: 1rem;">
|
||||
{#each [
|
||||
{ key: 'chat', label: `Chat (${posts.length})`, show: inst.chat_enabled },
|
||||
{ key: 'submissions', label: `Submissions (${submissions.length})`, show: !!inst.form_definition },
|
||||
{ key: 'edit', label: 'Bearbeiten', show: true },
|
||||
] as t (t.key)}
|
||||
{#if t.show}
|
||||
<button
|
||||
type="button"
|
||||
onclick={() => (activeTab = t.key as typeof activeTab)}
|
||||
style="background: none; border: none; padding: 0.5rem 0.875rem; cursor: pointer; color: var(--fb-fg); border-bottom: 2px solid {activeTab === t.key ? 'var(--fb-accent)' : 'transparent'}; font-size: 0.95rem; font-weight: {activeTab === t.key ? '600' : '400'};"
|
||||
>
|
||||
{t.label}
|
||||
</button>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
{#if activeTab === 'chat'}
|
||||
<section class="fb-section">
|
||||
<h2>Live-Chat</h2>
|
||||
{#if posts.length === 0}
|
||||
<p style="color: var(--fb-muted);">Noch keine Posts.</p>
|
||||
{:else}
|
||||
<div style="display: flex; flex-direction: column; gap: 0.5rem;">
|
||||
{#each posts as p (p.id)}
|
||||
<div class="fb-chat__post {p.hidden ? 'fb-chat__post--hidden' : ''}">
|
||||
<div class="fb-chat__meta">
|
||||
<span class="fb-chat__name">{p.display_name ?? 'anonym'}</span>
|
||||
<span>{fmtDateTime(p.created_at)}</span>
|
||||
<span style="font-size: 0.75rem;">session: {p.client_session_id.slice(0, 8)}…</span>
|
||||
<button
|
||||
type="button"
|
||||
style="margin-left: auto; background: none; border: 1px solid var(--fb-border); border-radius: 4px; padding: 0.15rem 0.5rem; font-size: 0.8rem; cursor: pointer; color: var(--fb-fg);"
|
||||
onclick={() => toggleHide(p.id, p.hidden)}
|
||||
>
|
||||
{p.hidden ? 'Wieder anzeigen' : 'Verstecken'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="fb-chat__body" style="text-decoration: {p.hidden ? 'line-through' : 'none'};">
|
||||
{p.body}
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'submissions'}
|
||||
<section class="fb-section">
|
||||
<h2>Form Submissions</h2>
|
||||
{#if submissions.length === 0}
|
||||
<p style="color: var(--fb-muted);">Noch keine Submissions.</p>
|
||||
{:else}
|
||||
<div style="overflow-x: auto;">
|
||||
<table style="width: 100%; border-collapse: collapse; font-size: 0.875rem;">
|
||||
<thead>
|
||||
<tr style="border-bottom: 1px solid var(--fb-border);">
|
||||
<th style="text-align: left; padding: 0.5rem 0.4rem;">Wann</th>
|
||||
<th style="text-align: left; padding: 0.5rem 0.4rem;">Name</th>
|
||||
{#each questions as q (q.id)}
|
||||
<th style="text-align: left; padding: 0.5rem 0.4rem;">{q.label}</th>
|
||||
{/each}
|
||||
</tr>
|
||||
</thead>
|
||||
<tbody>
|
||||
{#each submissions as s (s.id)}
|
||||
<tr style="border-bottom: 1px solid var(--fb-border);">
|
||||
<td style="padding: 0.5rem 0.4rem; white-space: nowrap; color: var(--fb-muted);">{fmtDateTime(s.created_at)}</td>
|
||||
<td style="padding: 0.5rem 0.4rem;">{s.display_name ?? 'anonym'}</td>
|
||||
{#each questions as q (q.id)}
|
||||
<td style="padding: 0.5rem 0.4rem; max-width: 320px; word-break: break-word;">
|
||||
{answerCellFor(q.id, s)}
|
||||
</td>
|
||||
{/each}
|
||||
</tr>
|
||||
{/each}
|
||||
</tbody>
|
||||
</table>
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
{:else if activeTab === 'edit'}
|
||||
<section class="fb-section">
|
||||
<h2>Bearbeiten</h2>
|
||||
{#if submissions.length > 0 || posts.length > 0}
|
||||
<div class="fb-banner">
|
||||
Diese Instance hat bereits Submissions/Posts. Form-Definitions-Änderungen können bestehende Antworten unbrauchbar machen.
|
||||
</div>
|
||||
{/if}
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-edit-title">Titel</label>
|
||||
<input id="fb-edit-title" class="fb-input" maxlength="200" bind:value={editTitle} />
|
||||
</div>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-edit-desc">Beschreibung</label>
|
||||
<textarea id="fb-edit-desc" class="fb-textarea" maxlength="2000" rows="2" bind:value={editDescription}></textarea>
|
||||
</div>
|
||||
<div class="fb-question">
|
||||
<label class="fb-option-row" style="display:inline-flex;">
|
||||
<input type="checkbox" bind:checked={editChatEnabled} />
|
||||
<span>Live-Chat aktiv</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="fb-edit-form">Form-Definition (JSON)</label>
|
||||
<textarea
|
||||
id="fb-edit-form"
|
||||
class="fb-textarea"
|
||||
rows="14"
|
||||
bind:value={editFormJson}
|
||||
style="font-family: ui-monospace, 'SF Mono', Menlo, monospace; font-size: 0.85rem;"
|
||||
></textarea>
|
||||
</div>
|
||||
<button type="button" class="fb-btn" disabled={actionInFlight} onclick={saveEdits}>
|
||||
{actionInFlight ? 'Speichere …' : 'Speichern'}
|
||||
</button>
|
||||
</section>
|
||||
{/if}
|
||||
</div>
|
||||
12
src/routes/login/+page.server.ts
Normal file
12
src/routes/login/+page.server.ts
Normal file
@@ -0,0 +1,12 @@
|
||||
import type { PageServerLoad } from './$types';
|
||||
import { redirect } from '@sveltejs/kit';
|
||||
|
||||
export const load: PageServerLoad = async ({ locals, url }) => {
|
||||
if (locals.userId) {
|
||||
const next = url.searchParams.get('redirect') ?? '/admin/feedback';
|
||||
throw redirect(303, next);
|
||||
}
|
||||
return {
|
||||
next: url.searchParams.get('redirect') ?? '/admin/feedback',
|
||||
};
|
||||
};
|
||||
81
src/routes/login/+page.svelte
Normal file
81
src/routes/login/+page.svelte
Normal file
@@ -0,0 +1,81 @@
|
||||
<script lang="ts">
|
||||
import type { PageData } from './$types';
|
||||
import { goto } from '$app/navigation';
|
||||
|
||||
let { data }: { data: PageData } = $props();
|
||||
|
||||
let email = $state('');
|
||||
let password = $state('');
|
||||
let error = $state<string | null>(null);
|
||||
let inFlight = $state(false);
|
||||
|
||||
async function signIn(e: SubmitEvent): Promise<void> {
|
||||
e.preventDefault();
|
||||
inFlight = true;
|
||||
error = null;
|
||||
try {
|
||||
const res = await fetch('/api/auth/sign-in', {
|
||||
method: 'POST',
|
||||
headers: { 'Content-Type': 'application/json' },
|
||||
body: JSON.stringify({ email, password }),
|
||||
});
|
||||
if (!res.ok) {
|
||||
const j = (await res.json().catch(() => ({}))) as { error?: string };
|
||||
error = j.error ?? `Fehler ${res.status}`;
|
||||
return;
|
||||
}
|
||||
await goto(data.next);
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Netzwerkfehler';
|
||||
} finally {
|
||||
inFlight = false;
|
||||
}
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Login — fdbck</title>
|
||||
<meta name="robots" content="noindex,nofollow" />
|
||||
</svelte:head>
|
||||
|
||||
<div class="fb-shell">
|
||||
<header class="fb-header">
|
||||
<h1>Admin-Login</h1>
|
||||
<p>Nur für m.</p>
|
||||
</header>
|
||||
|
||||
<section class="fb-section">
|
||||
<form onsubmit={signIn}>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="login-email">E-Mail</label>
|
||||
<input
|
||||
id="login-email"
|
||||
class="fb-input"
|
||||
type="email"
|
||||
autocomplete="username"
|
||||
required
|
||||
bind:value={email}
|
||||
/>
|
||||
</div>
|
||||
<div class="fb-question">
|
||||
<label class="fb-question__label" for="login-password">Passwort</label>
|
||||
<input
|
||||
id="login-password"
|
||||
class="fb-input"
|
||||
type="password"
|
||||
autocomplete="current-password"
|
||||
required
|
||||
bind:value={password}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{#if error}
|
||||
<div class="fb-banner fb-banner--error">{error}</div>
|
||||
{/if}
|
||||
|
||||
<button type="submit" class="fb-btn" disabled={inFlight}>
|
||||
{inFlight ? 'Logging in …' : 'Einloggen'}
|
||||
</button>
|
||||
</form>
|
||||
</section>
|
||||
</div>
|
||||
Reference in New Issue
Block a user