mirror of
https://github.com/Memo-2023/mana-monorepo.git
synced 2026-05-14 19:01: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
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"
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue