mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 22:41:09 +02:00
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:
parent
ced7dd7441
commit
30e124e609
87 changed files with 2528 additions and 3136 deletions
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
220
apps/calendar/apps/web/src/lib/data/queries.ts
Normal file
220
apps/calendar/apps/web/src/lib/data/queries.ts
Normal 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;
|
||||
});
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -24,7 +24,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
134
apps/chat/apps/web/src/lib/data/queries.ts
Normal file
134
apps/chat/apps/web/src/lib/data/queries.ts
Normal 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),
|
||||
};
|
||||
}
|
||||
|
|
@ -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);
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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') {
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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';
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
106
apps/clock/apps/web/src/lib/data/queries.ts
Normal file
106
apps/clock/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
87
apps/contacts/apps/web/src/lib/data/queries.ts
Normal file
87
apps/contacts/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -26,7 +26,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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)
|
||||
: []
|
||||
|
|
|
|||
147
apps/context/apps/web/src/lib/data/queries.ts
Normal file
147
apps/context/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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 -->
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
90
apps/manadeck/apps/web/src/lib/data/queries.ts
Normal file
90
apps/manadeck/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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
|
||||
*/
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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)} />
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
73
apps/presi/apps/web/src/lib/data/queries.ts
Normal file
73
apps/presi/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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,
|
||||
};
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -25,7 +25,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
135
apps/storage/apps/web/src/lib/data/queries.ts
Normal file
135
apps/storage/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -35,6 +35,7 @@
|
|||
primaryColor="#3b82f6"
|
||||
onSignIn={handleSignIn}
|
||||
onResendVerification={handleResendVerification}
|
||||
onSendMagicLink={(email) => authStore.sendMagicLink(email)}
|
||||
{goto}
|
||||
successRedirect="/files"
|
||||
registerPath="/register"
|
||||
|
|
|
|||
|
|
@ -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 {
|
||||
|
|
|
|||
91
apps/zitare/apps/web/src/lib/data/queries.ts
Normal file
91
apps/zitare/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue