FE-3 + FE-4: Full-Featured Research-Ansicht + Docker Deployment

FE-3:
- Error-System: ErrorBannerStore + ErrorBanner (stapelbar, auto-hide, 5 categories)
- ContradictionBanner: Visuelle Widerspruchserkennung
- StatusTab: Zeitleiste, Fortschrittsbalken, Details
- ShareButton: Inline-Share mit Geteilt-Badge
- ShareModal: Ablaufzeit (24h-30Tage/Nie), Max-Views, Copy/Revoke
- research/{id}/+page.svelte: 6 Tabs mit Polling (5s), onDestroy cleanup
- share/[token]/+page.svelte: Vollständige Read-Only Share-Ansicht mit Wasserzeichen
- research/new/+page.svelte: Char-Counter, Language/Depth-Auswahl

FE-4:
- Dockerfile: HEALTHCHECK, Error-Handling, multi-stage build
- Caddyfile: HTTPS, Security Headers, gzip+zstd
- docker-compose.yml: Healthchecks, resource limits, restart policy
- tests/: Theme, API, Formatter, Share-Link Tests
- README.md: Architektur-Diagramm, Installation, Nutzung
- CHANGELOG.md: FE-0 bis FE-4
This commit is contained in:
faligam
2026-09-06 08:10:08 +00:00
parent 7d35a9672a
commit 3e33359285
19 changed files with 1234 additions and 220 deletions

View File

@@ -1,11 +1,14 @@
<script lang="ts">
import { getApiKey } from '$lib/auth';
import { shareResearch, type ShareResult } from '$lib/research-api';
import { shareResearch, revokeShare, type ShareResult } from '$lib/research-api';
import { createEventDispatcher } from 'svelte';
import { errorBanners } from '$lib/components/ErrorBannerStore';
import type { ResearchDetail } from '$lib/research-api';
const dispatch = createEventDispatcher();
export let researchId: string;
export let detail: ResearchDetail | null = null;
const EXPIRY_OPTIONS = [
{ label: '24 Stunden', value: '24h', hours: 24 },
@@ -24,11 +27,14 @@
];
let selectedExpiry = '7d';
let maxViews = '';
let maxViews = '10';
let generating = false;
let result: ShareResult | null = null;
let copied = false;
let error = '';
let existingToken: string | null = null;
// Check if there's an existing share token in the detail
$: existingToken = (detail as any)?.share_token ?? null;
function getExpiryDate(hours: number): string | undefined {
if (hours <= 0) return undefined;
@@ -38,12 +44,11 @@
async function generateLink() {
generating = true;
error = '';
result = null;
const apiKey = getApiKey();
if (!apiKey) {
error = 'Kein API-Key verfügbar.';
errorBanners.addError('Kein API-Key verfügbar.', 'error');
generating = false;
return;
}
@@ -51,11 +56,12 @@
try {
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
const expiresAt = getExpiryDate(hours);
const maxViewsNum = maxViews === '' || maxViews === '0' ? undefined : parseInt(maxViews, 10);
const maxViewsNum = maxViews === '0' || maxViews === '' ? undefined : parseInt(maxViews, 10);
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
errorBanners.addErrorSuccess('Share-Link generiert.');
} catch (e) {
error = e instanceof Error ? e.message : 'Link konnte nicht generiert werden.';
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht generiert werden.', 'error');
} finally {
generating = false;
}
@@ -66,16 +72,29 @@
try {
await navigator.clipboard.writeText(result.url);
copied = true;
errorBanners.addErrorSuccess('Link kopiert!');
setTimeout(() => { copied = false; }, 2000);
} catch {
error = 'Link konnte nicht kopiert werden.';
errorBanners.addError('Link konnte nicht kopiert werden.', 'error');
}
}
async function revokeLink() {
const apiKey = getApiKey();
if (!apiKey) return;
try {
await revokeShare(apiKey, existingToken ?? '');
result = null;
existingToken = null;
errorBanners.addErrorSuccess('Share-Link deaktiviert.');
} catch (e) {
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht deaktiviert werden.', 'error');
}
}
function reset() {
result = null;
copied = false;
error = '';
}
function handleClose() {
@@ -111,7 +130,7 @@
<!-- Body -->
<div class="px-6 py-4 space-y-4">
{#if result}
{#if result || existingToken}
<!-- Generated link -->
<div class="space-y-3">
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
@@ -121,7 +140,7 @@
<input
type="text"
readonly
value={result.url}
value={result?.url ?? `https://app.example.com/share/${existingToken}`}
class="flex-1 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white font-mono truncate"
/>
<button
@@ -131,12 +150,18 @@
{copied ? '✓ Kopiert' : 'Kopieren'}
</button>
</div>
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
<p>Gültig bis: {result.token ? new Date().toLocaleDateString('de-DE') : '—'}</p>
{#if maxViews && maxViews !== '0'}
<p>Max. Aufrufe: {maxViews}</p>
{/if}
<div class="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 9.586 7.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
</svg>
Link wurde erfolgreich generiert
</div>
<button
on:click={revokeLink}
class="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 underline"
>
Link deaktivieren
</button>
</div>
{:else}
<!-- Expiry selector -->
@@ -163,7 +188,6 @@
bind:value={maxViews}
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
>
<option value="">Unbegrenzt</option>
{#each MAX_VIEWS_OPTIONS as opt}
<option value={opt.value}>{opt.label}</option>
{/each}
@@ -189,12 +213,6 @@
{/if}
</button>
{/if}
{#if error}
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
</div>
{/if}
</div>
<!-- Footer -->