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

Complete the useLiveQuery migration across all apps. Same pattern:
queries.ts with live query hooks, stores slimmed to mutation-only,
components use Svelte context for reactive reads.

Apps migrated:
- Picture: images, boards, boardItems (writable stores → liveQuery)
- Photos: albums, albumItems, favorites
- Planta: plants, plantPhotos, wateringSchedules, wateringLogs
- Questions: collections, questions
- Mukke: songs, playlists, playlistSongs, projects
- CityCorners: locations, favorites

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-28 02:42:13 +01:00
parent 4ff3ceb01a
commit 924c15277a
46 changed files with 1825 additions and 1547 deletions

View file

@ -0,0 +1,67 @@
/**
* Reactive Queries & Pure Filter Helpers for CityCorners
*
* 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 {
locationCollection,
favoriteCollection,
type LocalLocation,
type LocalFavorite,
} from './local-store';
// ─── Live Query Hooks (call during component init) ──────────
/** All locations, sorted by name. Auto-updates on any change. */
export function useAllLocations() {
return useLiveQueryWithDefault(async () => {
return locationCollection.getAll(undefined, {
sortBy: 'name',
sortDirection: 'asc',
});
}, [] as LocalLocation[]);
}
/** All favorites. Auto-updates on any change. */
export function useAllFavorites() {
return useLiveQueryWithDefault(async () => {
return favoriteCollection.getAll();
}, [] as LocalFavorite[]);
}
// ─── Pure Filter Functions (for $derived) ───────────────────
/** Get a Set of favorite location IDs for quick lookup. */
export function getFavoriteIds(favorites: LocalFavorite[]): Set<string> {
return new Set(favorites.map((f) => f.locationId));
}
/** Check if a location is favorited. */
export function isFavorite(favorites: LocalFavorite[], locationId: string): boolean {
return favorites.some((f) => f.locationId === locationId);
}
/** Filter locations by category. */
export function filterByCategory(
locations: LocalLocation[],
category: string | null
): LocalLocation[] {
if (!category) return locations;
return locations.filter((l) => l.category === category);
}
/** Filter locations by search query across name, description, address. */
export function searchLocations(locations: LocalLocation[], query: string): LocalLocation[] {
if (!query.trim()) return locations;
const search = query.toLowerCase().trim();
return locations.filter(
(l) =>
l.name.toLowerCase().includes(search) ||
l.description?.toLowerCase().includes(search) ||
l.address?.toLowerCase().includes(search)
);
}

View file

@ -1,102 +1,47 @@
/**
* Favorites Store - Manages favorite locations using Svelte 5 runes
* Favorites Store Mutation-Only
*
* 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 { authStore } from './auth.svelte';
import { api } from '$lib/api';
import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store';
interface Favorite {
id: string;
userId: string;
locationId: string;
createdAt: string;
}
let favoriteLocationIds = $state<Set<string>>(new Set());
let loading = $state(false);
export const favoritesStore = {
get favoriteIds() {
return favoriteLocationIds;
},
get loading() {
return loading;
},
isFavorite(locationId: string): boolean {
return favoriteLocationIds.has(locationId);
},
async load() {
if (!authStore.isAuthenticated) return;
/**
* Toggle a favorite writes to / removes from IndexedDB instantly.
*/
async toggle(locationId: string) {
loading = true;
try {
const token = await authStore.getValidToken();
if (!token) return;
const all = await favoriteCollection.getAll();
const existing = all.find((f) => f.locationId === locationId);
const res = await fetch(`${api('/favorites')}`, {
headers: { Authorization: `Bearer ${token}` },
});
if (res.ok) {
const data = await res.json();
favoriteLocationIds = new Set(data.favorites.map((f: Favorite) => f.locationId));
if (existing) {
await favoriteCollection.delete(existing.id);
} else {
const newFav: LocalFavorite = {
id: crypto.randomUUID(),
locationId,
};
await favoriteCollection.insert(newFav);
}
} catch (err) {
console.error('Failed to load favorites:', err);
console.error('Failed to toggle favorite:', err);
} finally {
loading = false;
}
},
async toggle(locationId: string) {
if (!authStore.isAuthenticated) return;
const token = await authStore.getValidToken();
if (!token) return;
const isFav = favoriteLocationIds.has(locationId);
// Optimistic update
const newSet = new Set(favoriteLocationIds);
if (isFav) {
newSet.delete(locationId);
} else {
newSet.add(locationId);
}
favoriteLocationIds = newSet;
try {
const res = await fetch(`${api(`/favorites/${locationId}`)}`, {
method: isFav ? 'DELETE' : 'POST',
headers: { Authorization: `Bearer ${token}` },
});
if (!res.ok) {
// Revert on error
const revertSet = new Set(favoriteLocationIds);
if (isFav) {
revertSet.add(locationId);
} else {
revertSet.delete(locationId);
}
favoriteLocationIds = revertSet;
}
} catch (err) {
console.error('Failed to toggle favorite:', err);
// Revert
const revertSet = new Set(favoriteLocationIds);
if (isFav) {
revertSet.add(locationId);
} else {
revertSet.delete(locationId);
}
favoriteLocationIds = revertSet;
}
},
clear() {
favoriteLocationIds = new Set();
// Nothing to clear — reads come from liveQuery
},
};

View file

@ -3,9 +3,14 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
// Live query for favorites — auto-updates on IndexedDB changes
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface Location {
id: string;
slug?: string;
@ -95,9 +100,6 @@
onMount(() => {
loadLocations();
if (authStore.isAuthenticated) {
favoritesStore.load();
}
});
// Reload when category changes
@ -230,11 +232,9 @@
<button
class="absolute right-3 top-3 flex h-9 w-9 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={(e) => handleFavoriteToggle(e, location.id)}
title={favoritesStore.isFavorite(location.id)
? $_('favorites.remove')
: $_('favorites.add')}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoritesStore.isFavorite(location.id)}
{#if favoriteIds.has(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"

View file

@ -3,8 +3,13 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
// Live query for favorites — auto-updates on IndexedDB changes
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface Location {
id: string;
slug?: string;
@ -36,7 +41,7 @@
// Collection detail view
let selectedCollection = $state<Collection | null>(null);
let favoriteLocations = $derived(allLocations.filter((l) => favoritesStore.isFavorite(l.id)));
let favoriteLocations = $derived(allLocations.filter((l) => favoriteIds.has(l.id)));
let selectedCollectionLocations = $derived(
selectedCollection
@ -56,7 +61,6 @@
}
if (authStore.isAuthenticated) {
await favoritesStore.load();
await loadCollections();
}
});

View file

