FE-1: Login-Seite vervollständigen, Theme-Toggle, Sidebar-Historie, Hauptlayout
This commit is contained in:
31
src/lib/auth.ts
Normal file
31
src/lib/auth.ts
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
import { user } from '$lib/stores';
|
||||||
|
|
||||||
|
const API_KEY_STORAGE = 'nsct-api-key';
|
||||||
|
|
||||||
|
export function login(apiKey: string): Promise<{ userId: string; username: string }> {
|
||||||
|
setApiKey(apiKey);
|
||||||
|
// Set a minimal user object; real identity is fetched from the backend later
|
||||||
|
user.set({ apiKey });
|
||||||
|
return Promise.resolve({ userId: apiKey, username: apiKey.substring(0, 8) });
|
||||||
|
}
|
||||||
|
|
||||||
|
export function logout(): void {
|
||||||
|
removeApiKey();
|
||||||
|
user.set(null);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function isAuthenticated(): boolean {
|
||||||
|
return getApiKey() !== null;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function getApiKey(): string | null {
|
||||||
|
return localStorage.getItem(API_KEY_STORAGE);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function setApiKey(key: string): void {
|
||||||
|
localStorage.setItem(API_KEY_STORAGE, key);
|
||||||
|
}
|
||||||
|
|
||||||
|
export function removeApiKey(): void {
|
||||||
|
localStorage.removeItem(API_KEY_STORAGE);
|
||||||
|
}
|
||||||
44
src/lib/components/ResearchListItem.svelte
Normal file
44
src/lib/components/ResearchListItem.svelte
Normal file
@@ -0,0 +1,44 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type ResearchSummary } from '$lib/types';
|
||||||
|
|
||||||
|
export let item: ResearchSummary;
|
||||||
|
export let isActive = false;
|
||||||
|
|
||||||
|
function truncate(str: string, max: number): string {
|
||||||
|
return str.length > max ? str.slice(0, max) + '…' : str;
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatDate(dateStr: string): string {
|
||||||
|
const d = new Date(dateStr);
|
||||||
|
return d.toLocaleDateString('de-DE', { day: '2-digit', month: '2-digit', year: 'numeric' })
|
||||||
|
+ ' ' + d.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' });
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div
|
||||||
|
class="group flex flex-col gap-1 p-3 rounded-lg cursor-pointer transition-colors border border-transparent"
|
||||||
|
class:ring-2 class:ring-indigo-500 class:bg-indigo-50 class:dark:bg-indigo-950/30 {isActive}
|
||||||
|
class:hover:bg-gray-100 class:dark:hover:bg-gray-800
|
||||||
|
on:click={() => window.location.href = `/research/${item.id}`}
|
||||||
|
>
|
||||||
|
<div class="flex items-center justify-between gap-2">
|
||||||
|
<code class="text-xs font-mono text-gray-500 dark:text-gray-400 truncate">
|
||||||
|
{item.id.slice(0, 8)}
|
||||||
|
</code>
|
||||||
|
<span class="text-xs text-gray-400 whitespace-nowrap">{formatDate(item.created_at)}</span>
|
||||||
|
</div>
|
||||||
|
<p class="text-sm text-gray-700 dark:text-gray-300 truncate" title={item.query}>
|
||||||
|
{item.query}
|
||||||
|
</p>
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span
|
||||||
|
class="inline-block w-2.5 h-2.5 rounded-full"
|
||||||
|
class:bg-emerald-400={item.status === 'COMPLETED'}
|
||||||
|
class:bg-yellow-400={item.status === 'PLANNING' || item.status === 'SEARCHING' || item.status === 'SYNTHESIZING'}
|
||||||
|
class:bg-blue-400={item.status === 'EXTRACTING' || item.status === 'ANALYZING'}
|
||||||
|
class:bg-red-400={item.status === 'FAILED'}
|
||||||
|
class:bg-gray-300={item.status === 'PENDING'}
|
||||||
|
></span>
|
||||||
|
<span class="text-xs text-gray-500 font-medium">{item.status}</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
157
src/lib/components/Sidebar.svelte
Normal file
157
src/lib/components/Sidebar.svelte
Normal file
@@ -0,0 +1,157 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { sidebarOpen, selectedResearchId, loadResearchHistory } from '$lib/sidebar';
|
||||||
|
import { user } from '$lib/stores';
|
||||||
|
import { theme, toggleTheme } from '$lib/theme';
|
||||||
|
import { isAuthenticated, logout } from '$lib/auth';
|
||||||
|
import { onMount, onDestroy, tick } from 'svelte';
|
||||||
|
import ResearchListItem from './ResearchListItem.svelte';
|
||||||
|
import type { ResearchSummary } from '$lib/types';
|
||||||
|
|
||||||
|
let researchHistory: ResearchSummary[] = [];
|
||||||
|
let loading = true;
|
||||||
|
let apiKey = '';
|
||||||
|
let isMobile = false;
|
||||||
|
|
||||||
|
$: activeId = $selectedResearchId;
|
||||||
|
$: userObj = $user;
|
||||||
|
|
||||||
|
$: showSidebar = !$sidebarOpen || window.innerWidth >= 768;
|
||||||
|
|
||||||
|
function detectMobile() {
|
||||||
|
isMobile = window.innerWidth < 768;
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleResize() {
|
||||||
|
detectMobile();
|
||||||
|
}
|
||||||
|
|
||||||
|
async function fetchHistory() {
|
||||||
|
if (!isAuthenticated()) return;
|
||||||
|
apiKey = localStorage.getItem('nsct-api-key') || '';
|
||||||
|
if (!apiKey) return;
|
||||||
|
loading = true;
|
||||||
|
try {
|
||||||
|
researchHistory = await loadResearchHistory(apiKey);
|
||||||
|
} catch {
|
||||||
|
// Silently handle
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
detectMobile();
|
||||||
|
fetchHistory();
|
||||||
|
window.addEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
window.removeEventListener('resize', handleResize);
|
||||||
|
});
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
}
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.update(open => !open);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<!-- Desktop sidebar always visible, mobile: controlled by sidebarOpen -->
|
||||||
|
<div
|
||||||
|
class="fixed inset-y-0 left-0 z-40 flex"
|
||||||
|
>
|
||||||
|
<aside
|
||||||
|
class="h-full flex flex-col bg-white dark:bg-gray-900 border-r border-gray-200 dark:border-gray-700 transition-transform duration-200 ease-in-out"
|
||||||
|
style="width: 20rem;"
|
||||||
|
class:{-translate-x-full: isMobile && !$sidebarOpen}
|
||||||
|
>
|
||||||
|
<!-- Top bar -->
|
||||||
|
<div class="flex items-center justify-between px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<h1 class="text-lg font-bold text-gray-900 dark:text-white">NSCT Research</h1>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
on:click={toggleTheme}
|
||||||
|
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title="Theme umschalten"
|
||||||
|
>
|
||||||
|
{$theme === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
{#if isMobile}
|
||||||
|
<button
|
||||||
|
on:click={toggleSidebar}
|
||||||
|
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800"
|
||||||
|
title="Menü schließen"
|
||||||
|
>
|
||||||
|
✕
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- User section -->
|
||||||
|
<div class="px-4 py-2 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="w-7 h-7 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold">
|
||||||
|
{$userObj?.apiKey?.[0]?.toUpperCase() ?? 'U'}
|
||||||
|
</div>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300 truncate">
|
||||||
|
{$userObj?.apiKey ? $userObj.apiKey.substring(0, 8) : 'Gast'}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
<button
|
||||||
|
on:click={handleLogout}
|
||||||
|
class="text-xs text-gray-500 hover:text-red-500 dark:text-gray-400 dark:hover:text-red-400 transition-colors"
|
||||||
|
title="Abmelden"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- New research button -->
|
||||||
|
<div class="px-4 py-3 border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<a
|
||||||
|
href="/research/new"
|
||||||
|
class="flex items-center justify-center gap-2 w-full px-4 py-2 bg-indigo-600 hover:bg-indigo-700 text-white text-sm font-medium rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
<span>+</span> Neue Recherche
|
||||||
|
</a>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- History list -->
|
||||||
|
<div class="flex-1 overflow-y-auto px-2 py-2 space-y-1 min-h-0">
|
||||||
|
{#if loading}
|
||||||
|
<div class="px-2 py-4 space-y-3">
|
||||||
|
{#each [1, 2, 3, 4, 5] as _}
|
||||||
|
<div class="p-3 rounded-lg bg-gray-100 dark:bg-gray-800 animate-pulse">
|
||||||
|
<div class="flex items-center justify-between mb-2">
|
||||||
|
<div class="h-3 w-16 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
<div class="h-3 w-12 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
<div class="h-3 w-32 bg-gray-300 dark:bg-gray-600 rounded mb-2"></div>
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<div class="h-2.5 w-2.5 bg-gray-300 dark:bg-gray-600 rounded-full"></div>
|
||||||
|
<div class="h-3 w-10 bg-gray-300 dark:bg-gray-600 rounded"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
{#each researchHistory as item (item.id)}
|
||||||
|
<ResearchListItem {item} isActive={item.id === activeId} />
|
||||||
|
{/each}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</aside>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Mobile overlay when sidebar is open -->
|
||||||
|
{#if isMobile && $sidebarOpen}
|
||||||
|
<div
|
||||||
|
class="fixed inset-0 z-30 bg-black/50"
|
||||||
|
on:click={toggleSidebar}
|
||||||
|
></div>
|
||||||
|
{/if}
|
||||||
35
src/lib/components/StatusBadge.svelte
Normal file
35
src/lib/components/StatusBadge.svelte
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { type ResearchStatus } from '$lib/types';
|
||||||
|
|
||||||
|
export let status: ResearchStatus | null = null;
|
||||||
|
|
||||||
|
const STATUS_COLORS: Record<string, string> = {
|
||||||
|
PLANNING: 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200',
|
||||||
|
SEARCHING: 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200',
|
||||||
|
EXTRACTING: 'bg-green-100 text-green-800 dark:bg-green-900 dark:text-green-200',
|
||||||
|
ANALYZING: 'bg-purple-100 text-purple-800 dark:bg-purple-900 dark:text-purple-200',
|
||||||
|
SYNTHESIZING: 'bg-indigo-100 text-indigo-800 dark:bg-indigo-900 dark:text-indigo-200',
|
||||||
|
COMPLETED: 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200',
|
||||||
|
FAILED: 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200',
|
||||||
|
PENDING: 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300'
|
||||||
|
};
|
||||||
|
|
||||||
|
const DOT_COLORS: Record<string, string> = {
|
||||||
|
PLANNING: 'bg-yellow-400 animate-pulse',
|
||||||
|
SEARCHING: 'bg-blue-400 animate-pulse',
|
||||||
|
EXTRACTING: 'bg-green-400 animate-pulse',
|
||||||
|
ANALYZING: 'bg-purple-400 animate-pulse',
|
||||||
|
SYNTHESIZING: 'bg-indigo-400 animate-pulse',
|
||||||
|
COMPLETED: 'bg-emerald-400',
|
||||||
|
FAILED: 'bg-red-400',
|
||||||
|
PENDING: 'bg-gray-400'
|
||||||
|
};
|
||||||
|
|
||||||
|
$: colorClass = status ? (STATUS_COLORS[status.status] || STATUS_COLORS.PENDING) : STATUS_COLORS.PENDING;
|
||||||
|
$: dotClass = status ? (DOT_COLORS[status.status] || DOT_COLORS.PENDING) : DOT_COLORS.PENDING;
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium {colorClass}">
|
||||||
|
<span class="w-2 h-2 rounded-full {dotClass}"></span>
|
||||||
|
<span>{status?.status ?? 'N/A'}</span>
|
||||||
|
</span>
|
||||||
66
src/lib/components/TopBar.svelte
Normal file
66
src/lib/components/TopBar.svelte
Normal file
@@ -0,0 +1,66 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { toggleTheme } from '$lib/theme';
|
||||||
|
import { theme } from '$lib/theme';
|
||||||
|
import { sidebarOpen } from '$lib/sidebar';
|
||||||
|
import { isAuthenticated, logout } from '$lib/auth';
|
||||||
|
import { user } from '$lib/stores';
|
||||||
|
|
||||||
|
export let title = '';
|
||||||
|
export let showToggle = false;
|
||||||
|
|
||||||
|
$: userObj = $user;
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.update(open => !open);
|
||||||
|
}
|
||||||
|
|
||||||
|
function handleLogout() {
|
||||||
|
logout();
|
||||||
|
window.location.href = '/';
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<header class="sticky top-0 z-30 flex items-center justify-between px-4 py-2 bg-white/80 dark:bg-gray-900/80 backdrop-blur-sm border-b border-gray-200 dark:border-gray-700">
|
||||||
|
<div class="flex items-center gap-2">
|
||||||
|
<button
|
||||||
|
on:click={toggleSidebar}
|
||||||
|
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors lg:hidden"
|
||||||
|
title="Menü öffnen"
|
||||||
|
>
|
||||||
|
☰
|
||||||
|
</button>
|
||||||
|
{#if title}
|
||||||
|
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-md">{title}</h2>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="flex items-center gap-3">
|
||||||
|
{#if showToggle}
|
||||||
|
<button
|
||||||
|
on:click={toggleTheme}
|
||||||
|
class="p-1.5 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors"
|
||||||
|
title="Theme umschalten"
|
||||||
|
>
|
||||||
|
{$theme === 'dark' ? '☀' : '☾'}
|
||||||
|
</button>
|
||||||
|
{/if}
|
||||||
|
{#if isAuthenticated()}
|
||||||
|
<div class="relative group">
|
||||||
|
<button class="flex items-center gap-1.5 px-2 py-1 rounded-md text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-200 hover:bg-gray-100 dark:hover:bg-gray-800 transition-colors">
|
||||||
|
<div class="w-6 h-6 rounded-full bg-indigo-500 flex items-center justify-center text-white text-xs font-bold">
|
||||||
|
{userObj?.apiKey?.[0]?.toUpperCase() ?? 'U'}
|
||||||
|
</div>
|
||||||
|
<span class="text-xs font-medium hidden sm:block">{userObj?.apiKey ? userObj.apiKey.substring(0, 8) : 'Gast'}</span>
|
||||||
|
</button>
|
||||||
|
<div class="absolute right-0 mt-1 w-32 bg-white dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg shadow-lg opacity-0 invisible group-hover:opacity-100 group-hover:visible transition-all">
|
||||||
|
<button
|
||||||
|
on:click={handleLogout}
|
||||||
|
class="w-full px-3 py-2 text-left text-sm text-red-500 hover:bg-gray-50 dark:hover:bg-gray-700 rounded-lg transition-colors"
|
||||||
|
>
|
||||||
|
Abmelden
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
43
src/lib/sidebar.ts
Normal file
43
src/lib/sidebar.ts
Normal file
@@ -0,0 +1,43 @@
|
|||||||
|
import { writable } from 'svelte/store';
|
||||||
|
import { apiGet, apiDelete, apiPost } from '$lib/api';
|
||||||
|
import type { ResearchSummary } from '$lib/types';
|
||||||
|
|
||||||
|
// Sidebar visibility (mobile toggle)
|
||||||
|
export const sidebarOpen = writable(false);
|
||||||
|
|
||||||
|
// Currently selected research session ID
|
||||||
|
export const selectedResearchId = writable<string | null>(null);
|
||||||
|
|
||||||
|
export interface ResearchListItem extends ResearchSummary {
|
||||||
|
truncatedQuery?: string;
|
||||||
|
truncatedId?: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
const HISTORY_LIMIT = 50;
|
||||||
|
|
||||||
|
export async function loadResearchHistory(apiKey: string): Promise<ResearchSummary[]> {
|
||||||
|
const list = await apiGet<ResearchSummary[]>('/v1/research', { apiKey });
|
||||||
|
// Sort descending by created_at (newest first), limit to HISTORY_LIMIT
|
||||||
|
const sorted = list
|
||||||
|
.slice()
|
||||||
|
.sort((a, b) => new Date(b.created_at).getTime() - new Date(a.created_at).getTime())
|
||||||
|
.slice(0, HISTORY_LIMIT);
|
||||||
|
return sorted;
|
||||||
|
}
|
||||||
|
|
||||||
|
export async function deleteResearch(id: string, apiKey: string): Promise<void> {
|
||||||
|
await apiDelete<void>(`/v1/research/${id}`, { apiKey });
|
||||||
|
}
|
||||||
|
|
||||||
|
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 }
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -39,3 +39,18 @@ export interface EvidenceScore {
|
|||||||
evidence_directness: number;
|
evidence_directness: number;
|
||||||
date_relevance: number;
|
date_relevance: number;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export interface ResearchDetail {
|
||||||
|
id: string;
|
||||||
|
query: string;
|
||||||
|
status: string;
|
||||||
|
progress?: number;
|
||||||
|
created_at: string;
|
||||||
|
completed_at?: string;
|
||||||
|
error?: string;
|
||||||
|
sources?: SourceInfo[];
|
||||||
|
claims?: ClaimInfo[];
|
||||||
|
evidence?: EvidenceScore[];
|
||||||
|
report?: string;
|
||||||
|
methodology?: string;
|
||||||
|
}
|
||||||
|
|||||||
@@ -1,49 +1,114 @@
|
|||||||
<script>
|
<script lang="ts">
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
import { user } from '$lib/stores';
|
import { user } from '$lib/stores';
|
||||||
import { theme, toggleTheme } from '$lib/theme';
|
import { theme, toggleTheme, applyTheme } from '$lib/theme';
|
||||||
|
import { isAuthenticated } from '$lib/auth';
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { apiGet } from '$lib/api';
|
||||||
|
|
||||||
let apiKey = '';
|
let apiKey = '';
|
||||||
let error = '';
|
let error = '';
|
||||||
|
let loading = false;
|
||||||
|
|
||||||
function login() {
|
$: userObj = $user;
|
||||||
if (apiKey.trim()) {
|
|
||||||
|
onMount(() => {
|
||||||
|
if (isAuthenticated()) {
|
||||||
|
redirect(302, '/research');
|
||||||
|
}
|
||||||
|
applyTheme($theme);
|
||||||
|
});
|
||||||
|
|
||||||
|
async function handleLogin() {
|
||||||
|
if (!apiKey.trim()) {
|
||||||
|
error = 'Bitte gib deinen API-Key ein.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
loading = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Validate the API key by fetching user info from the backend
|
||||||
|
const info = await apiGet<any>('/v1/health', { apiKey: apiKey.trim() });
|
||||||
user.set({ apiKey: apiKey.trim() });
|
user.set({ apiKey: apiKey.trim() });
|
||||||
error = '';
|
localStorage.setItem('nsct-api-key', apiKey.trim());
|
||||||
} else {
|
redirect(302, '/research');
|
||||||
error = 'Bitte API-Key eingeben.';
|
} catch (e) {
|
||||||
|
// If the API key is invalid, the backend will return 401/403
|
||||||
|
// or an error response — accept it and show the error
|
||||||
|
error = e instanceof Error ? e.message : 'Anmeldung fehlgeschlagen. Bitte prüfe deinen API-Key.';
|
||||||
|
} finally {
|
||||||
|
loading = false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<div class="min-h-screen flex items-center justify-center p-4">
|
<div class="min-h-screen flex items-center justify-center bg-gradient-to-br from-gray-100 to-gray-200 dark:from-gray-900 dark:to-gray-800 p-4">
|
||||||
<div class="w-full max-w-md">
|
<div class="w-full max-w-md">
|
||||||
<h1 class="text-3xl font-bold mb-2 text-center">NSCT</h1>
|
<!-- Logo and title -->
|
||||||
<p class="text-center text-gray-500 mb-8">Neutral Search Crawler Tool</p>
|
<div class="text-center mb-8">
|
||||||
|
<h1 class="text-4xl font-bold text-gray-900 dark:text-white mb-2">NSCT</h1>
|
||||||
|
<p class="text-gray-500 dark:text-gray-400">Neutral Search Crawler Tool</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Login form -->
|
||||||
|
<div class="bg-white dark:bg-gray-800 rounded-xl shadow-lg p-6 sm:p-8">
|
||||||
|
<h2 class="text-xl font-semibold text-gray-900 dark:text-white mb-6">Anmelden</h2>
|
||||||
|
|
||||||
|
<form on:submit|preventDefault={handleLogin}>
|
||||||
|
<div class="mb-4">
|
||||||
|
<label for="api-key" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
API-Key
|
||||||
|
</label>
|
||||||
|
<input
|
||||||
|
id="api-key"
|
||||||
|
type="password"
|
||||||
|
bind:value={apiKey}
|
||||||
|
placeholder="API-Key eingeben"
|
||||||
|
class="w-full px-4 py-2.5 border border-gray-300 dark:border-gray-600 rounded-lg bg-white dark:bg-gray-700 text-gray-900 dark:text-white placeholder-gray-400 dark:placeholder-gray-500 focus:ring-2 focus:ring-indigo-500 focus:border-transparent transition-colors"
|
||||||
|
disabled={loading}
|
||||||
|
autocomplete="off"
|
||||||
|
spellcheck="false"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<button
|
||||||
|
type="submit"
|
||||||
|
disabled={loading}
|
||||||
|
class="w-full px-6 py-2.5 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 flex items-center justify-center gap-2"
|
||||||
|
>
|
||||||
|
{#if loading}
|
||||||
|
<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 angemeldet…
|
||||||
|
{:else}
|
||||||
|
Anmelden
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
<div class="bg-white dark:bg-gray-800 rounded-lg shadow-lg p-6">
|
|
||||||
<h2 class="text-xl font-semibold mb-4">Anmelden</h2>
|
|
||||||
<input
|
|
||||||
bind:value={apiKey}
|
|
||||||
placeholder="API-Key eingeben"
|
|
||||||
class="w-full px-3 py-2 border rounded dark:bg-gray-700 dark:border-gray-600 mb-4"
|
|
||||||
on:keydown={(e) => e.key === 'Enter' && login()}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
on:click={login}
|
|
||||||
class="w-full bg-indigo-600 text-white py-2 rounded hover:bg-indigo-700"
|
|
||||||
>
|
|
||||||
Anmelden
|
|
||||||
</button>
|
|
||||||
{#if error}
|
{#if error}
|
||||||
<p class="text-red-500 mt-2 text-sm">{error}</p>
|
<p class="text-red-600 dark:text-red-400 mt-3 text-sm font-medium">{error}</p>
|
||||||
{/if}
|
{/if}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<button
|
<!-- Footer -->
|
||||||
on:click={toggleTheme}
|
<p class="text-center text-sm text-gray-500 dark:text-gray-400 mt-6">
|
||||||
class="mt-4 text-sm text-gray-500 hover:text-gray-300"
|
Noch keinen Account? <span class="font-medium">API-Key vom Admin anfordern</span>
|
||||||
>
|
</p>
|
||||||
Theme umschalten ☀/☾
|
|
||||||
</button>
|
<!-- Theme toggle -->
|
||||||
|
<div class="flex justify-center mt-4">
|
||||||
|
<button
|
||||||
|
on:click={toggleTheme}
|
||||||
|
class="text-sm text-gray-500 hover:text-gray-700 dark:text-gray-400 dark:hover:text-gray-300 transition-colors"
|
||||||
|
title="Theme umschalten"
|
||||||
|
>
|
||||||
|
{$theme === 'dark' ? '☀ Light Mode' : '☾ Dark Mode'}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
20
src/routes/research/+layout.svelte
Normal file
20
src/routes/research/+layout.svelte
Normal file
@@ -0,0 +1,20 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import Sidebar from '$lib/components/Sidebar.svelte';
|
||||||
|
import TopBar from '$lib/components/TopBar.svelte';
|
||||||
|
import { sidebarOpen } from '$lib/sidebar';
|
||||||
|
|
||||||
|
function toggleSidebar() {
|
||||||
|
sidebarOpen.update(open => !open);
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="min-h-screen bg-gray-50 dark:bg-gray-950 flex">
|
||||||
|
<!-- Desktop sidebar is always rendered for layout; mobile overlay is handled inside Sidebar -->
|
||||||
|
<Sidebar />
|
||||||
|
|
||||||
|
<!-- Main content area -->
|
||||||
|
<main class="flex-1 flex flex-col min-w-0 lg:ml-80">
|
||||||
|
<TopBar title="Research" showToggle={true} />
|
||||||
|
<slot />
|
||||||
|
</main>
|
||||||
|
</div>
|
||||||
145
src/routes/research/new/+page.svelte
Normal file
145
src/routes/research/new/+page.svelte
Normal file
@@ -0,0 +1,145 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { redirect } from '@sveltejs/kit';
|
||||||
|
import { isAuthenticated } from '$lib/auth';
|
||||||
|
import { createResearch } from '$lib/sidebar';
|
||||||
|
|
||||||
|
let query = '';
|
||||||
|
let language = 'Deutsch';
|
||||||
|
let depth = 'normal';
|
||||||
|
let submitting = false;
|
||||||
|
let error = '';
|
||||||
|
|
||||||
|
function getApiKey(): string | null {
|
||||||
|
return localStorage.getItem('nsct-api-key');
|
||||||
|
}
|
||||||
|
|
||||||
|
async function handleSubmit() {
|
||||||
|
if (!query.trim()) {
|
||||||
|
error = 'Bitte gib eine Suchanfrage ein.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const key = getApiKey();
|
||||||
|
if (!key) {
|
||||||
|
error = 'Nicht authentifiziert.';
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
submitting = true;
|
||||||
|
error = '';
|
||||||
|
|
||||||
|
try {
|
||||||
|
const result = await createResearch(key, query.trim(), language, depth);
|
||||||
|
window.location.href = `/research/${result.id}`;
|
||||||
|
} catch (e) {
|
||||||
|
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Recherche.';
|
||||||
|
} finally {
|
||||||
|
submitting = false;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex items-center justify-center min-h-[calc(100vh-4rem)] px-4 py-8">
|
||||||
|
<div class="w-full max-w-2xl">
|
||||||
|
<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 -->
|
||||||
|
<div>
|
||||||
|
<label for="query" class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Suchanfrage
|
||||||
|
</label>
|
||||||
|
<textarea
|
||||||
|
id="query"
|
||||||
|
bind:value={query}
|
||||||
|
placeholder="Gib eine Suchanfrage ein..."
|
||||||
|
rows="6"
|
||||||
|
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>
|
||||||
|
|
||||||
|
<!-- Language selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Sprache
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#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:ring-2 class:ring-indigo-500={language === lang}
|
||||||
|
class:border-indigo-500={language === lang}
|
||||||
|
class:border-gray-300 dark:border-gray-600={language !== lang}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="language"
|
||||||
|
value={lang}
|
||||||
|
bind:value={language}
|
||||||
|
class="sr-only"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{lang}</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Depth selection -->
|
||||||
|
<div>
|
||||||
|
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300 mb-1">
|
||||||
|
Tiefe
|
||||||
|
</label>
|
||||||
|
<div class="flex gap-3">
|
||||||
|
{#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:ring-2 class:ring-indigo-500={depth === d}
|
||||||
|
class:border-indigo-500={depth === d}
|
||||||
|
class:border-gray-300 dark:border-gray-600={depth !== d}
|
||||||
|
>
|
||||||
|
<input
|
||||||
|
type="radio"
|
||||||
|
name="depth"
|
||||||
|
value={d}
|
||||||
|
bind:value={depth}
|
||||||
|
class="sr-only"
|
||||||
|
disabled={submitting}
|
||||||
|
/>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">
|
||||||
|
{d === 'quick' ? 'Schnell' : d === 'normal' ? 'Normal' : 'Tief'}
|
||||||
|
</span>
|
||||||
|
</label>
|
||||||
|
{/each}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- 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"
|
||||||
|
>
|
||||||
|
{#if submitting}
|
||||||
|
<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>
|
||||||
|
Recherche wird gestartet…
|
||||||
|
</span>
|
||||||
|
{:else}
|
||||||
|
Recherche starten
|
||||||
|
{/if}
|
||||||
|
</button>
|
||||||
|
</form>
|
||||||
|
|
||||||
|
<!-- Error message -->
|
||||||
|
{#if error}
|
||||||
|
<div class="mt-4 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>
|
||||||
|
</div>
|
||||||
293
src/routes/research/{id}/+page.svelte
Normal file
293
src/routes/research/{id}/+page.svelte
Normal file
@@ -0,0 +1,293 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onDestroy, onMount } from 'svelte';
|
||||||
|
import { selectedResearchId } from '$lib/sidebar';
|
||||||
|
import { apiGet } from '$lib/api';
|
||||||
|
import type { ResearchStatus, ResearchDetail } from '$lib/types';
|
||||||
|
|
||||||
|
export let researchId: string;
|
||||||
|
|
||||||
|
const POLL_INTERVAL_MS = 5000;
|
||||||
|
|
||||||
|
let status: ResearchStatus | null = null;
|
||||||
|
let detail: ResearchDetail | null = null;
|
||||||
|
let error: string | null = null;
|
||||||
|
let lastUpdate = '';
|
||||||
|
let activeTab = 'Status';
|
||||||
|
|
||||||
|
$: activeId = $selectedResearchId;
|
||||||
|
|
||||||
|
const TABS = ['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
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let pollTimer: ReturnType<typeof setInterval> | null = null;
|
||||||
|
|
||||||
|
function startPolling() {
|
||||||
|
if (pollTimer) clearInterval(pollTimer);
|
||||||
|
pollStatus();
|
||||||
|
pollDetail();
|
||||||
|
pollTimer = setInterval(async () => {
|
||||||
|
await pollStatus();
|
||||||
|
if (status && (status.status === 'COMPLETED' || status.status === 'FAILED')) {
|
||||||
|
await pollDetail();
|
||||||
|
}
|
||||||
|
}, POLL_INTERVAL_MS);
|
||||||
|
}
|
||||||
|
|
||||||
|
function stopPolling() {
|
||||||
|
if (pollTimer) {
|
||||||
|
clearInterval(pollTimer);
|
||||||
|
pollTimer = null;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(() => {
|
||||||
|
startPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
onDestroy(() => {
|
||||||
|
stopPolling();
|
||||||
|
});
|
||||||
|
|
||||||
|
function statusColorClass(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'PLANNING': case 'SEARCHING': case 'SYNTHESIZING':
|
||||||
|
return 'bg-yellow-100 text-yellow-800 dark:bg-yellow-900 dark:text-yellow-200';
|
||||||
|
case 'EXTRACTING': case 'ANALYZING':
|
||||||
|
return 'bg-blue-100 text-blue-800 dark:bg-blue-900 dark:text-blue-200';
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'bg-emerald-100 text-emerald-800 dark:bg-emerald-900 dark:text-emerald-200';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'bg-red-100 text-red-800 dark:bg-red-900 dark:text-red-200';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-100 text-gray-600 dark:bg-gray-700 dark:text-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function statusDotClass(s: string): string {
|
||||||
|
switch (s) {
|
||||||
|
case 'PLANNING': case 'SEARCHING': case 'SYNTHESIZING':
|
||||||
|
return 'bg-yellow-400 animate-pulse';
|
||||||
|
case 'EXTRACTING': case 'ANALYZING':
|
||||||
|
return 'bg-blue-400 animate-pulse';
|
||||||
|
case 'COMPLETED':
|
||||||
|
return 'bg-emerald-400';
|
||||||
|
case 'FAILED':
|
||||||
|
return 'bg-red-400';
|
||||||
|
default:
|
||||||
|
return 'bg-gray-300';
|
||||||
|
}
|
||||||
|
}
|
||||||
|
</script>
|
||||||
|
|
||||||
|
<div class="flex-1 overflow-y-auto flex flex-col">
|
||||||
|
<!-- 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">
|
||||||
|
<h2 class="text-sm font-medium text-gray-700 dark:text-gray-300 truncate max-w-md">
|
||||||
|
{detail?.query ?? detail?.id ?? researchId}
|
||||||
|
</h2>
|
||||||
|
<div class="flex items-center gap-3 flex-shrink-0">
|
||||||
|
{#if status}
|
||||||
|
<span class="inline-flex items-center gap-1.5 px-2.5 py-1 rounded-full text-xs font-medium {statusColorClass(status.status)}">
|
||||||
|
<span class="w-2 h-2 rounded-full {statusDotClass(status.status)}"></span>
|
||||||
|
{status.status}
|
||||||
|
</span>
|
||||||
|
{#if status.progress !== undefined}
|
||||||
|
<span class="text-xs text-gray-500">{status.progress}%</span>
|
||||||
|
{/if}
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</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">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300">⚠ {error}</p>
|
||||||
|
</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}
|
||||||
|
<button
|
||||||
|
on:click={() => activeTab = tab}
|
||||||
|
class="px-4 py-3 text-sm font-medium border-b-2 transition-colors whitespace-nowrap"
|
||||||
|
class:border-indigo-500 class:text-indigo-600 class:dark:text-indigo-400={activeTab === tab}
|
||||||
|
class:border-transparent class:text-gray-500 class:hover:text-gray-700 class:dark:text-gray-400 class:dark:hover:text-gray-300={activeTab !== tab}
|
||||||
|
>
|
||||||
|
{tab}
|
||||||
|
</button>
|
||||||
|
{/each}
|
||||||
|
</nav>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Tab content -->
|
||||||
|
<div class="px-4 py-6 flex-1 min-h-0">
|
||||||
|
<!-- Status Tab -->
|
||||||
|
{#if activeTab === 'Status'}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Aktueller Status</h3>
|
||||||
|
{#if status}
|
||||||
|
<div class="space-y-3">
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-gray-700 dark:text-gray-300">Status</span>
|
||||||
|
<span class="px-2 py-0.5 rounded text-xs font-medium {statusColorClass(status.status)}">
|
||||||
|
{status.status}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
{#if status.progress !== undefined}
|
||||||
|
<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">{status.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: {status.progress}%"></div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
<div class="flex items-center justify-between">
|
||||||
|
<span class="text-sm text-gray-500">Letzte Aktualisierung</span>
|
||||||
|
<span class="text-sm text-gray-700 dark:text-gray-300">{lastUpdate}</span>
|
||||||
|
</div>
|
||||||
|
{#if status.error}
|
||||||
|
<div class="mt-3 p-3 bg-red-50 dark:bg-red-900/20 rounded-lg">
|
||||||
|
<p class="text-sm text-red-700 dark:text-red-300">Fehler: {status.error}</p>
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-500">Status wird geladen…</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Quellen Tab -->
|
||||||
|
{:else if activeTab === 'Quellen'}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Gefundene Quellen</h3>
|
||||||
|
{#if detail?.sources && detail.sources.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#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 -->
|
||||||
|
{:else if activeTab === 'Claims'}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Extrahierte Claims</h3>
|
||||||
|
{#if detail?.claims && detail.claims.length > 0}
|
||||||
|
<div class="space-y-2">
|
||||||
|
{#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 -->
|
||||||
|
{:else if activeTab === 'Evidence'}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Evidence-Scores</h3>
|
||||||
|
{#if detail?.evidence && detail.evidence.length > 0}
|
||||||
|
<div class="space-y-3">
|
||||||
|
{#each detail.evidence as score, i}
|
||||||
|
<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 -->
|
||||||
|
{:else if activeTab === 'Bericht'}
|
||||||
|
<div>
|
||||||
|
<h3 class="text-sm font-medium text-gray-500 dark:text-gray-400 mb-2">Forschungsbericht</h3>
|
||||||
|
{#if detail?.report}
|
||||||
|
<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>
|
||||||
|
{:else}
|
||||||
|
<p class="text-sm text-gray-500">Bericht wird generiert, sobald die Recherche abgeschlossen ist.</p>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<!-- Methodik Tab -->
|
||||||
|
{:else if activeTab === 'Methodik'}
|
||||||
|
<div>
|
||||||
|
<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}
|
||||||
|
</div>
|
||||||
|
{/if}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
Reference in New Issue
Block a user