349 lines
10 KiB
TypeScript
349 lines
10 KiB
TypeScript
import { create } from 'zustand';
|
|
import { persist, devtools } from 'zustand/middleware';
|
|
import { apiClient } from '@/services/api/client';
|
|
import { undoRedo } from '@/utils/undoRedo';
|
|
import { stateMiddleware } from '@/utils/stateMiddleware';
|
|
import {
|
|
normalize,
|
|
addToNormalized,
|
|
updateInNormalized,
|
|
removeFromNormalized,
|
|
createEmptyNormalized,
|
|
type NormalizedState,
|
|
} from '@/utils/stateNormalization';
|
|
import type { LibraryItem, PaginatedResponse, ApiError } from '@/types';
|
|
import type { WithUndoRedo } from './types';
|
|
|
|
// FE-TYPE-011: Fully typed store interfaces
|
|
export interface LibraryState {
|
|
// FE-STATE-009: Normalized state for better performance
|
|
items: NormalizedState<LibraryItem>;
|
|
favorites: NormalizedState<LibraryItem>;
|
|
isLoading: boolean;
|
|
error: ApiError | null;
|
|
pagination: {
|
|
page: number;
|
|
limit: number;
|
|
total: number;
|
|
hasNext: boolean;
|
|
hasPrev: boolean;
|
|
};
|
|
filters: {
|
|
type?: string;
|
|
search?: string;
|
|
};
|
|
}
|
|
|
|
export interface LibraryActions {
|
|
fetchItems: (params?: {
|
|
page?: number;
|
|
limit?: number;
|
|
type?: string;
|
|
search?: string;
|
|
}) => Promise<void>;
|
|
fetchFavorites: () => Promise<void>;
|
|
uploadFile: (
|
|
file: File,
|
|
metadata: { title: string; description?: string },
|
|
) => Promise<void>;
|
|
toggleFavorite: (itemId: string) => Promise<void>;
|
|
deleteItem: (itemId: string) => Promise<void>;
|
|
setFilters: (filters: { type?: string; search?: string }) => void;
|
|
setLoading: (loading: boolean) => void;
|
|
setError: (error: ApiError | null) => void;
|
|
clearItems: () => void;
|
|
}
|
|
|
|
// FE-TYPE-011: Export store type for reuse
|
|
export type LibraryStore = WithUndoRedo<LibraryState & LibraryActions>;
|
|
|
|
export const useLibraryStore = create<LibraryStore>()(
|
|
devtools(
|
|
persist(
|
|
undoRedo(
|
|
stateMiddleware(
|
|
(set, get) => ({
|
|
// État initial - FE-STATE-009: Normalized state
|
|
items: createEmptyNormalized<LibraryItem>(),
|
|
favorites: createEmptyNormalized<LibraryItem>(),
|
|
isLoading: false,
|
|
error: null,
|
|
pagination: {
|
|
page: 1,
|
|
limit: 20,
|
|
total: 0,
|
|
hasNext: false,
|
|
hasPrev: false,
|
|
},
|
|
filters: {},
|
|
|
|
// Actions
|
|
fetchItems: async (params = {}) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const {
|
|
page = 1,
|
|
limit = 20,
|
|
type,
|
|
} = { ...get().pagination, ...get().filters, ...params };
|
|
|
|
const response = await apiClient.get<PaginatedResponse<LibraryItem>>('/tracks', {
|
|
params: {
|
|
page,
|
|
limit,
|
|
type,
|
|
},
|
|
});
|
|
|
|
// apiClient unwrap déjà le format { success, data }, donc response.data contient directement la réponse
|
|
const data = response.data;
|
|
// Sécuriser response.data pour s'assurer que c'est toujours un tableau
|
|
const itemsArray = Array.isArray(data.data)
|
|
? data.data
|
|
: Array.isArray(data)
|
|
? data
|
|
: [];
|
|
|
|
// FE-STATE-009: Normalize items for better performance
|
|
set({
|
|
items: normalize(itemsArray),
|
|
pagination: {
|
|
page: data.page || 1,
|
|
limit: data.limit || limit,
|
|
total: data.total || 0,
|
|
hasNext: data.has_next || false,
|
|
hasPrev: data.has_prev || false,
|
|
},
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
} catch (error: any) {
|
|
// En cas d'erreur, s'assurer que items reste un état normalisé vide
|
|
set({
|
|
items: createEmptyNormalized<LibraryItem>(),
|
|
error: error as ApiError,
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
fetchFavorites: async () => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const response = await apiClient.get<PaginatedResponse<LibraryItem>>('/tracks', {
|
|
params: {
|
|
page: 1,
|
|
limit: 100,
|
|
type: 'favorites',
|
|
},
|
|
});
|
|
|
|
// apiClient unwrap déjà le format { success, data }, donc response.data contient directement la réponse
|
|
const data = response.data;
|
|
// Sécuriser response.data pour s'assurer que c'est toujours un tableau
|
|
const favoritesArray = Array.isArray(data.data)
|
|
? data.data
|
|
: Array.isArray(data)
|
|
? data
|
|
: [];
|
|
|
|
// FE-STATE-009: Normalize favorites for better performance
|
|
set({
|
|
favorites: normalize(favoritesArray),
|
|
isLoading: false,
|
|
error: null,
|
|
});
|
|
} catch (error: any) {
|
|
// En cas d'erreur, s'assurer que favorites reste un état normalisé vide
|
|
set({
|
|
favorites: createEmptyNormalized<LibraryItem>(),
|
|
error: error as ApiError,
|
|
isLoading: false,
|
|
});
|
|
}
|
|
},
|
|
|
|
uploadFile: async (file, metadata) => {
|
|
set({ isLoading: true, error: null });
|
|
try {
|
|
const formData = new FormData();
|
|
formData.append('file', file);
|
|
formData.append('title', metadata.title);
|
|
if (metadata.description) {
|
|
formData.append('description', metadata.description);
|
|
}
|
|
|
|
const response = await apiClient.post<LibraryItem>('/tracks', formData, {
|
|
headers: {
|
|
'Content-Type': 'multipart/form-data',
|
|
},
|
|
});
|
|
const newItem = response.data;
|
|
|
|
// FE-STATE-009: Add to normalized state at position 0 (prepend)
|
|
set((state) => ({
|
|
items: addToNormalized(state.items, newItem, 0),
|
|
isLoading: false,
|
|
error: null,
|
|
}));
|
|
} catch (error: any) {
|
|
set({
|
|
error: error as ApiError,
|
|
isLoading: false,
|
|
});
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// FE-STATE-005: Optimistic update for toggle favorite
|
|
// FE-STATE-009: Using normalized state operations
|
|
toggleFavorite: async (itemId: string) => {
|
|
const previousState = get();
|
|
const item = previousState.items.byId[itemId];
|
|
const newFavoriteStatus = item ? !item.is_favorite : true;
|
|
|
|
// Optimistic update - FE-STATE-009: Update in normalized state
|
|
set((state) => {
|
|
const updatedItems = updateInNormalized(state.items, itemId, {
|
|
is_favorite: newFavoriteStatus,
|
|
});
|
|
|
|
let updatedFavorites = state.favorites;
|
|
if (newFavoriteStatus && item) {
|
|
// Add to favorites
|
|
const favoriteItem = { ...item, is_favorite: true };
|
|
updatedFavorites = addToNormalized(
|
|
removeFromNormalized(state.favorites, itemId),
|
|
favoriteItem,
|
|
);
|
|
} else {
|
|
// Remove from favorites
|
|
updatedFavorites = removeFromNormalized(state.favorites, itemId);
|
|
}
|
|
|
|
return {
|
|
items: updatedItems,
|
|
favorites: updatedFavorites,
|
|
};
|
|
});
|
|
|
|
try {
|
|
const response = await apiClient.post<LibraryItem>(`/tracks/${itemId}/favorite`);
|
|
const updatedItem = response.data;
|
|
|
|
// Apply server response - FE-STATE-009: Update in normalized state
|
|
set((state) => {
|
|
const updatedItems = updateInNormalized(state.items, itemId, {
|
|
is_favorite: updatedItem.is_favorite,
|
|
});
|
|
|
|
let updatedFavorites = state.favorites;
|
|
if (updatedItem.is_favorite) {
|
|
updatedFavorites = addToNormalized(
|
|
removeFromNormalized(state.favorites, itemId),
|
|
updatedItem,
|
|
);
|
|
} else {
|
|
updatedFavorites = removeFromNormalized(state.favorites, itemId);
|
|
}
|
|
|
|
return {
|
|
items: updatedItems,
|
|
favorites: updatedFavorites,
|
|
};
|
|
});
|
|
} catch (error: any) {
|
|
// Rollback on error
|
|
set(() => previousState);
|
|
set({ error: error as ApiError });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
// FE-STATE-005: Optimistic update for delete item
|
|
// FE-STATE-009: Using normalized state operations
|
|
deleteItem: async (itemId: string) => {
|
|
const previousState = get();
|
|
|
|
// Optimistic update - remove item immediately from normalized state
|
|
set((state) => ({
|
|
items: removeFromNormalized(state.items, itemId),
|
|
favorites: removeFromNormalized(state.favorites, itemId),
|
|
}));
|
|
|
|
try {
|
|
// Appel API pour supprimer l'élément
|
|
await apiClient.delete(`/tracks/${itemId}`);
|
|
// No need to update state - optimistic update is final
|
|
} catch (error: any) {
|
|
// Rollback on error
|
|
set(() => previousState);
|
|
set({ error: error as ApiError });
|
|
throw error;
|
|
}
|
|
},
|
|
|
|
setFilters: (filters) => {
|
|
set({ filters });
|
|
// Recharger les éléments avec les nouveaux filtres
|
|
get().fetchItems({ page: 1 });
|
|
},
|
|
|
|
setLoading: (isLoading) => set({ isLoading }),
|
|
|
|
setError: (error) => set({ error }),
|
|
|
|
clearItems: () => {
|
|
set({
|
|
items: createEmptyNormalized<LibraryItem>(),
|
|
favorites: createEmptyNormalized<LibraryItem>(),
|
|
pagination: {
|
|
page: 1,
|
|
limit: 20,
|
|
total: 0,
|
|
hasNext: false,
|
|
hasPrev: false,
|
|
},
|
|
});
|
|
},
|
|
}),
|
|
{
|
|
// FE-STATE-010: Enable state middleware for logging, analytics, error handling
|
|
storeName: 'LibraryStore',
|
|
enableLogging: import.meta.env.DEV,
|
|
enableAnalytics: true,
|
|
enableErrorHandling: true,
|
|
},
|
|
),
|
|
{
|
|
// FE-STATE-006: Enable undo/redo for library store
|
|
maxHistorySize: 50,
|
|
enabled: true,
|
|
shouldTrack: (state, prevState) => {
|
|
// Track changes to items and favorites (not loading/error states)
|
|
// FE-STATE-009: Compare normalized state structures
|
|
return (
|
|
JSON.stringify(state.items) !== JSON.stringify(prevState?.items) ||
|
|
JSON.stringify(state.favorites) !== JSON.stringify(prevState?.favorites)
|
|
);
|
|
},
|
|
},
|
|
),
|
|
{
|
|
name: 'library-storage',
|
|
partialize: (state) => ({
|
|
// FE-STATE-001: Persist favorites and filters for offline support
|
|
// FE-STATE-009: Persist normalized favorites state
|
|
favorites: state.favorites,
|
|
filters: state.filters,
|
|
// Don't persist items and pagination as they should be fetched fresh
|
|
}),
|
|
},
|
|
),
|
|
{
|
|
// FE-STATE-007: Enable Redux DevTools for debugging
|
|
name: 'LibraryStore',
|
|
enabled: import.meta.env.DEV,
|
|
},
|
|
),
|
|
);
|