feat(api): real login + listening wired to the backend contract
Replace the faked ConnectPage login with a real /auth/login -> /auth/me
flow, including loading/error states. Add a backend-contract adapter layer
(api/mappers.ts) translating the backend's snake_case, lean *Out schemas and
{items,total,limit,offset} paging into the UI's camelCase domain types, so
swapping backends only touches the mappers.
- auth: chained login (tokens) + /auth/me (user); refresh on snake_case;
expiresIn optional (reauth is 401-driven, backend sends no TTL)
- streaming: GET /stream/{id}?token= (token query param for <audio>); SW
audio cache route + tests follow the path change (token stays cache-stable)
- library/playlists/likes/admin: correct paths (/tracks not /library/tracks),
page/pageSize<->limit/offset, duration_seconds->durationMs, likes as
append-only POST /likes event-log, admin is_superuser<->role
- downloads/storage: marked provisional (backend routes still stubs)
Co-Authored-By: Claude Opus 4.8 <noreply@anthropic.com>
This commit is contained in:
+9
-10
@@ -34,24 +34,23 @@ export const baseQueryWithReauth: BaseQueryFn<
|
||||
{
|
||||
url: '/auth/refresh',
|
||||
method: 'POST',
|
||||
body: { refreshToken },
|
||||
body: { refresh_token: refreshToken },
|
||||
},
|
||||
api,
|
||||
extraOptions,
|
||||
);
|
||||
|
||||
if (refreshResult.data) {
|
||||
const {
|
||||
accessToken,
|
||||
refreshToken: newRefresh,
|
||||
expiresIn,
|
||||
} = refreshResult.data as {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
// Backend wire format is snake_case with no TTL (see auth.ts adapter).
|
||||
const { access_token, refresh_token } = refreshResult.data as {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
};
|
||||
api.dispatch(
|
||||
setTokens({ accessToken, refreshToken: newRefresh, expiresIn }),
|
||||
setTokens({
|
||||
accessToken: access_token,
|
||||
refreshToken: refresh_token,
|
||||
}),
|
||||
);
|
||||
result = await rawBaseQuery()(args, api, extraOptions);
|
||||
} else {
|
||||
|
||||
+21
-10
@@ -1,33 +1,44 @@
|
||||
import { api } from '../index';
|
||||
import { toUser, type RawUser } from '../mappers';
|
||||
import type { User } from '../types';
|
||||
|
||||
/**
|
||||
* Admin user management. The backend models authorization as `is_superuser` /
|
||||
* `is_active` (no `role`/`email`); `toUser` maps superuser→role for the UI and
|
||||
* the mutations translate role back to `is_superuser` on the way out.
|
||||
*/
|
||||
export const adminApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getUsers: build.query<User[], void>({
|
||||
query: () => '/admin/users',
|
||||
transformResponse: (raw: RawUser[]) => raw.map(toUser),
|
||||
providesTags: ['User'],
|
||||
}),
|
||||
createUser: build.mutation<
|
||||
User,
|
||||
{
|
||||
username: string;
|
||||
password: string;
|
||||
email?: string;
|
||||
role: 'admin' | 'user';
|
||||
}
|
||||
{ username: string; password: string; role: 'admin' | 'user' }
|
||||
>({
|
||||
query: (body) => ({ url: '/admin/users', method: 'POST', body }),
|
||||
query: ({ username, password, role }) => ({
|
||||
url: '/admin/users',
|
||||
method: 'POST',
|
||||
body: { username, password, is_superuser: role === 'admin' },
|
||||
}),
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
invalidatesTags: ['User'],
|
||||
}),
|
||||
updateUser: build.mutation<
|
||||
User,
|
||||
{ id: string; role?: 'admin' | 'user'; email?: string }
|
||||
{ id: string; role?: 'admin' | 'user'; isActive?: boolean }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
query: ({ id, role, isActive }) => ({
|
||||
url: `/admin/users/${id}`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
body: {
|
||||
is_superuser: role === undefined ? undefined : role === 'admin',
|
||||
is_active: isActive,
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
invalidatesTags: ['User'],
|
||||
}),
|
||||
deleteUser: build.mutation<void, string>({
|
||||
|
||||
+65
-11
@@ -1,26 +1,80 @@
|
||||
import { api } from '../index';
|
||||
import type { LoginRequest, LoginResponse } from '../types';
|
||||
import { toUser, type RawUser } from '../mappers';
|
||||
import type { AuthTokens, LoginRequest, LoginResponse, User } from '../types';
|
||||
|
||||
/**
|
||||
* Auth seam over the backend's wire format: tokens-only login + a separate
|
||||
* `/auth/me` for the user. Token mapping lives here; user mapping is shared with
|
||||
* the admin endpoints via `toUser` in `mappers.ts`.
|
||||
*/
|
||||
|
||||
/** `/auth/login` & `/auth/refresh` response shape. */
|
||||
interface RawTokenResponse {
|
||||
access_token: string;
|
||||
refresh_token: string;
|
||||
token_type?: string;
|
||||
}
|
||||
|
||||
const toTokens = (raw: RawTokenResponse): AuthTokens => ({
|
||||
accessToken: raw.access_token,
|
||||
refreshToken: raw.refresh_token,
|
||||
// No TTL on the wire — expiry is 401-driven (see baseQuery reauth).
|
||||
});
|
||||
|
||||
export const authApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
// Login is a two-call flow: POST /auth/login yields tokens, then GET
|
||||
// /auth/me resolves the user. A queryFn chains both so callers get the
|
||||
// unified { user, tokens } the UI expects in one await.
|
||||
login: build.mutation<LoginResponse, LoginRequest>({
|
||||
query: (body) => ({ url: '/auth/login', method: 'POST', body }),
|
||||
async queryFn(body, _api, _extra, baseQuery) {
|
||||
const tokenRes = await baseQuery({
|
||||
url: '/auth/login',
|
||||
method: 'POST',
|
||||
body,
|
||||
});
|
||||
if (tokenRes.error) return { error: tokenRes.error };
|
||||
const tokens = toTokens(tokenRes.data as RawTokenResponse);
|
||||
|
||||
// The access token isn't in the store yet, so attach it explicitly —
|
||||
// baseQuery's prepareHeaders only injects what's already in auth state.
|
||||
const meRes = await baseQuery({
|
||||
url: '/auth/me',
|
||||
headers: { Authorization: `Bearer ${tokens.accessToken}` },
|
||||
});
|
||||
if (meRes.error) return { error: meRes.error };
|
||||
const user = toUser(meRes.data as RawUser);
|
||||
|
||||
return { data: { user, tokens } };
|
||||
},
|
||||
}),
|
||||
logout: build.mutation<void, void>({
|
||||
query: () => ({ url: '/auth/logout', method: 'POST' }),
|
||||
logout: build.mutation<void, { refreshToken: string }>({
|
||||
query: ({ refreshToken }) => ({
|
||||
url: '/auth/logout',
|
||||
method: 'POST',
|
||||
body: { refresh_token: refreshToken },
|
||||
}),
|
||||
}),
|
||||
refreshToken: build.mutation<
|
||||
{ accessToken: string; refreshToken: string; expiresIn: number },
|
||||
{ refreshToken: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/auth/refresh', method: 'POST', body }),
|
||||
refreshToken: build.mutation<AuthTokens, { refreshToken: string }>({
|
||||
query: ({ refreshToken }) => ({
|
||||
url: '/auth/refresh',
|
||||
method: 'POST',
|
||||
body: { refresh_token: refreshToken },
|
||||
}),
|
||||
transformResponse: (raw: RawTokenResponse) => toTokens(raw),
|
||||
}),
|
||||
me: build.query<import('../types').User, void>({
|
||||
me: build.query<User, void>({
|
||||
query: () => '/auth/me',
|
||||
transformResponse: (raw: RawUser) => toUser(raw),
|
||||
providesTags: ['User'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useLoginMutation, useLogoutMutation, useMeQuery } = authApi;
|
||||
export const {
|
||||
useLoginMutation,
|
||||
useLogoutMutation,
|
||||
useRefreshTokenMutation,
|
||||
useMeQuery,
|
||||
} = authApi;
|
||||
|
||||
@@ -1,6 +1,12 @@
|
||||
import { api } from '../index';
|
||||
import type { DownloadJob } from '../types';
|
||||
|
||||
// NOTE: the backend `/downloads` routes are still unimplemented stubs (they
|
||||
// return no body / no schema). The request shapes below are provisional and the
|
||||
// responses will need the same snake→camel mapper treatment as library/playlists
|
||||
// (see `mappers.ts`) once the backend defines DownloadJob's wire format. Do not
|
||||
// wire these into the UI until then.
|
||||
|
||||
export const downloadsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getDownloads: build.query<
|
||||
|
||||
@@ -1,4 +1,14 @@
|
||||
import { api } from '../index';
|
||||
import {
|
||||
toAlbum,
|
||||
toArtist,
|
||||
toPage,
|
||||
toTrack,
|
||||
type RawAlbum,
|
||||
type RawArtist,
|
||||
type RawPaged,
|
||||
type RawTrack,
|
||||
} from '../mappers';
|
||||
import type {
|
||||
Track,
|
||||
Album,
|
||||
@@ -7,10 +17,41 @@ import type {
|
||||
LibraryFilters,
|
||||
} from '../types';
|
||||
|
||||
// The backend sorts on a small allow-list; map the UI's sort keys onto it
|
||||
// (album/year aren't sortable server-side yet → fall back to recency).
|
||||
const SORT_BY: Record<NonNullable<LibraryFilters['sortBy']>, string> = {
|
||||
title: 'title',
|
||||
artist: 'artist',
|
||||
album: 'created_at',
|
||||
year: 'created_at',
|
||||
dateAdded: 'created_at',
|
||||
};
|
||||
|
||||
/** UI page/pageSize → backend limit/offset. */
|
||||
function paging(page?: number, pageSize?: number) {
|
||||
const size = pageSize ?? 50;
|
||||
return { limit: size, offset: ((page ?? 1) - 1) * size };
|
||||
}
|
||||
|
||||
function trackParams(f: LibraryFilters) {
|
||||
return {
|
||||
q: f.search,
|
||||
artist_id: f.artistId,
|
||||
album_id: f.albumId,
|
||||
sort_by: f.sortBy ? SORT_BY[f.sortBy] : undefined,
|
||||
order: f.sortOrder,
|
||||
...paging(f.page, f.pageSize),
|
||||
};
|
||||
}
|
||||
|
||||
export const libraryApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getTracks: build.query<PaginatedResponse<Track>, LibraryFilters | void>({
|
||||
query: (filters) => ({ url: '/library/tracks', params: filters ?? {} }),
|
||||
query: (filters) => ({
|
||||
url: '/tracks',
|
||||
params: trackParams(filters ?? {}),
|
||||
}),
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => toPage(raw, toTrack),
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
@@ -20,7 +61,8 @@ export const libraryApi = api.injectEndpoints({
|
||||
: ['Track'],
|
||||
}),
|
||||
getTrack: build.query<Track, string>({
|
||||
query: (id) => `/library/tracks/${id}`,
|
||||
query: (id) => `/tracks/${id}`,
|
||||
transformResponse: (raw: RawTrack) => toTrack(raw),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Track', id }],
|
||||
}),
|
||||
getAlbums: build.query<
|
||||
@@ -32,7 +74,15 @@ export const libraryApi = api.injectEndpoints({
|
||||
pageSize?: number;
|
||||
} | void
|
||||
>({
|
||||
query: (params) => ({ url: '/library/albums', params: params ?? {} }),
|
||||
query: (p) => ({
|
||||
url: '/albums',
|
||||
params: {
|
||||
q: p?.search,
|
||||
artist_id: p?.artistId,
|
||||
...paging(p?.page, p?.pageSize),
|
||||
},
|
||||
}),
|
||||
transformResponse: (raw: RawPaged<RawAlbum>) => toPage(raw, toAlbum),
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
@@ -42,11 +92,13 @@ export const libraryApi = api.injectEndpoints({
|
||||
: ['Album'],
|
||||
}),
|
||||
getAlbum: build.query<Album, string>({
|
||||
query: (id) => `/library/albums/${id}`,
|
||||
query: (id) => `/albums/${id}`,
|
||||
transformResponse: (raw: RawAlbum) => toAlbum(raw),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Album', id }],
|
||||
}),
|
||||
getAlbumTracks: build.query<Track[], string>({
|
||||
query: (albumId) => `/library/albums/${albumId}/tracks`,
|
||||
query: (albumId) => `/albums/${albumId}/tracks`,
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
|
||||
providesTags: (_r, _e, albumId) => [
|
||||
{ type: 'Album', id: albumId },
|
||||
'Track',
|
||||
@@ -56,7 +108,11 @@ export const libraryApi = api.injectEndpoints({
|
||||
PaginatedResponse<Artist>,
|
||||
{ search?: string; page?: number; pageSize?: number } | void
|
||||
>({
|
||||
query: (params) => ({ url: '/library/artists', params: params ?? {} }),
|
||||
query: (p) => ({
|
||||
url: '/artists',
|
||||
params: { q: p?.search, ...paging(p?.page, p?.pageSize) },
|
||||
}),
|
||||
transformResponse: (raw: RawPaged<RawArtist>) => toPage(raw, toArtist),
|
||||
providesTags: (result) =>
|
||||
result
|
||||
? [
|
||||
@@ -69,21 +125,40 @@ export const libraryApi = api.injectEndpoints({
|
||||
: ['Artist'],
|
||||
}),
|
||||
getArtist: build.query<Artist, string>({
|
||||
query: (id) => `/library/artists/${id}`,
|
||||
query: (id) => `/artists/${id}`,
|
||||
transformResponse: (raw: RawArtist) => toArtist(raw),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Artist', id }],
|
||||
}),
|
||||
getArtistAlbums: build.query<Album[], string>({
|
||||
query: (artistId) => `/library/artists/${artistId}/albums`,
|
||||
query: (artistId) => `/artists/${artistId}/albums`,
|
||||
transformResponse: (raw: RawPaged<RawAlbum>) => raw.items.map(toAlbum),
|
||||
providesTags: (_r, _e, artistId) => [
|
||||
{ type: 'Artist', id: artistId },
|
||||
'Album',
|
||||
],
|
||||
}),
|
||||
getArtistTracks: build.query<Track[], string>({
|
||||
query: (artistId) => `/artists/${artistId}/tracks`,
|
||||
transformResponse: (raw: RawPaged<RawTrack>) => raw.items.map(toTrack),
|
||||
providesTags: (_r, _e, artistId) => [
|
||||
{ type: 'Artist', id: artistId },
|
||||
'Track',
|
||||
],
|
||||
}),
|
||||
searchLibrary: build.query<
|
||||
{ tracks: Track[]; albums: Album[]; artists: Artist[] },
|
||||
string
|
||||
>({
|
||||
query: (q) => ({ url: '/library/search', params: { q } }),
|
||||
query: (q) => ({ url: '/search/library', params: { q } }),
|
||||
transformResponse: (raw: {
|
||||
tracks: RawTrack[];
|
||||
albums: RawAlbum[];
|
||||
artists: RawArtist[];
|
||||
}) => ({
|
||||
tracks: raw.tracks.map(toTrack),
|
||||
albums: raw.albums.map(toAlbum),
|
||||
artists: raw.artists.map(toArtist),
|
||||
}),
|
||||
providesTags: ['Track', 'Album', 'Artist'],
|
||||
}),
|
||||
}),
|
||||
@@ -99,5 +174,6 @@ export const {
|
||||
useGetArtistsQuery,
|
||||
useGetArtistQuery,
|
||||
useGetArtistAlbumsQuery,
|
||||
useGetArtistTracksQuery,
|
||||
useSearchLibraryQuery,
|
||||
} = libraryApi;
|
||||
|
||||
+58
-10
@@ -1,20 +1,68 @@
|
||||
import { api } from '../index';
|
||||
|
||||
/**
|
||||
* Likes are an append-only event-log on the backend: state changes by POSTing a
|
||||
* new `{track_id, value}` event, never by PUT/DELETE on a boolean. "Unlike" is
|
||||
* just a `neutral` event superseding the prior `like`.
|
||||
*/
|
||||
type LikeValue = 'like' | 'dislike' | 'neutral';
|
||||
|
||||
interface RawLikeState {
|
||||
track_id: string;
|
||||
value: LikeValue;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface LikeState {
|
||||
trackId: string;
|
||||
value: LikeValue;
|
||||
updatedAt: string;
|
||||
}
|
||||
|
||||
const toLikeState = (r: RawLikeState): LikeState => ({
|
||||
trackId: r.track_id,
|
||||
value: r.value,
|
||||
updatedAt: r.updated_at,
|
||||
});
|
||||
|
||||
export const likesApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
likeTrack: build.mutation<void, string>({
|
||||
query: (trackId) => ({ url: `/likes/tracks/${trackId}`, method: 'PUT' }),
|
||||
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
||||
}),
|
||||
unlikeTrack: build.mutation<void, string>({
|
||||
query: (trackId) => ({
|
||||
url: `/likes/tracks/${trackId}`,
|
||||
method: 'DELETE',
|
||||
setLike: build.mutation<LikeState, { trackId: string; value: LikeValue }>({
|
||||
query: ({ trackId, value }) => ({
|
||||
url: '/likes',
|
||||
method: 'POST',
|
||||
body: { track_id: trackId, value },
|
||||
}),
|
||||
invalidatesTags: (_r, _e, id) => ['Like', { type: 'Track', id }],
|
||||
transformResponse: (raw: RawLikeState) => toLikeState(raw),
|
||||
invalidatesTags: (_r, _e, { trackId }) => [
|
||||
'Like',
|
||||
{ type: 'Track', id: trackId },
|
||||
],
|
||||
}),
|
||||
// Latest like state for a set of tracks (drives like buttons in lists).
|
||||
getLikesState: build.query<LikeState[], string[]>({
|
||||
query: (trackIds) => ({
|
||||
url: '/likes/state',
|
||||
params: { track_ids: trackIds.join(',') },
|
||||
}),
|
||||
transformResponse: (raw: RawLikeState[]) => raw.map(toLikeState),
|
||||
providesTags: ['Like'],
|
||||
}),
|
||||
}),
|
||||
overrideExisting: false,
|
||||
});
|
||||
|
||||
export const { useLikeTrackMutation, useUnlikeTrackMutation } = likesApi;
|
||||
const { useSetLikeMutation, useGetLikesStateQuery } = likesApi;
|
||||
|
||||
/** Convenience hook preserving the like/unlike call sites. */
|
||||
export function useLikeActions() {
|
||||
const [setLike, state] = useSetLikeMutation();
|
||||
return {
|
||||
like: (trackId: string) => setLike({ trackId, value: 'like' }),
|
||||
unlike: (trackId: string) => setLike({ trackId, value: 'neutral' }),
|
||||
dislike: (trackId: string) => setLike({ trackId, value: 'dislike' }),
|
||||
state,
|
||||
};
|
||||
}
|
||||
|
||||
export { useSetLikeMutation, useGetLikesStateQuery };
|
||||
|
||||
@@ -1,36 +1,61 @@
|
||||
import { api } from '../index';
|
||||
import {
|
||||
toPage,
|
||||
toPlaylist,
|
||||
toTrack,
|
||||
type RawPaged,
|
||||
type RawPlaylist,
|
||||
type RawTrack,
|
||||
} from '../mappers';
|
||||
import type { Playlist, PlaylistTrack, PaginatedResponse } from '../types';
|
||||
|
||||
export const playlistsApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getPlaylists: build.query<PaginatedResponse<Playlist>, void>({
|
||||
query: () => '/playlists',
|
||||
transformResponse: (raw: RawPaged<RawPlaylist>) =>
|
||||
toPage(raw, toPlaylist),
|
||||
providesTags: ['Playlist'],
|
||||
}),
|
||||
getPlaylist: build.query<Playlist, string>({
|
||||
query: (id) => `/playlists/${id}`,
|
||||
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }],
|
||||
}),
|
||||
getPlaylistTracks: build.query<PlaylistTrack[], string>({
|
||||
query: (id) => `/playlists/${id}/tracks`,
|
||||
// The backend returns plain tracks in playlist order; position/addedAt
|
||||
// aren't on TrackOut, so derive position from order and default addedAt.
|
||||
transformResponse: (raw: RawPaged<RawTrack>) =>
|
||||
raw.items.map((r, i) => ({
|
||||
...toTrack(r),
|
||||
position: i,
|
||||
addedAt: r.created_at,
|
||||
})),
|
||||
providesTags: (_r, _e, id) => [{ type: 'Playlist', id }, 'Track'],
|
||||
}),
|
||||
createPlaylist: build.mutation<
|
||||
Playlist,
|
||||
{ name: string; description?: string; isPublic?: boolean }
|
||||
{ name: string; description?: string }
|
||||
>({
|
||||
query: (body) => ({ url: '/playlists', method: 'POST', body }),
|
||||
query: ({ name, description }) => ({
|
||||
url: '/playlists',
|
||||
method: 'POST',
|
||||
body: { name, description },
|
||||
}),
|
||||
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
|
||||
invalidatesTags: ['Playlist'],
|
||||
}),
|
||||
updatePlaylist: build.mutation<
|
||||
Playlist,
|
||||
{ id: string; name?: string; description?: string; isPublic?: boolean }
|
||||
{ id: string; name?: string; description?: string }
|
||||
>({
|
||||
query: ({ id, ...body }) => ({
|
||||
query: ({ id, name, description }) => ({
|
||||
url: `/playlists/${id}`,
|
||||
method: 'PATCH',
|
||||
body,
|
||||
body: { name, description },
|
||||
}),
|
||||
transformResponse: (raw: RawPlaylist) => toPlaylist(raw),
|
||||
invalidatesTags: (_r, _e, { id }) => [{ type: 'Playlist', id }],
|
||||
}),
|
||||
deletePlaylist: build.mutation<void, string>({
|
||||
@@ -39,12 +64,12 @@ export const playlistsApi = api.injectEndpoints({
|
||||
}),
|
||||
addTrackToPlaylist: build.mutation<
|
||||
void,
|
||||
{ playlistId: string; trackId: string }
|
||||
{ playlistId: string; trackId: string; position?: number }
|
||||
>({
|
||||
query: ({ playlistId, trackId }) => ({
|
||||
query: ({ playlistId, trackId, position }) => ({
|
||||
url: `/playlists/${playlistId}/tracks`,
|
||||
method: 'POST',
|
||||
body: { trackId },
|
||||
body: { track_id: trackId, position },
|
||||
}),
|
||||
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||
{ type: 'Playlist', id: playlistId },
|
||||
@@ -52,10 +77,10 @@ export const playlistsApi = api.injectEndpoints({
|
||||
}),
|
||||
removeTrackFromPlaylist: build.mutation<
|
||||
void,
|
||||
{ playlistId: string; trackId: string; position: number }
|
||||
{ playlistId: string; trackId: string }
|
||||
>({
|
||||
query: ({ playlistId, position }) => ({
|
||||
url: `/playlists/${playlistId}/tracks/${position}`,
|
||||
query: ({ playlistId, trackId }) => ({
|
||||
url: `/playlists/${playlistId}/tracks/${trackId}`,
|
||||
method: 'DELETE',
|
||||
}),
|
||||
invalidatesTags: (_r, _e, { playlistId }) => [
|
||||
|
||||
@@ -1,6 +1,13 @@
|
||||
import { api } from '../index';
|
||||
import type { StorageStats } from '../types';
|
||||
|
||||
// NOTE: the backend `/storage` routes are still unimplemented stubs (no body /
|
||||
// no schema), and the real paths differ from these placeholders (`GET /storage`,
|
||||
// `/storage/duplicates`, `/storage/broken`, `/storage/missing-metadata`,
|
||||
// `POST /storage/cleanup`). Re-point paths and add snake→camel mappers (see
|
||||
// `mappers.ts`) once the backend defines the storage response shapes; until then
|
||||
// these are provisional and unused by the UI.
|
||||
|
||||
export const storageApi = api.injectEndpoints({
|
||||
endpoints: (build) => ({
|
||||
getStorageStats: build.query<StorageStats, void>({
|
||||
|
||||
@@ -1,8 +1,13 @@
|
||||
import { getApiBaseUrl } from '../../config/runtime-config';
|
||||
|
||||
/**
|
||||
* Audio stream URL for the `<audio>` element. The access token rides as a query
|
||||
* param because `<audio>` can't send an `Authorization` header; the backend
|
||||
* accepts `?token=` on `GET /stream/{id}` for exactly this reason.
|
||||
*/
|
||||
export function getStreamUrl(trackId: string, token: string): string {
|
||||
const base = getApiBaseUrl();
|
||||
return `${base}/streaming/tracks/${trackId}?token=${encodeURIComponent(token)}`;
|
||||
return `${base}/stream/${trackId}?token=${encodeURIComponent(token)}`;
|
||||
}
|
||||
|
||||
export function getCoverUrl(artUrl: string | undefined): string | undefined {
|
||||
|
||||
@@ -0,0 +1,164 @@
|
||||
/**
|
||||
* Backend-contract adapters: the single place where the backend's wire format
|
||||
* (snake_case, lean `*Out` schemas, `{items,total,limit,offset}` paging) is
|
||||
* translated into the UI's internal camelCase domain types from `types.ts`.
|
||||
*
|
||||
* The endpoint files (`endpoints/*.ts`) own *paths and params*; this module owns
|
||||
* *shape*. Swapping or mocking a backend means rewriting the mappers here —
|
||||
* nothing in components, slices, or `types.ts` changes.
|
||||
*
|
||||
* Where the backend's lean schema omits a field the UI type carries (cover art,
|
||||
* liked state, durations on albums…), the mapper fills a safe client default and
|
||||
* says why inline. Those defaults are the contract's current edges, not bugs.
|
||||
*/
|
||||
import type {
|
||||
Album,
|
||||
Artist,
|
||||
PaginatedResponse,
|
||||
Playlist,
|
||||
Track,
|
||||
User,
|
||||
} from './types';
|
||||
|
||||
// ---- raw wire shapes (snake_case, exactly as the backend emits) ----
|
||||
|
||||
export interface RawPaged<T> {
|
||||
items: T[];
|
||||
total: number;
|
||||
limit: number;
|
||||
offset: number;
|
||||
}
|
||||
|
||||
export interface RawUser {
|
||||
id: string;
|
||||
username: string;
|
||||
is_superuser: boolean;
|
||||
is_active: boolean;
|
||||
created_at: string;
|
||||
updated_at: string;
|
||||
}
|
||||
|
||||
export interface RawTrack {
|
||||
id: string;
|
||||
title: string;
|
||||
artist_id: string;
|
||||
artist_name: string;
|
||||
album_id: string | null;
|
||||
album_title: string | null;
|
||||
duration_seconds: number | null;
|
||||
file_format: string;
|
||||
file_size: number;
|
||||
metadata_status: string;
|
||||
source: string;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RawAlbum {
|
||||
id: string;
|
||||
title: string;
|
||||
artist_id: string;
|
||||
artist_name: string;
|
||||
year: number | null;
|
||||
track_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RawArtist {
|
||||
id: string;
|
||||
name: string;
|
||||
album_count: number;
|
||||
track_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
export interface RawPlaylist {
|
||||
id: string;
|
||||
name: string;
|
||||
description: string | null;
|
||||
owner_id: string;
|
||||
version: number;
|
||||
track_count: number;
|
||||
created_at: string;
|
||||
}
|
||||
|
||||
// ---- mappers ----
|
||||
|
||||
export const toUser = (r: RawUser): User => ({
|
||||
id: r.id,
|
||||
username: r.username,
|
||||
// MVP role model: superuser → admin, everyone else → user.
|
||||
role: r.is_superuser ? 'admin' : 'user',
|
||||
createdAt: r.created_at,
|
||||
});
|
||||
|
||||
export const toTrack = (r: RawTrack): Track => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
artistId: r.artist_id,
|
||||
artistName: r.artist_name,
|
||||
albumId: r.album_id ?? '',
|
||||
albumTitle: r.album_title ?? '',
|
||||
// Cover endpoints aren't wired on the backend yet — leave art undefined so the
|
||||
// UI renders generated tile art instead of a broken image.
|
||||
albumArtUrl: undefined,
|
||||
durationMs: (r.duration_seconds ?? 0) * 1000,
|
||||
// The lean TrackOut carries no availability/like state: a track returned by
|
||||
// the library is on the server, and per-track like state comes from /likes.
|
||||
availability: 'server',
|
||||
liked: false,
|
||||
format: r.file_format,
|
||||
fileSize: r.file_size,
|
||||
});
|
||||
|
||||
export const toAlbum = (r: RawAlbum): Album => ({
|
||||
id: r.id,
|
||||
title: r.title,
|
||||
artistId: r.artist_id,
|
||||
artistName: r.artist_name,
|
||||
artUrl: undefined,
|
||||
year: r.year ?? undefined,
|
||||
trackCount: r.track_count,
|
||||
// AlbumOut has no aggregate duration; computed client-side from tracks when
|
||||
// an album is opened.
|
||||
totalDurationMs: 0,
|
||||
});
|
||||
|
||||
export const toArtist = (r: RawArtist): Artist => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
artUrl: undefined,
|
||||
albumCount: r.album_count,
|
||||
trackCount: r.track_count,
|
||||
});
|
||||
|
||||
export const toPlaylist = (r: RawPlaylist): Playlist => ({
|
||||
id: r.id,
|
||||
name: r.name,
|
||||
description: r.description ?? undefined,
|
||||
ownerId: r.owner_id,
|
||||
trackCount: r.track_count,
|
||||
totalDurationMs: 0,
|
||||
// No visibility concept on the backend yet — default private.
|
||||
isPublic: false,
|
||||
createdAt: r.created_at,
|
||||
// PlaylistOut omits updated_at; mirror created_at until it's added.
|
||||
updatedAt: r.created_at,
|
||||
});
|
||||
|
||||
/**
|
||||
* Translate the backend's `{items,total,limit,offset}` envelope into the UI's
|
||||
* `{items,total,page,pageSize,hasMore}`, mapping each element.
|
||||
*/
|
||||
export const toPage = <R, T>(
|
||||
raw: RawPaged<R>,
|
||||
map: (r: R) => T,
|
||||
): PaginatedResponse<T> => {
|
||||
const pageSize = raw.limit || raw.items.length || 1;
|
||||
return {
|
||||
items: raw.items.map(map),
|
||||
total: raw.total,
|
||||
page: Math.floor(raw.offset / pageSize) + 1,
|
||||
pageSize,
|
||||
hasMore: raw.offset + raw.items.length < raw.total,
|
||||
};
|
||||
};
|
||||
+3
-1
@@ -98,7 +98,9 @@ export interface User {
|
||||
export interface AuthTokens {
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
// Optional: the backend's TokenResponse carries no TTL — expiry is driven by
|
||||
// 401→refresh, not a client-side clock. Present only if a backend supplies it.
|
||||
expiresIn?: number;
|
||||
}
|
||||
|
||||
export interface LoginRequest {
|
||||
|
||||
@@ -1,18 +1,29 @@
|
||||
import { useState } from 'react';
|
||||
import { useNavigate } from 'react-router';
|
||||
import { useTranslation } from 'react-i18next';
|
||||
import type { FetchBaseQueryError } from '@reduxjs/toolkit/query';
|
||||
import { Card, TextField, Button, Callout, Badge } from '@olly/modern-sk';
|
||||
import { Icon } from '../../components/common/Icon';
|
||||
import { useAppDispatch } from '../../hooks/useAppDispatch';
|
||||
import { setTokens, setUser } from '../../store/slices/auth';
|
||||
import { setApiBaseUrl } from '../../config/runtime-config';
|
||||
import { useLoginMutation } from '../../api/endpoints/auth';
|
||||
import {
|
||||
listInstances,
|
||||
getActiveInstanceId,
|
||||
setActiveInstanceId,
|
||||
removeInstance,
|
||||
} from '../../config/instances';
|
||||
import type { User } from '../../api/types';
|
||||
|
||||
/** Map an RTKQ login failure to a user-facing i18n key. */
|
||||
function resolveLoginError(err: unknown): string {
|
||||
const e = err as FetchBaseQueryError | undefined;
|
||||
if (e && 'status' in e) {
|
||||
if (e.status === 'FETCH_ERROR') return 'connect.errors.unreachable';
|
||||
if (e.status === 401) return 'connect.errors.badCredentials';
|
||||
}
|
||||
return 'connect.errors.generic';
|
||||
}
|
||||
|
||||
export function ConnectPage() {
|
||||
const { t } = useTranslation();
|
||||
@@ -26,6 +37,9 @@ export function ConnectPage() {
|
||||
const [apiUrl, setApiUrl] = useState('https://');
|
||||
const [username, setUsername] = useState('');
|
||||
const [password, setPassword] = useState('');
|
||||
const [error, setError] = useState<string | null>(null);
|
||||
|
||||
const [login, { isLoading }] = useLoginMutation();
|
||||
|
||||
const switchTo = (id: string) => {
|
||||
setActiveInstanceId(id);
|
||||
@@ -37,25 +51,22 @@ export function ConnectPage() {
|
||||
setRev((r) => r + 1);
|
||||
};
|
||||
|
||||
const handleSubmit = (e: React.FormEvent) => {
|
||||
const handleSubmit = async (e: React.FormEvent) => {
|
||||
e.preventDefault();
|
||||
setError(null);
|
||||
// Point the API layer at this backend *before* logging in — baseQuery reads
|
||||
// the active instance's URL at request time. Auth tokens then persist under
|
||||
// that instance's namespace, never bleeding across servers.
|
||||
setApiBaseUrl(apiUrl);
|
||||
|
||||
const fakeUser: User = {
|
||||
id: 'dev-user',
|
||||
username: username || 'dev',
|
||||
role: 'admin',
|
||||
createdAt: new Date().toISOString(),
|
||||
};
|
||||
dispatch(
|
||||
setTokens({
|
||||
accessToken: 'dev-token',
|
||||
refreshToken: 'dev-refresh',
|
||||
expiresIn: 3600,
|
||||
}),
|
||||
);
|
||||
dispatch(setUser(fakeUser));
|
||||
void navigate('/');
|
||||
try {
|
||||
const { user, tokens } = await login({ username, password }).unwrap();
|
||||
dispatch(setTokens(tokens));
|
||||
dispatch(setUser(user));
|
||||
void navigate('/');
|
||||
} catch (err) {
|
||||
setError(resolveLoginError(err));
|
||||
}
|
||||
};
|
||||
|
||||
const labelStyle: React.CSSProperties = {
|
||||
@@ -227,15 +238,14 @@ export function ConnectPage() {
|
||||
required
|
||||
/>
|
||||
</div>
|
||||
<Callout variant="warning">
|
||||
{t('connect.form.stubNote')}
|
||||
</Callout>
|
||||
{error && <Callout variant="danger">{t(error)}</Callout>}
|
||||
<Button
|
||||
type="submit"
|
||||
variant="primary"
|
||||
disabled={isLoading}
|
||||
style={{ marginTop: '0.5rem' }}
|
||||
>
|
||||
{t('connect.form.submit')}
|
||||
{isLoading ? t('connect.form.submitting') : t('connect.form.submit')}
|
||||
</Button>
|
||||
</form>
|
||||
</Card>
|
||||
|
||||
@@ -35,8 +35,12 @@ const en = {
|
||||
username: 'Username',
|
||||
password: 'Password',
|
||||
submit: 'Connect',
|
||||
stubNote:
|
||||
'Stub mode — backend not wired. Connect signs in with a fake admin session, scoped to this instance.',
|
||||
submitting: 'Connecting…',
|
||||
},
|
||||
errors: {
|
||||
unreachable: "Can't reach this server. Check the URL and that it's online.",
|
||||
badCredentials: 'Incorrect username or password.',
|
||||
generic: 'Sign-in failed. Please try again.',
|
||||
},
|
||||
},
|
||||
library: {
|
||||
|
||||
@@ -37,8 +37,13 @@ const ru: Translations = {
|
||||
username: 'Имя пользователя',
|
||||
password: 'Пароль',
|
||||
submit: 'Подключиться',
|
||||
stubNote:
|
||||
'Режим заглушки — сервер не подключён. Создаётся фиктивная сессия администратора для этого экземпляра.',
|
||||
submitting: 'Подключение…',
|
||||
},
|
||||
errors: {
|
||||
unreachable:
|
||||
'Не удаётся подключиться к серверу. Проверьте URL и доступность.',
|
||||
badCredentials: 'Неверное имя пользователя или пароль.',
|
||||
generic: 'Не удалось войти. Попробуйте ещё раз.',
|
||||
},
|
||||
},
|
||||
library: {
|
||||
|
||||
@@ -38,12 +38,16 @@ export const authSlice = createSlice({
|
||||
action: PayloadAction<{
|
||||
accessToken: string;
|
||||
refreshToken: string;
|
||||
expiresIn: number;
|
||||
expiresIn?: number;
|
||||
}>,
|
||||
) {
|
||||
state.accessToken = action.payload.accessToken;
|
||||
state.refreshToken = action.payload.refreshToken;
|
||||
state.expiresAt = Date.now() + action.payload.expiresIn * 1000;
|
||||
// Backends that omit a TTL leave expiresAt null — reauth is 401-driven.
|
||||
state.expiresAt =
|
||||
action.payload.expiresIn != null
|
||||
? Date.now() + action.payload.expiresIn * 1000
|
||||
: null;
|
||||
persistAuth(state);
|
||||
},
|
||||
setUser(state, action: PayloadAction<User>) {
|
||||
|
||||
Reference in New Issue
Block a user