mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 20:01:09 +02:00
feat(events): scaffold social events module (Phase 1a, local-only)
New 'events' module for planning gatherings with guest lists and RSVPs, distinct from the personal calendar. Events surface in the calendar via TimeBlock with sourceModule='events'. Guests, RSVPs and a publish stub work fully local-first; the public RSVP server lands in Phase 1b.
This commit is contained in:
parent
22d3d2b695
commit
30022e82e1
19 changed files with 1717 additions and 5 deletions
|
|
@ -12,6 +12,7 @@ import { trackFirstContent } from '$lib/stores/funnel-tracking';
|
|||
import { fire as fireTrigger } from '$lib/triggers/registry';
|
||||
import { checkInlineSuggestion } from '$lib/triggers/inline-suggest';
|
||||
import { getEffectiveUserId } from './current-user';
|
||||
import { isQuotaError, notifyQuotaExceeded } from './quota-detect';
|
||||
|
||||
// ─── Database ──────────────────────────────────────────────
|
||||
|
||||
|
|
@ -623,6 +624,26 @@ export function setApplyingServerChanges(v: boolean): void {
|
|||
|
||||
const pendingChangesTable = db.table('_pendingChanges');
|
||||
|
||||
/**
|
||||
* Fire-and-forget pending-change writer that surfaces quota errors via the
|
||||
* QUOTA_EVENT bus. Without this wrapper, a full IndexedDB would silently
|
||||
* swallow the change-tracking entry while the user-visible write succeeded
|
||||
* — meaning the user types something, sees it, and the edit never syncs.
|
||||
*
|
||||
* The Dexie creating/updating hook itself is synchronous and cannot await
|
||||
* a recovery, so we just dispatch the event and let the UI / sync engine
|
||||
* decide what to do (e.g. surface a toast, run cleanupTombstones).
|
||||
*/
|
||||
function trackPendingChange(table: string, change: Record<string, unknown>): void {
|
||||
pendingChangesTable.add(change).catch((err: unknown) => {
|
||||
if (isQuotaError(err)) {
|
||||
notifyQuotaExceeded({ table, op: 'pending-change', cleaned: 0, recovered: false });
|
||||
} else {
|
||||
console.error('[mana-sync] failed to record pending change:', err);
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Hidden field on every synced record holding per-field LWW timestamps.
|
||||
* Not indexed, not sent to the server in pending-change payloads.
|
||||
|
|
@ -638,7 +659,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
const table = db.table(tableName);
|
||||
|
||||
table.hook('creating', function (_primKey, obj) {
|
||||
if (_applyingServerChanges) return;
|
||||
if (_applyingTables.has(tableName)) return;
|
||||
const now = new Date().toISOString();
|
||||
|
||||
// Auto-stamp the active user. Module stores never set userId themselves,
|
||||
|
|
@ -661,7 +682,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
// Build payload for pending-change WITHOUT the internal timestamp map
|
||||
const { [FIELD_TIMESTAMPS_KEY]: _omit, ...dataForSync } = obj as Record<string, unknown>;
|
||||
|
||||
pendingChangesTable.add({
|
||||
trackPendingChange(tableName, {
|
||||
appId,
|
||||
collection: tableName,
|
||||
recordId: obj.id,
|
||||
|
|
@ -682,7 +703,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
});
|
||||
|
||||
table.hook('updating', function (modifications, primKey, obj) {
|
||||
if (_applyingServerChanges) return undefined;
|
||||
if (_applyingTables.has(tableName)) return undefined;
|
||||
const now = new Date().toISOString();
|
||||
const fields: Record<string, { value: unknown; updatedAt: string }> = {};
|
||||
|
||||
|
|
@ -705,7 +726,7 @@ for (const [appId, tables] of Object.entries(SYNC_APP_MAP)) {
|
|||
newFT[key] = now;
|
||||
}
|
||||
|
||||
pendingChangesTable.add({
|
||||
trackPendingChange(tableName, {
|
||||
appId,
|
||||
collection: tableName,
|
||||
recordId: primKey as string,
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export type TimeBlockKind = 'scheduled' | 'logged';
|
|||
|
||||
export type TimeBlockType = 'event' | 'task' | 'habit' | 'timeEntry' | 'focus' | 'break';
|
||||
|
||||
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits';
|
||||
export type TimeBlockSourceModule = 'calendar' | 'todo' | 'times' | 'habits' | 'events';
|
||||
|
||||
// ─── Local Record Types (Dexie) ──────────────────────────
|
||||
|
||||
|
|
|
|||
214
apps/mana/apps/web/src/lib/modules/events/ListView.svelte
Normal file
214
apps/mana/apps/web/src/lib/modules/events/ListView.svelte
Normal file
|
|
@ -0,0 +1,214 @@
|
|||
<script lang="ts">
|
||||
import { useUpcomingEvents, usePastEvents, useGuestsByEvent, summarizeRsvps } from './queries';
|
||||
import { eventsStore } from './stores/events.svelte';
|
||||
import EventCard from './components/EventCard.svelte';
|
||||
import type { SocialEvent } from './types';
|
||||
|
||||
interface Props {
|
||||
onOpenEvent?: (id: string) => void;
|
||||
}
|
||||
|
||||
let { onOpenEvent }: Props = $props();
|
||||
|
||||
const upcoming = useUpcomingEvents();
|
||||
const past = usePastEvents();
|
||||
const guestsByEvent = useGuestsByEvent();
|
||||
|
||||
let showCreate = $state(false);
|
||||
let newTitle = $state('');
|
||||
let newDate = $state('');
|
||||
let newTime = $state('19:00');
|
||||
let newLocation = $state('');
|
||||
|
||||
async function handleCreate(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const title = newTitle.trim();
|
||||
if (!title || !newDate) return;
|
||||
const startTime = new Date(`${newDate}T${newTime || '19:00'}`).toISOString();
|
||||
const endTime = new Date(new Date(startTime).getTime() + 2 * 60 * 60 * 1000).toISOString();
|
||||
const result = await eventsStore.createEvent({
|
||||
title,
|
||||
startTime,
|
||||
endTime,
|
||||
location: newLocation.trim() || null,
|
||||
});
|
||||
if (result.success) {
|
||||
newTitle = '';
|
||||
newDate = '';
|
||||
newLocation = '';
|
||||
showCreate = false;
|
||||
onOpenEvent?.(result.id);
|
||||
}
|
||||
}
|
||||
|
||||
function open(event: SocialEvent) {
|
||||
onOpenEvent?.(event.id);
|
||||
}
|
||||
</script>
|
||||
|
||||
<svelte:head>
|
||||
<title>Events - Mana</title>
|
||||
</svelte:head>
|
||||
|
||||
<div class="events-page">
|
||||
<header class="events-header">
|
||||
<div>
|
||||
<h1 class="page-title">Events</h1>
|
||||
<p class="page-subtitle">
|
||||
{(upcoming.value ?? []).length} bevorstehend · {(past.value ?? []).length} vergangen
|
||||
</p>
|
||||
</div>
|
||||
<button class="new-btn" onclick={() => (showCreate = !showCreate)}>
|
||||
{showCreate ? 'Abbrechen' : '+ Neues Event'}
|
||||
</button>
|
||||
</header>
|
||||
|
||||
{#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 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}
|
||||
</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}
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.events-page {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 880px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.events-header {
|
||||
display: flex;
|
||||
align-items: flex-start;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.page-title {
|
||||
margin: 0;
|
||||
font-size: 1.5rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.page-subtitle {
|
||||
margin: 0.25rem 0 0;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.new-btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: none;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.create-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
padding: 1rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.form-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.form-row .input {
|
||||
flex: 1;
|
||||
min-width: 8rem;
|
||||
}
|
||||
.input {
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
align-self: flex-start;
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
|
||||
.event-section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.section-title {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.event-list {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.empty {
|
||||
padding: 1.5rem;
|
||||
text-align: center;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
border: 1px dashed hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
}
|
||||
</style>
|
||||
10
apps/mana/apps/web/src/lib/modules/events/collections.ts
Normal file
10
apps/mana/apps/web/src/lib/modules/events/collections.ts
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
/**
|
||||
* Events module — collection accessors.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalSocialEvent, LocalEventGuest, LocalEventInvitation } from './types';
|
||||
|
||||
export const socialEventTable = db.table<LocalSocialEvent>('socialEvents');
|
||||
export const eventGuestTable = db.table<LocalEventGuest>('eventGuests');
|
||||
export const eventInvitationTable = db.table<LocalEventInvitation>('eventInvitations');
|
||||
|
|
@ -0,0 +1,153 @@
|
|||
<script lang="ts">
|
||||
import type { SocialEvent, RsvpSummary } from '../types';
|
||||
|
||||
interface Props {
|
||||
event: SocialEvent;
|
||||
summary?: RsvpSummary | null;
|
||||
onclick?: () => void;
|
||||
}
|
||||
|
||||
let { event, summary = null, onclick }: Props = $props();
|
||||
|
||||
const startDate = $derived(new Date(event.startTime));
|
||||
const dateLabel = $derived(
|
||||
startDate.toLocaleDateString('de-DE', {
|
||||
weekday: 'short',
|
||||
day: '2-digit',
|
||||
month: 'short',
|
||||
})
|
||||
);
|
||||
const timeLabel = $derived(
|
||||
event.isAllDay
|
||||
? 'Ganztägig'
|
||||
: startDate.toLocaleTimeString('de-DE', { hour: '2-digit', minute: '2-digit' })
|
||||
);
|
||||
</script>
|
||||
|
||||
<button class="event-card" {onclick}>
|
||||
<div class="date-block" style:background={event.color ?? '#6366F1'}>
|
||||
<div class="date">{dateLabel}</div>
|
||||
<div class="time">{timeLabel}</div>
|
||||
</div>
|
||||
<div class="event-body">
|
||||
<div class="title-row">
|
||||
<h3 class="title">{event.title}</h3>
|
||||
{#if event.status === 'draft'}
|
||||
<span class="status-badge draft">Entwurf</span>
|
||||
{:else if event.status === 'cancelled'}
|
||||
<span class="status-badge cancelled">Abgesagt</span>
|
||||
{:else if event.isPublished}
|
||||
<span class="status-badge published">Geteilt</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.location}
|
||||
<div class="location">📍 {event.location}</div>
|
||||
{/if}
|
||||
{#if summary}
|
||||
<div class="summary-row">
|
||||
<span class="yes-count">{summary.totalAttending} kommen</span>
|
||||
{#if summary.pending > 0}
|
||||
<span class="pending-count">· {summary.pending} offen</span>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
</div>
|
||||
</button>
|
||||
|
||||
<style>
|
||||
.event-card {
|
||||
display: flex;
|
||||
align-items: stretch;
|
||||
gap: 0;
|
||||
padding: 0;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.625rem;
|
||||
background: hsl(var(--color-card));
|
||||
text-align: left;
|
||||
cursor: pointer;
|
||||
overflow: hidden;
|
||||
transition:
|
||||
transform 0.1s ease,
|
||||
box-shadow 0.15s;
|
||||
width: 100%;
|
||||
}
|
||||
.event-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: 5rem;
|
||||
padding: 0.75rem 0.5rem;
|
||||
color: white;
|
||||
}
|
||||
.date {
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
opacity: 0.95;
|
||||
}
|
||||
.time {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 700;
|
||||
margin-top: 0.125rem;
|
||||
}
|
||||
.event-body {
|
||||
flex: 1;
|
||||
padding: 0.75rem 1rem;
|
||||
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;
|
||||
}
|
||||
.status-badge {
|
||||
font-size: 0.6875rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.04em;
|
||||
padding: 0.125rem 0.5rem;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.status-badge.draft {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.status-badge.cancelled {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.status-badge.published {
|
||||
background: rgba(99, 102, 241, 0.15);
|
||||
color: rgb(79, 70, 229);
|
||||
}
|
||||
.location {
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.summary-row {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.yes-count {
|
||||
font-weight: 600;
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,214 @@
|
|||
<script lang="ts">
|
||||
import { eventGuestsStore } from '../stores/guests.svelte';
|
||||
import { useEventGuests } from '../queries';
|
||||
import type { RsvpStatus } from '../types';
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
}
|
||||
|
||||
let { eventId }: Props = $props();
|
||||
|
||||
const guests = useEventGuests(() => eventId);
|
||||
|
||||
let newName = $state('');
|
||||
let newEmail = $state('');
|
||||
|
||||
const RSVP_OPTIONS: { value: RsvpStatus; label: string }[] = [
|
||||
{ value: 'pending', label: 'Offen' },
|
||||
{ value: 'yes', label: 'Ja' },
|
||||
{ value: 'maybe', label: 'Vielleicht' },
|
||||
{ value: 'no', label: 'Nein' },
|
||||
];
|
||||
|
||||
async function handleAdd(e: SubmitEvent) {
|
||||
e.preventDefault();
|
||||
const name = newName.trim();
|
||||
if (!name) return;
|
||||
await eventGuestsStore.addGuest({
|
||||
eventId,
|
||||
name,
|
||||
email: newEmail.trim() || null,
|
||||
});
|
||||
newName = '';
|
||||
newEmail = '';
|
||||
}
|
||||
</script>
|
||||
|
||||
<div class="guest-editor">
|
||||
<form class="add-row" onsubmit={handleAdd}>
|
||||
<input type="text" bind:value={newName} placeholder="Name" class="input name-input" required />
|
||||
<input
|
||||
type="email"
|
||||
bind:value={newEmail}
|
||||
placeholder="E-Mail (optional)"
|
||||
class="input email-input"
|
||||
/>
|
||||
<button type="submit" class="add-btn">Hinzufügen</button>
|
||||
</form>
|
||||
|
||||
<ul class="guest-list">
|
||||
{#each guests.value ?? [] as guest (guest.id)}
|
||||
<li class="guest-row">
|
||||
<div class="guest-info">
|
||||
<div class="guest-name">{guest.name}</div>
|
||||
{#if guest.email}
|
||||
<div class="guest-email">{guest.email}</div>
|
||||
{/if}
|
||||
</div>
|
||||
|
||||
<div class="guest-controls">
|
||||
<select
|
||||
class="rsvp-select"
|
||||
value={guest.rsvpStatus}
|
||||
onchange={(e) =>
|
||||
eventGuestsStore.setRsvp(guest.id, e.currentTarget.value as RsvpStatus)}
|
||||
>
|
||||
{#each RSVP_OPTIONS as opt (opt.value)}
|
||||
<option value={opt.value}>{opt.label}</option>
|
||||
{/each}
|
||||
</select>
|
||||
|
||||
<label class="plus-ones">
|
||||
+
|
||||
<input
|
||||
type="number"
|
||||
min="0"
|
||||
max="20"
|
||||
value={guest.plusOnes}
|
||||
onchange={(e) =>
|
||||
eventGuestsStore.updateGuest(guest.id, {
|
||||
plusOnes: Number(e.currentTarget.value) || 0,
|
||||
})}
|
||||
/>
|
||||
</label>
|
||||
|
||||
<button
|
||||
class="remove-btn"
|
||||
onclick={() => eventGuestsStore.deleteGuest(guest.id)}
|
||||
title="Entfernen"
|
||||
>
|
||||
×
|
||||
</button>
|
||||
</div>
|
||||
</li>
|
||||
{/each}
|
||||
|
||||
{#if (guests.value ?? []).length === 0}
|
||||
<li class="empty">Noch keine Gäste hinzugefügt.</li>
|
||||
{/if}
|
||||
</ul>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.guest-editor {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.add-row {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.input {
|
||||
flex: 1;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.email-input {
|
||||
flex: 1.2;
|
||||
}
|
||||
.add-btn {
|
||||
padding: 0.5rem 0.875rem;
|
||||
border-radius: 0.5rem;
|
||||
border: none;
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
font-size: 0.8125rem;
|
||||
font-weight: 500;
|
||||
cursor: pointer;
|
||||
}
|
||||
.guest-list {
|
||||
list-style: none;
|
||||
padding: 0;
|
||||
margin: 0;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.375rem;
|
||||
}
|
||||
.guest-row {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.75rem;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
}
|
||||
.guest-info {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
}
|
||||
.guest-name {
|
||||
font-size: 0.875rem;
|
||||
font-weight: 500;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.guest-email {
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.guest-controls {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.rsvp-select {
|
||||
padding: 0.25rem 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.plus-ones {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: 0.125rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.plus-ones input {
|
||||
width: 2.5rem;
|
||||
padding: 0.25rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.25rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.75rem;
|
||||
text-align: center;
|
||||
}
|
||||
.remove-btn {
|
||||
padding: 0.125rem 0.5rem;
|
||||
border: none;
|
||||
background: transparent;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
font-size: 1.25rem;
|
||||
line-height: 1;
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.remove-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.empty {
|
||||
padding: 1rem;
|
||||
text-align: center;
|
||||
font-size: 0.8125rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,82 @@
|
|||
<script lang="ts">
|
||||
import type { RsvpSummary } from '../types';
|
||||
|
||||
interface Props {
|
||||
summary: RsvpSummary;
|
||||
capacity?: number | null;
|
||||
}
|
||||
|
||||
let { summary, capacity = null }: Props = $props();
|
||||
</script>
|
||||
|
||||
<div class="rsvp-summary">
|
||||
<div class="badge yes">
|
||||
<span class="count">{summary.yes}</span>
|
||||
<span class="label">Ja</span>
|
||||
</div>
|
||||
<div class="badge maybe">
|
||||
<span class="count">{summary.maybe}</span>
|
||||
<span class="label">Vielleicht</span>
|
||||
</div>
|
||||
<div class="badge no">
|
||||
<span class="count">{summary.no}</span>
|
||||
<span class="label">Nein</span>
|
||||
</div>
|
||||
<div class="badge pending">
|
||||
<span class="count">{summary.pending}</span>
|
||||
<span class="label">Offen</span>
|
||||
</div>
|
||||
<div class="total">
|
||||
<strong>{summary.totalAttending}</strong>
|
||||
{#if capacity}
|
||||
<span class="muted">/ {capacity}</span>
|
||||
{/if}
|
||||
<span class="muted">kommen</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<style>
|
||||
.rsvp-summary {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
align-items: center;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.badge {
|
||||
display: flex;
|
||||
align-items: baseline;
|
||||
gap: 0.375rem;
|
||||
padding: 0.375rem 0.625rem;
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-muted));
|
||||
font-size: 0.8125rem;
|
||||
}
|
||||
.badge .count {
|
||||
font-weight: 700;
|
||||
}
|
||||
.badge.yes {
|
||||
background: rgba(34, 197, 94, 0.15);
|
||||
color: rgb(22, 163, 74);
|
||||
}
|
||||
.badge.maybe {
|
||||
background: rgba(245, 158, 11, 0.15);
|
||||
color: rgb(202, 138, 4);
|
||||
}
|
||||
.badge.no {
|
||||
background: rgba(239, 68, 68, 0.15);
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
.badge.pending {
|
||||
background: hsl(var(--color-muted));
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.total {
|
||||
margin-left: auto;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.total .muted {
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
margin-left: 0.25rem;
|
||||
}
|
||||
</style>
|
||||
5
apps/mana/apps/web/src/lib/modules/events/index.ts
Normal file
5
apps/mana/apps/web/src/lib/modules/events/index.ts
Normal file
|
|
@ -0,0 +1,5 @@
|
|||
export * from './types';
|
||||
export * from './collections';
|
||||
export * from './queries';
|
||||
export { eventsStore } from './stores/events.svelte';
|
||||
export { eventGuestsStore } from './stores/guests.svelte';
|
||||
164
apps/mana/apps/web/src/lib/modules/events/queries.ts
Normal file
164
apps/mana/apps/web/src/lib/modules/events/queries.ts
Normal file
|
|
@ -0,0 +1,164 @@
|
|||
/**
|
||||
* Reactive queries & helpers for the events module.
|
||||
*
|
||||
* Joins LocalSocialEvent with its TimeBlock to produce the UI-facing SocialEvent.
|
||||
*/
|
||||
|
||||
import { useLiveQueryWithDefault } from '@mana/local-store/svelte';
|
||||
import { db } from '$lib/data/database';
|
||||
import { timeBlockTable } from '$lib/data/time-blocks/collections';
|
||||
import type { LocalTimeBlock } from '$lib/data/time-blocks/types';
|
||||
import type {
|
||||
LocalSocialEvent,
|
||||
LocalEventGuest,
|
||||
SocialEvent,
|
||||
EventGuest,
|
||||
RsvpSummary,
|
||||
} from './types';
|
||||
|
||||
// ─── Type Converters ───────────────────────────────────────
|
||||
|
||||
export function toSocialEvent(local: LocalSocialEvent, block: LocalTimeBlock | null): SocialEvent {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
title: local.title,
|
||||
description: local.description ?? null,
|
||||
location: local.location ?? null,
|
||||
locationUrl: local.locationUrl ?? null,
|
||||
hostContactId: local.hostContactId ?? null,
|
||||
coverImage: local.coverImage ?? null,
|
||||
color: local.color ?? null,
|
||||
capacity: local.capacity ?? null,
|
||||
isPublished: local.isPublished ?? false,
|
||||
publicToken: local.publicToken ?? null,
|
||||
status: local.status,
|
||||
timeBlockId: local.timeBlockId,
|
||||
startTime: block?.startDate ?? now,
|
||||
endTime: block?.endDate ?? block?.startDate ?? now,
|
||||
isAllDay: block?.allDay ?? false,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
export function toEventGuest(local: LocalEventGuest): EventGuest {
|
||||
const now = new Date().toISOString();
|
||||
return {
|
||||
id: local.id,
|
||||
eventId: local.eventId,
|
||||
contactId: local.contactId ?? null,
|
||||
name: local.name,
|
||||
email: local.email ?? null,
|
||||
phone: local.phone ?? null,
|
||||
rsvpStatus: local.rsvpStatus,
|
||||
rsvpAt: local.rsvpAt ?? null,
|
||||
plusOnes: local.plusOnes ?? 0,
|
||||
note: local.note ?? null,
|
||||
createdAt: local.createdAt ?? now,
|
||||
updatedAt: local.updatedAt ?? now,
|
||||
};
|
||||
}
|
||||
|
||||
// ─── Reactive Hooks ────────────────────────────────────────
|
||||
|
||||
/** All non-deleted events, joined with their TimeBlock for time fields. */
|
||||
export function useAllEvents() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
|
||||
const active = locals.filter((e) => !e.deletedAt);
|
||||
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
|
||||
return active.map((e, i) => toSocialEvent(e, blocks[i] ?? null));
|
||||
}, [] as SocialEvent[]);
|
||||
}
|
||||
|
||||
/** Upcoming events (startTime >= now), sorted ascending. */
|
||||
export function useUpcomingEvents() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
|
||||
const active = locals.filter((e) => !e.deletedAt && e.status !== 'cancelled');
|
||||
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
|
||||
const now = Date.now();
|
||||
return active
|
||||
.map((e, i) => toSocialEvent(e, blocks[i] ?? null))
|
||||
.filter((e) => new Date(e.startTime).getTime() >= now)
|
||||
.sort((a, b) => new Date(a.startTime).getTime() - new Date(b.startTime).getTime());
|
||||
}, [] as SocialEvent[]);
|
||||
}
|
||||
|
||||
/** Past events. */
|
||||
export function usePastEvents() {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const locals = await db.table<LocalSocialEvent>('socialEvents').toArray();
|
||||
const active = locals.filter((e) => !e.deletedAt);
|
||||
const blocks = await timeBlockTable.bulkGet(active.map((e) => e.timeBlockId));
|
||||
const now = Date.now();
|
||||
return active
|
||||
.map((e, i) => toSocialEvent(e, blocks[i] ?? null))
|
||||
.filter((e) => new Date(e.startTime).getTime() < now)
|
||||
.sort((a, b) => new Date(b.startTime).getTime() - new Date(a.startTime).getTime());
|
||||
}, [] as SocialEvent[]);
|
||||
}
|
||||
|
||||
/** Single event by ID. */
|
||||
export function useEvent(eventId: () => string) {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const id = eventId();
|
||||
if (!id) return null;
|
||||
const local = await db.table<LocalSocialEvent>('socialEvents').get(id);
|
||||
if (!local || local.deletedAt) return null;
|
||||
const block = await timeBlockTable.get(local.timeBlockId);
|
||||
return toSocialEvent(local, block ?? null);
|
||||
},
|
||||
null as SocialEvent | null
|
||||
);
|
||||
}
|
||||
|
||||
/** All guests across all events, grouped by eventId. Useful for list views. */
|
||||
export function useGuestsByEvent() {
|
||||
return useLiveQueryWithDefault(
|
||||
async () => {
|
||||
const all = await db.table<LocalEventGuest>('eventGuests').toArray();
|
||||
const map = new Map<string, EventGuest[]>();
|
||||
for (const g of all) {
|
||||
if (g.deletedAt) continue;
|
||||
const guest = toEventGuest(g);
|
||||
const arr = map.get(guest.eventId);
|
||||
if (arr) arr.push(guest);
|
||||
else map.set(guest.eventId, [guest]);
|
||||
}
|
||||
return map;
|
||||
},
|
||||
new Map() as Map<string, EventGuest[]>
|
||||
);
|
||||
}
|
||||
|
||||
/** Guests for a single event. */
|
||||
export function useEventGuests(eventId: () => string) {
|
||||
return useLiveQueryWithDefault(async () => {
|
||||
const id = eventId();
|
||||
if (!id) return [];
|
||||
const guests = await db
|
||||
.table<LocalEventGuest>('eventGuests')
|
||||
.where('eventId')
|
||||
.equals(id)
|
||||
.toArray();
|
||||
return guests.filter((g) => !g.deletedAt).map(toEventGuest);
|
||||
}, [] as EventGuest[]);
|
||||
}
|
||||
|
||||
// ─── Pure Helpers ──────────────────────────────────────────
|
||||
|
||||
export function summarizeRsvps(guests: EventGuest[]): RsvpSummary {
|
||||
const summary: RsvpSummary = { yes: 0, no: 0, maybe: 0, pending: 0, totalAttending: 0 };
|
||||
for (const g of guests) {
|
||||
summary[g.rsvpStatus]++;
|
||||
if (g.rsvpStatus === 'yes') summary.totalAttending += 1 + (g.plusOnes ?? 0);
|
||||
}
|
||||
return summary;
|
||||
}
|
||||
|
||||
export function getEventById(events: SocialEvent[], id: string): SocialEvent | undefined {
|
||||
return events.find((e) => e.id === id);
|
||||
}
|
||||
|
|
@ -0,0 +1,68 @@
|
|||
/**
|
||||
* Events QuickInputBar Adapter — quick-create gatherings.
|
||||
*
|
||||
* MVP: very simple parser. The whole query becomes the title; default
|
||||
* start = today 19:00, end = +2h. Future: parse date phrases like
|
||||
* "Geburtstag Anna freitag 19 uhr".
|
||||
*/
|
||||
|
||||
import type { InputBarAdapter } from '$lib/quick-input/types';
|
||||
import type { QuickInputItem } from '@mana/shared-ui';
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalSocialEvent } from './types';
|
||||
import { format } from 'date-fns';
|
||||
import { de } from 'date-fns/locale';
|
||||
|
||||
function defaultStart(): Date {
|
||||
const d = new Date();
|
||||
d.setHours(19, 0, 0, 0);
|
||||
return d;
|
||||
}
|
||||
|
||||
export function createAdapter(): InputBarAdapter {
|
||||
return {
|
||||
placeholder: 'Neues Event oder suchen...',
|
||||
appIcon: 'events',
|
||||
deferSearch: true,
|
||||
createText: 'Erstellen',
|
||||
emptyText: 'Keine Events gefunden',
|
||||
|
||||
async onSearch(query) {
|
||||
const q = query.toLowerCase();
|
||||
const events = await db.table<LocalSocialEvent>('socialEvents').toArray();
|
||||
return events
|
||||
.filter((e) => !e.deletedAt && e.title?.toLowerCase().includes(q))
|
||||
.slice(0, 10)
|
||||
.map((e) => ({
|
||||
id: e.id,
|
||||
title: e.title,
|
||||
subtitle: e.location ?? '',
|
||||
}));
|
||||
},
|
||||
|
||||
onSelect(item: QuickInputItem) {
|
||||
window.location.href = `/events/${item.id}`;
|
||||
},
|
||||
|
||||
onParseCreate(query) {
|
||||
if (!query.trim()) return null;
|
||||
const start = defaultStart();
|
||||
return {
|
||||
title: `"${query.trim()}" anlegen`,
|
||||
subtitle: `Start: ${format(start, 'EEE, d. MMM, HH:mm', { locale: de })}`,
|
||||
};
|
||||
},
|
||||
|
||||
async onCreate(query) {
|
||||
if (!query.trim()) return;
|
||||
const start = defaultStart();
|
||||
const end = new Date(start.getTime() + 2 * 60 * 60 * 1000);
|
||||
const { eventsStore } = await import('./stores/events.svelte');
|
||||
await eventsStore.createEvent({
|
||||
title: query.trim(),
|
||||
startTime: start.toISOString(),
|
||||
endTime: end.toISOString(),
|
||||
});
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
@ -0,0 +1,188 @@
|
|||
/**
|
||||
* Events store — mutation-only service.
|
||||
*
|
||||
* Creates a TimeBlock + LocalSocialEvent pair so events show up in calendar
|
||||
* via the universal time view (sourceModule: 'events').
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import { createBlock, updateBlock, deleteBlock } from '$lib/data/time-blocks/service';
|
||||
import type { LocalSocialEvent, EventStatus } from '../types';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const eventsStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async createEvent(input: {
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
locationUrl?: string | null;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay?: boolean;
|
||||
hostContactId?: string | null;
|
||||
coverImage?: string | null;
|
||||
color?: string | null;
|
||||
capacity?: number | null;
|
||||
status?: EventStatus;
|
||||
}) {
|
||||
error = null;
|
||||
try {
|
||||
const eventId = crypto.randomUUID();
|
||||
|
||||
const timeBlockId = await createBlock({
|
||||
startDate: input.startTime,
|
||||
endDate: input.endTime,
|
||||
allDay: input.isAllDay ?? false,
|
||||
kind: 'scheduled',
|
||||
type: 'event',
|
||||
sourceModule: 'events',
|
||||
sourceId: eventId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
color: input.color ?? null,
|
||||
});
|
||||
|
||||
const newLocal: LocalSocialEvent = {
|
||||
id: eventId,
|
||||
timeBlockId,
|
||||
title: input.title,
|
||||
description: input.description ?? null,
|
||||
location: input.location ?? null,
|
||||
locationUrl: input.locationUrl ?? null,
|
||||
hostContactId: input.hostContactId ?? null,
|
||||
coverImage: input.coverImage ?? null,
|
||||
color: input.color ?? null,
|
||||
capacity: input.capacity ?? null,
|
||||
isPublished: false,
|
||||
publicToken: null,
|
||||
status: input.status ?? 'draft',
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
|
||||
await db.table<LocalSocialEvent>('socialEvents').add(newLocal);
|
||||
return { success: true as const, id: eventId };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to create event';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
async updateEvent(
|
||||
id: string,
|
||||
input: {
|
||||
title?: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
locationUrl?: string | null;
|
||||
startTime?: string;
|
||||
endTime?: string;
|
||||
isAllDay?: boolean;
|
||||
color?: string | null;
|
||||
capacity?: number | null;
|
||||
status?: EventStatus;
|
||||
coverImage?: string | null;
|
||||
}
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
|
||||
if (!event) return { success: false as const, error: 'Event not found' };
|
||||
|
||||
const blockUpdates: Record<string, unknown> = {};
|
||||
if (input.startTime !== undefined) blockUpdates.startDate = input.startTime;
|
||||
if (input.endTime !== undefined) blockUpdates.endDate = input.endTime;
|
||||
if (input.isAllDay !== undefined) blockUpdates.allDay = input.isAllDay;
|
||||
if (input.title !== undefined) blockUpdates.title = input.title;
|
||||
if (input.description !== undefined) blockUpdates.description = input.description;
|
||||
if (input.color !== undefined) blockUpdates.color = input.color;
|
||||
|
||||
if (Object.keys(blockUpdates).length > 0) {
|
||||
await updateBlock(event.timeBlockId, blockUpdates);
|
||||
}
|
||||
|
||||
const localData: Partial<LocalSocialEvent> = {
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (input.title !== undefined) localData.title = input.title;
|
||||
if (input.description !== undefined) localData.description = input.description;
|
||||
if (input.location !== undefined) localData.location = input.location;
|
||||
if (input.locationUrl !== undefined) localData.locationUrl = input.locationUrl;
|
||||
if (input.color !== undefined) localData.color = input.color;
|
||||
if (input.capacity !== undefined) localData.capacity = input.capacity;
|
||||
if (input.status !== undefined) localData.status = input.status;
|
||||
if (input.coverImage !== undefined) localData.coverImage = input.coverImage;
|
||||
|
||||
await db.table('socialEvents').update(id, localData);
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update event';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
async deleteEvent(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const event = await db.table<LocalSocialEvent>('socialEvents').get(id);
|
||||
if (event?.timeBlockId) {
|
||||
await deleteBlock(event.timeBlockId);
|
||||
}
|
||||
await db.table('socialEvents').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete event';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
/**
|
||||
* Local-only "publish" stub for Phase 1a.
|
||||
* Just flips isPublished + assigns a placeholder token. Phase 1b will
|
||||
* push the snapshot to mana-events and use a real server-issued token.
|
||||
*/
|
||||
async publishEvent(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
const token =
|
||||
typeof crypto !== 'undefined' && 'randomUUID' in crypto
|
||||
? crypto.randomUUID().replace(/-/g, '').slice(0, 24)
|
||||
: Math.random().toString(36).slice(2, 26);
|
||||
|
||||
await db.table('socialEvents').update(id, {
|
||||
isPublished: true,
|
||||
publicToken: token,
|
||||
status: 'published' satisfies EventStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const, token };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to publish event';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
async unpublishEvent(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await db.table('socialEvents').update(id, {
|
||||
isPublished: false,
|
||||
publicToken: null,
|
||||
status: 'draft' satisfies EventStatus,
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to unpublish event';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
@ -0,0 +1,96 @@
|
|||
/**
|
||||
* Event guests store — mutation-only service for the guest list of an event.
|
||||
*/
|
||||
|
||||
import { db } from '$lib/data/database';
|
||||
import type { LocalEventGuest, RsvpStatus } from '../types';
|
||||
|
||||
let error = $state<string | null>(null);
|
||||
|
||||
export const eventGuestsStore = {
|
||||
get error() {
|
||||
return error;
|
||||
},
|
||||
|
||||
async addGuest(input: {
|
||||
eventId: string;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
contactId?: string | null;
|
||||
rsvpStatus?: RsvpStatus;
|
||||
plusOnes?: number;
|
||||
note?: string | null;
|
||||
}) {
|
||||
error = null;
|
||||
try {
|
||||
const id = crypto.randomUUID();
|
||||
const newGuest: LocalEventGuest = {
|
||||
id,
|
||||
eventId: input.eventId,
|
||||
contactId: input.contactId ?? null,
|
||||
name: input.name,
|
||||
email: input.email ?? null,
|
||||
phone: input.phone ?? null,
|
||||
rsvpStatus: input.rsvpStatus ?? 'pending',
|
||||
rsvpAt: null,
|
||||
plusOnes: input.plusOnes ?? 0,
|
||||
note: input.note ?? null,
|
||||
createdAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
await db.table<LocalEventGuest>('eventGuests').add(newGuest);
|
||||
return { success: true as const, id };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to add guest';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
async updateGuest(
|
||||
id: string,
|
||||
input: Partial<{
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
contactId: string | null;
|
||||
rsvpStatus: RsvpStatus;
|
||||
plusOnes: number;
|
||||
note: string | null;
|
||||
}>
|
||||
) {
|
||||
error = null;
|
||||
try {
|
||||
const data: Partial<LocalEventGuest> = {
|
||||
...input,
|
||||
updatedAt: new Date().toISOString(),
|
||||
};
|
||||
if (input.rsvpStatus !== undefined) {
|
||||
data.rsvpAt = new Date().toISOString();
|
||||
}
|
||||
await db.table('eventGuests').update(id, data);
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to update guest';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
|
||||
async setRsvp(id: string, status: RsvpStatus) {
|
||||
return this.updateGuest(id, { rsvpStatus: status });
|
||||
},
|
||||
|
||||
async deleteGuest(id: string) {
|
||||
error = null;
|
||||
try {
|
||||
await db.table('eventGuests').update(id, {
|
||||
deletedAt: new Date().toISOString(),
|
||||
updatedAt: new Date().toISOString(),
|
||||
});
|
||||
return { success: true as const };
|
||||
} catch (e) {
|
||||
error = e instanceof Error ? e.message : 'Failed to delete guest';
|
||||
return { success: false as const, error };
|
||||
}
|
||||
},
|
||||
};
|
||||
99
apps/mana/apps/web/src/lib/modules/events/types.ts
Normal file
99
apps/mana/apps/web/src/lib/modules/events/types.ts
Normal file
|
|
@ -0,0 +1,99 @@
|
|||
/**
|
||||
* Events module — social gatherings (parties, dinners, workshops).
|
||||
*
|
||||
* Distinct from the `calendar` module's events table: these have guest lists,
|
||||
* RSVPs, and shareable invitation tokens. The time dimension lives on a
|
||||
* TimeBlock (sourceModule: 'events') so the event surfaces in the calendar.
|
||||
*/
|
||||
|
||||
import type { BaseRecord } from '@mana/local-store';
|
||||
|
||||
export type EventStatus = 'draft' | 'published' | 'cancelled' | 'past';
|
||||
|
||||
export type RsvpStatus = 'pending' | 'yes' | 'no' | 'maybe';
|
||||
|
||||
export type InvitationChannel = 'link' | 'manual';
|
||||
|
||||
// ─── Local Records (Dexie) ─────────────────────────────────
|
||||
|
||||
export interface LocalSocialEvent extends BaseRecord {
|
||||
timeBlockId: string;
|
||||
title: string;
|
||||
description?: string | null;
|
||||
location?: string | null;
|
||||
locationUrl?: string | null;
|
||||
hostContactId?: string | null;
|
||||
coverImage?: string | null;
|
||||
color?: string | null;
|
||||
capacity?: number | null;
|
||||
isPublished: boolean;
|
||||
publicToken?: string | null;
|
||||
status: EventStatus;
|
||||
}
|
||||
|
||||
export interface LocalEventGuest extends BaseRecord {
|
||||
eventId: string;
|
||||
contactId?: string | null;
|
||||
name: string;
|
||||
email?: string | null;
|
||||
phone?: string | null;
|
||||
rsvpStatus: RsvpStatus;
|
||||
rsvpAt?: string | null;
|
||||
plusOnes: number;
|
||||
note?: string | null;
|
||||
}
|
||||
|
||||
export interface LocalEventInvitation extends BaseRecord {
|
||||
eventId: string;
|
||||
guestId: string;
|
||||
channel: InvitationChannel;
|
||||
sentAt?: string | null;
|
||||
openedAt?: string | null;
|
||||
token: string;
|
||||
}
|
||||
|
||||
// ─── Domain (UI-facing) ────────────────────────────────────
|
||||
|
||||
export interface SocialEvent {
|
||||
id: string;
|
||||
title: string;
|
||||
description: string | null;
|
||||
location: string | null;
|
||||
locationUrl: string | null;
|
||||
hostContactId: string | null;
|
||||
coverImage: string | null;
|
||||
color: string | null;
|
||||
capacity: number | null;
|
||||
isPublished: boolean;
|
||||
publicToken: string | null;
|
||||
status: EventStatus;
|
||||
timeBlockId: string;
|
||||
startTime: string;
|
||||
endTime: string;
|
||||
isAllDay: boolean;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface EventGuest {
|
||||
id: string;
|
||||
eventId: string;
|
||||
contactId: string | null;
|
||||
name: string;
|
||||
email: string | null;
|
||||
phone: string | null;
|
||||
rsvpStatus: RsvpStatus;
|
||||
rsvpAt: string | null;
|
||||
plusOnes: number;
|
||||
note: string | null;
|
||||
createdAt: string;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
export interface RsvpSummary {
|
||||
yes: number;
|
||||
no: number;
|
||||
maybe: number;
|
||||
pending: number;
|
||||
totalAttending: number; // yes + plusOnes
|
||||
}
|
||||
|
|
@ -0,0 +1,347 @@
|
|||
<script lang="ts">
|
||||
import { useEvent, useEventGuests, summarizeRsvps } from '../queries';
|
||||
import { eventsStore } from '../stores/events.svelte';
|
||||
import GuestListEditor from '../components/GuestListEditor.svelte';
|
||||
import RsvpSummaryView from '../components/RsvpSummary.svelte';
|
||||
|
||||
interface Props {
|
||||
eventId: string;
|
||||
onBack?: () => void;
|
||||
}
|
||||
|
||||
let { eventId, onBack }: Props = $props();
|
||||
|
||||
const eventQuery = useEvent(() => eventId);
|
||||
const guests = useEventGuests(() => eventId);
|
||||
const summary = $derived(summarizeRsvps(guests.value ?? []));
|
||||
const event = $derived(eventQuery.value);
|
||||
|
||||
let editing = $state(false);
|
||||
let titleDraft = $state('');
|
||||
let descDraft = $state('');
|
||||
let locationDraft = $state('');
|
||||
let startDraft = $state('');
|
||||
let endDraft = $state('');
|
||||
let allDayDraft = $state(false);
|
||||
|
||||
function startEdit() {
|
||||
if (!event) return;
|
||||
titleDraft = event.title;
|
||||
descDraft = event.description ?? '';
|
||||
locationDraft = event.location ?? '';
|
||||
startDraft = toLocalDatetime(event.startTime);
|
||||
endDraft = toLocalDatetime(event.endTime);
|
||||
allDayDraft = event.isAllDay;
|
||||
editing = true;
|
||||
}
|
||||
|
||||
async function saveEdit() {
|
||||
if (!event) return;
|
||||
await eventsStore.updateEvent(event.id, {
|
||||
title: titleDraft,
|
||||
description: descDraft || null,
|
||||
location: locationDraft || null,
|
||||
startTime: fromLocalDatetime(startDraft),
|
||||
endTime: fromLocalDatetime(endDraft),
|
||||
isAllDay: allDayDraft,
|
||||
});
|
||||
editing = false;
|
||||
}
|
||||
|
||||
function toLocalDatetime(iso: string): string {
|
||||
const d = new Date(iso);
|
||||
const pad = (n: number) => n.toString().padStart(2, '0');
|
||||
return `${d.getFullYear()}-${pad(d.getMonth() + 1)}-${pad(d.getDate())}T${pad(d.getHours())}:${pad(d.getMinutes())}`;
|
||||
}
|
||||
|
||||
function fromLocalDatetime(local: string): string {
|
||||
return new Date(local).toISOString();
|
||||
}
|
||||
|
||||
async function handlePublish() {
|
||||
if (!event) return;
|
||||
if (event.isPublished) {
|
||||
await eventsStore.unpublishEvent(event.id);
|
||||
} else {
|
||||
await eventsStore.publishEvent(event.id);
|
||||
}
|
||||
}
|
||||
|
||||
async function handleDelete() {
|
||||
if (!event) return;
|
||||
if (!confirm(`Event "${event.title}" wirklich löschen?`)) return;
|
||||
await eventsStore.deleteEvent(event.id);
|
||||
onBack?.();
|
||||
}
|
||||
|
||||
function copyShareLink() {
|
||||
if (!event?.publicToken) return;
|
||||
const url = `${window.location.origin}/rsvp/${event.publicToken}`;
|
||||
navigator.clipboard.writeText(url);
|
||||
}
|
||||
</script>
|
||||
|
||||
{#if !event}
|
||||
<div class="loading">Lade Event...</div>
|
||||
{:else}
|
||||
<div class="detail">
|
||||
<header class="detail-header">
|
||||
{#if onBack}
|
||||
<button class="back-btn" onclick={onBack}>← Zurück</button>
|
||||
{/if}
|
||||
<div class="header-actions">
|
||||
<button class="action-btn" onclick={startEdit}>Bearbeiten</button>
|
||||
<button class="action-btn danger" onclick={handleDelete}>Löschen</button>
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{#if editing}
|
||||
<div class="edit-form">
|
||||
<input class="title-input" bind:value={titleDraft} placeholder="Event-Titel" />
|
||||
<textarea class="desc-input" bind:value={descDraft} rows="3" placeholder="Beschreibung"
|
||||
></textarea>
|
||||
<input class="loc-input" bind:value={locationDraft} placeholder="Ort" />
|
||||
<div class="time-row">
|
||||
<label>
|
||||
<span>Start</span>
|
||||
<input type="datetime-local" bind:value={startDraft} />
|
||||
</label>
|
||||
<label>
|
||||
<span>Ende</span>
|
||||
<input type="datetime-local" bind:value={endDraft} />
|
||||
</label>
|
||||
<label class="all-day">
|
||||
<input type="checkbox" bind:checked={allDayDraft} />
|
||||
<span>Ganztägig</span>
|
||||
</label>
|
||||
</div>
|
||||
<div class="form-actions">
|
||||
<button class="action-btn" onclick={() => (editing = false)}>Abbrechen</button>
|
||||
<button class="action-btn primary" onclick={saveEdit}>Speichern</button>
|
||||
</div>
|
||||
</div>
|
||||
{:else}
|
||||
<div class="event-meta">
|
||||
<h1 class="title">{event.title}</h1>
|
||||
<div class="meta-row">
|
||||
<span class="when">
|
||||
{new Date(event.startTime).toLocaleString('de-DE', {
|
||||
weekday: 'long',
|
||||
day: '2-digit',
|
||||
month: 'long',
|
||||
year: 'numeric',
|
||||
hour: event.isAllDay ? undefined : '2-digit',
|
||||
minute: event.isAllDay ? undefined : '2-digit',
|
||||
})}
|
||||
</span>
|
||||
{#if event.location}
|
||||
<span class="where">📍 {event.location}</span>
|
||||
{/if}
|
||||
</div>
|
||||
{#if event.description}
|
||||
<p class="description">{event.description}</p>
|
||||
{/if}
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<section class="section">
|
||||
<h2>RSVPs</h2>
|
||||
<RsvpSummaryView {summary} capacity={event.capacity} />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Gäste</h2>
|
||||
<GuestListEditor eventId={event.id} />
|
||||
</section>
|
||||
|
||||
<section class="section">
|
||||
<h2>Teilen</h2>
|
||||
{#if event.isPublished && event.publicToken}
|
||||
<div class="share-row">
|
||||
<code class="share-link">{window.location.origin}/rsvp/{event.publicToken}</code>
|
||||
<button class="action-btn" onclick={copyShareLink}>Kopieren</button>
|
||||
<button class="action-btn" onclick={handlePublish}>Privat machen</button>
|
||||
</div>
|
||||
<p class="share-hint">
|
||||
Phase 1a: Link funktioniert noch nicht öffentlich — Gäste-Antworten musst du noch manuell
|
||||
oben in der Gästeliste eintragen.
|
||||
</p>
|
||||
{:else}
|
||||
<button class="action-btn primary" onclick={handlePublish}>
|
||||
Event veröffentlichen & Link generieren
|
||||
</button>
|
||||
{/if}
|
||||
</section>
|
||||
</div>
|
||||
{/if}
|
||||
|
||||
<style>
|
||||
.detail {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 1.5rem;
|
||||
padding: 1rem;
|
||||
max-width: 720px;
|
||||
margin: 0 auto;
|
||||
}
|
||||
.detail-header {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
}
|
||||
.back-btn {
|
||||
background: none;
|
||||
border: none;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
cursor: pointer;
|
||||
padding: 0.25rem 0;
|
||||
}
|
||||
.header-actions {
|
||||
display: flex;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
.action-btn {
|
||||
padding: 0.375rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-card));
|
||||
color: hsl(var(--color-foreground));
|
||||
font-size: 0.8125rem;
|
||||
cursor: pointer;
|
||||
}
|
||||
.action-btn:hover {
|
||||
background: hsl(var(--color-muted));
|
||||
}
|
||||
.action-btn.primary {
|
||||
background: hsl(var(--color-primary));
|
||||
color: hsl(var(--color-primary-foreground));
|
||||
border-color: transparent;
|
||||
}
|
||||
.action-btn.danger {
|
||||
color: rgb(220, 38, 38);
|
||||
}
|
||||
|
||||
.event-meta {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.title {
|
||||
margin: 0;
|
||||
font-size: 1.875rem;
|
||||
font-weight: 700;
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.meta-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 1rem;
|
||||
font-size: 0.875rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.description {
|
||||
margin: 0;
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
white-space: pre-wrap;
|
||||
}
|
||||
|
||||
.section {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.section h2 {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
font-weight: 600;
|
||||
text-transform: uppercase;
|
||||
letter-spacing: 0.05em;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
|
||||
.edit-form {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.75rem;
|
||||
}
|
||||
.title-input,
|
||||
.desc-input,
|
||||
.loc-input {
|
||||
padding: 0.625rem 0.875rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.9375rem;
|
||||
color: hsl(var(--color-foreground));
|
||||
font-family: inherit;
|
||||
}
|
||||
.title-input {
|
||||
font-size: 1.25rem;
|
||||
font-weight: 600;
|
||||
}
|
||||
.time-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.75rem;
|
||||
align-items: end;
|
||||
}
|
||||
.time-row label {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
gap: 0.25rem;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
.time-row input[type='datetime-local'] {
|
||||
padding: 0.5rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.375rem;
|
||||
background: hsl(var(--color-background));
|
||||
color: hsl(var(--color-foreground));
|
||||
}
|
||||
.all-day {
|
||||
flex-direction: row !important;
|
||||
align-items: center;
|
||||
gap: 0.375rem !important;
|
||||
padding-bottom: 0.5rem;
|
||||
}
|
||||
.form-actions {
|
||||
display: flex;
|
||||
justify-content: flex-end;
|
||||
gap: 0.5rem;
|
||||
}
|
||||
|
||||
.share-row {
|
||||
display: flex;
|
||||
flex-wrap: wrap;
|
||||
gap: 0.5rem;
|
||||
align-items: center;
|
||||
}
|
||||
.share-link {
|
||||
flex: 1;
|
||||
min-width: 0;
|
||||
padding: 0.5rem 0.75rem;
|
||||
border: 1px solid hsl(var(--color-border));
|
||||
border-radius: 0.5rem;
|
||||
background: hsl(var(--color-background));
|
||||
font-size: 0.8125rem;
|
||||
font-family: ui-monospace, monospace;
|
||||
color: hsl(var(--color-foreground));
|
||||
overflow-x: auto;
|
||||
white-space: nowrap;
|
||||
}
|
||||
.share-hint {
|
||||
margin: 0;
|
||||
font-size: 0.75rem;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
.loading {
|
||||
padding: 2rem;
|
||||
text-align: center;
|
||||
color: hsl(var(--color-muted-foreground));
|
||||
}
|
||||
</style>
|
||||
|
|
@ -0,0 +1,7 @@
|
|||
<script lang="ts">
|
||||
import type { Snippet } from 'svelte';
|
||||
|
||||
let { children }: { children: Snippet } = $props();
|
||||
</script>
|
||||
|
||||
{@render children()}
|
||||
10
apps/mana/apps/web/src/routes/(app)/events/+page.svelte
Normal file
10
apps/mana/apps/web/src/routes/(app)/events/+page.svelte
Normal file
|
|
@ -0,0 +1,10 @@
|
|||
<script lang="ts">
|
||||
import { goto } from '$app/navigation';
|
||||
import ListView from '$lib/modules/events/ListView.svelte';
|
||||
|
||||
function handleOpen(id: string) {
|
||||
goto(`/events/${id}`);
|
||||
}
|
||||
</script>
|
||||
|
||||
<ListView onOpenEvent={handleOpen} />
|
||||
13
apps/mana/apps/web/src/routes/(app)/events/[id]/+page.svelte
Normal file
13
apps/mana/apps/web/src/routes/(app)/events/[id]/+page.svelte
Normal file
|
|
@ -0,0 +1,13 @@
|
|||
<script lang="ts">
|
||||
import { page } from '$app/stores';
|
||||
import { goto } from '$app/navigation';
|
||||
import DetailView from '$lib/modules/events/views/DetailView.svelte';
|
||||
|
||||
const eventId = $derived($page.params.id ?? '');
|
||||
|
||||
function handleBack() {
|
||||
goto('/events');
|
||||
}
|
||||
</script>
|
||||
|
||||
<DetailView {eventId} onBack={handleBack} />
|
||||
|
|
@ -145,6 +145,9 @@ export const APP_ICONS = {
|
|||
arcade: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ar" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#ef4444"/><stop offset="100%" style="stop-color:#dc2626"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ar)"/><rect x="25" y="30" width="50" height="35" rx="5" stroke="white" stroke-width="4" fill="none"/><path d="M38 65v10M62 65v10M32 75h36" stroke="white" stroke-width="4" stroke-linecap="round"/><circle cx="60" cy="44" r="4" fill="white"/><circle cx="68" cy="50" r="3" fill="white" fill-opacity="0.7"/><path d="M35 44h10M40 39v10" stroke="white" stroke-width="3" stroke-linecap="round"/></svg>`
|
||||
),
|
||||
events: svgToDataUrl(
|
||||
`<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><linearGradient id="ev" x1="0%" y1="0%" x2="100%" y2="100%"><stop offset="0%" style="stop-color:#f43f5e"/><stop offset="100%" style="stop-color:#be123c"/></linearGradient></defs><rect width="100" height="100" rx="22" fill="url(#ev)"/><path d="M22 78l14-44 30 30-44 14z" fill="white"/><path d="M36 34c4-6 12-8 18-4M50 22c4-2 10 0 12 6M62 28c6-2 12 2 12 10" stroke="white" stroke-width="3" stroke-linecap="round" fill="none"/><circle cx="74" cy="46" r="2.5" fill="white"/><circle cx="80" cy="58" r="2" fill="white" fill-opacity="0.8"/><circle cx="68" cy="62" r="2" fill="white" fill-opacity="0.7"/></svg>`
|
||||
),
|
||||
} as const;
|
||||
|
||||
export type AppIconId = keyof typeof APP_ICONS;
|
||||
|
|
|
|||
|
|
@ -632,6 +632,23 @@ export const MANA_APPS: ManaApp[] = [
|
|||
status: 'development',
|
||||
requiredTier: 'founder',
|
||||
},
|
||||
{
|
||||
id: 'events',
|
||||
name: 'Events',
|
||||
description: {
|
||||
de: 'Veranstaltungen mit Gästeliste',
|
||||
en: 'Gatherings with guest lists',
|
||||
},
|
||||
longDescription: {
|
||||
de: 'Plane Geburtstage, Dinner und Workshops mit Gästeliste, RSVPs und teilbaren Einladungslinks. Events erscheinen automatisch in deinem Kalender.',
|
||||
en: 'Plan birthdays, dinners, and workshops with guest lists, RSVPs, and shareable invite links. Events appear automatically in your calendar.',
|
||||
},
|
||||
icon: APP_ICONS.events,
|
||||
color: '#f43f5e',
|
||||
comingSoon: false,
|
||||
status: 'development',
|
||||
requiredTier: 'founder',
|
||||
},
|
||||
{
|
||||
id: 'finance',
|
||||
name: 'Finance',
|
||||
|
|
@ -795,6 +812,7 @@ export const APP_URLS: Record<AppIconId, { dev: string; prod: string }> = {
|
|||
habits: { dev: 'http://localhost:5173/habits', prod: 'https://mana.how/habits' },
|
||||
notes: { dev: 'http://localhost:5173/notes', prod: 'https://mana.how/notes' },
|
||||
dreams: { dev: 'http://localhost:5173/dreams', prod: 'https://mana.how/dreams' },
|
||||
events: { dev: 'http://localhost:5173/events', prod: 'https://mana.how/events' },
|
||||
finance: { dev: 'http://localhost:5173/finance', prod: 'https://mana.how/finance' },
|
||||
places: { dev: 'http://localhost:5173/places', prod: 'https://mana.how/places' },
|
||||
wisekeep: { dev: 'http://localhost:5173/wisekeep', prod: 'https://mana.how/wisekeep' },
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue