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:
@@ -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