Skip to content
Draft
Show file tree
Hide file tree
Changes from all commits
Commits
Show all changes
38 commits
Select commit Hold shift + click to select a range
bd2b642
fix: re-add focus ring on search bar component
Bentroen Dec 26, 2025
64171f6
fix: improve accessibility on search bar component
Bentroen Dec 26, 2025
32934af
fix: make WebKit clear button white on search bar
Bentroen Dec 26, 2025
bb873f6
fix: disable autocomplete and spell check for search bar
Bentroen Dec 26, 2025
d08680b
fix: adjust disposition of navbar icons after adding search bar
Bentroen Dec 28, 2025
9d047d5
fix: adjust navbar icon to use same hover area for tooltip and effects
Bentroen Dec 28, 2025
54560c1
fix: standardize cursor to pointer in settings button to match upload…
Bentroen Dec 28, 2025
79b086f
fix: add hover animation for upload button as well
Bentroen Dec 28, 2025
7d97bc4
fix: adjust settings cog icon hover rotation from 45 to 30 degrees
Bentroen Dec 28, 2025
1732988
fix: default bevel color blending with navbar background
Bentroen Dec 28, 2025
4dd52bc
fix: scrollbar thumb appearing invisible
Bentroen Dec 28, 2025
0dce075
chore: fix "unknown at rule `@apply`" in CSS files
Bentroen Dec 28, 2025
13e8cec
feat: update VS Code recommended extensions
Bentroen Dec 28, 2025
cf35d63
fix: Lato font not being applied to body
Bentroen Dec 29, 2025
51a159e
fix: simplify search page title component
Bentroen Dec 29, 2025
d83d045
fix: remove loading overlays and initial loading UI
Bentroen Dec 29, 2025
29ad803
feat: add button to switch ordering when sorting searched song list
Bentroen Dec 29, 2025
0097875
style: reorder imports
Bentroen Dec 29, 2025
9e9da07
fix: improve UI for no search results
Bentroen Dec 29, 2025
b950a96
fix: remove search filtering functionality display (postponed)
Bentroen Dec 29, 2025
bf8702a
fix: remove "You've reached the end" text when you've reached the end
Bentroen Dec 29, 2025
a3b273c
fix: adjust padding in sort options dropdown selector
Bentroen Dec 29, 2025
7341179
fix: replace 'Uploader' sort option with 'Duration' and 'Note count'
Bentroen Dec 29, 2025
5175f77
fix: adjust order button position in search page header layout
Bentroen Dec 30, 2025
a6b1d1c
refactor: remove unused `isFilterChange` state
Bentroen Dec 30, 2025
0fea4c1
fix: implement skeleton loading placeholders when loading more songs
Bentroen Dec 30, 2025
2bf0d11
fix: standardize page size to 12 on loading and fetching
Bentroen Dec 30, 2025
1132105
style: line break
Bentroen Dec 30, 2025
fe2edce
fix: adjust top margin of load more button
Bentroen Dec 30, 2025
3a72f1a
refactor: remove unused loading state reference in search results
Bentroen Dec 30, 2025
e0b9231
chore: add `nuqs` as dependency for query state management
Bentroen Dec 30, 2025
a5f7f95
refactor: replace Next.js `useSearchParams` with nuqs `useQueryStates`
Bentroen Dec 30, 2025
14c3e01
refactor: replace hardcoded strings with enums in sort & order options
Bentroen Dec 30, 2025
e1f32dd
fix: use camelCase instead of kebab-case in sort options
Bentroen Dec 30, 2025
3029a6f
fix: redefine sort and order enums inline
Bentroen Dec 30, 2025
7602b42
feat: implement generic song query method supporting filtering + sorting
Bentroen Dec 30, 2025
c915db6
test: update tests for song controller and service
Bentroen Dec 30, 2025
7141ccd
perf(db): create indices for frequently queried fields
Bentroen Dec 30, 2025
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
5 changes: 4 additions & 1 deletion .vscode/extensions.json
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,9 @@
"dbaeumer.vscode-eslint",
"esbenp.prettier-vscode",
"unifiedjs.vscode-mdx",
"orta.vscode-jest"
"orta.vscode-jest",
"bradlc.vscode-tailwindcss",
"fill-labs.dependi",
"gruntfuggly.todo-tree"
]
}
4 changes: 4 additions & 0 deletions .vscode/settings.json
Original file line number Diff line number Diff line change
Expand Up @@ -12,5 +12,9 @@
"eslint.format.enable": true,
"editor.codeActionsOnSave": {
"source.fixAll.eslint": "always"
},
"files.associations": {
".css": "tailwindcss",
"*.scss": "tailwindcss"
}
}
42 changes: 30 additions & 12 deletions apps/backend/src/song/song.controller.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -26,16 +26,14 @@ import { SongService } from './song.service';

