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

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

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

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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