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 }
|
||||
);
|
||||
}
|
||||
@@ -38,4 +38,19 @@ export interface EvidenceScore {
|
||||
contradiction_level: number;
|
||||
evidence_directness: 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;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user