const mockSongService = {
getSongByPage: jest.fn(),
searchSongs: jest.fn(),
querySongs: jest.fn(),
getSong: jest.fn(),
getSongEdit: jest.fn(),
patchSong: jest.fn(),
getSongDownloadUrl: jest.fn(),
deleteSong: jest.fn(),
uploadSong: jest.fn(),
getRandomSongs: jest.fn(),
getRecentSongs: jest.fn(),
getSongsByCategory: jest.fn(),
getSongsForTimespan: jest.fn(),
getSongsBeforeTimespan: jest.fn(),
getCategories: jest.fn(),
Expand Down Expand Up @@ -97,13 +95,13 @@ describe('SongController', () => {
const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' };
const songList: SongPreviewDto[] = [];

mockSongService.searchSongs.mockResolvedValueOnce(songList);
mockSongService.querySongs.mockResolvedValueOnce(songList);

const result = await songController.getSongList(query);

expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
expect(songService.searchSongs).toHaveBeenCalled();
expect(songService.querySongs).toHaveBeenCalled();
});

it('should handle random sort', async () => {
Expand Down Expand Up @@ -161,13 +159,22 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];

mockSongService.getRecentSongs.mockResolvedValueOnce(songList);
mockSongService.querySongs.mockResolvedValueOnce(songList);

const result = await songController.getSongList(query);

expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
expect(songService.getRecentSongs).toHaveBeenCalledWith(1, 10);
expect(songService.querySongs).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
limit: 10,
sort: 'createdAt',
order: true,
}),
undefined,
undefined,
);
});

it('should handle recent sort with category', async () => {
Expand All @@ -179,13 +186,22 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];

mockSongService.getSongsByCategory.mockResolvedValueOnce(songList);
mockSongService.querySongs.mockResolvedValueOnce(songList);

const result = await songController.getSongList(query);

expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
expect(songService.getSongsByCategory).toHaveBeenCalledWith('pop', 1, 10);
expect(songService.querySongs).toHaveBeenCalledWith(
expect.objectContaining({
page: 1,
limit: 10,
sort: 'createdAt',
order: true,
}),
undefined,
'pop',
);
});

