FE-2: API-Anbindung, Session-Management, Chat-View, Quellen/Claims-Sicht
- research-api.ts: Vollständiger Research-API-Client mit allen Funktionen
(createResearch, getResearchStatus, getResearchDetail, listResearch,
deleteResearch, shareResearch, getShareData, stopResearch, revokeShare)
- stores.ts: Neue Stores (currentResearchId, researchData, researchHistory,
pollingInterval, pollingActive, lastApiError, shareToken)
- formatters.ts: Hilfsfunktionen (truncateId, truncateQuery, formatDate,
formatStatus, getConfidenceBadge, getIndependenceBadge, scoreColor, domainFavicon)
- SourcesTab.svelte: Quellen mit Titel, URL, Domain, Source-Type,
Independence-Score, Favicon-Platzhalter
- ClaimsTab.svelte: Claims mit Typ, Evidence-Span, Confidence-Badge
- EvidenceTab.svelte: 6D-Evidenz-Scores als Balkendiagramm
- ReportTab.svelte: Markdown-Report mit Download (JSON/Markdown/Text)
- MethodologyTab.svelte: Methodik als Markdown
- ShareModal.svelte: Share-Link-Generierung mit Ablaufzeit + Max-Views
- research/{id}/+page.svelte: Echte API-Polling alle 5s, Stop/Delete-Confirm,
Error-Banner, Tab-Integration, Share-Modal
- share/[token]/+page.svelte: Public Read-Only Share-Link-Ansicht mit
Quellen, Claims, Evidence, Report, Ablauf-Warnung
This commit is contained in:
218
src/lib/components/ShareModal.svelte
Normal file
218
src/lib/components/ShareModal.svelte
Normal file
@@ -0,0 +1,218 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getApiKey } from '$lib/auth';
|
||||||
|
import { shareResearch, type ShareResult } from '$lib/research-api';
|
||||||
|
import { createEventDispatcher } from 'svelte';
|
||||||
|
|
||||||
|
const dispatch = createEventDispatcher();
|
||||||
|
|
||||||
|
export let researchId: string;
|
||||||
|
|
||||||
|
const EXPIRY_OPTIONS = [
|
||||||
|
{ label: '24 Stunden', value: '24h', hours: 24 },
|
||||||
|
{ label: '48 Stunden', value: '48h', hours: 48 },
|
||||||
|
{ label: '7 Tage', value: '7d', hours: 168 },
|
||||||
|
{ label: '14 Tage', value: '14d', hours: 336 },
|
||||||
|
{ label: '30 Tage', value: '30d', hours: 720 },
|
||||||
|
{ label: 'Nie', value: 'never', hours: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
const MAX_VIEWS_OPTIONS = [
|
||||||
|
{ label: '1', value: 1 },
|
||||||
|
{ label: '10', value: 10 },
|
||||||
|
{ label: '50', value: 50 },
|
||||||
|
{ label: 'Unbegrenzt', value: 0 },
|
||||||
|
];
|
||||||
|
|
||||||
|
let selectedExpiry = '7d';
|
||||||
|
let maxViews = '';
|
||||||
|
let generating = false;
|
||||||
|
let result: ShareResult | null = null;
|
||||||
|
let copied = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
function getExpiryDate(hours: number): string | undefined {
|
||||||
|
if (hours <= 0) return undefined;
|
||||||
|
const d = new Date(Date.now() + hours * 60 * 60 * 1000);
|
||||||
|
return d.toISOString();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function generateLink() {
|
||||||
|
generating = true;
|
||||||
|
error = '';
|
||||||
|
result = null;
|
||||||
|
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
error = 'Kein API-Key verfügbar.';
|
||||||
|
generating = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
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);
|
||||||
|
|
||||||
|
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Link konnte nicht generiert werden.';
|
||||||
|
} finally {
|
||||||
|
generating = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function copyLink() {
|
||||||
|
if (!result) return;
|
||||||
|
try {
|
||||||
|
await navigator.clipboard.writeText(result.url);
|
||||||
|
copied = true;
|
||||||
|
setTimeout(() => { copied = false; }, 2000);
|
||||||
|
} catch {
|
||||||
|
error = 'Link konnte nicht kopiert werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function reset() {
|
||||||
|
result = null;
|
||||||
|
copied = false;
|
||||||
|
error = '';
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleClose() {
|
||||||
|
dispatch('close');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Close on Escape
|
||||||
|
function handleKeydown(e: KeyboardEvent) {
|
||||||
|
if (e.key === 'Escape') handleClose();
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-50 flex items-center justify-center bg-black/50"
|
||||||
|
on:keydown={handleKeydown}
|
||||||
|
role="dialog"
|
||||||
|
aria-modal="true"
|
||||||
|
aria-label="Thread teilen"
|
||||||
|
>
|
||||||
|
<div class="bg-white dark:bg-gray-900 rounded-xl shadow-2xl border border-gray-200 dark:border-gray-700 w-full max-w-md mx-4 overflow-hidden">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="flex items-center justify-between px-6 py-4 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h2 class="text-lg font-semibold text-gray-900 dark:text-white">Thread teilen</h2>
|
||||||
|
<button
|
||||||
|
on:click={handleClose}
|
||||||
|
class="p-1 rounded-md text-gray-400 hover:text-gray-600 dark:hover:text-gray-300 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
>
|
||||||
|
<svg class="w-5 h-5" 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>
|
||||||
|
|
||||||
|
<!-- Body -->
|
||||||
|
<div class="px-6 py-4 space-y-4">
|
||||||
|
{#if result}
|
||||||
|
<!-- Generated link -->
|
||||||
|
<div class="space-y-3">
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||||
|
Share-Link
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
readonly
|
||||||
|
value={result.url}
|
||||||
|
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
|
||||||
|
on:click={copyLink}
|
||||||
|
class="px-3 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors whitespace-nowrap"
|
||||||
|
>
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<!-- Expiry selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Ablaufzeit
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
bind:value={selectedExpiry}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
{#each EXPIRY_OPTIONS as opt}
|
||||||
|
<option value={opt.value}>{opt.label}</option>
|
||||||
|
{/each}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Max views selector -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Max. Aufrufe
|
||||||
|
</label>
|
||||||
|
<select
|
||||||
|
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}
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Generate button -->
|
||||||
|
<button
|
||||||
|
on:click={generateLink}
|
||||||
|
disabled={generating}
|
||||||
|
class="w-full px-4 py-2.5 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 generating}
|
||||||
|
<span class="flex items-center justify-center gap-2">
|
||||||
|
<svg class="animate-spin h-5 w-5" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
Wird generiert…
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Link generieren
|
||||||
|
{/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 -->
|
||||||
|
<div class="flex items-center justify-end px-6 py-3 bg-gray-50 dark:bg-gray-800/50 border-t border-gray-200 dark:border-gray-700 gap-2">
|
||||||
|
<button
|
||||||
|
on:click={handleClose}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-gray-700 dark:text-gray-300 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg hover:bg-gray-50 dark:hover:bg-gray-700 transition-colors"
|
||||||
|
>
|
||||||
|
Schließen
|
||||||
|
</button>
|
||||||
|
{#if result}
|
||||||
|
<button
|
||||||
|
on:click={handleClose}
|
||||||
|
class="px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
Fertig
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
167
src/lib/research-api.ts
Normal file
167
src/lib/research-api.ts
Normal file
@@ -0,0 +1,167 @@
|
|||||||
|
import { apiGet, apiPost, apiDelete } from '$lib/api';
|
||||||
|
import type { ResearchStatus, ResearchSummary, SourceInfo, ClaimInfo, EvidenceScore } from '$lib/types';
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Erzeugt eine neue Research-Anfrage.
|
||||||
|
*/
|
||||||
|
export async function createResearch(
|
||||||
|
apiKey: string,
|
||||||
|
query: string,
|
||||||
|
language: string,
|
||||||
|
depth: string
|
||||||
|
): Promise<{ id: string }> {
|
||||||
|
return await apiPost<{ id: string }>(
|
||||||
|
'/v1/research',
|
||||||
|
{ query, language, depth },
|
||||||
|
{ apiKey }
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Fragt den Status einer laufenden Research-Abfrage ab.
|
||||||
|
*/
|
||||||
|
export async function getResearchStatus(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string
|
||||||
|
): Promise<ResearchStatus> {
|
||||||
|
return await apiGet<ResearchStatus>(`/v1/research/${researchId}/status`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert die vollständigen Details einer Research-Abfrage.
|
||||||
|
*/
|
||||||
|
export async function getResearchDetail(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string
|
||||||
|
): Promise<ResearchDetail> {
|
||||||
|
return await apiGet<ResearchDetail>(`/v1/research/${researchId}`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine Liste aller Research-Einträge des authenticated Users.
|
||||||
|
*/
|
||||||
|
export async function listResearch(
|
||||||
|
apiKey: string,
|
||||||
|
limit: number = 50
|
||||||
|
): Promise<ResearchSummary[]> {
|
||||||
|
const list = await apiGet<ResearchSummary[]>('/v1/research', { apiKey });
|
||||||
|
return list.slice(0, limit);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert den finalen Forschungsbericht einer abgeschlossenen Research-Abfrage.
|
||||||
|
*/
|
||||||
|
export async function getResearchReport(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string
|
||||||
|
): Promise<any> {
|
||||||
|
return await apiGet<any>(`/v1/research/${researchId}/report`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Löscht eine Research-Abfrage permanent.
|
||||||
|
*/
|
||||||
|
export async function deleteResearch(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await apiDelete<void>(`/v1/research/${researchId}`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Generiert einen Share-Link für eine Research-Abfrage.
|
||||||
|
*/
|
||||||
|
export interface ShareResult {
|
||||||
|
token: string;
|
||||||
|
url: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function shareResearch(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string,
|
||||||
|
expiresAt?: string,
|
||||||
|
maxViews?: number
|
||||||
|
): Promise<ShareResult> {
|
||||||
|
return await apiPost<ShareResult>('/v1/research/share', {
|
||||||
|
research_id: researchId,
|
||||||
|
expires_at: expiresAt,
|
||||||
|
max_views: maxViews,
|
||||||
|
}, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liest die Daten eines geteilten Threads über den Share-Token.
|
||||||
|
* Keine Authentifizierung nötig.
|
||||||
|
*/
|
||||||
|
export async function getShareData(
|
||||||
|
token: string
|
||||||
|
): Promise<ShareData> {
|
||||||
|
return await apiGet<ShareData>(`/v1/research/share/${token}`);
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Beendet eine laufende Research-Abfrage.
|
||||||
|
*/
|
||||||
|
export async function stopResearch(
|
||||||
|
apiKey: string,
|
||||||
|
researchId: string
|
||||||
|
): Promise<void> {
|
||||||
|
await apiPost<void>(`/v1/research/${researchId}/stop`, undefined, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Revoke (deaktiviert) einen Share-Token.
|
||||||
|
*/
|
||||||
|
export async function revokeShare(
|
||||||
|
apiKey: string,
|
||||||
|
token: string
|
||||||
|
): Promise<void> {
|
||||||
|
await apiDelete<void>(`/v1/research/share/${token}`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Zusätzliche Typen
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
export interface ResearchDetail {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
language?: string;
|
||||||
|
depth?: string;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
sources?: SourceInfo[];
|
||||||
|
claims?: ClaimInfo[];
|
||||||
|
evidence?: EvidenceScore[];
|
||||||
|
evidence_scores?: Record<string, number>;
|
||||||
|
report?: string;
|
||||||
|
methodology?: string;
|
||||||
|
username?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
export interface ShareData {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
language?: string;
|
||||||
|
depth?: string;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
sources?: SourceInfo[];
|
||||||
|
claims?: ClaimInfo[];
|
||||||
|
evidence?: EvidenceScore[];
|
||||||
|
evidence_scores?: Record<string, number>;
|
||||||
|
report?: string;
|
||||||
|
methodology?: string;
|
||||||
|
username?: string;
|
||||||
|
share_token?: string;
|
||||||
|
share_expires_at?: string;
|
||||||
|
share_view_count?: number;
|
||||||
|
share_max_views?: number;
|
||||||
|
share_expired?: boolean;
|
||||||
|
}
|
||||||
@@ -1,4 +1,5 @@
|
|||||||
import { writable } from 'svelte/store';
|
import { writable } from 'svelte/store';
|
||||||
|
import type { ResearchSummary } from '$lib/types';
|
||||||
|
|
||||||
// Theme: 'dark' | 'light'
|
// Theme: 'dark' | 'light'
|
||||||
export const theme = writable<'dark' | 'light'>('dark');
|
export const theme = writable<'dark' | 'light'>('dark');
|
||||||
@@ -11,3 +12,28 @@ export const sidebarOpen = writable(false);
|
|||||||
|
|
||||||
// Session IDs der letzten Researches
|
// Session IDs der letzten Researches
|
||||||
export const recentResearchIds = writable<string[]>([]);
|
export const recentResearchIds = writable<string[]>([]);
|
||||||
|
|
||||||
|
// ========================
|
||||||
|
// Research-Specific Stores
|
||||||
|
// ========================
|
||||||
|
|
||||||
|
/** Aktuell selektierte Research-ID */
|
||||||
|
export const currentResearchId = writable<string | null>(null);
|
||||||
|
|
||||||
|
/** Aktuelle Research-Daten (wird durch Polling aktualisiert) */
|
||||||
|
export const researchData = writable<any>(null);
|
||||||
|
|
||||||
|
/** Research-Historie (Sidebar-Liste) */
|
||||||
|
export const researchHistory = writable<ResearchSummary[]>([]);
|
||||||
|
|
||||||
|
/** Polling-Intervall in Millisekunden */
|
||||||
|
export const pollingInterval = writable<number>(5000);
|
||||||
|
|
||||||
|
/** Ob das Polling currently aktiv ist */
|
||||||
|
export const pollingActive = writable<boolean>(false);
|
||||||
|
|
||||||
|
/** Letzter API-Fehler, falls vorhanden */
|
||||||
|
export const lastApiError = writable<string | null>(null);
|
||||||
|
|
||||||
|
/** Share-Token für den aktuellen Research-Thread */
|
||||||
|
export const shareToken = writable<string | null>(null);
|
||||||
100
src/lib/utils/formatters.ts
Normal file
100
src/lib/utils/formatters.ts
Normal file
@@ -0,0 +1,100 @@
|
|||||||
|
/**
|
||||||
|
* Kürzt eine UUID auf die ersten 8 Zeichen.
|
||||||
|
*/
|
||||||
|
export function truncateId(id: string): string {
|
||||||
|
if (!id) return '';
|
||||||
|
return id.length > 8 ? id.slice(0, 8) : id;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Kürzt einen Query-Text auf max Zeichen mit Auslassungszeichen.
|
||||||
|
*/
|
||||||
|
export function truncateQuery(query: string, max: number = 60): string {
|
||||||
|
if (!query) return '';
|
||||||
|
return query.length > max ? query.slice(0, max) + '…' : query;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert ein Datum als DD.MM.YYYY HH:mm.
|
||||||
|
*/
|
||||||
|
export function formatDate(date: string): string {
|
||||||
|
if (!date) return '';
|
||||||
|
const d = new Date(date);
|
||||||
|
if (isNaN(d.getTime())) return date;
|
||||||
|
return d.toLocaleDateString('de-DE', {
|
||||||
|
day: '2-digit',
|
||||||
|
month: '2-digit',
|
||||||
|
year: 'numeric',
|
||||||
|
}) + ' ' + d.toLocaleTimeString('de-DE', {
|
||||||
|
hour: '2-digit',
|
||||||
|
minute: '2-digit',
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Formatiert einen Status-Code in einen lesbaren deutschen Text.
|
||||||
|
*/
|
||||||
|
export function formatStatus(status: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
PENDING: 'Ausstehend',
|
||||||
|
PLANNING: 'Planung',
|
||||||
|
SEARCHING: 'Suche läuft',
|
||||||
|
EXTRACTING: 'Extraktion',
|
||||||
|
ANALYZING: 'Analyse',
|
||||||
|
SYNTHESIZING: 'Synthese',
|
||||||
|
COMPLETED: 'Abgeschlossen',
|
||||||
|
FAILED: 'Fehlgeschlagen',
|
||||||
|
STOPPED: 'Gestoppt',
|
||||||
|
};
|
||||||
|
return map[status] ?? status;
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein Badge-Objekt für Confidence-Scores.
|
||||||
|
*/
|
||||||
|
export function getConfidenceBadge(confidence: number): { label: string; color: string } {
|
||||||
|
if (confidence >= 0.8) {
|
||||||
|
return { label: 'Hoch', color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' };
|
||||||
|
}
|
||||||
|
if (confidence >= 0.6) {
|
||||||
|
return { label: 'Mittel', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' };
|
||||||
|
}
|
||||||
|
if (confidence >= 0.4) {
|
||||||
|
return { label: 'Niedrig', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' };
|
||||||
|
}
|
||||||
|
return { label: 'Sehr niedrig', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert ein Badge-Objekt für Independence-Scores.
|
||||||
|
*/
|
||||||
|
export function getIndependenceBadge(score: number): { label: string; color: string } {
|
||||||
|
if (score >= 0.8) {
|
||||||
|
return { label: 'Sehr unabhängig', color: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200' };
|
||||||
|
}
|
||||||
|
if (score >= 0.6) {
|
||||||
|
return { label: 'Unabhängig', color: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200' };
|
||||||
|
}
|
||||||
|
if (score >= 0.4) {
|
||||||
|
return { label: 'Eingeschränkt', color: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200' };
|
||||||
|
}
|
||||||
|
return { label: 'Abhängig', color: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200' };
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert eine CSS-Klasse basierend auf einem numerischen Score.
|
||||||
|
*/
|
||||||
|
export function scoreColor(score: number): string {
|
||||||
|
if (score >= 0.8) return 'text-emerald-600 dark:text-emerald-400';
|
||||||
|
if (score >= 0.6) return 'text-blue-600 dark:text-blue-400';
|
||||||
|
if (score >= 0.4) return 'text-yellow-600 dark:text-yellow-400';
|
||||||
|
return 'text-red-600 dark:text-red-400';
|
||||||
|
}
|
||||||
|
|
||||||
|
/**
|
||||||
|
* Liefert einen Favicon-Placeholder-Buchstaben aus einer Domain.
|
||||||
|
*/
|
||||||
|
export function domainFavicon(domain: string): string {
|
||||||
|
if (!domain) return '?';
|
||||||
|
return domain.replace(/^(https?:\/\/)?(www\.)?/, '').charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
@@ -1,55 +1,91 @@
|
|||||||
<script lang="ts">
|
<script lang="ts">
|
||||||
import { onDestroy, onMount } from 'svelte';
|
import { onDestroy, onMount } from 'svelte';
|
||||||
import { selectedResearchId } from '$lib/sidebar';
|
import { getResearchStatus, getResearchDetail, stopResearch, deleteResearch, revokeShare } from '$lib/research-api';
|
||||||
import { apiGet } from '$lib/api';
|
import { getApiKey } from '$lib/auth';
|
||||||
import type { ResearchStatus, ResearchDetail } from '$lib/types';
|
import { pollingInterval, pollingActive, lastApiError, shareToken } from '$lib/stores';
|
||||||
|
import { truncateId } from '$lib/utils/formatters';
|
||||||
|
import type { ResearchDetail } from '$lib/research-api';
|
||||||
|
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';
|
||||||
|
|
||||||
export let researchId: string;
|
export let researchId: string;
|
||||||
|
|
||||||
const POLL_INTERVAL_MS = 5000;
|
|
||||||
|
|
||||||
let status: ResearchStatus | null = null;
|
|
||||||
let detail: ResearchDetail | null = null;
|
let detail: ResearchDetail | null = null;
|
||||||
|
let statusText = '';
|
||||||
|
let progress = 0;
|
||||||
let error: string | null = null;
|
let error: string | null = null;
|
||||||
let lastUpdate = '';
|
let loading = true;
|
||||||
let activeTab = 'Status';
|
let activeTab = 'Status';
|
||||||
|
let showShareModal = false;
|
||||||
|
let showStopConfirm = false;
|
||||||
|
let showDeleteConfirm = false;
|
||||||
|
let hasFinished = false;
|
||||||
|
|
||||||
$: activeId = $selectedResearchId;
|
$: TABS = hasFinished
|
||||||
|
? ['Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik']
|
||||||
const TABS = ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
|
: ['Status', 'Quellen', 'Claims', 'Evidence', 'Bericht', 'Methodik'];
|
||||||
|
|
||||||
async function pollStatus() {
|
|
||||||
try {
|
|
||||||
const res = await apiGet<ResearchStatus>(`/v1/research/${researchId}/status`);
|
|
||||||
status = res;
|
|
||||||
lastUpdate = new Date().toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit', second: '2-digit' });
|
|
||||||
error = null;
|
|
||||||
} catch (e) {
|
|
||||||
error = e instanceof Error ? e.message : 'Status-Abfrage fehlgeschlagen.';
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
async function pollDetail() {
|
|
||||||
try {
|
|
||||||
const res = await apiGet<ResearchDetail>(`/v1/research/${researchId}`);
|
|
||||||
detail = res;
|
|
||||||
} catch {
|
|
||||||
// Detail fetch is non-critical
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
|
// --- Polling ---
|
||||||
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
$: pollingMs = $pollingInterval;
|
||||||
|
$: isPollingActive = $pollingActive;
|
||||||
|
|
||||||
|
async function doPoll() {
|
||||||
|
try {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) {
|
||||||
|
error = 'Kein API-Key verfügbar.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const res = 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;
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
// Statusänderung — partiell updaten für live-Quellen/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 ?? '' };
|
||||||
|
}
|
||||||
|
} catch {
|
||||||
|
// Non-critical
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
lastApiError.set(null);
|
||||||
|
} catch (e) {
|
||||||
|
const msg = e instanceof Error ? e.message : 'Status-Abfrage fehlgeschlagen.';
|
||||||
|
error = msg;
|
||||||
|
lastApiError.set(msg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function startPolling() {
|
function startPolling() {
|
||||||
if (pollTimer) clearInterval(pollTimer);
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
pollStatus();
|
doPoll();
|
||||||
pollDetail();
|
|
||||||
pollTimer = setInterval(async () => {
|
pollTimer = setInterval(async () => {
|
||||||
await pollStatus();
|
await doPoll();
|
||||||
if (status && (status.status === 'COMPLETED' || status.status === 'FAILED')) {
|
}, pollingMs);
|
||||||
await pollDetail();
|
|
||||||
}
|
|
||||||
}, POLL_INTERVAL_MS);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function stopPolling() {
|
function stopPolling() {
|
||||||
@@ -57,6 +93,30 @@
|
|||||||
clearInterval(pollTimer);
|
clearInterval(pollTimer);
|
||||||
pollTimer = null;
|
pollTimer = null;
|
||||||
}
|
}
|
||||||
|
$pollingActive = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleStop() {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
try {
|
||||||
|
await stopResearch(apiKey, researchId);
|
||||||
|
hasFinished = true;
|
||||||
|
error = null;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Research konnte nicht gestoppt werden.';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleDelete() {
|
||||||
|
const apiKey = getApiKey();
|
||||||
|
if (!apiKey) return;
|
||||||
|
try {
|
||||||
|
await deleteResearch(apiKey, researchId);
|
||||||
|
window.location.href = '/research';
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Research konnte nicht gelöscht werden.';
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
onMount(() => {
|
onMount(() => {
|
||||||
@@ -77,6 +137,8 @@
|
|||||||
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200';
|
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200';
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
case 'STOPPED':
|
||||||
|
return 'bg-orange-100 text-orange-800 dark:bg-orange-900 dark:text-orange-200';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
}
|
}
|
||||||
@@ -92,6 +154,8 @@
|
|||||||
return 'bg-emerald-400';
|
return 'bg-emerald-400';
|
||||||
case 'FAILED':
|
case 'FAILED':
|
||||||
return 'bg-red-400';
|
return 'bg-red-400';
|
||||||
|
case 'STOPPED':
|
||||||
|
return 'bg-orange-400';
|
||||||
default:
|
default:
|
||||||
return 'bg-gray-300';
|
return 'bg-gray-300';
|
||||||
}
|
}
|
||||||
@@ -102,27 +166,90 @@
|
|||||||
<!-- Header -->
|
<!-- 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="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">
|
<div class="flex items-center justify-between gap-4">
|
||||||
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-md">
|
<div class="flex-1 min-w-0">
|
||||||
|
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate">
|
||||||
{detail?.query ?? detail?.id ?? researchId}
|
{detail?.query ?? detail?.id ?? researchId}
|
||||||
</h2>
|
</h2>
|
||||||
<div class="flex items-center gap-3 flex-shrink-0">
|
<span class="text-xs text-gray-400 font-mono">{truncateId(researchId)}</span>
|
||||||
{#if status}
|
</div>
|
||||||
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium {statusColorClass(status.status)}">
|
<div class="flex items-center gap-2 flex-shrink-0">
|
||||||
<span class="w-2 h-2 rounded-full {statusDotClass(status.status)}"></span>
|
{#if statusText}
|
||||||
{status.status}
|
<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>
|
</span>
|
||||||
{#if status.progress !== undefined}
|
|
||||||
<span class="text-xs text-gray-500">{status.progress}%</span>
|
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
||||||
|
<button
|
||||||
|
on:click={() => showStopConfirm = !showStopConfirm}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 24 24">
|
||||||
|
<rect x="6" y="6" width="12" height="12" rx="1"></rect>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
{/if}
|
{/if}
|
||||||
|
<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}
|
||||||
|
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"
|
||||||
|
>
|
||||||
|
<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="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"></path>
|
||||||
|
</svg>
|
||||||
|
</button>
|
||||||
</div>
|
</div>
|
||||||
</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>
|
</div>
|
||||||
|
|
||||||
<!-- Error banner -->
|
<!-- Error banner -->
|
||||||
{#if error}
|
{#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="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>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
|
||||||
@@ -143,37 +270,66 @@
|
|||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Tab content -->
|
<!-- Tab content -->
|
||||||
<div class="px-4 py-6 flex-1 min-h-0">
|
<div class="px-4 py-6 flex-1 min-h-0 overflow-y-auto">
|
||||||
<!-- Status Tab -->
|
<!-- Status Tab -->
|
||||||
{#if activeTab === 'Status'}
|
{#if activeTab === 'Status'}
|
||||||
<div>
|
<div>
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Aktueller Status</h3>
|
{#if statusText}
|
||||||
{#if status}
|
<div class="space-y-4">
|
||||||
<div class="space-y-3">
|
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-gray-700 dark:text-gray-300">Status</span>
|
<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(status.status)}">
|
<span class="px-2 py-0.5 rounded text-xs font-medium {statusColorClass(statusText)}">
|
||||||
{status.status}
|
{statusText}
|
||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if status.progress !== undefined}
|
{#if statusText !== 'COMPLETED' && statusText !== 'FAILED' && statusText !== 'STOPPED'}
|
||||||
<div>
|
<div>
|
||||||
<div class="flex items-center justify-between mb-1">
|
<div class="flex items-center justify-between mb-1">
|
||||||
<span class="text-sm text-gray-500">Fortschritt</span>
|
<span class="text-sm text-gray-500">Fortschritt</span>
|
||||||
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{status.progress}%</span>
|
<span class="text-sm font-medium text-gray-700 dark:text-gray-300">{progress}%</span>
|
||||||
</div>
|
</div>
|
||||||
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-2">
|
<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: {status.progress}%"></div>
|
<div
|
||||||
|
class="bg-indigo-600 h-2 rounded-full transition-all duration-300"
|
||||||
|
style="width: {progress}%"
|
||||||
|
></div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
|
{#if detail?.created_at}
|
||||||
<div class="flex items-center justify-between">
|
<div class="flex items-center justify-between">
|
||||||
<span class="text-sm text-gray-500">Letzte Aktualisierung</span>
|
<span class="text-sm text-gray-500 dark:text-gray-400">Erstellt</span>
|
||||||
<span class="text-sm text-gray-700 dark:text-gray-300">{lastUpdate}</span>
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{new Date(detail.created_at).toLocaleString('de-DE')}
|
||||||
|
</span>
|
||||||
</div>
|
</div>
|
||||||
{#if status.error}
|
{/if}
|
||||||
<div class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
{#if detail?.completed_at}
|
||||||
<p class="text-sm text-red-700 dark:text-red-300">Fehler: {status.error}</p>
|
<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>
|
</div>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
@@ -182,112 +338,52 @@
|
|||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<!-- Quellen Tab -->
|
<!-- Sources Tab -->
|
||||||
{:else if activeTab === 'Quellen'}
|
{:else if activeTab === 'Quellen'}
|
||||||
<div>
|
<SourcesTab
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Gefundene Quellen</h3>
|
{sources: detail?.sources ?? []}
|
||||||
{#if detail?.sources && detail.sources.length > 0}
|
{loading: false}
|
||||||
<div class="space-y-2">
|
{error}
|
||||||
{#each detail.sources as source}
|
/>
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">{source.title}</p>
|
|
||||||
<p class="text-xs text-gray-500 mt-1 truncate">{source.url}</p>
|
|
||||||
{#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>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-500">Noch keine Quellen gefunden.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Claims Tab -->
|
<!-- Claims Tab -->
|
||||||
{:else if activeTab === 'Claims'}
|
{:else if activeTab === 'Claims'}
|
||||||
<div>
|
<ClaimsTab
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Extrahierte Claims</h3>
|
{claims: detail?.claims ?? []}
|
||||||
{#if detail?.claims && detail.claims.length > 0}
|
{loading: false}
|
||||||
<div class="space-y-2">
|
{error}
|
||||||
{#each detail.claims as claim}
|
/>
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
|
||||||
<p class="text-sm text-gray-900 dark:text-white">{claim.claim}</p>
|
|
||||||
<div class="flex items-center gap-3 mt-2">
|
|
||||||
<span class="text-xs text-gray-500">Typ: {claim.claim_type}</span>
|
|
||||||
<span class="text-xs text-gray-500">Confidence: {(claim.confidence * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-500">Noch keine Claims extrahiert.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Evidence Tab -->
|
<!-- Evidence Tab -->
|
||||||
{:else if activeTab === 'Evidence'}
|
{:else if activeTab === 'Evidence'}
|
||||||
<div>
|
<EvidenceTab
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Evidence-Scores</h3>
|
evidence={detail?.evidence ?? []}
|
||||||
{#if detail?.evidence && detail.evidence.length > 0}
|
evidence_scores={detail?.evidence_scores ?? {}}
|
||||||
<div class="space-y-3">
|
{loading: false}
|
||||||
{#each detail.evidence as score, i}
|
{error}
|
||||||
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg space-y-2">
|
/>
|
||||||
<p class="text-sm font-medium text-gray-900 dark:text-white">Score {i + 1}</p>
|
|
||||||
<div class="grid grid-cols-2 gap-2 text-xs">
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Quellen-Unabhängigkeit</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.source_independence * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Primärquellen-Nähe</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.primary_source_proximity * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Quellen-Übereinstimmung</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.cross_source_support * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Widerspruch</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.contradiction_level * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Evidenz-Direktheit</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.evidence_directness * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
<div class="flex justify-between">
|
|
||||||
<span class="text-gray-500">Datumsrelevanz</span>
|
|
||||||
<span class="font-medium text-gray-700 dark:text-gray-300">{(score.date_relevance * 100).toFixed(0)}%</span>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
{/each}
|
|
||||||
</div>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-500">Noch keine Evidence-Scores berechnet.</p>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<!-- Bericht Tab -->
|
<!-- Report Tab -->
|
||||||
{:else if activeTab === 'Bericht'}
|
{:else if activeTab === 'Bericht'}
|
||||||
<div>
|
<ReportTab
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Forschungsbericht</h3>
|
report={detail?.report ?? ''}
|
||||||
{#if detail?.report}
|
detail={detail}
|
||||||
<pre class="whitespace-pre-wrap bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-sm text-gray-700 dark:text-gray-300">{detail.report}</pre>
|
{loading: false}
|
||||||
{:else}
|
{error}
|
||||||
<p class="text-sm text-gray-500">Bericht wird generiert, sobald die Recherche abgeschlossen ist.</p>
|
/>
|
||||||
|
|
||||||
|
<!-- Methodology Tab -->
|
||||||
|
{:else if activeTab === 'Methodik'}
|
||||||
|
<MethodologyTab
|
||||||
|
methodology={detail?.methodology ?? ''}
|
||||||
|
{loading: false}
|
||||||
|
{error}
|
||||||
|
/>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<!-- Methodik Tab -->
|
<!-- Share Modal -->
|
||||||
{:else if activeTab === 'Methodik'}
|
{#if showShareModal}
|
||||||
<div>
|
<ShareModal researchId={researchId} on:close={() => showShareModal = false} />
|
||||||
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Methodik-Dokumentation</h3>
|
|
||||||
{#if detail?.methodology}
|
|
||||||
<pre class="whitespace-pre-wrap bg-gray-50 dark:bg-gray-800 p-4 rounded-lg text-sm text-gray-700 dark:text-gray-300">{detail.methodology}</pre>
|
|
||||||
{:else}
|
|
||||||
<p class="text-sm text-gray-500">Methodik wird nach Abschluss der Recherche verfügbar sein.</p>
|
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
|
||||||
{/if}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
112
src/routes/research/{id}/components/ClaimsTab.svelte
Normal file
112
src/routes/research/{id}/components/ClaimsTab.svelte
Normal file
@@ -0,0 +1,112 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { getConfidenceBadge, formatDate } from '$lib/utils/formatters';
|
||||||
|
import type { ClaimInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export let claims: ClaimInfo[] = [];
|
||||||
|
export let loading = false;
|
||||||
|
export let error: string | null = null;
|
||||||
|
|
||||||
|
function claimTypeIcon(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'fact': return '🔍';
|
||||||
|
case 'estimate': return '📊';
|
||||||
|
case 'prediction': return '🔮';
|
||||||
|
case 'opinion': return '💬';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimTypeLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
fact: 'Fakt',
|
||||||
|
estimate: 'Schätzung',
|
||||||
|
prediction: 'Vorhersage',
|
||||||
|
opinion: 'Meinung',
|
||||||
|
};
|
||||||
|
return map[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimTypeColor(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
estimate: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
prediction: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
opinion: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Extrahierte Claims ({claims.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3] as _}
|
||||||
|
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg animate-pulse">
|
||||||
|
<div class="flex gap-2 mb-2">
|
||||||
|
<div class="h-5 w-5 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
<div class="h-3 flex-1 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="h-5 w-16 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
<div class="h-5 w-16 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else 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>
|
||||||
|
{:else if claims.length === 0}
|
||||||
|
<p class="text-sm text-gray-500">Noch keine Claims extrahiert.</p>
|
||||||
|
{:else}
|
||||||
|
{#each claims as claim, i}
|
||||||
|
<div class="group p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 transition-colors">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<span class="text-xl flex-shrink-0 mt-0.5">{claimTypeIcon(claim.claim_type)}</span>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium {claimTypeColor(claim.claim_type)}">
|
||||||
|
{claimTypeLabel(claim.claim_type)}
|
||||||
|
</span>
|
||||||
|
{#if claim.id}
|
||||||
|
<span class="text-xs text-gray-400 font-mono">{claim.id}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">
|
||||||
|
{claim.claim}
|
||||||
|
</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mt-3">
|
||||||
|
<!-- Confidence Badge -->
|
||||||
|
{#if claim.confidence !== undefined}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium {getConfidenceBadge(claim.confidence).color}">
|
||||||
|
Confidence: {(claim.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
<!-- Evidence Span -->
|
||||||
|
{#if claim.evidence_span}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-indigo-50 text-indigo-700 dark:bg-indigo-900/50 dark:text-indigo-300">
|
||||||
|
Evidence Span
|
||||||
|
</span>
|
||||||
|
<p class="text-xs text-gray-600 dark:text-gray-400 leading-relaxed">
|
||||||
|
{claim.evidence_span}
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
{#if claim.source_id}
|
||||||
|
<span class="text-xs text-gray-400 font-mono">
|
||||||
|
Quelle: {claim.source_id}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
113
src/routes/research/{id}/components/EvidenceTab.svelte
Normal file
113
src/routes/research/{id}/components/EvidenceTab.svelte
Normal file
@@ -0,0 +1,113 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { scoreColor } from '$lib/utils/formatters';
|
||||||
|
|
||||||
|
export let evidence: any[] = [];
|
||||||
|
export let evidence_scores: Record<string, number> = {};
|
||||||
|
export let loading = false;
|
||||||
|
export let error: string | null = null;
|
||||||
|
|
||||||
|
// 6 Dimensionen
|
||||||
|
const DIMENSIONS = [
|
||||||
|
{ key: 'source_independence', label: 'Quellen-Unabhängigkeit' },
|
||||||
|
{ key: 'primary_source_proximity', label: 'Primärquellen-Nähe' },
|
||||||
|
{ key: 'cross_source_support', label: 'Quellen-Übereinstimmung' },
|
||||||
|
{ key: 'contradiction_level', label: 'Widerspruch' },
|
||||||
|
{ key: 'evidence_directness', label: 'Evidenz-Direktheit' },
|
||||||
|
{ key: 'date_relevance', label: 'Datumsrelevanz' },
|
||||||
|
];
|
||||||
|
|
||||||
|
function scorePercent(score: number): string {
|
||||||
|
return (score * 100).toFixed(0);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-6">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Evidenz-Scores
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each DIMENSIONS as dim}
|
||||||
|
<div>
|
||||||
|
<div class="h-3 w-32 bg-gray-300 dark:bg-gray-600 rounded mb-1"></div>
|
||||||
|
<div class="h-5 w-full bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else 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>
|
||||||
|
{:else if evidence.length === 0 && Object.keys(evidence_scores).length === 0}
|
||||||
|
<p class="text-sm text-gray-500">Noch keine Evidenz-Scores berechnet.</p>
|
||||||
|
{:else}
|
||||||
|
<!-- Single evidence object or aggregate scores -->
|
||||||
|
{#if evidence_scores && Object.keys(evidence_scores).length > 0}
|
||||||
|
<div class="space-y-4">
|
||||||
|
{#each DIMENSIONS as dim}
|
||||||
|
{#if evidence_scores[dim.key] !== undefined}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{dim.label}</span>
|
||||||
|
<span class="text-sm font-medium {scoreColor(evidence_scores[dim.key])}">
|
||||||
|
{scorePercent(evidence_scores[dim.key])}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-3 rounded-full transition-all duration-500"
|
||||||
|
style="width: {scorePercent(evidence_scores[dim.key])}%"
|
||||||
|
class:bg-emerald-500={evidence_scores[dim.key] >= 0.8}
|
||||||
|
class:bg-blue-500={evidence_scores[dim.key] >= 0.6 && evidence_scores[dim.key] < 0.8}
|
||||||
|
class:bg-yellow-500={evidence_scores[dim.key] >= 0.4 && evidence_scores[dim.key] < 0.6}
|
||||||
|
class:bg-red-500={evidence_scores[dim.key] < 0.4}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Per-evidence-blocks -->
|
||||||
|
{#if evidence && evidence.length > 1}
|
||||||
|
<div class="border-t border-gray-200 dark:border-gray-700 pt-4">
|
||||||
|
<h4 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-3">
|
||||||
|
Einzelscores
|
||||||
|
</h4>
|
||||||
|
{#each evidence as score, idx}
|
||||||
|
<div class="mb-4">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white mb-2">
|
||||||
|
Score {idx + 1}
|
||||||
|
</p>
|
||||||
|
<div class="grid grid-cols-1 sm:grid-cols-2 gap-x-6 gap-y-2">
|
||||||
|
{#each DIMENSIONS as dim}
|
||||||
|
{#if score[dim.key] !== undefined}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-xs text-gray-500">{dim.label}</span>
|
||||||
|
<span class="text-xs font-medium {scoreColor(score[dim.key])}">
|
||||||
|
{scorePercent(score[dim.key])}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="col-span-2 mt-0.5 w-full bg-gray-200 dark:bg-gray-700 rounded-full h-1.5 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-1.5 rounded-full"
|
||||||
|
style="width: {scorePercent(score[dim.key])}%"
|
||||||
|
class:bg-emerald-500={score[dim.key] >= 0.8}
|
||||||
|
class:bg-blue-500={score[dim.key] >= 0.6 && score[dim.key] < 0.8}
|
||||||
|
class:bg-yellow-500={score[dim.key] >= 0.4 && score[dim.key] < 0.6}
|
||||||
|
class:bg-red-500={score[dim.key] < 0.4}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
35
src/routes/research/{id}/components/MethodologyTab.svelte
Normal file
35
src/routes/research/{id}/components/MethodologyTab.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Markdown } from 'svelte-markdown';
|
||||||
|
|
||||||
|
export let methodology: string = '';
|
||||||
|
export let loading = false;
|
||||||
|
export let error: string | null = null;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Methodik-Dokumentation
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3, 4, 5] as _}
|
||||||
|
<div class="h-3 w-full bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else 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>
|
||||||
|
{:else if methodology}
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Markdown>{methodology}</Markdown>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Methodik wird nach Abschluss der Recherche verfügbar sein.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
122
src/routes/research/{id}/components/ReportTab.svelte
Normal file
122
src/routes/research/{id}/components/ReportTab.svelte
Normal file
@@ -0,0 +1,122 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { Markdown } from 'svelte-markdown';
|
||||||
|
import type { ResearchDetail } from '$lib/research-api';
|
||||||
|
|
||||||
|
export let report: string = '';
|
||||||
|
export let detail: ResearchDetail | null = null;
|
||||||
|
export let loading = false;
|
||||||
|
export let error: string | null = null;
|
||||||
|
|
||||||
|
let showDownload = false;
|
||||||
|
let downloadFormat = 'markdown';
|
||||||
|
|
||||||
|
function downloadReport(format: string) {
|
||||||
|
let content: string;
|
||||||
|
let filename: string;
|
||||||
|
let mimeType: string;
|
||||||
|
|
||||||
|
const title = detail?.query || 'research-report';
|
||||||
|
const safeTitle = title.replace(/[^a-z0-9]/gi, '-').toLowerCase().slice(0, 40);
|
||||||
|
|
||||||
|
switch (format) {
|
||||||
|
case 'json':
|
||||||
|
const data = {
|
||||||
|
id: detail?.id,
|
||||||
|
query: detail?.query,
|
||||||
|
report,
|
||||||
|
created_at: detail?.created_at,
|
||||||
|
completed_at: detail?.completed_at,
|
||||||
|
};
|
||||||
|
content = JSON.stringify(data, null, 2);
|
||||||
|
filename = `${safeTitle}.json`;
|
||||||
|
mimeType = 'application/json';
|
||||||
|
break;
|
||||||
|
case 'markdown':
|
||||||
|
content = report;
|
||||||
|
filename = `${safeTitle}.md`;
|
||||||
|
mimeType = 'text/markdown';
|
||||||
|
break;
|
||||||
|
case 'text':
|
||||||
|
content = report.replace(/\n/g, '\n');
|
||||||
|
filename = `${safeTitle}.txt`;
|
||||||
|
mimeType = 'text/plain';
|
||||||
|
break;
|
||||||
|
default:
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const blob = new Blob([content], { type: mimeType });
|
||||||
|
const url = URL.createObjectURL(blob);
|
||||||
|
const a = document.createElement('a');
|
||||||
|
a.href = url;
|
||||||
|
a.download = filename;
|
||||||
|
document.body.appendChild(a);
|
||||||
|
a.click();
|
||||||
|
document.body.removeChild(a);
|
||||||
|
URL.revokeObjectURL(url);
|
||||||
|
showDownload = false;
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-4">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Forschungsbericht
|
||||||
|
</h3>
|
||||||
|
{#if report}
|
||||||
|
<button
|
||||||
|
on:click={() => showDownload = !showDownload}
|
||||||
|
class="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"
|
||||||
|
>
|
||||||
|
<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="M4 16v1a3 3 0 003 3h10a3 3 0 003-3v-1m-4-4l-4 4m0 0l-4-4m4 4V4"></path>
|
||||||
|
</svg>
|
||||||
|
Herunterladen
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Download dropdown -->
|
||||||
|
{#if showDownload}
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<button
|
||||||
|
on:click={() => downloadReport('markdown')}
|
||||||
|
class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Markdown (.md)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => downloadReport('json')}
|
||||||
|
class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
JSON (.json)
|
||||||
|
</button>
|
||||||
|
<button
|
||||||
|
on:click={() => downloadReport('text')}
|
||||||
|
class="px-3 py-1.5 text-xs font-medium text-gray-700 dark:text-gray-300 bg-gray-100 dark:bg-gray-700 hover:bg-gray-200 dark:hover:bg-gray-600 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Text (.txt)
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3, 4, 5] as _}
|
||||||
|
<div class="h-3 w-full bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else 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>
|
||||||
|
{:else if report}
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Markdown>{report}</Markdown>
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-500">
|
||||||
|
Bericht wird generiert, sobald die Recherche abgeschlossen ist.
|
||||||
|
</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
109
src/routes/research/{id}/components/SourcesTab.svelte
Normal file
109
src/routes/research/{id}/components/SourcesTab.svelte
Normal file
@@ -0,0 +1,109 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { domainFavicon, getIndependenceBadge, formatDate } from '$lib/utils/formatters';
|
||||||
|
import type { SourceInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export let sources: SourceInfo[] = [];
|
||||||
|
export let loading = false;
|
||||||
|
export let error: string | null = null;
|
||||||
|
|
||||||
|
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;
|
||||||
|
}
|
||||||
|
|
||||||
|
function sourceTypeColor(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
academic: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
news: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
blog: 'bg-green-100 text-green-700 dark:bg-green-900 dark:text-green-300',
|
||||||
|
government: 'bg-indigo-100 text-indigo-700 dark:bg-indigo-900 dark:text-indigo-300',
|
||||||
|
forum: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
social: 'bg-pink-100 text-pink-700 dark:bg-pink-900 dark:text-pink-300',
|
||||||
|
wikipedia: 'bg-gray-100 text-gray-700 dark:bg-gray-600 dark:text-gray-300',
|
||||||
|
};
|
||||||
|
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400">
|
||||||
|
Gefundene Quellen ({sources.length})
|
||||||
|
</h3>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each [1, 2, 3, 4] as _}
|
||||||
|
<div class="p-4 bg-gray-50 dark:bg-gray-800 rounded-lg animate-pulse">
|
||||||
|
<div class="h-4 w-48 bg-gray-300 dark:bg-gray-600 rounded mb-2"></div>
|
||||||
|
<div class="h-3 w-32 bg-gray-300 dark:bg-gray-600 rounded mb-2"></div>
|
||||||
|
<div class="flex gap-2">
|
||||||
|
<div class="h-5 w-16 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
<div class="h-5 w-24 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else 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>
|
||||||
|
{:else if sources.length === 0}
|
||||||
|
<p class="text-sm text-gray-500">Noch keine Quellen gefunden.</p>
|
||||||
|
{:else}
|
||||||
|
{#each sources as source, i}
|
||||||
|
<div class="group p-4 bg-gray-50 dark:bg-gray-800/50 rounded-lg border border-gray-100 dark:border-gray-700 hover:border-gray-200 dark:hover:border-gray-600 transition-colors">
|
||||||
|
<div class="flex items-start gap-3">
|
||||||
|
<!-- Favicon Placeholder -->
|
||||||
|
<div class="flex-shrink-0 w-8 h-8 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs font-bold text-gray-500 dark:text-gray-400">
|
||||||
|
{domainFavicon(source.domain)}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-semibold text-gray-900 dark:text-white truncate">
|
||||||
|
{source.title}
|
||||||
|
</p>
|
||||||
|
<a
|
||||||
|
href={source.url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
class="text-xs text-blue-600 dark:text-blue-400 hover:underline truncate block mt-0.5"
|
||||||
|
title={source.url}
|
||||||
|
>
|
||||||
|
{source.url}
|
||||||
|
</a>
|
||||||
|
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||||
|
{#if source.domain}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400">
|
||||||
|
{source.domain}
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if source.source_type}
|
||||||
|
<span class="inline-flex items-center px-2 py-0.5 rounded text-xs font-medium {sourceTypeColor(source.source_type)}">
|
||||||
|
{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}
|
||||||
|
{#if source.id}
|
||||||
|
<span class="text-xs text-gray-400 font-mono">ID: {source.id}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
273
src/routes/share/[token]/+page.svelte
Normal file
273
src/routes/share/[token]/+page.svelte
Normal file
@@ -0,0 +1,273 @@
|
|||||||
|
<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 { Markdown } from 'svelte-markdown';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import type { ClaimInfo } from '$lib/types';
|
||||||
|
|
||||||
|
export let token: string;
|
||||||
|
|
||||||
|
let shareData: ShareData | null = null;
|
||||||
|
let loading = true;
|
||||||
|
let error: string | null = null;
|
||||||
|
let expired = false;
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
shareData = await getShareData(token);
|
||||||
|
expired = !!shareData.share_expired;
|
||||||
|
} catch (e) {
|
||||||
|
const err = e instanceof Error ? e.message : 'Share-Daten konnten nicht geladen werden.';
|
||||||
|
if (err.includes('404') || err.includes('not found') || err.includes('Not Found')) {
|
||||||
|
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.';
|
||||||
|
} else {
|
||||||
|
error = err;
|
||||||
|
}
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
function domainFavicon(domain: string): string {
|
||||||
|
if (!domain) return '?';
|
||||||
|
return domain.replace(/^(https?:\/\/)?(www\.)?/, '').charAt(0).toUpperCase();
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimTypeIcon(type: string): string {
|
||||||
|
switch (type) {
|
||||||
|
case 'fact': return '🔍';
|
||||||
|
case 'estimate': return '📊';
|
||||||
|
case 'prediction': return '🔮';
|
||||||
|
case 'opinion': return '💬';
|
||||||
|
default: return '📋';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimTypeLabel(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
fact: 'Fakt',
|
||||||
|
estimate: 'Schätzung',
|
||||||
|
prediction: 'Vorhersage',
|
||||||
|
opinion: 'Meinung',
|
||||||
|
};
|
||||||
|
return map[type] ?? type;
|
||||||
|
}
|
||||||
|
|
||||||
|
function claimTypeColor(type: string): string {
|
||||||
|
const map: Record<string, string> = {
|
||||||
|
fact: 'bg-blue-100 text-blue-700 dark:bg-blue-900 dark:text-blue-300',
|
||||||
|
estimate: 'bg-purple-100 text-purple-700 dark:bg-purple-900 dark:text-purple-300',
|
||||||
|
prediction: 'bg-orange-100 text-orange-700 dark:bg-orange-900 dark:text-orange-300',
|
||||||
|
opinion: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400',
|
||||||
|
};
|
||||||
|
return map[type] ?? 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-400';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="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">
|
||||||
|
🔗 Geteilter Research-Thread — Nur lesen
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Expiry warning -->
|
||||||
|
{#if expired}
|
||||||
|
<div class="px-4 py-3 bg-amber-50 dark:bg-amber-900/20 border-b border-amber-200 dark:border-amber-800">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<span class="text-amber-600 dark:text-amber-400">⚠️</span>
|
||||||
|
<p class="text-sm text-amber-700 dark:text-amber-300">
|
||||||
|
Dieser Share-Link ist abgelaufen. Der Thread kann nicht mehr angezeigt werden.
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
{#if loading}
|
||||||
|
<div class="flex items-center justify-center min-h-[50vh]">
|
||||||
|
<div class="text-center space-y-3">
|
||||||
|
<svg class="animate-spin h-8 w-8 text-indigo-500 mx-auto" viewBox="0 0 24 24" fill="none">
|
||||||
|
<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>
|
||||||
|
<p class="text-sm text-gray-500">Share-Daten werden geladen…</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if error}
|
||||||
|
<div class="flex items-center justify-center min-h-[50vh]">
|
||||||
|
<div class="text-center space-y-3 px-4">
|
||||||
|
<span class="text-4xl">⚠️</span>
|
||||||
|
<p class="text-lg font-medium text-gray-700 dark:text-gray-300">{error}</p>
|
||||||
|
<a
|
||||||
|
href="/"
|
||||||
|
class="inline-flex items-center gap-1.5 px-4 py-2 text-sm font-medium text-white bg-indigo-600 rounded-lg hover:bg-indigo-700 transition-colors"
|
||||||
|
>
|
||||||
|
← Zurück zur Startseite
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{:else if shareData}
|
||||||
|
<div class="max-w-3xl mx-auto px-4 py-8 space-y-8">
|
||||||
|
<!-- Header -->
|
||||||
|
<div class="border-b border-gray-200 dark:border-gray-700 pb-6">
|
||||||
|
<div class="flex items-start justify-between gap-4">
|
||||||
|
<div>
|
||||||
|
<h1 class="text-xl font-bold text-gray-900 dark:text-white mb-1">
|
||||||
|
{shareData.query}
|
||||||
|
</h1>
|
||||||
|
<p class="text-sm text-gray-500 dark:text-gray-400">
|
||||||
|
Research-ID: <span class="font-mono">{truncateId(shareData.id)}</span>
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="flex items-center gap-4 mt-4 text-xs text-gray-400">
|
||||||
|
{#if shareData.created_at}
|
||||||
|
<span>Erstellt: {formatDate(shareData.created_at)}</span>
|
||||||
|
{/if}
|
||||||
|
{#if shareData.status}
|
||||||
|
<span>Status: {shareData.status}</span>
|
||||||
|
{/if}
|
||||||
|
{#if shareData.share_view_count !== undefined}
|
||||||
|
<span>Aufrufe: {shareData.share_view_count}</span>
|
||||||
|
{/if}
|
||||||
|
{#if shareData.share_max_views && shareData.share_max_views > 0}
|
||||||
|
<span>Max: {shareData.share_max_views}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Query info -->
|
||||||
|
<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-2 uppercase tracking-wide">
|
||||||
|
Suchanfrage
|
||||||
|
</h2>
|
||||||
|
<p class="text-gray-900 dark:text-white">
|
||||||
|
{shareData.query}
|
||||||
|
</p>
|
||||||
|
{#if shareData.language}
|
||||||
|
<p class="text-xs text-gray-500 mt-2">Sprache: {shareData.language}</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quellen -->
|
||||||
|
{#if shareData.sources && shareData.sources.length > 0}
|
||||||
|
<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">
|
||||||
|
Quellen ({shareData.sources.length})
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each shareData.sources as source}
|
||||||
|
<div class="flex items-start gap-3 p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<div class="flex-shrink-0 w-7 h-7 rounded bg-gray-200 dark:bg-gray-700 flex items-center justify-center text-xs font-bold text-gray-500 dark:text-gray-400">
|
||||||
|
{domainFavicon(source.domain)}
|
||||||
|
</div>
|
||||||
|
<div class="flex-1 min-w-0">
|
||||||
|
<p class="text-sm font-medium text-gray-900 dark:text-white truncate">{source.title}</p>
|
||||||
|
{#if source.url}
|
||||||
|
<a href={source.url} target="_blank" rel="noopener" class="text-xs text-blue-600 dark:text-blue-400 hover:underline truncate block">
|
||||||
|
{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>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Claims -->
|
||||||
|
{#if shareData.claims && shareData.claims.length > 0}
|
||||||
|
<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">
|
||||||
|
Claims ({shareData.claims.length})
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#each shareData.claims as claim}
|
||||||
|
<div class="p-3 bg-gray-50 dark:bg-gray-800 rounded-lg">
|
||||||
|
<div class="flex items-center gap-2 mb-1">
|
||||||
|
<span class="text-lg">{claimTypeIcon(claim.claim_type)}</span>
|
||||||
|
<span class="inline-flex px-2 py-0.5 rounded text-xs font-medium {claimTypeColor(claim.claim_type)}">
|
||||||
|
{claimTypeLabel(claim.claim_type)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{claim.claim}</p>
|
||||||
|
<div class="flex flex-wrap items-center gap-3 mt-2">
|
||||||
|
{#if claim.confidence !== undefined}
|
||||||
|
<span class="inline-flex items-center gap-1 px-2 py-0.5 rounded-full text-xs font-medium {getConfidenceBadge(claim.confidence).color}">
|
||||||
|
Confidence: {(claim.confidence * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
{/if}
|
||||||
|
{#if claim.evidence_span}
|
||||||
|
<span class="text-xs text-gray-500 dark:text-gray-400">{claim.evidence_span}</span>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Evidence Scores -->
|
||||||
|
{#if shareData.evidence_scores && Object.keys(shareData.evidence_scores).length > 0}
|
||||||
|
<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">
|
||||||
|
Evidenz-Scores
|
||||||
|
</h2>
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each shareData.evidence_scores as (key, value)}
|
||||||
|
<div>
|
||||||
|
<div class="flex items-center justify-between mb-1">
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{key.replace(/_/g, ' ').replace(/\b\w/g, c => c.toUpperCase())}
|
||||||
|
</span>
|
||||||
|
<span class="text-sm font-medium {scoreColor(value)}">
|
||||||
|
{(value * 100).toFixed(0)}%
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<div class="w-full bg-gray-200 dark:bg-gray-700 rounded-full h-3 overflow-hidden">
|
||||||
|
<div
|
||||||
|
class="h-3 rounded-full transition-all duration-500"
|
||||||
|
style="width: {(value * 100).toFixed(0)}%"
|
||||||
|
class:bg-emerald-500={value >= 0.8}
|
||||||
|
class:bg-blue-500={value >= 0.6 && value < 0.8}
|
||||||
|
class:bg-yellow-500={value >= 0.4 && value < 0.6}
|
||||||
|
class:bg-red-500={value < 0.4}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
|
||||||
|
<!-- Report -->
|
||||||
|
{#if shareData.report}
|
||||||
|
<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">
|
||||||
|
Forschungsbericht
|
||||||
|
</h2>
|
||||||
|
<div class="prose prose-sm dark:prose-invert max-w-none">
|
||||||
|
<Markdown>{shareData.report}</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}
|
||||||
|
<p>Dieser Thread wurde von @{shareData.username} erstellt.</p>
|
||||||
|
{/if}
|
||||||
|
<p>Geteilt über NSCT Research · {new Date().toLocaleDateString('de-DE')}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user