@ -6,9 +6,14 @@
import { _ } from 'svelte-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { favoritesStore } from '$lib/stores/favorites.svelte';
import { useAllFavorites, getFavoriteIds } from '$lib/data/queries';
import { api } from '$lib/api';
import { isOpenNow } from '$lib/opening-hours';
// Live query for favorites — auto-updates on IndexedDB changes
const allFavorites = useAllFavorites();
let favoriteIds = $derived(getFavoriteIds(allFavorites.value));
interface TimelineEntry {
year: string;
event: string;
@ -140,10 +145,6 @@
loading = false;
}
if (authStore.isAuthenticated) {
favoritesStore.load();
}
// Load reviews
loadReviews();
});
@ -406,11 +407,9 @@
<button
class="flex h-10 w-10 items-center justify-center rounded-full bg-black/30 backdrop-blur-sm transition-all hover:bg-black/50"
onclick={() => favoritesStore.toggle(location!.id)}
title={favoritesStore.isFavorite(location.id)
? $_('favorites.remove')
: $_('favorites.add')}
title={favoriteIds.has(location.id) ? $_('favorites.remove') : $_('favorites.add')}
>
{#if favoritesStore.isFavorite(location.id)}
{#if favoriteIds.has(location.id)}
<svg class="h-5 w-5 text-red-500" fill="currentColor" viewBox="0 0 24 24">
<path
d="M11.645 20.91l-.007-.003-.022-.012a15.247 15.247 0 01-.383-.218 25.18 25.18 0 01-4.244-3.17C4.688 15.36 2.25 12.174 2.25 8.25 2.25 5.322 4.714 3 7.688 3A5.5 5.5 0 0112 5.052 5.5 5.5 0 0116.313 3c2.973 0 5.437 2.322 5.437 5.25 0 3.925-2.438 7.111-4.739 9.256a25.175 25.175 0 01-4.244 3.17 15.247 15.247 0 01-.383.219l-.022.012-.007.004-.003.001a.752.752 0 01-.704 0l-.003-.001z"

View file

@ -0,0 +1,157 @@
/**
* Reactive Queries & Pure Filter Helpers for Mukke
*
* 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.
*
* NOTE: Mukke's library/playlist/project stores still use backend API calls
* for most operations (upload, streaming, metadata extraction, etc.).
* These queries provide reactive reads from IndexedDB for the local-first
* collections. The stores remain for API-driven mutations.
*/
import { useLiveQueryWithDefault } from '@manacore/local-store/svelte';
import {
songCollection,
playlistCollection,
playlistSongCollection,
projectCollection,
markerCollection,
type LocalSong,
type LocalPlaylist,
type LocalPlaylistSong,
type LocalProject,
type LocalMarker,
} from './local-store';
// ─── Live Query Hooks (call during component init) ──────────
/** All songs, sorted by title. Auto-updates on any change. */
export function useAllSongs() {
return useLiveQueryWithDefault(async () => {
return songCollection.getAll(undefined, {
sortBy: 'title',
sortDirection: 'asc',
});
}, [] as LocalSong[]);
}
/** All playlists, sorted by name. Auto-updates on any change. */
export function useAllPlaylists() {
return useLiveQueryWithDefault(async () => {
return playlistCollection.getAll(undefined, {
sortBy: 'name',
sortDirection: 'asc',
});
}, [] as LocalPlaylist[]);
}
/** All playlist-song associations. Auto-updates on any change. */
export function useAllPlaylistSongs() {
return useLiveQueryWithDefault(async () => {
return playlistSongCollection.getAll();
}, [] as LocalPlaylistSong[]);
}
/** All projects, sorted by title. Auto-updates on any change. */
export function useAllProjects() {
return useLiveQueryWithDefault(async () => {
return projectCollection.getAll(undefined, {
sortBy: 'title',
sortDirection: 'asc',
});
}, [] as LocalProject[]);
}
/** All markers for a given beat ID. */
export function useMarkersByBeat(beatId: string) {
return useLiveQueryWithDefault(async () => {
const all = await markerCollection.getAll();
return all.filter((m) => m.beatId === beatId).sort((a, b) => a.startTime - b.startTime);
}, [] as LocalMarker[]);
}
// ─── Pure Filter Functions (for $derived) ───────────────────
/** Filter songs by search query across title, artist, album. */
export function searchSongs(songs: LocalSong[], query: string): LocalSong[] {
if (!query.trim()) return songs;
const search = query.toLowerCase().trim();
return songs.filter(
(s) =>
s.title?.toLowerCase().includes(search) ||
s.artist?.toLowerCase().includes(search) ||
s.album?.toLowerCase().includes(search)
);
}
/** Filter songs to favorites only. */
export function filterFavorites(songs: LocalSong[]): LocalSong[] {
return songs.filter((s) => s.favorite);
}
/** Filter songs by artist. */
export function filterByArtist(songs: LocalSong[], artist: string): LocalSong[] {
if (!artist) return songs;
return songs.filter((s) => s.artist === artist);
}
/** Filter songs by album. */
export function filterByAlbum(songs: LocalSong[], album: string): LocalSong[] {
if (!album) return songs;
return songs.filter((s) => s.album === album);
}
/** Filter songs by genre. */
export function filterByGenre(songs: LocalSong[], genre: string): LocalSong[] {
if (!genre) return songs;
return songs.filter((s) => s.genre === genre);
}
/** Get songs for a playlist, sorted by sortOrder. */
export function getPlaylistSongs(
songs: LocalSong[],
playlistSongs: LocalPlaylistSong[],
playlistId: string
): LocalSong[] {
const psForPlaylist = playlistSongs
.filter((ps) => ps.playlistId === playlistId)
.sort((a, b) => a.sortOrder - b.sortOrder);
return psForPlaylist
.map((ps) => songs.find((s) => s.id === ps.songId))
.filter((s): s is LocalSong => !!s);
}
/** Group songs by artist. */
export function groupByArtist(songs: LocalSong[]): Record<string, LocalSong[]> {
const groups: Record<string, LocalSong[]> = {};
for (const song of songs) {
const key = song.artist || 'Unknown Artist';
if (!groups[key]) groups[key] = [];
groups[key].push(song);
}
return groups;
}
/** Group songs by album. */
export function groupByAlbum(songs: LocalSong[]): Record<string, LocalSong[]> {
const groups: Record<string, LocalSong[]> = {};
for (const song of songs) {
const key = song.album || 'Unknown Album';
if (!groups[key]) groups[key] = [];
groups[key].push(song);
}
return groups;
}
/** Group songs by genre. */
export function groupByGenre(songs: LocalSong[]): Record<string, LocalSong[]> {
const groups: Record<string, LocalSong[]> = {};
for (const song of songs) {
const key = song.genre || 'Unknown Genre';
if (!groups[key]) groups[key] = [];
groups[key].push(song);
}
return groups;
}

View file

@ -1,3 +1,15 @@
/**
* Library Store Mutation + API Operations
*
* Reads for songs list are handled by useLiveQuery (see $lib/data/queries.ts).
* This store handles:
* - Mutations that write to IndexedDB (toggle favorite, delete)
* - API-only operations (upload, cover URLs, metadata extraction, write tags)
* - Aggregated views from backend (albums, artists, genres, stats)
*
* The live queries will automatically pick up local changes.
*/
import type {
Song,
Album,
@ -8,10 +20,10 @@ import type {
SortDirection,
} from '@mukke/shared';
import { authStore } from './auth.svelte';
import { songCollection, type LocalSong } from '$lib/data/local-store';
import { trackEvent } from '@manacore/shared-utils/analytics';
interface LibraryState {
songs: Song[];
albums: Album[];
artists: Artist[];
genres: Genre[];
@ -37,7 +49,6 @@ function getBackendUrl(): string {
function createLibraryStore() {
let state = $state<LibraryState>({
songs: [],
albums: [],
artists: [],
genres: [],
@ -70,9 +81,6 @@ function createLibraryStore() {
}
return {
get songs() {
return state.songs;
},
get albums() {
return state.albums;
},
@ -120,22 +128,7 @@ function createLibraryStore() {
}
},
async loadSongs() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ songs: Song[] }>(
`/songs?sort=${state.sortField}&direction=${state.sortDirection}`
);
state.songs = data.songs;
const coverPaths = data.songs.map((s) => s.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load songs';
}
state.isLoading = false;
},
/** Load albums from backend (aggregated view). */
async loadAlbums() {
state.isLoading = true;
state.error = null;
@ -150,6 +143,7 @@ function createLibraryStore() {
state.isLoading = false;
},
/** Load artists from backend (aggregated view). */
async loadArtists() {
state.isLoading = true;
state.error = null;
@ -162,6 +156,7 @@ function createLibraryStore() {
state.isLoading = false;
},
/** Load genres from backend (aggregated view). */
async loadGenres() {
state.isLoading = true;
state.error = null;
@ -174,6 +169,7 @@ function createLibraryStore() {
state.isLoading = false;
},
/** Load stats from backend. */
async loadStats() {
try {
const data = await fetchApi<{ stats: LibraryStats }>('/library/stats');
@ -183,57 +179,62 @@ function createLibraryStore() {
}
},
async loadAll() {
state.isLoading = true;
state.error = null;
try {
const [songsData, statsData] = await Promise.all([
fetchApi<{ songs: Song[] }>(
`/songs?sort=${state.sortField}&direction=${state.sortDirection}`
),
fetchApi<{ stats: LibraryStats }>('/library/stats'),
]);
state.songs = songsData.songs;
state.stats = statsData.stats;
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load library';
}
state.isLoading = false;
},
/** Toggle favorite — writes to IndexedDB instantly. */
async toggleFavorite(id: string) {
const data = await fetchApi<{ song: Song }>(`/songs/${id}/favorite`, {
method: 'PUT',
});
state.songs = state.songs.map((s) => (s.id === id ? data.song : s));
return data.song;
const local = await songCollection.get(id);
if (local) {
await songCollection.update(id, { favorite: !local.favorite } as Partial<LocalSong>);
}
// Also update backend
try {
await fetchApi<{ song: Song }>(`/songs/${id}/favorite`, { method: 'PUT' });
} catch {
// Sync will reconcile
}
},
async incrementPlayCount(id: string) {
const data = await fetchApi<{ song: Song }>(`/songs/${id}/play`, {
method: 'PUT',
});
state.songs = state.songs.map((s) => (s.id === id ? data.song : s));
return data.song;
const local = await songCollection.get(id);
if (local) {
await songCollection.update(id, {
playCount: (local.playCount || 0) + 1,
lastPlayedAt: new Date().toISOString(),
} as Partial<LocalSong>);
}
try {
await fetchApi<{ song: Song }>(`/songs/${id}/play`, { method: 'PUT' });
} catch {
// Sync will reconcile
}
},
/** Search songs from IndexedDB. */
async searchSongs(query: string) {
const data = await fetchApi<{ songs: Song[] }>(
`/songs/search?q=${encodeURIComponent(query)}`
);
return data.songs;
const all = await songCollection.getAll();
const q = query.toLowerCase();
return all
.filter(
(s) =>
s.title?.toLowerCase().includes(q) ||
s.artist?.toLowerCase().includes(q) ||
s.album?.toLowerCase().includes(q)
)
.slice(0, 20) as unknown as Song[];
},
/** Delete song — removes from IndexedDB instantly + backend. */
async deleteSong(id: string) {
await fetchApi(`/songs/${id}`, { method: 'DELETE' });
state.songs = state.songs.filter((s) => s.id !== id);
await songCollection.delete(id);
try {
await fetchApi(`/songs/${id}`, { method: 'DELETE' });
} catch {
// Sync will reconcile
}
},
setActiveTab(tab: 'songs' | 'albums' | 'artists' | 'genres') {
state.activeTab = tab;
if (tab === 'songs' && state.songs.length === 0) {
this.loadSongs();
} else if (tab === 'albums' && state.albums.length === 0) {
if (tab === 'albums' && state.albums.length === 0) {
this.loadAlbums();
} else if (tab === 'artists' && state.artists.length === 0) {
this.loadArtists();
@ -242,16 +243,7 @@ function createLibraryStore() {
}
},
async setSortField(field: SortField) {
state.sortField = field;
await this.loadSongs();
},
async setSortDirection(direction: SortDirection) {
state.sortDirection = direction;
await this.loadSongs();
},
/** Upload song via API, then store metadata in IndexedDB. */
async uploadSong(file: File) {
const uploadData = await fetchApi<{ song: Song; uploadUrl: string }>('/songs/upload', {
method: 'POST',
@ -267,25 +259,77 @@ function createLibraryStore() {
headers: { 'Content-Type': file.type },
});
state.songs = [uploadData.song, ...state.songs];
// Write to IndexedDB so liveQuery picks it up
const localSong: LocalSong = {
id: uploadData.song.id,
title: uploadData.song.title || file.name,
artist: uploadData.song.artist ?? null,
album: uploadData.song.album ?? null,
albumArtist: uploadData.song.albumArtist ?? null,
genre: uploadData.song.genre ?? null,
trackNumber: uploadData.song.trackNumber ?? null,
year: uploadData.song.year ?? null,
duration: uploadData.song.duration ?? null,
storagePath: uploadData.song.storagePath,
coverArtPath: uploadData.song.coverArtPath ?? null,
fileSize: uploadData.song.fileSize ?? null,
bpm: uploadData.song.bpm ?? null,
favorite: false,
playCount: 0,
lastPlayedAt: null,
};
await songCollection.insert(localSong);
trackEvent('song_uploaded');
return uploadData.song;
},
/** Update song metadata — writes to IndexedDB + backend. */
async updateSongMetadata(id: string, data: Partial<Song>) {
const result = await fetchApi<{ song: Song }>(`/songs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
state.songs = state.songs.map((s) => (s.id === id ? result.song : s));
return result.song;
const updateData: Partial<LocalSong> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.artist !== undefined) updateData.artist = data.artist ?? null;
if (data.album !== undefined) updateData.album = data.album ?? null;
if (data.albumArtist !== undefined) updateData.albumArtist = data.albumArtist ?? null;
if (data.genre !== undefined) updateData.genre = data.genre ?? null;
if (data.trackNumber !== undefined) updateData.trackNumber = data.trackNumber ?? null;
if (data.year !== undefined) updateData.year = data.year ?? null;
if (data.bpm !== undefined) updateData.bpm = data.bpm ?? null;
await songCollection.update(id, updateData);
// Also update backend
try {
const result = await fetchApi<{ song: Song }>(`/songs/${id}`, {
method: 'PUT',
body: JSON.stringify(data),
});
return result.song;
} catch {
// Sync will reconcile
return data as Song;
}
},
/** Extract metadata from file — server-side operation, then update IndexedDB. */
async extractMetadata(id: string) {
const result = await fetchApi<{ song: Song }>(`/songs/${id}/extract-metadata`, {
method: 'POST',
});
state.songs = state.songs.map((s) => (s.id === id ? result.song : s));
// Update IndexedDB with extracted metadata
const updateData: Partial<LocalSong> = {
title: result.song.title,
artist: result.song.artist ?? null,
album: result.song.album ?? null,
albumArtist: result.song.albumArtist ?? null,
genre: result.song.genre ?? null,
trackNumber: result.song.trackNumber ?? null,
year: result.song.year ?? null,
duration: result.song.duration ?? null,
coverArtPath: result.song.coverArtPath ?? null,
bpm: result.song.bpm ?? null,
};
await songCollection.update(id, updateData);
return result.song;
},

View file

@ -1,8 +1,21 @@
/**
* Playlist Store Mutation + API Operations
*
* Reads for playlist lists are handled by useLiveQuery (see $lib/data/queries.ts).
* This store handles mutations that write to IndexedDB + backend.
* The live queries will automatically pick up local changes.
*/
import type { Playlist, PlaylistWithSongs } from '@mukke/shared';
import { authStore } from './auth.svelte';
import {
playlistCollection,
playlistSongCollection,
type LocalPlaylist,
type LocalPlaylistSong,
} from '$lib/data/local-store';
interface PlaylistState {
playlists: Playlist[];
currentPlaylist: PlaylistWithSongs | null;
coverUrls: Record<string, string>;
isLoading: boolean;
@ -22,7 +35,6 @@ function getBackendUrl(): string {
function createPlaylistStore() {
let state = $state<PlaylistState>({
playlists: [],
currentPlaylist: null,
coverUrls: {},
isLoading: false,
@ -49,9 +61,6 @@ function createPlaylistStore() {
}
return {
get playlists() {
return state.playlists;
},
get currentPlaylist() {
return state.currentPlaylist;
},
@ -79,22 +88,7 @@ function createPlaylistStore() {
}
},
async loadPlaylists() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ playlists: Playlist[] }>('/playlists');
state.playlists = data.playlists;
const coverPaths = data.playlists
.map((p) => p.coverArtPath)
.filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlists';
}
state.isLoading = false;
},
/** Load a single playlist detail from backend. */
async loadPlaylist(id: string) {
state.isLoading = true;
state.error = null;
@ -111,72 +105,150 @@ function createPlaylistStore() {
state.isLoading = false;
},
/** Create playlist — writes to IndexedDB + backend. */
async createPlaylist(name: string, description?: string) {
const data = await fetchApi<{ playlist: Playlist }>('/playlists', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
state.playlists = [data.playlist, ...state.playlists];
return data.playlist;
},
const newLocal: LocalPlaylist = {
id: crypto.randomUUID(),
name,
description: description ?? null,
coverArtPath: null,
};
await playlistCollection.insert(newLocal);
async updatePlaylist(id: string, updates: { name?: string; description?: string }) {
const data = await fetchApi<{ playlist: Playlist }>(`/playlists/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
state.playlists = state.playlists.map((p) => (p.id === id ? data.playlist : p));
if (state.currentPlaylist?.id === id) {
state.currentPlaylist = { ...state.currentPlaylist, ...data.playlist };
// Also create on backend
try {
const data = await fetchApi<{ playlist: Playlist }>('/playlists', {
method: 'POST',
body: JSON.stringify({ name, description }),
});
return data.playlist;
} catch {
// Sync will reconcile
return newLocal as unknown as Playlist;
}
return data.playlist;
},
/** Update playlist — writes to IndexedDB + backend. */
async updatePlaylist(id: string, updates: { name?: string; description?: string }) {
const updateData: Partial<LocalPlaylist> = {};
if (updates.name !== undefined) updateData.name = updates.name;
if (updates.description !== undefined) updateData.description = updates.description ?? null;
await playlistCollection.update(id, updateData);
try {
const data = await fetchApi<{ playlist: Playlist }>(`/playlists/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
if (state.currentPlaylist?.id === id) {
state.currentPlaylist = { ...state.currentPlaylist, ...data.playlist };
}
return data.playlist;
} catch {
return updates as unknown as Playlist;
}
},
/** Delete playlist — removes from IndexedDB + backend. */
async deletePlaylist(id: string) {
await fetchApi(`/playlists/${id}`, { method: 'DELETE' });
state.playlists = state.playlists.filter((p) => p.id !== id);
await playlistCollection.delete(id);
// Also delete associated playlistSongs
const allPS = await playlistSongCollection.getAll();
for (const ps of allPS.filter((p) => p.playlistId === id)) {
await playlistSongCollection.delete(ps.id);
}
if (state.currentPlaylist?.id === id) {
state.currentPlaylist = null;
}
try {
await fetchApi(`/playlists/${id}`, { method: 'DELETE' });
} catch {
// Sync will reconcile
}
},
/** Add song to playlist — writes to IndexedDB + backend. */
async addSong(playlistId: string, songId: string) {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs`,
{
method: 'POST',
body: JSON.stringify({ songId }),
const allPS = await playlistSongCollection.getAll();
const maxSort = allPS
.filter((ps) => ps.playlistId === playlistId)
.reduce((max, ps) => Math.max(max, ps.sortOrder), -1);
const newPS: LocalPlaylistSong = {
id: crypto.randomUUID(),
playlistId,
songId,
sortOrder: maxSort + 1,
};
await playlistSongCollection.insert(newPS);
try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs`,
{
method: 'POST',
body: JSON.stringify({ songId }),
}
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
}
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
return data.playlist;
} catch {
return null;
}
return data.playlist;
},
/** Remove song from playlist — removes from IndexedDB + backend. */
async removeSong(playlistId: string, songId: string) {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs/${songId}`,
{ method: 'DELETE' }
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
const allPS = await playlistSongCollection.getAll();
const toRemove = allPS.find((ps) => ps.playlistId === playlistId && ps.songId === songId);
if (toRemove) {
await playlistSongCollection.delete(toRemove.id);
}
try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs/${songId}`,
{ method: 'DELETE' }
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
}
return data.playlist;
} catch {
return null;
}
return data.playlist;
},
/** Reorder songs in playlist — updates IndexedDB + backend. */
async reorderSongs(playlistId: string, songIds: string[]) {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs/reorder`,
{
method: 'PUT',
body: JSON.stringify({ songIds }),
const allPS = await playlistSongCollection.getAll();
const psForPlaylist = allPS.filter((ps) => ps.playlistId === playlistId);
for (let i = 0; i < songIds.length; i++) {
const ps = psForPlaylist.find((p) => p.songId === songIds[i]);
if (ps) {
await playlistSongCollection.update(ps.id, { sortOrder: i });
}
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
}
return data.playlist;
try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(
`/playlists/${playlistId}/songs/reorder`,
{
method: 'PUT',
body: JSON.stringify({ songIds }),
}
);
if (state.currentPlaylist?.id === playlistId) {
state.currentPlaylist = data.playlist;
}
return data.playlist;
} catch {
return null;
}
},
};
}

View file

@ -1,8 +1,21 @@
/**
* Project Store Mutation + API Operations
*
* Reads for project lists are handled by useLiveQuery (see $lib/data/queries.ts).
* This store handles mutations + API-only operations (beats, lyrics, markers, export).
* The live queries will automatically pick up local changes.
*/
import type { Project, Beat, Lyrics, LyricLine, Marker } from '@mukke/shared';
import { authStore } from './auth.svelte';
import {
projectCollection,
markerCollection,
type LocalProject,
type LocalMarker,
} from '$lib/data/local-store';
interface ProjectState {
projects: Project[];
currentProject: Project | null;
currentBeat: Beat | null;
currentLyrics: Lyrics | null;
@ -25,7 +38,6 @@ function getBackendUrl(): string {
function createProjectStore() {
let state = $state<ProjectState>({
projects: [],
currentProject: null,
currentBeat: null,
currentLyrics: null,
@ -55,9 +67,6 @@ function createProjectStore() {
}
return {
get projects() {
return state.projects;
},
get currentProject() {
return state.currentProject;
},
@ -80,18 +89,7 @@ function createProjectStore() {
return state.error;
},
async loadProjects() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ projects: Project[] }>('/projects');
state.projects = data.projects;
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load projects';
}
state.isLoading = false;
},
/** Load project detail from backend (includes beat, lyrics). */
async loadProject(id: string) {
state.isLoading = true;
state.error = null;
@ -124,30 +122,51 @@ function createProjectStore() {
state.isLoading = false;
},
/** Create project — writes to IndexedDB + backend. */
async createProject(title: string, description?: string) {
const data = await fetchApi<{ project: Project }>('/projects', {
method: 'POST',
body: JSON.stringify({ title, description }),
});
state.projects = [data.project, ...state.projects];
return data.project;
},
const newLocal: LocalProject = {
id: crypto.randomUUID(),
title,
description: description ?? null,
songId: null,
};
await projectCollection.insert(newLocal);
async updateProject(id: string, updates: { title?: string; description?: string }) {
const data = await fetchApi<{ project: Project }>(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
state.projects = state.projects.map((p) => (p.id === id ? data.project : p));
if (state.currentProject?.id === id) {
state.currentProject = data.project;
try {
const data = await fetchApi<{ project: Project }>('/projects', {
method: 'POST',
body: JSON.stringify({ title, description }),
});
return data.project;
} catch {
return newLocal as unknown as Project;
}
return data.project;
},
/** Update project — writes to IndexedDB + backend. */
async updateProject(id: string, updates: { title?: string; description?: string }) {
const updateData: Partial<LocalProject> = {};
if (updates.title !== undefined) updateData.title = updates.title;
if (updates.description !== undefined) updateData.description = updates.description ?? null;
await projectCollection.update(id, updateData);
try {
const data = await fetchApi<{ project: Project }>(`/projects/${id}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
if (state.currentProject?.id === id) {
state.currentProject = data.project;
}
return data.project;
} catch {
return updates as unknown as Project;
}
},
/** Delete project — removes from IndexedDB + backend. */
async deleteProject(id: string) {
await fetchApi(`/projects/${id}`, { method: 'DELETE' });
state.projects = state.projects.filter((p) => p.id !== id);
await projectCollection.delete(id);
if (state.currentProject?.id === id) {
state.currentProject = null;
state.currentBeat = null;
@ -155,6 +174,11 @@ function createProjectStore() {
state.currentLines = [];
state.currentMarkers = [];
}
try {
await fetchApi(`/projects/${id}`, { method: 'DELETE' });
} catch {
// Sync will reconcile
}
},
async uploadBeat(projectId: string, file: File) {
@ -249,29 +273,71 @@ function createProjectStore() {
return data.line;
},
/** Create marker — writes to IndexedDB + backend. */
async createMarker(beatId: string, marker: Omit<Marker, 'id' | 'beatId'>) {
const data = await fetchApi<{ marker: Marker }>('/markers', {
method: 'POST',
body: JSON.stringify({ beatId, ...marker }),
});
state.currentMarkers = [...state.currentMarkers, data.marker].sort(
(a, b) => a.startTime - b.startTime
);
return data.marker;
const newLocal: LocalMarker = {
id: crypto.randomUUID(),
beatId,
type: marker.type as LocalMarker['type'],
label: marker.label ?? null,
startTime: marker.startTime,
endTime: marker.endTime ?? null,
color: marker.color ?? null,
sortOrder: marker.sortOrder,
};
await markerCollection.insert(newLocal);
try {
const data = await fetchApi<{ marker: Marker }>('/markers', {
method: 'POST',
body: JSON.stringify({ beatId, ...marker }),
});
state.currentMarkers = [...state.currentMarkers, data.marker].sort(
(a, b) => a.startTime - b.startTime
);
return data.marker;
} catch {
state.currentMarkers = [...state.currentMarkers, newLocal as unknown as Marker].sort(
(a, b) => a.startTime - b.startTime
);
return newLocal as unknown as Marker;
}
},
/** Update marker — writes to IndexedDB + backend. */
async updateMarker(markerId: string, updates: Partial<Marker>) {
const data = await fetchApi<{ marker: Marker }>(`/markers/${markerId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
state.currentMarkers = state.currentMarkers.map((m) => (m.id === markerId ? data.marker : m));
return data.marker;
const updateData: Partial<LocalMarker> = {};
if (updates.type !== undefined) updateData.type = updates.type as LocalMarker['type'];
if (updates.label !== undefined) updateData.label = updates.label ?? null;
if (updates.startTime !== undefined) updateData.startTime = updates.startTime;
if (updates.endTime !== undefined) updateData.endTime = updates.endTime ?? null;
if (updates.color !== undefined) updateData.color = updates.color ?? null;
if (updates.sortOrder !== undefined) updateData.sortOrder = updates.sortOrder;
await markerCollection.update(markerId, updateData);
try {
const data = await fetchApi<{ marker: Marker }>(`/markers/${markerId}`, {
method: 'PUT',
body: JSON.stringify(updates),
});
state.currentMarkers = state.currentMarkers.map((m) =>
m.id === markerId ? data.marker : m
);
return data.marker;
} catch {
return updates as unknown as Marker;
}
},
/** Delete marker — removes from IndexedDB + backend. */
async deleteMarker(markerId: string) {
await fetchApi(`/markers/${markerId}`, { method: 'DELETE' });
await markerCollection.delete(markerId);
state.currentMarkers = state.currentMarkers.filter((m) => m.id !== markerId);
try {
await fetchApi(`/markers/${markerId}`, { method: 'DELETE' });
} catch {
// Sync will reconcile
}
},
clearCurrent() {

View file

@ -8,6 +8,13 @@
import SongEditor from '$lib/components/SongEditor.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import type { Song } from '@mukke/shared';
import { useAllSongs } from '$lib/data/queries';
import type { LocalSong } from '$lib/data/local-store';
// Live query — auto-updates on IndexedDB changes
const allSongs = useAllSongs();
// Cast LocalSong[] to Song[] for compatibility with existing UI
let songs = $derived(allSongs.value as unknown as Song[]);
const tabs = ['songs', 'albums', 'artists', 'genres'] as const;
@ -97,9 +104,6 @@
onMount(() => {
libraryStore.setActiveTab('songs');
if (libraryStore.songs.length === 0) {
libraryStore.loadSongs();
}
});
function formatDuration(seconds: number | null | undefined): string {
@ -110,7 +114,7 @@
async function handleToggleFavorite(id: string, e: Event) {
e.preventDefault();
e.stopPropagation();
const song = libraryStore.songs.find((s) => s.id === id);
const song = songs.find((s) => s.id === id);
await libraryStore.toggleFavorite(id);
MukkeEvents.songFavorited(!song?.favorite);
}
@ -122,7 +126,7 @@
}
function handlePlaySong(song: Song, index: number) {
playerStore.playSong(song, libraryStore.songs, index);
playerStore.playSong(song, songs, index);
MukkeEvents.songPlayed();
}
@ -175,14 +179,11 @@
{:else if libraryStore.error}
<div class="text-center py-16">
<p class="text-red-500 mb-2">{libraryStore.error}</p>
<button onclick={() => libraryStore.loadSongs()} class="text-sm text-primary hover:underline">
Try again
</button>
</div>
{:else}
<!-- Songs Tab -->
{#if libraryStore.activeTab === 'songs'}
{#if libraryStore.songs.length === 0}
{#if songs.length === 0}
<div class="text-center py-16">
<svg
class="w-12 h-12 text-foreground-secondary mx-auto mb-3"
@ -216,7 +217,7 @@
<span></span>
</div>
<!-- Song rows -->
{#each libraryStore.songs as song, index}
{#each songs as song, index}
<div
role="button"
tabindex="0"
@ -472,7 +473,6 @@
open={editingSong !== null}
onclose={() => {
editingSong = null;
libraryStore.loadSongs();
}}
/>
{/if}

View file

@ -1,17 +1,16 @@
<script lang="ts">
import { onMount } from 'svelte';
import { playlistStore } from '$lib/stores/playlist.svelte';
import { MukkeEvents } from '@manacore/shared-utils/analytics';
import { useAllPlaylists } from '$lib/data/queries';
// Live query — auto-updates on IndexedDB changes
const allPlaylists = useAllPlaylists();
let showCreateModal = $state(false);
let newName = $state('');
let newDescription = $state('');
let isCreating = $state(false);
onMount(() => {
playlistStore.loadPlaylists();
});
async function handleCreate() {
if (!newName.trim()) return;
isCreating = true;
@ -59,23 +58,7 @@
</button>
</div>
{#if playlistStore.isLoading}
<div class="flex items-center justify-center py-16">
<div
class="w-8 h-8 border-4 border-primary border-t-transparent rounded-full animate-spin"
></div>
</div>
{:else if playlistStore.error}
<div class="text-center py-16">
<p class="text-red-500 mb-2">{playlistStore.error}</p>
<button
onclick={() => playlistStore.loadPlaylists()}
class="text-sm text-primary hover:underline"
>
Try again
</button>
</div>
{:else if playlistStore.playlists.length === 0}
{#if allPlaylists.value.length === 0}
<div class="text-center py-16">
<svg
class="w-12 h-12 text-foreground-secondary mx-auto mb-3"
@ -97,7 +80,7 @@
</div>
{:else}
<div class="grid grid-cols-1 sm:grid-cols-2 md:grid-cols-3 lg:grid-cols-4 gap-4">
{#each playlistStore.playlists as playlist}
{#each allPlaylists.value as playlist}
<a
href="/playlists/{playlist.id}"
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group relative"
@ -105,12 +88,8 @@
<div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
>
{#if playlist.coverArtPath && playlistStore.coverUrls[playlist.coverArtPath]}
<img
src={playlistStore.coverUrls[playlist.coverArtPath]}
alt={playlist.name}
class="w-full h-full object-cover"
/>
{#if playlist.coverArtPath && false}
<img src={false} alt={playlist.name} class="w-full h-full object-cover" />
{:else}
<svg
class="w-12 h-12 text-foreground-secondary"

View file

@ -2,7 +2,7 @@
import type { Album } from '@photos/shared';
import AlbumCard from './AlbumCard.svelte';
import { ContextMenu, type ContextMenuItem } from '@manacore/shared-ui';
import { albumStore } from '$lib/stores/albums.svelte';
import { albumMutations } from '$lib/stores/albums.svelte';
import { _ } from 'svelte-i18n';
interface Props {
@ -43,7 +43,7 @@
id: 'delete',
label: $_('common.delete'),
variant: 'danger',
action: () => albumStore.deleteAlbum(album.id),
action: () => albumMutations.deleteAlbum(album.id),
},
];
}

View file

@ -0,0 +1,110 @@
/**
* Reactive Queries & Pure Helpers for Photos
*
* 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 {
albumCollection,
albumItemCollection,
favoriteCollection,
type LocalAlbum,
type LocalAlbumItem,
type LocalFavorite,
} from './local-store';
import type { Album, AlbumItem } from '@photos/shared';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalAlbum (IndexedDB) to the shared Album type. */
export function toAlbum(local: LocalAlbum): Album {
return {
id: local.id,
name: local.name,
description: local.description ?? undefined,
coverMediaId: local.coverMediaId ?? undefined,
isAutoGenerated: local.isAutoGenerated,
autoGenerateType: local.autoGenerateType ?? undefined,
autoGenerateValue: local.autoGenerateValue ?? undefined,
itemCount: 0,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
} as Album;
}
/** Convert a LocalAlbumItem (IndexedDB) to the shared AlbumItem type. */
export function toAlbumItem(local: LocalAlbumItem): AlbumItem {
return {
id: local.id,
albumId: local.albumId,
mediaId: local.mediaId,
sortOrder: local.sortOrder,
addedAt: local.createdAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All albums. Auto-updates on any change. */
export function useAllAlbums() {
return useLiveQueryWithDefault(async () => {
const locals = await albumCollection.getAll();
return locals.map(toAlbum);
}, [] as Album[]);
}
/** All album items. Auto-updates on any change. */
export function useAllAlbumItems() {
return useLiveQueryWithDefault(async () => {
const locals = await albumItemCollection.getAll();
return locals.map(toAlbumItem);
}, [] as AlbumItem[]);
}
/** All favorite media IDs. Auto-updates on any change. */
export function useAllFavorites() {
return useLiveQueryWithDefault(async () => {
const locals = await favoriteCollection.getAll();
return locals;
}, [] as LocalFavorite[]);
}
// ─── Pure Album Helpers ────────────────────────────────────
/** Get an album by ID. */
export function getAlbumById(albums: Album[], id: string): Album | undefined {
return albums.find((a) => a.id === id);
}
/** Get album items for a specific album, sorted by sortOrder. */
export function getAlbumItemsForAlbum(allItems: AlbumItem[], albumId: string): AlbumItem[] {
return allItems.filter((i) => i.albumId === albumId).sort((a, b) => a.sortOrder - b.sortOrder);
}
/** Get the count of items in an album. */
export function getAlbumItemCount(allItems: AlbumItem[], albumId: string): number {
return allItems.filter((i) => i.albumId === albumId).length;
}
/** Enrich albums with item counts from album items. */
export function enrichAlbumsWithCounts(albums: Album[], allItems: AlbumItem[]): Album[] {
return albums.map((album) => ({
...album,
itemCount: getAlbumItemCount(allItems, album.id),
}));
}
// ─── Pure Favorite Helpers ─────────────────────────────────
/** Check if a media ID is favorited. */
export function isFavorited(favorites: LocalFavorite[], mediaId: string): boolean {
return favorites.some((f) => f.mediaId === mediaId);
}
/** Get the set of favorited media IDs. */
export function getFavoritedMediaIds(favorites: LocalFavorite[]): Set<string> {
return new Set(favorites.map((f) => f.mediaId));
}

View file

@ -1,102 +1,17 @@
/**
* Albums Store Local-First with Dexie.js
* Albums Store Mutation-Only
*
* All reads and writes go to IndexedDB first.
* When authenticated, changes sync to the server in the background.
* Reads are handled by live queries in queries.ts.
* This store only handles mutations (create, update, delete, add/remove items).
*/
import {
albumCollection,
albumItemCollection,
type LocalAlbum,
type LocalAlbumItem,
} from '$lib/data/local-store';
import { albumCollection, albumItemCollection, type LocalAlbum } from '$lib/data/local-store';
import { PhotosEvents } from '@manacore/shared-utils/analytics';
import type { Album, Photo } from '@photos/shared';
import { toAlbum } from '$lib/data/queries';
import type { Album } from '@photos/shared';
// State
let albums = $state<Album[]>([]);
let currentAlbum = $state<Album | null>(null);
let albumPhotos = $state<Photo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
function toAlbum(local: LocalAlbum): Album {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? undefined,
coverMediaId: local.coverMediaId ?? undefined,
isAutoGenerated: local.isAutoGenerated,
autoGenerateType: local.autoGenerateType ?? undefined,
autoGenerateValue: local.autoGenerateValue ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
} as Album;
}
async function refreshAlbums() {
const localAlbums = await albumCollection.getAll();
albums = localAlbums.map(toAlbum);
}
export const albumStore = {
get albums() {
return albums;
},
get currentAlbum() {
return currentAlbum;
},
get albumPhotos() {
return albumPhotos;
},
get loading() {
return loading;
},
get error() {
return error;
},
async loadAlbums() {
loading = true;
error = null;
try {
await refreshAlbums();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load albums';
} finally {
loading = false;
}
},
async loadAlbum(id: string) {
loading = true;
error = null;
try {
const local = await albumCollection.get(id);
if (local) {
currentAlbum = toAlbum(local);
// Load album items (media IDs)
const items = await albumItemCollection.getAll();
const albumItems = items
.filter((item) => item.albumId === id)
.sort((a, b) => a.sortOrder - b.sortOrder);
// Album items reference mediaIds — photo data comes from mana-media
albumPhotos = albumItems.map((item) => ({ id: item.mediaId }) as Photo);
} else {
currentAlbum = null;
albumPhotos = [];
}
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load album';
} finally {
loading = false;
}
},
async createAlbum(data: { name: string; description?: string }) {
error = null;
export const albumMutations = {
async createAlbum(data: { name: string; description?: string }): Promise<Album | null> {
try {
const newLocal: LocalAlbum = {
id: crypto.randomUUID(),
@ -108,39 +23,32 @@ export const albumStore = {
autoGenerateValue: null,
};
const inserted = await albumCollection.insert(newLocal);
const newAlbum = toAlbum(inserted);
albums = [...albums, newAlbum];
PhotosEvents.albumCreated();
return newAlbum;
return toAlbum(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create album';
console.error('Failed to create album:', e);
return null;
}
},
async updateAlbum(id: string, data: { name?: string; description?: string }) {
error = null;
async updateAlbum(
id: string,
data: { name?: string; description?: string }
): Promise<Album | null> {
try {
const updateData: Partial<LocalAlbum> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description ?? null;
const updated = await albumCollection.update(id, updateData);
if (updated) {
const updatedAlbum = toAlbum(updated);
albums = albums.map((a) => (a.id === id ? updatedAlbum : a));
if (currentAlbum?.id === id) currentAlbum = updatedAlbum;
return updatedAlbum;
}
return null;
return updated ? toAlbum(updated) : null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update album';
console.error('Failed to update album:', e);
return null;
}
},
async deleteAlbum(id: string) {
error = null;
async deleteAlbum(id: string): Promise<boolean> {
try {
// Delete album items first
const items = await albumItemCollection.getAll();
@ -148,28 +56,21 @@ export const albumStore = {
await albumItemCollection.delete(item.id);
}
await albumCollection.delete(id);
albums = albums.filter((a) => a.id !== id);
PhotosEvents.albumDeleted();
if (currentAlbum?.id === id) {
currentAlbum = null;
albumPhotos = [];
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to delete album';
console.error('Failed to delete album:', e);
return false;
}
},
async addPhotosToAlbum(albumId: string, mediaIds: string[]) {
error = null;
async addPhotosToAlbum(albumId: string, mediaIds: string[]): Promise<boolean> {
try {
const existing = await albumItemCollection.getAll();
const existingInAlbum = existing.filter((i) => i.albumId === albumId);
let nextOrder = existingInAlbum.length;
for (const mediaId of mediaIds) {
// Skip duplicates
if (existingInAlbum.some((i) => i.mediaId === mediaId)) continue;
await albumItemCollection.insert({
@ -180,61 +81,37 @@ export const albumStore = {
});
}
PhotosEvents.photosAddedToAlbum(mediaIds.length);
if (currentAlbum?.id === albumId) {
await this.loadAlbum(albumId);
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to add photos to album';
console.error('Failed to add photos to album:', e);
return false;
}
},
async removePhotoFromAlbum(albumId: string, mediaId: string) {
error = null;
async removePhotoFromAlbum(albumId: string, mediaId: string): Promise<boolean> {
try {
const items = await albumItemCollection.getAll();
const item = items.find((i) => i.albumId === albumId && i.mediaId === mediaId);
if (item) {
await albumItemCollection.delete(item.id);
albumPhotos = albumPhotos.filter((p) => p.id !== mediaId);
PhotosEvents.photoRemovedFromAlbum();
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to remove photo from album';
console.error('Failed to remove photo from album:', e);
return false;
}
},
async setCover(albumId: string, mediaId: string) {
error = null;
async setCover(albumId: string, mediaId: string): Promise<boolean> {
try {
const updated = await albumCollection.update(albumId, {
await albumCollection.update(albumId, {
coverMediaId: mediaId,
} as Partial<LocalAlbum>);
if (updated) {
const updatedAlbum = toAlbum(updated);
albums = albums.map((a) => (a.id === albumId ? updatedAlbum : a));
if (currentAlbum?.id === albumId) currentAlbum = updatedAlbum;
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to set album cover';
console.error('Failed to set album cover:', e);
return false;
}
},
clearCurrentAlbum() {
currentAlbum = null;
albumPhotos = [];
},
reset() {
albums = [];
currentAlbum = null;
albumPhotos = [];
loading = false;
error = null;
},
};

View file

@ -1,8 +1,9 @@
/**
* Photos Store Fetches from mana-media directly, favorites local-first.
* Photos Store Server-fetched photos from mana-media + local-first mutations.
*
* Photo files live on mana-media. Albums/favorites/tags are local (Dexie).
* This store calls mana-media for photo listing and enriches with local data.
* Photo files live on mana-media (server-side, not in Dexie).
* Favorites are local-first via Dexie reads handled by live queries in queries.ts.
* This store handles server-fetched photo listing, selection, and favorite mutations.
*/
import { favoriteCollection, type LocalFavorite } from '$lib/data/local-store';
@ -32,7 +33,7 @@ async function mediaFetch<T>(path: string, options: RequestInit = {}): Promise<T
return response.json();
}
// State
// State — server-fetched photos (not local-first)
let photos = $state<Photo[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
@ -46,13 +47,6 @@ let filters = $state<PhotoFilters>({
let stats = $state<PhotoStats | null>(null);
let selectedPhoto = $state<Photo | null>(null);
/** Enrich photos with local favorite status. */
async function enrichWithFavorites(items: Photo[]): Promise<Photo[]> {
const favs = await favoriteCollection.getAll();
const favMediaIds = new Set(favs.map((f) => f.mediaId));
return items.map((p) => ({ ...p, isFavorited: favMediaIds.has(p.id) }));
}
export const photoStore = {
get photos() {
return photos;
@ -105,8 +99,7 @@ export const photoStore = {
);
if (result) {
const enriched = await enrichWithFavorites(result.items);
photos = reset ? enriched : [...photos, ...enriched];
photos = reset ? result.items : [...photos, ...result.items];
hasMore = result.hasMore;
filters = { ...filters, offset: (filters.offset || 0) + result.items.length };
}
@ -141,7 +134,7 @@ export const photoStore = {
selectedPhoto = photo;
},
/** Toggle favorite — local-first via Dexie. */
/** Toggle favorite — local-first via Dexie. Live query handles read reactivity. */
async toggleFavorite(mediaId: string) {
try {
const existing = await favoriteCollection.getAll();
@ -160,6 +153,7 @@ export const photoStore = {
}
PhotosEvents.photoFavorited(isFavorited);
// Update server-fetched photos in-memory for immediate UI feedback
photos = photos.map((p) => (p.id === mediaId ? { ...p, isFavorited } : p));
if (selectedPhoto?.id === mediaId) {
selectedPhoto = { ...selectedPhoto, isFavorited };

View file

@ -7,7 +7,6 @@
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { photoStore } from '$lib/stores/photos.svelte';
import { albumStore } from '$lib/stores/albums.svelte';
import {
tagLocalStore,
tagMutations,
@ -19,9 +18,18 @@
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { photosStore } from '$lib/data/local-store';
import { useAllAlbums, useAllAlbumItems, useAllFavorites } from '$lib/data/queries';
// Live query for shared tags (local-first)
// Live queries for local-first data (auto-update on Dexie changes)
const allAlbums = useAllAlbums();
const allAlbumItems = useAllAlbumItems();
const allFavorites = useAllFavorites();
const allTags = useAllSharedTags();
// Set context for child components
setContext('albums', allAlbums);
setContext('albumItems', allAlbumItems);
setContext('favorites', allFavorites);
setContext('tags', allTags);
let { children } = $props();
@ -89,14 +97,13 @@
async function handleLogout() {
await authStore.signOut();
photoStore.reset();
albumStore.reset();
goto('/login');
}
// QuickInputBar handlers
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const q = query.toLowerCase();
const albums = albumStore.albums.filter((a) => a.name?.toLowerCase().includes(q));
const albums = allAlbums.value.filter((a) => a.name?.toLowerCase().includes(q));
const tags = allTags.value.filter((t) => t.name?.toLowerCase().includes(q));
const results: QuickInputItem[] = [];
for (const album of albums.slice(0, 5)) {
@ -123,7 +130,7 @@
if (authStore.isAuthenticated) {
photosStore.startSync(() => authStore.getValidToken());
tagMutations.startSync(() => authStore.getValidToken());
await Promise.all([photoStore.loadStats(), albumStore.loadAlbums()]);
await photoStore.loadStats();
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('photos')) {
showGuestWelcome = true;

View file

@ -1,9 +1,17 @@
<script lang="ts">
import { _ } from 'svelte-i18n';
import { goto } from '$app/navigation';
import { albumStore } from '$lib/stores/albums.svelte';
import { getContext } from 'svelte';
import { albumMutations } from '$lib/stores/albums.svelte';
import { enrichAlbumsWithCounts } from '$lib/data/queries';
import AlbumGrid from '$lib/components/albums/AlbumGrid.svelte';
import CreateAlbumModal from '$lib/components/albums/CreateAlbumModal.svelte';
import type { Album, AlbumItem } from '@photos/shared';
const allAlbums: { readonly value: Album[] } = getContext('albums');
const allAlbumItems: { readonly value: AlbumItem[] } = getContext('albumItems');
let albums = $derived(enrichAlbumsWithCounts(allAlbums.value, allAlbumItems.value));
let showCreateModal = $state(false);
@ -12,7 +20,7 @@
}
async function handleCreateAlbum(data: { name: string; description?: string }) {
const album = await albumStore.createAlbum(data);
const album = await albumMutations.createAlbum(data);
if (album) {
showCreateModal = false;
goto(`/albums/${album.id}`);
@ -47,11 +55,7 @@
</button>
</header>
{#if albumStore.error}
<div class="error-message">
<p>{albumStore.error}</p>
</div>
{:else if albumStore.albums.length === 0 && !albumStore.loading}
{#if albums.length === 0}
<div class="empty-state">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -76,11 +80,7 @@
</button>
</div>
{:else}
<AlbumGrid
albums={albumStore.albums}
loading={albumStore.loading}
onAlbumClick={handleAlbumClick}
/>
<AlbumGrid {albums} loading={false} onAlbumClick={handleAlbumClick} />
{/if}
{#if showCreateModal}

View file

@ -1,20 +1,22 @@
<script lang="ts">
import { page } from '$app/stores';
import { goto } from '$app/navigation';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
import { _ } from 'svelte-i18n';
import { albumStore } from '$lib/stores/albums.svelte';
import { albumMutations } from '$lib/stores/albums.svelte';
import { photoStore } from '$lib/stores/photos.svelte';
import { getAlbumById, getAlbumItemsForAlbum } from '$lib/data/queries';
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
import type { Album, AlbumItem, Photo } from '@photos/shared';
const allAlbums: { readonly value: Album[] } = getContext('albums');
const allAlbumItems: { readonly value: AlbumItem[] } = getContext('albumItems');
const albumId = $derived($page.params.id);
onMount(async () => {
if (albumId) {
await albumStore.loadAlbum(albumId);
}
});
let currentAlbum = $derived(getAlbumById(allAlbums.value, albumId));
let albumItems = $derived(getAlbumItemsForAlbum(allAlbumItems.value, albumId));
let albumPhotos = $derived(albumItems.map((item) => ({ id: item.mediaId }) as Photo));
function handlePhotoClick(photo: any) {
photoStore.selectPhoto(photo);
@ -26,7 +28,7 @@
async function handleDeleteAlbum() {
if (confirm($_('albums.deleteConfirm'))) {
const success = await albumStore.deleteAlbum(albumId);
const success = await albumMutations.deleteAlbum(albumId);
if (success) {
goto('/albums');
}
@ -35,19 +37,15 @@
</script>
<svelte:head>
<title>{albumStore.currentAlbum?.name || $_('albums.title')} | Photos</title>
<title>{currentAlbum?.name || $_('albums.title')} | Photos</title>
</svelte:head>
<div class="album-detail-page">
{#if albumStore.loading}
{#if !currentAlbum}
<div class="loading-state">
<div class="animate-pulse text-muted-foreground">{$_('common.loading')}</div>
</div>
{:else if albumStore.error}
<div class="error-message">
<p>{albumStore.error}</p>
</div>
{:else if albumStore.currentAlbum}
{:else}
<header class="page-header">
<div class="flex items-center gap-3">
<button class="icon-btn" onclick={() => goto('/albums')} title="Back">
@ -66,15 +64,15 @@
</svg>
</button>
<div>
<h1 class="text-2xl font-bold">{albumStore.currentAlbum.name}</h1>
{#if albumStore.currentAlbum.description}
<p class="text-sm text-muted-foreground">{albumStore.currentAlbum.description}</p>
<h1 class="text-2xl font-bold">{currentAlbum.name}</h1>
{#if currentAlbum.description}
<p class="text-sm text-muted-foreground">{currentAlbum.description}</p>
{/if}
</div>
</div>
<div class="flex items-center gap-2">
<span class="text-sm text-muted-foreground">
{albumStore.albumPhotos.length}
{albumPhotos.length}
{$_('albums.items')}
</span>
<button
@ -101,13 +99,13 @@
</div>
</header>
{#if albumStore.albumPhotos.length === 0}
{#if albumPhotos.length === 0}
<div class="empty-state">
<p class="text-muted-foreground">{$_('gallery.empty')}</p>
</div>
{:else}
<PhotoGrid
photos={albumStore.albumPhotos}
photos={albumPhotos}
loading={false}
hasMore={false}
onPhotoClick={handlePhotoClick}

View file

@ -1,34 +1,18 @@
<script lang="ts">
import { onMount } from 'svelte';
import { _ } from 'svelte-i18n';
import { getContext } from 'svelte';
import { photoStore } from '$lib/stores/photos.svelte';
import { favoriteCollection } from '$lib/data/local-store';
import PhotoGrid from '$lib/components/gallery/PhotoGrid.svelte';
import PhotoDetailModal from '$lib/components/gallery/PhotoDetailModal.svelte';
import type { Photo } from '@photos/shared';
import type { LocalFavorite } from '$lib/data/local-store';
let favorites = $state<Photo[]>([]);
let loading = $state(true);
let error = $state<string | null>(null);
const allFavorites: { readonly value: LocalFavorite[] } = getContext('favorites');
onMount(async () => {
await loadFavorites();
});
async function loadFavorites() {
loading = true;
error = null;
try {
const localFavs = await favoriteCollection.getAll();
// Favorited media IDs — full photo data would come from mana-media
favorites = localFavs.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load favorites';
} finally {
loading = false;
}
}
// Derive favorite photos from live query (auto-updates when favorites change)
let favorites = $derived<Photo[]>(
allFavorites.value.map((f) => ({ id: f.mediaId, isFavorited: true }) as Photo)
);
function handlePhotoClick(photo: Photo) {
photoStore.selectPhoto(photo);
@ -52,11 +36,7 @@
</span>
</header>
{#if error}
<div class="error-message">
<p>{error}</p>
</div>
{:else if favorites.length === 0 && !loading}
{#if favorites.length === 0}
<div class="empty-state">
<svg
xmlns="http://www.w3.org/2000/svg"
@ -80,7 +60,7 @@
{:else}
<PhotoGrid
photos={favorites}
{loading}
loading={false}
hasMore={false}
onPhotoClick={handlePhotoClick}
onLoadMore={() => {}}

View file

@ -2,8 +2,8 @@
import type { Image } from '$lib/api/images';
import Modal from '../ui/Modal.svelte';
import Button from '../ui/Button.svelte';
import { unarchiveImage, deleteImage, downloadImage } from '$lib/api/images';
import { archivedImages } from '$lib/stores/archive';
import { downloadImage } from '$lib/api/images';
import { imageCollection } from '$lib/data/local-store';
import { DownloadSimple, ArrowCounterClockwise, Trash } from '@manacore/shared-icons';
interface Props {
@ -21,9 +21,8 @@
isUnarchiving = true;
try {
await unarchiveImage(image.id);
// Update store
archivedImages.update((current) => current.filter((img) => img.id !== image.id));
// Clear archivedAt locally (live query auto-refreshes)
await imageCollection.update(image.id, { archivedAt: null });
onClose();
} catch (error) {
console.error('Error unarchiving image:', error);
@ -40,9 +39,8 @@
isDeleting = true;
try {
await deleteImage(image.id);
// Update store
archivedImages.update((current) => current.filter((img) => img.id !== image.id));
// Delete locally (live query auto-refreshes)
await imageCollection.delete(image.id);
onClose();
} catch (error) {
console.error('Error deleting image:', error);

View file

@ -1,12 +1,11 @@
<script lang="ts">
import { page } from '$app/stores';
import { authStore } from '$lib/stores/auth.svelte';
import { images, isLoading as isLoadingImages } from '$lib/stores/images';
import { canvasItems, addCanvasItem } from '$lib/stores/canvas';
import { getImages } from '$lib/api/images';
import { addBoardItem } from '$lib/api/boardItems';
import { toastStore, Modal, Button } from '@manacore/shared-ui';
import { MagnifyingGlass, Image as ImageIcon, Check } from '@manacore/shared-icons';
import { getContext } from 'svelte';
import type { Image } from '$lib/api/images';
interface Props {
open: boolean;
@ -15,42 +14,14 @@
let { open, onClose }: Props = $props();
const allImages: { value: Image[] } = getContext('allImages');
let selectedImages = $state<Set<string>>(new Set());
let isAdding = $state(false);
let searchQuery = $state('');
let currentPage = $state(1);
let hasMore = $state(true);
const boardId = $derived($page.params.id);
// Load images when modal opens
$effect(() => {
if (open && authStore.user) {
loadImages();
}
});
async function loadImages() {
if (!authStore.user) return;
isLoadingImages.set(true);
try {
const data = await getImages({
page: 1,
limit: 50,
archived: false,
});
images.set(data);
currentPage = 1;
hasMore = data.length === 50;
} catch (error) {
console.error('Error loading images:', error);
toastStore.show('Fehler beim Laden der Bilder', 'error');
} finally {
isLoadingImages.set(false);
}
}
function toggleImageSelection(imageId: string) {
if (selectedImages.has(imageId)) {
selectedImages.delete(imageId);
@ -83,7 +54,7 @@
}
// Get image details
const image = $images.find((img) => img.id === imageId);
const image = allImages.value.find((img) => img.id === imageId);
if (!image) continue;
// Add to board
@ -116,7 +87,7 @@
}
function handleSelectAll() {
const availableImages = $images.filter((img) => !isImageAlreadyOnBoard(img.id));
const availableImages = allImages.value.filter((img) => !isImageAlreadyOnBoard(img.id));
selectedImages = new Set(availableImages.map((img) => img.id));
}
@ -127,8 +98,10 @@
const filteredImages = $derived(
searchQuery.trim()
? $images.filter((img) => img.prompt?.toLowerCase().includes(searchQuery.toLowerCase()))
: $images
? allImages.value.filter((img) =>
img.prompt?.toLowerCase().includes(searchQuery.toLowerCase())
)
: allImages.value
);
</script>
@ -175,13 +148,7 @@
<!-- Images Grid -->
<div class="flex-1 overflow-y-auto">
{#if $isLoadingImages}
<div class="grid grid-cols-3 gap-4 sm:grid-cols-4 lg:grid-cols-5">
{#each Array(15) as _}
<div class="aspect-square animate-pulse rounded-lg bg-gray-200 dark:bg-gray-700"></div>
{/each}
</div>
{:else if filteredImages.length === 0}
{#if filteredImages.length === 0}
<div class="flex h-full flex-col items-center justify-center py-12">
<ImageIcon size={64} weight="thin" class="text-gray-300 dark:text-gray-600" />
<p class="mt-4 text-gray-600 dark:text-gray-400">

View file

@ -8,7 +8,8 @@
publishImage,
unpublishImage,
} from '$lib/api/images';
import { images, selectedImage } from '$lib/stores/images';
import { selectedImage } from '$lib/stores/images';
import { imageCollection } from '$lib/data/local-store';
import { toastStore } from '@manacore/shared-ui';
import { fade, fly } from 'svelte/transition';
import { getImageTags, addTagToImage, removeTagFromImage } from '$lib/api/tags';
@ -35,6 +36,7 @@
let { image, onClose }: Props = $props();
const sharedTags: { value: SharedTag[] } = getContext('tags');
const allImages: { value: Image[] } = getContext('allImages');
let isArchiving = $state(false);
let isDeleting = $state(false);
@ -57,10 +59,12 @@
);
// Get current image index
const currentIndex = $derived(image ? $images.findIndex((img) => img.id === image?.id) : -1);
const currentIndex = $derived(
image ? allImages.value.findIndex((img) => img.id === image?.id) : -1
);
const hasPrevious = $derived(currentIndex > 0);
const hasNext = $derived(currentIndex >= 0 && currentIndex < $images.length - 1);
const hasNext = $derived(currentIndex >= 0 && currentIndex < allImages.value.length - 1);
// Load tags for current image
$effect(() => {
@ -79,13 +83,13 @@
function navigatePrevious() {
if (hasPrevious) {
selectedImage.set($images[currentIndex - 1]);
selectedImage.set(allImages.value[currentIndex - 1]);
}
}
function navigateNext() {
if (hasNext) {
selectedImage.set($images[currentIndex + 1]);
selectedImage.set(allImages.value[currentIndex + 1]);
}
}
@ -115,9 +119,8 @@
isArchiving = true;
try {
await archiveImage(imageId);
// Update store
images.update((current) => current.filter((img) => img.id !== imageId));
// Update locally (live query auto-refreshes)
await imageCollection.update(imageId, { archivedAt: new Date().toISOString() });
toastStore.show('Bild erfolgreich archiviert', 'success');
onClose();
} catch (error) {
@ -140,9 +143,8 @@
isDeleting = true;
try {
await deleteImage(imageId);
// Update store
images.update((current) => current.filter((img) => img.id !== imageId));
// Delete locally (live query auto-refreshes)
await imageCollection.delete(imageId);
toastStore.show('Bild erfolgreich gelöscht', 'success');
onClose();
} catch (error) {

View file

@ -12,9 +12,7 @@
const allTags: { value: Tag[] } = getContext('tags');
import { addTagToImage, removeTagFromImage, getImageTags } from '$lib/api/tags';
import { archiveImage, unarchiveImage, deleteImage, toggleFavorite } from '$lib/api/images';
import { images } from '$lib/stores/images';
import { archivedImages } from '$lib/stores/archive';
import { imageCollection } from '$lib/data/local-store';
import { toastStore } from '@manacore/shared-ui';
import {
DownloadSimple,
@ -122,9 +120,7 @@
if (confirm('Möchten Sie dieses Bild wirklich löschen?')) {
try {
await deleteImage($contextMenu.image.id);
// Remove from store
images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id));
await imageCollection.delete($contextMenu.image.id);
hideContextMenu();
toastStore.show('Bild gelöscht', 'success');
} catch (error) {
@ -139,19 +135,15 @@
try {
if (isArchived) {
// Unarchive: Move back to gallery
await unarchiveImage($contextMenu.image.id);
// Remove from archive store
archivedImages.update((current) =>
current.filter((img) => img.id !== $contextMenu.image?.id)
);
// Unarchive: clear archivedAt
await imageCollection.update($contextMenu.image.id, { archivedAt: null });
hideContextMenu();
toastStore.show('Bild wiederhergestellt', 'success');
} else {
// Archive: Move to archive
await archiveImage($contextMenu.image.id);
// Remove from gallery store
images.update((current) => current.filter((img) => img.id !== $contextMenu.image?.id));
// Archive: set archivedAt
await imageCollection.update($contextMenu.image.id, {
archivedAt: new Date().toISOString(),
});
hideContextMenu();
toastStore.show('Bild archiviert', 'success');
}
@ -166,19 +158,7 @@
try {
const newFavoriteStatus = !isFavorite;
await toggleFavorite($contextMenu.image.id, newFavoriteStatus);
// Update in all stores
images.update((current) =>
current.map((img) =>
img.id === $contextMenu.image?.id ? { ...img, isFavorite: newFavoriteStatus } : img
)
);
archivedImages.update((current) =>
current.map((img) =>
img.id === $contextMenu.image?.id ? { ...img, isFavorite: newFavoriteStatus } : img
)
);
await imageCollection.update($contextMenu.image.id, { isFavorite: newFavoriteStatus });
hideContextMenu();
toastStore.show(

View file

@ -0,0 +1,155 @@
/**
* Reactive Queries & Pure Helpers for Picture
*
* 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 {
imageCollection,
boardCollection,
boardItemCollection,
imageTagCollection,
type LocalImage,
type LocalBoard,
type LocalBoardItem,
} from './local-store';
import type { Image } from '$lib/api/images';
import type { Board, BoardWithCount } from '$lib/api/boards';
// ─── Type Converters ──────────────────────────────────────
/** Convert LocalImage (IndexedDB) to the Image type used by components. */
export function toImage(local: LocalImage): Image {
return {
id: local.id,
userId: 'local',
prompt: local.prompt,
negativePrompt: local.negativePrompt ?? undefined,
model: local.model ?? undefined,
style: local.style ?? undefined,
publicUrl: local.publicUrl ?? undefined,
storagePath: local.storagePath,
filename: local.filename,
format: local.format ?? undefined,
width: local.width ?? undefined,
height: local.height ?? undefined,
fileSize: local.fileSize ?? undefined,
blurhash: local.blurhash ?? undefined,
isPublic: local.isPublic,
isFavorite: local.isFavorite,
downloadCount: local.downloadCount,
rating: local.rating ?? undefined,
archivedAt: local.archivedAt ?? undefined,
generationId: local.generationId ?? undefined,
sourceImageId: local.sourceImageId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert LocalBoard (IndexedDB) to Board type. */
export function toBoard(local: LocalBoard): Board {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? undefined,
thumbnailUrl: local.thumbnailUrl ?? undefined,
canvasWidth: local.canvasWidth,
canvasHeight: local.canvasHeight,
backgroundColor: local.backgroundColor,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ────────
/** All non-archived images, sorted by createdAt desc. Auto-updates on any change. */
export function useAllImages() {
return useLiveQueryWithDefault(async () => {
const locals = await imageCollection.getAll();
return locals
.filter((img) => !img.archivedAt)
.map(toImage)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [] as Image[]);
}
/** All archived images, sorted by createdAt desc. Auto-updates on any change. */
export function useArchivedImages() {
return useLiveQueryWithDefault(async () => {
const locals = await imageCollection.getAll();
return locals
.filter((img) => !!img.archivedAt)
.map(toImage)
.sort((a, b) => new Date(b.createdAt).getTime() - new Date(a.createdAt).getTime());
}, [] as Image[]);
}
/** All boards with item counts, sorted by updatedAt desc. Auto-updates on any change. */
export function useAllBoards() {
return useLiveQueryWithDefault(async () => {
const locals = await boardCollection.getAll();
const allItems = await boardItemCollection.getAll();
// Count items per board
const itemCounts = new Map<string, number>();
for (const item of allItems) {
itemCounts.set(item.boardId, (itemCounts.get(item.boardId) || 0) + 1);
}
return locals
.map(
(local): BoardWithCount => ({
...toBoard(local),
itemCount: itemCounts.get(local.id) || 0,
})
)
.sort((a, b) => new Date(b.updatedAt).getTime() - new Date(a.updatedAt).getTime());
}, [] as BoardWithCount[]);
}
/** All image-tag associations. Auto-updates on any change. */
export function useAllImageTags() {
return useLiveQueryWithDefault(
async () => {
return await imageTagCollection.getAll();
},
[] as { id: string; imageId: string; tagId: string }[]
);
}
// ─── Pure Helper Functions (for $derived) ─────────────────
/** Filter images by favorites only. */
export function getFavoriteImages(images: Image[]): Image[] {
return images.filter((img) => img.isFavorite);
}
/** Filter images by tag IDs using image-tag associations. */
export function getImagesByTags(
images: Image[],
imageTags: { imageId: string; tagId: string }[],
selectedTagIds: string[]
): Image[] {
if (selectedTagIds.length === 0) return images;
const imageIdsWithTags = new Set(
imageTags.filter((it) => selectedTagIds.includes(it.tagId)).map((it) => it.imageId)
);
return images.filter((img) => imageIdsWithTags.has(img.id));
}
/** Find an image by ID. */
export function findImageById(images: Image[], id: string): Image | undefined {
return images.find((img) => img.id === id);
}
/** Find a board by ID. */
export function findBoardById(boards: BoardWithCount[], id: string): BoardWithCount | undefined {
return boards.find((b) => b.id === id);
}

View file

@ -1,7 +1,7 @@
import { writable } from 'svelte/store';
import type { Image } from '$lib/api/images';
/**
* Archive store UI-only state.
* Archived image reads are handled by useLiveQuery hooks in queries.ts.
*/
export const archivedImages = writable<Image[]>([]);
export const isLoadingArchive = writable(false);
export const hasMoreArchive = writable(true);
export const currentArchivePage = writable(1);
// This file is kept for backwards compatibility.
// The archivedImages data is now provided via live query (useArchivedImages) from queries.ts.

View file

@ -1,23 +1,14 @@
import { writable, derived } from 'svelte/store';
import type { Board, BoardWithCount } from '$lib/api/boards';
import type { Board } from '$lib/api/boards';
// Current boards list
export const boards = writable<BoardWithCount[]>([]);
/**
* UI-only state for board editing.
* Board list reads are handled by useLiveQuery hooks in queries.ts.
*/
// Current board being edited
// Current board being edited (on the canvas page)
export const currentBoard = writable<Board | null>(null);
// Loading states
export const isLoadingBoards = writable(false);
export const isLoadingBoard = writable(false);
// Pagination
export const currentBoardsPage = writable(1);
export const hasBoardsMore = writable(true);
// Selected board (for actions like delete, duplicate)
export const selectedBoard = writable<Board | null>(null);
// Create board modal
export const showCreateBoardModal = writable(false);
@ -31,40 +22,3 @@ export const boardSettings = derived(currentBoard, ($currentBoard) => ({
height: $currentBoard?.canvasHeight || 1500,
backgroundColor: $currentBoard?.backgroundColor || '#ffffff',
}));
// Helper functions for board management
export function resetBoardsState() {
boards.set([]);
currentBoardsPage.set(1);
hasBoardsMore.set(true);
}
export function addBoard(board: BoardWithCount) {
boards.update((current) => [board, ...current]);
}
export function updateBoardInList(boardId: string, updates: Partial<Board>) {
boards.update((current) =>
current.map((board) => (board.id === boardId ? { ...board, ...updates } : board))
);
}
export function removeBoardFromList(boardId: string) {
boards.update((current) => current.filter((board) => board.id !== boardId));
}
export function incrementBoardItemCount(boardId: string) {
boards.update((current) =>
current.map((board) =>
board.id === boardId ? { ...board, itemCount: board.itemCount + 1 } : board
)
);
}
export function decrementBoardItemCount(boardId: string) {
boards.update((current) =>
current.map((board) =>
board.id === boardId ? { ...board, itemCount: Math.max(0, board.itemCount - 1) } : board
)
);
}

View file

@ -1,9 +1,10 @@
import { writable } from 'svelte/store';
import type { Image } from '$lib/api/images';
export const images = writable<Image[]>([]);
/**
* UI-only state for gallery image selection and filter toggles.
* Data reads are handled by useLiveQuery hooks in queries.ts.
*/
export const selectedImage = writable<Image | null>(null);
export const isLoading = writable(false);
export const hasMore = writable(true);
export const currentPage = writable(1);
export const showFavoritesOnly = writable(false);

View file

@ -30,6 +30,12 @@
import { SessionExpiredBanner, AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { pictureStore } from '$lib/data/local-store';
import {
useAllImages,
useArchivedImages,
useAllBoards,
useAllImageTags,
} from '$lib/data/queries';
import { viewMode, setViewMode } from '$lib/stores/view';
import type { ViewMode } from '$lib/stores/view';
import { browser } from '$app/environment';
@ -41,6 +47,19 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Live queries for picture data (local-first)
const allImages = useAllImages();
setContext('allImages', allImages);
const archivedImages = useArchivedImages();
setContext('archivedImages', archivedImages);
const allBoards = useAllBoards();
setContext('allBoards', allBoards);
const allImageTags = useAllImageTags();
setContext('allImageTags', allImageTags);
let { children } = $props();
// PillNav state

View file

@ -1,88 +1,15 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import {
archivedImages,
isLoadingArchive,
hasMoreArchive,
currentArchivePage,
} from '$lib/stores/archive';
import { getImages } from '$lib/api/images';
import type { Image } from '$lib/api/images';
import ArchivedImageCard from '$lib/components/archive/ArchivedImageCard.svelte';
import ArchivedImageModal from '$lib/components/archive/ArchivedImageModal.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import { Archive } from '@manacore/shared-icons';
import { onMount } from 'svelte';
import { getContext } from 'svelte';
const archivedImages: { value: Image[] } = getContext('archivedImages');
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
let selectedImage = $state<Image | null>(null);
onMount(() => {
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && $hasMoreArchive && !$isLoadingArchive && !loadingMore) {
loadMoreImages();
}
},
{
threshold: 0.1,
rootMargin: '100px', // Load before reaching the trigger
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
};
});
async function loadInitialImages() {
if (!authStore.user) return;
isLoadingArchive.set(true);
try {
const data = await getImages({ page: 1, archived: true });
archivedImages.set(data);
currentArchivePage.set(1);
hasMoreArchive.set(data.length === 20);
} catch (error) {
console.error('Error loading archived images:', error);
} finally {
isLoadingArchive.set(false);
}
}
async function loadMoreImages() {
if (!authStore.user || !$hasMoreArchive || $isLoadingArchive || loadingMore) return;
loadingMore = true;
const nextPage = $currentArchivePage + 1;
try {
const newImages = await getImages({ page: nextPage, archived: true });
if (newImages.length > 0) {
archivedImages.update((current) => [...current, ...newImages]);
currentArchivePage.set(nextPage);
hasMoreArchive.set(newImages.length === 20);
} else {
hasMoreArchive.set(false);
}
} catch (error) {
console.error('Error loading more archived images:', error);
} finally {
loadingMore = false;
}
}
function handleImageClick(image: Image) {
selectedImage = image;
}
@ -92,11 +19,7 @@
<title>Archive - Picture</title>
</svelte:head>
{#if $isLoadingArchive}
<div class="px-4 py-8">
<ImageSkeleton count={20} />
</div>
{:else if $archivedImages.length === 0}
{#if archivedImages.value.length === 0}
<div class="flex min-h-[400px] items-center justify-center px-4 py-8">
<div class="text-center">
<Archive size={64} weight="thin" class="mx-auto text-gray-400 dark:text-gray-600" />
@ -109,23 +32,10 @@
{:else}
<div class="px-4 py-8 pb-32">
<div class="grid grid-cols-2 gap-4 sm:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5">
{#each $archivedImages as image (image.id)}
{#each archivedImages.value as image (image.id)}
<ArchivedImageCard {image} onclick={() => handleImageClick(image)} />
{/each}
</div>
<!-- Infinite Scroll Trigger -->
{#if $hasMoreArchive}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
</div>
{/if}

View file

@ -1,28 +1,13 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import {
boards,
isLoadingBoards,
currentBoardsPage,
hasBoardsMore,
showCreateBoardModal,
selectedBoard,
resetBoardsState,
addBoard,
removeBoardFromList,
} from '$lib/stores/boards';
import { showCreateBoardModal } from '$lib/stores/boards';
import type { BoardWithCount } from '$lib/api/boards';
import { boardCollection, boardItemCollection, type LocalBoard } from '$lib/data/local-store';
import { getContext } from 'svelte';
import { PageHeader, Button, Modal, toastStore } from '@manacore/shared-ui';
import { Plus, SquaresFour, Image, Trash } from '@manacore/shared-icons';
const PAGE_SIZE = 20;
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
const allBoards: { value: BoardWithCount[] } = getContext('allBoards');
// Create board modal state
let boardName = $state('');
@ -33,101 +18,6 @@
let showDeleteModal = $state(false);
let deletingBoard = $state<string | null>(null);
/** Convert LocalBoard to BoardWithCount for existing components. */
async function toBoardWithCount(local: LocalBoard): Promise<BoardWithCount> {
const items = await boardItemCollection.getAll({ boardId: local.id });
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? undefined,
thumbnailUrl: local.thumbnailUrl ?? undefined,
canvasWidth: local.canvasWidth,
canvasHeight: local.canvasHeight,
backgroundColor: local.backgroundColor,
isPublic: local.isPublic,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
itemCount: items.length,
};
}
onMount(() => {
resetBoardsState();
loadInitialBoards();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && $hasBoardsMore && !$isLoadingBoards && !loadingMore) {
loadMoreBoards();
}
},
{
threshold: 0.1,
rootMargin: '100px',
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
}
return () => {
if (observer) observer.disconnect();
};
});
async function loadInitialBoards() {
isLoadingBoards.set(true);
try {
const localBoards = await boardCollection.getAll();
// Sort by updatedAt descending
localBoards.sort(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
const page1 = localBoards.slice(0, PAGE_SIZE);
const data = await Promise.all(page1.map(toBoardWithCount));
boards.set(data);
currentBoardsPage.set(1);
hasBoardsMore.set(localBoards.length > PAGE_SIZE);
} catch (error) {
console.error('Error loading boards:', error);
toastStore.show('Fehler beim Laden der Boards', 'error');
} finally {
isLoadingBoards.set(false);
}
}
async function loadMoreBoards() {
if (!$hasBoardsMore || $isLoadingBoards || loadingMore) return;
loadingMore = true;
const nextPage = $currentBoardsPage + 1;
try {
const localBoards = await boardCollection.getAll();
localBoards.sort(
(a, b) => new Date(b.updatedAt ?? 0).getTime() - new Date(a.updatedAt ?? 0).getTime()
);
const start = (nextPage - 1) * PAGE_SIZE;
const pageBoards = localBoards.slice(start, start + PAGE_SIZE);
if (pageBoards.length > 0) {
const data = await Promise.all(pageBoards.map(toBoardWithCount));
boards.update((current) => [...current, ...data]);
currentBoardsPage.set(nextPage);
hasBoardsMore.set(start + PAGE_SIZE < localBoards.length);
} else {
hasBoardsMore.set(false);
}
} catch (error) {
console.error('Error loading more boards:', error);
} finally {
loadingMore = false;
}
}
async function handleCreateBoard() {
if (!boardName.trim()) return;
@ -142,9 +32,7 @@
backgroundColor: '#ffffff',
isPublic: false,
};
const inserted = await boardCollection.insert(newLocal);
const boardWithCount = await toBoardWithCount(inserted);
addBoard(boardWithCount);
await boardCollection.insert(newLocal);
showCreateBoardModal.set(false);
boardName = '';
boardDescription = '';
@ -167,7 +55,6 @@
await boardItemCollection.delete(item.id);
}
await boardCollection.delete(deletingBoard);
removeBoardFromList(deletingBoard);
showDeleteModal = false;
deletingBoard = null;
toastStore.show('Board gelöscht', 'success');
@ -192,7 +79,7 @@
backgroundColor: original.backgroundColor,
isPublic: false,
};
const inserted = await boardCollection.insert(duplicated);
await boardCollection.insert(duplicated);
// Duplicate board items
const originalItems = await boardItemCollection.getAll({ boardId });
@ -204,8 +91,6 @@
});
}
const boardWithCount = await toBoardWithCount(inserted);
addBoard(boardWithCount);
toastStore.show('Board dupliziert', 'success');
} catch (error) {
console.error('Error duplicating board:', error);
@ -241,18 +126,7 @@
{/snippet}
</PageHeader>
<!-- Loading State -->
{#if $isLoadingBoards}
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each Array(8) as _}
<div class="animate-pulse">
<div class="aspect-[4/3] rounded-lg bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-3 h-6 rounded bg-gray-200 dark:bg-gray-700"></div>
<div class="mt-2 h-4 w-2/3 rounded bg-gray-200 dark:bg-gray-700"></div>
</div>
{/each}
</div>
{:else if $boards.length === 0}
{#if allBoards.value.length === 0}
<!-- Empty State -->
<div class="flex flex-col items-center justify-center py-20">
<SquaresFour size={96} weight="thin" class="text-gray-300 dark:text-gray-600" />
@ -269,7 +143,7 @@
{:else}
<!-- Boards Grid -->
<div class="grid grid-cols-1 gap-6 sm:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4">
{#each $boards as board (board.id)}
{#each allBoards.value as board (board.id)}
<div
class="group relative overflow-hidden rounded-lg border border-gray-200 bg-white transition-all hover:shadow-lg dark:border-gray-700 dark:bg-gray-800"
>
@ -330,19 +204,6 @@
</div>
{/each}
</div>
<!-- Infinite Scroll Trigger -->
{#if $hasBoardsMore}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
{/if}
</div>

View file

@ -1,180 +1,38 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import {
images,
isLoading,
hasMore,
currentPage,
selectedImage,
showFavoritesOnly,
} from '$lib/stores/images';
import { selectedImage, showFavoritesOnly } from '$lib/stores/images';
import { isUIVisible } from '$lib/stores/ui';
import { selectedTags } from '$lib/stores/tags';
import { getContext } from 'svelte';
import type { Tag } from '@manacore/shared-tags';
import { imageCollection, imageTagCollection } from '$lib/data/local-store';
import type { Image } from '$lib/api/images';
import type { LocalImage } from '$lib/data/local-store';
import { getFavoriteImages, getImagesByTags } from '$lib/data/queries';
import GalleryGrid from '$lib/components/gallery/GalleryGrid.svelte';
import ImageDetailModal from '$lib/components/gallery/ImageDetailModal.svelte';
import QuickGenerateBar from '$lib/components/gallery/QuickGenerateBar.svelte';
import ContextMenu from '$lib/components/ui/ContextMenu.svelte';
import ImageSkeleton from '$lib/components/ui/ImageSkeleton.svelte';
import ViewModeSwitcher from '$lib/components/ui/ViewModeSwitcher.svelte';
import TagPills from '$lib/components/tags/TagPills.svelte';
import { Heart } from '@manacore/shared-icons';
import { onMount } from 'svelte';
const allTags: { value: Tag[] } = getContext('tags');
const allImages: { value: Image[] } = getContext('allImages');
const allImageTags: { value: { imageId: string; tagId: string }[] } = getContext('allImageTags');
const PAGE_SIZE = 20;
// Derive filtered images reactively from live query data
const filteredImages = $derived.by(() => {
let result = allImages.value;
let loadingMore = $state(false);
let observer: IntersectionObserver | null = null;
let loadMoreTrigger = $state<HTMLElement | null>(null);
/** Convert LocalImage (IndexedDB) to the Image type used by components. */
function toImage(local: LocalImage): Image {
return {
id: local.id,
userId: 'local',
prompt: local.prompt,
negativePrompt: local.negativePrompt ?? undefined,
model: local.model ?? undefined,
style: local.style ?? undefined,
publicUrl: local.publicUrl ?? undefined,
storagePath: local.storagePath,
filename: local.filename,
format: local.format ?? undefined,
width: local.width ?? undefined,
height: local.height ?? undefined,
fileSize: local.fileSize ?? undefined,
blurhash: local.blurhash ?? undefined,
isPublic: local.isPublic,
isFavorite: local.isFavorite,
downloadCount: local.downloadCount,
rating: local.rating ?? undefined,
archivedAt: local.archivedAt ?? undefined,
generationId: local.generationId ?? undefined,
sourceImageId: local.sourceImageId ?? undefined,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
onMount(() => {
loadInitialImages();
// Setup Intersection Observer for infinite scroll
observer = new IntersectionObserver(
(entries) => {
if (entries[0].isIntersecting && $hasMore && !$isLoading && !loadingMore) {
loadMoreImages();
}
},
{
threshold: 0.1,
rootMargin: '100px',
}
);
if (loadMoreTrigger) {
observer.observe(loadMoreTrigger);
// Filter favorites
if ($showFavoritesOnly) {
result = getFavoriteImages(result);
}
return () => {
if (observer) observer.disconnect();
};
// Filter by tags
if ($selectedTags.length > 0) {
result = getImagesByTags(result, allImageTags.value, $selectedTags);
}
return result;
});
// React to tag and favorites filter changes
$effect(() => {
if ($selectedTags || $showFavoritesOnly !== undefined) {
loadInitialImages();
}
});
async function loadInitialImages() {
isLoading.set(true);
try {
let allImages = await imageCollection.getAll();
// Filter out archived images
allImages = allImages.filter((img) => !img.archivedAt);
// Filter favorites
if ($showFavoritesOnly) {
allImages = allImages.filter((img) => img.isFavorite);
}
// Filter by tags
if ($selectedTags.length > 0) {
const allImageTags = await imageTagCollection.getAll();
const imageIdsWithTags = new Set(
allImageTags.filter((it) => $selectedTags.includes(it.tagId)).map((it) => it.imageId)
);
allImages = allImages.filter((img) => imageIdsWithTags.has(img.id));
}
// Sort by createdAt descending
allImages.sort(
(a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
);
// Paginate
const page1 = allImages.slice(0, PAGE_SIZE);
images.set(page1.map(toImage));
currentPage.set(1);
hasMore.set(allImages.length > PAGE_SIZE);
} catch (error) {
console.error('Error loading images:', error);
} finally {
isLoading.set(false);
}
}
async function loadMoreImages() {
if (!$hasMore || $isLoading || loadingMore) return;
loadingMore = true;
const nextPage = $currentPage + 1;
try {
let allImages = await imageCollection.getAll();
allImages = allImages.filter((img) => !img.archivedAt);
if ($showFavoritesOnly) {
allImages = allImages.filter((img) => img.isFavorite);
}
if ($selectedTags.length > 0) {
const allImageTags = await imageTagCollection.getAll();
const imageIdsWithTags = new Set(
allImageTags.filter((it) => $selectedTags.includes(it.tagId)).map((it) => it.imageId)
);
allImages = allImages.filter((img) => imageIdsWithTags.has(img.id));
}
allImages.sort(
(a, b) => new Date(b.createdAt ?? 0).getTime() - new Date(a.createdAt ?? 0).getTime()
);
const start = (nextPage - 1) * PAGE_SIZE;
const pageImages = allImages.slice(start, start + PAGE_SIZE);
if (pageImages.length > 0) {
images.update((current) => [...current, ...pageImages.map(toImage)]);
currentPage.set(nextPage);
hasMore.set(start + PAGE_SIZE < allImages.length);
} else {
hasMore.set(false);
}
} catch (error) {
console.error('Error loading more images:', error);
} finally {
loadingMore = false;
}
}
</script>
<svelte:head>
@ -235,28 +93,9 @@
{/if}
</div>
{#if $isLoading}
<div class="px-4 py-8">
<ImageSkeleton count={20} />
</div>
{:else}
<div class="px-4 py-8 pb-32">
<GalleryGrid images={$images} />
<!-- Infinite Scroll Trigger -->
{#if $hasMore}
<div bind:this={loadMoreTrigger} class="mt-8 flex justify-center">
{#if loadingMore}
<div
class="h-8 w-8 animate-spin rounded-full border-4 border-solid border-blue-600 border-r-transparent dark:border-blue-400"
></div>
{:else}
<p class="text-sm text-gray-500 dark:text-gray-400">Scroll to load more</p>
{/if}
</div>
{/if}
</div>
{/if}
<div class="px-4 py-8 pb-32">
<GalleryGrid images={filteredImages} />
</div>
<!-- Image Detail Modal -->
<ImageDetailModal image={$selectedImage} onClose={() => selectedImage.set(null)} />
@ -266,5 +105,5 @@
<!-- Quick Generate Bar (conditionally visible) -->
{#if $isUIVisible}
<QuickGenerateBar onGenerated={loadInitialImages} />
<QuickGenerateBar />
{/if}

View file

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

View file

@ -6,7 +6,6 @@
import { toastStore } from '@manacore/shared-ui';
import { PageHeader } from '@manacore/shared-ui';
import DropZone from '$lib/components/upload/DropZone.svelte';
import { images } from '$lib/stores/images';
import { Check, Image, CloudArrowUp, CheckCircle } from '@manacore/shared-icons';
let uploading = $state(false);
@ -29,8 +28,7 @@
successCount = uploadedImages.length;
// Add uploaded images to store
images.update((current) => [...uploadedImages, ...current]);
// Images will appear in gallery automatically via live query
if (successCount === files.length) {
toastStore.show(

View file

@ -0,0 +1,148 @@
/**
* Planta Mutation Helpers (Local-First)
*
* All writes go to IndexedDB first, sync handles the rest.
*/
import {
plantCollection,
wateringScheduleCollection,
wateringLogCollection,
type LocalPlant,
type LocalWateringSchedule,
type LocalWateringLog,
} from './local-store';
import { toPlant, toWateringSchedule } from './queries';
import { trackEvent } from '@manacore/shared-utils/analytics';
import type { Plant, CreatePlantDto, UpdatePlantDto } from '@planta/shared';
export const plantMutations = {
async create(dto: CreatePlantDto): Promise<Plant | null> {
try {
const newLocal: LocalPlant = {
id: crypto.randomUUID(),
name: dto.name,
scientificName: dto.scientificName ?? null,
commonName: dto.commonName ?? null,
species: null,
lightRequirements: null,
wateringFrequencyDays: null,
humidity: null,
temperature: null,
soilType: null,
careNotes: null,
isActive: true,
healthStatus: null,
acquiredAt: dto.acquiredAt ?? null,
};
const inserted = await plantCollection.insert(newLocal);
trackEvent('plant_created');
return toPlant(inserted);
} catch (e) {
console.error('Failed to create plant:', e);
return null;
}
},
async update(id: string, dto: UpdatePlantDto): Promise<Plant | null> {
try {
const updateData: Partial<LocalPlant> = {};
if (dto.name !== undefined) updateData.name = dto.name;
if (dto.scientificName !== undefined) updateData.scientificName = dto.scientificName ?? null;
if (dto.commonName !== undefined) updateData.commonName = dto.commonName ?? null;
if (dto.careNotes !== undefined) updateData.careNotes = dto.careNotes ?? null;
if (dto.isActive !== undefined) updateData.isActive = dto.isActive;
if (dto.lightRequirements !== undefined)
updateData.lightRequirements = dto.lightRequirements ?? null;
if (dto.wateringFrequencyDays !== undefined)
updateData.wateringFrequencyDays = dto.wateringFrequencyDays ?? null;
if (dto.humidity !== undefined) updateData.humidity = dto.humidity ?? null;
const updated = await plantCollection.update(id, updateData);
return updated ? toPlant(updated) : null;
} catch (e) {
console.error('Failed to update plant:', e);
return null;
}
},
async delete(id: string): Promise<boolean> {
try {
await plantCollection.delete(id);
trackEvent('plant_deleted');
return true;
} catch (e) {
console.error('Failed to delete plant:', e);
return false;
}
},
};
export const wateringMutations = {
async logWatering(plantId: string, notes?: string): Promise<boolean> {
try {
const now = new Date().toISOString();
// Create watering log entry
const logEntry: LocalWateringLog = {
id: crypto.randomUUID(),
plantId,
wateredAt: now,
notes: notes ?? null,
};
await wateringLogCollection.insert(logEntry);
// Update watering schedule
const schedules = await wateringScheduleCollection.getAll();
const schedule = schedules.find((s) => s.plantId === plantId);
if (schedule) {
const nextDate = new Date();
nextDate.setDate(nextDate.getDate() + schedule.frequencyDays);
await wateringScheduleCollection.update(schedule.id, {
lastWateredAt: now,
nextWateringAt: nextDate.toISOString(),
} as Partial<LocalWateringSchedule>);
}
trackEvent('plant_watered');
return true;
} catch (e) {
console.error('Failed to log watering:', e);
return false;
}
},
async updateSchedule(plantId: string, frequencyDays: number): Promise<boolean> {
try {
const schedules = await wateringScheduleCollection.getAll();
const schedule = schedules.find((s) => s.plantId === plantId);
if (schedule) {
const nextDate = schedule.lastWateredAt
? new Date(new Date(schedule.lastWateredAt).getTime() + frequencyDays * 86400000)
: new Date(Date.now() + frequencyDays * 86400000);
await wateringScheduleCollection.update(schedule.id, {
frequencyDays,
nextWateringAt: nextDate.toISOString(),
} as Partial<LocalWateringSchedule>);
} else {
const nextDate = new Date(Date.now() + frequencyDays * 86400000);
await wateringScheduleCollection.insert({
id: crypto.randomUUID(),
plantId,
frequencyDays,
lastWateredAt: null,
nextWateringAt: nextDate.toISOString(),
reminderEnabled: false,
reminderHoursBefore: 0,
} as LocalWateringSchedule);
}
return true;
} catch (e) {
console.error('Failed to update watering schedule:', e);
return false;
}
},
};

View file

@ -0,0 +1,180 @@
/**
* Reactive Queries & Pure Helpers for Planta
*
* 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 {
plantCollection,
plantPhotoCollection,
wateringScheduleCollection,
wateringLogCollection,
type LocalPlant,
type LocalPlantPhoto,
type LocalWateringSchedule,
type LocalWateringLog,
} from './local-store';
import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '@planta/shared';
// ─── Type Converters ───────────────────────────────────────
/** Convert a LocalPlant (IndexedDB) to the shared Plant type. */
export function toPlant(local: LocalPlant): Plant {
return {
id: local.id,
userId: 'local',
name: local.name,
scientificName: local.scientificName ?? undefined,
commonName: local.commonName ?? undefined,
species: local.species ?? undefined,
lightRequirements: local.lightRequirements ?? undefined,
wateringFrequencyDays: local.wateringFrequencyDays ?? undefined,
humidity: local.humidity ?? undefined,
temperature: local.temperature ?? undefined,
soilType: local.soilType ?? undefined,
careNotes: local.careNotes ?? undefined,
isActive: local.isActive,
healthStatus: local.healthStatus ?? undefined,
acquiredAt: local.acquiredAt ? new Date(local.acquiredAt) : undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
updatedAt: new Date(local.updatedAt ?? new Date().toISOString()),
};
}
/** Convert a LocalPlantPhoto (IndexedDB) to the shared PlantPhoto type. */
export function toPlantPhoto(local: LocalPlantPhoto): PlantPhoto {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
storagePath: local.storagePath,
publicUrl: local.publicUrl ?? undefined,
filename: local.filename,
mimeType: local.mimeType ?? undefined,
fileSize: local.fileSize ?? undefined,
width: local.width ?? undefined,
height: local.height ?? undefined,
isPrimary: local.isPrimary,
isAnalyzed: local.isAnalyzed,
takenAt: local.takenAt ? new Date(local.takenAt) : undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
};
}
/** Convert a LocalWateringSchedule (IndexedDB) to the shared WateringSchedule type. */
export function toWateringSchedule(local: LocalWateringSchedule): WateringSchedule {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
frequencyDays: local.frequencyDays,
lastWateredAt: local.lastWateredAt ? new Date(local.lastWateredAt) : undefined,
nextWateringAt: local.nextWateringAt ? new Date(local.nextWateringAt) : undefined,
reminderEnabled: local.reminderEnabled,
reminderHoursBefore: local.reminderHoursBefore,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
updatedAt: new Date(local.updatedAt ?? new Date().toISOString()),
};
}
/** Convert a LocalWateringLog (IndexedDB) to the shared WateringLog type. */
export function toWateringLog(local: LocalWateringLog): WateringLog {
return {
id: local.id,
plantId: local.plantId,
userId: 'local',
wateredAt: new Date(local.wateredAt),
notes: local.notes ?? undefined,
createdAt: new Date(local.createdAt ?? new Date().toISOString()),
};
}
// ─── Live Query Hooks (call during component init) ─────────
/** All plants. Auto-updates on any change. */
export function useAllPlants() {
return useLiveQueryWithDefault(async () => {
const locals = await plantCollection.getAll();
return locals.map(toPlant);
}, [] as Plant[]);
}
/** All plant photos. Auto-updates on any change. */
export function useAllPlantPhotos() {
return useLiveQueryWithDefault(async () => {
const locals = await plantPhotoCollection.getAll();
return locals.map(toPlantPhoto);
}, [] as PlantPhoto[]);
}
/** All watering schedules. Auto-updates on any change. */
export function useAllWateringSchedules() {
return useLiveQueryWithDefault(async () => {
const locals = await wateringScheduleCollection.getAll();
return locals.map(toWateringSchedule);
}, [] as WateringSchedule[]);
}
/** All watering logs. Auto-updates on any change. */
export function useAllWateringLogs() {
return useLiveQueryWithDefault(async () => {
const locals = await wateringLogCollection.getAll();
return locals.map(toWateringLog);
}, [] as WateringLog[]);
}
// ─── Pure Plant Helpers ────────────────────────────────────
/** Get a plant by ID. */
export function getPlantById(plants: Plant[], id: string): Plant | undefined {
return plants.find((p) => p.id === id);
}
/** Get active plants only. */
export function getActivePlants(plants: Plant[]): Plant[] {
return plants.filter((p) => p.isActive);
}
/** Get photos for a specific plant. */
export function getPhotosForPlant(photos: PlantPhoto[], plantId: string): PlantPhoto[] {
return photos.filter((p) => p.plantId === plantId);
}
/** Get the primary photo for a plant. */
export function getPrimaryPhoto(photos: PlantPhoto[], plantId: string): PlantPhoto | undefined {
return photos.find((p) => p.plantId === plantId && p.isPrimary);
}
// ─── Pure Watering Helpers ─────────────────────────────────
/** Get watering schedule for a specific plant. */
export function getScheduleForPlant(
schedules: WateringSchedule[],
plantId: string
): WateringSchedule | undefined {
return schedules.find((s) => s.plantId === plantId);
}
/** Get watering logs for a specific plant, sorted by date (newest first). */
export function getLogsForPlant(logs: WateringLog[], plantId: string): WateringLog[] {
return logs
.filter((l) => l.plantId === plantId)
.sort((a, b) => new Date(b.wateredAt).getTime() - new Date(a.wateredAt).getTime());
}
/** Calculate days until next watering. Negative means overdue. */
export function getDaysUntilWatering(schedule: WateringSchedule | undefined): number | null {
if (!schedule?.nextWateringAt) return null;
const now = new Date();
const next = new Date(schedule.nextWateringAt);
return Math.ceil((next.getTime() - now.getTime()) / (1000 * 60 * 60 * 24));
}
/** Check if a plant's watering is overdue. */
export function isWateringOverdue(schedule: WateringSchedule | undefined): boolean {
const days = getDaysUntilWatering(schedule);
return days !== null && days < 0;
}

View file

@ -10,7 +10,13 @@
} from '@manacore/shared-stores';
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { plantsApi } from '$lib/api/plants';
import { plantMutations } from '$lib/data/mutations';
import {
useAllPlants,
useAllPlantPhotos,
useAllWateringSchedules,
useAllWateringLogs,
} from '$lib/data/queries';
import {
parsePlantInput,
resolvePlantData,
@ -23,7 +29,18 @@
let { children } = $props();
// Live queries for local-first data (auto-update on Dexie changes)
const allPlants = useAllPlants();
const allPlantPhotos = useAllPlantPhotos();
const allWateringSchedules = useAllWateringSchedules();
const allWateringLogs = useAllWateringLogs();
const allTags = useAllSharedTags();
// Set context for child components
setContext('plants', allPlants);
setContext('plantPhotos', allPlantPhotos);
setContext('wateringSchedules', allWateringSchedules);
setContext('wateringLogs', allWateringLogs);
setContext('tags', allTags);
let showGuestWelcome = $state(false);
@ -37,7 +54,7 @@
// Navigation items for Planta
const navItems: PillNavItem[] = [
{ href: '/dashboard', label: 'Meine Pflanzen', icon: 'document' },
{ href: '/add', label: 'Hinzufügen', icon: 'plus' },
{ href: '/add', label: 'Hinzufuegen', icon: 'plus' },
{ href: '/settings', label: 'Einstellungen', icon: 'settings' },
{
href: '/',
@ -59,9 +76,9 @@
goto('/login');
}
// QuickInputBar handlers
// QuickInputBar handlers — use live query data instead of API
async function handleInputSearch(query: string): Promise<QuickInputItem[]> {
const plants = await plantsApi.getAll();
const plants = allPlants.value;
const q = query.toLowerCase();
return plants
.filter(
@ -79,7 +96,7 @@
}
function handleInputSelect(item: QuickInputItem) {
goto(`/plant/${item.id}`);
goto(`/plants/${item.id}`);
}
// Quick-Create handlers
@ -99,12 +116,12 @@
const parsed = parsePlantInput(query);
if (!parsed.name) return;
const resolved = resolvePlantData(parsed);
const plant = await plantsApi.create({
const plant = await plantMutations.create({
name: resolved.name,
acquiredAt: resolved.acquiredAt,
});
if (plant?.id) {
goto(`/plant/${plant.id}`);
goto(`/plants/${plant.id}`);
}
}

View file

@ -2,7 +2,7 @@
import { goto } from '$app/navigation';
import { photosApi } from '$lib/api/photos';
import { analysisApi } from '$lib/api/analysis';
import { plantsApi } from '$lib/api/plants';
import { plantMutations } from '$lib/data/mutations';
import { PlantaEvents } from '@manacore/shared-utils/analytics';
import type { PlantPhoto, PlantAnalysis } from '@planta/shared';
@ -94,8 +94,8 @@
saving = true;
error = '';
// Create plant
const plant = await plantsApi.create({
// Create plant (local-first)
const plant = await plantMutations.create({
name: plantName.trim(),
scientificName: analysis.scientificName || undefined,
commonName: analysis.commonNames?.[0] || undefined,

View file

@ -1,51 +1,57 @@
<script lang="ts">
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { plantsApi } from '$lib/api/plants';
import { wateringApi } from '$lib/api/watering';
import { getContext } from 'svelte';
import { PlantaEvents } from '@manacore/shared-utils/analytics';
import type { Plant, WateringStatus } from '@planta/shared';
import { wateringMutations } from '$lib/data/mutations';
import {
getActivePlants,
getPrimaryPhoto,
getScheduleForPlant,
getDaysUntilWatering,
isWateringOverdue,
} from '$lib/data/queries';
import type { Plant, PlantPhoto, WateringSchedule } from '@planta/shared';
let plants = $state<Plant[]>([]);
let wateringStatus = $state<WateringStatus[]>([]);
let loading = $state(true);
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
const allWateringSchedules: { readonly value: WateringSchedule[] } =
getContext('wateringSchedules');
onMount(async () => {
const [plantsData, wateringData] = await Promise.all([
plantsApi.getAll(),
wateringApi.getUpcoming(),
]);
plants = plantsData;
wateringStatus = wateringData;
loading = false;
});
// Derived reactive data from live queries
let plants = $derived(getActivePlants(allPlants.value));
function getWateringStatusForPlant(plantId: string): WateringStatus | undefined {
return wateringStatus.find((s) => s.plantId === plantId);
}
function getWateringClass(status: WateringStatus | undefined): string {
if (!status) return '';
if (status.isOverdue) return 'overdue';
if (status.daysUntilWatering <= 1) return 'soon';
function getWateringClass(plantId: string): string {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return '';
if (isWateringOverdue(schedule)) return 'overdue';
const days = getDaysUntilWatering(schedule);
if (days !== null && days <= 1) return 'soon';
return 'ok';
}
function getWateringText(status: WateringStatus | undefined): string {
if (!status) return '';
if (status.isOverdue) return 'Überfällig!';
if (status.daysUntilWatering === 0) return 'Heute gießen';
if (status.daysUntilWatering === 1) return 'Morgen gießen';
return `In ${status.daysUntilWatering} Tagen`;
function getWateringText(plantId: string): string {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return '';
const days = getDaysUntilWatering(schedule);
if (days === null) return '';
if (days < 0) return 'Ueberfaellig!';
if (days === 0) return 'Heute giessen';
if (days === 1) return 'Morgen giessen';
return `In ${days} Tagen`;
}
function shouldShowWaterButton(plantId: string): boolean {
const schedule = getScheduleForPlant(allWateringSchedules.value, plantId);
if (!schedule) return false;
const days = getDaysUntilWatering(schedule);
return days !== null && days <= 1;
}
async function handleWater(plantId: string, e: Event) {
e.stopPropagation();
const success = await wateringApi.logWatering(plantId);
const success = await wateringMutations.logWatering(plantId);
if (success) {
PlantaEvents.plantWatered();
// Refresh watering status
wateringStatus = await wateringApi.getUpcoming();
}
}
</script>
@ -57,35 +63,29 @@
<div class="space-y-6">
<div class="flex items-center justify-between">
<h1 class="text-2xl font-bold">Meine Pflanzen</h1>
<a href="/add" class="btn btn-success"> + Pflanze hinzufügen </a>
<a href="/add" class="btn btn-success"> + Pflanze hinzufuegen </a>
</div>
{#if loading}
<div class="flex justify-center py-12">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{:else if plants.length === 0}
{#if plants.length === 0}
<div class="text-center py-12">
<div class="text-6xl mb-4">🌱</div>
<h2 class="text-xl font-semibold mb-2">Noch keine Pflanzen</h2>
<p class="text-muted-foreground mb-4">
Füge deine erste Pflanze hinzu und lass sie von der KI analysieren.
Fuege deine erste Pflanze hinzu und lass sie von der KI analysieren.
</p>
<a href="/add" class="btn btn-success"> Erste Pflanze hinzufügen </a>
<a href="/add" class="btn btn-success"> Erste Pflanze hinzufuegen </a>
</div>
{:else}
<div class="grid grid-cols-2 sm:grid-cols-3 md:grid-cols-4 lg:grid-cols-5 gap-4">
{#each plants as plant (plant.id)}
{@const status = getWateringStatusForPlant(plant.id)}
{@const primaryPhoto = getPrimaryPhoto(allPlantPhotos.value, plant.id)}
<button
type="button"
class="card plant-card cursor-pointer text-left"
onclick={() => goto(`/plants/${plant.id}`)}
>
{#if plant.primaryPhoto?.publicUrl}
<img src={plant.primaryPhoto.publicUrl} alt={plant.name} />
{#if primaryPhoto?.publicUrl}
<img src={primaryPhoto.publicUrl} alt={plant.name} />
{:else}
<div class="flex h-full w-full items-center justify-center bg-muted text-4xl">🌿</div>
{/if}
@ -96,14 +96,15 @@
{#if plant.commonName}
<p class="text-xs text-white/70 truncate">{plant.commonName}</p>
{/if}
{#if status}
<div class="water-status {getWateringClass(status)} mt-1">
{@const waterText = getWateringText(plant.id)}
{#if waterText}
<div class="water-status {getWateringClass(plant.id)} mt-1">
<span>💧</span>
<span>{getWateringText(status)}</span>
<span>{waterText}</span>
</div>
{/if}
</div>
{#if status && (status.isOverdue || status.daysUntilWatering <= 1)}
{#if shouldShowWaterButton(plant.id)}
<button
type="button"
class="absolute top-2 right-2 rounded-full bg-blue-500 p-2 text-white hover:bg-blue-600"

View file

@ -1,52 +1,48 @@
<script lang="ts">
import { page } from '$app/stores';
import { onMount } from 'svelte';
import { goto } from '$app/navigation';
import { plantsApi } from '$lib/api/plants';
import { wateringApi } from '$lib/api/watering';
import { getContext } from 'svelte';
import { PlantaEvents } from '@manacore/shared-utils/analytics';
import type { PlantWithDetails, WateringLog } from '@planta/shared';
import { plantMutations, wateringMutations } from '$lib/data/mutations';
import {
getPlantById,
getPhotosForPlant,
getScheduleForPlant,
getLogsForPlant,
} from '$lib/data/queries';
import type { Plant, PlantPhoto, WateringSchedule, WateringLog } from '@planta/shared';
const allPlants: { readonly value: Plant[] } = getContext('plants');
const allPlantPhotos: { readonly value: PlantPhoto[] } = getContext('plantPhotos');
const allWateringSchedules: { readonly value: WateringSchedule[] } =
getContext('wateringSchedules');
const allWateringLogs: { readonly value: WateringLog[] } = getContext('wateringLogs');
const plantId = $derived($page.params.id);
// Derived reactive data from live queries (auto-updates on any change)
let plant = $derived(getPlantById(allPlants.value, plantId));
let photos = $derived(getPhotosForPlant(allPlantPhotos.value, plantId));
let wateringSchedule = $derived(getScheduleForPlant(allWateringSchedules.value, plantId));
let wateringHistory = $derived(getLogsForPlant(allWateringLogs.value, plantId));
let plant = $state<PlantWithDetails | null>(null);
let wateringHistory = $state<WateringLog[]>([]);
let loading = $state(true);
let watering = $state(false);
$effect(() => {
const plantId = $page.params.id;
if (plantId) {
loadPlant(plantId);
}
});
async function loadPlant(id: string) {
loading = true;
const [plantData, historyData] = await Promise.all([
plantsApi.getById(id),
wateringApi.getHistory(id),
]);
plant = plantData;
wateringHistory = historyData;
loading = false;
}
async function handleWater() {
if (!plant) return;
watering = true;
const success = await wateringApi.logWatering(plant.id);
const success = await wateringMutations.logWatering(plant.id);
if (success) {
PlantaEvents.plantWatered();
// Reload plant data
await loadPlant(plant.id);
}
watering = false;
}
async function handleDelete() {
if (!plant) return;
if (!confirm(`Möchtest du "${plant.name}" wirklich löschen?`)) return;
if (!confirm(`Moechtest du "${plant.name}" wirklich loeschen?`)) return;
const success = await plantsApi.delete(plant.id);
const success = await plantMutations.delete(plant.id);
if (success) {
PlantaEvents.plantDeleted();
goto('/dashboard');
@ -102,16 +98,10 @@
<title>{plant?.name || 'Pflanze'} - Planta</title>
</svelte:head>
{#if loading}
<div class="flex justify-center py-12">
<div
class="h-12 w-12 animate-spin rounded-full border-4 border-solid border-primary border-r-transparent"
></div>
</div>
{:else if !plant}
{#if !plant}
<div class="text-center py-12">
<p class="text-lg">Pflanze nicht gefunden</p>
<a href="/dashboard" class="btn btn-primary mt-4">Zurück zur Übersicht</a>
<a href="/dashboard" class="btn btn-primary mt-4">Zurueck zur Uebersicht</a>
</div>
{:else}
<div class="space-y-6">
@ -132,9 +122,9 @@
</div>
<!-- Photo Gallery -->
{#if plant.photos && plant.photos.length > 0}
{#if photos.length > 0}
<div class="grid grid-cols-3 gap-2">
{#each plant.photos as photo (photo.id)}
{#each photos as photo (photo.id)}
<img
src={photo.publicUrl}
alt={plant.name}
@ -155,7 +145,7 @@
<p class="font-medium">☀️ {getLightText(plant.lightRequirements)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Gießen</p>
<p class="text-sm text-muted-foreground">Giessen</p>
<p class="font-medium">
💧 {plant.wateringFrequencyDays ? `Alle ${plant.wateringFrequencyDays} Tage` : '-'}
</p>
@ -180,34 +170,34 @@
<!-- Watering Schedule -->
<div class="card">
<div class="flex items-center justify-between mb-4">
<h2 class="font-semibold">Gießplan</h2>
<h2 class="font-semibold">Giessplan</h2>
<button type="button" class="btn btn-success" onclick={handleWater} disabled={watering}>
{#if watering}
<span
class="inline-block h-4 w-4 animate-spin rounded-full border-2 border-white border-r-transparent"
></span>
{:else}
💧 Jetzt gießen
💧 Jetzt giessen
{/if}
</button>
</div>
{#if plant.wateringSchedule}
{#if wateringSchedule}
<div class="grid grid-cols-2 gap-4 mb-4">
<div>
<p class="text-sm text-muted-foreground">Zuletzt gegossen</p>
<p class="font-medium">{formatDate(plant.wateringSchedule.lastWateredAt)}</p>
<p class="font-medium">{formatDate(wateringSchedule.lastWateredAt)}</p>
</div>
<div>
<p class="text-sm text-muted-foreground">Nächstes Gießen</p>
<p class="font-medium">{formatDate(plant.wateringSchedule.nextWateringAt)}</p>
<p class="text-sm text-muted-foreground">Naechstes Giessen</p>
<p class="font-medium">{formatDate(wateringSchedule.nextWateringAt)}</p>
</div>
</div>
{/if}
{#if wateringHistory.length > 0}
<div class="border-t pt-4">
<p class="text-sm text-muted-foreground mb-2">Letzte Gießvorgänge</p>
<p class="text-sm text-muted-foreground mb-2">Letzte Giessvorgaenge</p>
<ul class="space-y-1">
{#each wateringHistory.slice(0, 5) as log (log.id)}
<li class="text-sm flex justify-between">
@ -222,9 +212,9 @@
<!-- Actions -->
<div class="flex gap-4">
<a href="/dashboard" class="btn flex-1 bg-muted text-foreground"> ← Zurück </a>
<a href="/dashboard" class="btn flex-1 bg-muted text-foreground"> Zurueck </a>
<button type="button" class="btn bg-destructive text-white" onclick={handleDelete}>
Löschen
Loeschen
</button>
</div>
</div>

View file

@ -0,0 +1,108 @@
/**
* Reactive Queries & Pure Filter Helpers for Questions
*
* 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 {
collectionCollection,
questionCollection,
answerCollection,
type LocalCollection,
type LocalQuestion,
type LocalAnswer,
} from './local-store';
import type { Collection, Question } from '$lib/types';
// ─── Type Converters ────────────────────────────────────────
/** Convert a LocalCollection (IndexedDB record) to the shared Collection type. */
export function toCollection(local: LocalCollection): Collection {
return {
id: local.id,
userId: 'local',
name: local.name,
description: local.description ?? undefined,
color: local.color,
icon: local.icon,
isDefault: local.isDefault,
sortOrder: local.sortOrder,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
/** Convert a LocalQuestion (IndexedDB record) to the shared Question type. */
export function toQuestion(local: LocalQuestion): Question {
return {
id: local.id,
userId: 'local',
collectionId: local.collectionId ?? undefined,
title: local.title,
description: local.description ?? undefined,
status: local.status,
priority: local.priority,
tags: local.tags ?? [],
researchDepth: local.researchDepth,
createdAt: local.createdAt ?? new Date().toISOString(),
updatedAt: local.updatedAt ?? new Date().toISOString(),
};
}
// ─── Live Query Hooks (call during component init) ──────────
/** All collections, sorted by sortOrder. Auto-updates on any change. */
export function useAllCollections() {
return useLiveQueryWithDefault(async () => {
const locals = await collectionCollection.getAll(undefined, {
sortBy: 'sortOrder',
sortDirection: 'asc',
});
return locals.map(toCollection);
}, [] as Collection[]);
}
/** All questions. Auto-updates on any change. */
export function useAllQuestions() {
return useLiveQueryWithDefault(async () => {
const locals = await questionCollection.getAll();
return locals.map(toQuestion);
}, [] as Question[]);
}
/** All answers for a given question. */
export function useAnswersByQuestion(questionId: string) {
return useLiveQueryWithDefault(async () => {
const locals = await answerCollection.getAll();
return locals.filter((a) => a.questionId === questionId);
}, [] as LocalAnswer[]);
}
// ─── Pure Filter Functions (for $derived) ───────────────────
/** Filter questions by collection ID. */
export function filterByCollection(questions: Question[], collectionId: string | null): Question[] {
if (!collectionId) return questions;
return questions.filter((q) => q.collectionId === collectionId);
}
/** Filter questions by status. */
export function filterByStatus(questions: Question[], status: string): Question[] {
if (!status) return questions;
return questions.filter((q) => q.status === status);
}
/** Filter questions by search query across title, description, and tags. */
export function searchQuestions(questions: Question[], query: string): Question[] {
if (!query.trim()) return questions;
const search = query.toLowerCase().trim();
return questions.filter(
(q) =>
q.title.toLowerCase().includes(search) ||
q.description?.toLowerCase().includes(search) ||
q.tags?.some((t: string) => t.toLowerCase().includes(search))
);
}

View file

@ -1,24 +1,21 @@
/**
* Collections Store - Manages collections state using Svelte 5 runes
* Authenticated users: collections from API
* Demo mode: static sample collection to showcase the app
* Collections Store Mutation-Only
*
* 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 { collectionsApi } from '$lib/api/collections';
import { collectionCollection, type LocalCollection } from '$lib/data/local-store';
import { toCollection } from '$lib/data/queries';
import { QuestionsEvents } from '@manacore/shared-utils/analytics';
import type { Collection, CreateCollectionDto, UpdateCollectionDto } from '$lib/types';
import { authStore } from './auth.svelte';
import { DEMO_COLLECTION, isDemoCollection } from '$lib/data/demo-questions';
let collections = $state<Collection[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let selectedId = $state<string | null>(null);
export const collectionsStore = {
get collections() {
return collections;
},
get loading() {
return loading;
},
@ -28,57 +25,28 @@ export const collectionsStore = {
get selectedId() {
return selectedId;
},
get selected() {
return selectedId ? collections.find((c) => c.id === selectedId) : null;
},
/**
* Load collections
* Demo mode: shows static sample collection
* Authenticated: fetches from API
*/
async load() {
loading = true;
error = null;
// Demo mode: load demo collection
if (!authStore.isAuthenticated) {
collections = [DEMO_COLLECTION];
loading = false;
return;
}
// Authenticated: fetch from API
try {
collections = await collectionsApi.getAll();
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load collections';
collections = [];
} finally {
loading = false;
}
},
/**
* Create a new collection
* Demo mode: returns auth_required error
* Authenticated: creates via API
* Create a new collection writes to IndexedDB instantly.
*/
async create(data: CreateCollectionDto): Promise<Collection | null> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to create collections';
return null;
}
loading = true;
error = null;
try {
const collection = await collectionsApi.create(data);
collections = [...collections, collection];
const newLocal: LocalCollection = {
id: crypto.randomUUID(),
name: data.name,
description: data.description ?? null,
color: data.color || '#6366f1',
icon: data.icon || 'folder',
isDefault: data.isDefault || false,
sortOrder: Date.now(),
};
const inserted = await collectionCollection.insert(newLocal);
QuestionsEvents.collectionCreated();
return collection;
return toCollection(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create collection';
return null;
@ -88,23 +56,22 @@ export const collectionsStore = {
},
/**
* Update a collection
* Demo mode: returns auth_required error
* Authenticated: updates via API
* Update a collection writes to IndexedDB instantly.
*/
async update(id: string, data: UpdateCollectionDto): Promise<Collection | null> {
// Demo collection or not authenticated: require authentication
if (isDemoCollection(id) || !authStore.isAuthenticated) {
error = 'Login required to update collections';
return null;
}
error = null;
try {
const updated = await collectionsApi.update(id, data);
collections = collections.map((c) => (c.id === id ? updated : c));
return updated;
const updateData: Partial<LocalCollection> = {};
if (data.name !== undefined) updateData.name = data.name;
if (data.description !== undefined) updateData.description = data.description ?? null;
if (data.color !== undefined) updateData.color = data.color;
if (data.icon !== undefined) updateData.icon = data.icon;
if (data.isDefault !== undefined) updateData.isDefault = data.isDefault;
const updated = await collectionCollection.update(id, updateData);
if (updated) return toCollection(updated);
return null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update collection';
return null;
@ -112,22 +79,13 @@ export const collectionsStore = {
},
/**
* Delete a collection
* Demo mode: returns auth_required error
* Authenticated: deletes via API
* Delete a collection removes from IndexedDB instantly.
*/
async delete(id: string): Promise<boolean> {
// Demo collection or not authenticated: require authentication
if (isDemoCollection(id) || !authStore.isAuthenticated) {
error = 'Login required to delete collections';
return false;
}
error = null;
try {
await collectionsApi.delete(id);
collections = collections.filter((c) => c.id !== id);
await collectionCollection.delete(id);
QuestionsEvents.collectionDeleted();
if (selectedId === id) {
selectedId = null;
@ -140,26 +98,15 @@ export const collectionsStore = {
},
/**
* Reorder collections
* Demo mode: returns auth_required error
* Authenticated: reorders via API
* Reorder collections updates sortOrder in IndexedDB.
*/
async reorder(orderedIds: string[]): Promise<boolean> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to reorder collections';
return false;
}
error = null;
try {
await collectionsApi.reorder(orderedIds);
// Reorder local state
const reordered = orderedIds
.map((id) => collections.find((c) => c.id === id))
.filter((c): c is Collection => c !== undefined);
collections = reordered;
for (let i = 0; i < orderedIds.length; i++) {
await collectionCollection.update(orderedIds[i], { sortOrder: i });
}
return true;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to reorder collections';
@ -171,19 +118,14 @@ export const collectionsStore = {
selectedId = id;
},
getById(id: string): Collection | undefined {
return collections.find((c) => c.id === id);
},
/**
* Check if a collection is a demo collection
* No longer relevant all collections are local and editable.
*/
isDemoCollection(id: string): boolean {
return isDemoCollection(id);
isDemoCollection(_id: string): boolean {
return false;
},
clear() {
collections = [];
error = null;
selectedId = null;
},

View file

@ -1,112 +1,49 @@
/**
* Questions Store - Manages questions state using Svelte 5 runes
* Authenticated users: questions from API
* Demo mode: static sample questions to showcase the app
* Questions Store Mutation-Only
*
* 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 { questionsApi, type QuestionFilters } from '$lib/api/questions';
import { questionCollection, type LocalQuestion } from '$lib/data/local-store';
import { toQuestion } from '$lib/data/queries';
import { QuestionsEvents } from '@manacore/shared-utils/analytics';
import type { Question, CreateQuestionDto, UpdateQuestionDto } from '$lib/types';
import { authStore } from './auth.svelte';
import { generateDemoQuestions, isDemoQuestion } from '$lib/data/demo-questions';
let questions = $state<Question[]>([]);
let loading = $state(false);
let error = $state<string | null>(null);
let total = $state(0);
let currentFilters = $state<QuestionFilters>({});
export const questionsStore = {
get questions() {
return questions;
},
get loading() {
return loading;
},
get error() {
return error;
},
get total() {
return total;
},
get filters() {
return currentFilters;
},
/**
* Load questions
* Demo mode: shows static sample questions
* Authenticated: fetches from API
*/
async load(filters?: QuestionFilters) {
loading = true;
error = null;
currentFilters = filters || {};
// Demo mode: load demo questions
if (!authStore.isAuthenticated) {
let demoQuestions = generateDemoQuestions();
// Apply filters
if (filters?.collectionId) {
demoQuestions = demoQuestions.filter(
(q: Question) => q.collectionId === filters.collectionId
);
}
if (filters?.status) {
demoQuestions = demoQuestions.filter((q: Question) => q.status === filters.status);
}
if (filters?.search) {
const search = filters.search.toLowerCase();
demoQuestions = demoQuestions.filter(
(q: Question) =>
q.title.toLowerCase().includes(search) ||
q.description?.toLowerCase().includes(search) ||
q.tags?.some((t: string) => t.toLowerCase().includes(search))
);
}
questions = demoQuestions;
total = demoQuestions.length;
loading = false;
return;
}
// Authenticated: fetch from API
try {
const response = await questionsApi.getAll(filters);
questions = response.data;
total = response.total;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to load questions';
questions = [];
total = 0;
} finally {
loading = false;
}
},
/**
* Create a new question
* Demo mode: returns auth_required error
* Authenticated: creates via API
* Create a new question writes to IndexedDB instantly.
*/
async create(data: CreateQuestionDto): Promise<Question | null> {
// Demo mode: require authentication
if (!authStore.isAuthenticated) {
error = 'Login required to create questions';
return null;
}
loading = true;
error = null;
try {
const question = await questionsApi.create(data);
questions = [question, ...questions];
total++;
const newLocal: LocalQuestion = {
id: crypto.randomUUID(),
collectionId: data.collectionId ?? null,
title: data.title,
description: data.description ?? null,
status: 'open',
priority: data.priority || 'normal',
tags: data.tags || [],
researchDepth: data.researchDepth || 'standard',
};
const inserted = await questionCollection.insert(newLocal);
QuestionsEvents.questionCreated(data.researchDepth || 'standard');
return question;
return toQuestion(inserted);
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to create question';
return null;
@ -116,23 +53,25 @@ export const questionsStore = {
},
/**
* Update a question
* Demo mode: returns auth_required error
* Authenticated: updates via API
* Update a question writes to IndexedDB instantly.
*/
async update(id: string, data: UpdateQuestionDto): Promise<Question | null> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to update questions';
return null;
}
error = null;
try {
const updated = await questionsApi.update(id, data);
questions = questions.map((q) => (q.id === id ? updated : q));
return updated;
const updateData: Partial<LocalQuestion> = {};
if (data.title !== undefined) updateData.title = data.title;
if (data.description !== undefined) updateData.description = data.description ?? null;
if (data.collectionId !== undefined) updateData.collectionId = data.collectionId ?? null;
if (data.tags !== undefined) updateData.tags = data.tags;
if (data.priority !== undefined)
updateData.priority = data.priority as LocalQuestion['priority'];
if (data.researchDepth !== undefined)
updateData.researchDepth = data.researchDepth as LocalQuestion['researchDepth'];
const updated = await questionCollection.update(id, updateData);
if (updated) return toQuestion(updated);
return null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update question';
return null;
@ -140,23 +79,13 @@ export const questionsStore = {
},
/**
* Delete a question
* Demo mode: returns auth_required error
* Authenticated: deletes via API
* Delete a question removes from IndexedDB instantly.
*/
async delete(id: string): Promise<boolean> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to delete questions';
return false;
}
error = null;
try {
await questionsApi.delete(id);
questions = questions.filter((q) => q.id !== id);
total--;
await questionCollection.delete(id);
QuestionsEvents.questionDeleted();
return true;
} catch (e) {
@ -166,44 +95,31 @@ export const questionsStore = {
},
/**
* Update question status
* Demo mode: returns auth_required error
* Authenticated: updates via API
* Update question status writes to IndexedDB instantly.
*/
async updateStatus(id: string, status: string): Promise<Question | null> {
// Demo question or not authenticated: require authentication
if (isDemoQuestion(id) || !authStore.isAuthenticated) {
error = 'Login required to update question status';
return null;
}
error = null;
try {
const updated = await questionsApi.updateStatus(id, status);
questions = questions.map((q) => (q.id === id ? updated : q));
return updated;
const updated = await questionCollection.update(id, {
status: status as LocalQuestion['status'],
});
if (updated) return toQuestion(updated);
return null;
} catch (e) {
error = e instanceof Error ? e.message : 'Failed to update status';
return null;
}
},
getById(id: string): Question | undefined {
return questions.find((q) => q.id === id);
},
/**
* Check if a question is a demo question
* No longer relevant all questions are local and editable.
*/
isDemoQuestion(id: string): boolean {
return isDemoQuestion(id);
isDemoQuestion(_id: string): boolean {
return false;
},
clear() {
questions = [];
total = 0;
error = null;
currentFilters = {};
},
};

View file

@ -5,12 +5,16 @@
import { browser } from '$app/environment';
import { locale } from 'svelte-i18n';
import { authStore, collectionsStore, questionsStore } from '$lib/stores';
import { apiClient } from '$lib/api/client';
import { questionsApi } from '$lib/api/questions';
import { theme } from '$lib/stores/theme';
import { AuthGate, GuestWelcomeModal } from '@manacore/shared-auth-ui';
import { shouldShowGuestWelcome } from '@manacore/shared-auth-ui';
import { questionsAppStore } from '$lib/data/local-store';
import {
useAllCollections,
useAllQuestions,
filterByCollection,
searchQuestions,
} from '$lib/data/queries';
import { PillNavigation, QuickInputBar, TagStrip } from '@manacore/shared-ui';
import type {
PillNavItem,
@ -30,6 +34,10 @@
const allTags = useAllSharedTags();
setContext('tags', allTags);
// Reactive live queries from IndexedDB
const allCollections = useAllCollections();
const allQuestions = useAllQuestions();
// App switcher items
const appItems = getPillAppItems('questions');
@ -59,10 +67,6 @@
const getToken = () => authStore.getValidToken();
questionsAppStore.startSync(getToken);
tagMutations.startSync(getToken);
const token = await authStore.getValidToken();
apiClient.setAccessToken(token);
await collectionsStore.load();
await questionsStore.load();
}
if (!authStore.isAuthenticated && shouldShowGuestWelcome('questions')) {
showGuestWelcome = true;
@ -93,31 +97,16 @@
}
}
// InputBar search - search questions
// InputBar search - search questions from liveQuery data
async function handleSearch(query: string): Promise<QuickInputItem[]> {
if (!query.trim()) return [];
// Demo mode: search from store
if (!authStore.isAuthenticated) {
await questionsStore.load({ search: query });
return questionsStore.questions.slice(0, 10).map((q) => ({
id: q.id,
title: q.title,
subtitle: q.status || 'pending',
}));
}
// Authenticated: search via API
try {
const response = await questionsApi.getAll({ search: query, limit: 10 });
return response.data.map((q) => ({
id: q.id,
title: q.title,
subtitle: q.status || 'pending',
}));
} catch {
return [];
}
const results = searchQuestions(allQuestions.value, query);
return results.slice(0, 10).map((q) => ({
id: q.id,
title: q.title,
subtitle: q.status || 'pending',
}));
}
function handleSelect(item: QuickInputItem) {
@ -148,7 +137,7 @@
}
}
// Collection dropdown items
// Collection dropdown items — driven by liveQuery
let collectionItems = $derived<PillDropdownItem[]>([
{
id: 'all',
@ -157,7 +146,7 @@
onClick: () => selectCollection(null),
active: !collectionsStore.selectedId,
},
...collectionsStore.collections.map((c) => ({
...allCollections.value.map((c) => ({
id: c.id,
label: c.name,
icon: 'folder',
@ -168,18 +157,12 @@
let currentCollectionLabel = $derived(
collectionsStore.selectedId
? collectionsStore.collections.find((c) => c.id === collectionsStore.selectedId)?.name ||
'Collection'
? allCollections.value.find((c) => c.id === collectionsStore.selectedId)?.name || 'Collection'
: 'All Questions'
);
function selectCollection(id: string | null) {
collectionsStore.select(id);
if (id) {
questionsStore.load({ collectionId: id });
} else {
questionsStore.load();
}
}
// TagStrip visibility

View file

@ -1,6 +1,6 @@
<script lang="ts">
import { questionsStore, collectionsStore } from '$lib/stores';
import { QuestionSkeleton, ErrorAlert } from '$lib/components';
import { collectionsStore } from '$lib/stores';
import { ErrorAlert } from '$lib/components';
import {
MagnifyingGlass,
Funnel,
@ -10,10 +10,36 @@
Archive,
} from '@manacore/shared-icons';
import type { QuestionStatus, ResearchDepth } from '$lib/types';
import {
useAllQuestions,
useAllCollections,
filterByCollection,
filterByStatus,
searchQuestions,
} from '$lib/data/queries';
// Live queries — auto-update on IndexedDB changes
const allQuestions = useAllQuestions();
const allCollections = useAllCollections();
let searchQuery = $state('');
let statusFilter = $state<QuestionStatus | ''>('');
// Derived filtered list — pure functions over liveQuery data
let filteredQuestions = $derived.by(() => {
let result = allQuestions.value;
result = filterByCollection(result, collectionsStore.selectedId);
if (statusFilter) result = filterByStatus(result, statusFilter);
if (searchQuery) result = searchQuestions(result, searchQuery);
return result;
});
let selectedCollection = $derived(
collectionsStore.selectedId
? allCollections.value.find((c) => c.id === collectionsStore.selectedId)
: null
);
const statusIcons = {
open: { icon: Clock, color: 'text-gray-500' },
researching: { icon: CircleNotch, color: 'text-blue-500' },
@ -27,14 +53,6 @@
deep: 'Deep',
};
async function handleSearch() {
const filters: Record<string, unknown> = {};
if (searchQuery) filters.search = searchQuery;
if (statusFilter) filters.status = statusFilter;
if (collectionsStore.selectedId) filters.collectionId = collectionsStore.selectedId;
await questionsStore.load(filters);
}
function formatDate(dateString: string): string {
const date = new Date(dateString);
const now = new Date();
@ -57,10 +75,10 @@
<!-- Header -->
<div class="mb-6">
<h1 class="text-2xl font-bold text-foreground">
{collectionsStore.selected ? collectionsStore.selected.name : 'All Questions'}
{selectedCollection ? selectedCollection.name : 'All Questions'}
</h1>
<p class="mt-1 text-muted-foreground">
{questionsStore.total} question{questionsStore.total !== 1 ? 's' : ''}
{filteredQuestions.length} question{filteredQuestions.length !== 1 ? 's' : ''}
</p>
</div>
@ -73,7 +91,6 @@
<input
type="text"
bind:value={searchQuery}
onkeyup={(e) => e.key === 'Enter' && handleSearch()}
placeholder="Search questions..."
class="w-full rounded-lg border border-border bg-background py-2 pl-10 pr-4 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
/>
@ -81,7 +98,6 @@
<select
bind:value={statusFilter}
onchange={handleSearch}
class="rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value="">All Status</option>
@ -90,31 +106,17 @@
<option value="answered">Answered</option>
<option value="archived">Archived</option>
</select>
<button
onclick={handleSearch}
class="flex items-center gap-2 rounded-lg bg-secondary px-4 py-2 text-foreground hover:bg-secondary-hover"
>
<Funnel class="h-5 w-5" />
<span>Filter</span>
</button>
</div>
<!-- Error -->
{#if questionsStore.error}
{#if collectionsStore.error}
<div class="mb-6">
<ErrorAlert
message={questionsStore.error}
onRetry={() => questionsStore.load(questionsStore.filters)}
onDismiss={() => {}}
/>
<ErrorAlert message={collectionsStore.error} onRetry={() => {}} onDismiss={() => {}} />
</div>
{/if}
<!-- Questions List -->
{#if questionsStore.loading}
<QuestionSkeleton count={5} />
{:else if questionsStore.questions.length === 0}
{#if filteredQuestions.length === 0}
<div class="py-12 text-center">
<div class="mb-4 text-6xl">🤔</div>
<h2 class="mb-2 text-xl font-semibold text-foreground">No questions yet</h2>
@ -130,7 +132,7 @@
</div>
{:else}
<div class="space-y-3">
{#each questionsStore.questions as question}
{#each filteredQuestions as question}
{@const StatusIcon = statusIcons[question.status]?.icon || Clock}
{@const statusColor = statusIcons[question.status]?.color || 'text-gray-500'}

View file

@ -10,6 +10,10 @@
DotsSixVertical,
} from '@manacore/shared-icons';
import type { Collection } from '$lib/types';
import { useAllCollections } from '$lib/data/queries';
// Live query — auto-updates on IndexedDB changes
const allCollections = useAllCollections();
let showModal = $state(false);
let editingCollection = $state<Collection | null>(null);
@ -66,7 +70,7 @@
</div>
<!-- Collections List -->
{#if collectionsStore.collections.length === 0}
{#if allCollections.value.length === 0}
<div class="rounded-xl border border-dashed border-border p-8 text-center">
<div class="mb-4 text-4xl">📁</div>
<h2 class="mb-2 text-lg font-semibold text-foreground">No collections yet</h2>
@ -83,7 +87,7 @@
</div>
{:else}
<div class="space-y-3">
{#each collectionsStore.collections as collection}
{#each allCollections.value as collection}
<div
class="flex items-center gap-4 rounded-xl border border-border bg-card p-4 transition-all hover:border-primary/50"
>

View file

@ -4,6 +4,10 @@
import { researchApi } from '$lib/api/research';
import { ArrowLeft, Lightning, Clock, Sparkle } from '@manacore/shared-icons';
import type { ResearchDepth, QuestionPriority } from '$lib/types';
import { useAllCollections } from '$lib/data/queries';
// Live query for collections dropdown
const allCollections = useAllCollections();
let title = $state('');
let description = $state('');
@ -129,7 +133,7 @@
class="w-full rounded-lg border border-border bg-background px-4 py-2 text-foreground focus:border-primary focus:outline-none focus:ring-2 focus:ring-primary/20"
>
<option value={undefined}>No collection</option>
{#each collectionsStore.collections as collection}
{#each allCollections.value as collection}
<option value={collection.id}>{collection.name}</option>
{/each}
</select>