it('should handle category filter', async () => {
Expand All @@ -196,16 +212,18 @@ describe('SongController', () => {
};
const songList: SongPreviewDto[] = [];

mockSongService.getSongsByCategory.mockResolvedValueOnce(songList);
mockSongService.querySongs.mockResolvedValueOnce(songList);

const result = await songController.getSongList(query);

expect(result).toBeInstanceOf(PageDto);
expect(result.content).toEqual(songList);
expect(songService.getSongsByCategory).toHaveBeenCalledWith(
'rock',
expect(songService.getSongsBySortAndCategory).toHaveBeenCalledWith(
'createdAt',
true,
1,
10,
'rock',
);
});

Expand Down
97 changes: 18 additions & 79 deletions apps/backend/src/song/song.controller.ts
Original file line number Diff line number Diff line change
Expand Up @@ -77,7 +77,7 @@ export class SongController {

**Query Parameters:**
- \`q\`: Search string to filter songs by title or description (optional)
- \`sort\`: Sort songs by criteria (recent, random, play-count, title, duration, note-count)
- \`sort\`: Sort songs by criteria (recent, random, playCount, title, duration, noteCount)
- \`order\`: Sort order (asc, desc) - only applies if sort is not random
- \`category\`: Filter by category - if left empty, returns songs in any category
- \`uploader\`: Filter by uploader username - if provided, will only return songs uploaded by that user
Expand All @@ -100,33 +100,6 @@ export class SongController {
public async getSongList(
@Query() query: SongListQueryDTO,
): Promise<PageDto<SongPreviewDto>> {
// Handle search query
if (query.q) {
const sortFieldMap = new Map([
[SongSortType.RECENT, 'createdAt'],
[SongSortType.PLAY_COUNT, 'playCount'],
[SongSortType.TITLE, 'title'],
[SongSortType.DURATION, 'duration'],
[SongSortType.NOTE_COUNT, 'noteCount'],
]);

const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';

const pageQuery = new PageQueryDTO({
page: query.page,
limit: query.limit,
sort: sortField,
order: query.order === 'desc' ? false : true,
});
const data = await this.songService.searchSongs(pageQuery, query.q);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Handle random sort
if (query.sort === SongSortType.RANDOM) {
if (query.limit && (query.limit < 1 || query.limit > 10)) {
Expand All @@ -147,67 +120,33 @@ export class SongController {
});
}

// Handle recent sort
if (query.sort === SongSortType.RECENT) {
// If category is provided, use getSongsByCategory (which also sorts by recent)
if (query.category) {
const data = await this.songService.getSongsByCategory(
query.category,
query.page,
query.limit,
);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

const data = await this.songService.getRecentSongs(
query.page,
query.limit,
);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Handle category filter
if (query.category) {
const data = await this.songService.getSongsByCategory(
query.category,
query.page,
query.limit,
);
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
limit: query.limit,
total: data.length,
});
}

// Default: get songs with standard pagination
const sortFieldMap = new Map([
// Map sort types to MongoDB field paths
const sortFieldMap = new Map<SongSortType, string>([
[SongSortType.RECENT, 'createdAt'],
[SongSortType.PLAY_COUNT, 'playCount'],
[SongSortType.TITLE, 'title'],
[SongSortType.DURATION, 'duration'],
[SongSortType.NOTE_COUNT, 'noteCount'],
[SongSortType.DURATION, 'stats.duration'],
[SongSortType.NOTE_COUNT, 'stats.noteCount'],
]);

const sortField = sortFieldMap.get(query.sort) ?? 'createdAt';
const isDescending = query.order ? query.order === 'desc' : true;

// Build PageQueryDTO with the sort field
const pageQuery = new PageQueryDTO({
page: query.page,
limit: query.limit,
sort: sortField,
order: query.order === 'desc' ? false : true,
order: isDescending,
});
const data = await this.songService.getSongByPage(pageQuery);

// Query songs with optional search and category filters
const data = await this.songService.querySongs(
pageQuery,
query.q,
query.category,
);

return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
Expand Down Expand Up @@ -323,7 +262,7 @@ export class SongController {
@Query() query: PageQueryDTO,
@Query('q') q: string,
): Promise<PageDto<SongPreviewDto>> {
const data = await this.songService.searchSongs(query, q ?? '');
const data = await this.songService.querySongs(query, q ?? '');
return new PageDto<SongPreviewDto>({
content: data,
page: query.page,
Expand Down
82 changes: 62 additions & 20 deletions apps/backend/src/song/song.service.spec.ts
Original file line number Diff line number Diff line change
Expand Up @@ -1028,11 +1028,15 @@ describe('SongService', () => {
});
});

describe('getSongsByCategory', () => {
it('should return a list of songs by category', async () => {
const category = 'test-category';
const page = 1;
const limit = 10;
describe('querySongs', () => {
it('should return songs sorted by field with optional category filter', async () => {
const query = {
page: 1,
limit: 10,
sort: 'stats.duration',
order: false,
};
const category = 'pop';
const songList: SongWithUser[] = [];

const mockFind = {
Expand All @@ -1045,20 +1049,22 @@ describe('SongService', () => {

jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);

const result = await service.getSongsByCategory(category, page, limit);
const result = await service.querySongs(query, undefined, category);

expect(result).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);

expect(songModel.find).toHaveBeenCalledWith({
category,
visibility: 'public',
category,
});

expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 });
expect(mockFind.skip).toHaveBeenCalledWith(page * limit - limit);
expect(mockFind.limit).toHaveBeenCalledWith(limit);
expect(mockFind.sort).toHaveBeenCalledWith({ 'stats.duration': 1 });
expect(mockFind.skip).toHaveBeenCalledWith(
(query.limit as number) * ((query.page as number) - 1),
);
expect(mockFind.limit).toHaveBeenCalledWith(query.limit);

expect(mockFind.populate).toHaveBeenCalledWith(
'uploader',
Expand All @@ -1067,12 +1073,14 @@ describe('SongService', () => {

expect(mockFind.exec).toHaveBeenCalled();
});
});

describe('getRecentSongs', () => {
it('should return recent songs', async () => {
const page = 1;
const limit = 10;
it('should work without category filter', async () => {
const query = {
page: 1,
limit: 10,
sort: 'createdAt',
order: true,
};
const songList: SongWithUser[] = [];

const mockFind = {
Expand All @@ -1085,16 +1093,50 @@ describe('SongService', () => {

jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);

const result = await service.getRecentSongs(page, limit);
const result = await service.querySongs(query);

expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' });
expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 });
expect(result).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);
});

expect(songModel.find).toHaveBeenCalledWith({ visibility: 'public' });
expect(mockFind.sort).toHaveBeenCalledWith({ createdAt: -1 });
expect(mockFind.skip).toHaveBeenCalledWith(page * limit - limit);
expect(mockFind.limit).toHaveBeenCalledWith(limit);
it('should search with text query and filters', async () => {
const query = {
page: 1,
limit: 10,
sort: 'playCount',
order: false,
};
const searchTerm = 'test song';
const category = 'rock';
const songList: SongWithUser[] = [];

const mockFind = {
sort: jest.fn().mockReturnThis(),
skip: jest.fn().mockReturnThis(),
limit: jest.fn().mockReturnThis(),
populate: jest.fn().mockReturnThis(),
exec: jest.fn().mockResolvedValue(songList),
};

jest.spyOn(songModel, 'find').mockReturnValue(mockFind as any);

const result = await service.querySongs(query, searchTerm, category);

expect(result).toEqual(
songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)),
);

expect(songModel.find).toHaveBeenCalledWith(
expect.objectContaining({
visibility: 'public',
category,
}),
);

expect(mockFind.sort).toHaveBeenCalledWith({ playCount: 1 });
});
});

Expand Down
Loading
Loading