From f0e1bbc7f8582407667924c8335c2c9957d43574 Mon Sep 17 00:00:00 2001 From: Boris Cherepanov Date: Mon, 23 Mar 2026 15:09:39 +0300 Subject: [PATCH] feat: added redux --- furumi-node-player/client/package-lock.json | 115 +++++++++++++++++- furumi-node-player/client/package.json | 4 +- furumi-node-player/client/src/main.tsx | 6 +- furumi-node-player/client/src/store/index.ts | 21 ++++ .../src/store/slices/albumTracksSlice.ts | 54 ++++++++ .../client/src/store/slices/albumsSlice.ts | 54 ++++++++ .../client/src/store/slices/artistsSlice.ts | 54 ++++++++ .../src/store/slices/trackDetailSlice.ts | 57 +++++++++ 8 files changed, 360 insertions(+), 5 deletions(-) create mode 100644 furumi-node-player/client/src/store/index.ts create mode 100644 furumi-node-player/client/src/store/slices/albumTracksSlice.ts create mode 100644 furumi-node-player/client/src/store/slices/albumsSlice.ts create mode 100644 furumi-node-player/client/src/store/slices/artistsSlice.ts create mode 100644 furumi-node-player/client/src/store/slices/trackDetailSlice.ts diff --git a/furumi-node-player/client/package-lock.json b/furumi-node-player/client/package-lock.json index 3ebe2c3..6286599 100644 --- a/furumi-node-player/client/package-lock.json +++ b/furumi-node-player/client/package-lock.json @@ -8,9 +8,11 @@ "name": "client", "version": "0.0.0", "dependencies": { + "@reduxjs/toolkit": "^2.11.2", "axios": "^1.7.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-redux": "^9.2.0" }, "devDependencies": { "@eslint/js": "^9.39.4", @@ -587,6 +589,32 @@ "url": "https://github.com/sponsors/Boshen" } }, + "node_modules/@reduxjs/toolkit": { + "version": "2.11.2", + "resolved": "https://registry.npmjs.org/@reduxjs/toolkit/-/toolkit-2.11.2.tgz", + "integrity": "sha512-Kd6kAHTA6/nUpp8mySPqj3en3dm0tdMIgbttnQ1xFMVpufoj+ADi8pXLBsd4xzTRHQa7t/Jv8W5UnCuW4kuWMQ==", + "license": "MIT", + "dependencies": { + "@standard-schema/spec": "^1.0.0", + "@standard-schema/utils": "^0.3.0", + "immer": "^11.0.0", + "redux": "^5.0.1", + "redux-thunk": "^3.1.0", + "reselect": "^5.1.0" + }, + "peerDependencies": { + "react": "^16.9.0 || ^17.0.0 || ^18 || ^19", + "react-redux": "^7.2.1 || ^8.1.3 || ^9.0.0" + }, + "peerDependenciesMeta": { + "react": { + "optional": true + }, + "react-redux": { + "optional": true + } + } + }, "node_modules/@rolldown/binding-android-arm64": { "version": "1.0.0-rc.10", "resolved": "https://registry.npmjs.org/@rolldown/binding-android-arm64/-/binding-android-arm64-1.0.0-rc.10.tgz", @@ -849,6 +877,18 @@ "dev": true, "license": "MIT" }, + "node_modules/@standard-schema/spec": { + "version": "1.1.0", + "resolved": "https://registry.npmjs.org/@standard-schema/spec/-/spec-1.1.0.tgz", + "integrity": "sha512-l2aFy5jALhniG5HgqrD6jXLi/rUWrKvqN/qJx6yoJsgKhblVd+iqqU4RCXavm/jPityDo5TCvKMnpjKnOriy0w==", + "license": "MIT" + }, + "node_modules/@standard-schema/utils": { + "version": "0.3.0", + "resolved": "https://registry.npmjs.org/@standard-schema/utils/-/utils-0.3.0.tgz", + "integrity": "sha512-e7Mew686owMaPJVNNLs55PUvgz371nKgwsc4vxE49zsODpJEnxgxRo2y/OKrqueavXgZNMDVj3DdHFlaSAeU8g==", + "license": "MIT" + }, "node_modules/@tybys/wasm-util": { "version": "0.10.1", "resolved": "https://registry.npmjs.org/@tybys/wasm-util/-/wasm-util-0.10.1.tgz", @@ -888,7 +928,7 @@ "version": "19.2.14", "resolved": "https://registry.npmjs.org/@types/react/-/react-19.2.14.tgz", "integrity": "sha512-ilcTH/UniCkMdtexkoCN0bI7pMcJDvmQFPvuPvmEaYA/NSfFTAgdUSLAoVjaRJm7+6PvcM+q1zYOwS4wTYMF9w==", - "dev": true, + "devOptional": true, "license": "MIT", "dependencies": { "csstype": "^3.2.2" @@ -904,6 +944,12 @@ "@types/react": "^19.2.0" } }, + "node_modules/@types/use-sync-external-store": { + "version": "0.0.6", + "resolved": "https://registry.npmjs.org/@types/use-sync-external-store/-/use-sync-external-store-0.0.6.tgz", + "integrity": "sha512-zFDAD+tlpf2r4asuHEj0XH6pY6i0g5NeAHPn+15wk3BV6JA69eERFXC1gyGThDkVa1zCyKr5jox1+2LbV/AMLg==", + "license": "MIT" + }, "node_modules/@typescript-eslint/eslint-plugin": { "version": "8.57.1", "resolved": "https://registry.npmjs.org/@typescript-eslint/eslint-plugin/-/eslint-plugin-8.57.1.tgz", @@ -1496,7 +1542,7 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "dev": true, + "devOptional": true, "license": "MIT" }, "node_modules/debug": { @@ -2127,6 +2173,16 @@ "node": ">= 4" } }, + "node_modules/immer": { + "version": "11.1.4", + "resolved": "https://registry.npmjs.org/immer/-/immer-11.1.4.tgz", + "integrity": "sha512-XREFCPo6ksxVzP4E0ekD5aMdf8WMwmdNaz6vuvxgI40UaEiu6q3p8X52aU6GdyvLY3XXX/8R7JOTXStz/nBbRw==", + "license": "MIT", + "funding": { + "type": "opencollective", + "url": "https://opencollective.com/immer" + } + }, "node_modules/import-fresh": { "version": "3.3.1", "resolved": "https://registry.npmjs.org/import-fresh/-/import-fresh-3.3.1.tgz", @@ -2831,6 +2887,50 @@ "react": "^19.2.4" } }, + "node_modules/react-redux": { + "version": "9.2.0", + "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", + "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", + "license": "MIT", + "dependencies": { + "@types/use-sync-external-store": "^0.0.6", + "use-sync-external-store": "^1.4.0" + }, + "peerDependencies": { + "@types/react": "^18.2.25 || ^19", + "react": "^18.0 || ^19", + "redux": "^5.0.0" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "redux": { + "optional": true + } + } + }, + "node_modules/redux": { + "version": "5.0.1", + "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", + "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", + "license": "MIT" + }, + "node_modules/redux-thunk": { + "version": "3.1.0", + "resolved": "https://registry.npmjs.org/redux-thunk/-/redux-thunk-3.1.0.tgz", + "integrity": "sha512-NW2r5T6ksUKXCabzhL9z+h206HQw/NJkcLm1GPImRQ8IzfXwRGqjVhKJGauHirT0DAuyy6hjdnMZaRoAcy0Klw==", + "license": "MIT", + "peerDependencies": { + "redux": "^5.0.0" + } + }, + "node_modules/reselect": { + "version": "5.1.1", + "resolved": "https://registry.npmjs.org/reselect/-/reselect-5.1.1.tgz", + "integrity": "sha512-K/BG6eIky/SBpzfHZv/dd+9JBFiS4SWV7FIujVyJRux6e45+73RaUHXLmIR1f7WOMaQ0U1km6qwklRQxpJJY0w==", + "license": "MIT" + }, "node_modules/resolve-from": { "version": "4.0.0", "resolved": "https://registry.npmjs.org/resolve-from/-/resolve-from-4.0.0.tgz", @@ -3094,6 +3194,15 @@ "punycode": "^2.1.0" } }, + "node_modules/use-sync-external-store": { + "version": "1.6.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.6.0.tgz", + "integrity": "sha512-Pp6GSwGP/NrPIrxVFAIkOQeyw8lFenOHijQWkUTrDvrF4ALqylP2C/KCkeS9dpUM3KvYRQhna5vt7IL95+ZQ9w==", + "license": "MIT", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/vite": { "version": "8.0.1", "resolved": "https://registry.npmjs.org/vite/-/vite-8.0.1.tgz", diff --git a/furumi-node-player/client/package.json b/furumi-node-player/client/package.json index d608129..9d6d481 100644 --- a/furumi-node-player/client/package.json +++ b/furumi-node-player/client/package.json @@ -10,9 +10,11 @@ "preview": "vite preview" }, "dependencies": { + "@reduxjs/toolkit": "^2.11.2", "axios": "^1.7.9", "react": "^19.2.4", - "react-dom": "^19.2.4" + "react-dom": "^19.2.4", + "react-redux": "^9.2.0" }, "devDependencies": { "@eslint/js": "^9.39.4", diff --git a/furumi-node-player/client/src/main.tsx b/furumi-node-player/client/src/main.tsx index bef5202..9d4c1bf 100644 --- a/furumi-node-player/client/src/main.tsx +++ b/furumi-node-player/client/src/main.tsx @@ -1,10 +1,14 @@ import { StrictMode } from 'react' import { createRoot } from 'react-dom/client' +import { Provider } from 'react-redux' +import { store } from './store' import './index.css' import App from './App.tsx' createRoot(document.getElementById('root')!).render( - + + + , ) diff --git a/furumi-node-player/client/src/store/index.ts b/furumi-node-player/client/src/store/index.ts new file mode 100644 index 0000000..c10bc40 --- /dev/null +++ b/furumi-node-player/client/src/store/index.ts @@ -0,0 +1,21 @@ +import { configureStore } from '@reduxjs/toolkit' +import { useDispatch, useSelector, type TypedUseSelectorHook } from 'react-redux' +import artistsReducer from './slices/artistsSlice' +import albumsReducer from './slices/albumsSlice' +import albumTracksReducer from './slices/albumTracksSlice' +import trackDetailReducer from './slices/trackDetailSlice' + +export const store = configureStore({ + reducer: { + artists: artistsReducer, + albums: albumsReducer, + albumTracks: albumTracksReducer, + trackDetail: trackDetailReducer, + }, +}) + +export type RootState = ReturnType +export type AppDispatch = typeof store.dispatch + +export const useAppDispatch = useDispatch.withTypes() +export const useAppSelector: TypedUseSelectorHook = useSelector diff --git a/furumi-node-player/client/src/store/slices/albumTracksSlice.ts b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts new file mode 100644 index 0000000..8ede869 --- /dev/null +++ b/furumi-node-player/client/src/store/slices/albumTracksSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Track } from '../../types' +import { getAlbumTracks } from '../../furumiApi' + +export const fetchAlbumTracks = createAsyncThunk( + 'albumTracks/fetch', + async (albumSlug: string, { rejectWithValue }) => { + const data = await getAlbumTracks(albumSlug) + if (data === null) return rejectWithValue('Failed to fetch album tracks') + return { albumSlug, tracks: data } + }, +) + +interface AlbumTracksState { + byAlbum: Record + loading: boolean + error: string | null +} + +const initialState: AlbumTracksState = { + byAlbum: {}, + loading: false, + error: null, +} + +const albumTracksSlice = createSlice({ + name: 'albumTracks', + initialState, + reducers: { + clearAlbumTracks(state) { + state.byAlbum = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchAlbumTracks.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchAlbumTracks.fulfilled, (state, action) => { + state.loading = false + state.byAlbum[action.payload.albumSlug] = action.payload.tracks + state.error = null + }) + .addCase(fetchAlbumTracks.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearAlbumTracks } = albumTracksSlice.actions +export default albumTracksSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/albumsSlice.ts b/furumi-node-player/client/src/store/slices/albumsSlice.ts new file mode 100644 index 0000000..386c3f5 --- /dev/null +++ b/furumi-node-player/client/src/store/slices/albumsSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Album } from '../../types' +import { getArtistAlbums } from '../../furumiApi' + +export const fetchArtistAlbums = createAsyncThunk( + 'albums/fetchByArtist', + async (artistSlug: string, { rejectWithValue }) => { + const data = await getArtistAlbums(artistSlug) + if (data === null) return rejectWithValue('Failed to fetch albums') + return { artistSlug, albums: data } + }, +) + +interface AlbumsState { + byArtist: Record + loading: boolean + error: string | null +} + +const initialState: AlbumsState = { + byArtist: {}, + loading: false, + error: null, +} + +const albumsSlice = createSlice({ + name: 'albums', + initialState, + reducers: { + clearAlbums(state) { + state.byArtist = {} + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtistAlbums.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtistAlbums.fulfilled, (state, action) => { + state.loading = false + state.byArtist[action.payload.artistSlug] = action.payload.albums + state.error = null + }) + .addCase(fetchArtistAlbums.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearAlbums } = albumsSlice.actions +export default albumsSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/artistsSlice.ts b/furumi-node-player/client/src/store/slices/artistsSlice.ts new file mode 100644 index 0000000..ecef76a --- /dev/null +++ b/furumi-node-player/client/src/store/slices/artistsSlice.ts @@ -0,0 +1,54 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { Artist } from '../../types' +import { getArtists } from '../../furumiApi' + +export const fetchArtists = createAsyncThunk( + 'artists/fetch', + async (_, { rejectWithValue }) => { + const data = await getArtists() + if (data === null) return rejectWithValue('Failed to fetch artists') + return data + }, +) + +interface ArtistsState { + items: Artist[] + loading: boolean + error: string | null +} + +const initialState: ArtistsState = { + items: [], + loading: false, + error: null, +} + +const artistsSlice = createSlice({ + name: 'artists', + initialState, + reducers: { + clearArtists(state) { + state.items = [] + state.error = null + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchArtists.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchArtists.fulfilled, (state, action) => { + state.loading = false + state.items = action.payload + state.error = null + }) + .addCase(fetchArtists.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearArtists } = artistsSlice.actions +export default artistsSlice.reducer diff --git a/furumi-node-player/client/src/store/slices/trackDetailSlice.ts b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts new file mode 100644 index 0000000..60a852f --- /dev/null +++ b/furumi-node-player/client/src/store/slices/trackDetailSlice.ts @@ -0,0 +1,57 @@ +import { createSlice, createAsyncThunk } from '@reduxjs/toolkit' +import type { TrackDetail } from '../../types' +import { getTrackInfo } from '../../furumiApi' + +export const fetchTrackDetail = createAsyncThunk( + 'trackDetail/fetch', + async (trackSlug: string, { rejectWithValue }) => { + const data = await getTrackInfo(trackSlug) + if (data === null) return rejectWithValue('Failed to fetch track detail') + return { trackSlug, detail: data } + }, +) + +interface TrackDetailState { + bySlug: Record + loading: boolean + error: string | null +} + +const initialState: TrackDetailState = { + bySlug: {}, + loading: false, + error: null, +} + +const trackDetailSlice = createSlice({ + name: 'trackDetail', + initialState, + reducers: { + clearTrackDetail(state) { + state.bySlug = {} + state.error = null + }, + removeTrackDetail(state, action: { payload: string }) { + delete state.bySlug[action.payload] + }, + }, + extraReducers: (builder) => { + builder + .addCase(fetchTrackDetail.pending, (state) => { + state.loading = true + state.error = null + }) + .addCase(fetchTrackDetail.fulfilled, (state, action) => { + state.loading = false + state.bySlug[action.payload.trackSlug] = action.payload.detail + state.error = null + }) + .addCase(fetchTrackDetail.rejected, (state, action) => { + state.loading = false + state.error = action.payload as string ?? 'Unknown error' + }) + }, +}) + +export const { clearTrackDetail, removeTrackDetail } = trackDetailSlice.actions +export default trackDetailSlice.reducer