feat: auth & admin
This commit is contained in:
@@ -1,5 +1,6 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
import type { User } from '../../api/types';
|
||||
import { instanceStorage } from '../../config/instances';
|
||||
|
||||
interface AuthState {
|
||||
accessToken: string | null;
|
||||
@@ -8,9 +9,11 @@ interface AuthState {
|
||||
user: User | null;
|
||||
}
|
||||
|
||||
// Auth is bound to the active backend: tokens persist under that instance's
|
||||
// namespace, so connecting to a different server never reuses another's session.
|
||||
const loadPersistedAuth = (): Partial<AuthState> => {
|
||||
try {
|
||||
const raw = localStorage.getItem('mcma_auth');
|
||||
const raw = instanceStorage.get('auth');
|
||||
return raw ? (JSON.parse(raw) as Partial<AuthState>) : {};
|
||||
} catch {
|
||||
return {};
|
||||
@@ -30,7 +33,14 @@ export const authSlice = createSlice({
|
||||
name: 'auth',
|
||||
initialState,
|
||||
reducers: {
|
||||
setTokens(state, action: PayloadAction<{ accessToken: string; refreshToken: string; expiresIn: number }>) {
|
||||
setTokens(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
}>,
|
||||
) {
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.expiresAt = Date.now() + action.payload.expiresIn * 1000;
|
||||
@@ -45,20 +55,21 @@ export const authSlice = createSlice({
|
||||
state.refreshToken = null;
|
||||
state.expiresAt = null;
|
||||
state.user = null;
|
||||
localStorage.removeItem('mcma_auth');
|
||||
instanceStorage.remove('auth');
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
function persistAuth(state: AuthState): void {
|
||||
try {
|
||||
localStorage.setItem('mcma_auth', JSON.stringify({
|
||||
instanceStorage.set(
|
||||
'auth',
|
||||
JSON.stringify({
|
||||
accessToken: state.accessToken,
|
||||
refreshToken: state.refreshToken,
|
||||
expiresAt: state.expiresAt,
|
||||
user: state.user,
|
||||
}));
|
||||
} catch { /* storage unavailable */ }
|
||||
}),
|
||||
);
|
||||
}
|
||||
|
||||
export const { setTokens, setUser, logout } = authSlice.actions;
|
||||
|
||||
+51
-18
@@ -20,12 +20,14 @@ const initialState: PlayerState = {
|
||||
isPlaying: false,
|
||||
position: 0,
|
||||
duration: 0,
|
||||
volume: 0.8,
|
||||
volume: 0.78,
|
||||
muted: false,
|
||||
repeat: 'none',
|
||||
shuffle: false,
|
||||
isNowPlayingOpen: false,
|
||||
isQueueOpen: false,
|
||||
// STUB: open by default so the queue drawer look is visible before a backend
|
||||
// exists (pairs with DEMO_QUEUE). Default to false once real playback lands.
|
||||
isQueueOpen: true,
|
||||
};
|
||||
|
||||
export const playerSlice = createSlice({
|
||||
@@ -37,25 +39,56 @@ export const playerSlice = createSlice({
|
||||
state.isPlaying = true;
|
||||
state.position = 0;
|
||||
},
|
||||
pause(state) { state.isPlaying = false; },
|
||||
resume(state) { state.isPlaying = true; },
|
||||
stop(state) { state.isPlaying = false; state.currentTrackId = null; state.position = 0; },
|
||||
setPosition(state, action: PayloadAction<number>) { state.position = action.payload; },
|
||||
setDuration(state, action: PayloadAction<number>) { state.duration = action.payload; },
|
||||
setVolume(state, action: PayloadAction<number>) { state.volume = action.payload; },
|
||||
toggleMute(state) { state.muted = !state.muted; },
|
||||
setRepeat(state, action: PayloadAction<RepeatMode>) { state.repeat = action.payload; },
|
||||
toggleShuffle(state) { state.shuffle = !state.shuffle; },
|
||||
toggleNowPlaying(state) { state.isNowPlayingOpen = !state.isNowPlayingOpen; },
|
||||
toggleQueue(state) { state.isQueueOpen = !state.isQueueOpen; },
|
||||
pause(state) {
|
||||
state.isPlaying = false;
|
||||
},
|
||||
resume(state) {
|
||||
state.isPlaying = true;
|
||||
},
|
||||
stop(state) {
|
||||
state.isPlaying = false;
|
||||
state.currentTrackId = null;
|
||||
state.position = 0;
|
||||
},
|
||||
setPosition(state, action: PayloadAction<number>) {
|
||||
state.position = action.payload;
|
||||
},
|
||||
setDuration(state, action: PayloadAction<number>) {
|
||||
state.duration = action.payload;
|
||||
},
|
||||
setVolume(state, action: PayloadAction<number>) {
|
||||
state.volume = action.payload;
|
||||
},
|
||||
toggleMute(state) {
|
||||
state.muted = !state.muted;
|
||||
},
|
||||
setRepeat(state, action: PayloadAction<RepeatMode>) {
|
||||
state.repeat = action.payload;
|
||||
},
|
||||
toggleShuffle(state) {
|
||||
state.shuffle = !state.shuffle;
|
||||
},
|
||||
toggleNowPlaying(state) {
|
||||
state.isNowPlayingOpen = !state.isNowPlayingOpen;
|
||||
},
|
||||
toggleQueue(state) {
|
||||
state.isQueueOpen = !state.isQueueOpen;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const {
|
||||
play, pause, resume, stop,
|
||||
setPosition, setDuration,
|
||||
setVolume, toggleMute,
|
||||
setRepeat, toggleShuffle,
|
||||
toggleNowPlaying, toggleQueue,
|
||||
play,
|
||||
pause,
|
||||
resume,
|
||||
stop,
|
||||
setPosition,
|
||||
setDuration,
|
||||
setVolume,
|
||||
toggleMute,
|
||||
setRepeat,
|
||||
toggleShuffle,
|
||||
toggleNowPlaying,
|
||||
toggleQueue,
|
||||
} = playerSlice.actions;
|
||||
export default playerSlice.reducer;
|
||||
|
||||
+69
-10
@@ -1,6 +1,12 @@
|
||||
import { createSlice, type PayloadAction } from '@reduxjs/toolkit';
|
||||
|
||||
type QueueSource = 'manual' | 'album' | 'playlist' | 'artist' | 'search';
|
||||
type QueueSource =
|
||||
| 'manual'
|
||||
| 'album'
|
||||
| 'playlist'
|
||||
| 'artist'
|
||||
| 'search'
|
||||
| 'radio';
|
||||
|
||||
interface QueueEntry {
|
||||
trackId: string;
|
||||
@@ -19,19 +25,63 @@ interface QueueState {
|
||||
sourceName: string | null;
|
||||
}
|
||||
|
||||
// STUB demo queue — purely client-side display data so the player bar and
|
||||
// queue drawer render with content before the backend exists. Delete this
|
||||
// block (reset entries/currentIndex/source to the empty values) once real
|
||||
// playback wires tracks into the queue.
|
||||
const DEMO_QUEUE: QueueEntry[] = [
|
||||
{
|
||||
trackId: 'd1',
|
||||
title: 'Quiet Storage',
|
||||
artistName: 'Cyan Atlas',
|
||||
albumTitle: 'Night Index',
|
||||
durationMs: 312_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd2',
|
||||
title: 'Magnetic North',
|
||||
artistName: 'Tidal Bloom',
|
||||
albumTitle: 'Ferric Coast',
|
||||
durationMs: 243_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd3',
|
||||
title: 'Ambergris',
|
||||
artistName: 'Møller',
|
||||
albumTitle: 'Warm Static',
|
||||
durationMs: 201_000,
|
||||
},
|
||||
{
|
||||
trackId: 'd4',
|
||||
title: 'Slow Carrier',
|
||||
artistName: 'Tidal Bloom',
|
||||
albumTitle: 'Ferric Coast',
|
||||
durationMs: 301_000,
|
||||
},
|
||||
];
|
||||
|
||||
const initialState: QueueState = {
|
||||
entries: [],
|
||||
currentIndex: -1,
|
||||
source: 'manual',
|
||||
entries: DEMO_QUEUE,
|
||||
currentIndex: 0,
|
||||
source: 'radio',
|
||||
sourceId: null,
|
||||
sourceName: null,
|
||||
sourceName: 'My radio',
|
||||
};
|
||||
|
||||
export const queueSlice = createSlice({
|
||||
name: 'queue',
|
||||
initialState,
|
||||
reducers: {
|
||||
setQueue(state, action: PayloadAction<{ entries: QueueEntry[]; startIndex?: number; source: QueueSource; sourceId?: string; sourceName?: string }>) {
|
||||
setQueue(
|
||||
state,
|
||||
action: PayloadAction<{
|
||||
entries: QueueEntry[];
|
||||
startIndex?: number;
|
||||
source: QueueSource;
|
||||
sourceId?: string;
|
||||
sourceName?: string;
|
||||
}>,
|
||||
) {
|
||||
state.entries = action.payload.entries;
|
||||
state.currentIndex = action.payload.startIndex ?? 0;
|
||||
state.source = action.payload.source;
|
||||
@@ -53,8 +103,10 @@ export const queueSlice = createSlice({
|
||||
const [entry] = state.entries.splice(from, 1);
|
||||
state.entries.splice(to, 0, entry);
|
||||
if (state.currentIndex === from) state.currentIndex = to;
|
||||
else if (from < state.currentIndex && to >= state.currentIndex) state.currentIndex--;
|
||||
else if (from > state.currentIndex && to <= state.currentIndex) state.currentIndex++;
|
||||
else if (from < state.currentIndex && to >= state.currentIndex)
|
||||
state.currentIndex--;
|
||||
else if (from > state.currentIndex && to <= state.currentIndex)
|
||||
state.currentIndex++;
|
||||
},
|
||||
goToIndex(state, action: PayloadAction<number>) {
|
||||
state.currentIndex = action.payload;
|
||||
@@ -73,7 +125,14 @@ export const queueSlice = createSlice({
|
||||
});
|
||||
|
||||
export const {
|
||||
setQueue, addToQueue, addNextInQueue, removeFromQueue,
|
||||
moveInQueue, goToIndex, nextTrack, prevTrack, clearQueue,
|
||||
setQueue,
|
||||
addToQueue,
|
||||
addNextInQueue,
|
||||
removeFromQueue,
|
||||
moveInQueue,
|
||||
goToIndex,
|
||||
nextTrack,
|
||||
prevTrack,
|
||||
clearQueue,
|
||||
} = queueSlice.actions;
|
||||
export default queueSlice.reducer;
|
||||
|
||||
+22
-6
@@ -16,13 +16,29 @@ export const uiSlice = createSlice({
|
||||
name: 'ui',
|
||||
initialState,
|
||||
reducers: {
|
||||
toggleSidebar(state) { state.sidebarCollapsed = !state.sidebarCollapsed; },
|
||||
setSidebarCollapsed(state, action: PayloadAction<boolean>) { state.sidebarCollapsed = action.payload; },
|
||||
openModal(state, action: PayloadAction<string>) { state.activeModal = action.payload; },
|
||||
closeModal(state) { state.activeModal = null; },
|
||||
setActiveContextMenu(state, action: PayloadAction<string | null>) { state.activeTrackContextMenuId = action.payload; },
|
||||
toggleSidebar(state) {
|
||||
state.sidebarCollapsed = !state.sidebarCollapsed;
|
||||
},
|
||||
setSidebarCollapsed(state, action: PayloadAction<boolean>) {
|
||||
state.sidebarCollapsed = action.payload;
|
||||
},
|
||||
openModal(state, action: PayloadAction<string>) {
|
||||
state.activeModal = action.payload;
|
||||
},
|
||||
closeModal(state) {
|
||||
state.activeModal = null;
|
||||
},
|
||||
setActiveContextMenu(state, action: PayloadAction<string | null>) {
|
||||
state.activeTrackContextMenuId = action.payload;
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
export const { toggleSidebar, setSidebarCollapsed, openModal, closeModal, setActiveContextMenu } = uiSlice.actions;
|
||||
export const {
|
||||
toggleSidebar,
|
||||
setSidebarCollapsed,
|
||||
openModal,
|
||||
closeModal,
|
||||
setActiveContextMenu,
|
||||
} = uiSlice.actions;
|
||||
export default uiSlice.reducer;
|
||||
|
||||
Reference in New Issue
Block a user