feat: auth & admin

This commit is contained in:
2026-06-03 10:41:53 +03:00
parent 612d0f0125
commit 7dc59fb3c4
120 changed files with 4683 additions and 2159 deletions
+18 -7
View File
@@ -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
View File
@@ -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
View File
@@ -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
View File
@@ -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;