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

@@ -0,0 +1,98 @@
<script lang="ts">
import type { ClaimInfo, SourceInfo } from '$lib/types';
export let claims: ClaimInfo[] = [];
export let sources: SourceInfo[] = [];
export let onDismiss?: () => void;
// Find contradictions: claims with same topic but different conclusions
// A simple heuristic: claims that reference the same source_id or overlapping text
interface ContradictionPair {
claimA: ClaimInfo;
claimB: ClaimInfo;
sourceA: SourceInfo | undefined;
sourceB: SourceInfo | undefined;
}
$: contradictions: ContradictionPair[] = findContradictions(claims, sources);
function findContradictions(claims: ClaimInfo[], sources: SourceInfo[]): ContradictionPair[] {
const pairs: ContradictionPair[] = [];
for (let i = 0; i < claims.length; i++) {
for (let j = i + 1; j < claims.length; j++) {
const a = claims[i];
const b = claims[j];
// Check if same source_id but different confidence (potential contradiction)
const sameSource = a.source_id && b.source_id && a.source_id === b.source_id;
const differentConfidence = Math.abs(a.confidence - b.confidence) > 0.3;
// Check if confidence differs significantly and claims are different
if (sameSource && differentConfidence) {
pairs.push({
claimA: a,
claimB: b,
sourceA: sources.find(s => s.id === a.source_id),
sourceB: sources.find(s => s.id === b.source_id),
});
}
}
}
return pairs;
}
</script>
{#if contradictions.length > 0}
<div class="space-y-4">
{#each contradictions as pair}
<div
class="relative overflow-hidden rounded-xl border-2 border-red-300 dark:border-red-700 bg-red-50/80 dark:bg-red-900/10"
>
<!-- Contradiction badge -->
<div class="absolute top-0 left-0 right-0 bg-red-500 dark:bg-red-600 text-white text-xs font-bold px-3 py-1 text-center uppercase tracking-wider">
⚠ Widerspruch erkannt
</div>
<div class="pt-8 pb-4 px-4">
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
<!-- Claim A -->
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle A</span>
</div>
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimA.claim}</p>
<div class="flex flex-wrap items-center gap-2 mt-2">
{#if pair.claimA.source_id}
<span class="text-xs text-gray-500 font-mono">{pair.claimA.source_id}</span>
{/if}
{#if pair.sourceA?.domain}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
{pair.sourceA.domain}
</span>
{/if}
</div>
</div>
<!-- Claim B -->
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
<div class="flex items-center gap-2 mb-2">
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle B</span>
</div>
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimB.claim}</p>
<div class="flex flex-wrap items-center gap-2 mt-2">
{#if pair.claimB.source_id}
<span class="text-xs text-gray-500 font-mono">{pair.claimB.source_id}</span>
{/if}
{#if pair.sourceB?.domain}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
{pair.sourceB.domain}
</span>
{/if}
</div>
</div>
</div>
</div>
</div>
{/each}
</div>
{/if}

View File

@@ -0,0 +1,54 @@
<script lang="ts">
import { errorBanners } from './ErrorBannerStore';
type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
const TYPE_STYLES: Record<ErrorType, string> = {
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300',
success: 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300',
warning: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300',
fatal: 'bg-red-100 dark:bg-red-900/40 border-red-400 dark:border-red-700 text-red-900 dark:text-red-200',
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
};
const TYPE_ICONS: Record<ErrorType, string> = {
error: '⚠️',
success: '✅',
warning: '⚠️',
fatal: '🔴',
info: '',
};
</script>
<div class="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full pointer-events-none">
{#each $errorBanners as banner (banner.id)}
{#if !banner.dismissed}
<div
class="pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg animate-slide-in {TYPE_STYLES[banner.type]}"
role="alert"
>
<span class="flex-shrink-0 text-sm mt-0.5">{TYPE_ICONS[banner.type]}</span>
<p class="flex-1 text-sm leading-relaxed">{banner.message}</p>
<button
on:click={() => errorBanners.dismiss(banner.id)}
class="flex-shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
aria-label="Schließen"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
</svg>
</button>
</div>
{/if}
{/each}
</div>
<style>
@keyframes slide-in {
from { opacity: 0; transform: translateX(100%); }
to { opacity: 1; transform: translateX(0); }
}
.animate-slide-in {
animation: slide-in 0.3s ease-out;
}
</style>

View File

@@ -0,0 +1,73 @@
import { writable } from 'svelte/store';
export type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
export interface BannerMessage {
id: string;
message: string;
type: ErrorType;
duration?: number;
dismissed: boolean;
}
function createId(): string {
return Math.random().toString(36).substring(2, 11);
}
function createBanner(message: string, type: ErrorType, duration = 5000): BannerMessage {
return {
id: createId(),
message,
type,
duration: duration > 0 ? duration : undefined,
dismissed: false,
};
}
export function addError(message: string, type: ErrorType = 'error', duration?: number) {
return banners.update(banners => {
const banner = createBanner(message, type, duration);
return [...banners, banner];
});
}
export function addErrorSuccess(message: string) {
return addError(message, 'success', 3000);
}
export function addErrorWarning(message: string) {
return addError(message, 'warning', 5000);
}
export function addErrorFatal(message: string) {
return addError(message, 'fatal', 0);
}
export function addErrorInfo(message: string) {
return addError(message, 'info', 4000);
}
function createErrorBannerStore() {
const { subscribe, update } = writable<BannerMessage[]>([]);
function dismiss(id: string) {
update(banners => banners.filter(b => b.id !== id));
}
function clearAll() {
update([]);
}
return {
subscribe,
dismiss,
clearAll,
addError,
addErrorSuccess,
addErrorWarning,
addErrorFatal,
addErrorInfo,
};
}
export const errorBanners = createErrorBannerStore();

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 -->