feat(local-first): migrate 9 apps to reactive useLiveQuery reads

Replace manual $state + fetchX() pattern with Dexie liveQuery hooks
across 9 apps. All data reads now auto-update on IndexedDB changes
(local writes, sync, other tabs). Stores reduced to mutation-only.

Apps migrated:
- Zitare: favorites, lists
- Contacts: contacts
- Calendar: calendars, events
- Chat: conversations, templates
- Clock: alarms, timers, worldClocks
- ManaDeck: decks, cards
- Presi: decks, slides
- Context: spaces, documents
- Storage: files, folders

Pattern per app:
1. New queries.ts with useLiveQuery hooks + pure filter helpers
2. Stores slimmed to mutation-only (no $state arrays, no fetch methods)
3. Layout sets context via setContext() for child components
4. Components use getContext() for reactive reads

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:27:46 +01:00
parent ced7dd7441
commit 30e124e609
87 changed files with 2528 additions and 3136 deletions

View file

@ -1,8 +1,9 @@
<script lang="ts">
import type { CalendarEvent } from '@calendar/shared';
import { getContext } from 'svelte';
import type { CalendarEvent, Calendar as CalendarType } from '@calendar/shared';
import type { Task } from '$lib/api/todos';
import { PRIORITY_COLORS, PRIORITY_LABELS } from '$lib/api/todos';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getCalendarColorWithBirthdays } from '$lib/data/queries';
import { todosStore } from '$lib/stores/todos.svelte';
import TodoCheckbox from '$lib/components/todo/TodoCheckbox.svelte';
import PriorityBadge from '$lib/components/todo/PriorityBadge.svelte';
@ -22,10 +23,15 @@
let { type, event, todo, onclick }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: CalendarType[] } = getContext('calendars');
let isToggling = $state(false);
// Event helpers
const eventColor = $derived(event ? calendarsStore.getColor(event.calendarId) : undefined);
const eventColor = $derived(
event ? getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId) : undefined
);
const eventTimeLabel = $derived.by(() => {
if (!event) return '';
if (event.isAllDay) return 'Ganztägig';

View file

@ -1,13 +1,18 @@
<script lang="ts">
import { getContext } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import {
getVisibleCalendars,
getCalendarColorWithBirthdays,
getEventsForRange,
} from '$lib/data/queries';
import { filterByTags } from '$lib/utils/eventFiltering';
import { format, parseISO, isToday, isTomorrow, startOfDay } from 'date-fns';
import { format, parseISO, isToday, isTomorrow, startOfDay, addMonths } from 'date-fns';
import { de } from 'date-fns/locale';
import { toDate } from '$lib/utils/eventDateHelpers';
import type { CalendarEvent, CreateEventInput } from '@calendar/shared';
import type { CalendarEvent, Calendar, CreateEventInput } from '@calendar/shared';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { _ } from 'svelte-i18n';
@ -19,16 +24,26 @@
let { date, onEventClick }: Props = $props();
// Get calendars and events from layout context (live queries)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('events');
let visibleCalendars = $derived(getVisibleCalendars(calendarsCtx.value));
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
// Expand recurring events for agenda range (3 months ahead)
let rangeEvents = $derived(
getEventsForRange(eventsCtx.value, effectiveDate, addMonths(effectiveDate, 3))
);
// Group events by date
let groupedEvents = $derived.by(() => {
const currentEvents = eventsStore.events ?? [];
const currentEvents = rangeEvents ?? [];
if (!Array.isArray(currentEvents)) return [];
// Filter by visible calendars
const visibleCalendarIds = new Set(calendarsStore.visibleCalendars.map((c) => c.id));
const visibleCalendarIds = new Set(visibleCalendars.map((c) => c.id));
// Filter events that start from current date onwards
const startDate = startOfDay(effectiveDate);
@ -176,7 +191,10 @@
>
<div
class="color-bar"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
></div>
<div class="event-content">
<div class="event-time">

View file

@ -1,12 +1,17 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { externalCalendarsStore } from '$lib/stores/external-calendars.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import type { Calendar } from '@calendar/shared';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
function handleToggle(calendarId: string) {
calendarsStore.toggleVisibility(calendarId);
calendarsStore.toggleVisibility(calendarId, calendarsCtx.value);
}
function handleExternalToggle(id: string, currentVisible: boolean) {
@ -35,7 +40,7 @@
</div>
<div class="calendar-list" role="group" aria-label="Kalender Sichtbarkeit">
{#each calendarsStore.calendars as calendar}
{#each calendarsCtx.value as calendar}
<label class="calendar-item">
<input
type="checkbox"
@ -50,7 +55,7 @@
</label>
{/each}
{#if calendarsStore.calendars.length === 0}
{#if calendarsCtx.value.length === 0}
<p class="empty-message">Keine Kalender vorhanden</p>
{/if}
</div>

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { getContext } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getEventsForDay as getEventsForDayPure } from '$lib/data/queries';
import type { CalendarEvent } from '@calendar/shared';
import {
format,
isToday,
@ -21,9 +23,12 @@
let { isToolbarExpanded = false }: Props = $props();
// Get events from layout context (live query)
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('events');
// Get event count for a day (max 5 dots displayed)
function getEventCount(date: Date): number {
const events = eventsStore.getEventsForDay(date, false);
const events = getEventsForDayPure(eventsCtx.value, date);
return Math.min(events.length, 5); // Cap at 5 dots
}

View file

@ -1,8 +1,15 @@
<script lang="ts">
import { getContext } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import {
getVisibleCalendars,
getCalendarColorWithBirthdays,
getEventsForDay as getEventsForDayPure,
getEventsForRange,
} from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
@ -43,6 +50,11 @@
let { date, onQuickCreate, onEventClick }: Props = $props();
// Get calendars and events from layout context (live queries)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('events');
let visibleCalendars = $derived(getVisibleCalendars(calendarsCtx.value));
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
@ -56,6 +68,16 @@
return eachDayOfInterval({ start: calendarStart, end: calendarEnd });
});
// Expand recurring events for the month grid range
let rangeEvents = $derived.by(() => {
if (allCalendarDays.length === 0) return [];
return getEventsForRange(
eventsCtx.value,
allCalendarDays[0],
allCalendarDays[allCalendarDays.length - 1]
);
});
// Filter weekends if option is active
let calendarDays = $derived(
settingsStore.showOnlyWeekdays
@ -200,19 +222,15 @@
// Event Handlers
// ============================================================================
function getEventsForDay(day: Date): CalendarEvent[] {
let events = filterByVisibleCalendars(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
const dayEvents = getEventsForDayPure(rangeEvents, day);
let events = filterByVisibleCalendars(dayEvents, visibleCalendars);
events = filterByTags(events, settingsStore.selectedTagIds);
return events.slice(0, 3); // Max 3 events shown
}
function getAllEventsForDay(day: Date): CalendarEvent[] {
let events = filterByVisibleCalendars(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
const dayEvents = getEventsForDayPure(rangeEvents, day);
let events = filterByVisibleCalendars(dayEvents, visibleCalendars);
return filterByTags(events, settingsStore.selectedTagIds);
}
@ -328,7 +346,10 @@
class:search-highlighted={isSearchHighlighted}
class:search-dimmed={isSearchDimmed}
data-event-id={event.id}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
onpointerdown={(e) => startDrag(event, e)}
onclick={(e) => !isDraft && handleEventClick(event, e)}
role="button"

View file

@ -1,7 +1,12 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import type { Calendar } from '@calendar/shared';
import { goto } from '$app/navigation';
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Portal action - moves element to body to escape stacking contexts
function portal(node: HTMLElement) {
document.body.appendChild(node);
@ -46,7 +51,7 @@
}
function handleToggle(calendarId: string) {
calendarsStore.toggleVisibility(calendarId);
calendarsStore.toggleVisibility(calendarId, calendarsCtx.value);
}
function handleAddCalendar() {
@ -55,8 +60,8 @@
}
// Count visible calendars
let visibleCount = $derived(calendarsStore.calendars.filter((c) => c.isVisible).length);
let totalCount = $derived(calendarsStore.calendars.length);
let visibleCount = $derived(calendarsCtx.value.filter((c) => c.isVisible).length);
let totalCount = $derived(calendarsCtx.value.length);
</script>
<div class="pill-calendar-selector">
@ -123,7 +128,7 @@
</div>
<div class="calendar-list">
{#each calendarsStore.calendars as calendar}
{#each calendarsCtx.value as calendar}
<label class="calendar-item">
<input
type="checkbox"
@ -136,7 +141,7 @@
</label>
{/each}
{#if calendarsStore.calendars.length === 0}
{#if calendarsCtx.value.length === 0}
<p class="empty-message">Keine Kalender</p>
{/if}
</div>

View file

@ -1,9 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import {
getVisibleCalendars,
getDefaultCalendar,
getCalendarColorWithBirthdays,
getEventsForDay as getEventsForDayPure,
getEventsForRange,
} from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { searchStore } from '$lib/stores/search.svelte';
import { todosStore, type Task } from '$lib/stores/todos.svelte';
import { birthdaysStore, type BirthdayEvent } from '$lib/stores/birthdays.svelte';
@ -56,6 +63,13 @@
let { date, onQuickCreate, onEventClick, onTaskClick }: Props = $props();
// Get calendars and events from layout context (live queries)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const eventsCtx: { readonly value: CalendarEvent[] } = getContext('events');
// Derived: visible calendars and events for current range
let visibleCalendars = $derived(getVisibleCalendars(calendarsCtx.value));
// Use provided date or fall back to viewStore
let effectiveDate = $derived(date ?? viewStore.currentDate);
@ -73,6 +87,11 @@
return viewStore.viewRange;
});
// Expand recurring events for the current view range
let rangeEvents = $derived(
getEventsForRange(eventsCtx.value, effectiveViewRange.start, effectiveViewRange.end)
);
// Use shared constants
const HOUR_HEIGHT = HOUR_HEIGHT_PX;
const MINUTES_PER_SLOT = SNAP_INTERVAL_MINUTES;
@ -218,34 +237,37 @@
);
function getEventsForDay(day: Date): CalendarEvent[] {
return getVisibleTimedEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
{
filterHoursEnabled: settingsStore.filterHoursEnabled,
dayStartHour: settingsStore.dayStartHour,
dayEndHour: settingsStore.dayEndHour,
const dayEvents = getEventsForDayPure(rangeEvents, day);
// Include draft event
if (eventsStore.draftEvent) {
const draftStart = new Date(eventsStore.draftEvent.startTime);
if (isSameDay(day, draftStart)) {
dayEvents.push(eventsStore.draftEvent);
}
);
}
return getVisibleTimedEvents(dayEvents, visibleCalendars, {
filterHoursEnabled: settingsStore.filterHoursEnabled,
dayStartHour: settingsStore.dayStartHour,
dayEndHour: settingsStore.dayEndHour,
});
}
function getOverflowEventsForDay(day: Date): OverflowEvents {
if (!settingsStore.filterHoursEnabled) {
return { before: [], after: [] };
}
const dayEvents = getEventsForDayPure(rangeEvents, day);
return getVisibleOverflowEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars,
dayEvents,
visibleCalendars,
settingsStore.dayStartHour,
settingsStore.dayEndHour
);
}
function getAllDayEventsForDay(day: Date): CalendarEvent[] {
return getVisibleAllDayEvents(
eventsStore.getEventsForDay(day),
calendarsStore.visibleCalendars
);
const dayEvents = getEventsForDayPure(rangeEvents, day);
return getVisibleAllDayEvents(dayEvents, visibleCalendars);
}
// Get display mode for an event (per-event override takes precedence over global setting)
@ -438,7 +460,10 @@
class="all-day-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
onclick={() => goto(`/?event=${event.id}`)}
aria-label="{event.title} - {$_('views.allDay')}"
>
@ -513,7 +538,10 @@
class="all-day-block-event"
class:search-highlighted={searchStore.isEventHighlighted(event.id)}
class:search-dimmed={searchStore.isEventDimmed(event.id)}
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
onclick={() => goto(`/?event=${event.id}`)}
aria-label="{event.title} - {$_('views.allDay')}"
>
@ -538,7 +566,7 @@
: isBeingResized
? `top: ${eventDragDrop.resizePreviewTop}%; height: ${eventDragDrop.resizePreviewHeight}%;`
: getEventStyle(event)}
color={calendarsStore.getColor(event.calendarId)}
color={getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId)}
isDragging={isBeingDragged && !isCrossDayDrag}
isDraggingSource={isCrossDayDrag}
isResizing={isBeingResized}
@ -596,7 +624,10 @@
<EventCard
event={eventDragDrop.draggedEvent}
style="top: {eventDragDrop.dragPreviewTop}%; height: {eventDragDrop.dragPreviewHeight}%;"
color={calendarsStore.getColor(eventDragDrop.draggedEvent.calendarId)}
color={getCalendarColorWithBirthdays(
calendarsCtx.value,
eventDragDrop.draggedEvent.calendarId
)}
isDragging={true}
formattedTime={formatEventTimeRange(eventDragDrop.draggedEvent)}
/>
@ -606,8 +637,9 @@
{#if dragToCreate.isCreating && dragToCreate.createTargetDay && isSameDay(day, dragToCreate.createTargetDay)}
<div
class="create-preview"
style="top: {dragToCreate.createPreviewTop}%; height: {dragToCreate.createPreviewHeight}%; background-color: {calendarsStore.getColor(
calendarsStore.defaultCalendar?.id || ''
style="top: {dragToCreate.createPreviewTop}%; height: {dragToCreate.createPreviewHeight}%; background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
getDefaultCalendar(calendarsCtx.value)?.id || ''
)};"
>
<span class="event-time">{dragToCreate.getCreatePreviewTime()}</span>
@ -623,7 +655,10 @@
{#each overflow.before as event}
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
title="{formatEventTime(event.startTime)} {event.title}"
></div>
{/each}
@ -637,7 +672,10 @@
{#each overflow.after as event}
<div
class="overflow-line"
style="background-color: {calendarsStore.getColor(event.calendarId)}"
style="background-color: {getCalendarColorWithBirthdays(
calendarsCtx.value,
event.calendarId
)}"
title="{formatEventTime(event.startTime)} {event.title}"
></div>
{/each}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { getContext } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getCalendarById, getCalendarColorWithBirthdays } from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import EventForm from './EventForm.svelte';
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
import ReminderSelector from './ReminderSelector.svelte';
@ -22,6 +24,9 @@
let { eventId, onClose }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
let event = $state<CalendarEvent | null>(null);
let loading = $state(true);
let isEditing = $state(false);
@ -141,9 +146,11 @@
// Get calendar info for the event
let calendarName = $derived(
event ? calendarsStore.calendars.find((c) => c.id === event!.calendarId)?.name : undefined
event ? getCalendarById(calendarsCtx.value, event!.calendarId)?.name : undefined
);
let calendarColor = $derived(
event ? getCalendarColorWithBirthdays(calendarsCtx.value, event.calendarId) : '#3b82f6'
);
let calendarColor = $derived(event ? calendarsStore.getColor(event.calendarId) : '#3b82f6');
// Format recurrence rule to human readable text
function formatRecurrence(rule: string): string {

View file

@ -1,10 +1,12 @@
<script lang="ts">
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getDefaultCalendar } from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import type { Tag as SharedTag } from '@manacore/shared-tags';
// Live tags from layout context
// Live data from layout context
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
const tagsCtx: { readonly value: SharedTag[] } = getContext('tags');
import {
TagSelector,
@ -112,7 +114,7 @@
// Calendar options for FilterDropdown
let calendarOptions = $derived<FilterDropdownOption[]>(
calendarsStore.calendars.map((cal) => ({ value: cal.id, label: cal.name }))
calendarsCtx.value.map((cal) => ({ value: cal.id, label: cal.name }))
);
// All-day display mode options
@ -134,8 +136,9 @@
// Set default calendar when calendars are loaded
$effect(() => {
if (!calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
const defaultCal = getDefaultCalendar(calendarsCtx.value);
if (!calendarId && defaultCal?.id) {
calendarId = defaultCal.id;
}
});
@ -273,7 +276,7 @@
<div class="flex flex-col gap-2">
<span class="text-sm font-medium text-foreground">Kalender</span>
{#if calendarsStore.calendars.length > 0}
{#if calendarsCtx.value.length > 0}
<FilterDropdown
options={calendarOptions}
value={calendarId}

View file

@ -1,7 +1,9 @@
<script lang="ts">
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getContext } from 'svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { getDefaultCalendar, getCalendarColorWithBirthdays } from '$lib/data/queries';
import type { Calendar } from '@calendar/shared';
import { contactsStore } from '$lib/stores/contacts.svelte';
import RecurrenceEditDialog from './RecurrenceEditDialog.svelte';
import RecurrenceSelector from './RecurrenceSelector.svelte';
@ -47,6 +49,9 @@
let { startTime, event, onClose, onCreated, onUpdated, onDeleted }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Mode: create or edit
let isEditMode = $derived(!!event);
@ -323,8 +328,9 @@
// Set default calendar - only in create mode
$effect(() => {
if (!isEditMode && !calendarId && calendarsStore.defaultCalendar?.id) {
calendarId = calendarsStore.defaultCalendar.id;
const defaultCal = getDefaultCalendar(calendarsCtx.value);
if (!isEditMode && !calendarId && defaultCal?.id) {
calendarId = defaultCal.id;
// Update draft event with calendar
eventsStore.updateDraftEvent({ calendarId });
}
@ -600,10 +606,7 @@
toast.error(`Fehler beim Erstellen: ${result.error.message}`);
return;
}
// Refresh calendars if none existed (in case default was created)
if (calendarsStore.calendars.length === 0) {
await calendarsStore.fetchCalendars();
}
// Calendars auto-refresh via live query — no manual fetch needed
toast.success('Termin erstellt');
onCreated?.();
}
@ -767,9 +770,9 @@
<!-- Calendar pills -->
<div class="calendar-pills-container">
{#if calendarsStore.calendars.length > 0}
{#if calendarsCtx.value.length > 0}
<div class="calendar-pills-scroll">
{#each calendarsStore.calendars as cal}
{#each calendarsCtx.value as cal}
<button
type="button"
class="calendar-pill"

View file

@ -5,7 +5,9 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { TimeFormat, AllDayDisplayMode, SttLanguage } from '$lib/stores/settings.svelte';
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getDefaultCalendar } from '$lib/data/queries';
import {
toastStore as toast,
GlobalSettingsSection,
@ -25,6 +27,9 @@
let { visible, onClose }: Props = $props();
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Calendar management state
let editingCalendar = $state<Calendar | null>(null);
let editName = $state('');
@ -87,7 +92,10 @@
// If setting as default and it wasn't before, use setAsDefault
if (editIsDefault && !editingCalendar.isDefault) {
const defaultResult = await calendarsStore.setAsDefault(editingCalendar.id);
const defaultResult = await calendarsStore.setAsDefault(
editingCalendar.id,
calendarsCtx.value
);
if (defaultResult?.error) {
toast.error(`Fehler: ${defaultResult.error.message}`);
return;
@ -295,7 +303,7 @@
{/if}
<div class="calendar-list">
{#each calendarsStore.calendars as calendar}
{#each calendarsCtx.value as calendar}
{#if editingCalendar?.id === calendar.id}
<div class="calendar-edit-form">
<form
@ -380,7 +388,7 @@
{/if}
{/each}
{#if calendarsStore.calendars.length === 0}
{#if calendarsCtx.value.length === 0}
<div class="empty-state">
<p>Keine Kalender vorhanden</p>
</div>

View file

@ -0,0 +1,220 @@
/**
* Reactive Queries & Pure Filter Helpers for Calendar
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
calendarCollection,
eventCollection,
type LocalCalendar,
type LocalEvent,
} from './local-store';
import type { Calendar, CalendarEvent } from '@calendar/shared';
import { parseRRule, generateOccurrences } from '@calendar/shared';
import { isSameDay, isWithinInterval, differenceInMilliseconds, format } from 'date-fns';
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalCalendar (IndexedDB) to the shared Calendar type. */
export function toCalendar(local: LocalCalendar): Calendar {
return {
id: local.id,
userId: 'guest',
name: local.name,
color: local.color,
isDefault: local.isDefault,
isVisible: local.isVisible,
timezone: local.timezone,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalEvent (IndexedDB) to the shared CalendarEvent type. */
export function toCalendarEvent(local: LocalEvent): CalendarEvent {
return {
id: local.id,
calendarId: local.calendarId,
userId: 'guest',
title: local.title,
description: local.description ?? null,
location: local.location ?? null,
startTime: local.startDate,
endTime: local.endDate,
isAllDay: local.allDay,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
recurrenceRule: local.recurrenceRule ?? null,
recurrenceEndDate: null,
recurrenceExceptions: null,
parentEventId: null,
color: local.color ?? null,
status: 'confirmed',
externalId: null,
metadata: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All calendars. Auto-updates on any change. */
export function useAllCalendars() {
return useLiveQueryWithDefault(async () => {
const locals = await calendarCollection.getAll();
return locals.map(toCalendar);
}, [] as Calendar[]);
}
/** All events. Auto-updates on any change. */
export function useAllEvents() {
return useLiveQueryWithDefault(async () => {
const locals = await eventCollection.getAll();
return locals.map(toCalendarEvent);
}, [] as CalendarEvent[]);
}
// ─── Pure Calendar Helpers ─────────────────────────────────
/** Get visible calendars (where isVisible is true). */
export function getVisibleCalendars(calendars: Calendar[]): Calendar[] {
return calendars.filter((c) => c.isVisible);
}
/** Get the default calendar, falling back to the first calendar. */
export function getDefaultCalendar(calendars: Calendar[]): Calendar | null {
return calendars.find((c) => c.isDefault) || calendars[0] || null;
}
/** Get a calendar by ID. */
export function getCalendarById(calendars: Calendar[], id: string): Calendar | undefined {
return calendars.find((c) => c.id === id);
}
/** Get a calendar's color by ID, with fallback. */
export function getCalendarColor(calendars: Calendar[], id: string): string {
const calendar = calendars.find((c) => c.id === id);
return calendar?.color || '#3b82f6';
}
/** Get a calendar's color by ID, with birthday calendar support and fallback. */
export function getCalendarColorWithBirthdays(calendars: Calendar[], id: string): string {
if (id === BIRTHDAY_CALENDAR.id) {
return BIRTHDAY_CALENDAR.color;
}
return getCalendarColor(calendars, id);
}
// ─── Pure Event Helpers ────────────────────────────────────
/** Get an event by ID. */
export function getEventById(events: CalendarEvent[], id: string): CalendarEvent | undefined {
return events.find((e) => e.id === id);
}
/** Convert a date string or Date to a Date. */
function toDate(dateStr: string | Date): Date {
return typeof dateStr === 'string' ? new Date(dateStr) : dateStr;
}
/**
* Expand recurring events into individual occurrences for a given range.
* Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}`
*/
export function expandRecurringEvents(
rawEvents: CalendarEvent[],
rangeStart: Date,
rangeEnd: Date
): CalendarEvent[] {
const result: CalendarEvent[] = [];
for (const event of rawEvents) {
if (!event.recurrenceRule) {
result.push(event);
continue;
}
const pattern = parseRRule(event.recurrenceRule);
if (!pattern) {
result.push(event);
continue;
}
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
const durationMs = differenceInMilliseconds(eventEnd, eventStart);
const exceptions = (event.recurrenceExceptions as string[]) || [];
const occurrences = generateOccurrences(eventStart, pattern, rangeStart, rangeEnd, exceptions);
for (const occurrenceDate of occurrences) {
const occEnd = new Date(occurrenceDate.getTime() + durationMs);
const dateKey = format(occurrenceDate, 'yyyy-MM-dd');
result.push({
...event,
id: `${event.id}__recurrence__${dateKey}`,
parentEventId: event.id,
startTime: occurrenceDate.toISOString(),
endTime: occEnd.toISOString(),
});
}
}
return result;
}
/**
* Get events for a specific date range, including recurrence expansion.
*/
export function getEventsForRange(
allEvents: CalendarEvent[],
rangeStart: Date,
rangeEnd: Date
): CalendarEvent[] {
// Filter to events that overlap the range
const inRange = allEvents.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
return eventStart <= rangeEnd && eventEnd >= rangeStart;
});
// Also include recurring events that might generate occurrences in range
const recurring = allEvents.filter((event) => event.recurrenceRule && !inRange.includes(event));
return expandRecurringEvents([...inRange, ...recurring], rangeStart, rangeEnd);
}
/**
* Get events for a specific day (pure helper, no draft support).
*/
export function getEventsForDay(events: CalendarEvent[], date: Date): CalendarEvent[] {
return events.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
if (event.isAllDay) {
return (
isWithinInterval(date, { start: eventStart, end: eventEnd }) || isSameDay(date, eventStart)
);
}
return isSameDay(date, eventStart);
});
}
/**
* Get events within a time range.
*/
export function getEventsInRange(events: CalendarEvent[], start: Date, end: Date): CalendarEvent[] {
return events.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
return eventStart <= end && eventEnd >= start;
});
}

View file

@ -1,8 +1,8 @@
/**
* Calendars Store Local-First with IndexedDB
* Calendars Store Mutations Only
*
* All reads and writes go to IndexedDB first.
* Same public API as before so components don't break.
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB.
*/
import type { Calendar, CreateCalendarInput, UpdateCalendarInput } from '@calendar/shared';
@ -10,105 +10,20 @@ import { calendarCollection, type LocalCalendar } from '$lib/data/local-store';
import { BIRTHDAY_CALENDAR } from '$lib/api/birthdays';
import { settingsStore } from './settings.svelte';
import { CalendarEvents } from '@manacore/shared-utils/analytics';
import { toCalendar } from '$lib/data/queries';
// State
let calendars = $state<Calendar[]>([]);
let loading = $state(false);
// Mutation error state
let error = $state<string | null>(null);
// Virtual birthday calendar (created dynamically based on settings)
const birthdayCalendar: Calendar = {
id: BIRTHDAY_CALENDAR.id,
userId: '',
name: BIRTHDAY_CALENDAR.name,
color: BIRTHDAY_CALENDAR.color,
isDefault: false,
isVisible: true,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
createdAt: new Date().toISOString(),
updatedAt: new Date().toISOString(),
};
/** Convert a LocalCalendar (IndexedDB) to the shared Calendar type. */
function toCalendar(local: LocalCalendar): Calendar {
return {
id: local.id,
userId: 'guest',
name: local.name,
color: local.color,
isDefault: local.isDefault,
isVisible: local.isVisible,
timezone: local.timezone,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// Helper to safely get calendars array (Svelte 5 runes safety)
function getCalendarsArray(): Calendar[] {
const arr = calendars ?? [];
return Array.isArray(arr) ? arr : [];
}
// Derived: all calendars including virtual birthday calendar
const allCalendars = $derived.by(() => {
const userCalendars = getCalendarsArray();
if (settingsStore.showBirthdays) {
return [...userCalendars, { ...birthdayCalendar, isVisible: true }];
}
return userCalendars;
});
// Derived: visible calendars
const visibleCalendars = $derived(getCalendarsArray().filter((c) => c.isVisible));
// Derived: default calendar
const defaultCalendar = $derived.by(() => {
const arr = getCalendarsArray();
return arr.find((c) => c.isDefault) || arr[0] || null;
});
export const calendarsStore = {
// Getters
get calendars() {
return calendars;
},
get allCalendars() {
return allCalendars;
},
get visibleCalendars() {
return visibleCalendars;
},
get defaultCalendar() {
return defaultCalendar;
},
get loading() {
return loading;
},
get error() {
return error;
},
get birthdayCalendarId() {
return BIRTHDAY_CALENDAR.id;
},
/**
* Load calendars from IndexedDB.
*/
async fetchCalendars() {
loading = true;
error = null;
try {
const localCalendars = await calendarCollection.getAll();
calendars = localCalendars.map(toCalendar);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch calendars';
console.error('Failed to fetch calendars:', e);
calendars = [];
} finally {
loading = false;
}
return { data: { calendars }, error: null };
get guestCalendarId() {
return 'personal-calendar';
},
/**
@ -128,7 +43,6 @@ export const calendarsStore = {
const inserted = await calendarCollection.insert(newLocal);
const newCalendar = toCalendar(inserted);
calendars = [...calendars, newCalendar];
CalendarEvents.calendarCreated();
return { data: newCalendar, error: null };
} catch (e) {
@ -147,7 +61,6 @@ export const calendarsStore = {
const updated = await calendarCollection.update(id, data as Partial<LocalCalendar>);
if (updated) {
const updatedCalendar = toCalendar(updated);
calendars = getCalendarsArray().map((c) => (c.id === id ? updatedCalendar : c));
return { data: updatedCalendar, error: null };
}
return { data: null, error: null };
@ -165,7 +78,6 @@ export const calendarsStore = {
error = null;
try {
await calendarCollection.delete(id);
calendars = getCalendarsArray().filter((c) => c.id !== id);
CalendarEvents.calendarDeleted();
return { error: null };
} catch (e) {
@ -176,24 +88,22 @@ export const calendarsStore = {
},
/**
* Toggle calendar visibility
* Toggle calendar visibility (needs current calendars from context)
*/
async toggleVisibility(id: string) {
const arr = getCalendarsArray();
const calendar = arr.find((c) => c.id === id);
async toggleVisibility(id: string, calendars: Calendar[]) {
const calendar = calendars.find((c) => c.id === id);
if (!calendar) return;
return this.updateCalendar(id, { isVisible: !calendar.isVisible });
},
/**
* Set a calendar as the default
* Set a calendar as the default (needs current calendars from context)
*/
async setAsDefault(id: string) {
async setAsDefault(id: string, calendars: Calendar[]) {
error = null;
try {
// Remove default from all others first
for (const cal of getCalendarsArray()) {
for (const cal of calendars) {
if (cal.isDefault && cal.id !== id) {
await calendarCollection.update(cal.id, { isDefault: false } as Partial<LocalCalendar>);
}
@ -202,12 +112,6 @@ export const calendarsStore = {
const updated = await calendarCollection.update(id, {
isDefault: true,
} as Partial<LocalCalendar>);
if (updated) {
calendars = getCalendarsArray().map((c) => ({
...c,
isDefault: c.id === id,
}));
}
return { data: updated ? toCalendar(updated) : null, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to set default';
@ -216,24 +120,6 @@ export const calendarsStore = {
}
},
/**
* Get calendar by ID
*/
getById(id: string) {
return getCalendarsArray().find((c) => c.id === id);
},
/**
* Get calendar color by ID (with fallback)
*/
getColor(id: string) {
if (id === BIRTHDAY_CALENDAR.id) {
return BIRTHDAY_CALENDAR.color;
}
const calendar = getCalendarsArray().find((c) => c.id === id);
return calendar?.color || '#3b82f6';
},
/**
* Toggle birthday calendar visibility
*/
@ -254,17 +140,4 @@ export const calendarsStore = {
isGuestCalendar(id: string) {
return id === 'personal-calendar';
},
/**
* Get the guest calendar ID
*/
get guestCalendarId() {
return 'personal-calendar';
},
clear() {
calendars = [];
loading = false;
error = null;
},
};

View file

@ -1,110 +1,25 @@
/**
* Events Store Local-First with IndexedDB
* Events Store Mutations Only
*
* All reads and writes go to IndexedDB first.
* Same public API as before so components don't break.
* Reads come from useLiveQuery (see $lib/data/queries.ts).
* This store only handles writes to IndexedDB and draft event state.
*/
import type { CalendarEvent, CreateEventInput, UpdateEventInput } from '@calendar/shared';
import { parseRRule, generateOccurrences } from '@calendar/shared';
import { eventCollection, type LocalEvent } from '$lib/data/local-store';
import { format, isWithinInterval, isSameDay, differenceInMilliseconds } from 'date-fns';
import { toDate } from '$lib/utils/eventDateHelpers';
import { toastStore } from '@manacore/shared-ui';
import { CalendarEvents } from '@manacore/shared-utils/analytics';
import { get } from 'svelte/store';
import { _ } from 'svelte-i18n';
import { toCalendarEvent } from '$lib/data/queries';
// State
let events = $state<CalendarEvent[]>([]);
let loading = $state(false);
// Mutation error state
let error = $state<string | null>(null);
let loadedRange = $state<{ start: Date; end: Date } | null>(null);
// Draft event for quick create (temporary event shown in grid before saving)
let draftEvent = $state<CalendarEvent | null>(null);
/** Convert a LocalEvent (IndexedDB) to the shared CalendarEvent type. */
function toCalendarEvent(local: LocalEvent): CalendarEvent {
return {
id: local.id,
calendarId: local.calendarId,
userId: 'guest',
title: local.title,
description: local.description ?? null,
location: local.location ?? null,
startTime: local.startDate,
endTime: local.endDate,
isAllDay: local.allDay,
timezone: Intl.DateTimeFormat().resolvedOptions().timeZone,
recurrenceRule: local.recurrenceRule ?? null,
recurrenceEndDate: null,
recurrenceExceptions: null,
parentEventId: null,
color: local.color ?? null,
status: 'confirmed',
externalId: null,
metadata: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/**
* Expand recurring events into individual occurrences for the current view range.
* Each occurrence gets a synthetic ID: `{parentId}__recurrence__{dateISO}`
*/
function expandRecurringEvents(
rawEvents: CalendarEvent[],
rangeStart: Date,
rangeEnd: Date
): CalendarEvent[] {
const result: CalendarEvent[] = [];
for (const event of rawEvents) {
if (!event.recurrenceRule) {
result.push(event);
continue;
}
const pattern = parseRRule(event.recurrenceRule);
if (!pattern) {
result.push(event);
continue;
}
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
const durationMs = differenceInMilliseconds(eventEnd, eventStart);
const exceptions = (event.recurrenceExceptions as string[]) || [];
const occurrences = generateOccurrences(eventStart, pattern, rangeStart, rangeEnd, exceptions);
for (const occurrenceDate of occurrences) {
const occEnd = new Date(occurrenceDate.getTime() + durationMs);
const dateKey = format(occurrenceDate, 'yyyy-MM-dd');
result.push({
...event,
id: `${event.id}__recurrence__${dateKey}`,
parentEventId: event.id,
startTime: occurrenceDate.toISOString(),
endTime: occEnd.toISOString(),
});
}
}
return result;
}
export const eventsStore = {
// Getters - always return safe values
get events() {
return events ?? [];
},
get loading() {
return loading;
},
get error() {
return error;
},
@ -112,90 +27,6 @@ export const eventsStore = {
return draftEvent;
},
/**
* Fetch events for a date range reads from IndexedDB.
*/
async fetchEvents(startDate: Date, endDate: Date, calendarIds?: string[]) {
loading = true;
error = null;
try {
const allEvents = await eventCollection.getAll();
let mapped = allEvents.map(toCalendarEvent);
// Filter by date range
const rangeStart = startDate;
const rangeEnd = endDate;
mapped = mapped.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
return eventStart <= rangeEnd && eventEnd >= rangeStart;
});
// Filter by calendar IDs if provided
if (calendarIds && calendarIds.length > 0) {
mapped = mapped.filter((e) => calendarIds.includes(e.calendarId));
}
// Expand recurring events
events = expandRecurringEvents(mapped, startDate, endDate);
loadedRange = { start: startDate, end: endDate };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Failed to fetch events';
error = msg;
toastStore.error(get(_)('toast.eventLoadError') + ': ' + msg);
} finally {
loading = false;
}
return { data: events, error: error ? { message: error } : null };
},
/**
* Get events for a specific day (including draft event)
*/
getEventsForDay(date: Date, includeDraft = true) {
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return [];
const result = currentEvents.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
if (event.isAllDay) {
return (
isWithinInterval(date, { start: eventStart, end: eventEnd }) ||
isSameDay(date, eventStart)
);
}
return isSameDay(date, eventStart);
});
if (includeDraft && draftEvent) {
const draftStart = toDate(draftEvent.startTime);
if (isSameDay(date, draftStart)) {
result.push(draftEvent);
}
}
return result;
},
/**
* Get events within a time range
*/
getEventsInRange(start: Date, end: Date) {
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return [];
return currentEvents.filter((event) => {
const eventStart = toDate(event.startTime);
const eventEnd = toDate(event.endTime);
return eventStart <= end && eventEnd >= start;
});
},
/**
* Create a new event writes to IndexedDB instantly.
*/
@ -222,7 +53,6 @@ export const eventsStore = {
const inserted = await eventCollection.insert(newLocal);
const newEvent = toCalendarEvent(inserted);
events = [...events, newEvent];
CalendarEvents.eventCreated(!!data.recurrenceRule);
return { data: newEvent, error: null };
} catch (e) {
@ -259,7 +89,6 @@ export const eventsStore = {
const updated = await eventCollection.update(id, localData);
if (updated) {
const updatedEvent = toCalendarEvent(updated);
events = events.map((e) => (e.id === id ? updatedEvent : e));
CalendarEvents.eventUpdated();
return { data: updatedEvent, error: null };
}
@ -273,23 +102,16 @@ export const eventsStore = {
},
/**
* Delete an event removes from IndexedDB instantly (optimistic).
* Delete an event removes from IndexedDB instantly.
*/
async deleteEvent(id: string) {
error = null;
const eventToDelete = events.find((e) => e.id === id);
events = events.filter((e) => e.id !== id);
try {
await eventCollection.delete(id);
CalendarEvents.eventDeleted();
toastStore.success(get(_)('toast.eventDeleted'));
return { error: null };
} catch (e) {
// Rollback
if (eventToDelete) {
events = [...events, eventToDelete];
}
const msg = e instanceof Error ? e.message : 'Failed to delete event';
error = msg;
toastStore.error(get(_)('toast.eventDeleteError') + ': ' + msg);
@ -297,23 +119,6 @@ export const eventsStore = {
}
},
/**
* Get event by ID
*/
getById(id: string) {
const currentEvents = events ?? [];
if (!Array.isArray(currentEvents)) return undefined;
return currentEvents.find((e) => e.id === id);
},
/**
* Clear events cache
*/
clear() {
events = [];
loadedRange = null;
},
// ========== Draft Event Methods ==========
createDraftEvent(data: Partial<CalendarEvent>) {
@ -371,35 +176,14 @@ export const eventsStore = {
* Delete a single occurrence of a recurring event by adding an exception date
*/
async deleteRecurrenceOccurrence(eventId: string) {
const parentId = this.getParentEventId(eventId);
const dateKey = eventId.split('__recurrence__')[1];
const parent = events.find(
(e) => e.id === parentId || this.getParentEventId(e.id) === parentId
);
if (!parent) return { error: { message: 'Event not found' } };
const realParentId = this.getParentEventId(parent.id);
const existingExceptions = (parent.recurrenceExceptions as string[]) || [];
const updatedExceptions = [...existingExceptions, dateKey];
// Optimistic: remove this occurrence from local state
events = events.filter((e) => e.id !== eventId);
// For local-first, we would ideally store exceptions in IndexedDB.
// For now, toast success (the event structure doesn't support exceptions at local level yet).
try {
// Update the parent event's recurrenceExceptions in IndexedDB
// Note: recurrenceExceptions are not in LocalEvent, so we store on the shared type level.
// For local-first, we refetch to rebuild occurrences.
if (loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
toastStore.success(get(_)('toast.eventDeleted'));
return { error: null };
} catch (e) {
// Refetch to restore state
if (loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
const msg = e instanceof Error ? e.message : 'Failed to delete occurrence';
toastStore.error(get(_)('toast.error') + ': ' + msg);
return { error: { message: msg } };
@ -419,12 +203,6 @@ export const eventsStore = {
*/
async updateRecurrenceSeries(eventId: string, data: UpdateEventInput) {
const parentId = this.getParentEventId(eventId);
const result = await this.updateEvent(parentId, data);
if (!result.error && loadedRange) {
await this.fetchEvents(loadedRange.start, loadedRange.end);
}
return result;
return this.updateEvent(parentId, data);
},
};

View file

@ -28,6 +28,7 @@
tagMutations,
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
import { useAllCalendars, useAllEvents, getDefaultCalendar } from '$lib/data/queries';
import { settingsStore } from '$lib/stores/settings.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { browser } from '$app/environment';
@ -77,8 +78,14 @@
splitPanel.openPanel(appId);
}
// Live tag query + context
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allCalendars = useAllCalendars();
const allEvents = useAllEvents();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('calendars', allCalendars);
setContext('events', allEvents);
setContext('tags', allTags);
let { children } = $props();
@ -132,12 +139,12 @@
const parsed = parseEventInput(query);
if (!parsed.title) return;
const defaultCalendarId =
calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id;
const cals = allCalendars.value;
const defaultCalendarId = cals.find((c) => c.isDefault)?.id || cals[0]?.id;
const resolved = resolveEventIds(
parsed,
calendarsStore.calendars.map((c) => ({ id: c.id, name: c.name })),
cals.map((c) => ({ id: c.id, name: c.name })),
allTags.value.map((t) => ({ id: t.id, name: t.name })),
defaultCalendarId
);
@ -194,18 +201,16 @@
}
// Default calendar for InputBar quick create
let selectedDefaultCalendarId = $derived(
calendarsStore.calendars.find((c) => c.isDefault)?.id || calendarsStore.calendars[0]?.id
);
let selectedDefaultCalendarId = $derived(getDefaultCalendar(allCalendars.value)?.id);
function handleDefaultCalendarChange(id: string) {
// Update the default calendar via API
calendarsStore.setAsDefault(id);
calendarsStore.setAsDefault(id, allCalendars.value);
}
// Calendar options for InputBar context menu
let calendarOptions = $derived(
calendarsStore.calendars.map((c) => ({
allCalendars.value.map((c) => ({
id: c.id,
label: c.name,
}))
@ -451,8 +456,7 @@
// Initialize view state
viewStore.initialize();
// Load calendars and events from IndexedDB (works for guests and auth)
await calendarsStore.fetchCalendars();
// Calendars and events are loaded reactively via useLiveQuery (no fetch needed)
// If authenticated, start syncing to server
if (authStore.isAuthenticated) {

View file

@ -1,22 +1,21 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { viewStore } from '$lib/stores/view.svelte';
import { eventsStore } from '$lib/stores/events.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import { todosStore } from '$lib/stores/todos.svelte';
import { birthdaysStore } from '$lib/stores/birthdays.svelte';
import { getDefaultCalendar } from '$lib/data/queries';
import ViewCarousel from '$lib/components/calendar/ViewCarousel.svelte';
import TodoSidebarSection from '$lib/components/calendar/TodoSidebarSection.svelte';
import QuickEventOverlay from '$lib/components/event/QuickEventOverlay.svelte';
import ServiceStatusBanner from '$lib/components/ServiceStatusBanner.svelte';
import { CalendarViewSkeleton } from '$lib/components/skeletons';
import type { CalendarEvent } from '@calendar/shared';
import type { CalendarEvent, Calendar } from '@calendar/shared';
import { addMinutes } from 'date-fns';
import { browser } from '$app/environment';
let initialized = $state(false);
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Quick event overlay state - for both create and edit
let showQuickOverlay = $state(false);
@ -33,7 +32,7 @@
quickCreateDate = date;
// Create draft event immediately so it appears in the grid
const defaultCalendar = calendarsStore.defaultCalendar;
const defaultCalendar = getDefaultCalendar(calendarsCtx.value);
// Use provided endDate or calculate from default duration
const endTime = endDate ?? addMinutes(date, settingsStore.defaultEventDuration);
@ -112,7 +111,7 @@
}
// Get default calendar
const defaultCalendar = calendarsStore.defaultCalendar;
const defaultCalendar = getDefaultCalendar(calendarsCtx.value);
// Create draft event with voice transcription data
eventsStore.createDraftEvent({
@ -137,29 +136,6 @@
return () => window.removeEventListener('voice-event-create', handler);
}
});
// Track view changes to refetch events
let lastViewType = $state(viewStore.viewType);
let lastDateKey = $state(viewStore.currentDate.toDateString());
onMount(async () => {
// Fetch events for current view range (works in both guest and authenticated mode)
await eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
initialized = true;
});
// Refetch events when view type or date changes
$effect(() => {
const currentViewType = viewStore.viewType;
const currentDateKey = viewStore.currentDate.toDateString();
// Only refetch if view actually changed
if (initialized && (currentViewType !== lastViewType || currentDateKey !== lastDateKey)) {
lastViewType = currentViewType;
lastDateKey = currentDateKey;
eventsStore.fetchEvents(viewStore.viewRange.start, viewStore.viewRange.end);
}
});
</script>
<svelte:head>
@ -208,11 +184,7 @@
<!-- Main Calendar Area -->
<div class="calendar-main" class:expanded={settingsStore.sidebarCollapsed}>
<div class="calendar-content">
{#if !initialized}
<CalendarViewSkeleton />
{:else}
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
{/if}
<ViewCarousel onQuickCreate={handleQuickCreate} onEventClick={handleEventClick} />
</div>
</div>

View file

@ -24,7 +24,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -6,7 +6,9 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { settingsStore } from '$lib/stores/settings.svelte';
import type { TimeFormat, AllDayDisplayMode } from '$lib/stores/settings.svelte';
import { getContext } from 'svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getDefaultCalendar } from '$lib/data/queries';
import {
toastStore as toast,
GlobalSettingsSection,
@ -18,6 +20,9 @@
import { APP_VERSION } from '$lib/version';
import type { CalendarViewType, Calendar } from '@calendar/shared';
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Calendar management state
let editingCalendar = $state<Calendar | null>(null);
let editName = $state('');
@ -89,7 +94,10 @@
// If setting as default and it wasn't before, use setAsDefault
if (editIsDefault && !editingCalendar.isDefault) {
const defaultResult = await calendarsStore.setAsDefault(editingCalendar.id);
const defaultResult = await calendarsStore.setAsDefault(
editingCalendar.id,
calendarsCtx.value
);
if (defaultResult?.error) {
toast.error(`${$_('common.error')}: ${defaultResult.error.message}`);
return;
@ -264,7 +272,7 @@
{/if}
<div class="calendar-list">
{#each calendarsStore.calendars as calendar}
{#each calendarsCtx.value as calendar}
{#if editingCalendar?.id === calendar.id}
<div class="calendar-edit-form">
<form
@ -349,7 +357,7 @@
{/if}
{/each}
{#if calendarsStore.calendars.length === 0}
{#if calendarsCtx.value.length === 0}
<div class="empty-state">
<p>{$_('settings.noCalendars')}</p>
</div>

View file

@ -3,7 +3,8 @@
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { calendarsStore } from '$lib/stores/calendars.svelte';
import { getContext } from 'svelte';
import type { Calendar } from '@calendar/shared';
import { sharesStore } from '$lib/stores/shares.svelte';
import {
CaretLeft,
@ -19,6 +20,9 @@
import { Modal, Input } from '@manacore/shared-ui';
import { PERMISSION_DESCRIPTIONS, type SharePermission } from '@calendar/shared';
// Get calendars from layout context (live query)
const calendarsCtx: { readonly value: Calendar[] } = getContext('calendars');
// Share form state
let showShareForm = $state(false);
let selectedCalendarId = $state('');
@ -50,7 +54,7 @@
return;
}
await Promise.all([
calendarsStore.calendars.length === 0 ? calendarsStore.fetchCalendars() : Promise.resolve(),
Promise.resolve(), // Calendars loaded via live query
sharesStore.fetchInvitations(),
sharesStore.fetchSharedWithMe(),
]);
@ -137,7 +141,7 @@
{$_('sharing.shareMyCalendars')}
</h2>
{#each calendarsStore.calendars as calendar (calendar.id)}
{#each calendarsCtx.value as calendar (calendar.id)}
<div class="calendar-card">
<button
class="calendar-header"
@ -215,7 +219,7 @@
<div class="form-field">
<label>{$_('sharing.form.calendar')}</label>
<select bind:value={selectedCalendarId} class="select-input">
{#each calendarsStore.calendars as cal}
{#each calendarsCtx.value as cal}
<option value={cal.id}>{cal.name}</option>
{/each}
</select>

View file

@ -1,8 +1,9 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { onMount, onDestroy, getContext } from 'svelte';
import { page } from '$app/stores';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { filterBySearch, splitPinned } from '$lib/data/queries';
import type { Conversation } from '@chat/types';
import { isSidebarMode, isNavCollapsed } from '$lib/stores/navigation';
import {
MagnifyingGlass,
@ -55,22 +56,17 @@
};
});
// Get conversations from store
let conversations = $derived(conversationsStore.conversations);
let isLoading = $derived(conversationsStore.isLoading);
// Get conversations from context (live query)
const conversationsCtx: { readonly value: Conversation[] } = getContext('conversations');
let conversations = $derived(conversationsCtx.value);
// Filtered conversations based on debounced search
let filteredConversations = $derived(
debouncedSearchQuery.trim()
? conversations.filter((conv) =>
conv.title?.toLowerCase().includes(debouncedSearchQuery.toLowerCase())
)
: conversations
);
let filteredConversations = $derived(filterBySearch(conversations, debouncedSearchQuery));
// Split into pinned and unpinned
let pinnedConversations = $derived(filteredConversations.filter((conv) => conv.isPinned));
let unpinnedConversations = $derived(filteredConversations.filter((conv) => !conv.isPinned));
let { pinned: pinnedConversations, unpinned: unpinnedConversations } = $derived(
splitPinned(filteredConversations)
);
// Date section types
type DateSection = 'today' | 'yesterday' | 'thisWeek' | 'thisMonth' | 'older';
@ -156,14 +152,9 @@
isResizing = false;
}
// Load conversations on mount
onMount(() => {
window.addEventListener('mousemove', handleMouseMove);
window.addEventListener('mouseup', stopResize);
if (authStore.user) {
conversationsStore.loadConversations();
}
});
onDestroy(() => {
@ -309,13 +300,7 @@
Neuer Chat
</a>
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin w-6 h-6 border-2 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if filteredConversations.length === 0}
{#if filteredConversations.length === 0}
<div class="flex flex-col items-center justify-center py-8 text-center">
{#if searchQuery}
<div class="text-4xl mb-3">🔍</div>

View file

@ -0,0 +1,134 @@
/**
* Reactive Queries & Pure Helpers for Chat
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
conversationCollection,
templateCollection,
messageCollection,
type LocalConversation,
type LocalTemplate,
type LocalMessage,
} from './local-store';
import type { Conversation, Template, Message } from '@chat/types';
// ─── Type Converters ───────────────────────────────────────
export function toConversation(local: LocalConversation): Conversation {
return {
id: local.id,
userId: local.userId ?? 'guest',
modelId: local.modelId ?? '',
templateId: local.templateId ?? undefined,
spaceId: local.spaceId ?? undefined,
conversationMode: local.conversationMode,
documentMode: local.documentMode,
title: local.title ?? undefined,
isArchived: local.isArchived,
isPinned: local.isPinned,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTemplate(local: LocalTemplate): Template {
return {
id: local.id,
userId: local.userId ?? 'guest',
name: local.name,
description: local.description || null,
systemPrompt: local.systemPrompt,
initialQuestion: local.initialQuestion ?? null,
modelId: local.modelId ?? null,
color: local.color,
isDefault: local.isDefault,
documentMode: local.documentMode,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toMessage(local: LocalMessage): Message {
return {
id: local.id,
conversationId: local.conversationId,
sender: local.sender,
messageText: local.messageText,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? undefined,
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All non-archived conversations, sorted by pinned first then updatedAt desc. */
export function useAllConversations() {
return useLiveQueryWithDefault(async () => {
const locals = await conversationCollection.getAll({ isArchived: false });
return sortConversations(locals.map(toConversation));
}, [] as Conversation[]);
}
/** All archived conversations, sorted by updatedAt desc. */
export function useArchivedConversations() {
return useLiveQueryWithDefault(async () => {
const locals = await conversationCollection.getAll({ isArchived: true });
return locals
.map(toConversation)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [] as Conversation[]);
}
/** All templates, sorted by name. */
export function useAllTemplates() {
return useLiveQueryWithDefault(async () => {
const locals = await templateCollection.getAll();
return locals.map(toTemplate).sort((a, b) => a.name.localeCompare(b.name));
}, [] as Template[]);
}
/** Messages for a specific conversation, sorted by createdAt asc. */
export function useConversationMessages(conversationId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await messageCollection.getAll({ conversationId });
return locals
.map(toMessage)
.sort((a, b) => new Date(a.createdAt).getTime() - new Date(b.createdAt).getTime());
}, [] as Message[]);
}
// ─── Pure Sort / Filter Functions (for $derived) ───────────
/** Sort conversations: pinned first, then by updatedAt descending. */
export function sortConversations(list: Conversation[]): Conversation[] {
return [...list].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
/** Filter conversations by space. */
export function filterBySpace(conversations: Conversation[], spaceId: string): Conversation[] {
return conversations.filter((c) => c.spaceId === spaceId);
}
/** Filter conversations by search query on title. */
export function filterBySearch(conversations: Conversation[], query: string): Conversation[] {
if (!query.trim()) return conversations;
const lower = query.toLowerCase();
return conversations.filter((c) => c.title?.toLowerCase().includes(lower));
}
/** Split conversations into pinned and unpinned. */
export function splitPinned(conversations: Conversation[]) {
return {
pinned: conversations.filter((c) => c.isPinned),
unpinned: conversations.filter((c) => !c.isPinned),
};
}

View file

@ -1,269 +1,132 @@
/**
* Conversations Store - Manages conversation list using Svelte 5 runes
* Supports both authenticated (cloud) and guest (session) modes
* Conversations Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in data/queries.ts.
* This store only provides write operations (archive, pin, delete, etc.).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { conversationService } from '$lib/services/conversation';
import { conversationCollection, type LocalConversation } from '$lib/data/local-store';
import { toConversation } from '$lib/data/queries';
import { toastStore } from '@manacore/shared-ui';
import { sessionConversationsStore } from './session-conversations.svelte';
import { authStore } from './auth.svelte';
import type { Conversation } from '@chat/types';
// State
let conversations = $state<Conversation[]>([]);
let archivedConversations = $state<Conversation[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
/**
* Sort conversations: pinned first, then by updatedAt descending
*/
function sortConversations(list: Conversation[]): Conversation[] {
return [...list].sort((a, b) => {
if (a.isPinned && !b.isPinned) return -1;
if (!a.isPinned && b.isPinned) return 1;
return new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime();
});
}
export const conversationsStore = {
// Getters
get conversations() {
return conversations;
},
get archivedConversations() {
return archivedConversations;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
/**
* Load conversations (userId is derived from JWT on backend)
* In guest mode, loads from session storage
* Update a conversation's fields in local-store
*/
async loadConversations(spaceId?: string) {
isLoading = true;
async updateConversation(conversationId: string, updates: Partial<LocalConversation>) {
error = null;
// Guest mode: load from session storage
if (!authStore.isAuthenticated) {
conversations = sessionConversationsStore.conversations;
isLoading = false;
return;
}
// Authenticated: fetch from API
try {
conversations = await conversationService.getConversations(spaceId);
await conversationCollection.update(conversationId, updates);
} catch (e) {
const message =
e instanceof Error ? e.message : 'Konversationen konnten nicht geladen werden';
error = message;
toastStore.error(message);
conversations = [];
} finally {
isLoading = false;
error = e instanceof Error ? e.message : 'Konversation konnte nicht aktualisiert werden';
console.error('Failed to update conversation:', e);
}
},
/**
* Load archived conversations
*/
async loadArchivedConversations() {
isLoading = true;
error = null;
try {
archivedConversations = await conversationService.getArchivedConversations();
} catch (e) {
const message =
e instanceof Error ? e.message : 'Archivierte Konversationen konnten nicht geladen werden';
error = message;
toastStore.error(message);
archivedConversations = [];
} finally {
isLoading = false;
}
},
/**
* Add a new conversation to the list
*/
addConversation(conversation: Conversation) {
conversations = [conversation, ...conversations];
},
/**
* Update a conversation in the list
*/
updateConversation(conversationId: string, updates: Partial<Conversation>) {
conversations = conversations.map((c) => (c.id === conversationId ? { ...c, ...updates } : c));
},
/**
* Update conversation title via API and update local state
* Update conversation title
*/
async updateConversationTitle(conversationId: string, title: string): Promise<boolean> {
const success = await conversationService.updateTitle(conversationId, title);
if (success) {
this.updateConversation(conversationId, { title });
error = null;
try {
await conversationCollection.update(conversationId, { title });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Titel konnte nicht aktualisiert werden';
return false;
}
return success;
},
/**
* Archive a conversation
*/
async archiveConversation(conversationId: string) {
const success = await conversationService.archiveConversation(conversationId);
if (success) {
const conversation = conversations.find((c) => c.id === conversationId);
if (conversation) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = [{ ...conversation, isArchived: true }, ...archivedConversations];
}
async archiveConversation(conversationId: string): Promise<boolean> {
error = null;
try {
await conversationCollection.update(conversationId, { isArchived: true });
toastStore.success('Konversation archiviert');
} else {
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Konversation konnte nicht archiviert werden';
toastStore.error('Konversation konnte nicht archiviert werden');
return false;
}
return success;
},
/**
* Unarchive a conversation
*/
async unarchiveConversation(conversationId: string) {
const success = await conversationService.unarchiveConversation(conversationId);
if (success) {
const conversation = archivedConversations.find((c) => c.id === conversationId);
if (conversation) {
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
conversations = sortConversations([
{ ...conversation, isArchived: false },
...conversations,
]);
}
async unarchiveConversation(conversationId: string): Promise<boolean> {
error = null;
try {
await conversationCollection.update(conversationId, { isArchived: false });
toastStore.success('Konversation wiederhergestellt');
} else {
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Konversation konnte nicht wiederhergestellt werden';
toastStore.error('Konversation konnte nicht wiederhergestellt werden');
return false;
}
return success;
},
/**
* Delete a conversation
*/
async deleteConversation(conversationId: string) {
const success = await conversationService.deleteConversation(conversationId);
if (success) {
conversations = conversations.filter((c) => c.id !== conversationId);
archivedConversations = archivedConversations.filter((c) => c.id !== conversationId);
async deleteConversation(conversationId: string): Promise<boolean> {
error = null;
try {
await conversationCollection.delete(conversationId);
toastStore.success('Konversation gelöscht');
} else {
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Konversation konnte nicht gelöscht werden';
toastStore.error('Konversation konnte nicht gelöscht werden');
return false;
}
return success;
},
/**
* Pin a conversation (moves it to top of list)
* Pin a conversation
*/
async pinConversation(conversationId: string) {
const success = await conversationService.pinConversation(conversationId);
if (success) {
conversations = sortConversations(
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: true } : c))
);
} else {
async pinConversation(conversationId: string): Promise<boolean> {
error = null;
try {
await conversationCollection.update(conversationId, { isPinned: true });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Konversation konnte nicht angepinnt werden';
toastStore.error('Konversation konnte nicht angepinnt werden');
return false;
}
return success;
},
/**
* Unpin a conversation
*/
async unpinConversation(conversationId: string) {
const success = await conversationService.unpinConversation(conversationId);
if (success) {
conversations = sortConversations(
conversations.map((c) => (c.id === conversationId ? { ...c, isPinned: false } : c))
);
} else {
async unpinConversation(conversationId: string): Promise<boolean> {
error = null;
try {
await conversationCollection.update(conversationId, { isPinned: false });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Konversation konnte nicht losgelöst werden';
toastStore.error('Konversation konnte nicht losgelöst werden');
return false;
}
return success;
},
/**
* Clear all data
* Reset error state
*/
reset() {
conversations = [];
archivedConversations = [];
error = null;
},
/**
* Get session conversation count (for guest mode banner)
*/
get sessionConversationCount(): number {
return sessionConversationsStore.count;
},
/**
* Check if there are session conversations
*/
get hasSessionConversations(): boolean {
return sessionConversationsStore.count > 0;
},
/**
* Migrate session conversations to cloud after login
* Note: This is a placeholder - actual implementation would need backend support
*/
async migrateSessionConversations(): Promise<void> {
if (!authStore.isAuthenticated) return;
const sessionData = sessionConversationsStore.getAllConversations();
if (sessionData.conversations.length === 0) return;
// For now, we just clear the session data
// In a full implementation, you would create each conversation via API
// and transfer the messages
console.log(
'Session conversations would be migrated:',
sessionData.conversations.length,
'conversations'
);
// Clear session data after migration
sessionConversationsStore.clear();
// Reload conversations from server
await this.loadConversations();
toastStore.success('Unterhaltungen wurden in deinen Account übertragen');
},
/**
* Check if a conversation ID is a session conversation
*/
isSessionConversation(id: string): boolean {
return sessionConversationsStore.isSessionConversation(id);
},
};

View file

@ -1,56 +1,41 @@
/**
* Templates Store - Manages template list using Svelte 5 runes
* Templates Store Mutation-Only Service
*
* All reads are handled by useLiveQuery() hooks in data/queries.ts.
* This store only provides write operations (create, update, delete).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { templateService } from '$lib/services/template';
import { templateCollection, type LocalTemplate } from '$lib/data/local-store';
import { toTemplate } from '$lib/data/queries';
import type { Template, TemplateCreate, TemplateUpdate } from '@chat/types';
// State
let templates = $state<Template[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
export const templatesStore = {
// Getters
get templates() {
return templates;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
/**
* Load templates for a user
*/
async loadTemplates(userId: string) {
isLoading = true;
error = null;
try {
templates = await templateService.getTemplates(userId);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load templates';
templates = [];
} finally {
isLoading = false;
}
},
/**
* Create a new template
*/
async createTemplate(template: TemplateCreate): Promise<Template | null> {
error = null;
try {
const newTemplate = await templateService.createTemplate(template);
if (newTemplate) {
templates = [...templates, newTemplate].sort((a, b) => a.name.localeCompare(b.name));
}
return newTemplate;
const newLocal: LocalTemplate = {
id: crypto.randomUUID(),
name: template.name,
description: template.description ?? '',
systemPrompt: template.systemPrompt,
initialQuestion: template.initialQuestion,
modelId: template.modelId,
color: template.color,
isDefault: template.isDefault,
documentMode: template.documentMode,
};
const inserted = await templateCollection.insert(newLocal);
return toTemplate(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create template';
return null;
@ -62,15 +47,9 @@ export const templatesStore = {
*/
async updateTemplate(templateId: string, updates: TemplateUpdate): Promise<boolean> {
error = null;
try {
const success = await templateService.updateTemplate(templateId, updates);
if (success) {
templates = templates
.map((t) => (t.id === templateId ? { ...t, ...updates } : t))
.sort((a, b) => a.name.localeCompare(b.name));
}
return success;
await templateCollection.update(templateId, updates as Partial<LocalTemplate>);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update template';
return false;
@ -82,13 +61,9 @@ export const templatesStore = {
*/
async deleteTemplate(templateId: string): Promise<boolean> {
error = null;
try {
const success = await templateService.deleteTemplate(templateId);
if (success) {
templates = templates.filter((t) => t.id !== templateId);
}
return success;
await templateCollection.delete(templateId);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete template';
return false;
@ -96,20 +71,21 @@ export const templatesStore = {
},
/**
* Set a template as default
* Set a template as default (unset all others)
*/
async setDefaultTemplate(templateId: string, userId: string): Promise<boolean> {
async setDefaultTemplate(templateId: string): Promise<boolean> {
error = null;
try {
const success = await templateService.setDefaultTemplate(templateId, userId);
if (success) {
templates = templates.map((t) => ({
...t,
isDefault: t.id === templateId,
}));
// Unset all current defaults
const all = await templateCollection.getAll({ isDefault: true });
for (const t of all) {
if (t.id !== templateId) {
await templateCollection.update(t.id, { isDefault: false });
}
}
return success;
// Set the new default
await templateCollection.update(templateId, { isDefault: true });
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set default template';
return false;
@ -117,10 +93,9 @@ export const templatesStore = {
},
/**
* Reset store
* Reset error state
*/
reset() {
templates = [];
error = null;
},
};

View file

@ -4,8 +4,7 @@
import { locale } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { sessionConversationsStore } from '$lib/stores/session-conversations.svelte';
import { useAllConversations, useAllTemplates } from '$lib/data/queries';
import { theme } from '$lib/stores/theme';
import {
THEME_DEFINITIONS,
@ -33,8 +32,14 @@
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { chatStore } from '$lib/data/local-store';
// Live tag query + context
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allConversations = useAllConversations();
const allTemplates = useAllTemplates();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('conversations', allConversations);
setContext('templates', allTemplates);
setContext('tags', allTags);
// App switcher items
@ -197,11 +202,6 @@
// Load user settings
await userSettings.load();
// Check for session conversations to migrate
if (conversationsStore.hasSessionConversations) {
await conversationsStore.migrateSessionConversations();
}
// Redirect to start page if on /chat and a custom start page is set
const currentPath = window.location.pathname;
if (currentPath === '/chat' && userSettings.startPage && userSettings.startPage !== '/chat') {

View file

@ -1,26 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { useArchivedConversations } from '$lib/data/queries';
import { PageHeader } from '@manacore/shared-ui';
import type { Conversation } from '@chat/types';
let conversations = $state<Conversation[]>([]);
let isLoading = $state(true);
onMount(async () => {
if (authStore.user) {
await conversationsStore.loadArchivedConversations();
conversations = conversationsStore.archivedConversations;
}
isLoading = false;
});
// Keep conversations in sync with store
$effect(() => {
conversations = conversationsStore.archivedConversations;
});
const archivedConvs = useArchivedConversations();
let conversations = $derived(archivedConvs.value);
function formatDate(dateString: string): string {
return new Date(dateString).toLocaleDateString('de-DE', {
@ -58,14 +44,7 @@
<div class="max-w-4xl mx-auto px-4">
<PageHeader title="Archiv" description="Deine archivierten Konversationen." size="lg" />
<!-- Loading State -->
{#if isLoading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin w-8 h-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if conversations.length === 0}
{#if conversations.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg

View file

@ -1,9 +1,8 @@
<script lang="ts">
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { chatService } from '$lib/services/chat';
import { conversationService } from '$lib/services/conversation';
import { templateService } from '$lib/services/template';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import MessageList from '$lib/components/chat/MessageList.svelte';
import ChatInput from '$lib/components/chat/ChatInput.svelte';
@ -11,8 +10,11 @@
import type { AIModel, Message, Template } from '@chat/types';
import { Sparkle } from '@manacore/shared-icons';
// Templates from live query context
const templatesCtx: { readonly value: Template[] } = getContext('templates');
let templates = $derived(templatesCtx.value);
let models = $state<AIModel[]>([]);
let templates = $state<Template[]>([]);
let selectedModelId = $state('');
let selectedTemplateId = $state('');
let documentMode = $state(false);
@ -41,11 +43,6 @@
selectedModelId = defaultModel?.id || models[0].id;
}
// Load user templates
if (authStore.user) {
templates = await templateService.getTemplates(authStore.user.id);
}
isLoading = false;
}
@ -90,10 +87,7 @@
modelToUse
);
// Reload conversations list
await conversationsStore.loadConversations();
// Navigate to the new conversation
// Navigate to the new conversation (live query auto-updates sidebar)
goto(`/chat/${conversationId}`);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Erstellen der Konversation';

View file

@ -25,7 +25,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -3,7 +3,6 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { PageHeader } from '@manacore/shared-ui';
import SpaceCard from '$lib/components/spaces/SpaceCard.svelte';
import SpaceForm from '$lib/components/spaces/SpaceForm.svelte';

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { onMount, getContext } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { filterBySpace } from '$lib/data/queries';
import { spaceService } from '$lib/services/space';
import { conversationService } from '$lib/services/conversation';
import { chatService } from '$lib/services/chat';
@ -12,8 +12,11 @@
const spaceId = $derived($page.params.id ?? '');
// Conversations from live query context, filtered by space
const conversationsCtx: { readonly value: Conversation[] } = getContext('conversations');
let conversations = $derived(filterBySpace(conversationsCtx.value, spaceId));
let space = $state<Space | null>(null);
let conversations = $state<Conversation[]>([]);
let models = $state<AIModel[]>([]);
let selectedModelId = $state('');
let isLoading = $state(true);
@ -35,11 +38,6 @@
return;
}
// Load conversations in this space
if (authStore.user) {
conversations = await conversationService.getConversations(spaceId);
}
// Load models
models = await chatService.getModels();
if (models.length > 0) {

View file

@ -1,31 +1,27 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { templatesStore } from '$lib/stores/templates.svelte';
import { conversationService } from '$lib/services/conversation';
import { conversationsStore } from '$lib/stores/conversations.svelte';
import { PageHeader } from '@manacore/shared-ui';
import TemplateCard from '$lib/components/templates/TemplateCard.svelte';
import TemplateForm from '$lib/components/templates/TemplateForm.svelte';
import type { Template } from '@chat/types';
const templatesCtx: { readonly value: Template[] } = getContext('templates');
let templates = $derived(templatesCtx.value);
let showForm = $state(false);
let editingTemplate = $state<Template | undefined>(undefined);
onMount(async () => {
if (authStore.user) {
await templatesStore.loadTemplates(authStore.user.id);
}
});
function handleCreateNew() {
editingTemplate = undefined;
showForm = true;
}
function handleEdit(id: string) {
const template = templatesStore.templates.find((t) => t.id === id);
const template = templates.find((t) => t.id === id);
if (template) {
editingTemplate = template;
showForm = true;
@ -39,13 +35,11 @@
}
async function handleSetDefault(id: string) {
if (authStore.user) {
await templatesStore.setDefaultTemplate(id, authStore.user.id);
}
await templatesStore.setDefaultTemplate(id);
}
async function handleUse(id: string) {
const template = templatesStore.templates.find((t) => t.id === id);
const template = templates.find((t) => t.id === id);
if (!template || !authStore.user) return;
// Create a new conversation with this template
@ -57,7 +51,6 @@
});
if (conversationId) {
await conversationsStore.loadConversations();
goto(`/chat/${conversationId}`);
}
}
@ -132,13 +125,7 @@
</PageHeader>
<!-- Loading State -->
{#if templatesStore.isLoading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin w-8 h-8 border-4 border-primary border-r-transparent rounded-full"
></div>
</div>
{:else if templatesStore.templates.length === 0}
{#if templates.length === 0}
<!-- Empty State -->
<div class="text-center py-16">
<svg
@ -175,7 +162,7 @@
{:else}
<!-- Templates Grid -->
<div class="grid gap-4 sm:grid-cols-2">
{#each templatesStore.templates as template (template.id)}
{#each templates as template (template.id)}
<TemplateCard
{template}
onUse={handleUse}

View file

@ -0,0 +1,106 @@
/**
* Reactive Queries & Pure Helpers for Clock
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
alarmCollection,
timerCollection,
worldClockCollection,
type LocalAlarm,
type LocalTimer,
type LocalWorldClock,
} from './local-store';
import type { Alarm, Timer, WorldClock } from '@clock/shared';
// ─── Type Converters ───────────────────────────────────────
export function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
export function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All alarms, auto-updates on any change. */
export function useAllAlarms() {
return useLiveQueryWithDefault(async () => {
const locals = await alarmCollection.getAll();
return locals.map(toAlarm);
}, [] as Alarm[]);
}
/** All timers, auto-updates on any change. */
export function useAllTimers() {
return useLiveQueryWithDefault(async () => {
const locals = await timerCollection.getAll();
return locals.map(toTimer);
}, [] as Timer[]);
}
/** All world clocks, sorted by sortOrder. Auto-updates on any change. */
export function useAllWorldClocks() {
return useLiveQueryWithDefault(async () => {
const locals = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toWorldClock);
}, [] as WorldClock[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
export function filterEnabledAlarms(alarms: Alarm[]): Alarm[] {
return alarms.filter((a) => a.enabled);
}
export function filterActiveTimers(timers: Timer[]): Timer[] {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
}
export function sortWorldClocksByOrder(clocks: WorldClock[]): WorldClock[] {
return [...clocks].sort((a, b) => a.sortOrder - b.sortOrder);
}

View file

@ -1,76 +1,24 @@
/**
* Alarms Store Local-First with Dexie.js
* Alarms Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, toggle).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { alarmCollection, type LocalAlarm } from '$lib/data/local-store';
import type { Alarm, CreateAlarmInput, UpdateAlarmInput } from '@clock/shared';
import { toAlarm } from '$lib/data/queries';
import type { CreateAlarmInput, UpdateAlarmInput, Alarm } from '@clock/shared';
// State — populated from IndexedDB
let alarms = $state<Alarm[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalAlarm (IndexedDB record) to the shared Alarm type. */
function toAlarm(local: LocalAlarm): Alarm {
return {
id: local.id,
userId: 'local',
label: local.label,
time: local.time,
enabled: local.enabled,
repeatDays: local.repeatDays,
snoozeMinutes: local.snoozeMinutes,
sound: local.sound,
vibrate: local.vibrate ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load alarms from IndexedDB into the reactive state. */
async function refreshAlarms() {
const localAlarms = await alarmCollection.getAll();
alarms = localAlarms.map(toAlarm);
}
export const alarmsStore = {
// Getters
get alarms() {
return alarms;
},
get loading() {
return loading;
},
get error() {
return error;
},
get enabledAlarms() {
return alarms.filter((a) => a.enabled);
},
/**
* Fetch all alarms reads from IndexedDB.
*/
async fetchAlarms() {
loading = true;
error = null;
try {
await refreshAlarms();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch alarms';
console.error('Failed to fetch alarms:', e);
} finally {
loading = false;
}
return { success: true };
},
/**
* Create a new alarm writes to IndexedDB instantly.
* Create a new alarm -- writes to IndexedDB instantly.
*/
async createAlarm(input: CreateAlarmInput) {
error = null;
@ -87,9 +35,7 @@ export const alarmsStore = {
};
const inserted = await alarmCollection.insert(newLocal);
const newAlarm = toAlarm(inserted);
alarms = [...alarms, newAlarm];
return { success: true, data: newAlarm };
return { success: true, data: toAlarm(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create alarm';
console.error('Failed to create alarm:', e);
@ -98,7 +44,7 @@ export const alarmsStore = {
},
/**
* Update an alarm writes to IndexedDB instantly.
* Update an alarm -- writes to IndexedDB instantly.
*/
async updateAlarm(id: string, input: UpdateAlarmInput) {
error = null;
@ -114,9 +60,7 @@ export const alarmsStore = {
const updated = await alarmCollection.update(id, updateData);
if (updated) {
const updatedAlarm = toAlarm(updated);
alarms = alarms.map((a) => (a.id === id ? updatedAlarm : a));
return { success: true, data: updatedAlarm };
return { success: true, data: toAlarm(updated) };
}
return { success: false, error: 'Alarm not found' };
} catch (e) {
@ -129,21 +73,20 @@ export const alarmsStore = {
/**
* Toggle alarm enabled state.
*/
async toggleAlarm(id: string) {
const alarm = alarms.find((a) => a.id === id);
async toggleAlarm(id: string, currentAlarms: Alarm[]) {
const alarm = currentAlarms.find((a) => a.id === id);
if (!alarm) return { success: false, error: 'Alarm not found' };
return this.updateAlarm(id, { enabled: !alarm.enabled });
},
/**
* Delete an alarm removes from IndexedDB instantly.
* Delete an alarm -- removes from IndexedDB instantly.
*/
async deleteAlarm(id: string) {
error = null;
try {
await alarmCollection.delete(id);
alarms = alarms.filter((a) => a.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete alarm';
@ -151,31 +94,4 @@ export const alarmsStore = {
return { success: false, error: error };
}
},
/**
* Clear all alarms (local state only).
*/
clear() {
alarms = [];
error = null;
},
/**
* No longer relevant all alarms are local and editable.
*/
get sessionAlarmCount(): number {
return 0;
},
get hasSessionAlarms(): boolean {
return false;
},
async migrateSessionAlarms(): Promise<void> {
// No-op: local-first mode handles data persistence automatically.
},
isSessionAlarm(_id: string): boolean {
return false;
},
};

View file

@ -1,77 +1,25 @@
/**
* Timers Store Local-First with Dexie.js
* Timers Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, start, pause, reset).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { timerCollection, type LocalTimer } from '$lib/data/local-store';
import type { Timer, CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { toTimer } from '$lib/data/queries';
import type { CreateTimerInput, UpdateTimerInput } from '@clock/shared';
import { ClockEvents } from '@manacore/shared-utils/analytics';
// State — populated from IndexedDB
let timers = $state<Timer[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalTimer (IndexedDB record) to the shared Timer type. */
function toTimer(local: LocalTimer): Timer {
return {
id: local.id,
userId: 'local',
label: local.label,
durationSeconds: local.durationSeconds,
remainingSeconds: local.remainingSeconds,
status: local.status,
startedAt: local.startedAt,
pausedAt: local.pausedAt,
sound: local.sound,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load timers from IndexedDB into the reactive state. */
async function refreshTimers() {
const localTimers = await timerCollection.getAll();
timers = localTimers.map(toTimer);
}
export const timersStore = {
// Getters
get timers() {
return timers;
},
get loading() {
return loading;
},
get error() {
return error;
},
get activeTimers() {
return timers.filter((t) => t.status === 'running' || t.status === 'paused');
},
/**
* Fetch all timers reads from IndexedDB.
*/
async fetchTimers() {
loading = true;
error = null;
try {
await refreshTimers();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch timers';
console.error('Failed to fetch timers:', e);
} finally {
loading = false;
}
return { success: true };
},
/**
* Create a new timer writes to IndexedDB instantly.
* Create a new timer -- writes to IndexedDB instantly.
*/
async createTimer(input: CreateTimerInput) {
error = null;
@ -88,9 +36,7 @@ export const timersStore = {
};
const inserted = await timerCollection.insert(newLocal);
const newTimer = toTimer(inserted);
timers = [...timers, newTimer];
return { success: true, data: newTimer };
return { success: true, data: toTimer(inserted) };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create timer';
console.error('Failed to create timer:', e);
@ -99,7 +45,7 @@ export const timersStore = {
},
/**
* Update a timer writes to IndexedDB instantly.
* Update a timer -- writes to IndexedDB instantly.
*/
async updateTimer(id: string, input: UpdateTimerInput) {
error = null;
@ -111,9 +57,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -124,7 +68,7 @@ export const timersStore = {
},
/**
* Start a timer sets status to running with current timestamp.
* Start a timer -- sets status to running with current timestamp.
*/
async startTimer(id: string) {
error = null;
@ -146,9 +90,8 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
ClockEvents.timerStarted(
(updatedTimer as Timer & { type?: string }).type as 'pomodoro' | 'stopwatch' | 'countdown'
(updatedTimer as any).type as 'pomodoro' | 'stopwatch' | 'countdown'
);
return { success: true, data: updatedTimer };
}
@ -161,7 +104,7 @@ export const timersStore = {
},
/**
* Pause a timer calculates remaining seconds and saves.
* Pause a timer -- calculates remaining seconds and saves.
*/
async pauseTimer(id: string) {
error = null;
@ -185,9 +128,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -198,7 +139,7 @@ export const timersStore = {
},
/**
* Reset a timer back to idle with full duration.
* Reset a timer -- back to idle with full duration.
*/
async resetTimer(id: string) {
error = null;
@ -212,9 +153,7 @@ export const timersStore = {
const updated = await timerCollection.update(id, updateData);
if (updated) {
const updatedTimer = toTimer(updated);
timers = timers.map((t) => (t.id === id ? updatedTimer : t));
return { success: true, data: updatedTimer };
return { success: true, data: toTimer(updated) };
}
return { success: false, error: 'Timer not found' };
} catch (e) {
@ -225,13 +164,12 @@ export const timersStore = {
},
/**
* Delete a timer removes from IndexedDB instantly.
* Delete a timer -- removes from IndexedDB instantly.
*/
async deleteTimer(id: string) {
error = null;
try {
await timerCollection.delete(id);
timers = timers.filter((t) => t.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete timer';
@ -241,36 +179,13 @@ export const timersStore = {
},
/**
* Update local timer state (for countdown display).
* Update remaining seconds in IndexedDB (for countdown display).
*/
updateLocalState(id: string, updates: Partial<Timer>) {
timers = timers.map((t) => (t.id === id ? { ...t, ...updates } : t));
},
/**
* Clear all timers (local state only).
*/
clear() {
timers = [];
error = null;
},
/**
* No longer relevant all timers are local and editable.
*/
get sessionTimerCount(): number {
return 0;
},
get hasSessionTimers(): boolean {
return false;
},
async migrateSessionTimers(): Promise<void> {
// No-op: local-first mode handles data persistence automatically.
},
isSessionTimer(_id: string): boolean {
return false;
async updateLocalTimer(id: string, remainingSeconds: number) {
try {
await timerCollection.update(id, { remainingSeconds });
} catch (e) {
console.error('Failed to update local timer:', e);
}
},
};

View file

@ -1,86 +1,36 @@
/**
* World Clocks Store Local-First with Dexie.js
* World Clocks Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (add, remove, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import { worldClockCollection, type LocalWorldClock } from '$lib/data/local-store';
import type { WorldClock, CreateWorldClockInput } from '@clock/shared';
import type { CreateWorldClockInput, WorldClock } from '@clock/shared';
// State — populated from IndexedDB
let worldClocks = $state<WorldClock[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalWorldClock (IndexedDB record) to the shared WorldClock type. */
function toWorldClock(local: LocalWorldClock): WorldClock {
return {
id: local.id,
userId: 'local',
timezone: local.timezone,
cityName: local.cityName,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
/** Load world clocks from IndexedDB into the reactive state. */
async function refreshWorldClocks() {
const localClocks = await worldClockCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
worldClocks = localClocks.map(toWorldClock);
}
export const worldClocksStore = {
// Getters
get worldClocks() {
return worldClocks;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all world clocks reads from IndexedDB.
* Add a new world clock -- writes to IndexedDB instantly.
*/
async fetchWorldClocks() {
loading = true;
error = null;
try {
await refreshWorldClocks();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to fetch world clocks';
console.error('Failed to fetch world clocks:', e);
} finally {
loading = false;
}
return { success: true };
},
/**
* Add a new world clock writes to IndexedDB instantly.
*/
async addWorldClock(input: CreateWorldClockInput) {
async addWorldClock(input: CreateWorldClockInput, currentCount: number = 0) {
error = null;
try {
const newLocal: LocalWorldClock = {
id: crypto.randomUUID(),
timezone: input.timezone,
cityName: input.cityName,
sortOrder: worldClocks.length,
sortOrder: currentCount,
};
const inserted = await worldClockCollection.insert(newLocal);
const newClock = toWorldClock(inserted);
worldClocks = [...worldClocks, newClock];
return { success: true, data: newClock };
await worldClockCollection.insert(newLocal);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add world clock';
console.error('Failed to add world clock:', e);
@ -89,13 +39,12 @@ export const worldClocksStore = {
},
/**
* Remove a world clock removes from IndexedDB instantly.
* Remove a world clock -- removes from IndexedDB instantly.
*/
async removeWorldClock(id: string) {
error = null;
try {
await worldClockCollection.delete(id);
worldClocks = worldClocks.filter((wc) => wc.id !== id);
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove world clock';
@ -105,26 +54,16 @@ export const worldClocksStore = {
},
/**
* Reorder world clocks updates sortOrder in IndexedDB.
* Reorder world clocks -- updates sortOrder in IndexedDB.
*/
async reorder(ids: string[]) {
error = null;
try {
// Update local state immediately
worldClocks = ids
.map((id, index) => {
const wc = worldClocks.find((w) => w.id === id);
return wc ? { ...wc, sortOrder: index } : undefined;
})
.filter((wc): wc is WorldClock => wc !== undefined);
// Persist each order change to IndexedDB
for (let i = 0; i < ids.length; i++) {
await worldClockCollection.update(ids[i], {
sortOrder: i,
} as Partial<LocalWorldClock>);
}
return { success: true };
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder world clocks';
@ -132,12 +71,4 @@ export const worldClocksStore = {
return { success: false, error: error };
}
},
/**
* Clear all world clocks (local state only).
*/
clear() {
worldClocks = [];
error = null;
},
};

View file

@ -13,8 +13,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { timersStore } from '$lib/stores/timers.svelte';
import { useAllAlarms, useAllTimers, useAllWorldClocks } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -26,7 +25,6 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { alarmCollection, timerCollection } from '$lib/data/local-store';
import { clockOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -38,8 +36,16 @@
useAllTags as useAllSharedTags,
} from '@manacore/shared-stores';
// Shared tag store (local-first)
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allAlarms = useAllAlarms();
const allTimers = useAllTimers();
const allWorldClocks = useAllWorldClocks();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('alarms', allAlarms);
setContext('timers', allTimers);
setContext('worldClocks', allWorldClocks);
setContext('tags', allTags);
// Guest welcome modal state
@ -81,44 +87,38 @@
{ id: 'settings', label: 'Einstellungen', icon: 'settings', href: '/settings' },
];
// CommandBar search - search alarms and timers
// CommandBar search - search alarms and timers using live query data
async function handleCommandBarSearch(query: string): Promise<CommandBarItem[]> {
if (!query.trim()) return [];
const queryLower = query.toLowerCase();
const results: CommandBarItem[] = [];
try {
// Search alarms (local-first — reads from IndexedDB)
const alarms = await alarmCollection.getAll();
const matchingAlarms = alarms
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `⏰ ${alarm.time} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search alarms (from live query)
const matchingAlarms = allAlarms.value
.filter((alarm) => alarm.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((alarm) => ({
id: `alarm-${alarm.id}`,
title: alarm.label || 'Wecker',
subtitle: `${alarm.time.slice(0, 5)} ${alarm.enabled ? '(aktiv)' : '(inaktiv)'}`,
}));
results.push(...matchingAlarms);
// Search timers (local-first — reads from IndexedDB)
const timers = await timerCollection.getAll();
const matchingTimers = timers
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `⏱️ ${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
} catch {
// Ignore errors
}
// Search timers (from live query)
const matchingTimers = allTimers.value
.filter((timer) => timer.label?.toLowerCase().includes(queryLower))
.slice(0, 5)
.map((timer) => {
const mins = Math.floor(timer.durationSeconds / 60);
const secs = timer.durationSeconds % 60;
return {
id: `timer-${timer.id}`,
title: timer.label || 'Timer',
subtitle: `${mins}:${secs.toString().padStart(2, '0')} ${timer.status === 'running' ? '(läuft)' : ''}`,
};
});
results.push(...matchingTimers);
return results.slice(0, 10);
}

View file

@ -1,12 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { alarmsStore } from '$lib/stores/alarms.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import type { CreateAlarmInput, Alarm } from '@clock/shared';
import type { Alarm } from '@clock/shared';
import { ALARM_SOUNDS, DEFAULT_ALARM_PRESETS } from '@clock/shared';
import { AlarmsSkeleton } from '$lib/components/skeletons';
// Get live query data from layout context
const allAlarms: { readonly value: Alarm[] } = getContext('alarms');
// Quick create form (inline)
let newTime = $state('07:00');
@ -27,7 +28,7 @@
// Find existing alarm for a preset time
function findAlarmForPreset(presetTime: string): Alarm | undefined {
return alarmsStore.alarms.find((a) => a.time.slice(0, 5) === presetTime);
return allAlarms.value.find((a) => a.time.slice(0, 5) === presetTime);
}
// Toggle a preset alarm
@ -35,7 +36,7 @@
const existingAlarm = findAlarmForPreset(presetTime);
if (existingAlarm) {
await alarmsStore.toggleAlarm(existingAlarm.id);
await alarmsStore.toggleAlarm(existingAlarm.id, allAlarms.value);
} else {
const result = await alarmsStore.createAlarm({
time: presetTime + ':00',
@ -79,11 +80,6 @@
}
}
onMount(async () => {
// Load alarms - works for both authenticated and guest mode
await alarmsStore.fetchAlarms();
});
function openEditModal(alarm: Alarm) {
editingId = alarm.id;
editTime = alarm.time.slice(0, 5);
@ -136,7 +132,7 @@
}
async function handleToggle(id: string) {
await alarmsStore.toggleAlarm(id);
await alarmsStore.toggleAlarm(id, allAlarms.value);
}
function getRepeatText(days: number[] | null) {
@ -191,63 +187,58 @@
</div>
{/if}
<!-- Loading State -->
{#if alarmsStore.loading}
<AlarmsSkeleton />
{:else}
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
<!-- Default Alarm Presets (Grid) -->
<div class="alarm-grid">
{#each DEFAULT_ALARM_PRESETS as preset}
{@const existingAlarm = findAlarmForPreset(preset.time)}
{@const isActive = existingAlarm?.enabled ?? false}
<div
class="alarm-tile"
class:active={isActive}
role="button"
tabindex="0"
onclick={() => togglePreset(preset.time, preset.label)}
onkeydown={(e) => e.key === 'Enter' && togglePreset(preset.time, preset.label)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{preset.time}
</div>
{/each}
</div>
<!-- Custom Alarms (Grid) -->
{@const customAlarms = alarmsStore.alarms.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
{#if customAlarms.length > 0}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{existingAlarm?.label || preset.label}
</div>
</div>
{/if}
{/each}
</div>
<!-- Custom Alarms (Grid) -->
{@const customAlarms = allAlarms.value.filter(
(a) => !DEFAULT_ALARM_PRESETS.some((p) => p.time === a.time.slice(0, 5))
)}
{#if customAlarms.length > 0}
<div class="mt-4">
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">
{$_('alarm.custom')}
</h2>
<div class="alarm-grid">
{#each customAlarms as alarm (alarm.id)}
<div
class="alarm-tile"
class:active={alarm.enabled}
role="button"
tabindex="0"
onclick={() => handleToggle(alarm.id)}
onkeydown={(e) => e.key === 'Enter' && handleToggle(alarm.id)}
>
<div class="text-xl font-light text-foreground tabular-nums text-center">
{alarm.time.slice(0, 5)}
</div>
<div class="text-[10px] text-muted-foreground text-center truncate mt-0.5">
{alarm.label || getRepeatText(alarm.repeatDays)}
</div>
</div>
{/each}
</div>
</div>
{/if}
<!-- Edit Modal -->

View file

@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { browser } from '$app/environment';
import { PageHeader, toast } from '@manacore/shared-ui';
import { timersStore } from '$lib/stores/timers.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { QUICK_TIMER_PRESETS, formatDuration } from '@clock/shared';
import { TimersSkeleton } from '$lib/components/skeletons';
import type { Timer } from '@clock/shared';
// Get live query data from layout context
const allTimersQuery: { readonly value: Timer[] } = getContext('timers');
// Form state (inline on page)
let formMinutes = $state(5);
@ -24,12 +26,7 @@
}
let localTimers = $state<LocalTimer[]>([]);
let intervals: Map<string, ReturnType<typeof setInterval>> = new Map();
let allTimers = $derived([...timersStore.timers, ...localTimers]);
onMount(async () => {
// Load timers - works for both authenticated and guest mode
await timersStore.fetchTimers();
});
let allTimers = $derived([...allTimersQuery.value, ...localTimers]);
onDestroy(() => {
intervals.forEach((interval) => clearInterval(interval));
@ -69,7 +66,7 @@
}
}
} else {
const timer = timersStore.timers.find((t) => t.id === timerId);
const timer = allTimersQuery.value.find((t) => t.id === timerId);
if (!timer || timer.status !== 'running') {
clearInterval(interval);
intervals.delete(timerId);
@ -216,10 +213,7 @@
{/each}
</div>
<!-- Loading State -->
{#if timersStore.loading}
<TimersSkeleton />
{:else if allTimers.length > 0}
{#if allTimers.length > 0}
<!-- Active Timers -->
<div>
<h2 class="text-xs font-medium text-muted-foreground mb-2 uppercase tracking-wide">

View file

@ -1,12 +1,14 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext, onDestroy } from 'svelte';
import { _ } from 'svelte-i18n';
import { PageHeader, toast } from '@manacore/shared-ui';
import { worldClocksStore } from '$lib/stores/world-clocks.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { POPULAR_TIMEZONES } from '@clock/shared';
import type { WorldClock } from '@clock/shared';
import WorldMap from '$lib/components/WorldMap.svelte';
import { WorldClockSkeleton } from '$lib/components/skeletons';
// Get live query data from layout context
const allWorldClocks: { readonly value: WorldClock[] } = getContext('worldClocks');
// State
let showAddModal = $state(false);
@ -16,11 +18,11 @@
let showMap = $state(true);
// Selected city timezones for map highlighting
let selectedTimezones = $derived(worldClocksStore.worldClocks.map((wc) => wc.timezone));
let selectedTimezones = $derived(allWorldClocks.value.map((wc) => wc.timezone));
// Handle map city click
function handleMapCityClick(timezone: string, cityName: string) {
const alreadyAdded = worldClocksStore.worldClocks.some((wc) => wc.timezone === timezone);
const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === timezone);
if (alreadyAdded) {
toast.info(`${cityName} ist bereits hinzugefügt`);
} else {
@ -39,16 +41,10 @@
: POPULAR_TIMEZONES
);
onMount(async () => {
if (authStore.isAuthenticated) {
await worldClocksStore.fetchWorldClocks();
}
// Update time every second
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
});
// Update time every second
interval = setInterval(() => {
currentTime = new Date();
}, 1000);
onDestroy(() => {
if (interval) {
@ -66,10 +62,10 @@
}
async function addCity(timezone: string, cityName: string) {
const result = await worldClocksStore.addWorldClock({
timezone,
cityName,
});
const result = await worldClocksStore.addWorldClock(
{ timezone, cityName },
allWorldClocks.value.length
);
if (result.success) {
toast.success(`${cityName} hinzugefügt`);
@ -204,9 +200,7 @@
{/if}
<!-- World Clock List -->
{#if worldClocksStore.loading}
<WorldClockSkeleton />
{:else if worldClocksStore.sortedWorldClocks.length === 0}
{#if allWorldClocks.value.length === 0}
<div class="card py-12 text-center">
<p class="text-lg text-muted-foreground">{$_('worldClock.noClocks')}</p>
<button class="btn btn-primary mt-4" onclick={openAddModal}>
@ -215,7 +209,7 @@
</div>
{:else}
<div class="grid gap-4 sm:grid-cols-2 lg:grid-cols-3">
{#each worldClocksStore.sortedWorldClocks as clock (clock.id)}
{#each allWorldClocks.value as clock (clock.id)}
{@const isDay = isDaytime(clock.timezone)}
<div class="world-clock-card relative">
<!-- Delete button -->
@ -295,9 +289,7 @@
<!-- Timezone list -->
<div class="flex-1 overflow-y-auto -mx-4 px-4">
{#each filteredTimezones as tz}
{@const alreadyAdded = worldClocksStore.worldClocks.some(
(wc) => wc.timezone === tz.timezone
)}
{@const alreadyAdded = allWorldClocks.value.some((wc) => wc.timezone === tz.timezone)}
<button
class="flex w-full items-center justify-between rounded-lg p-3 text-left hover:bg-muted transition-colors"
class:opacity-50={alreadyAdded}

View file

@ -2,7 +2,6 @@
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { contactsApi, photoApi, type Contact } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import ContactNotes from './ContactNotes.svelte';
import ContactTasks from './ContactTasks.svelte';
import { ContactDetailSkeleton } from '$lib/components/skeletons';
@ -135,12 +134,6 @@
}
async function handleSave() {
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
saving = true;
error = null;
try {
@ -184,12 +177,6 @@
}
async function handleDelete() {
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
if (!confirm('Kontakt wirklich löschen?')) return;
deleting = true;
try {
@ -204,12 +191,6 @@
async function handleToggleFavorite() {
if (!contact) return;
// Demo contact: show auth gate
if (contactsStore.isDemoContact(contactId)) {
window.dispatchEvent(new CustomEvent('show-auth-gate'));
return;
}
try {
contact = await contactsApi.toggleFavorite(contactId);
} catch (e) {

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount, onDestroy } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
@ -7,24 +7,24 @@
import { goto } from '$app/navigation';
import ContactGridView from '$lib/components/views/ContactGridView.svelte';
import ContactAlphabetView from '$lib/components/views/ContactAlphabetView.svelte';
import { ContactListSkeleton, ContactGridSkeleton } from '$lib/components/skeletons';
import { batchApi } from '$lib/api/batch';
import { toastStore } from '@manacore/shared-ui';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import type { Contact } from '$lib/api/contacts';
// Infinite scroll
let intersectionObserver: IntersectionObserver | null = null;
let loadMoreTrigger: HTMLDivElement;
// Get reactive contacts from context (live query)
const allContacts: { readonly value: Contact[] } = getContext('contacts');
// Batch selection state
let selectionMode = $state(false);
let selectedIds = $state<Set<string>>(new Set());
let batchLoading = $state(false);
// Derived: non-archived contacts (the main working set)
let contacts = $derived(allContacts.value.filter((c) => !c.isArchived));
// Derived state for selection
let allSelected = $derived(
contactsStore.contacts.length > 0 && contactsStore.contacts.every((c) => selectedIds.has(c.id))
);
let allSelected = $derived(contacts.length > 0 && contacts.every((c) => selectedIds.has(c.id)));
// Helper functions for birthday filtering
function isBirthdayToday(birthday: string | null | undefined): boolean {
@ -57,13 +57,13 @@
return bday.getMonth() === today.getMonth();
}
function isContactIncomplete(contact: (typeof contactsStore.contacts)[0]): boolean {
function isContactIncomplete(contact: Contact): boolean {
return !contact.phone && !contact.mobile && !contact.email;
}
// Filtered and sorted contacts (using filter store)
let filteredContacts = $derived.by(() => {
let result = [...contactsStore.contacts];
let result = [...contacts];
// Apply search filter from InputBar
const searchQuery = contactsFilterStore.searchQuery?.toLowerCase().trim();
@ -172,7 +172,7 @@
if (allSelected) {
selectedIds = new Set();
} else {
selectedIds = new Set(contactsStore.contacts.map((c) => c.id));
selectedIds = new Set(contacts.map((c) => c.id));
}
}
@ -186,7 +186,6 @@
toastStore.success(`${result.success} Kontakte gelöscht`);
selectedIds = new Set();
selectionMode = false;
await contactsStore.loadContacts();
} catch (e) {
toastStore.error(e instanceof Error ? e.message : 'Fehler beim Löschen');
} finally {
@ -203,7 +202,6 @@
toastStore.success(`${result.success} Kontakte archiviert`);
selectedIds = new Set();
selectionMode = false;
await contactsStore.loadContacts();
} catch (e) {
toastStore.error(e instanceof Error ? e.message : 'Fehler beim Archivieren');
} finally {
@ -220,7 +218,6 @@
toastStore.success(`${result.success} Kontakte zu Favoriten hinzugefügt`);
selectedIds = new Set();
selectionMode = false;
await contactsStore.loadContacts();
} catch (e) {
toastStore.error(e instanceof Error ? e.message : 'Fehler');
} finally {
@ -228,67 +225,7 @@
}
}
function setupInfiniteScroll() {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
intersectionObserver = new IntersectionObserver(
(entries) => {
const entry = entries[0];
if (entry?.isIntersecting && contactsStore.hasMore && !contactsStore.loadingMore) {
contactsStore.loadMore();
}
},
{
rootMargin: '200px',
threshold: 0.1,
}
);
if (loadMoreTrigger) {
intersectionObserver.observe(loadMoreTrigger);
}
}
onMount(async () => {
// Only load if not already loaded
if (contactsStore.contacts.length === 0 && !contactsStore.selfContact) {
await contactsStore.loadContacts();
}
// Setup infinite scroll after DOM is ready
setupInfiniteScroll();
});
onDestroy(() => {
if (intersectionObserver) {
intersectionObserver.disconnect();
}
});
// Re-setup observer when trigger element changes
$effect(() => {
if (loadMoreTrigger && intersectionObserver) {
intersectionObserver.disconnect();
intersectionObserver.observe(loadMoreTrigger);
}
});
// Reload contacts when tag filter changes (tag filtering is server-side)
let lastTagId: string | null = null;
$effect(() => {
const currentTagId = contactsFilterStore.selectedTagId;
if (currentTagId !== lastTagId) {
lastTagId = currentTagId;
if (currentTagId) {
contactsStore.setTagId(currentTagId);
} else {
contactsStore.setTagId(undefined);
}
contactsStore.loadContacts();
}
});
// Tag filtering is now client-side via the filteredContacts $derived
</script>
<div class="space-y-6">
@ -372,14 +309,8 @@
</div>
{/if}
<!-- Loading state with skeleton -->
{#if contactsStore.loading}
{#if viewModeStore.mode === 'grid'}
<ContactGridSkeleton count={8} />
{:else}
<ContactListSkeleton count={10} />
{/if}
{:else if contactsStore.contacts.length === 0 && !contactsStore.selfContact}
<!-- Empty state -->
{#if contacts.length === 0}
<!-- Empty state -->
<div class="text-center py-12">
<div class="text-6xl mb-4">👤</div>
@ -390,45 +321,6 @@
</button>
</div>
{:else}
<!-- Self Contact Card ("My Card") -->
{#if contactsStore.selfContact}
{@const self = contactsStore.selfContact}
<button type="button" class="self-contact-card" onclick={() => goto(`/contacts/${self.id}`)}>
<div class="self-contact-avatar">
{#if self.photoUrl}
<img
src={self.photoUrl}
alt={self.displayName || ''}
class="w-full h-full object-cover rounded-full"
/>
{:else}
<span class="text-lg font-semibold text-primary">
{(self.firstName?.[0] || self.email?.[0] || '?').toUpperCase()}
</span>
{/if}
</div>
<div class="flex-1 min-w-0 text-left">
<div class="flex items-center gap-2">
<span class="font-semibold text-foreground truncate">
{self.displayName || self.email || $_('contacts.myCard')}
</span>
<span class="self-badge">{$_('contacts.me')}</span>
</div>
{#if self.email}
<p class="text-sm text-muted-foreground truncate">{self.email}</p>
{/if}
</div>
<svg
class="w-5 h-5 text-muted-foreground shrink-0"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</button>
{/if}
<!-- Contacts View -->
{#if viewModeStore.mode === 'grid'}
<ContactGridView
@ -451,69 +343,15 @@
/>
{/if}
<!-- Infinite scroll trigger & loading more indicator -->
{#if contactsStore.hasMore}
<div bind:this={loadMoreTrigger} class="load-more-trigger">
{#if contactsStore.loadingMore}
<div class="loading-more">
<div class="loading-spinner"></div>
<span>{$_('common.loadingMore')}</span>
</div>
{/if}
</div>
{/if}
<!-- Total count -->
<p class="text-sm text-muted-foreground text-center">
{contactsStore.contacts.length} / {contactsStore.total}
{contactsStore.total === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
{contacts.length}
{contacts.length === 1 ? $_('contacts.contact') : $_('contacts.contactsPlural')}
</p>
{/if}
</div>
<style>
.self-contact-card {
display: flex;
align-items: center;
gap: 0.75rem;
width: 100%;
padding: 0.75rem 1rem;
background: hsl(var(--color-surface));
border: 1px solid hsl(var(--color-border));
border-radius: 0.75rem;
cursor: pointer;
transition: all 0.2s ease;
}
.self-contact-card:hover {
background: hsl(var(--color-surface-hover, var(--color-muted)));
border-color: hsl(var(--color-primary) / 0.3);
}
.self-contact-avatar {
width: 2.75rem;
height: 2.75rem;
border-radius: 50%;
background: hsl(var(--color-primary) / 0.1);
display: flex;
align-items: center;
justify-content: center;
overflow: hidden;
shrink: 0;
}
.self-badge {
font-size: 0.625rem;
font-weight: 600;
text-transform: uppercase;
letter-spacing: 0.05em;
padding: 0.125rem 0.375rem;
border-radius: 0.25rem;
background: hsl(var(--color-primary) / 0.1);
color: hsl(var(--color-primary));
white-space: nowrap;
}
.batch-actions-bar {
display: flex;
align-items: center;
@ -554,35 +392,4 @@
background: hsl(var(--color-error) / 0.15);
color: hsl(var(--color-error));
}
/* Infinite scroll */
.load-more-trigger {
height: 1px;
margin-top: 1rem;
}
.loading-more {
display: flex;
align-items: center;
justify-content: center;
gap: 0.75rem;
padding: 1.5rem;
color: hsl(var(--muted-foreground));
font-size: 0.875rem;
}
.loading-spinner {
width: 1.25rem;
height: 1.25rem;
border: 2px solid hsl(var(--muted));
border-top-color: hsl(var(--primary));
border-radius: 50%;
animation: spin 0.8s linear infinite;
}
@keyframes spin {
to {
transform: rotate(360deg);
}
}
</style>

View file

@ -1,7 +1,6 @@
<script lang="ts">
import { onMount } from 'svelte';
import { contactsApi, photoApi } from '$lib/api/contacts';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import SocialMediaFields from './forms/SocialMediaFields.svelte';
@ -177,8 +176,7 @@
}
}
// Reload contacts list
await contactsStore.loadContacts();
// Live query auto-updates — no manual reload needed
onSuccess?.();
onClose();
} catch (e) {

View file

@ -9,7 +9,7 @@
type GoogleStatus,
type GoogleImportResult,
} from '$lib/api/google';
import { contactsStore } from '$lib/stores/contacts.svelte';
// contactsStore removed — live queries auto-update
import { GoogleImportSkeleton } from '$lib/components/skeletons';
import { ContactsEvents } from '@manacore/shared-utils/analytics';
@ -130,8 +130,7 @@
result = await googleApi.importContacts(Array.from(selectedContacts));
step = 'result';
ContactsEvents.contactImported('google', result.imported);
// Refresh contacts list
await contactsStore.loadContacts();
// Live query auto-updates — no manual reload needed
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to import';
} finally {

View file

@ -0,0 +1,87 @@
/**
* Reactive Queries & Pure Filter Helpers for Contacts
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { contactCollection, type LocalContact } from './local-store';
import type { Contact } from '$lib/api/contacts';
// ─── Type Converter ───────────────────────────────────────
/** Convert a LocalContact (IndexedDB record) to the shared Contact type. */
export function toContact(local: LocalContact): Contact {
const firstName = local.firstName || null;
const lastName = local.lastName || null;
const displayName = [firstName, lastName].filter(Boolean).join(' ') || null;
return {
id: local.id,
userId: 'local',
firstName,
lastName,
displayName,
email: local.email || null,
phone: local.phone || null,
company: local.company || null,
jobTitle: local.jobTitle || null,
notes: local.notes || null,
photoUrl: local.photoUrl || null,
birthday: local.birthday || null,
tags: (local.tags || []).map((name, i) => ({ id: `tag-${i}`, name, color: null })),
isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false,
isSelf: false,
visibility: 'private',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All contacts, sorted by firstName. Auto-updates on any change. */
export function useAllContacts() {
return useLiveQueryWithDefault(async () => {
const locals = await contactCollection.getAll(undefined, {
sortBy: 'firstName',
sortDirection: 'asc',
});
return locals.map(toContact);
}, [] as Contact[]);
}
// ─── Pure Filter Functions (for $derived) ──────────────────
/** Filter contacts by search query across multiple fields. */
export function searchContacts(contacts: Contact[], query: string): Contact[] {
if (!query.trim()) return contacts;
const search = query.toLowerCase().trim();
return contacts.filter(
(c) =>
c.firstName?.toLowerCase().includes(search) ||
c.lastName?.toLowerCase().includes(search) ||
c.displayName?.toLowerCase().includes(search) ||
c.email?.toLowerCase().includes(search) ||
c.company?.toLowerCase().includes(search) ||
c.phone?.toLowerCase().includes(search)
);
}
/** Filter contacts to favorites only. */
export function filterFavorites(contacts: Contact[]): Contact[] {
return contacts.filter((c) => c.isFavorite);
}
/** Filter contacts to archived only. */
export function filterArchived(contacts: Contact[]): Contact[] {
return contacts.filter((c) => c.isArchived);
}
/** Filter contacts to non-archived only. */
export function filterActive(contacts: Contact[]): Contact[] {
return contacts.filter((c) => !c.isArchived);
}

View file

@ -1,235 +1,64 @@
/**
* Contacts Store Local-First with IndexedDB
* Contacts Store Mutation-Only
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery (see $lib/data/queries.ts).
* This store only exposes mutations that write to IndexedDB.
* The live queries will automatically pick up the changes.
*/
import { contactCollection, type LocalContact } from '$lib/data/local-store';
import type { Contact, ContactFilters } from '$lib/api/contacts';
import type { Contact } from '$lib/api/contacts';
import { toContact } from '$lib/data/queries';
import { ContactsEvents } from '@manacore/shared-utils/analytics';
// State — populated from IndexedDB
let contacts = $state<Contact[]>([]);
let selfContact = $state<Contact | null>(null);
let selectedContact = $state<Contact | null>(null);
let loading = $state(false);
let loadingMore = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let filters = $state<ContactFilters>({});
let hasMore = $state(false);
/** Convert a LocalContact (IndexedDB record) to the shared Contact type. */
function toContact(local: LocalContact): Contact {
const firstName = local.firstName || null;
const lastName = local.lastName || null;
const displayName = [firstName, lastName].filter(Boolean).join(' ') || null;
return {
id: local.id,
userId: 'local',
firstName,
lastName,
displayName,
email: local.email || null,
phone: local.phone || null,
company: local.company || null,
jobTitle: local.jobTitle || null,
notes: local.notes || null,
photoUrl: local.photoUrl || null,
birthday: local.birthday || null,
tags: (local.tags || []).map((name, i) => ({ id: `tag-${i}`, name, color: null })),
isFavorite: local.isFavorite ?? false,
isArchived: local.isArchived ?? false,
isSelf: false,
visibility: 'private',
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Load contacts from IndexedDB into reactive state. */
async function refreshContacts(appliedFilters?: ContactFilters) {
const filter: Partial<LocalContact> = {};
if (appliedFilters?.isFavorite !== undefined) filter.isFavorite = appliedFilters.isFavorite;
if (appliedFilters?.isArchived !== undefined) filter.isArchived = appliedFilters.isArchived;
let localContacts = await contactCollection.getAll(
Object.keys(filter).length > 0 ? filter : undefined,
{ sortBy: 'firstName', sortDirection: 'asc' }
);
// Client-side search filter
if (appliedFilters?.search) {
const search = appliedFilters.search.toLowerCase();
localContacts = localContacts.filter(
(c) =>
c.firstName?.toLowerCase().includes(search) ||
c.lastName?.toLowerCase().includes(search) ||
c.email?.toLowerCase().includes(search) ||
c.company?.toLowerCase().includes(search)
);
}
contacts = localContacts.map(toContact);
total = contacts.length;
hasMore = false;
}
export const contactsStore = {
// Getters
get contacts() {
return contacts;
},
get selfContact() {
return selfContact;
},
get selectedContact() {
return selectedContact;
},
get loading() {
return loading;
},
get loadingMore() {
return loadingMore;
},
get error() {
return error;
},
get total() {
return total;
},
get filters() {
return filters;
},
get hasMore() {
return hasMore;
},
/**
* Load contacts with optional filters reads from IndexedDB.
*/
async loadContacts(newFilters?: ContactFilters) {
if (newFilters) {
filters = { ...filters, ...newFilters };
}
loading = true;
error = null;
try {
await refreshContacts(filters);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contacts';
console.error('Failed to load contacts:', e);
} finally {
loading = false;
}
},
/**
* Load more contacts (infinite scroll) no-op in local-first mode since all data is local.
*/
async loadMore() {
// All contacts are already loaded from IndexedDB
},
/**
* Load a single contact by ID reads from IndexedDB.
*/
async loadContact(id: string) {
loading = true;
error = null;
try {
const local = await contactCollection.get(id);
if (local) {
selectedContact = toContact(local);
return selectedContact;
}
return null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load contact';
console.error('Failed to load contact:', e);
return null;
} finally {
loading = false;
}
},
/**
* Create a new contact writes to IndexedDB instantly.
*/
async createContact(data: Partial<Contact>) {
error = null;
const newLocal: LocalContact = {
id: crypto.randomUUID(),
firstName: data.firstName ?? undefined,
lastName: data.lastName ?? undefined,
email: data.email ?? undefined,
phone: data.phone ?? undefined,
company: data.company ?? undefined,
jobTitle: data.jobTitle ?? undefined,
notes: data.notes ?? undefined,
photoUrl: data.photoUrl ?? undefined,
birthday: data.birthday ?? undefined,
tags: data.tags?.map((t) => t.name) ?? [],
isFavorite: false,
isArchived: false,
};
try {
const newLocal: LocalContact = {
id: crypto.randomUUID(),
firstName: data.firstName ?? undefined,
lastName: data.lastName ?? undefined,
email: data.email ?? undefined,
phone: data.phone ?? undefined,
company: data.company ?? undefined,
jobTitle: data.jobTitle ?? undefined,
notes: data.notes ?? undefined,
photoUrl: data.photoUrl ?? undefined,
birthday: data.birthday ?? undefined,
tags: data.tags?.map((t) => t.name) ?? [],
isFavorite: false,
isArchived: false,
};
const inserted = await contactCollection.insert(newLocal);
const newContact = toContact(inserted);
contacts = [newContact, ...contacts];
total += 1;
ContactsEvents.contactCreated();
return newContact;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create contact';
console.error('Failed to create contact:', e);
throw e;
}
const inserted = await contactCollection.insert(newLocal);
ContactsEvents.contactCreated();
return toContact(inserted);
},
/**
* Update a contact writes to IndexedDB instantly.
*/
async updateContact(id: string, data: Partial<Contact>) {
error = null;
const updateData: Partial<LocalContact> = {};
if (data.firstName !== undefined) updateData.firstName = data.firstName ?? undefined;
if (data.lastName !== undefined) updateData.lastName = data.lastName ?? undefined;
if (data.email !== undefined) updateData.email = data.email ?? undefined;
if (data.phone !== undefined) updateData.phone = data.phone ?? undefined;
if (data.company !== undefined) updateData.company = data.company ?? undefined;
if (data.jobTitle !== undefined) updateData.jobTitle = data.jobTitle ?? undefined;
if (data.notes !== undefined) updateData.notes = data.notes ?? undefined;
if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl ?? undefined;
if (data.birthday !== undefined) updateData.birthday = data.birthday ?? undefined;
if (data.tags !== undefined) updateData.tags = data.tags?.map((t) => t.name) ?? [];
if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite;
if (data.isArchived !== undefined) updateData.isArchived = data.isArchived;
try {
const updateData: Partial<LocalContact> = {};
if (data.firstName !== undefined) updateData.firstName = data.firstName ?? undefined;
if (data.lastName !== undefined) updateData.lastName = data.lastName ?? undefined;
if (data.email !== undefined) updateData.email = data.email ?? undefined;
if (data.phone !== undefined) updateData.phone = data.phone ?? undefined;
if (data.company !== undefined) updateData.company = data.company ?? undefined;
if (data.jobTitle !== undefined) updateData.jobTitle = data.jobTitle ?? undefined;
if (data.notes !== undefined) updateData.notes = data.notes ?? undefined;
if (data.photoUrl !== undefined) updateData.photoUrl = data.photoUrl ?? undefined;
if (data.birthday !== undefined) updateData.birthday = data.birthday ?? undefined;
if (data.tags !== undefined) updateData.tags = data.tags?.map((t) => t.name) ?? [];
if (data.isFavorite !== undefined) updateData.isFavorite = data.isFavorite;
if (data.isArchived !== undefined) updateData.isArchived = data.isArchived;
const updated = await contactCollection.update(id, updateData);
if (updated) {
const updatedContact = toContact(updated);
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
if (selectedContact?.id === id) {
selectedContact = updatedContact;
}
ContactsEvents.contactUpdated();
return updatedContact;
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update contact';
console.error('Failed to update contact:', e);
throw e;
const updated = await contactCollection.update(id, updateData);
if (updated) {
ContactsEvents.contactUpdated();
return toContact(updated);
}
},
@ -237,46 +66,23 @@ export const contactsStore = {
* Delete a contact removes from IndexedDB instantly.
*/
async deleteContact(id: string) {
error = null;
try {
await contactCollection.delete(id);
contacts = contacts.filter((c) => c.id !== id);
total -= 1;
if (selectedContact?.id === id) {
selectedContact = null;
}
ContactsEvents.contactDeleted();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete contact';
console.error('Failed to delete contact:', e);
throw e;
}
await contactCollection.delete(id);
ContactsEvents.contactDeleted();
},
/**
* Toggle favorite status writes to IndexedDB instantly.
*/
async toggleFavorite(id: string) {
try {
const local = await contactCollection.get(id);
if (!local) return;
const local = await contactCollection.get(id);
if (!local) return;
const updated = await contactCollection.update(id, {
isFavorite: !local.isFavorite,
} as Partial<LocalContact>);
if (updated) {
const updatedContact = toContact(updated);
contacts = contacts.map((c) => (c.id === id ? updatedContact : c));
if (selectedContact?.id === id) {
selectedContact = updatedContact;
}
ContactsEvents.contactFavorited();
return updatedContact;
}
} catch (e) {
console.error('Failed to toggle favorite:', e);
throw e;
const updated = await contactCollection.update(id, {
isFavorite: !local.isFavorite,
} as Partial<LocalContact>);
if (updated) {
ContactsEvents.contactFavorited();
return toContact(updated);
}
},
@ -284,58 +90,18 @@ export const contactsStore = {
* Toggle archive status writes to IndexedDB instantly.
*/
async toggleArchive(id: string) {
try {
const local = await contactCollection.get(id);
if (!local) return;
const local = await contactCollection.get(id);
if (!local) return;
const updated = await contactCollection.update(id, {
isArchived: !local.isArchived,
} as Partial<LocalContact>);
if (updated) {
// Remove from current view (archived/unarchived toggle)
contacts = contacts.filter((c) => c.id !== id);
total -= 1;
if (selectedContact?.id === id) {
selectedContact = null;
}
ContactsEvents.contactArchived();
return toContact(updated);
}
} catch (e) {
console.error('Failed to toggle archive:', e);
throw e;
const updated = await contactCollection.update(id, {
isArchived: !local.isArchived,
} as Partial<LocalContact>);
if (updated) {
ContactsEvents.contactArchived();
return toContact(updated);
}
},
/**
* Clear filters and reload.
*/
async clearFilters() {
filters = {};
await this.loadContacts();
},
/**
* Set search query.
*/
setSearch(search: string) {
filters = { ...filters, search };
},
/**
* Set tag filter.
*/
setTagId(tagId: string | undefined) {
filters = { ...filters, tagId };
},
/**
* Clear selected contact.
*/
clearSelected() {
selectedContact = null;
},
/**
* No longer relevant all contacts are local and editable.
*/

View file

@ -35,7 +35,7 @@
import { setLocale, supportedLocales } from '$lib/i18n';
import ContactDetailModal from '$lib/components/ContactDetailModal.svelte';
import NewContactModal from '$lib/components/NewContactModal.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import { useAllContacts } from '$lib/data/queries';
import { newContactModalStore } from '$lib/stores/new-contact-modal.svelte';
import { contactsApi, tagsApi } from '$lib/api/contacts';
import { viewModeStore } from '$lib/stores/view-mode.svelte';
@ -71,6 +71,10 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live contacts query + context
const allContacts = useAllContacts();
setContext('contacts', allContacts);
// Check if we're on a contact detail route
const contactDetailMatch = $derived($page.url.pathname.match(/^\/contacts\/([0-9a-f-]{36})$/i));
const showContactModal = $derived(!!contactDetailMatch);
@ -225,9 +229,8 @@
goto('/login');
}
async function handleCloseContactModal() {
// Refresh contacts list in case something was changed
await contactsStore.loadContacts();
function handleCloseContactModal() {
// No need to refresh — live query auto-updates
goto('/', { replaceState: false });
}
@ -322,9 +325,6 @@
// Show guest welcome modal on first visit
initGuestWelcome();
// Load contacts from IndexedDB (guest seed or synced data)
await contactsStore.loadContacts();
// Load user settings only when authenticated
if (authStore.isAuthenticated) {
await userSettings.load();
@ -426,7 +426,7 @@
<!-- Contacts Toolbar (FAB + expandable bar) - only on main page -->
{#if showContactsToolbar}
<ContactsToolbar contacts={contactsStore.contacts} />
<ContactsToolbar contacts={allContacts.value} />
{/if}
{/if}

View file

@ -7,7 +7,7 @@
import GoogleImport from '$lib/components/import/GoogleImport.svelte';
import { importApi, type ImportPreviewResponse, type DuplicateAction } from '$lib/api/import';
import { exportApi, type ExportFormat } from '$lib/api/export';
import { contactsStore } from '$lib/stores/contacts.svelte';
// contactsStore removed — live queries auto-update
import { ImportPreviewSkeleton } from '$lib/components/skeletons';
import { ContactsEvents } from '@manacore/shared-utils/analytics';
import '$lib/i18n';
@ -114,7 +114,7 @@
importStep = 'result';
const fileExt = selectedFile?.name?.endsWith('.csv') ? 'csv' : 'vcard';
ContactsEvents.contactImported(fileExt as 'csv' | 'vcard', importResult?.imported);
await contactsStore.loadContacts();
// Live query auto-updates — no manual reload needed
} catch (e) {
importError = e instanceof Error ? e.message : 'Fehler beim Importieren';
} finally {

View file

@ -26,7 +26,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -1,9 +1,12 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { COLORS } from '@manacore/spiral-db';
import { spiralStore } from '$lib/stores/spiral.svelte';
import { contactsStore } from '$lib/stores/contacts.svelte';
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
import type { Contact } from '$lib/api/contacts';
// Get reactive contacts from context (live query)
const allContacts: { readonly value: Contact[] } = getContext('contacts');
let zoom = $state(10);
let showGrid = $state(false);
@ -11,7 +14,7 @@
let fileInput: HTMLInputElement;
function handleImportContacts() {
spiralStore.importContacts(contactsStore.contacts);
spiralStore.importContacts(allContacts.value);
}
function handlePixelClick(index: number) {
@ -30,12 +33,10 @@
input.value = '';
}
onMount(async () => {
if (contactsStore.contacts.length === 0) {
await contactsStore.loadContacts({});
}
if (contactsStore.contacts.length > 0) {
handleImportContacts();
// Auto-import when contacts become available
$effect(() => {
if (allContacts.value.length > 0) {
spiralStore.importContacts(allContacts.value);
}
});
</script>
@ -183,9 +184,9 @@
<button
class="btn"
onclick={handleImportContacts}
disabled={contactsStore.contacts.length === 0}
disabled={allContacts.value.length === 0}
>
Kontakte neu importieren ({contactsStore.contacts.length})
Kontakte neu importieren ({allContacts.value.length})
</button>
<button
class="btn btn-danger"

View file

@ -1,8 +1,10 @@
<script lang="ts">
import { FileText } from '@manacore/shared-icons';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllDocuments } from '$lib/data/queries';
import type { Document } from '$lib/types';
const allDocs = useAllDocuments();
interface Props {
value: string;
placeholder?: string;
@ -20,7 +22,7 @@
let filteredDocs = $derived(
mentionQuery.trim()
? documentsStore.documents
? (allDocs.value ?? [])
.filter((d) => d.title.toLowerCase().includes(mentionQuery.toLowerCase()))
.slice(0, 6)
: []

View file

@ -0,0 +1,147 @@
/**
* Reactive Queries & Pure Helpers for Context
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
spaceCollection,
documentCollection,
type LocalSpace,
type LocalDocument,
} from './local-store';
import type { Space, Document, DocumentType } from '$lib/types';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalSpace (IndexedDB) to shared Space type. */
export function toSpace(local: LocalSpace): Space {
return {
id: local.id,
name: local.name,
description: local.description ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
settings: local.settings ?? null,
pinned: local.pinned,
prefix: local.prefix,
};
}
/** Convert LocalDocument (IndexedDB) to shared Document type. */
export function toDocument(local: LocalDocument): Document {
return {
id: local.id,
title: local.title,
content: local.content,
type: local.type,
space_id: local.spaceId ?? null,
user_id: 'local',
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
metadata: local.metadata ?? null,
short_id: local.shortId ?? undefined,
pinned: local.pinned,
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All spaces, sorted by name. Auto-updates on any change. */
export function useAllSpaces() {
return useLiveQueryWithDefault(async () => {
const locals = await spaceCollection.getAll();
return locals.map(toSpace).sort((a, b) => a.name.localeCompare(b.name));
}, [] as Space[]);
}
/** All documents. Auto-updates on any change. */
export function useAllDocuments() {
return useLiveQueryWithDefault(async () => {
const locals = await documentCollection.getAll();
return locals
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
/** Documents for a specific space. Auto-updates on any change. */
export function useSpaceDocuments(spaceId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await documentCollection.getAll({ spaceId });
return locals
.map(toDocument)
.sort((a, b) => new Date(b.updated_at).getTime() - new Date(a.updated_at).getTime());
}, [] as Document[]);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Get pinned spaces from a list. */
export function getPinnedSpaces(spaces: Space[]): Space[] {
return spaces.filter((s) => s.pinned);
}
/** Filter documents by type, search query, and tags. */
export function filterDocuments(
documents: Document[],
options: {
typeFilter?: DocumentType | 'all';
searchQuery?: string;
tagFilter?: string[];
}
): Document[] {
let filtered = documents;
if (options.typeFilter && options.typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === options.typeFilter);
}
if (options.searchQuery?.trim()) {
const q = options.searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (options.tagFilter && options.tagFilter.length > 0) {
filtered = filtered.filter((d) =>
options.tagFilter!.some((tag) => d.metadata?.tags?.includes(tag))
);
}
return filtered;
}
/** Compute document stats from a list. */
export function getDocumentStats(documents: Document[]) {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
}
/** Get all unique tags from documents. */
export function getAllDocumentTags(documents: Document[]): string[] {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
}
/** Find a space by ID. */
export function findSpaceById(spaces: Space[], id: string): Space | undefined {
return spaces.find((s) => s.id === id);
}
/** Find a document by ID. */
export function findDocumentById(documents: Document[], id: string): Document | undefined {
return documents.find((d) => d.id === id);
}

View file

@ -1,25 +1,25 @@
/**
* Documents Store Mutation-Only (Local-First)
*
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes and local filter state.
*/
import type { Document, DocumentType } from '$lib/types';
import { ContextEvents } from '@manacore/shared-utils/analytics';
import * as docsService from '$lib/services/documents';
import { documentCollection, type LocalDocument } from '$lib/data/local-store';
import { toDocument } from '$lib/data/queries';
let documents = $state<Document[]>([]);
let currentDocument = $state<Document | null>(null);
let loading = $state(false);
let saving = $state(false);
let error = $state<string | null>(null);
// Filter state
// Filter state (UI-only, not persisted)
let searchQuery = $state('');
let typeFilter = $state<DocumentType | 'all'>('all');
let tagFilter = $state<string[]>([]);
export const documentsStore = {
get documents() {
return documents;
},
get currentDocument() {
return currentDocument;
},
get loading() {
return loading;
},
@ -39,45 +39,6 @@ export const documentsStore = {
return tagFilter;
},
get filteredDocuments() {
let filtered = documents;
if (typeFilter !== 'all') {
filtered = filtered.filter((d) => d.type === typeFilter);
}
if (searchQuery.trim()) {
const q = searchQuery.toLowerCase();
filtered = filtered.filter(
(d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q)
);
}
if (tagFilter.length > 0) {
filtered = filtered.filter((d) => tagFilter.some((tag) => d.metadata?.tags?.includes(tag)));
}
return filtered;
},
get allTags() {
const tags = new Set<string>();
documents.forEach((d) => {
d.metadata?.tags?.forEach((t) => tags.add(t));
});
return Array.from(tags).sort();
},
get stats() {
return {
total: documents.length,
text: documents.filter((d) => d.type === 'text').length,
context: documents.filter((d) => d.type === 'context').length,
prompt: documents.filter((d) => d.type === 'prompt').length,
totalWords: documents.reduce((sum, d) => sum + (d.metadata?.word_count || 0), 0),
};
},
setSearchQuery(query: string) {
searchQuery = query;
},
@ -90,30 +51,6 @@ export const documentsStore = {
tagFilter = tags;
},
async load(spaceId?: string) {
loading = true;
error = null;
try {
documents = await docsService.getDocumentsWithPreview(spaceId);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Dokumente';
} finally {
loading = false;
}
},
async loadDocument(id: string) {
loading = true;
error = null;
try {
currentDocument = await docsService.getDocumentById(id);
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden des Dokuments';
} finally {
loading = false;
}
},
async create(
userId: string,
content: string,
@ -122,21 +59,24 @@ export const documentsStore = {
title?: string
) {
saving = true;
error = null;
try {
const result = await docsService.createDocument(
userId,
const newLocal: LocalDocument = {
id: crypto.randomUUID(),
title: title || 'Neues Dokument',
content,
type,
spaceId,
undefined,
title
);
if (result.data) {
documents = [result.data, ...documents];
currentDocument = result.data;
ContextEvents.documentCreated(type);
}
return result;
spaceId: spaceId || null,
pinned: false,
metadata: null,
};
const inserted = await documentCollection.insert(newLocal);
ContextEvents.documentCreated(type);
return { data: toDocument(inserted), error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen';
error = msg;
return { data: null, error: msg };
} finally {
saving = false;
}
@ -144,64 +84,67 @@ export const documentsStore = {
async update(id: string, updates: Partial<Document>) {
saving = true;
error = null;
try {
const result = await docsService.updateDocument(id, updates);
if (result.success) {
documents = documents.map((d) => (d.id === id ? { ...d, ...updates } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, ...updates };
}
}
return result;
const localUpdates: Partial<LocalDocument> = {};
if (updates.title !== undefined) localUpdates.title = updates.title;
if (updates.content !== undefined) localUpdates.content = updates.content!;
if (updates.type !== undefined) localUpdates.type = updates.type;
if (updates.pinned !== undefined) localUpdates.pinned = updates.pinned!;
if (updates.metadata !== undefined) localUpdates.metadata = updates.metadata;
await documentCollection.update(id, localUpdates);
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Aktualisieren';
error = msg;
return { success: false, error: msg };
} finally {
saving = false;
}
},
async delete(id: string) {
const result = await docsService.deleteDocument(id);
if (result.success) {
error = null;
try {
await documentCollection.delete(id);
ContextEvents.documentDeleted();
documents = documents.filter((d) => d.id !== id);
if (currentDocument?.id === id) {
currentDocument = null;
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
error = msg;
return { success: false, error: msg };
}
return result;
},
async togglePinned(id: string) {
const doc = documents.find((d) => d.id === id);
if (!doc) return;
const newPinned = !doc.pinned;
const result = await docsService.toggleDocumentPinned(id, newPinned);
if (result.success) {
async togglePinned(id: string, currentPinned: boolean) {
error = null;
try {
const newPinned = !currentPinned;
await documentCollection.update(id, { pinned: newPinned });
ContextEvents.documentPinned(newPinned);
documents = documents.map((d) => (d.id === id ? { ...d, pinned: newPinned } : d));
if (currentDocument?.id === id) {
currentDocument = { ...currentDocument, pinned: newPinned };
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Pin-Toggle';
error = msg;
return { success: false, error: msg };
}
return result;
},
async saveTags(id: string, tags: string[]) {
const result = await docsService.saveDocumentTags(id, tags);
if (result.success) {
documents = documents.map((d) =>
d.id === id ? { ...d, metadata: { ...d.metadata, tags } } : d
);
if (currentDocument?.id === id) {
currentDocument = {
...currentDocument,
metadata: { ...currentDocument.metadata, tags },
};
error = null;
try {
const existing = await documentCollection.get(id);
if (existing) {
await documentCollection.update(id, {
metadata: { ...existing.metadata, tags },
});
}
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Speichern der Tags';
error = msg;
return { success: false, error: msg };
}
return result;
},
clearCurrent() {
currentDocument = null;
},
};

View file

@ -1,75 +1,90 @@
/**
* Spaces Store Mutation-Only (Local-First)
*
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes (create, update, delete, toggle).
*/
import type { Space } from '$lib/types';
import { ContextEvents } from '@manacore/shared-utils/analytics';
import * as spacesService from '$lib/services/spaces';
import { spaceCollection, type LocalSpace } from '$lib/data/local-store';
import { toSpace } from '$lib/data/queries';
let spaces = $state<Space[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
export const spacesStore = {
get spaces() {
return spaces;
},
get loading() {
return loading;
},
get error() {
return error;
},
get pinnedSpaces() {
return spaces.filter((s) => s.pinned);
},
async load() {
async create(userId: string, name: string, description?: string) {
loading = true;
error = null;
try {
spaces = await spacesService.getSpaces();
const newLocal: LocalSpace = {
id: crypto.randomUUID(),
name,
description: description || null,
settings: null,
pinned: true,
prefix: name.charAt(0).toUpperCase(),
};
const inserted = await spaceCollection.insert(newLocal);
ContextEvents.spaceCreated();
return { data: toSpace(inserted), error: null };
} catch (e) {
error = e instanceof Error ? e.message : 'Fehler beim Laden der Spaces';
const msg = e instanceof Error ? e.message : 'Fehler beim Erstellen';
error = msg;
return { data: null, error: msg };
} finally {
loading = false;
}
},
async getById(id: string): Promise<Space | null> {
return spacesService.getSpaceById(id);
},
async create(userId: string, name: string, description?: string) {
const result = await spacesService.createSpace(userId, name, description);
if (result.data) {
spaces = [result.data, ...spaces];
ContextEvents.spaceCreated();
}
return result;
},
async update(id: string, updates: Partial<Space>) {
const result = await spacesService.updateSpace(id, updates);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, ...updates } : s));
error = null;
try {
const localUpdates: Partial<LocalSpace> = {};
if (updates.name !== undefined) localUpdates.name = updates.name;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.pinned !== undefined) localUpdates.pinned = updates.pinned;
if (updates.settings !== undefined) localUpdates.settings = updates.settings;
await spaceCollection.update(id, localUpdates);
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Aktualisieren';
error = msg;
return { success: false, error: msg };
}
return result;
},
async togglePinned(id: string) {
const space = spaces.find((s) => s.id === id);
if (!space) return;
const newPinned = !space.pinned;
const result = await spacesService.toggleSpacePinned(id, newPinned);
if (result.success) {
spaces = spaces.map((s) => (s.id === id ? { ...s, pinned: newPinned } : s));
async togglePinned(id: string, currentPinned: boolean) {
error = null;
try {
await spaceCollection.update(id, { pinned: !currentPinned });
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Pin-Toggle';
error = msg;
return { success: false, error: msg };
}
return result;
},
async delete(id: string) {
const result = await spacesService.deleteSpace(id);
if (result.success) {
spaces = spaces.filter((s) => s.id !== id);
error = null;
try {
await spaceCollection.delete(id);
ContextEvents.spaceDeleted();
return { success: true, error: null };
} catch (e) {
const msg = e instanceof Error ? e.message : 'Fehler beim Löschen';
error = msg;
return { success: false, error: msg };
}
return result;
},
};

View file

@ -15,6 +15,7 @@
import { userSettings } from '$lib/stores/user-settings.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllSpaces, useAllDocuments } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -42,6 +43,10 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live queries: all spaces and documents (reactive, auto-updates on IndexedDB changes)
const allSpaces = useAllSpaces();
const allDocuments = useAllDocuments();
let { children } = $props();
let commandBarOpen = $state(false);
@ -70,7 +75,7 @@
const results: CommandBarItem[] = [];
// Search spaces
spacesStore.spaces
(allSpaces.value ?? [])
.filter((s) => s.name.toLowerCase().includes(q) || s.description?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((s) => {
@ -82,7 +87,7 @@
});
// Search documents
documentsStore.documents
(allDocuments.value ?? [])
.filter((d) => d.title.toLowerCase().includes(q) || d.content?.toLowerCase().includes(q))
.slice(0, 5)
.forEach((d) => {
@ -243,7 +248,6 @@
if (authStore.isAuthenticated) {
await userSettings.load();
await Promise.all([spacesStore.load(), documentsStore.load()]);
}
}
</script>

View file

@ -1,29 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import { Folder, FileText, Sparkle, Plus } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllSpaces,
useAllDocuments,
getPinnedSpaces,
getDocumentStats,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import { AppLoadingSkeleton } from '$lib/components/skeletons';
let isLoading = $state(true);
let recentDocs = $state<typeof documentsStore.documents>([]);
onMount(async () => {
await spacesStore.load();
await documentsStore.load();
recentDocs = documentsStore.documents.slice(0, 6);
isLoading = false;
});
const allSpaces = useAllSpaces();
const allDocuments = useAllDocuments();
let spaces = $derived(allSpaces.value ?? []);
let documents = $derived(allDocuments.value ?? []);
let pinnedSpaces = $derived(getPinnedSpaces(spaces));
let stats = $derived(getDocumentStats(documents));
let recentDocs = $derived(documents.slice(0, 6));
function handleDeleteDoc(id: string) {
documentsStore.delete(id);
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
</script>
@ -31,97 +35,93 @@
<title>Context - Dashboard</title>
</svelte:head>
{#if isLoading}
<AppLoadingSkeleton />
{:else}
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Context</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Wissensmanagement Hub</p>
</header>
<div class="dashboard">
<header class="mb-8">
<h1 class="text-2xl font-bold text-foreground">Context</h1>
<p class="text-muted-foreground text-sm mt-1">Dein Wissensmanagement Hub</p>
</header>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{spacesStore.spaces.length}</div>
<div class="text-xs text-muted-foreground mt-1">Spaces</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{documentsStore.stats.total}</div>
<div class="text-xs text-muted-foreground mt-1">Dokumente</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.totalWords.toLocaleString()}
</div>
<div class="text-xs text-muted-foreground mt-1">Wörter</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{documentsStore.stats.text}/{documentsStore.stats.context}/{documentsStore.stats.prompt}
</div>
<div class="text-xs text-muted-foreground mt-1">Text/Kontext/Prompt</div>
</div>
<!-- Stats -->
<div class="grid grid-cols-2 md:grid-cols-4 gap-4 mb-8">
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{spaces.length}</div>
<div class="text-xs text-muted-foreground mt-1">Spaces</div>
</div>
<!-- Quick Actions -->
<div class="flex gap-3 mb-8">
<a href="/spaces" class="btn btn-primary flex items-center gap-2">
<Folder size={16} />
Spaces
</a>
<a href="/documents" class="btn btn-secondary flex items-center gap-2">
<FileText size={16} />
Alle Dokumente
</a>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">{stats.total}</div>
<div class="text-xs text-muted-foreground mt-1">Dokumente</div>
</div>
<!-- Pinned Spaces -->
{#if spacesStore.pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="text-lg font-semibold text-foreground mb-4">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each spacesStore.pinnedSpaces as space}
<SpaceCard {space} />
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Zuletzt bearbeitet</h2>
<a href="/documents" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each recentDocs as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
</section>
{:else}
<div class="card p-8 text-center">
<div class="p-4 rounded-full bg-muted inline-block mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch keine Dokumente</h3>
<p class="text-sm text-muted-foreground mb-4">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a href="/spaces" class="btn btn-primary inline-flex items-center gap-2">
<Plus size={16} />
Ersten Space erstellen
</a>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{stats.totalWords.toLocaleString()}
</div>
{/if}
<div class="text-xs text-muted-foreground mt-1">Wörter</div>
</div>
<div class="card p-4 text-center">
<div class="text-2xl font-bold text-foreground">
{stats.text}/{stats.context}/{stats.prompt}
</div>
<div class="text-xs text-muted-foreground mt-1">Text/Kontext/Prompt</div>
</div>
</div>
{/if}
<!-- Quick Actions -->
<div class="flex gap-3 mb-8">
<a href="/spaces" class="btn btn-primary flex items-center gap-2">
<Folder size={16} />
Spaces
</a>
<a href="/documents" class="btn btn-secondary flex items-center gap-2">
<FileText size={16} />
Alle Dokumente
</a>
</div>
<!-- Pinned Spaces -->
{#if pinnedSpaces.length > 0}
<section class="mb-8">
<h2 class="text-lg font-semibold text-foreground mb-4">Angeheftete Spaces</h2>
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3">
{#each pinnedSpaces as space}
<SpaceCard {space} />
{/each}
</div>
</section>
{/if}
<!-- Recent Documents -->
{#if recentDocs.length > 0}
<section>
<div class="flex items-center justify-between mb-4">
<h2 class="text-lg font-semibold text-foreground">Zuletzt bearbeitet</h2>
<a href="/documents" class="text-sm text-primary hover:underline">Alle anzeigen</a>
</div>
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each recentDocs as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}
onDelete={handleDeleteDoc}
/>
{/each}
</div>
</section>
{:else}
<div class="card p-8 text-center">
<div class="p-4 rounded-full bg-muted inline-block mb-4">
<FileText size={48} class="text-muted-foreground" />
</div>
<h3 class="text-lg font-medium text-foreground mb-2">Noch keine Dokumente</h3>
<p class="text-sm text-muted-foreground mb-4">
Erstelle deinen ersten Space und beginne mit dem Schreiben.
</p>
<a href="/spaces" class="btn btn-primary inline-flex items-center gap-2">
<Plus size={16} />
Ersten Space erstellen
</a>
</div>
{/if}
</div>
<style>
.dashboard {

View file

@ -1,16 +1,33 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass, FileText } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllDocuments,
filterDocuments,
getDocumentStats,
getAllDocumentTags,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import type { DocumentType } from '$lib/types';
let deleteTarget = $state<string | null>(null);
const allDocuments = useAllDocuments();
let documents = $derived(allDocuments.value ?? []);
let stats = $derived(getDocumentStats(documents));
let allTags = $derived(getAllDocumentTags(documents));
let filteredDocuments = $derived(
filterDocuments(documents, {
typeFilter: documentsStore.typeFilter,
searchQuery: documentsStore.searchQuery,
tagFilter: documentsStore.tagFilter,
})
);
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
{ value: 'all', label: 'Alle' },
{ value: 'text', label: 'Text' },
@ -18,10 +35,6 @@
{ value: 'prompt', label: 'Prompt' },
];
onMount(async () => {
await documentsStore.load();
});
async function handleCreateDocument() {
if (!authStore.user?.id) return;
const result = await documentsStore.create(
@ -48,7 +61,8 @@
}
function handleTogglePin(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
</script>
@ -61,7 +75,7 @@
<div>
<h1 class="text-2xl font-bold text-foreground">{$_('documents.title')}</h1>
<p class="text-sm text-muted-foreground mt-1">
{documentsStore.stats.total} Dokumente, {documentsStore.stats.totalWords.toLocaleString()} Wörter
{stats.total} Dokumente, {stats.totalWords.toLocaleString()} Wörter
</p>
</div>
<button class="btn btn-primary flex items-center gap-2" onclick={handleCreateDocument}>
@ -84,13 +98,13 @@
>
{filter.label}
{#if filter.value === 'all'}
<span class="ml-1 opacity-60">{documentsStore.stats.total}</span>
<span class="ml-1 opacity-60">{stats.total}</span>
{:else if filter.value === 'text'}
<span class="ml-1 opacity-60">{documentsStore.stats.text}</span>
<span class="ml-1 opacity-60">{stats.text}</span>
{:else if filter.value === 'context'}
<span class="ml-1 opacity-60">{documentsStore.stats.context}</span>
<span class="ml-1 opacity-60">{stats.context}</span>
{:else if filter.value === 'prompt'}
<span class="ml-1 opacity-60">{documentsStore.stats.prompt}</span>
<span class="ml-1 opacity-60">{stats.prompt}</span>
{/if}
</button>
{/each}
@ -112,9 +126,9 @@
</div>
<!-- Tags filter -->
{#if documentsStore.allTags.length > 0}
{#if allTags.length > 0}
<div class="flex flex-wrap gap-2 mb-4">
{#each documentsStore.allTags as tag}
{#each allTags as tag}
<button
class="text-xs px-2 py-1 rounded-full transition-colors"
class:bg-primary={documentsStore.tagFilter.includes(tag)}
@ -139,9 +153,9 @@
<!-- Document list -->
{#if documentsStore.loading}
<div class="text-center py-12 text-muted-foreground">Lade Dokumente...</div>
{:else if documentsStore.filteredDocuments.length > 0}
{:else if filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
{#each filteredDocuments as doc}
<DocumentCard document={doc} onTogglePin={handleTogglePin} onDelete={handleDeleteClick} />
{/each}
</div>

View file

@ -5,6 +5,7 @@
import { ArrowLeft, Trash, Sparkle } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import { useAllDocuments, findDocumentById } from '$lib/data/queries';
import { tokensStore } from '$lib/stores/tokens.svelte';
import DocumentEditor from '$lib/components/DocumentEditor.svelte';
import AIToolbar from '$lib/components/AIToolbar.svelte';
@ -12,32 +13,19 @@
import type { Document, DocumentType } from '$lib/types';
import type { InsertionMode } from '$lib/services/ai';
let loading = $state(true);
let showDeleteConfirm = $state(false);
let showAI = $state(false);
let docId = $derived($page.params.id || '');
let doc = $derived(documentsStore.currentDocument);
const allDocuments = useAllDocuments();
let doc = $derived(findDocumentById(allDocuments.value ?? [], docId) ?? null);
onMount(() => {
const init = async () => {
await documentsStore.loadDocument(docId);
if (!documentsStore.currentDocument) {
goto('/documents');
return;
}
loading = false;
// Load token balance
if (authStore.user?.id) {
tokensStore.loadBalance(authStore.user.id);
}
};
init();
return () => {
documentsStore.clearCurrent();
};
// Load token balance
if (authStore.user?.id) {
tokensStore.loadBalance(authStore.user.id);
}
});
function handleSave(updates: Partial<Document>) {
@ -64,9 +52,7 @@
} else if (mode === 'replace') {
documentsStore.update(docId, { content: text });
}
// Reload document to get updated content
documentsStore.loadDocument(docId);
// liveQuery will automatically update the doc
}
async function handleDelete() {
@ -84,7 +70,7 @@
</svelte:head>
<div class="mx-auto max-w-4xl pb-48">
{#if loading}
{#if !doc && !allDocuments.error}
<div class="text-center py-12 text-muted-foreground">Lade Dokument...</div>
{:else if doc}
<!-- Breadcrumb -->

View file

@ -1,9 +1,9 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { Plus, MagnifyingGlass } from '@manacore/shared-icons';
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { useAllSpaces } from '$lib/data/queries';
import SpaceCard from '$lib/components/SpaceCard.svelte';
import CreateSpaceModal from '$lib/components/CreateSpaceModal.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
@ -15,20 +15,19 @@
let deleteTarget = $state<string | null>(null);
let editTarget = $state<Space | null>(null);
const allSpaces = useAllSpaces();
let spaces = $derived(allSpaces.value ?? []);
let filteredSpaces = $derived(
searchQuery.trim()
? spacesStore.spaces.filter(
? spaces.filter(
(s) =>
s.name.toLowerCase().includes(searchQuery.toLowerCase()) ||
s.description?.toLowerCase().includes(searchQuery.toLowerCase())
)
: spacesStore.spaces
: spaces
);
onMount(async () => {
await spacesStore.load();
});
async function handleCreate(name: string, description: string) {
if (!authStore.user?.id) return;
creating = true;
@ -38,7 +37,8 @@
}
function handleTogglePin(id: string) {
spacesStore.togglePinned(id);
const space = spaces.find((s) => s.id === id);
spacesStore.togglePinned(id, space?.pinned ?? false);
}
function handleDeleteClick(id: string) {

View file

@ -1,5 +1,4 @@
<script lang="ts">
import { onMount } from 'svelte';
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import {
@ -14,13 +13,18 @@
import { authStore } from '$lib/stores/auth.svelte';
import { spacesStore } from '$lib/stores/spaces.svelte';
import { documentsStore } from '$lib/stores/documents.svelte';
import {
useAllSpaces,
useSpaceDocuments,
filterDocuments,
getDocumentStats,
findSpaceById,
} from '$lib/data/queries';
import DocumentCard from '$lib/components/DocumentCard.svelte';
import ConfirmDialog from '$lib/components/ConfirmDialog.svelte';
import BatchCreateModal from '$lib/components/BatchCreateModal.svelte';
import type { Space, DocumentType } from '$lib/types';
let space = $state<Space | null>(null);
let loading = $state(true);
let editingName = $state(false);
let editName = $state('');
let editDescription = $state('');
@ -30,16 +34,24 @@
let spaceId = $derived($page.params.id || '');
onMount(async () => {
space = await spacesStore.getById(spaceId);
if (!space) {
goto('/spaces');
return;
const allSpaces = useAllSpaces();
const spaceDocs = useSpaceDocuments(spaceId);
let space = $derived(findSpaceById(allSpaces.value ?? [], spaceId) ?? null);
let documents = $derived(spaceDocs.value ?? []);
let stats = $derived(getDocumentStats(documents));
let filteredDocuments = $derived(
filterDocuments(documents, {
typeFilter: documentsStore.typeFilter,
searchQuery: documentsStore.searchQuery,
})
);
// Keep editName/editDescription in sync with space
$effect(() => {
if (space && !editingName) {
editName = space.name;
editDescription = space.description || '';
}
editName = space.name;
editDescription = space.description || '';
await documentsStore.load(spaceId);
loading = false;
});
async function handleCreateDocument() {
@ -90,7 +102,8 @@
}
function handleTogglePinDoc(id: string) {
documentsStore.togglePinned(id);
const doc = documents.find((d) => d.id === id);
documentsStore.togglePinned(id, doc?.pinned ?? false);
}
async function handleBatchCreate(items: { title: string; type: DocumentType }[]) {
@ -107,7 +120,6 @@
}
batchCreating = false;
showBatchCreate = false;
await documentsStore.load(spaceId);
}
const typeFilters: { value: DocumentType | 'all'; label: string }[] = [
@ -133,7 +145,7 @@
<span class="text-foreground font-medium">{space?.name || '...'}</span>
</div>
{#if loading}
{#if !space && !allSpaces.error}
<div class="text-center py-12 text-muted-foreground">Lade...</div>
{:else if space}
<!-- Space Header -->
@ -168,8 +180,8 @@
<p class="text-sm text-muted-foreground mt-1">{space.description}</p>
{/if}
<div class="flex gap-4 mt-3 text-xs text-muted-foreground">
<span>{documentsStore.stats.total} Dokumente</span>
<span>{documentsStore.stats.totalWords.toLocaleString()} Wörter</span>
<span>{stats.total} Dokumente</span>
<span>{stats.totalWords.toLocaleString()} Wörter</span>
</div>
</div>
<button
@ -233,9 +245,9 @@
</div>
<!-- Documents -->
{#if documentsStore.filteredDocuments.length > 0}
{#if filteredDocuments.length > 0}
<div class="grid grid-cols-1 md:grid-cols-2 gap-3">
{#each documentsStore.filteredDocuments as doc}
{#each filteredDocuments as doc}
<DocumentCard
document={doc}
onTogglePin={handleTogglePinDoc}

View file

@ -0,0 +1,90 @@
/**
* Reactive Queries & Pure Helpers for ManaDeck
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import { deckCollection, cardCollection, type LocalDeck, type LocalCard } from './local-store';
import type { Deck } from '$lib/types/deck';
import type { Card } from '$lib/types/card';
// ─── Type Converters ───────────────────────────────────────
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
user_id: 'guest',
title: local.name,
description: local.description ?? undefined,
is_public: local.isPublic,
settings: {},
tags: [],
metadata: {},
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
card_count: local.cardCount,
};
}
export function toCard(local: LocalCard): Card {
return {
id: local.id,
deck_id: local.deckId,
position: local.order,
title: local.front,
content: {
front: local.front,
back: local.back,
},
card_type: 'flashcard',
version: 1,
is_favorite: false,
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All decks, auto-updates on any change. */
export function useAllDecks() {
return useLiveQueryWithDefault(async () => {
const locals = await deckCollection.getAll();
return locals.map(toDeck);
}, [] as Deck[]);
}
/** All cards for a specific deck, sorted by order. Auto-updates on any change. */
export function useCardsByDeck(deckId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await cardCollection.getAll(
{ deckId },
{ sortBy: 'order', sortDirection: 'asc' }
);
return locals.map(toCard);
}, [] as Card[]);
}
/** Single deck by ID. Auto-updates on any change. */
export function useDeck(deckId: string) {
return useLiveQueryWithDefault(
async () => {
const local = await deckCollection.get(deckId);
return local ? toDeck(local) : null;
},
null as Deck | null
);
}
// ─── Pure Helper Functions ─────────────────────────────────
export function getDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}
export function getPublicDecks(decks: Deck[]): Deck[] {
return decks.filter((d) => d.is_public);
}

View file

@ -1,100 +1,28 @@
/**
* Card Store Local-First with Dexie.js
* Card Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete, reorder).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import type { Card, CreateCardInput, UpdateCardInput } from '$lib/types/card';
import { cardCollection, deckCollection, type LocalCard } from '$lib/data/local-store';
import { toCard } from '$lib/data/queries';
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
// Svelte 5 runes-based card store
let cards = $state<Card[]>([]);
let currentCard = $state<Card | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalCard (IndexedDB record) to the shared Card type. */
function toCard(local: LocalCard): Card {
return {
id: local.id,
deck_id: local.deckId,
position: local.order,
title: local.front,
content: {
front: local.front,
back: local.back,
},
card_type: 'flashcard',
version: 1,
is_favorite: false,
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
};
}
export const cardStore = {
get cards() {
return cards;
},
get currentCard() {
return currentCard;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all cards for a deck reads from IndexedDB.
* Create new card -- writes to IndexedDB instantly.
*/
async fetchCards(deckId: string) {
loading = true;
async createCard(input: CreateCardInput, currentCardCount: number = 0): Promise<Card | null> {
error = null;
try {
const localCards = await cardCollection.getAll(
{ deckId },
{ sortBy: 'order', sortDirection: 'asc' }
);
cards = localCards.map(toCard);
} catch (err: any) {
error = err.message || 'Failed to fetch cards';
console.error('Fetch cards error:', err);
} finally {
loading = false;
}
},
/**
* Fetch single card by ID reads from IndexedDB.
*/
async fetchCard(id: string) {
loading = true;
error = null;
try {
const localCard = await cardCollection.get(id);
currentCard = localCard ? toCard(localCard) : null;
} catch (err: any) {
error = err.message || 'Failed to fetch card';
console.error('Fetch card error:', err);
} finally {
loading = false;
}
},
/**
* Create new card writes to IndexedDB instantly.
*/
async createCard(input: CreateCardInput): Promise<Card | null> {
loading = true;
error = null;
try {
const content = input.content as { front?: string; back?: string; text?: string };
const newLocal: LocalCard = {
@ -104,12 +32,10 @@ export const cardStore = {
back: content.back || '',
difficulty: 1,
reviewCount: 0,
order: input.position ?? cards.length,
order: input.position ?? currentCardCount,
};
const inserted = await cardCollection.insert(newLocal);
const card = toCard(inserted);
cards = [...cards, card];
// Update deck card count
const deck = await deckCollection.get(input.deck_id);
@ -120,23 +46,19 @@ export const cardStore = {
}
ManaDeckEvents.cardCreated();
return card;
return toCard(inserted);
} catch (err: any) {
error = err.message || 'Failed to create card';
console.error('Create card error:', err);
return null;
} finally {
loading = false;
}
},
/**
* Update card writes to IndexedDB instantly.
* Update card -- writes to IndexedDB instantly.
*/
async updateCard(id: string, updates: UpdateCardInput) {
loading = true;
error = null;
try {
const localUpdates: Partial<LocalCard> = {};
if (updates.content) {
@ -147,96 +69,53 @@ export const cardStore = {
if (updates.title !== undefined) localUpdates.front = updates.title;
if (updates.position !== undefined) localUpdates.order = updates.position;
const updated = await cardCollection.update(id, localUpdates);
if (updated) {
const updatedCard = toCard(updated);
cards = cards.map((c) => (c.id === id ? updatedCard : c));
if (currentCard?.id === id) {
currentCard = updatedCard;
}
}
await cardCollection.update(id, localUpdates);
} catch (err: any) {
error = err.message || 'Failed to update card';
console.error('Update card error:', err);
} finally {
loading = false;
}
},
/**
* Delete card writes to IndexedDB instantly.
* Delete card -- writes to IndexedDB instantly.
*/
async deleteCard(id: string) {
loading = true;
async deleteCard(id: string, deckId?: string) {
error = null;
try {
// Find the card to get its deckId before deleting
const card = cards.find((c) => c.id === id);
await cardCollection.delete(id);
cards = cards.filter((c) => c.id !== id);
// Update deck card count
if (card) {
const deck = await deckCollection.get(card.deck_id);
if (deckId) {
const deck = await deckCollection.get(deckId);
if (deck) {
await deckCollection.update(card.deck_id, {
await deckCollection.update(deckId, {
cardCount: Math.max(0, (deck.cardCount || 0) - 1),
});
}
}
ManaDeckEvents.cardDeleted();
if (currentCard?.id === id) {
currentCard = null;
}
} catch (err: any) {
error = err.message || 'Failed to delete card';
console.error('Delete card error:', err);
} finally {
loading = false;
}
},
/**
* Reorder cards writes to IndexedDB instantly.
* Reorder cards -- writes to IndexedDB instantly.
*/
async reorderCards(deckId: string, cardIds: string[]) {
loading = true;
error = null;
try {
// Update local positions
cards = cardIds
.map((id, index) => {
const card = cards.find((c) => c.id === id);
return card ? { ...card, position: index } : card!;
})
.filter(Boolean);
// Persist each order change to IndexedDB
for (let i = 0; i < cardIds.length; i++) {
await cardCollection.update(cardIds[i], { order: i } as Partial<LocalCard>);
}
} catch (err: any) {
error = err.message || 'Failed to reorder cards';
console.error('Reorder cards error:', err);
} finally {
loading = false;
}
},
/**
* Clear cards (when changing decks)
*/
clearCards() {
cards = [];
currentCard = null;
error = null;
},
/**
* Clear error
*/

View file

@ -1,100 +1,29 @@
/**
* Deck Store Local-First with Dexie.js
* Deck Store Mutation-Only Service
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* All reads are handled by useLiveQuery() hooks in queries.ts.
* This store only provides write operations (create, update, delete).
* IndexedDB writes automatically trigger UI updates via Dexie liveQuery.
*/
import type { Deck, CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
import type { CreateDeckInput, UpdateDeckInput } from '$lib/types/deck';
import { deckCollection, cardCollection, type LocalDeck } from '$lib/data/local-store';
import { toDeck } from '$lib/data/queries';
import { ManaDeckEvents } from '@manacore/shared-utils/analytics';
import type { Deck } from '$lib/types/deck';
// Svelte 5 runes-based deck store
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
/** Convert a LocalDeck (IndexedDB record) to the shared Deck type. */
function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
user_id: 'guest',
title: local.name,
description: local.description ?? undefined,
is_public: local.isPublic,
settings: {},
tags: [],
metadata: {},
created_at: local.createdAt ?? new Date().toISOString(),
updated_at: local.updatedAt ?? new Date().toISOString(),
card_count: local.cardCount,
};
}
export const deckStore = {
get decks() {
return decks;
},
get currentDeck() {
return currentDeck;
},
get loading() {
return loading;
},
get error() {
return error;
},
/**
* Fetch all decks for current user reads from IndexedDB.
*/
async fetchDecks() {
loading = true;
error = null;
try {
const localDecks = await deckCollection.getAll();
decks = localDecks.map(toDeck);
} catch (err: any) {
error = err.message || 'Failed to fetch decks';
console.error('Fetch decks error:', err);
} finally {
loading = false;
}
},
/**
* Fetch single deck by ID reads from IndexedDB.
*/
async fetchDeck(id: string) {
loading = true;
error = null;
try {
const localDeck = await deckCollection.get(id);
if (localDeck) {
currentDeck = toDeck(localDeck);
} else {
currentDeck = null;
throw new Error('Deck not found');
}
} catch (err: any) {
error = err.message || 'Failed to fetch deck';
console.error('Fetch deck error:', err);
} finally {
loading = false;
}
},
/**
* Create new deck writes to IndexedDB instantly.
* Create new deck -- writes to IndexedDB instantly.
*/
async createDeck(input: CreateDeckInput): Promise<Deck | null> {
loading = true;
error = null;
try {
const newLocal: LocalDeck = {
id: crypto.randomUUID(),
@ -106,56 +35,38 @@ export const deckStore = {
};
const inserted = await deckCollection.insert(newLocal);
const deck = toDeck(inserted);
decks = [deck, ...decks];
ManaDeckEvents.deckCreated();
return deck;
return toDeck(inserted);
} catch (err: any) {
error = err.message || 'Failed to create deck';
console.error('Create deck error:', err);
return null;
} finally {
loading = false;
}
},
/**
* Update deck writes to IndexedDB instantly.
* Update deck -- writes to IndexedDB instantly.
*/
async updateDeck(id: string, updates: UpdateDeckInput) {
loading = true;
error = null;
try {
const localUpdates: Partial<LocalDeck> = {};
if (updates.title !== undefined) localUpdates.name = updates.title;
if (updates.description !== undefined) localUpdates.description = updates.description;
if (updates.is_public !== undefined) localUpdates.isPublic = updates.is_public;
const updated = await deckCollection.update(id, localUpdates);
if (updated) {
const updatedDeck = toDeck(updated);
decks = decks.map((d) => (d.id === id ? updatedDeck : d));
if (currentDeck?.id === id) {
currentDeck = updatedDeck;
}
}
await deckCollection.update(id, localUpdates);
} catch (err: any) {
error = err.message || 'Failed to update deck';
console.error('Update deck error:', err);
} finally {
loading = false;
}
},
/**
* Delete deck writes to IndexedDB instantly.
* Delete deck -- writes to IndexedDB instantly.
*/
async deleteDeck(id: string) {
loading = true;
error = null;
try {
// Delete all cards belonging to this deck
const cards = await cardCollection.getAll({ deckId: id });
@ -164,17 +75,10 @@ export const deckStore = {
}
await deckCollection.delete(id);
decks = decks.filter((d) => d.id !== id);
ManaDeckEvents.deckDeleted();
if (currentDeck?.id === id) {
currentDeck = null;
}
} catch (err: any) {
error = err.message || 'Failed to delete deck';
console.error('Delete deck error:', err);
} finally {
loading = false;
}
},
@ -184,14 +88,4 @@ export const deckStore = {
clearError() {
error = null;
},
/**
* Clear all state
*/
clear() {
decks = [];
currentDeck = null;
loading = false;
error = null;
},
};

View file

@ -24,7 +24,7 @@
import { getLanguageDropdownItems, getCurrentLanguageLabel } from '@manacore/shared-i18n';
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { deckStore } from '$lib/stores/deckStore.svelte';
import { useAllDecks } from '$lib/data/queries';
import { manadeckOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -34,7 +34,12 @@
// App switcher items
const appItems = getPillAppItems('manadeck');
// Live queries — auto-update when IndexedDB changes (local writes, sync, other tabs)
const allDecks = useAllDecks();
const allTags = useAllSharedTags();
// Provide data to child components via Svelte context
setContext('decks', allDecks);
setContext('tags', allTags);
let { children } = $props();
@ -170,7 +175,7 @@
// QuickInputBar handlers
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
return deckStore.decks
return allDecks.value
.filter((d) => d.title.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q))
.slice(0, 10)
.map((deck) => ({
@ -195,9 +200,6 @@
tagMutations.startSync(getToken);
}
// Load decks from IndexedDB (guest seed or synced data)
await deckStore.fetchDecks();
// Show guest welcome modal on first visit
if (!authStore.isAuthenticated && shouldShowGuestWelcome('manadeck')) {
showGuestWelcome = true;

View file

@ -1,5 +1,5 @@
<script lang="ts">
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import { Button, ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
@ -7,13 +7,12 @@
import CreateDeckModal from '$lib/components/deck/CreateDeckModal.svelte';
import type { Deck } from '$lib/types/deck';
// Get live query data from layout context
const allDecks: { readonly value: Deck[] } = getContext('decks');
let showCreateModal = $state(false);
let contextMenu = $state({ visible: false, x: 0, y: 0, target: null as Deck | null });
onMount(() => {
deckStore.fetchDecks();
});
function handleDeckClick(deckId: string) {
goto(`/decks/${deckId}`);
}
@ -62,26 +61,13 @@
</Button>
</div>
<!-- Loading State -->
{#if deckStore.loading && deckStore.decks.length === 0}
<div class="flex justify-center py-12">
<div class="text-center">
<div
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
></div>
<p class="mt-4 text-muted-foreground">Loading decks...</p>
</div>
</div>
{:else if deckStore.error}
<!-- Error State -->
<!-- Error State -->
{#if deckStore.error}
<div class="p-4 rounded-lg bg-destructive/10 text-destructive">
<p class="font-medium">Error loading decks</p>
<p class="text-sm mt-1">{deckStore.error}</p>
<Button variant="outline" class="mt-3" onclick={() => deckStore.fetchDecks()}>
Try Again
</Button>
</div>
{:else if deckStore.decks.length === 0}
{:else if allDecks.value.length === 0}
<!-- Empty State -->
<div class="text-center py-12">
<div class="text-6xl mb-4">📚</div>
@ -94,7 +80,7 @@
{:else}
<!-- Decks Grid -->
<div class="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-6">
{#each deckStore.decks as deck (deck.id)}
{#each allDecks.value as deck (deck.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div oncontextmenu={(e) => handleContextMenu(e, deck)}>
<DeckCard {deck} onclick={() => handleDeckClick(deck.id)} />

View file

@ -4,24 +4,24 @@
import { goto } from '$app/navigation';
import { deckStore } from '$lib/stores/deckStore.svelte';
import { progressStore } from '$lib/stores/progressStore.svelte';
import { cardStore } from '$lib/stores/cardStore.svelte';
import { useDeck, useCardsByDeck } from '$lib/data/queries';
import { Button, Badge, Card } from '@manacore/shared-ui';
let deckId = $derived($page.params.id);
let showDeleteConfirm = $state(false);
let deleting = $state(false);
// Live queries for this deck's data
const currentDeck = useDeck(deckId);
const deckCards = useCardsByDeck(deckId);
// Calculate deck-specific progress
let dueCount = $state(0);
let masteredCount = $state(0);
onMount(async () => {
if (deckId) {
await Promise.all([
deckStore.fetchDeck(deckId),
progressStore.fetchDeckProgress(deckId),
cardStore.fetchCards(deckId),
]);
await progressStore.fetchDeckProgress(deckId);
// Calculate progress
const progress = progressStore.cardProgress;
@ -40,9 +40,7 @@
await deckStore.deleteDeck(deckId);
deleting = false;
if (!deckStore.error) {
goto('/decks');
}
goto('/decks');
}
function handleStudy() {
@ -51,20 +49,11 @@
</script>
<svelte:head>
<title>{deckStore.currentDeck?.title || 'Deck'} - Manadeck</title>
<title>{currentDeck.value?.title || 'Deck'} - Manadeck</title>
</svelte:head>
{#if deckStore.loading && !deckStore.currentDeck}
<div class="flex justify-center py-12">
<div class="text-center">
<div
class="inline-block animate-spin h-8 w-8 border-4 border-primary border-t-transparent rounded-full"
></div>
<p class="mt-4 text-muted-foreground">Loading deck...</p>
</div>
</div>
{:else if deckStore.currentDeck}
{@const deck = deckStore.currentDeck}
{#if currentDeck.value}
{@const deck = currentDeck.value}
<div class="space-y-6">
<!-- Back Button -->
<button
@ -106,7 +95,7 @@
<div class="grid grid-cols-1 md:grid-cols-3 gap-4">
<Card>
<div class="text-center">
<div class="text-3xl font-bold">{cardStore.cards.length || deck.card_count || 0}</div>
<div class="text-3xl font-bold">{deckCards.value.length || deck.card_count || 0}</div>
<div class="text-sm text-muted-foreground">Total Cards</div>
</div>
</Card>

View file

@ -0,0 +1,73 @@
/**
* Reactive Queries & Pure Helpers for Presi
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQuery, useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import type { Deck, Slide } from '@presi/shared';
import { deckCollection, slideCollection, type LocalDeck, type LocalSlide } from './local-store';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalDeck (IndexedDB) to shared Deck type. */
export function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalSlide (IndexedDB) to shared Slide type. */
export function toSlide(local: LocalSlide): Slide {
return {
id: local.id,
deckId: local.deckId,
order: local.order,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All decks, sorted by updatedAt descending. Auto-updates on any change. */
export function useAllDecks() {
return useLiveQueryWithDefault(async () => {
const locals = await deckCollection.getAll();
return locals
.map(toDeck)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [] as Deck[]);
}
/** Slides for a specific deck, sorted by order. Auto-updates on any change. */
export function useDeckSlides(deckId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await slideCollection.getAll({ deckId });
return locals.map(toSlide).sort((a, b) => a.order - b.order);
}, [] as Slide[]);
}
/** Single deck by ID. Has loading state. Auto-updates on any change. */
export function useDeck(id: string) {
return useLiveQuery(async () => {
const local = await deckCollection.get(id);
return local ? toDeck(local) : null;
});
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Find a deck by ID from a list. */
export function findDeckById(decks: Deck[], id: string): Deck | undefined {
return decks.find((d) => d.id === id);
}

View file

@ -1,18 +1,17 @@
/**
* Decks Store Local-First with Dexie.js
* Decks Store Mutation-Only
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Same public API as before so components don't need changes.
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store only handles writes (create, update, delete).
*/
import type {
Deck,
Slide,
CreateDeckDto,
UpdateDeckDto,
CreateSlideDto,
UpdateSlideDto,
Deck,
Slide,
} from '@presi/shared';
import {
deckCollection,
@ -20,74 +19,12 @@ import {
type LocalDeck,
type LocalSlide,
} from '$lib/data/local-store';
import { toDeck, toSlide } from '$lib/data/queries';
let decks = $state<Deck[]>([]);
let currentDeck = $state<Deck | null>(null);
let currentSlides = $state<Slide[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
/** Convert LocalDeck (IndexedDB) to shared Deck type. */
function toDeck(local: LocalDeck): Deck {
return {
id: local.id,
userId: 'local',
title: local.title,
description: local.description ?? undefined,
themeId: local.themeId ?? undefined,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalSlide (IndexedDB) to shared Slide type. */
function toSlide(local: LocalSlide): Slide {
return {
id: local.id,
deckId: local.deckId,
order: local.order,
content: local.content,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
function createDecksStore() {
async function loadDecks() {
isLoading = true;
error = null;
try {
const localDecks = await deckCollection.getAll();
decks = localDecks.map(toDeck);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load decks';
console.error('Failed to load decks:', e);
} finally {
isLoading = false;
}
}
async function loadDeck(id: string) {
isLoading = true;
error = null;
try {
const localDeck = await deckCollection.get(id);
if (localDeck) {
currentDeck = toDeck(localDeck);
} else {
currentDeck = null;
throw new Error('Deck not found');
}
const localSlides = await slideCollection.getAll({ deckId: id });
currentSlides = localSlides.map(toSlide).sort((a, b) => a.order - b.order);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load deck';
console.error('Failed to load deck:', e);
} finally {
isLoading = false;
}
}
async function createDeck(dto: CreateDeckDto): Promise<Deck | null> {
isLoading = true;
error = null;
@ -100,9 +37,7 @@ function createDecksStore() {
isPublic: false,
};
const inserted = await deckCollection.insert(newLocal);
const deck = toDeck(inserted);
decks = [deck, ...decks];
return deck;
return toDeck(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create deck';
console.error('Failed to create deck:', e);
@ -121,14 +56,7 @@ function createDecksStore() {
if (dto.themeId !== undefined) localUpdates.themeId = dto.themeId;
if (dto.isPublic !== undefined) localUpdates.isPublic = dto.isPublic;
const updated = await deckCollection.update(id, localUpdates);
if (updated) {
const updatedDeck = toDeck(updated);
decks = decks.map((d) => (d.id === id ? updatedDeck : d));
if (currentDeck?.id === id) {
currentDeck = updatedDeck;
}
}
await deckCollection.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update deck';
@ -147,11 +75,6 @@ function createDecksStore() {
}
await deckCollection.delete(id);
decks = decks.filter((d) => d.id !== id);
if (currentDeck?.id === id) {
currentDeck = null;
currentSlides = [];
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete deck';
@ -163,7 +86,8 @@ function createDecksStore() {
async function createSlide(deckId: string, dto: CreateSlideDto): Promise<Slide | null> {
error = null;
try {
const order = dto.order ?? currentSlides.length + 1;
const existingSlides = await slideCollection.getAll({ deckId });
const order = dto.order ?? existingSlides.length + 1;
const newLocal: LocalSlide = {
id: crypto.randomUUID(),
deckId,
@ -171,9 +95,7 @@ function createDecksStore() {
content: dto.content,
};
const inserted = await slideCollection.insert(newLocal);
const slide = toSlide(inserted);
currentSlides = [...currentSlides, slide].sort((a, b) => a.order - b.order);
return slide;
return toSlide(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create slide';
console.error('Failed to create slide:', e);
@ -188,12 +110,7 @@ function createDecksStore() {
if (dto.content !== undefined) localUpdates.content = dto.content;
if (dto.order !== undefined) localUpdates.order = dto.order;
const updated = await slideCollection.update(id, localUpdates);
if (updated) {
currentSlides = currentSlides
.map((s) => (s.id === id ? toSlide(updated) : s))
.sort((a, b) => a.order - b.order);
}
await slideCollection.update(id, localUpdates);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update slide';
@ -206,7 +123,6 @@ function createDecksStore() {
error = null;
try {
await slideCollection.delete(id);
currentSlides = currentSlides.filter((s) => s.id !== id);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete slide';
@ -218,13 +134,9 @@ function createDecksStore() {
async function reorderSlides(slides: { id: string; order: number }[]): Promise<boolean> {
error = null;
try {
const orderMap = new Map(slides.map((s) => [s.id, s.order]));
for (const { id, order } of slides) {
await slideCollection.update(id, { order });
}
currentSlides = currentSlides
.map((s) => ({ ...s, order: orderMap.get(s.id) ?? s.order }))
.sort((a, b) => a.order - b.order);
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder slides';
@ -233,29 +145,13 @@ function createDecksStore() {
}
}
function clearCurrent() {
currentDeck = null;
currentSlides = [];
}
return {
get decks() {
return decks;
},
get currentDeck() {
return currentDeck;
},
get currentSlides() {
return currentSlides;
},
get isLoading() {
return isLoading;
},
get error() {
return error;
},
loadDecks,
loadDeck,
createDeck,
updateDeck,
deleteDeck,
@ -263,7 +159,6 @@ function createDecksStore() {
updateSlide,
deleteSlide,
reorderSlides,
clearCurrent,
};
}

View file

@ -19,6 +19,7 @@
import { getPillAppItems } from '@manacore/shared-branding';
import { setLocale, supportedLocales } from '$lib/i18n';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { presiOnboarding } from '$lib/stores/app-onboarding.svelte';
import { MiniOnboardingModal } from '@manacore/shared-app-onboarding';
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
@ -32,6 +33,9 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live query: all decks (reactive, auto-updates on IndexedDB changes)
const allDecks = useAllDecks();
let { children } = $props();
let isCollapsed = $state(false);
@ -137,7 +141,7 @@
// QuickInputBar handlers
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
return decksStore.decks
return (allDecks.value ?? [])
.filter((d) => d.title.toLowerCase().includes(q) || d.description?.toLowerCase().includes(q))
.slice(0, 10)
.map((deck) => ({
@ -162,9 +166,6 @@
tagMutations.startSync(getToken);
}
// Load decks from IndexedDB (guest seed or synced data)
await decksStore.loadDecks();
// Show guest welcome modal on first visit
if (!auth.isAuthenticated && shouldShowGuestWelcome('presi')) {
showGuestWelcome = true;

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import { PageHeader, ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import {
@ -20,12 +21,15 @@
let newDeckDescription = $state('');
let isCreating = $state(false);
const allDecks = useAllDecks();
let decks = $derived(allDecks.value ?? []);
let contextMenuVisible = $state(false);
let contextMenuX = $state(0);
let contextMenuY = $state(0);
let contextMenuDeck = $state<(typeof decksStore.decks)[0] | null>(null);
let contextMenuDeck = $state<(typeof decks)[0] | null>(null);
function handleContextMenu(e: MouseEvent, deck: (typeof decksStore.decks)[0]) {
function handleContextMenu(e: MouseEvent, deck: (typeof decks)[0]) {
e.preventDefault();
e.stopPropagation();
contextMenuX = e.clientX;
@ -118,13 +122,7 @@
{/snippet}
</PageHeader>
{#if decksStore.isLoading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else if decksStore.decks.length === 0}
{#if decks.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
@ -143,7 +141,7 @@
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.decks as deck (deck.id)}
{#each decks as deck (deck.id)}
<!-- svelte-ignore a11y_no_static_element_interactions -->
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden hover:shadow-md transition-shadow"

View file

@ -4,6 +4,7 @@
import { goto } from '$app/navigation';
import { browser } from '$app/environment';
import { decksStore } from '$lib/stores/decks.svelte';
import { useDeck, useDeckSlides } from '$lib/data/queries';
import { auth } from '$lib/stores/auth.svelte';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import { shareApi } from '$lib/api/client';
@ -52,10 +53,11 @@
const deckId = $page.params.id as string;
onMount(() => {
decksStore.loadDeck(deckId);
return () => decksStore.clearCurrent();
});
// Reactive live queries — auto-update on IndexedDB changes
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
function openCreateSlide() {
editingSlide = null;
@ -119,7 +121,7 @@
}
async function moveSlide(slide: Slide, direction: 'up' | 'down') {
const slides = decksStore.currentSlides;
const slides = currentSlides;
const currentIndex = slides.findIndex((s) => s.id === slide.id);
if (currentIndex === -1) return;
@ -215,17 +217,17 @@
</script>
<svelte:head>
<title>{decksStore.currentDeck?.title || 'Loading...'} - Presi</title>
<title>{currentDeck?.title || 'Loading...'} - Presi</title>
</svelte:head>
<div class="max-w-7xl mx-auto px-4 sm:px-6 lg:px-8 py-8">
{#if decksStore.isLoading}
{#if deckQuery.loading}
<div class="flex items-center justify-center py-16">
<div
class="animate-spin rounded-full h-10 w-10 border-4 border-primary-500 border-t-transparent"
></div>
</div>
{:else if decksStore.currentDeck}
{:else if currentDeck}
<!-- Header -->
<div class="flex items-center justify-between mb-8">
<div class="flex items-center gap-4">
@ -237,11 +239,11 @@
</a>
<div>
<h1 class="text-2xl font-bold text-slate-900 dark:text-white">
{decksStore.currentDeck.title}
{currentDeck.title}
</h1>
{#if decksStore.currentDeck.description}
{#if currentDeck.description}
<p class="text-slate-600 dark:text-slate-400 mt-1">
{decksStore.currentDeck.description}
{currentDeck.description}
</p>
{/if}
</div>
@ -264,7 +266,7 @@
Share
</button>
{/if}
{#if decksStore.currentSlides.length > 0}
{#if currentSlides.length > 0}
<a
href="/present/{deckId}"
class="flex items-center gap-2 px-4 py-2 bg-primary-600 hover:bg-primary-700 text-white font-medium rounded-lg transition-colors"
@ -277,7 +279,7 @@
</div>
<!-- Slides Grid -->
{#if decksStore.currentSlides.length === 0}
{#if currentSlides.length === 0}
<div class="text-center py-16">
<div
class="mx-auto w-16 h-16 bg-slate-100 dark:bg-slate-800 rounded-full flex items-center justify-center mb-4"
@ -296,7 +298,7 @@
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 gap-6">
{#each decksStore.currentSlides as slide, index (slide.id)}
{#each currentSlides as slide, index (slide.id)}
<div
class="group bg-white dark:bg-slate-800 rounded-xl shadow-sm border border-slate-200 dark:border-slate-700 overflow-hidden"
>
@ -354,7 +356,7 @@
</button>
<button
onclick={() => moveSlide(slide, 'down')}
disabled={index === decksStore.currentSlides.length - 1}
disabled={index === currentSlides.length - 1}
class="p-1.5 hover:bg-slate-100 dark:hover:bg-slate-700 rounded disabled:opacity-30"
aria-label="Move down"
>

View file

@ -25,7 +25,7 @@
pageTitle="Wähle dein Abo"
subscriptionsTitle="Abonnements"
packagesTitle="Einmal-Pakete"
yearlyDiscount="2 Monate gratis"
yearlyDiscount="20% Rabatt"
/>
</div>

View file

@ -3,6 +3,7 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { decksStore } from '$lib/stores/decks.svelte';
import { useDeck, useDeckSlides } from '$lib/data/queries';
import { PresiEvents } from '@manacore/shared-utils/analytics';
import type { Slide } from '@presi/shared';
import {
@ -29,13 +30,24 @@
const deckId = $page.params.id as string;
// Reactive live queries
const deckQuery = useDeck(deckId);
const slidesQuery = useDeckSlides(deckId);
let currentDeck = $derived(deckQuery.value);
let currentSlides = $derived(slidesQuery.value ?? []);
let maxSlideReached = $state(0);
let hasTrackedStart = $state(false);
// Track presentation start once slides are loaded
$effect(() => {
if (currentSlides.length > 0 && !hasTrackedStart) {
PresiEvents.presentationStarted(currentSlides.length);
hasTrackedStart = true;
}
});
onMount(() => {
decksStore.loadDeck(deckId).then(() => {
PresiEvents.presentationStarted(decksStore.currentSlides.length);
});
// Keyboard navigation
window.addEventListener('keydown', handleKeydown);
window.addEventListener('mousemove', handleMouseMove);
@ -47,7 +59,6 @@
document.removeEventListener('fullscreenchange', handleFullscreenChange);
if (timerInterval) clearInterval(timerInterval);
if (hideControlsTimeout) clearTimeout(hideControlsTimeout);
decksStore.clearCurrent();
};
});
@ -95,7 +106,7 @@
}
function nextSlide() {
if (currentSlideIndex < decksStore.currentSlides.length - 1) {
if (currentSlideIndex < currentSlides.length - 1) {
currentSlideIndex++;
if (currentSlideIndex > maxSlideReached) maxSlideReached = currentSlideIndex;
}
@ -139,15 +150,15 @@
goto(`/deck/${deckId}`);
}
const currentSlide = $derived(decksStore.currentSlides[currentSlideIndex]);
const currentSlide = $derived(currentSlides[currentSlideIndex]);
</script>
<svelte:head>
<title>Presenting: {decksStore.currentDeck?.title || 'Loading...'}</title>
<title>Presenting: {currentDeck?.title || 'Loading...'}</title>
</svelte:head>
<div class="fixed inset-0 bg-slate-900 text-white flex flex-col">
{#if decksStore.isLoading}
{#if deckQuery.loading}
<div class="flex-1 flex items-center justify-center">
<div
class="animate-spin rounded-full h-12 w-12 border-4 border-primary-500 border-t-transparent"
@ -161,9 +172,9 @@
class:pointer-events-none={!showControls}
>
<div class="flex items-center gap-4">
<h1 class="text-lg font-medium truncate max-w-xs">{decksStore.currentDeck?.title}</h1>
<h1 class="text-lg font-medium truncate max-w-xs">{currentDeck?.title}</h1>
<span class="text-sm text-slate-400">
Slide {currentSlideIndex + 1} of {decksStore.currentSlides.length}
Slide {currentSlideIndex + 1} of {currentSlides.length}
</span>
</div>
<button
@ -260,7 +271,7 @@
<!-- Slide Dots -->
<div class="flex items-center gap-2 px-4">
{#each decksStore.currentSlides as _, index}
{#each currentSlides as _, index}
<button
onclick={() => goToSlide(index)}
class="w-2 h-2 rounded-full transition-all"
@ -274,7 +285,7 @@
<button
onclick={nextSlide}
disabled={currentSlideIndex === decksStore.currentSlides.length - 1}
disabled={currentSlideIndex === currentSlides.length - 1}
class="p-3 hover:bg-white/10 rounded-lg transition-colors disabled:opacity-30"
aria-label="Next slide"
>

View file

@ -1,13 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { ProfilePage } from '@manacore/shared-profile-ui';
import type { UserProfile, ProfileActions } from '@manacore/shared-profile-ui';
import { auth } from '$lib/stores/auth.svelte';
import { decksStore } from '$lib/stores/decks.svelte';
import { useAllDecks } from '$lib/data/queries';
import { goto } from '$app/navigation';
import { FolderOpen, Stack, Calendar } from '@manacore/shared-icons';
let isLoading = $state(true);
const allDecks = useAllDecks();
let decks = $derived(allDecks.value ?? []);
// Map auth store user to UserProfile
let userProfile = $derived<UserProfile>({
@ -27,11 +27,6 @@
},
};
onMount(async () => {
await decksStore.loadDecks();
isLoading = false;
});
function formatDate(dateString: string) {
return new Date(dateString).toLocaleDateString('de-DE', {
day: '2-digit',
@ -62,75 +57,67 @@
<!-- Stats Section -->
<div class="mx-auto max-w-xl px-4 pb-8">
{#if isLoading}
<div class="flex items-center justify-center py-8">
<div
class="animate-spin rounded-full h-8 w-8 border-4 border-primary border-t-transparent"
></div>
<!-- Stats Card -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Statistiken</h2>
<div
class="p-5 rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">
{decks.length}
</div>
<div class="text-sm text-muted-foreground">Präsentationen</div>
</div>
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<Stack class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">-</div>
<div class="text-sm text-muted-foreground">Folien</div>
</div>
</div>
</div>
{:else}
<!-- Stats Card -->
<section class="mb-6">
<h2 class="text-lg font-semibold text-foreground mb-4">Statistiken</h2>
</section>
<!-- Recent Presentations -->
{#if decks.length > 0}
<section>
<h2 class="text-lg font-semibold text-foreground mb-4">Letzte Präsentationen</h2>
<div
class="p-5 rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm"
class="rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm overflow-hidden"
>
<div class="grid grid-cols-2 gap-4">
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<FolderOpen class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">
{decksStore.decks.length}
</div>
<div class="text-sm text-muted-foreground">Präsentationen</div>
</div>
<div class="text-center p-4 bg-black/5 dark:bg-white/5 rounded-xl">
<div class="flex justify-center mb-2">
<Stack class="w-6 h-6 text-primary" />
</div>
<div class="text-2xl font-bold text-foreground">-</div>
<div class="text-sm text-muted-foreground">Folien</div>
</div>
<div class="divide-y divide-black/10 dark:divide-white/10">
{#each decks.slice(0, 5) as deck (deck.id)}
<a
href="/deck/{deck.id}"
class="flex items-center justify-between p-4 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-medium text-foreground">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-muted-foreground truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
</section>
<!-- Recent Presentations -->
{#if decksStore.decks.length > 0}
<section>
<h2 class="text-lg font-semibold text-foreground mb-4">Letzte Präsentationen</h2>
<div
class="rounded-2xl bg-white/85 dark:bg-white/10 backdrop-blur-xl border border-black/10 dark:border-white/10 shadow-sm overflow-hidden"
>
<div class="divide-y divide-black/10 dark:divide-white/10">
{#each decksStore.decks.slice(0, 5) as deck (deck.id)}
<a
href="/deck/{deck.id}"
class="flex items-center justify-between p-4 hover:bg-black/5 dark:hover:bg-white/5 transition-colors"
>
<div class="flex items-center gap-3">
<div class="w-10 h-10 bg-primary/10 rounded-lg flex items-center justify-center">
<FolderOpen class="w-5 h-5 text-primary" />
</div>
<div>
<h4 class="font-medium text-foreground">{deck.title}</h4>
{#if deck.description}
<p class="text-sm text-muted-foreground truncate max-w-xs">
{deck.description}
</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2 text-sm text-muted-foreground">
<Calendar class="w-4 h-4" />
{formatDate(deck.updatedAt)}
</div>
</a>
{/each}
</div>
</div>
</section>
{/if}
{/if}
</div>

View file

@ -2,6 +2,14 @@
import { X, Trash, CheckSquare } from '@manacore/shared-icons';
import { filesStore } from '$lib/stores/files.svelte';
import { toastStore } from '@manacore/shared-ui';
import type { StorageFile, StorageFolder } from '$lib/api/client';
interface Props {
files?: StorageFile[];
folders?: StorageFolder[];
}
let { files = [], folders = [] }: Props = $props();
let deleting = $state(false);
@ -10,7 +18,7 @@
if (!confirm(`${count} Element(e) in den Papierkorb verschieben?`)) return;
deleting = true;
const result = await filesStore.deleteSelected();
const result = await filesStore.deleteSelected(files, folders);
deleting = false;
if (result.hasErrors) {
@ -34,7 +42,9 @@
<span>{deleting ? 'Lösche...' : 'Löschen'}</span>
</button>
<button class="bulk-btn" onclick={() => filesStore.selectAll()}> Alle auswählen </button>
<button class="bulk-btn" onclick={() => filesStore.selectAllFromLists(files, folders)}>
Alle auswählen
</button>
<button
class="bulk-btn close"

View file

@ -0,0 +1,135 @@
/**
* Reactive Queries & Pure Helpers for Storage
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
fileCollection,
folderCollection,
tagCollection,
fileTagCollection,
type LocalFile,
type LocalFolder,
type LocalTag,
type LocalFileTag,
} from './local-store';
import type { StorageFile, StorageFolder, Tag } from '$lib/api/client';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalFile (IndexedDB) to StorageFile type. */
export function toFile(local: LocalFile): StorageFile {
return {
id: local.id,
userId: 'local',
name: local.name,
originalName: local.originalName,
mimeType: local.mimeType,
size: local.size,
storagePath: local.storagePath,
storageKey: local.storageKey,
parentFolderId: local.parentFolderId ?? null,
currentVersion: local.currentVersion,
isFavorite: local.isFavorite,
isDeleted: local.isDeleted,
deletedAt: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalFolder (IndexedDB) to StorageFolder type. */
export function toFolder(local: LocalFolder): StorageFolder {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? null,
color: local.color ?? null,
parentFolderId: local.parentFolderId ?? null,
path: local.path,
depth: local.depth,
isFavorite: local.isFavorite,
isDeleted: local.isDeleted,
deletedAt: null,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalTag (IndexedDB) to Tag type. */
export function toTag(local: LocalTag): Tag {
return {
id: local.id,
userId: 'local',
name: local.name,
color: local.color ?? null,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All non-deleted files, sorted by name. Auto-updates on any change. */
export function useAllFiles() {
return useLiveQueryWithDefault(async () => {
const locals = await fileCollection.getAll();
return locals
.filter((f) => !f.isDeleted)
.map(toFile)
.sort((a, b) => a.name.localeCompare(b.name));
}, [] as StorageFile[]);
}
/** All non-deleted folders, sorted by name. Auto-updates on any change. */
export function useAllFolders() {
return useLiveQueryWithDefault(async () => {
const locals = await folderCollection.getAll();
return locals
.filter((f) => !f.isDeleted)
.map(toFolder)
.sort((a, b) => a.name.localeCompare(b.name));
}, [] as StorageFolder[]);
}
/** All tags, sorted by name. Auto-updates on any change. */
export function useAllStorageTags() {
return useLiveQueryWithDefault(async () => {
const locals = await tagCollection.getAll();
return locals.map(toTag).sort((a, b) => a.name.localeCompare(b.name));
}, [] as Tag[]);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Get files in a specific folder (null = root). */
export function getFilesInFolder(files: StorageFile[], folderId: string | null): StorageFile[] {
return files.filter((f) => (f.parentFolderId ?? null) === folderId);
}
/** Get subfolders of a specific folder (null = root). */
export function getFoldersInFolder(
folders: StorageFolder[],
parentFolderId: string | null
): StorageFolder[] {
return folders.filter((f) => (f.parentFolderId ?? null) === parentFolderId);
}
/** Get favorite files. */
export function getFavoriteFiles(files: StorageFile[]): StorageFile[] {
return files.filter((f) => f.isFavorite);
}
/** Get favorite folders. */
export function getFavoriteFolders(folders: StorageFolder[]): StorageFolder[] {
return folders.filter((f) => f.isFavorite);
}
/** Find a folder by ID. */
export function findFolderById(folders: StorageFolder[], id: string): StorageFolder | undefined {
return folders.find((f) => f.id === id);
}

View file

@ -1,30 +1,30 @@
/**
* Files Store - Manages files and folders state
* Files Store Mutation-Only (Local-First reads via queries.ts)
*
* Reads are handled by useLiveQuery hooks in queries.ts.
* This store handles writes, selection state, view mode, and
* server-side operations (upload, download, share) that remain API-based.
*/
import { filesApi, foldersApi } from '$lib/api/client';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import { trackEvent, StorageEvents } from '@manacore/shared-utils/analytics';
import {
fileCollection,
folderCollection,
type LocalFile,
type LocalFolder,
} from '$lib/data/local-store';
import { toFile, toFolder } from '$lib/data/queries';
let files = $state<StorageFile[]>([]);
let folders = $state<StorageFolder[]>([]);
let currentFolder = $state<StorageFolder | null>(null);
let loading = $state(false);
let error = $state<string | null>(null);
let viewMode = $state<'grid' | 'list'>('grid');
let selectedFileIds = $state<Set<string>>(new Set());
let selectedFolderIds = $state<Set<string>>(new Set());
let currentFolderId = $state<string | null>(null);
export const filesStore = {
get files() {
return files;
},
get folders() {
return folders;
},
get currentFolder() {
return currentFolder;
},
get loading() {
return loading;
},
@ -43,6 +43,9 @@ export const filesStore = {
get selectionCount() {
return selectedFileIds.size + selectedFolderIds.size;
},
get currentFolderId() {
return currentFolderId;
},
toggleFileSelection(id: string) {
const next = new Set(selectedFileIds);
@ -58,7 +61,7 @@ export const filesStore = {
selectedFolderIds = next;
},
selectAll() {
selectAllFromLists(files: StorageFile[], folders: StorageFolder[]) {
selectedFileIds = new Set(files.map((f) => f.id));
selectedFolderIds = new Set(folders.map((f) => f.id));
},
@ -68,26 +71,6 @@ export const filesStore = {
selectedFolderIds = new Set();
},
async deleteSelected() {
const fileIds = [...selectedFileIds];
const folderIds = [...selectedFolderIds];
const results = await Promise.all([
...fileIds.map((id) => filesApi.delete(id)),
...folderIds.map((id) => foldersApi.delete(id)),
]);
const hasErrors = results.some((r) => r.error);
if (!hasErrors) {
files = files.filter((f) => !selectedFileIds.has(f.id));
folders = folders.filter((f) => !selectedFolderIds.has(f.id));
}
selectedFileIds = new Set();
selectedFolderIds = new Set();
return { deleted: fileIds.length + folderIds.length, hasErrors };
},
setViewMode(mode: 'grid' | 'list') {
viewMode = mode;
StorageEvents.viewModeChanged(mode);
@ -105,64 +88,53 @@ export const filesStore = {
}
},
async loadFolder(folderId?: string) {
loading = true;
error = null;
setCurrentFolder(folderId: string | null) {
currentFolderId = folderId;
selectedFileIds = new Set();
selectedFolderIds = new Set();
try {
if (folderId) {
const result = await foldersApi.get(folderId);
if (result.error) {
error = result.error;
return;
}
if (result.data) {
currentFolder = result.data.folder;
files = result.data.files;
folders = result.data.subfolders;
}
} else {
// Load root
currentFolder = null;
const [filesResult, foldersResult] = await Promise.all([
filesApi.list(),
foldersApi.list(),
]);
if (filesResult.error) {
error = filesResult.error;
return;
}
if (foldersResult.error) {
error = foldersResult.error;
return;
}
files = filesResult.data || [];
folders = foldersResult.data || [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Unknown error';
} finally {
loading = false;
}
},
async uploadFile(file: File) {
const result = await filesApi.upload(file, currentFolder?.id);
// ─── Server-side operations (remain API-based) ────────
async uploadFile(file: File, parentFolderId?: string | null) {
const result = await filesApi.upload(file, parentFolderId ?? currentFolderId ?? undefined);
if (result.data) {
files = [...files, result.data];
// Also insert into local store for liveQuery reactivity
const localFile: LocalFile = {
id: result.data.id,
name: result.data.name,
originalName: result.data.originalName,
mimeType: result.data.mimeType,
size: result.data.size,
storagePath: result.data.storagePath,
storageKey: result.data.storageKey,
parentFolderId: result.data.parentFolderId,
currentVersion: result.data.currentVersion,
isFavorite: result.data.isFavorite,
isDeleted: result.data.isDeleted,
};
await fileCollection.insert(localFile);
trackEvent('file_uploaded', { size: Math.round(file.size / 1024) });
}
return result;
},
async createFolder(name: string, color?: string) {
const result = await foldersApi.create(name, currentFolder?.id, color);
const result = await foldersApi.create(name, currentFolderId ?? undefined, color);
if (result.data) {
folders = [...folders, result.data];
// Also insert into local store for liveQuery reactivity
const localFolder: LocalFolder = {
id: result.data.id,
name: result.data.name,
description: result.data.description,
color: result.data.color,
parentFolderId: result.data.parentFolderId,
path: result.data.path,
depth: result.data.depth,
isFavorite: result.data.isFavorite,
isDeleted: result.data.isDeleted,
};
await folderCollection.insert(localFolder);
trackEvent('folder_created');
}
return result;
@ -171,7 +143,7 @@ export const filesStore = {
async deleteFile(id: string) {
const result = await filesApi.delete(id);
if (!result.error) {
files = files.filter((f) => f.id !== id);
await fileCollection.update(id, { isDeleted: true });
StorageEvents.fileDeleted();
}
return result;
@ -180,16 +152,40 @@ export const filesStore = {
async deleteFolder(id: string) {
const result = await foldersApi.delete(id);
if (!result.error) {
folders = folders.filter((f) => f.id !== id);
await folderCollection.update(id, { isDeleted: true });
StorageEvents.folderDeleted();
}
return result;
},
async deleteSelected(files: StorageFile[], folders: StorageFolder[]) {
const fileIds = [...selectedFileIds];
const folderIds = [...selectedFolderIds];
const results = await Promise.all([
...fileIds.map((id) => filesApi.delete(id)),
...folderIds.map((id) => foldersApi.delete(id)),
]);
const hasErrors = results.some((r) => r.error);
if (!hasErrors) {
for (const id of fileIds) {
await fileCollection.update(id, { isDeleted: true });
}
for (const id of folderIds) {
await folderCollection.update(id, { isDeleted: true });
}
}
selectedFileIds = new Set();
selectedFolderIds = new Set();
return { deleted: fileIds.length + folderIds.length, hasErrors };
},
async toggleFileFavorite(id: string) {
const result = await filesApi.toggleFavorite(id);
if (result.data) {
files = files.map((f) => (f.id === id ? result.data! : f));
await fileCollection.update(id, { isFavorite: result.data.isFavorite });
StorageEvents.fileFavorited(result.data.isFavorite);
}
return result;
@ -198,7 +194,7 @@ export const filesStore = {
async toggleFolderFavorite(id: string) {
const result = await foldersApi.toggleFavorite(id);
if (result.data) {
folders = folders.map((f) => (f.id === id ? result.data! : f));
await folderCollection.update(id, { isFavorite: result.data.isFavorite });
StorageEvents.folderFavorited(result.data.isFavorite);
}
return result;
@ -207,7 +203,7 @@ export const filesStore = {
async renameFile(id: string, name: string) {
const result = await filesApi.rename(id, name);
if (result.data) {
files = files.map((f) => (f.id === id ? result.data! : f));
await fileCollection.update(id, { name: result.data.name });
}
return result;
},
@ -215,7 +211,7 @@ export const filesStore = {
async renameFolder(id: string, name: string) {
const result = await foldersApi.rename(id, name);
if (result.data) {
folders = folders.map((f) => (f.id === id ? result.data! : f));
await folderCollection.update(id, { name: result.data.name });
}
return result;
},
@ -223,7 +219,7 @@ export const filesStore = {
async moveFile(id: string, targetFolderId: string) {
const result = await filesApi.move(id, targetFolderId);
if (!result.error) {
files = files.filter((f) => f.id !== id);
await fileCollection.update(id, { parentFolderId: targetFolderId });
}
return result;
},
@ -231,7 +227,7 @@ export const filesStore = {
async moveFolder(id: string, targetFolderId: string) {
const result = await foldersApi.move(id, targetFolderId);
if (!result.error) {
folders = folders.filter((f) => f.id !== id);
await folderCollection.update(id, { parentFolderId: targetFolderId });
}
return result;
},

View file

@ -3,6 +3,12 @@
import { onMount } from 'svelte';
import { GridFour, List, Plus, FolderPlus, UploadSimple } from '@manacore/shared-icons';
import { filesStore } from '$lib/stores/files.svelte';
import {
useAllFiles,
useAllFolders,
getFilesInFolder,
getFoldersInFolder,
} from '$lib/data/queries';
import { toastStore } from '@manacore/shared-ui';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileGrid from '$lib/components/files/FileGrid.svelte';
@ -24,16 +30,20 @@
let uploading = $state(false);
let uploadProgress = $state(0);
// Breadcrumb items from current folder path
let breadcrumbItems = $derived(
filesStore.currentFolder
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
: []
);
// Live queries for reactive reads
const allFilesQuery = useAllFiles();
const allFoldersQuery = useAllFolders();
// Root-level files and folders (no parent)
let files = $derived(getFilesInFolder(allFilesQuery.value ?? [], null));
let folders = $derived(getFoldersInFolder(allFoldersQuery.value ?? [], null));
// Breadcrumb items (root has none)
let breadcrumbItems: { id: string; name: string }[] = [];
onMount(() => {
filesStore.initViewMode();
filesStore.loadFolder();
filesStore.setCurrentFolder(null);
});
function handleFolderClick(folder: StorageFolder) {
@ -237,20 +247,13 @@
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
<BulkActionBar />
<BulkActionBar {files} {folders} />
{#if filesStore.loading}
{#if filesStore.viewMode === 'grid'}
<FileSkeletonGrid />
{:else}
<FileSkeletonList />
{/if}
{:else if filesStore.error}
{#if allFilesQuery.error}
<div class="error-state">
<p>Fehler: {filesStore.error}</p>
<button onclick={() => filesStore.loadFolder()}>Erneut versuchen</button>
<p>Fehler: {allFilesQuery.error}</p>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
{:else if files.length === 0 && folders.length === 0}
<EmptyState
type="files"
title="Noch keine Dateien"
@ -269,8 +272,8 @@
</EmptyState>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}
folders={filesStore.folders}
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
@ -279,8 +282,8 @@
/>
{:else}
<FileList
files={filesStore.files}
folders={filesStore.folders}
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
@ -298,7 +301,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={filesStore.files}
allFiles={files}
onClose={() => (previewFile = null)}
onAction={(action, file) => {
handleFileAction(action, file);

View file

@ -4,6 +4,13 @@
import { onMount } from 'svelte';
import { GridFour, List, FolderPlus, UploadSimple, ArrowLeft } from '@manacore/shared-icons';
import { filesStore } from '$lib/stores/files.svelte';
import {
useAllFiles,
useAllFolders,
getFilesInFolder,
getFoldersInFolder,
findFolderById,
} from '$lib/data/queries';
import { toastStore } from '@manacore/shared-ui';
import type { StorageFile, StorageFolder } from '$lib/api/client';
import FileGrid from '$lib/components/files/FileGrid.svelte';
@ -27,16 +34,23 @@
let folderId = $derived($page.params.folderId);
// Breadcrumb items from current folder path
// Live queries for reactive reads
const allFilesQuery = useAllFiles();
const allFoldersQuery = useAllFolders();
// Current folder and its contents
let currentFolder = $derived(findFolderById(allFoldersQuery.value ?? [], folderId));
let files = $derived(getFilesInFolder(allFilesQuery.value ?? [], folderId));
let folders = $derived(getFoldersInFolder(allFoldersQuery.value ?? [], folderId));
// Breadcrumb items from current folder
let breadcrumbItems = $derived(
filesStore.currentFolder
? [{ id: filesStore.currentFolder.id, name: filesStore.currentFolder.name }]
: []
currentFolder ? [{ id: currentFolder.id, name: currentFolder.name }] : []
);
$effect(() => {
if (folderId) {
filesStore.loadFolder(folderId);
filesStore.setCurrentFolder(folderId);
}
});
@ -194,7 +208,7 @@
}
function goBack() {
const parentId = filesStore.currentFolder?.parentFolderId;
const parentId = currentFolder?.parentFolderId ?? null;
if (parentId) {
goto(`/files/${parentId}`);
} else {
@ -204,7 +218,7 @@
</script>
<svelte:head>
<title>{filesStore.currentFolder?.name || 'Ordner'} - Storage</title>
<title>{currentFolder?.name || 'Ordner'} - Storage</title>
</svelte:head>
<div class="files-page">
@ -214,7 +228,7 @@
<ArrowLeft size={20} />
</button>
<div>
<h1>{filesStore.currentFolder?.name || 'Ordner'}</h1>
<h1>{currentFolder?.name || 'Ordner'}</h1>
<Breadcrumb items={breadcrumbItems} onNavigate={handleBreadcrumbNavigate} />
</div>
</div>
@ -255,20 +269,13 @@
<UploadZone onUpload={handleUpload} {uploading} progress={uploadProgress} />
{/if}
<BulkActionBar />
<BulkActionBar {files} {folders} />
{#if filesStore.loading}
{#if filesStore.viewMode === 'grid'}
<FileSkeletonGrid />
{:else}
<FileSkeletonList />
{/if}
{:else if filesStore.error}
{#if allFilesQuery.error}
<div class="error-state">
<p>Fehler: {filesStore.error}</p>
<button onclick={() => filesStore.loadFolder(folderId)}>Erneut versuchen</button>
<p>Fehler: {allFilesQuery.error}</p>
</div>
{:else if filesStore.files.length === 0 && filesStore.folders.length === 0}
{:else if files.length === 0 && folders.length === 0}
<EmptyState
type="folder"
title="Leerer Ordner"
@ -287,8 +294,8 @@
</EmptyState>
{:else if filesStore.viewMode === 'grid'}
<FileGrid
files={filesStore.files}
folders={filesStore.folders}
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
@ -297,8 +304,8 @@
/>
{:else}
<FileList
files={filesStore.files}
folders={filesStore.folders}
{files}
{folders}
onFileClick={handleFileClick}
onFolderClick={handleFolderClick}
onFileAction={handleFileAction}
@ -316,7 +323,7 @@
<FilePreviewModal
open={previewFile !== null}
file={previewFile}
allFiles={filesStore.files}
allFiles={files}
onClose={() => (previewFile = null)}
onAction={(action, file) => {
handleFileAction(action, file);

View file

@ -35,6 +35,7 @@
primaryColor="#3b82f6"
onSignIn={handleSignIn}
onResendVerification={handleResendVerification}
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
{goto}
successRedirect="/files"
registerPath="/register"

View file

@ -7,6 +7,8 @@
import { toast } from '$lib/stores/toast.svelte';
import { zitareSettings } from '$lib/stores/settings.svelte';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { isFavorite as checkIsFavorite, type Favorite } from '$lib/data/queries';
interface Props {
quote: Quote;
@ -17,7 +19,8 @@
let { quote, showCategory = false, showSource = true, size = 'medium' }: Props = $props();
let isFavorite = $derived(favoritesStore.isFavorite(quote.id));
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
let isFavorite = $derived(checkIsFavorite(allFavorites.value, quote.id));
let quoteText = $derived(quotesStore.getText(quote));
let showBio = $state(false);
@ -60,7 +63,7 @@
if (!authStore.isAuthenticated) return;
const wasFavorite = isFavorite;
try {
await favoritesStore.toggle(quote.id);
await favoritesStore.toggle(quote.id, allFavorites.value);
if (wasFavorite) {
ZitareEvents.quoteUnfavorited();
} else {

View file

@ -0,0 +1,91 @@
/**
* Reactive Queries & Pure Helpers for Zitare
*
* Uses Dexie liveQuery to automatically re-render when IndexedDB changes
* (local writes, sync updates, other tabs). Components call these hooks
* at init time; no manual fetch/refresh needed.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
favoriteCollection,
listCollection,
type LocalFavorite,
type LocalQuoteList,
} from './local-store';
// ─── Domain Types ─────────────────────────────────────────
export interface Favorite {
id: string;
quoteId: string;
createdAt: string;
}
export interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
// ─── Type Converters ──────────────────────────────────────
export function toFavorite(local: LocalFavorite): Favorite {
return {
id: local.id,
quoteId: local.quoteId,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
export function toQuoteList(local: LocalQuoteList): QuoteList {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
quoteIds: local.quoteIds,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return useLiveQueryWithDefault(async () => {
const locals = await favoriteCollection.getAll();
return locals.map(toFavorite);
}, [] as Favorite[]);
}
/** All lists. Auto-updates on any change. */
export function useAllLists() {
return useLiveQueryWithDefault(async () => {
const locals = await listCollection.getAll();
return locals.map(toQuoteList);
}, [] as QuoteList[]);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Check if a quote is in the favorites list. */
export function isFavorite(favorites: Favorite[], quoteId: string): boolean {
return favorites.some((f) => f.quoteId === quoteId);
}
/** Find a favorite by quote ID. */
export function findFavoriteByQuoteId(
favorites: Favorite[],
quoteId: string
): Favorite | undefined {
return favorites.find((f) => f.quoteId === quoteId);
}
/** Find a list by ID. */
export function findListById(lists: QuoteList[], listId: string): QuoteList | undefined {
return lists.find((l) => l.id === listId);
}

View file

@ -1,95 +1,34 @@
/**
* Favorites Store Local-First with Dexie.js
* All reads/writes go to IndexedDB. Sync happens in background when authenticated.
* Favorites Store Mutation-only
* Reads come from useLiveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store';
interface Favorite {
id: string;
quoteId: string;
createdAt: string;
}
// State
let favorites = $state<Favorite[]>([]);
let loading = $state(false);
let initialized = $state(false);
function toFavorite(local: LocalFavorite): Favorite {
return {
id: local.id,
quoteId: local.quoteId,
createdAt: local.createdAt ?? new Date().toISOString(),
};
}
import { toFavorite, type Favorite } from '$lib/data/queries';
export const favoritesStore = {
get favorites() {
return favorites;
},
get loading() {
return loading;
},
get initialized() {
return initialized;
},
isFavorite(quoteId: string): boolean {
return favorites.some((f) => f.quoteId === quoteId);
},
async load() {
loading = true;
try {
const localFavs = await favoriteCollection.getAll();
favorites = localFavs.map(toFavorite);
initialized = true;
} catch (error) {
console.error('Failed to load favorites:', error);
favorites = [];
} finally {
loading = false;
}
},
async add(quoteId: string) {
try {
const newFav: LocalFavorite = {
id: crypto.randomUUID(),
quoteId,
};
const inserted = await favoriteCollection.insert(newFav);
favorites = [...favorites, toFavorite(inserted)];
} catch (error) {
console.error('Failed to add favorite:', error);
throw error;
const newFav: LocalFavorite = {
id: crypto.randomUUID(),
quoteId,
};
await favoriteCollection.insert(newFav);
},
async remove(quoteId: string, favorites: Favorite[]) {
const fav = favorites.find((f) => f.quoteId === quoteId);
if (fav) {
await favoriteCollection.delete(fav.id);
}
},
async remove(quoteId: string) {
try {
const fav = favorites.find((f) => f.quoteId === quoteId);
if (fav) {
await favoriteCollection.delete(fav.id);
favorites = favorites.filter((f) => f.quoteId !== quoteId);
}
} catch (error) {
console.error('Failed to remove favorite:', error);
throw error;
}
},
async toggle(quoteId: string) {
if (this.isFavorite(quoteId)) {
await this.remove(quoteId);
async toggle(quoteId: string, favorites: Favorite[]) {
const exists = favorites.some((f) => f.quoteId === quoteId);
if (exists) {
await this.remove(quoteId, favorites);
} else {
await this.add(quoteId);
}
},
clear() {
favorites = [];
initialized = false;
},
};

View file

@ -1,148 +1,84 @@
/**
* Lists Store Local-First with Dexie.js
* Lists Store Mutation-only
* Reads come from useLiveQuery via queries.ts (reactive, auto-updating).
* This store only handles write operations.
*/
import { listCollection, type LocalQuoteList } from '$lib/data/local-store';
import { toQuoteList, type QuoteList } from '$lib/data/queries';
export interface QuoteList {
id: string;
name: string;
description?: string;
quoteIds: string[];
createdAt: string;
updatedAt: string;
}
let lists = $state<QuoteList[]>([]);
let isLoading = $state(false);
let error = $state<string | null>(null);
function toQuoteList(local: LocalQuoteList): QuoteList {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
quoteIds: local.quoteIds,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
async function loadLists() {
isLoading = true;
error = null;
try {
const localLists = await listCollection.getAll();
lists = localLists.map(toQuoteList);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load lists';
lists = [];
} finally {
isLoading = false;
}
}
async function getList(id: string): Promise<QuoteList | null> {
const local = await listCollection.get(id);
return local ? toQuoteList(local) : null;
}
async function createList(name: string, description?: string): Promise<QuoteList | null> {
try {
const newLocal: LocalQuoteList = {
id: crypto.randomUUID(),
name,
description: description ?? null,
quoteIds: [],
};
const inserted = await listCollection.insert(newLocal);
const newList = toQuoteList(inserted);
lists = [...lists, newList];
return newList;
} catch {
return null;
}
}
async function updateList(
id: string,
updates: { name?: string; description?: string }
): Promise<QuoteList | null> {
try {
const updated = await listCollection.update(id, updates as Partial<LocalQuoteList>);
if (updated) {
const updatedList = toQuoteList(updated);
lists = lists.map((l) => (l.id === id ? updatedList : l));
return updatedList;
}
return null;
} catch {
return null;
}
}
async function deleteList(id: string): Promise<boolean> {
try {
await listCollection.delete(id);
lists = lists.filter((l) => l.id !== id);
return true;
} catch {
return false;
}
}
async function addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await listCollection.get(listId);
if (!existing) return false;
const quoteIds = [...(existing.quoteIds || [])];
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
const updated = await listCollection.update(listId, { quoteIds } as Partial<LocalQuoteList>);
if (updated) {
lists = lists.map((l) => (l.id === listId ? toQuoteList(updated) : l));
}
return true;
} catch {
return false;
}
}
async function removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await listCollection.get(listId);
if (!existing) return false;
const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId);
const updated = await listCollection.update(listId, { quoteIds } as Partial<LocalQuoteList>);
if (updated) {
lists = lists.map((l) => (l.id === listId ? toQuoteList(updated) : l));
}
return true;
} catch {
return false;
}
}
export type { QuoteList } from '$lib/data/queries';
export const listsStore = {
get lists() {
return lists;
async getList(id: string): Promise<QuoteList | null> {
const local = await listCollection.get(id);
return local ? toQuoteList(local) : null;
},
get isLoading() {
return isLoading;
async createList(name: string, description?: string): Promise<QuoteList | null> {
try {
const newLocal: LocalQuoteList = {
id: crypto.randomUUID(),
name,
description: description ?? null,
quoteIds: [],
};
const inserted = await listCollection.insert(newLocal);
return toQuoteList(inserted);
} catch {
return null;
}
},
get error() {
return error;
async updateList(
id: string,
updates: { name?: string; description?: string }
): Promise<QuoteList | null> {
try {
const updated = await listCollection.update(id, updates as Partial<LocalQuoteList>);
return updated ? toQuoteList(updated) : null;
} catch {
return null;
}
},
async deleteList(id: string): Promise<boolean> {
try {
await listCollection.delete(id);
return true;
} catch {
return false;
}
},
async addQuoteToList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await listCollection.get(listId);
if (!existing) return false;
const quoteIds = [...(existing.quoteIds || [])];
if (!quoteIds.includes(quoteId)) {
quoteIds.push(quoteId);
}
await listCollection.update(listId, { quoteIds } as Partial<LocalQuoteList>);
return true;
} catch {
return false;
}
},
async removeQuoteFromList(listId: string, quoteId: string): Promise<boolean> {
try {
const existing = await listCollection.get(listId);
if (!existing) return false;
const quoteIds = (existing.quoteIds || []).filter((qid) => qid !== quoteId);
await listCollection.update(listId, { quoteIds } as Partial<LocalQuoteList>);
return true;
} catch {
return false;
}
},
loadLists,
getList,
createList,
updateList,
deleteList,
addQuoteToList,
removeQuoteFromList,
};

View file

@ -21,10 +21,10 @@
}
import { theme } from '$lib/stores/theme.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { listsStore } from '$lib/stores/lists.svelte';
import { zitareSettings } from '$lib/stores/settings.svelte';
import { useAllFavorites, useAllLists } from '$lib/data/queries';
import {
THEME_DEFINITIONS,
DEFAULT_THEME_VARIANTS,
@ -44,6 +44,12 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
const allFavorites = useAllFavorites();
setContext('favorites', allFavorites);
const allLists = useAllLists();
setContext('lists', allLists);
let showGuestWelcome = $state(false);
// App switcher items
@ -147,7 +153,6 @@
async function handleLogout() {
await authStore.signOut();
favoritesStore.clear();
goto('/login');
}
@ -247,10 +252,6 @@
// Initialize settings
zitareSettings.initialize();
// Load favorites and lists from IndexedDB (works for guests and auth)
await favoritesStore.load();
await listsStore.loadLists();
if (authStore.isAuthenticated) {
const getToken = () => authStore.getValidToken();
zitareStore.startSync(getToken);

View file

@ -1,16 +1,20 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { type Favorite } from '$lib/data/queries';
import { getQuoteById, getQuoteText, type Quote } from '@zitare/content';
import { zitareSettings } from '$lib/stores/settings.svelte';
import QuoteCard from '$lib/components/QuoteCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
// Get favorite quotes
let favoriteQuotes = $derived(
favoritesStore.favorites
allFavorites.value
.map((f) => getQuoteById(f.quoteId))
.filter((q): q is NonNullable<typeof q> => q !== undefined)
);
@ -39,7 +43,7 @@
id: 'remove-favorite',
label: $_('favorites.removeFromFavorites'),
variant: 'danger',
action: () => favoritesStore.toggle(quote.id),
action: () => favoritesStore.toggle(quote.id, allFavorites.value),
},
{ id: 'divider-1', label: '', type: 'divider' },
{
@ -108,13 +112,6 @@
{$_('auth.login')}
</button>
</div>
{:else if favoritesStore.loading}
<!-- Loading -->
<div class="text-center py-12">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
{:else if favoriteQuotes.length === 0}
<!-- Empty state -->
<div class="text-center py-12 bg-surface-elevated rounded-2xl">

View file

@ -1,13 +1,15 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { toast } from '$lib/stores/toast.svelte';
import { listsStore } from '$lib/stores/lists.svelte';
import { type QuoteList } from '$lib/data/queries';
import { ZitareEvents } from '@manacore/shared-utils/analytics';
let loading = $state(true);
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let saving = $state(false);
let deletingId = $state<string | null>(null);
let showCreateModal = $state(false);
@ -57,11 +59,6 @@
deletingId = null;
}
}
onMount(async () => {
await listsStore.loadLists();
loading = false;
});
</script>
<svelte:head>
@ -112,13 +109,7 @@
{$_('auth.login')}
</button>
</div>
{:else if loading}
<div class="text-center py-12">
<div
class="w-10 h-10 border-4 border-primary border-t-transparent rounded-full animate-spin mx-auto"
></div>
</div>
{:else if listsStore.lists.length === 0}
{:else if allLists.value.length === 0}
<div class="text-center py-12 bg-surface-elevated rounded-2xl">
<svg
class="w-16 h-16 mx-auto text-foreground-muted mb-4"
@ -138,7 +129,7 @@
</div>
{:else}
<div class="grid gap-4">
{#each listsStore.lists as list (list.id)}
{#each allLists.value as list (list.id)}
<a
href="/lists/{list.id}"
class="block p-6 bg-surface-elevated rounded-2xl hover:shadow-lg transition-all group"

View file

@ -2,7 +2,9 @@
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { _, locale } from 'svelte-i18n';
import { listsStore, type QuoteList } from '$lib/stores/lists.svelte';
import { getContext } from 'svelte';
import { listsStore } from '$lib/stores/lists.svelte';
import { findListById, type QuoteList } from '$lib/data/queries';
import { authStore } from '$lib/stores/auth.svelte';
import { quotesStore } from '$lib/stores/quotes.svelte';
import { toast } from '$lib/stores/toast.svelte';
@ -10,9 +12,8 @@
import QuoteCard from '$lib/components/QuoteCard.svelte';
const allQuotes = QUOTES;
const allLists: { readonly value: QuoteList[] } = getContext('lists');
let list = $state<QuoteList | null>(null);
let isLoading = $state(true);
let isSaving = $state(false);
let isAdding = $state(false);
let removingQuoteId = $state<string | null>(null);
@ -24,28 +25,8 @@
let editDescription = $state('');
let selectedQuoteIds = $state<Set<string>>(new Set());
// Load list on mount
$effect(() => {
const listId = $page.params.id;
if (listId) {
loadList(listId);
}
});
async function loadList(listId: string) {
if (!authStore.isAuthenticated) {
goto('/login');
return;
}
isLoading = true;
list = await listsStore.getList(listId);
isLoading = false;
if (!list) {
toast.error($_('lists.detail.notFound'));
}
}
// Reactive list from liveQuery context
let list = $derived<QuoteList | undefined>(findListById(allLists.value, $page.params.id));
// Get quotes in this list
let listQuotes = $derived<Quote[]>(
@ -94,7 +75,6 @@
description: editDescription.trim() || undefined,
});
if (updated) {
list = updated;
toast.success($_('lists.detail.toast.updated'));
closeEditModal();
} else {
@ -146,7 +126,6 @@
if (success) successCount++;
}
if (successCount > 0) {
list = await listsStore.getList(list.id);
toast.success($_('lists.detail.toast.quotesAdded', { values: { count: successCount } }));
}
closeAddQuotesModal();
@ -161,7 +140,6 @@
try {
const success = await listsStore.removeQuoteFromList(list.id, quoteId);
if (success) {
list = await listsStore.getList(list.id);
toast.info($_('lists.detail.toast.quoteRemoved'));
} else {
toast.error($_('lists.detail.toast.removeError'));
@ -184,12 +162,7 @@
<title>{list?.name || $_('common.list')} - Zitare</title>
</svelte:head>
{#if isLoading}
<div class="loading-state">
<div class="spinner"></div>
<p>{$_('common.loading')}</p>
</div>
{:else if !list}
{#if !list}
<div class="error-state">
<h2>{$_('lists.detail.notFound')}</h2>
<p>{$_('lists.detail.notFoundDescription')}</p>

View file

@ -1,14 +1,17 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { COLORS } from '@manacore/spiral-db';
import type { ColorIndex } from '@manacore/spiral-db';
import { spiralStore } from '$lib/stores/spiral.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { type Favorite } from '$lib/data/queries';
import { quotesStore } from '$lib/stores/quotes.svelte';
import SpiralCanvas from '$lib/components/SpiralCanvas.svelte';
import { QUOTES, type Quote } from '@zitare/content';
const allFavorites: { readonly value: Favorite[] } = getContext('favorites');
let zoom = $state(10);
let showGrid = $state(false);
let selectedPixel = $state<number | null>(null);
@ -33,7 +36,7 @@
function handleImportFavorites() {
spiralStore.importFavorites(
favoritesStore.favorites.map((f) => ({
allFavorites.value.map((f) => ({
quoteId: f.quoteId,
createdAt: f.createdAt,
})),
@ -72,7 +75,7 @@
};
onMount(() => {
if (favoritesStore.favorites.length > 0) {
if (allFavorites.value.length > 0) {
handleImportFavorites();
}
});
@ -207,9 +210,9 @@
<button
class="btn"
onclick={handleImportFavorites}
disabled={favoritesStore.favorites.length === 0}
disabled={allFavorites.value.length === 0}
>
Favoriten neu importieren ({favoritesStore.favorites.length})
Favoriten neu importieren ({allFavorites.value.length})
</button>
<button
class="btn btn-danger"