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

8
src/app.d.ts vendored
View File

@@ -11,4 +11,10 @@ declare global {
}
}
export {};
export {};
declare global {
const window: {
__shareToken: string | null;
} & Window;
}

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

View File

@@ -2,6 +2,7 @@
import { redirect } from '@sveltejs/kit';
import { isAuthenticated } from '$lib/auth';
import { createResearch } from '$lib/sidebar';
import { errorBanners } from '$lib/components/ErrorBannerStore';
let query = '';
let language = 'Deutsch';
@@ -9,6 +10,12 @@
let submitting = false;
let error = '';
const MAX_LENGTH = 500;
$: charCount = query.length;
$: remaining = MAX_LENGTH - charCount;
$: charCountColor = remaining < 0 ? 'text-red-500' : remaining <= 20 ? 'text-amber-500' : 'text-gray-400';
function getApiKey(): string | null {
return localStorage.getItem('nsct-api-key');
}
@@ -16,12 +23,20 @@
async function handleSubmit() {
if (!query.trim()) {
error = 'Bitte gib eine Suchanfrage ein.';
errorBanners.addError('Keine Suchanfrage eingegeben.', 'warning');
return;
}
if (query.length > MAX_LENGTH) {
error = `Maximale Länge von ${MAX_LENGTH} Zeichen erreicht.`;
errorBanners.addError('Zu lange Anfrage.', 'warning');
return;
}
const key = getApiKey();
if (!key) {
error = 'Nicht authentifiziert.';
errorBanners.addError('Kein API-Key verfügbar.', 'error');
return;
}
@@ -30,9 +45,12 @@
try {
const result = await createResearch(key, query.trim(), language, depth);
errorBanners.addErrorSuccess('Research wird erstellt…');
window.location.href = `/research/${result.id}`;
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Recherche.';
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen der Recherche.';
error = msg;
errorBanners.addError(msg, 'error');
} finally {
submitting = false;
}
@@ -44,7 +62,7 @@
<h1 class="text-2xl font-bold text-gray-900 dark:text-white mb-6">Neue Recherche</h1>
<form on:submit|preventDefault={handleSubmit} class="space-y-4">
<!-- Query textarea -->
<!-- Query textarea with counter -->
<div>
<label for="query" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Suchanfrage
@@ -52,11 +70,20 @@
<textarea
id="query"
bind:value={query}
placeholder="Gib eine Suchanfrage ein..."
rows="6"
placeholder="Gib eine Suchanfrage ein... (z.B. 'Was sind die neuesten Forschungsergebnisse zu KI in der Medizin?')"
rows="5"
maxlength={MAX_LENGTH}
class="w-full px-4 py-3 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-800 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent resize-none transition-colors"
disabled={submitting}
></textarea>
<div class="flex items-center justify-between mt-1">
<p class="text-xs text-gray-500 dark:text-gray-400">
Mindestens 5 Zeichen erforderlich.
</p>
<p class="text-xs font-medium {charCountColor}">
{charCount} / {MAX_LENGTH}
</p>
</div>
</div>
<!-- Language selection -->
@@ -64,13 +91,14 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Sprache
</label>
<div class="flex gap-3">
<div class="flex flex-wrap gap-2">
{#each ['Deutsch', 'English', 'Multi'] as lang}
<label
class="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors"
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors"
class:ring-2 class:ring-indigo-500={language === lang}
class:border-indigo-500={language === lang}
class:border-gray-300 dark:border-gray-600={language !== lang}
class:bg-indigo-50 dark:class:bg-indigo-900/20={language === lang}
>
<input
type="radio"
@@ -91,13 +119,14 @@
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
Tiefe
</label>
<div class="flex gap-3">
<div class="flex flex-wrap gap-2">
{#each ['quick', 'normal', 'deep'] as d}
<label
class="flex items-center gap-2 px-4 py-2 border rounded-lg cursor-pointer transition-colors"
class="flex items-center gap-2 px-4 py-2.5 border rounded-lg cursor-pointer transition-colors"
class:ring-2 class:ring-indigo-500={depth === d}
class:border-indigo-500={depth === d}
class:border-gray-300 dark:border-gray-600={depth !== d}
class:bg-indigo-50 dark:class:bg-indigo-900/20={depth === d}
>
<input
type="radio"
@@ -108,7 +137,7 @@
disabled={submitting}
/>
<span class="text-sm text-gray-700 dark:text-gray-300">
{d === 'quick' ? 'Schnell' : d === 'normal' ? 'Normal' : 'Tief'}
{d === 'quick' ? 'Schnell' : d === 'normal' ? '📋 Normal' : '🔍 Tief'}
</span>
</label>
{/each}
@@ -118,8 +147,8 @@
<!-- Submit button -->
<button
type="submit"
disabled={submitting}
class="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2 disabled:cursor-not-allowed"
disabled={submitting || query.length < 5}
class="w-full px-6 py-3 bg-indigo-600 hover:bg-indigo-700 disabled:bg-indigo-400 disabled:cursor-not-allowed text-white font-medium rounded-lg transition-colors focus:ring-2 focus:ring-indigo-500 focus:ring-offset-2"
>
{#if submitting}
<span class="flex items-center justify-center gap-2">
@@ -127,7 +156,7 @@
<circle class="opacity-25" cx="12" cy="12" r="10" stroke="currentColor" stroke-width="4"></circle>
<path class="opacity-75" fill="currentColor" d="M4 12a8 8 0 018-8V0C5.373 0 0 5.373 0 12h4z"></path>
</svg>
Recherche wird gestartet…
Wird erstellt…
</span>
{:else}
Recherche starten

View File

@@ -1,16 +1,20 @@
<script lang="ts">
import { onDestroy, onMount } from 'svelte';
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch, revokeShare } from '$lib/research-api';
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch } from '$lib/research-api';
import { getApiKey } from '$lib/auth';
import { pollingInterval, pollingActive, lastApiError, shareToken } from '$lib/stores';
import { truncateId } from '$lib/utils/formatters';
import type { ResearchDetail } from '$lib/research-api';
import { pollingInterval, pollingActive } from '$lib/stores';
import { truncateId, formatDate } from '$lib/utils/formatters';
import type { ResearchDetail, ResearchStatus } from '$lib/research-api';
import StatusTab from './components/StatusTab.svelte';
import SourcesTab from './components/SourcesTab.svelte';
import ClaimsTab from './components/ClaimsTab.svelte';
import EvidenceTab from './components/EvidenceTab.svelte';
import ReportTab from './components/ReportTab.svelte';
import MethodologyTab from './components/MethodologyTab.svelte';
import ShareModal from '$lib/components/ShareModal.svelte';
import ShareButton from './components/ShareButton.svelte';
import { errorBanners } from '$lib/components/ErrorBannerStore';
import type { ErrorType } from '$lib/components/ErrorBannerStore';
export let researchId: string;
@@ -21,62 +25,60 @@
let loading = true;
let activeTab = 'Status';
let showShareModal = false;
let showStopConfirm = false;
let showDeleteConfirm = false;
let hasFinished = false;
$: TABS = hasFinished
? ['Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik']
: ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
// --- Tab definitions with i18n labels ---
const TABS = ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
const TABS_COMPLETED = ['Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
// --- Polling ---
let pollTimer: ReturnType<typeof setInterval> | null = null;
$: pollingMs = $pollingInterval;
$: isPollingActive = $pollingActive;
$: tabs = (statusText === 'COMPLETED' || statusText === 'FAILED' || statusText === 'STOPPED')
? TABS_COMPLETED
: TABS;
async function doPoll() {
try {
const apiKey = getApiKey();
if (!apiKey) {
error = 'Kein API-Key verfügbar.';
errorBanners.addError('Kein API-Key verfügbar.', 'error');
return;
}
const res = await getResearchStatus(apiKey, researchId);
const res: ResearchStatus = await getResearchStatus(apiKey, researchId);
statusText = res.status;
progress = res.progress ?? 0;
error = null;
// Nur wenn fertig oder fehlgeschlagen, Detail holen
if (res.status === 'COMPLETED' || res.status === 'FAILED' || res.status === 'STOPPED') {
if (!hasFinished) {
hasFinished = true;
$pollingActive = false;
const d = await getResearchDetail(apiKey, researchId);
detail = d;
} else {
const d = await getResearchDetail(apiKey, researchId);
detail = d;
}
hasFinished = true;
$pollingActive = false;
const d = await getResearchDetail(apiKey, researchId);
detail = d;
} else {
// Statusänderung — partiell updaten für live-Quellen/Claims
// Partial update for live sources/claims
try {
const d = await getResearchDetail(apiKey, researchId);
if (d) {
// Nur nicht-fertige Updates (kein vollständiger Report)
detail = { ...d, report: detail?.report ?? '', methodology: detail?.methodology ?? '' };
detail = {
...d,
report: detail?.report ?? '',
methodology: detail?.methodology ?? '',
};
}
} catch {
// Non-critical
}
}
lastApiError.set(null);
errorBanners.addErrorInfo(null as any);
} catch (e) {
const msg = e instanceof Error ? e.message : 'Status-Abfrage fehlgeschlagen.';
error = msg;
lastApiError.set(msg);
errorBanners.addError(msg, 'error');
}
}
@@ -103,8 +105,11 @@
await stopResearch(apiKey, researchId);
hasFinished = true;
error = null;
errorBanners.addErrorSuccess('Research gestoppt.');
} catch (e) {
error = e instanceof Error ? e.message : 'Research konnte nicht gestoppt werden.';
const msg = e instanceof Error ? e.message : 'Research konnte nicht gestoppt werden.';
error = msg;
errorBanners.addError(msg, 'error');
}
}
@@ -113,12 +118,28 @@
if (!apiKey) return;
try {
await deleteResearch(apiKey, researchId);
errorBanners.addErrorSuccess('Research gelöscht.');
window.location.href = '/research';
} catch (e) {
error = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
const msg = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
errorBanners.addError(msg, 'error');
}
}
function onShare(token: string) {
window.__shareToken = token;
}
function onRevoke() {
window.__shareToken = null;
}
function shareLink(): string {
return window.__shareToken
? `${window.location.origin}/share/${window.__shareToken}`
: '';
}
onMount(() => {
startPolling();
});
@@ -127,6 +148,9 @@
stopPolling();
});
// Global state for share
window.__shareToken = null;
function statusColorClass(s: string): string {
switch (s) {
case 'PLANNING': case 'SEARCHING': case 'SYNTHESIZING':
@@ -166,22 +190,37 @@
<!-- Header -->
<div class="sticky top-0 z-20 bg-white/90 dark:bg-gray-900/90 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700 px-4 py-3">
<div class="flex items-center justify-between gap-4">
<!-- Query + ID -->
<div class="flex-1 min-w-0">
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">
{detail?.query ?? detail?.id ?? researchId}
</h2>
</p>
<span class="text-xs text-gray-400 font-mono">{truncateId(researchId)}</span>
</div>
<div class="flex items-center gap-2 flex-shrink-0">
{#if statusText}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium {statusColorClass(statusText)}">
<span class="w-2 h-2 rounded-full {statusDotClass(statusText)}"></span>
{statusText}
</span>
<!-- Status badge -->
{#if statusText}
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium shrink-0 {statusColorClass(statusText)}">
<span class="w-2 h-2 rounded-full {statusDotClass(statusText)}"></span>
{statusText}
</span>
{/if}
<!-- Details (language, depth, times) -->
<div class="hidden sm:flex items-center gap-3 text-xs text-gray-500 dark:text-gray-400 shrink-0">
{#if detail?.language}
<span>{detail.language}</span>
{/if}
{#if statusText !== 'FAILED' && statusText !== 'STOPPED'}
{#if detail?.depth}
<span>{detail.depth === 'quick' ? 'Schnell' : detail.depth === 'normal' ? 'Normal' : 'Tief'}</span>
{/if}
</div>
<!-- Action buttons -->
<div class="flex items-center gap-1 shrink-0">
{#if statusText !== 'FAILED' && statusText !== 'STOPPED' && statusText !== 'COMPLETED'}
<button
on:click={() => showStopConfirm = !showStopConfirm}
on:click={handleStop}
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Recherche stoppen"
>
@@ -190,17 +229,14 @@
</svg>
</button>
{/if}
<ShareButton
{researchId}
{detail}
on:share={onShare}
on:revoke={onRevoke}
/>
<button
on:click={() => showShareModal = !showShareModal}
class="p-1.5 rounded text-gray-400 hover:text-blue-500 hover:bg-blue-50 dark:hover:bg-blue-900/20 transition-colors"
title="Thread teilen"
>
<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="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-6a3 3 0 11-6.632 6m0 0 6.632-6"></path>
</svg>
</button>
<button
on:click={() => showDeleteConfirm = !showDeleteConfirm}
on:click={handleDelete}
class="p-1.5 rounded text-gray-400 hover:text-red-500 hover:bg-red-50 dark:hover:bg-red-900/20 transition-colors"
title="Löschen"
>
@@ -210,53 +246,12 @@
</button>
</div>
</div>
<!-- Stop confirmation -->
{#if showStopConfirm}
<div class="absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2 shadow-lg z-10">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-700 dark:text-gray-300">Recherche wirklich stoppen?</span>
<div class="flex gap-2">
<button on:click={handleStop} class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Stoppen</button>
<button on:click={() => showStopConfirm = false} class="px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Abbrechen</button>
</div>
</div>
</div>
{/if}
<!-- Delete confirmation -->
{#if showDeleteConfirm}
<div class="absolute top-full left-0 right-0 bg-white dark:bg-gray-800 border-b border-gray-200 dark:border-gray-700 px-4 py-2 shadow-lg z-10">
<div class="flex items-center justify-between">
<span class="text-sm text-red-600 dark:text-red-400">Thread wirklich löschen? (unwiderruflich)</span>
<div class="flex gap-2">
<button on:click={handleDelete} class="px-3 py-1 text-xs font-medium text-white bg-red-600 rounded hover:bg-red-700">Löschen</button>
<button on:click={() => showDeleteConfirm = false} class="px-3 py-1 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 rounded hover:bg-gray-200 dark:hover:bg-gray-600">Abbrechen</button>
</div>
</div>
</div>
{/if}
</div>
<!-- Error banner -->
{#if error}
<div class="px-4 py-2 bg-red-50 dark:bg-red-900/20 border-b border-red-200 dark:border-red-800">
<div class="flex items-center justify-between">
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
<button
on:click={() => error = null}
class="text-xs text-red-500 hover:text-red-700 dark:hover:text-red-300"
>
</button>
</div>
</div>
{/if}
<!-- Tabs navigation -->
<div class="border-b border-gray-200 dark:border-gray-700 flex-shrink-0">
<nav class="flex gap-0 px-4 -mb-px overflow-x-auto">
{#each TABS as tab}
{#each tabs as tab}
<button
on:click={() => activeTab = tab}
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
@@ -273,85 +268,32 @@
<div class="px-4 py-6 flex-1 min-h-0 overflow-y-auto">
<!-- Status Tab -->
{#if activeTab === 'Status'}
<div>
{#if statusText}
<div class="space-y-4">
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Status</span>
<span class="px-2 py-0.5 rounded text-xs font-medium {statusColorClass(statusText)}">
{statusText}
</span>
</div>
{#if statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
<div>
<div class="flex items-center justify-between mb-1">
<span class="text-sm text-gray-500">Fortschritt</span>
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}%</span>
</div>
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
<div
class="bg-indigo-600 h-2 rounded-full transition-all duration-300"
style="width: {progress}%"
></div>
</div>
</div>
{/if}
{#if detail?.created_at}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Erstellt</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{new Date(detail.created_at).toLocaleString('de-DE')}
</span>
</div>
{/if}
{#if detail?.completed_at}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Abgeschlossen</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{new Date(detail.completed_at).toLocaleString('de-DE')}
</span>
</div>
{/if}
{#if detail?.language}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Sprache</span>
<span class="text-sm text-gray-700 dark:text-gray-300">{detail.language}</span>
</div>
{/if}
{#if detail?.depth}
<div class="flex items-center justify-between">
<span class="text-sm text-gray-500 dark:text-gray-400">Tiefe</span>
<span class="text-sm text-gray-700 dark:text-gray-300">
{detail.depth === 'quick' ? 'Schnell' : detail.depth === 'normal' ? 'Normal' : 'Tief'}
</span>
</div>
{/if}
{#if statusText === 'FAILED' && detail?.error}
<div class="mt-3 p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<p class="text-sm font-medium text-red-700 dark:text-red-300 mb-1">Fehler</p>
<p class="text-sm text-red-600 dark:text-red-400">{detail.error}</p>
</div>
{/if}
</div>
{:else}
<p class="text-sm text-gray-500">Status wird geladen…</p>
{/if}
</div>
<StatusTab
statusText={statusText}
{progress}
language={detail?.language ?? ''}
depth={detail?.depth ?? ''}
sourceCount={detail?.sources?.length ?? 0}
claimCount={detail?.claims?.length ?? 0}
error={detail?.error ?? ''}
created_at={detail?.created_at ?? ''}
completed_at={detail?.completed_at ?? ''}
/>
<!-- Sources Tab -->
{:else if activeTab === 'Quellen'}
<SourcesTab
{sources: detail?.sources ?? []}
{loading: false}
{error}
sources={detail?.sources ?? []}
loading={false}
error={error}
/>
<!-- Claims Tab -->
{:else if activeTab === 'Claims'}
<ClaimsTab
{claims: detail?.claims ?? []}
{loading: false}
{error}
claims={detail?.claims ?? []}
loading={false}
error={error}
/>
<!-- Evidence Tab -->
@@ -359,8 +301,8 @@
<EvidenceTab
evidence={detail?.evidence ?? []}
evidence_scores={detail?.evidence_scores ?? {}}
{loading: false}
{error}
loading={false}
error={error}
/>
<!-- Report Tab -->
@@ -368,16 +310,16 @@
<ReportTab
report={detail?.report ?? ''}
detail={detail}
{loading: false}
{error}
loading={false}
error={error}
/>
<!-- Methodology Tab -->
{:else if activeTab === 'Methodik'}
<MethodologyTab
methodology={detail?.methodology ?? ''}
{loading: false}
{error}
loading={false}
error={error}
/>
{/if}
</div>
@@ -385,5 +327,5 @@
<!-- Share Modal -->
{#if showShareModal}
<ShareModal researchId={researchId} on:close={() => showShareModal = false} />
<ShareModal researchId={researchId} detail={detail} on:close={() => showShareModal = false} on:share={onShare} on:revoke={onRevoke} />
{/if}

View File

@@ -0,0 +1,30 @@
<script lang="ts">
import { createEventDispatcher } from 'svelte';
export let researchId: string;
export let detail: any = null;
const dispatch = createEventDispatcher();
function shareLink(): string {
const token = (detail as any)?.share_token;
if (!token) return '';
return `${window.location.origin}/share/${token}`;
}
$: hasShareLink = !!((detail as any)?.share_token);
</script>
<button
on:click={() => dispatch('open')}
class="relative inline-flex items-center gap-1.5 px-3 py-1.5 text-xs font-medium text-gray-600 dark:text-gray-400 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
title="Thread teilen"
>
<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="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-6a3 3 0 11-6.632 6m0 0 6.632-6"></path>
</svg>
Teilen
{#if hasShareLink}
<span class="inline-flex items-center justify-center w-4 h-4 bg-emerald-500 text-white text-[10px] rounded-full"></span>
{/if}
</button>

View File

@@ -0,0 +1,159 @@
<script lang="ts">
import { formatStatus } from '$lib/utils/formatters';
export let statusText: string = '';
export let progress: number = 0;
export let language: string = '';
export let depth: string = '';
export let sourceCount: number = 0;
export let claimCount: number = 0;
export let error: string = '';
export let created_at: string = '';
export let completed_at: string = '';
const STEPS = [
{ key: 'CREATED', label: 'Erstellt' },
{ key: 'PLANNING', label: 'Planung' },
{ key: 'SEARCHING', label: 'Suche' },
{ key: 'EXTRACTING', label: 'Extraktion' },
{ key: 'ANALYZING', label: 'Analyse' },
{ key: 'SYNTHESIZING', label: 'Synthese' },
{ key: 'COMPLETED', label: 'Abgeschlossen' },
];
const STEP_COLORS: Record<string, string> = {
CREATED: 'text-blue-500 dark:text-blue-400',
PLANNING: 'text-yellow-500 dark:text-yellow-400',
SEARCHING: 'text-yellow-500 dark:text-yellow-400',
EXTRACTING: 'text-blue-500 dark:text-blue-400',
ANALYZING: 'text-blue-500 dark:text-blue-400',
SYNTHESIZING: 'text-yellow-500 dark:text-yellow-400',
COMPLETED: 'text-emerald-500 dark:text-emerald-400',
FAILED: 'text-red-500 dark:text-red-400',
STOPPED: 'text-orange-500 dark:text-orange-400',
};
function currentStepIndex(): number {
if (statusText === 'FAILED' || statusText === 'STOPPED') {
return STEPS.length - 1; // Show full but mark incomplete
}
for (let i = 0; i < STEPS.length; i++) {
if (statusText === STEPS[i].key) {
return i;
}
}
return 0;
}
function statusColor(): string {
if (statusText === 'FAILED') return 'text-red-600 dark:text-red-400 bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800';
if (statusText === 'STOPPED') return 'text-orange-600 dark:text-orange-400 bg-orange-50 dark:bg-orange-900/20 border-orange-200 dark:border-orange-800';
if (statusText === 'COMPLETED') return 'text-emerald-600 dark:text-emerald-400 bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800';
return 'text-blue-600 dark:text-blue-400 bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800';
}
</script>
<div class="space-y-6">
<!-- Status header -->
<div class="flex items-center justify-between">
<div class="flex items-center gap-3">
<span class="inline-flex items-center gap-2 px-3 py-1 rounded-full text-sm font-medium border {statusColor()}">
<span class="w-2 h-2 rounded-full bg-current {statusText === 'FAILED' || statusText === 'STOPPED' ? '' : 'animate-pulse'}"></span>
{statusText ? formatStatus(statusText) : '—'}
</span>
</div>
{#if progress > 0}
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}%</span>
{/if}
</div>
<!-- Progress bar -->
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2.5 overflow-hidden">
<div
class="h-2.5 rounded-full transition-all duration-500 ease-out"
class:bg-emerald-500={statusText === 'COMPLETED'}
class:bg-red-500={statusText === 'FAILED'}
class:bg-yellow-500={statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
style="width: {progress}%"
></div>
</div>
<!-- Timeline -->
<div class="relative">
<div class="absolute left-4 top-0 bottom-0 w-0.5 bg-gray-200 dark:bg-gray-700"></div>
<div class="space-y-4">
{#each STEPS as step, i}
<div class="relative flex items-start gap-3 pl-8">
<!-- Timeline dot -->
<div
class="absolute left-2.5 w-3 h-3 rounded-full border-2 z-10"
class:border-blue-500 class:bg-blue-500={i <= currentStepIndex() && statusText !== 'FAILED' && statusText !== 'STOPPED'}
class:border-emerald-500 class:bg-emerald-500={statusText === 'COMPLETED' && i <= currentStepIndex()}
class:border-red-500 class:bg-red-500={statusText === 'FAILED'}
class:border-orange-500 class:bg-orange-500={statusText === 'STOPPED'}
class:border-gray-300 class:bg-gray-100 dark:class:bg-gray-800 dark:class:border-gray-600={i > currentStepIndex()}
></div>
<!-- Step label -->
<span class="text-sm {STEP_COLORS[statusText] || (i <= currentStepIndex() ? 'text-gray-700 dark:text-gray-300' : 'text-gray-400 dark:text-gray-600')}">
{step.label}
</span>
<!-- Active indicator -->
{#if statusText === step.key && statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
<span class="text-xs text-blue-500 dark:text-blue-400 font-medium ml-1 animate-pulse"></span>
{/if}
{#if statusText === 'COMPLETED' && step.key === 'COMPLETED'}
<span class="text-xs text-emerald-500 dark:text-emerald-400 font-medium ml-1"></span>
{/if}
</div>
{/each}
</div>
</div>
<!-- Details -->
<div class="grid grid-cols-2 gap-4 pt-2 border-t border-gray-200 dark:border-gray-700">
{#if language}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Sprache</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{language}</p>
</div>
{/if}
{#if depth}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Tiefe</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{depth === 'quick' ? 'Schnell' : depth === 'normal' ? 'Normal' : 'Tief'}</p>
</div>
{/if}
{#if sourceCount > 0}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Quellen</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{sourceCount}</p>
</div>
{/if}
{#if claimCount > 0}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Claims</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{claimCount}</p>
</div>
{/if}
{#if created_at}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Erstellt</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{new Date(created_at).toLocaleString('de-DE')}</p>
</div>
{/if}
{#if completed_at}
<div>
<span class="text-xs text-gray-500 dark:text-gray-400 uppercase tracking-wide">Abgeschlossen</span>
<p class="text-sm text-gray-700 dark:text-gray-300">{new Date(completed_at).toLocaleString('de-DE')}</p>
</div>
{/if}
</div>
<!-- Error message -->
{#if error}
<div class="p-4 bg-red-50 dark:bg-red-900/20 rounded-lg border border-red-200 dark:border-red-800">
<p class="text-sm font-medium text-red-700 dark:text-red-300 mb-1">Fehler</p>
<p class="text-sm text-red-600 dark:text-red-400">{error}</p>
</div>
{/if}
</div>

View File

@@ -1,10 +1,10 @@
<script lang="ts">
import { getShareData } from '$lib/research-api';
import type { ShareData } from '$lib/research-api';
import { truncateId, formatDate, getConfidenceBadge, scoreColor } from '$lib/utils/formatters';
import type { ShareData, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/research-api';
import { truncateId, formatDate, getConfidenceBadge, getIndependenceBadge, scoreColor } from '$lib/utils/formatters';
import { Markdown } from 'svelte-markdown';
import { onMount } from 'svelte';
import type { ClaimInfo } from '$lib/types';
import { errorBanners } from '$lib/components/ErrorBannerStore';
export let token: string;
@@ -23,9 +23,11 @@
error = 'Share-Link ungültig oder nicht gefunden.';
} else if (err.includes('410') || err.includes('expired') || err.includes('Gone')) {
error = 'Dieser Share-Link ist abgelaufen.';
expired = true;
} else {
error = err;
}
errorBanners.addError(err, 'error');
} finally {
loading = false;
}
@@ -65,9 +67,30 @@
};
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
}
function sourceTypeLabel(type: string): string {
const map: Record<string, string> = {
academic: 'Akademisch',
news: 'Nachrichten',
blog: 'Blog',
government: 'Regierung',
forum: 'Forum',
social: 'Social Media',
wikipedia: 'Wikipedia',
other: 'Sonstige',
};
return map[type] ?? type;
}
</script>
<div class="min-h-screen bg-gray-50 dark:bg-gray-950">
<!-- Watermark background -->
<div class="fixed inset-0 pointer-events-none overflow-hidden z-0" aria-hidden="true">
<div class="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 text-8xl text-gray-100 dark:text-gray-800 opacity-30 whitespace-nowrap rotate-12 select-none">
Geteilter NSCT Research-Thread
</div>
</div>
<div class="relative z-10 min-h-screen bg-gray-50 dark:bg-gray-950">
<!-- Read-only banner -->
<div class="bg-indigo-600 dark:bg-indigo-900 px-4 py-2 text-center">
<p class="text-sm font-medium text-white">
@@ -151,6 +174,9 @@
{#if shareData.language}
<p class="text-xs text-gray-500 mt-2">Sprache: {shareData.language}</p>
{/if}
{#if shareData.depth}
<p class="text-xs text-gray-500">Tiefe: {shareData.depth === 'quick' ? 'Schnell' : shareData.depth === 'normal' ? 'Normal' : 'Tief'}</p>
{/if}
</div>
<!-- Quellen -->
@@ -172,11 +198,23 @@
{source.url}
</a>
{/if}
{#if source.domain}
<span class="inline-block mt-1 text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{source.domain}
</span>
{/if}
<div class="flex flex-wrap items-center gap-2 mt-1">
{#if source.domain}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{source.domain}
</span>
{/if}
{#if source.source_type}
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 text-gray-600 dark:text-gray-400 rounded">
{sourceTypeLabel(source.source_type)}
</span>
{/if}
{#if source.independence_score !== undefined}
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded text-xs font-medium {getIndependenceBadge(source.independence_score).color}">
Unabhängigkeit: {(source.independence_score * 100).toFixed(0)}%
</span>
{/if}
</div>
</div>
</div>
{/each}
@@ -209,6 +247,9 @@
{#if claim.evidence_span}
<span class="text-xs text-gray-500 dark:text-gray-400">{claim.evidence_span}</span>
{/if}
{#if claim.source_id}
<span class="text-xs text-gray-400 font-mono">Quelle: {claim.source_id}</span>
{/if}
</div>
</div>
{/each}
@@ -222,7 +263,7 @@
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wide">
Evidenz-Scores
</h2>
<div class="space-y-3">
<div class="space-y-4">
{#each shareData.evidence_scores as (key, value)}
<div>
<div class="flex items-center justify-between mb-1">
@@ -261,6 +302,18 @@
</div>
{/if}
<!-- Methodology -->
{#if shareData.methodology}
<div class="bg-white dark:bg-gray-900 rounded-xl border border-gray-200 dark:border-gray-700 p-4">
<h2 class="text-sm font-semibold text-gray-500 dark:text-gray-400 mb-3 uppercase tracking-wide">
Methodik
</h2>
<div class="prose prose-sm dark:prose-invert max-w-none">
<Markdown>{shareData.methodology}</Markdown>
</div>
</div>
{/if}
<!-- Footer -->
<div class="text-center text-xs text-gray-400 pt-4 border-t border-gray-200 dark:border-gray-700">
{#if shareData.username}