veza/apps/web/src/stores/library.ts

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,
},
),
);