mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 18:41:08 +02:00
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:
parent
4ff3ceb01a
commit
924c15277a
46 changed files with 1825 additions and 1547 deletions
67
apps/citycorners/apps/web/src/lib/data/queries.ts
Normal file
67
apps/citycorners/apps/web/src/lib/data/queries.ts
Normal 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)
|
||||
);
|
||||
}
|
||||
|
|
@ -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
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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();
|
||||
}
|
||||
});
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
157
apps/mukke/apps/web/src/lib/data/queries.ts
Normal file
157
apps/mukke/apps/web/src/lib/data/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
}
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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() {
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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),
|
||||
},
|
||||
];
|
||||
}
|
||||
|
|
|
|||
110
apps/photos/apps/web/src/lib/data/queries.ts
Normal file
110
apps/photos/apps/web/src/lib/data/queries.ts
Normal 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));
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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 };
|
||||
|
|
|
|||
|
|
@ -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;
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -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={() => {}}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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">
|
||||
|
|
|
|||
|
|
@ -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) {
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
155
apps/picture/apps/web/src/lib/data/queries.ts
Normal file
155
apps/picture/apps/web/src/lib/data/queries.ts
Normal 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);
|
||||
}
|
||||
|
|
@ -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.
|
||||
|
|
|
|||
|
|
@ -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
|
||||
)
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -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);
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
||||
|
|
|
|||
|
|
@ -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}
|
||||
|
|
|
|||
|
|
@ -31,7 +31,7 @@
|
|||
pageTitle="Wähle dein Abo"
|
||||
subscriptionsTitle="Abonnements"
|
||||
packagesTitle="Einmal-Pakete"
|
||||
yearlyDiscount="2 Monate gratis"
|
||||
yearlyDiscount="20% Rabatt"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -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(
|
||||
|
|
|
|||
148
apps/planta/apps/web/src/lib/data/mutations.ts
Normal file
148
apps/planta/apps/web/src/lib/data/mutations.ts
Normal 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;
|
||||
}
|
||||
},
|
||||
};
|
||||
180
apps/planta/apps/web/src/lib/data/queries.ts
Normal file
180
apps/planta/apps/web/src/lib/data/queries.ts
Normal 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;
|
||||
}
|
||||
|
|
@ -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}`);
|
||||
}
|
||||
}
|
||||
|
||||
|
|
|
|||
|
|
@ -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,
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
108
apps/questions/apps/web/src/lib/data/queries.ts
Normal file
108
apps/questions/apps/web/src/lib/data/queries.ts
Normal 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))
|
||||
);
|
||||
}
|
||||
|
|
@ -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;
|
||||
},
|
||||
|
|
|
|||
|
|
@ -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 = {};
|
||||
},
|
||||
};
|
||||
|
|
|
|||
|
|
@ -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
|
||||
|
|
|
|||
|
|
@ -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'}
|
||||
|
||||
|
|
|
|||
|
|
@ -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"
|
||||
>
|
||||
|
|
|
|||
|
|
@ -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>
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue