FE-3 + FE-4: Full-Featured Research-Ansicht + Docker Deployment
FE-3:
- Error-System: ErrorBannerStore + ErrorBanner (stapelbar, auto-hide, 5 categories)
- ContradictionBanner: Visuelle Widerspruchserkennung
- StatusTab: Zeitleiste, Fortschrittsbalken, Details
- ShareButton: Inline-Share mit Geteilt-Badge
- ShareModal: Ablaufzeit (24h-30Tage/Nie), Max-Views, Copy/Revoke
- research/{id}/+page.svelte: 6 Tabs mit Polling (5s), onDestroy cleanup
- share/[token]/+page.svelte: Vollständige Read-Only Share-Ansicht mit Wasserzeichen
- research/new/+page.svelte: Char-Counter, Language/Depth-Auswahl
FE-4:
- Dockerfile: HEALTHCHECK, Error-Handling, multi-stage build
- Caddyfile: HTTPS, Security Headers, gzip+zstd
- docker-compose.yml: Healthchecks, resource limits, restart policy
- tests/: Theme, API, Formatter, Share-Link Tests
- README.md: Architektur-Diagramm, Installation, Nutzung
- CHANGELOG.md: FE-0 bis FE-4
This commit is contained in:
98
src/lib/components/ContradictionBanner.svelte
Normal file
98
src/lib/components/ContradictionBanner.svelte
Normal file
@@ -0,0 +1,98 @@
|
||||
<script lang="ts">
|
||||
import type { ClaimInfo, SourceInfo } from '$lib/types';
|
||||
|
||||
export let claims: ClaimInfo[] = [];
|
||||
export let sources: SourceInfo[] = [];
|
||||
export let onDismiss?: () => void;
|
||||
|
||||
// Find contradictions: claims with same topic but different conclusions
|
||||
// A simple heuristic: claims that reference the same source_id or overlapping text
|
||||
interface ContradictionPair {
|
||||
claimA: ClaimInfo;
|
||||
claimB: ClaimInfo;
|
||||
sourceA: SourceInfo | undefined;
|
||||
sourceB: SourceInfo | undefined;
|
||||
}
|
||||
|
||||
$: contradictions: ContradictionPair[] = findContradictions(claims, sources);
|
||||
|
||||
function findContradictions(claims: ClaimInfo[], sources: SourceInfo[]): ContradictionPair[] {
|
||||
const pairs: ContradictionPair[] = [];
|
||||
for (let i = 0; i < claims.length; i++) {
|
||||
for (let j = i + 1; j < claims.length; j++) {
|
||||
const a = claims[i];
|
||||
const b = claims[j];
|
||||
|
||||
// Check if same source_id but different confidence (potential contradiction)
|
||||
const sameSource = a.source_id && b.source_id && a.source_id === b.source_id;
|
||||
const differentConfidence = Math.abs(a.confidence - b.confidence) > 0.3;
|
||||
|
||||
// Check if confidence differs significantly and claims are different
|
||||
if (sameSource && differentConfidence) {
|
||||
pairs.push({
|
||||
claimA: a,
|
||||
claimB: b,
|
||||
sourceA: sources.find(s => s.id === a.source_id),
|
||||
sourceB: sources.find(s => s.id === b.source_id),
|
||||
});
|
||||
}
|
||||
}
|
||||
}
|
||||
return pairs;
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if contradictions.length > 0}
|
||||
<div class="space-y-4">
|
||||
{#each contradictions as pair}
|
||||
<div
|
||||
class="relative overflow-hidden rounded-xl border-2 border-red-300 dark:border-red-700 bg-red-50/80 dark:bg-red-900/10"
|
||||
>
|
||||
<!-- Contradiction badge -->
|
||||
<div class="absolute top-0 left-0 right-0 bg-red-500 dark:bg-red-600 text-white text-xs font-bold px-3 py-1 text-center uppercase tracking-wider">
|
||||
⚠ Widerspruch erkannt
|
||||
</div>
|
||||
|
||||
<div class="pt-8 pb-4 px-4">
|
||||
<div class="grid grid-cols-1 md:grid-cols-2 gap-4">
|
||||
<!-- Claim A -->
|
||||
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle A</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimA.claim}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
{#if pair.claimA.source_id}
|
||||
<span class="text-xs text-gray-500 font-mono">{pair.claimA.source_id}</span>
|
||||
{/if}
|
||||
{#if pair.sourceA?.domain}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
|
||||
{pair.sourceA.domain}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<!-- Claim B -->
|
||||
<div class="p-3 bg-white dark:bg-gray-800 rounded-lg border border-red-200 dark:border-red-800">
|
||||
<div class="flex items-center gap-2 mb-2">
|
||||
<span class="text-xs font-bold text-red-600 dark:text-red-400">Quelle B</span>
|
||||
</div>
|
||||
<p class="text-sm text-gray-900 dark:text-white leading-relaxed">{pair.claimB.claim}</p>
|
||||
<div class="flex flex-wrap items-center gap-2 mt-2">
|
||||
{#if pair.claimB.source_id}
|
||||
<span class="text-xs text-gray-500 font-mono">{pair.claimB.source_id}</span>
|
||||
{/if}
|
||||
{#if pair.sourceB?.domain}
|
||||
<span class="text-xs px-1.5 py-0.5 bg-gray-100 dark:bg-gray-700 rounded text-gray-600 dark:text-gray-400">
|
||||
{pair.sourceB.domain}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
54
src/lib/components/ErrorBanner.svelte
Normal file
54
src/lib/components/ErrorBanner.svelte
Normal file
@@ -0,0 +1,54 @@
|
||||
<script lang="ts">
|
||||
import { errorBanners } from './ErrorBannerStore';
|
||||
|
||||
type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
|
||||
|
||||
const TYPE_STYLES: Record<ErrorType, string> = {
|
||||
error: 'bg-red-50 dark:bg-red-900/20 border-red-200 dark:border-red-800 text-red-700 dark:text-red-300',
|
||||
success: 'bg-emerald-50 dark:bg-emerald-900/20 border-emerald-200 dark:border-emerald-800 text-emerald-700 dark:text-emerald-300',
|
||||
warning: 'bg-amber-50 dark:bg-amber-900/20 border-amber-200 dark:border-amber-800 text-amber-700 dark:text-amber-300',
|
||||
fatal: 'bg-red-100 dark:bg-red-900/40 border-red-400 dark:border-red-700 text-red-900 dark:text-red-200',
|
||||
info: 'bg-blue-50 dark:bg-blue-900/20 border-blue-200 dark:border-blue-800 text-blue-700 dark:text-blue-300',
|
||||
};
|
||||
|
||||
const TYPE_ICONS: Record<ErrorType, string> = {
|
||||
error: '⚠️',
|
||||
success: '✅',
|
||||
warning: '⚠️',
|
||||
fatal: '🔴',
|
||||
info: 'ℹ️',
|
||||
};
|
||||
</script>
|
||||
|
||||
<div class="fixed top-4 right-4 z-[100] space-y-2 max-w-sm w-full pointer-events-none">
|
||||
{#each $errorBanners as banner (banner.id)}
|
||||
{#if !banner.dismissed}
|
||||
<div
|
||||
class="pointer-events-auto flex items-start gap-3 px-4 py-3 rounded-lg border shadow-lg animate-slide-in {TYPE_STYLES[banner.type]}"
|
||||
role="alert"
|
||||
>
|
||||
<span class="flex-shrink-0 text-sm mt-0.5">{TYPE_ICONS[banner.type]}</span>
|
||||
<p class="flex-1 text-sm leading-relaxed">{banner.message}</p>
|
||||
<button
|
||||
on:click={() => errorBanners.dismiss(banner.id)}
|
||||
class="flex-shrink-0 p-0.5 rounded hover:bg-black/10 dark:hover:bg-white/10 transition-colors"
|
||||
aria-label="Schließen"
|
||||
>
|
||||
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
|
||||
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12"></path>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
{/each}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
@keyframes slide-in {
|
||||
from { opacity: 0; transform: translateX(100%); }
|
||||
to { opacity: 1; transform: translateX(0); }
|
||||
}
|
||||
.animate-slide-in {
|
||||
animation: slide-in 0.3s ease-out;
|
||||
}
|
||||
</style>
|
||||
73
src/lib/components/ErrorBannerStore.ts
Normal file
73
src/lib/components/ErrorBannerStore.ts
Normal file
@@ -0,0 +1,73 @@
|
||||
import { writable } from 'svelte/store';
|
||||
|
||||
export type ErrorType = 'error' | 'success' | 'warning' | 'fatal' | 'info';
|
||||
|
||||
export interface BannerMessage {
|
||||
id: string;
|
||||
message: string;
|
||||
type: ErrorType;
|
||||
duration?: number;
|
||||
dismissed: boolean;
|
||||
}
|
||||
|
||||
function createId(): string {
|
||||
return Math.random().toString(36).substring(2, 11);
|
||||
}
|
||||
|
||||
function createBanner(message: string, type: ErrorType, duration = 5000): BannerMessage {
|
||||
return {
|
||||
id: createId(),
|
||||
message,
|
||||
type,
|
||||
duration: duration > 0 ? duration : undefined,
|
||||
dismissed: false,
|
||||
};
|
||||
}
|
||||
|
||||
export function addError(message: string, type: ErrorType = 'error', duration?: number) {
|
||||
return banners.update(banners => {
|
||||
const banner = createBanner(message, type, duration);
|
||||
return [...banners, banner];
|
||||
});
|
||||
}
|
||||
|
||||
export function addErrorSuccess(message: string) {
|
||||
return addError(message, 'success', 3000);
|
||||
}
|
||||
|
||||
export function addErrorWarning(message: string) {
|
||||
return addError(message, 'warning', 5000);
|
||||
}
|
||||
|
||||
export function addErrorFatal(message: string) {
|
||||
return addError(message, 'fatal', 0);
|
||||
}
|
||||
|
||||
export function addErrorInfo(message: string) {
|
||||
return addError(message, 'info', 4000);
|
||||
}
|
||||
|
||||
function createErrorBannerStore() {
|
||||
const { subscribe, update } = writable<BannerMessage[]>([]);
|
||||
|
||||
function dismiss(id: string) {
|
||||
update(banners => banners.filter(b => b.id !== id));
|
||||
}
|
||||
|
||||
function clearAll() {
|
||||
update([]);
|
||||
}
|
||||
|
||||
return {
|
||||
subscribe,
|
||||
dismiss,
|
||||
clearAll,
|
||||
addError,
|
||||
addErrorSuccess,
|
||||
addErrorWarning,
|
||||
addErrorFatal,
|
||||
addErrorInfo,
|
||||
};
|
||||
}
|
||||
|
||||
export const errorBanners = createErrorBannerStore();
|
||||
@@ -1,11 +1,14 @@
|
||||
<script lang="ts">
|
||||
import { getApiKey } from '$lib/auth';
|
||||
import { shareResearch, type ShareResult } from '$lib/research-api';
|
||||
import { shareResearch, revokeShare, type ShareResult } from '$lib/research-api';
|
||||
import { createEventDispatcher } from 'svelte';
|
||||
import { errorBanners } from '$lib/components/ErrorBannerStore';
|
||||
import type { ResearchDetail } from '$lib/research-api';
|
||||
|
||||
const dispatch = createEventDispatcher();
|
||||
|
||||
export let researchId: string;
|
||||
export let detail: ResearchDetail | null = null;
|
||||
|
||||
const EXPIRY_OPTIONS = [
|
||||
{ label: '24 Stunden', value: '24h', hours: 24 },
|
||||
@@ -24,11 +27,14 @@
|
||||
];
|
||||
|
||||
let selectedExpiry = '7d';
|
||||
let maxViews = '';
|
||||
let maxViews = '10';
|
||||
let generating = false;
|
||||
let result: ShareResult | null = null;
|
||||
let copied = false;
|
||||
let error = '';
|
||||
let existingToken: string | null = null;
|
||||
|
||||
// Check if there's an existing share token in the detail
|
||||
$: existingToken = (detail as any)?.share_token ?? null;
|
||||
|
||||
function getExpiryDate(hours: number): string | undefined {
|
||||
if (hours <= 0) return undefined;
|
||||
@@ -38,12 +44,11 @@
|
||||
|
||||
async function generateLink() {
|
||||
generating = true;
|
||||
error = '';
|
||||
result = null;
|
||||
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) {
|
||||
error = 'Kein API-Key verfügbar.';
|
||||
errorBanners.addError('Kein API-Key verfügbar.', 'error');
|
||||
generating = false;
|
||||
return;
|
||||
}
|
||||
@@ -51,11 +56,12 @@
|
||||
try {
|
||||
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
|
||||
const expiresAt = getExpiryDate(hours);
|
||||
const maxViewsNum = maxViews === '' || maxViews === '0' ? undefined : parseInt(maxViews, 10);
|
||||
const maxViewsNum = maxViews === '0' || maxViews === '' ? undefined : parseInt(maxViews, 10);
|
||||
|
||||
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
||||
errorBanners.addErrorSuccess('Share-Link generiert.');
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Link konnte nicht generiert werden.';
|
||||
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht generiert werden.', 'error');
|
||||
} finally {
|
||||
generating = false;
|
||||
}
|
||||
@@ -66,16 +72,29 @@
|
||||
try {
|
||||
await navigator.clipboard.writeText(result.url);
|
||||
copied = true;
|
||||
errorBanners.addErrorSuccess('Link kopiert!');
|
||||
setTimeout(() => { copied = false; }, 2000);
|
||||
} catch {
|
||||
error = 'Link konnte nicht kopiert werden.';
|
||||
errorBanners.addError('Link konnte nicht kopiert werden.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
async function revokeLink() {
|
||||
const apiKey = getApiKey();
|
||||
if (!apiKey) return;
|
||||
try {
|
||||
await revokeShare(apiKey, existingToken ?? '');
|
||||
result = null;
|
||||
existingToken = null;
|
||||
errorBanners.addErrorSuccess('Share-Link deaktiviert.');
|
||||
} catch (e) {
|
||||
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht deaktiviert werden.', 'error');
|
||||
}
|
||||
}
|
||||
|
||||
function reset() {
|
||||
result = null;
|
||||
copied = false;
|
||||
error = '';
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
@@ -111,7 +130,7 @@
|
||||
|
||||
<!-- Body -->
|
||||
<div class="px-6 py-4 space-y-4">
|
||||
{#if result}
|
||||
{#if result || existingToken}
|
||||
<!-- Generated link -->
|
||||
<div class="space-y-3">
|
||||
<label class="block text-sm font-medium text-gray-700 dark:text-gray-300">
|
||||
@@ -121,7 +140,7 @@
|
||||
<input
|
||||
type="text"
|
||||
readonly
|
||||
value={result.url}
|
||||
value={result?.url ?? `https://app.example.com/share/${existingToken}`}
|
||||
class="flex-1 px-3 py-2 text-sm bg-gray-100 dark:bg-gray-800 border border-gray-200 dark:border-gray-700 rounded-lg text-gray-900 dark:text-white font-mono truncate"
|
||||
/>
|
||||
<button
|
||||
@@ -131,12 +150,18 @@
|
||||
{copied ? '✓ Kopiert' : 'Kopieren'}
|
||||
</button>
|
||||
</div>
|
||||
<div class="text-xs text-gray-500 dark:text-gray-400 space-y-0.5">
|
||||
<p>Gültig bis: {result.token ? new Date().toLocaleDateString('de-DE') : '—'}</p>
|
||||
{#if maxViews && maxViews !== '0'}
|
||||
<p>Max. Aufrufe: {maxViews}</p>
|
||||
{/if}
|
||||
<div class="flex items-center gap-2 text-xs text-emerald-600 dark:text-emerald-400">
|
||||
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
|
||||
<path fill-rule="evenodd" d="M10 18a8 8 0 100-16 8 8 0 000 16zm3.707-9.293a1 1 0 00-1.414-1.414L9 9.586 7.707 8.293a1 1 0 00-1.414 1.414l2 2a1 1 0 001.414 0l4-4z" clip-rule="evenodd"></path>
|
||||
</svg>
|
||||
Link wurde erfolgreich generiert
|
||||
</div>
|
||||
<button
|
||||
on:click={revokeLink}
|
||||
class="text-xs text-red-600 dark:text-red-400 hover:text-red-800 dark:hover:text-red-300 underline"
|
||||
>
|
||||
Link deaktivieren
|
||||
</button>
|
||||
</div>
|
||||
{:else}
|
||||
<!-- Expiry selector -->
|
||||
@@ -163,7 +188,6 @@
|
||||
bind:value={maxViews}
|
||||
class="w-full px-3 py-2 bg-white dark:bg-gray-800 border border-gray-300 dark:border-gray-600 rounded-lg text-gray-900 dark:text-white focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
|
||||
>
|
||||
<option value="">Unbegrenzt</option>
|
||||
{#each MAX_VIEWS_OPTIONS as opt}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
@@ -189,12 +213,6 @@
|
||||
{/if}
|
||||
</button>
|
||||
{/if}
|
||||
|
||||
{#if error}
|
||||
<div class="p-3 bg-red-50 dark:bg-red-900/20 border border-red-200 dark:border-red-800 rounded-lg">
|
||||
<p class="text-sm text-red-700 dark:text-red-300">{error}</p>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<!-- Footer -->
|
||||
|
||||
Reference in New Issue
Block a user