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
236 lines
10 KiB
Svelte
236 lines
10 KiB
Svelte
<script lang="ts">
|
|
import { getApiKey } from '$lib/auth';
|
|
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 },
|
|
{ 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 = '10';
|
|
let generating = false;
|
|
let result: ShareResult | null = null;
|
|
let copied = false;
|
|
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;
|
|
const d = new Date(Date.now() + hours * 60 * 60 * 1000);
|
|
return d.toISOString();
|
|
}
|
|
|
|
async function generateLink() {
|
|
generating = true;
|
|
result = null;
|
|
|
|
const apiKey = getApiKey();
|
|
if (!apiKey) {
|
|
errorBanners.addError('Kein API-Key verfügbar.', 'error');
|
|
generating = false;
|
|
return;
|
|
}
|
|
|
|
try {
|
|
const hours = EXPIRY_OPTIONS.find(o => o.value === selectedExpiry)?.hours ?? 168;
|
|
const expiresAt = getExpiryDate(hours);
|
|
const maxViewsNum = maxViews === '0' || maxViews === '' ? undefined : parseInt(maxViews, 10);
|
|
|
|
result = await shareResearch(apiKey, researchId, expiresAt, maxViewsNum);
|
|
errorBanners.addErrorSuccess('Share-Link generiert.');
|
|
} catch (e) {
|
|
errorBanners.addError(e instanceof Error ? e.message : 'Link konnte nicht generiert werden.', 'error');
|
|
} finally {
|
|
generating = false;
|
|
}
|
|
}
|
|
|
|
async function copyLink() {
|
|
if (!result) return;
|
|
try {
|
|
await navigator.clipboard.writeText(result.url);
|
|
copied = true;
|
|
errorBanners.addErrorSuccess('Link kopiert!');
|
|
setTimeout(() => { copied = false; }, 2000);
|
|
} catch {
|
|
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;
|
|
}
|
|
|
|
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 || existingToken}
|
|
<!-- 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 ?? `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
|
|
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="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 -->
|
|
<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"
|
|
>
|
|
{#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}
|
|
</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> |