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:
8
src/app.d.ts
vendored
8
src/app.d.ts
vendored
@@ -11,4 +11,10 @@ declare global {
|
||||
}
|
||||
}
|
||||
|
||||
export {};
|
||||
export {};
|
||||
|
||||
declare global {
|
||||
const window: {
|
||||
__shareToken: string | null;
|
||||
} & Window;
|
||||
}
|
||||
98
src/lib/components/ContradictionBanner.svelte
Normal file
98
src/lib/components/ContradictionBanner.svelte
Normal 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}
|
||||
54
src/lib/components/ErrorBanner.svelte
Normal file
54
src/lib/components/ErrorBanner.svelte
Normal 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>
|
||||
73
src/lib/components/ErrorBannerStore.ts
Normal file
73
src/lib/components/ErrorBannerStore.ts
Normal 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();
|
||||
@@ -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 -->
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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}
|
||||
30
src/routes/research/{id}/components/ShareButton.svelte
Normal file
30
src/routes/research/{id}/components/ShareButton.svelte
Normal 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>
|
||||
159
src/routes/research/{id}/components/StatusTab.svelte
Normal file
159
src/routes/research/{id}/components/StatusTab.svelte
Normal 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>
|
||||
@@ -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}
|
||||
|
||||
Reference in New Issue
Block a user