mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-19 09:41:24 +02:00
feat(events): add Event Discovery — Phase 1 + 2
Phase 1: Manual iCal feeds + Discovery tab - 5 new DB tables in event_discovery schema (regions, interests, sources, discovered_events, user_actions) - iCal parser (node-ical) with deduplication (SHA-256 hash) - Crawl scheduler (15-min interval, auto-deactivate after 5 errors) - CRUD routes for regions, interests, sources + paginated feed endpoint - Frontend: "Meine Events" / "Entdecken" tab navigation in ListView - Discovery setup wizard (regions via mana-geocoding + interests) - DiscoveredEventCard with save/dismiss, SourceManager for iCal feeds - "Merken" creates a local socialEvent from discovered event Phase 2: Auto source discovery + LLM extraction + relevance scoring - Source discoverer: web search via mana-research to auto-find iCal feeds and venue websites for a region - Website extractor: crawl via mana-research /extract, then LLM-based event extraction via mana-llm with structured JSON output - Flexible date parsing (ISO, DD.MM.YYYY), markdown fence stripping - Relevance scorer: category match, freetext match, haversine distance, time proximity, weekend bonus (0-100 clamped) - Routes: POST regions/:id/discover-sources, PUT/DELETE sources/:id/activate|reject - Frontend: "Automatisch finden" button, suggested vs active sources UI 107 tests (all passing), no regressions. Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
parent
677123091a
commit
b5d55fdb21
34 changed files with 5105 additions and 45 deletions
|
|
@ -4,6 +4,7 @@
|
|||
import { eventsStore } from './stores/events.svelte';
|
||||
import { drainTombstones } from './tombstones';
|
||||
import EventCard from './components/EventCard.svelte';
|
||||
import DiscoveryTab from './components/DiscoveryTab.svelte';
|
||||
import type { SocialEvent } from './types';
|
||||
import type { ViewProps } from '$lib/app-registry';
|
||||
|
||||
|
|
@ -13,6 +14,8 @@
|
|||
const past = usePastEvents();
|
||||
const guestsByEvent = useGuestsByEvent();
|
||||
|
||||
let activeTab = $state<'mine' | 'discover'>('mine');
|
||||
|
||||
// Retry any orphaned server snapshots from previous failed deletes.
|
||||
onMount(() => {
|
||||
void drainTombstones();
|
||||
|
|
@ -60,55 +63,72 @@
|
|||
</svelte:head>
|
||||
|
||||
<div class="events-page">
|
||||
<header class="events-header">
|
||||
<p class="page-subtitle">
|
||||
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
|
||||
</p>
|
||||
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Abbrechen' : '+ Neues Event'}
|
||||
<div class="tab-bar">
|
||||
<button class="tab" class:active={activeTab === 'mine'} onclick={() => (activeTab = 'mine')}>
|
||||
Meine Events
|
||||
</button>
|
||||
</header>
|
||||
<button
|
||||
class="tab"
|
||||
class:active={activeTab === 'discover'}
|
||||
onclick={() => (activeTab = 'discover')}
|
||||
>
|
||||
Entdecken
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={handleCreate}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newTitle}
|
||||
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
|
||||
required
|
||||
/>
|
||||
<div class="form-row">
|
||||
<input class="input" type="date" bind:value={newDate} required />
|
||||
<input class="input" type="time" bind:value={newTime} />
|
||||
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
|
||||
</div>
|
||||
<button type="submit" class="action-btn primary">Event anlegen</button>
|
||||
</form>
|
||||
{/if}
|
||||
{#if activeTab === 'mine'}
|
||||
<header class="events-header">
|
||||
<p class="page-subtitle">
|
||||
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
|
||||
</p>
|
||||
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Abbrechen' : '+ Neues Event'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Bevorstehend</h2>
|
||||
{#if (upcoming.value ?? []).length === 0}
|
||||
<p class="empty">Keine bevorstehenden Events. Zeit für eine Party?</p>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each upcoming.value ?? [] as event (event.id)}
|
||||
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
|
||||
<EventCard {event} {summary} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
{#if showCreate}
|
||||
<form class="create-form" onsubmit={handleCreate}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newTitle}
|
||||
placeholder="Worum geht's? (z. B. Geburtstag Anna)"
|
||||
required
|
||||
/>
|
||||
<div class="form-row">
|
||||
<input class="input" type="date" bind:value={newDate} required />
|
||||
<input class="input" type="time" bind:value={newTime} />
|
||||
<input class="input" bind:value={newLocation} placeholder="Ort (optional)" />
|
||||
</div>
|
||||
<button type="submit" class="action-btn primary">Event anlegen</button>
|
||||
</form>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if (past.value ?? []).length > 0}
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Vergangen</h2>
|
||||
<div class="event-list">
|
||||
{#each past.value ?? [] as event (event.id)}
|
||||
<EventCard {event} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
<h2 class="section-title">Bevorstehend</h2>
|
||||
{#if (upcoming.value ?? []).length === 0}
|
||||
<p class="empty">Keine bevorstehenden Events. Zeit fur eine Party?</p>
|
||||
{:else}
|
||||
<div class="event-list">
|
||||
{#each upcoming.value ?? [] as event (event.id)}
|
||||
{@const summary = summarizeRsvps(guestsByEvent.value?.get(event.id) ?? [])}
|
||||
<EventCard {event} {summary} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</section>
|
||||
|
||||
{#if (past.value ?? []).length > 0}
|
||||
<section class="event-section">
|
||||
<h2 class="section-title">Vergangen</h2>
|
||||
<div class="event-list">
|
||||
{#each past.value ?? [] as event (event.id)}
|
||||
<EventCard {event} onclick={() => open(event)} />
|
||||
{/each}
|
||||
</div>
|
||||
</section>
|
||||
{/if}
|
||||
{:else}
|
||||
<DiscoveryTab />
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
|
|
@ -121,6 +141,30 @@
|
|||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.tab-bar {
|
||||
display: flex;
|
||||
gap: 0;
|
||||
border-bottom: 1px solid hsl(var(--color-border));
|
||||
}
|
||||
.tab {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
border-bottom: 2px solid transparent;
|
||||
margin-bottom: -1px;
|
||||
font-family: inherit;
|
||||
}
|
||||
.tab.active {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-bottom-color: hsl(var(--color-primary));
|
||||
}
|
||||
.tab:hover:not(.active) {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.events-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
|
|
|
|||
|
|
@ -0,0 +1,204 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoveredEvent } from '../discovery/types';
|
||||
|
||||
interface Props {
|
||||
event: DiscoveredEvent;
|
||||
onSave?: () => void;
|
||||
onDismiss?: () => void;
|
||||
}
|
||||
|
||||
let { event, onSave, onDismiss }: Props = $props();
|
||||
|
||||
const startDate = $derived(new Date(event.startAt));
|
||||
const dateLabel = $derived(
|
||||
startDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
);
|
||||
const timeLabel = $derived(
|
||||
event.allDay
|
||||
? 'Ganztag'
|
||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
|
||||
const isSaved = $derived(event.userAction === 'save');
|
||||
</script>
|
||||
|
||||
<div class="discovered-card">
|
||||
<div class="date-block">
|
||||
<div class="date">{dateLabel}</div>
|
||||
<div class="time">{timeLabel}</div>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="title-row">
|
||||
<h3 class="title">{event.title}</h3>
|
||||
{#if event.category}
|
||||
<span class="category-badge">{event.category}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<div class="meta-line">{event.location}</div>
|
||||
{/if}
|
||||
{#if event.priceInfo}
|
||||
<div class="meta-line">{event.priceInfo}</div>
|
||||
{/if}
|
||||
{#if event.description}
|
||||
<div class="description">
|
||||
{event.description.slice(0, 150)}{event.description.length > 150 ? '...' : ''}
|
||||
</div>
|
||||
{/if}
|
||||
<div class="footer">
|
||||
{#if event.sourceName}
|
||||
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener">
|
||||
{event.sourceName}
|
||||
</a>
|
||||
{:else}
|
||||
<a class="source-link" href={event.sourceUrl} target="_blank" rel="noopener"> Quelle </a>
|
||||
{/if}
|
||||
<div class="actions">
|
||||
{#if isSaved}
|
||||
<span class="saved-label">Gespeichert</span>
|
||||
{:else}
|
||||
<button class="action-btn save" onclick={onSave}>Merken</button>
|
||||
<button class="action-btn dismiss" onclick={onDismiss}>Ausblenden</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discovered-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.15s;
|
||||
}
|
||||
.discovered-card:hover {
|
||||
transform: translateY(-1px);
|
||||
box-shadow: 0 2px 8px rgba(0, 0, 0, 0.08);
|
||||
}
|
||||
.date-block {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
min-width: 4.5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.date {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.9;
|
||||
}
|
||||
.time {
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.card-body {
|
||||
flex: 1;
|
||||
padding: 0.625rem 0.875rem;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
min-width: 0;
|
||||
}
|
||||
.title-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.title {
|
||||
flex: 1;
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.category-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.125rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.meta-line {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.description {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
line-height: 1.4;
|
||||
}
|
||||
.footer {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
margin-top: 0.25rem;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.source-link {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
overflow: hidden;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.source-link:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.action-btn.save {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-btn.dismiss {
|
||||
opacity: 0.7;
|
||||
}
|
||||
.action-btn.dismiss:hover {
|
||||
opacity: 1;
|
||||
}
|
||||
.saved-label {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,169 @@
|
|||
<script lang="ts">
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
import { EVENT_CATEGORIES } from '../discovery/types';
|
||||
import RegionPicker from './RegionPicker.svelte';
|
||||
|
||||
interface Props {
|
||||
onComplete?: () => void;
|
||||
}
|
||||
|
||||
let { onComplete }: Props = $props();
|
||||
|
||||
let step = $state<1 | 2>(1);
|
||||
let selectedCategories = $state<Set<string>>(new Set());
|
||||
let freetext = $state('');
|
||||
|
||||
function toggleCategory(id: string) {
|
||||
const next = new Set(selectedCategories);
|
||||
if (next.has(id)) {
|
||||
next.delete(id);
|
||||
} else {
|
||||
next.add(id);
|
||||
}
|
||||
selectedCategories = next;
|
||||
}
|
||||
|
||||
async function finishSetup() {
|
||||
// Save interests
|
||||
for (const cat of selectedCategories) {
|
||||
await discoveryStore.addInterest({ category: cat });
|
||||
}
|
||||
if (freetext.trim()) {
|
||||
for (const text of freetext
|
||||
.split(',')
|
||||
.map((t) => t.trim())
|
||||
.filter(Boolean)) {
|
||||
await discoveryStore.addInterest({ category: 'other', freetext: text });
|
||||
}
|
||||
}
|
||||
onComplete?.();
|
||||
}
|
||||
|
||||
const canProceed = $derived(discoveryStore.regions.length > 0);
|
||||
const canFinish = $derived(selectedCategories.size > 0 || freetext.trim().length > 0);
|
||||
</script>
|
||||
|
||||
<div class="setup">
|
||||
<h2 class="setup-title">Event-Entdeckung einrichten</h2>
|
||||
|
||||
{#if step === 1}
|
||||
<div class="step">
|
||||
<p class="step-desc">Welche Regionen sollen nach Events durchsucht werden?</p>
|
||||
<RegionPicker regions={discoveryStore.regions} />
|
||||
<button class="next-btn" disabled={!canProceed} onclick={() => (step = 2)}> Weiter </button>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="step">
|
||||
<p class="step-desc">Was interessiert dich?</p>
|
||||
<div class="category-grid">
|
||||
{#each EVENT_CATEGORIES as cat}
|
||||
<button
|
||||
class="category-chip"
|
||||
class:selected={selectedCategories.has(cat.id)}
|
||||
onclick={() => toggleCategory(cat.id)}
|
||||
>
|
||||
{cat.label}
|
||||
</button>
|
||||
{/each}
|
||||
</div>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={freetext}
|
||||
placeholder="Weitere Interessen (kommagetrennt, z.B. Impro-Theater, Rust Meetups)"
|
||||
/>
|
||||
<div class="step-actions">
|
||||
<button class="back-btn" onclick={() => (step = 1)}>Zuruck</button>
|
||||
<button class="next-btn" disabled={!canFinish} onclick={finishSetup}> Fertig </button>
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.setup {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
padding: 1.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.75rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.setup-title {
|
||||
margin: 0;
|
||||
font-size: 1.125rem;
|
||||
font-weight: 600;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.step {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.step-desc {
|
||||
margin: 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.category-grid {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.category-chip {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
transition:
|
||||
background 0.1s,
|
||||
border-color 0.1s;
|
||||
font-family: inherit;
|
||||
}
|
||||
.category-chip.selected {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.step-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.next-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.next-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.back-btn {
|
||||
padding: 0.5rem 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.875rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
<script lang="ts">
|
||||
import { onMount } from 'svelte';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
import DiscoverySetup from './DiscoverySetup.svelte';
|
||||
import DiscoveredEventCard from './DiscoveredEventCard.svelte';
|
||||
import RegionPicker from './RegionPicker.svelte';
|
||||
import SourceManager from './SourceManager.svelte';
|
||||
|
||||
let initialized = $state(false);
|
||||
let showSources = $state(false);
|
||||
|
||||
onMount(async () => {
|
||||
await discoveryStore.init();
|
||||
initialized = true;
|
||||
});
|
||||
|
||||
async function handleSetupComplete() {
|
||||
await discoveryStore.refreshFeed();
|
||||
}
|
||||
|
||||
async function handleSave(eventId: string) {
|
||||
await discoveryStore.saveEvent(eventId);
|
||||
}
|
||||
|
||||
async function handleDismiss(eventId: string) {
|
||||
await discoveryStore.dismissEvent(eventId);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="discovery-tab">
|
||||
{#if !initialized}
|
||||
<p class="loading">Lade...</p>
|
||||
{:else if !discoveryStore.isSetUp}
|
||||
<DiscoverySetup onComplete={handleSetupComplete} />
|
||||
{:else}
|
||||
<div class="controls">
|
||||
<RegionPicker regions={discoveryStore.regions} />
|
||||
<div class="control-row">
|
||||
<button class="control-btn" onclick={() => discoveryStore.refreshFeed()}>
|
||||
Aktualisieren
|
||||
</button>
|
||||
<button
|
||||
class="control-btn"
|
||||
class:active={showSources}
|
||||
onclick={() => (showSources = !showSources)}
|
||||
>
|
||||
Quellen {discoveryStore.sources.length > 0 ? `(${discoveryStore.sources.length})` : ''}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showSources}
|
||||
<SourceManager sources={discoveryStore.sources} regions={discoveryStore.regions} />
|
||||
{/if}
|
||||
|
||||
{#if discoveryStore.loading}
|
||||
<p class="loading">Lade Events...</p>
|
||||
{:else if discoveryStore.error}
|
||||
<p class="error-msg">{discoveryStore.error}</p>
|
||||
{:else if discoveryStore.feed.length === 0}
|
||||
<div class="empty">
|
||||
<p class="empty-title">Noch keine Events gefunden</p>
|
||||
<p class="empty-hint">
|
||||
Fuge iCal-Feeds von Venues oder Vereinen hinzu, um Events zu entdecken.
|
||||
</p>
|
||||
{#if !showSources}
|
||||
<button class="action-btn" onclick={() => (showSources = true)}>
|
||||
Quellen verwalten
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{:else}
|
||||
<div class="feed">
|
||||
{#each discoveryStore.feed as event (event.id)}
|
||||
<DiscoveredEventCard
|
||||
{event}
|
||||
onSave={() => handleSave(event.id)}
|
||||
onDismiss={() => handleDismiss(event.id)}
|
||||
/>
|
||||
{/each}
|
||||
{#if discoveryStore.feedHasMore}
|
||||
<button
|
||||
class="load-more"
|
||||
onclick={() => discoveryStore.refreshFeed({ offset: discoveryStore.feed.length })}
|
||||
>
|
||||
Mehr laden
|
||||
</button>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.discovery-tab {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1rem;
|
||||
}
|
||||
.controls {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.control-row {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.control-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.control-btn.active {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.loading {
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
padding: 2rem 0;
|
||||
margin: 0;
|
||||
}
|
||||
.error-msg {
|
||||
font-size: 0.875rem;
|
||||
color: rgb(220, 38, 38);
|
||||
text-align: center;
|
||||
padding: 1rem;
|
||||
margin: 0;
|
||||
}
|
||||
.empty {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
padding: 2rem 1rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
text-align: center;
|
||||
}
|
||||
.empty-title {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.empty-hint {
|
||||
margin: 0;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.feed {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.load-more {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
text-align: center;
|
||||
font-family: inherit;
|
||||
}
|
||||
.load-more:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,241 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoveryRegion } from '../discovery/types';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
|
||||
interface Props {
|
||||
regions: DiscoveryRegion[];
|
||||
}
|
||||
|
||||
let { regions }: Props = $props();
|
||||
|
||||
let searchQuery = $state('');
|
||||
let suggestions = $state<{ label: string; lat: number; lon: number }[]>([]);
|
||||
let searching = $state(false);
|
||||
let showForm = $state(false);
|
||||
let radiusKm = $state(25);
|
||||
|
||||
async function searchLocation() {
|
||||
const q = searchQuery.trim();
|
||||
if (q.length < 2) {
|
||||
suggestions = [];
|
||||
return;
|
||||
}
|
||||
searching = true;
|
||||
try {
|
||||
const res = await fetch(
|
||||
`http://localhost:3018/api/v1/geocode/search?q=${encodeURIComponent(q)}&limit=5&lang=de`
|
||||
);
|
||||
if (res.ok) {
|
||||
const data = await res.json();
|
||||
suggestions = (data.results ?? []).map(
|
||||
(r: { label: string; latitude: number; longitude: number }) => ({
|
||||
label: r.label,
|
||||
lat: r.latitude,
|
||||
lon: r.longitude,
|
||||
})
|
||||
);
|
||||
}
|
||||
} catch {
|
||||
suggestions = [];
|
||||
} finally {
|
||||
searching = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function selectSuggestion(s: { label: string; lat: number; lon: number }) {
|
||||
await discoveryStore.addRegion({
|
||||
label: s.label.split(',')[0] ?? s.label,
|
||||
lat: s.lat,
|
||||
lon: s.lon,
|
||||
radiusKm,
|
||||
});
|
||||
searchQuery = '';
|
||||
suggestions = [];
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async function removeRegion(id: string) {
|
||||
await discoveryStore.removeRegion(id);
|
||||
}
|
||||
|
||||
let debounceTimer: ReturnType<typeof setTimeout>;
|
||||
function onSearchInput() {
|
||||
clearTimeout(debounceTimer);
|
||||
debounceTimer = setTimeout(searchLocation, 300);
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="region-picker">
|
||||
<div class="region-list">
|
||||
{#each regions as region (region.id)}
|
||||
<div class="region-chip">
|
||||
<span class="region-label">{region.label}</span>
|
||||
<span class="region-radius">{region.radiusKm} km</span>
|
||||
<button class="remove-btn" onclick={() => removeRegion(region.id)}>x</button>
|
||||
</div>
|
||||
{/each}
|
||||
{#if !showForm}
|
||||
<button class="add-btn" onclick={() => (showForm = true)}>+ Region</button>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<div class="search-form">
|
||||
<input
|
||||
class="input"
|
||||
bind:value={searchQuery}
|
||||
oninput={onSearchInput}
|
||||
placeholder="Stadt oder Region suchen..."
|
||||
/>
|
||||
<div class="radius-row">
|
||||
<label class="radius-label">Radius: {radiusKm} km</label>
|
||||
<input type="range" min="5" max="100" step="5" bind:value={radiusKm} />
|
||||
</div>
|
||||
{#if suggestions.length > 0}
|
||||
<ul class="suggestions">
|
||||
{#each suggestions as s}
|
||||
<li>
|
||||
<button class="suggestion-btn" onclick={() => selectSuggestion(s)}>
|
||||
{s.label}
|
||||
</button>
|
||||
</li>
|
||||
{/each}
|
||||
</ul>
|
||||
{/if}
|
||||
{#if searching}
|
||||
<p class="searching-hint">Suche...</p>
|
||||
{/if}
|
||||
<button
|
||||
class="cancel-btn"
|
||||
onclick={() => {
|
||||
showForm = false;
|
||||
searchQuery = '';
|
||||
suggestions = [];
|
||||
}}
|
||||
>
|
||||
Abbrechen
|
||||
</button>
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.region-picker {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.region-list {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.375rem;
|
||||
align-items: center;
|
||||
}
|
||||
.region-chip {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.25rem;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: hsl(var(--color-muted));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.region-label {
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.region-radius {
|
||||
font-size: 0.6875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.remove-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-size: 0.75rem;
|
||||
padding: 0 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.remove-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 1rem;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
border-color: hsl(var(--color-foreground));
|
||||
}
|
||||
.search-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.radius-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.radius-label {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
white-space: nowrap;
|
||||
}
|
||||
.suggestions {
|
||||
list-style: none;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
}
|
||||
.suggestion-btn {
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
padding: 0.375rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
cursor: pointer;
|
||||
border-radius: 0.25rem;
|
||||
font-family: inherit;
|
||||
}
|
||||
.suggestion-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.searching-hint {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
.cancel-btn {
|
||||
align-self: flex-start;
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: none;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,384 @@
|
|||
<script lang="ts">
|
||||
import type { DiscoverySource, DiscoveryRegion } from '../discovery/types';
|
||||
import { discoveryStore } from '../discovery/store.svelte';
|
||||
|
||||
interface Props {
|
||||
sources: DiscoverySource[];
|
||||
regions: DiscoveryRegion[];
|
||||
}
|
||||
|
||||
let { sources, regions }: Props = $props();
|
||||
|
||||
let showForm = $state(false);
|
||||
let newName = $state('');
|
||||
let newUrl = $state('');
|
||||
let newRegionId = $state('');
|
||||
let discovering = $state(false);
|
||||
|
||||
const activeSources = $derived(sources.filter((s) => s.isActive));
|
||||
const suggestedSources = $derived(sources.filter((s) => !s.isActive));
|
||||
|
||||
async function handleDiscover() {
|
||||
const regionId = regions[0]?.id;
|
||||
if (!regionId) return;
|
||||
discovering = true;
|
||||
try {
|
||||
await discoveryStore.discoverSources(regionId);
|
||||
} finally {
|
||||
discovering = false;
|
||||
}
|
||||
}
|
||||
|
||||
async function handleActivate(id: string) {
|
||||
await discoveryStore.activateSource(id);
|
||||
}
|
||||
|
||||
async function handleReject(id: string) {
|
||||
await discoveryStore.rejectSource(id);
|
||||
}
|
||||
|
||||
const regionIdDefault = $derived(regions[0]?.id ?? '');
|
||||
|
||||
async function handleAdd(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
const url = newUrl.trim();
|
||||
const regionId = newRegionId || regionIdDefault;
|
||||
if (!name || !url || !regionId) return;
|
||||
|
||||
await discoveryStore.addSource({ type: 'ical', url, name, regionId });
|
||||
newName = '';
|
||||
newUrl = '';
|
||||
showForm = false;
|
||||
}
|
||||
|
||||
async function handleRemove(id: string) {
|
||||
await discoveryStore.removeSource(id);
|
||||
}
|
||||
|
||||
async function handleCrawl(id: string) {
|
||||
await discoveryStore.crawlSource(id);
|
||||
}
|
||||
|
||||
function formatDate(iso: string | null): string {
|
||||
if (!iso) return 'nie';
|
||||
return new Date(iso).toLocaleString('de-DE', {
|
||||
day: '2-digit',
|
||||
month: '2-digit',
|
||||
hour: '2-digit',
|
||||
minute: '2-digit',
|
||||
});
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="source-manager">
|
||||
<div class="header">
|
||||
<h3 class="section-title">Quellen</h3>
|
||||
<div class="header-actions">
|
||||
<button
|
||||
class="discover-btn"
|
||||
onclick={handleDiscover}
|
||||
disabled={discovering || regions.length === 0}
|
||||
>
|
||||
{discovering ? 'Suche...' : 'Automatisch finden'}
|
||||
</button>
|
||||
{#if !showForm}
|
||||
<button class="add-btn" onclick={() => (showForm = true)}>+ iCal-Feed</button>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{#if showForm}
|
||||
<form class="add-form" onsubmit={handleAdd}>
|
||||
<input
|
||||
class="input"
|
||||
bind:value={newName}
|
||||
placeholder="Name (z.B. Jazzhaus Freiburg)"
|
||||
required
|
||||
/>
|
||||
<input class="input" bind:value={newUrl} placeholder="iCal URL (.ics)" type="url" required />
|
||||
{#if regions.length > 1}
|
||||
<select class="input" bind:value={newRegionId}>
|
||||
{#each regions as r (r.id)}
|
||||
<option value={r.id}>{r.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
{/if}
|
||||
<div class="form-actions">
|
||||
<button type="submit" class="action-btn primary">Hinzufugen</button>
|
||||
<button type="button" class="action-btn" onclick={() => (showForm = false)}
|
||||
>Abbrechen</button
|
||||
>
|
||||
</div>
|
||||
</form>
|
||||
{/if}
|
||||
|
||||
{#if suggestedSources.length > 0}
|
||||
<div class="suggestions-section">
|
||||
<h4 class="sub-title">Vorgeschlagene Quellen</h4>
|
||||
<div class="source-list">
|
||||
{#each suggestedSources as source (source.id)}
|
||||
<div class="source-item suggested">
|
||||
<div class="source-info">
|
||||
<div class="source-name">{source.name}</div>
|
||||
<div class="source-meta">
|
||||
{source.type.toUpperCase()}
|
||||
{#if source.url}
|
||||
· <a class="source-url" href={source.url} target="_blank" rel="noopener"
|
||||
>{new URL(source.url).hostname}</a
|
||||
>
|
||||
{/if}
|
||||
</div>
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="icon-btn activate" onclick={() => handleActivate(source.id)}
|
||||
>Aktivieren</button
|
||||
>
|
||||
<button class="icon-btn danger" onclick={() => handleReject(source.id)}>x</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
{#if activeSources.length === 0 && suggestedSources.length === 0}
|
||||
<p class="empty">
|
||||
Noch keine Quellen. Nutze "Automatisch finden" oder fuge iCal-Feeds manuell hinzu.
|
||||
</p>
|
||||
{:else if activeSources.length > 0}
|
||||
<div class="source-list">
|
||||
{#each activeSources as source (source.id)}
|
||||
<div class="source-item" class:error={source.errorCount > 0}>
|
||||
<div class="source-info">
|
||||
<div class="source-name">{source.name}</div>
|
||||
<div class="source-meta">
|
||||
{source.type.toUpperCase()} · Letzter Scan: {formatDate(source.lastCrawledAt)}
|
||||
{#if source.errorCount > 0}
|
||||
<span class="error-badge">{source.errorCount} Fehler</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if source.lastError}
|
||||
<div class="source-error">{source.lastError}</div>
|
||||
{/if}
|
||||
</div>
|
||||
<div class="source-actions">
|
||||
<button class="icon-btn" onclick={() => handleCrawl(source.id)} title="Jetzt scannen">
|
||||
Scannen
|
||||
</button>
|
||||
<button
|
||||
class="icon-btn danger"
|
||||
onclick={() => handleRemove(source.id)}
|
||||
title="Entfernen"
|
||||
>
|
||||
x
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
{/each}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.source-manager {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.discover-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px solid hsl(var(--color-primary));
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-primary));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.discover-btn:hover {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
}
|
||||
.discover-btn:disabled {
|
||||
opacity: 0.5;
|
||||
cursor: not-allowed;
|
||||
}
|
||||
.suggestions-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.sub-title {
|
||||
margin: 0;
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
color: hsl(var(--color-primary));
|
||||
}
|
||||
.source-item.suggested {
|
||||
border-color: hsl(var(--color-primary) / 0.3);
|
||||
background: hsl(var(--color-primary) / 0.05);
|
||||
}
|
||||
.source-url {
|
||||
color: hsl(var(--color-primary));
|
||||
text-decoration: none;
|
||||
font-size: 0.6875rem;
|
||||
}
|
||||
.source-url:hover {
|
||||
text-decoration: underline;
|
||||
}
|
||||
.icon-btn.activate {
|
||||
color: hsl(var(--color-primary));
|
||||
border-color: hsl(var(--color-primary));
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0.25rem 0.625rem;
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
}
|
||||
.add-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
padding: 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-card));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
font-family: inherit;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.source-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.source-item {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.source-item.error {
|
||||
border-color: rgba(239, 68, 68, 0.3);
|
||||
}
|
||||
.source-item.inactive {
|
||||
opacity: 0.5;
|
||||
}
|
||||
.source-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.source-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.source-meta {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.375rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.error-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.inactive-badge {
|
||||
font-size: 0.625rem;
|
||||
font-weight: 600;
|
||||
padding: 0.0625rem 0.375rem;
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.source-error {
|
||||
font-size: 0.6875rem;
|
||||
color: rgb(220, 38, 38);
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.source-actions {
|
||||
display: flex;
|
||||
gap: 0.25rem;
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.icon-btn {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
background: none;
|
||||
font-size: 0.75rem;
|
||||
cursor: pointer;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.icon-btn:hover {
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.icon-btn.danger:hover {
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.empty {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin: 0;
|
||||
}
|
||||
</style>
|
||||
170
apps/mana/apps/web/src/lib/modules/events/discovery/api.ts
Normal file
170
apps/mana/apps/web/src/lib/modules/events/discovery/api.ts
Normal file
|
|
@ -0,0 +1,170 @@
|
|||
/**
|
||||
* Discovery HTTP client — JWT-authenticated calls to mana-events discovery endpoints.
|
||||
*/
|
||||
|
||||
import { authStore } from '$lib/stores/auth.svelte';
|
||||
import { getManaEventsUrl } from '$lib/api/config';
|
||||
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
|
||||
|
||||
async function fetchWithAuth<T>(path: string, init: RequestInit = {}): Promise<T> {
|
||||
const token = await authStore.getValidToken();
|
||||
const res = await fetch(`${getManaEventsUrl()}${path}`, {
|
||||
...init,
|
||||
headers: {
|
||||
'Content-Type': 'application/json',
|
||||
...(token ? { Authorization: `Bearer ${token}` } : {}),
|
||||
...init.headers,
|
||||
},
|
||||
});
|
||||
if (!res.ok) {
|
||||
const err = await res.json().catch(() => ({ message: 'Request failed' }));
|
||||
throw new Error(err.message || `HTTP ${res.status}`);
|
||||
}
|
||||
return res.json() as Promise<T>;
|
||||
}
|
||||
|
||||
// ─── Regions ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getRegions(): Promise<DiscoveryRegion[]> {
|
||||
const { regions } = await fetchWithAuth<{ regions: DiscoveryRegion[] }>(
|
||||
'/api/v1/discovery/regions'
|
||||
);
|
||||
return regions;
|
||||
}
|
||||
|
||||
export async function createRegion(input: {
|
||||
label: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
radiusKm?: number;
|
||||
}): Promise<DiscoveryRegion> {
|
||||
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>('/api/v1/discovery/regions', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return region;
|
||||
}
|
||||
|
||||
export async function updateRegion(
|
||||
id: string,
|
||||
input: { label?: string; radiusKm?: number; isActive?: boolean }
|
||||
): Promise<DiscoveryRegion> {
|
||||
const { region } = await fetchWithAuth<{ region: DiscoveryRegion }>(
|
||||
`/api/v1/discovery/regions/${id}`,
|
||||
{ method: 'PUT', body: JSON.stringify(input) }
|
||||
);
|
||||
return region;
|
||||
}
|
||||
|
||||
export async function deleteRegion(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/regions/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─── Interests ──────────────────────────────────────────────────────
|
||||
|
||||
export async function getInterests(): Promise<DiscoveryInterest[]> {
|
||||
const { interests } = await fetchWithAuth<{ interests: DiscoveryInterest[] }>(
|
||||
'/api/v1/discovery/interests'
|
||||
);
|
||||
return interests;
|
||||
}
|
||||
|
||||
export async function createInterest(input: {
|
||||
category: string;
|
||||
freetext?: string | null;
|
||||
weight?: number;
|
||||
}): Promise<DiscoveryInterest> {
|
||||
const { interest } = await fetchWithAuth<{ interest: DiscoveryInterest }>(
|
||||
'/api/v1/discovery/interests',
|
||||
{ method: 'POST', body: JSON.stringify(input) }
|
||||
);
|
||||
return interest;
|
||||
}
|
||||
|
||||
export async function deleteInterest(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/interests/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
// ─── Sources ────────────────────────────────────────────────────────
|
||||
|
||||
export async function getSources(): Promise<DiscoverySource[]> {
|
||||
const { sources } = await fetchWithAuth<{ sources: DiscoverySource[] }>(
|
||||
'/api/v1/discovery/sources'
|
||||
);
|
||||
return sources;
|
||||
}
|
||||
|
||||
export async function createSource(input: {
|
||||
type: 'ical' | 'website';
|
||||
url: string;
|
||||
name: string;
|
||||
regionId: string;
|
||||
crawlIntervalHours?: number;
|
||||
}): Promise<DiscoverySource> {
|
||||
const { source } = await fetchWithAuth<{ source: DiscoverySource }>('/api/v1/discovery/sources', {
|
||||
method: 'POST',
|
||||
body: JSON.stringify(input),
|
||||
});
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function deleteSource(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/sources/${id}`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function crawlSourceNow(id: string): Promise<{ upserted: number; error?: string }> {
|
||||
return fetchWithAuth(`/api/v1/discovery/sources/${id}/crawl`, { method: 'POST' });
|
||||
}
|
||||
|
||||
export async function activateSource(id: string): Promise<DiscoverySource> {
|
||||
const { source } = await fetchWithAuth<{ source: DiscoverySource }>(
|
||||
`/api/v1/discovery/sources/${id}/activate`,
|
||||
{ method: 'PUT' }
|
||||
);
|
||||
return source;
|
||||
}
|
||||
|
||||
export async function rejectSource(id: string): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/sources/${id}/reject`, { method: 'DELETE' });
|
||||
}
|
||||
|
||||
export async function discoverSources(
|
||||
regionId: string
|
||||
): Promise<{ suggestedCount: number; queries: number; searchResults: number }> {
|
||||
return fetchWithAuth(`/api/v1/discovery/regions/${regionId}/discover-sources`, {
|
||||
method: 'POST',
|
||||
});
|
||||
}
|
||||
|
||||
// ─── Feed ───────────────────────────────────────────────────────────
|
||||
|
||||
export interface FeedParams {
|
||||
from?: string;
|
||||
to?: string;
|
||||
category?: string;
|
||||
limit?: number;
|
||||
offset?: number;
|
||||
hideDismissed?: boolean;
|
||||
}
|
||||
|
||||
export async function getFeed(
|
||||
params: FeedParams = {}
|
||||
): Promise<{ events: DiscoveredEvent[]; total: number; hasMore: boolean }> {
|
||||
const searchParams = new URLSearchParams();
|
||||
if (params.from) searchParams.set('from', params.from);
|
||||
if (params.to) searchParams.set('to', params.to);
|
||||
if (params.category) searchParams.set('category', params.category);
|
||||
if (params.limit) searchParams.set('limit', String(params.limit));
|
||||
if (params.offset) searchParams.set('offset', String(params.offset));
|
||||
if (params.hideDismissed) searchParams.set('hideDismissed', 'true');
|
||||
|
||||
const qs = searchParams.toString();
|
||||
return fetchWithAuth(`/api/v1/discovery/feed${qs ? `?${qs}` : ''}`);
|
||||
}
|
||||
|
||||
export async function setEventAction(eventId: string, action: 'save' | 'dismiss'): Promise<void> {
|
||||
await fetchWithAuth(`/api/v1/discovery/feed/${eventId}/action`, {
|
||||
method: 'POST',
|
||||
body: JSON.stringify({ action }),
|
||||
});
|
||||
}
|
||||
|
|
@ -0,0 +1,206 @@
|
|||
/**
|
||||
* Discovery store — reactive state for regions, interests, sources, and feed.
|
||||
*
|
||||
* Server-authoritative: all reads fetch from mana-events, no Dexie.
|
||||
* State is held in Svelte 5 runes ($state) and refreshed on mount / mutation.
|
||||
*/
|
||||
|
||||
import * as api from './api';
|
||||
import type { DiscoveryRegion, DiscoveryInterest, DiscoverySource, DiscoveredEvent } from './types';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
|
||||
// ─── State ──────────────────────────────────────────────────────────
|
||||
|
||||
let regions = $state<DiscoveryRegion[]>([]);
|
||||
let interests = $state<DiscoveryInterest[]>([]);
|
||||
let sources = $state<DiscoverySource[]>([]);
|
||||
let feed = $state<DiscoveredEvent[]>([]);
|
||||
let feedHasMore = $state(false);
|
||||
let loading = $state(false);
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
// ─── Loaders ────────────────────────────────────────────<E29480><E29480>───────────
|
||||
|
||||
async function loadRegions() {
|
||||
try {
|
||||
regions = await api.getRegions();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load regions:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadInterests() {
|
||||
try {
|
||||
interests = await api.getInterests();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load interests:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadSources() {
|
||||
try {
|
||||
sources = await api.getSources();
|
||||
} catch (e) {
|
||||
console.error('[discovery] failed to load sources:', e);
|
||||
}
|
||||
}
|
||||
|
||||
async function loadFeed(params: api.FeedParams = {}) {
|
||||
loading = true;
|
||||
error = null;
|
||||
try {
|
||||
const result = await api.getFeed({ hideDismissed: true, ...params });
|
||||
feed = result.events;
|
||||
feedHasMore = result.hasMore;
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Fehler beim Laden';
|
||||
console.error('[discovery] failed to load feed:', e);
|
||||
} finally {
|
||||
loading = false;
|
||||
}
|
||||
}
|
||||
|
||||
// ─── Exported store ─────────────────────────────────────────────────
|
||||
|
||||
export const discoveryStore = {
|
||||
// Reactive getters
|
||||
get regions() {
|
||||
return regions;
|
||||
},
|
||||
get interests() {
|
||||
return interests;
|
||||
},
|
||||
get sources() {
|
||||
return sources;
|
||||
},
|
||||
get feed() {
|
||||
return feed;
|
||||
},
|
||||
get feedHasMore() {
|
||||
return feedHasMore;
|
||||
},
|
||||
get loading() {
|
||||
return loading;
|
||||
},
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
get isSetUp() {
|
||||
return regions.length > 0;
|
||||
},
|
||||
|
||||
// ── Init ─────────────────────────────────────────────────────
|
||||
async init() {
|
||||
await Promise.all([loadRegions(), loadInterests(), loadSources()]);
|
||||
if (regions.length > 0) {
|
||||
await loadFeed();
|
||||
}
|
||||
},
|
||||
|
||||
async refreshFeed(params?: api.FeedParams) {
|
||||
await loadFeed(params);
|
||||
},
|
||||
|
||||
// ── Regions ──────────────────────────────────────────────────
|
||||
async addRegion(input: { label: string; lat: number; lon: number; radiusKm?: number }) {
|
||||
const region = await api.createRegion(input);
|
||||
regions = [...regions, region];
|
||||
return region;
|
||||
},
|
||||
|
||||
async updateRegion(id: string, input: { label?: string; radiusKm?: number; isActive?: boolean }) {
|
||||
const region = await api.updateRegion(id, input);
|
||||
regions = regions.map((r) => (r.id === id ? region : r));
|
||||
return region;
|
||||
},
|
||||
|
||||
async removeRegion(id: string) {
|
||||
await api.deleteRegion(id);
|
||||
regions = regions.filter((r) => r.id !== id);
|
||||
},
|
||||
|
||||
// ── Interests ────────────────────────────────────────────────
|
||||
async addInterest(input: { category: string; freetext?: string | null; weight?: number }) {
|
||||
const interest = await api.createInterest(input);
|
||||
interests = [...interests, interest];
|
||||
return interest;
|
||||
},
|
||||
|
||||
async removeInterest(id: string) {
|
||||
await api.deleteInterest(id);
|
||||
interests = interests.filter((i) => i.id !== id);
|
||||
},
|
||||
|
||||
// ── Sources <20><><EFBFBD>─────────────────────────────────────────────────
|
||||
async addSource(input: {
|
||||
type: 'ical' | 'website';
|
||||
url: string;
|
||||
name: string;
|
||||
regionId: string;
|
||||
crawlIntervalHours?: number;
|
||||
}) {
|
||||
const source = await api.createSource(input);
|
||||
sources = [...sources, source];
|
||||
// Trigger immediate crawl
|
||||
api
|
||||
.crawlSourceNow(source.id)
|
||||
.then(() => loadFeed())
|
||||
.catch(() => {});
|
||||
return source;
|
||||
},
|
||||
|
||||
async removeSource(id: string) {
|
||||
await api.deleteSource(id);
|
||||
sources = sources.filter((s) => s.id !== id);
|
||||
},
|
||||
|
||||
async crawlSource(id: string) {
|
||||
const result = await api.crawlSourceNow(id);
|
||||
await loadSources();
|
||||
await loadFeed();
|
||||
return result;
|
||||
},
|
||||
|
||||
async activateSource(id: string) {
|
||||
const source = await api.activateSource(id);
|
||||
sources = sources.map((s) => (s.id === id ? source : s));
|
||||
},
|
||||
|
||||
async rejectSource(id: string) {
|
||||
await api.rejectSource(id);
|
||||
sources = sources.filter((s) => s.id !== id);
|
||||
},
|
||||
|
||||
async discoverSources(regionId: string) {
|
||||
const result = await api.discoverSources(regionId);
|
||||
await loadSources(); // refresh to include new suggestions
|
||||
return result;
|
||||
},
|
||||
|
||||
// ── Feed Actions ─────────────────────────────────────────────
|
||||
async saveEvent(eventId: string) {
|
||||
const event = feed.find((e) => e.id === eventId);
|
||||
if (!event) return;
|
||||
|
||||
await api.setEventAction(eventId, 'save');
|
||||
feed = feed.map((e) => (e.id === eventId ? { ...e, userAction: 'save' as const } : e));
|
||||
|
||||
// Create a local socialEvent from the discovered event
|
||||
const startMs = new Date(event.startAt).getTime();
|
||||
const fallbackEnd = new Date(startMs + 2 * 60 * 60 * 1000).toISOString();
|
||||
await eventsStore.createEvent({
|
||||
title: event.title,
|
||||
startTime: event.startAt,
|
||||
endTime: event.endAt ?? fallbackEnd,
|
||||
location: event.location ?? undefined,
|
||||
description: event.description
|
||||
? `${event.description}\n\nQuelle: ${event.sourceUrl}`
|
||||
: `Quelle: ${event.sourceUrl}`,
|
||||
});
|
||||
},
|
||||
|
||||
async dismissEvent(eventId: string) {
|
||||
await api.setEventAction(eventId, 'dismiss');
|
||||
feed = feed.filter((e) => e.id !== eventId);
|
||||
},
|
||||
};
|
||||
72
apps/mana/apps/web/src/lib/modules/events/discovery/types.ts
Normal file
72
apps/mana/apps/web/src/lib/modules/events/discovery/types.ts
Normal file
|
|
@ -0,0 +1,72 @@
|
|||
/**
|
||||
* Discovery types — shared between API client, queries, and UI components.
|
||||
*/
|
||||
|
||||
export interface DiscoveryRegion {
|
||||
id: string;
|
||||
label: string;
|
||||
lat: number;
|
||||
lon: number;
|
||||
radiusKm: number;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoveryInterest {
|
||||
id: string;
|
||||
category: string;
|
||||
freetext: string | null;
|
||||
weight: number;
|
||||
createdAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoverySource {
|
||||
id: string;
|
||||
type: 'ical' | 'website';
|
||||
url: string | null;
|
||||
name: string;
|
||||
regionId: string | null;
|
||||
crawlIntervalHours: number;
|
||||
lastCrawledAt: string | null;
|
||||
lastSuccessAt: string | null;
|
||||
errorCount: number;
|
||||
lastError: string | null;
|
||||
isActive: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface DiscoveredEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
lat: number | null;
|
||||
lon: number | null;
|
||||
startAt: string;
|
||||
endAt: string | null;
|
||||
allDay: boolean;
|
||||
imageUrl: string | null;
|
||||
sourceUrl: string;
|
||||
sourceName: string | null;
|
||||
category: string | null;
|
||||
priceInfo: string | null;
|
||||
crawledAt: string;
|
||||
userAction: 'save' | 'dismiss' | null;
|
||||
}
|
||||
|
||||
export const EVENT_CATEGORIES = [
|
||||
{ id: 'music', label: 'Musik' },
|
||||
{ id: 'theater', label: 'Theater' },
|
||||
{ id: 'art', label: 'Kunst' },
|
||||
{ id: 'tech', label: 'Tech' },
|
||||
{ id: 'sport', label: 'Sport' },
|
||||
{ id: 'food', label: 'Kulinarik' },
|
||||
{ id: 'family', label: 'Familie' },
|
||||
{ id: 'nature', label: 'Natur' },
|
||||
{ id: 'education', label: 'Bildung' },
|
||||
{ id: 'community', label: 'Community' },
|
||||
{ id: 'nightlife', label: 'Nachtleben' },
|
||||
{ id: 'market', label: 'Markt' },
|
||||
{ id: 'other', label: 'Sonstiges' },
|
||||
] as const;
|
||||
Loading…
Add table
Add a link
Reference in a new issue