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:
faligam
2026-09-06 07:28:20 +00:00
parent 407a30e776
commit 7d35a9672a
11 changed files with 1534 additions and 163 deletions

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