diff --git a/.eslintrc.js b/.eslintrc.js index 675f56e9..9cada1ed 100644 --- a/.eslintrc.js +++ b/.eslintrc.js @@ -2,10 +2,11 @@ module.exports = { ignorePatterns: ['.eslintrc.js'], parserOptions: { parser: '@typescript-eslint/parser', - project: './tsconfig.json', + project: ['./tsconfig.json', './*/tsconfig.json'], tsconfigRootDir: __dirname, sourceType: 'module', ecmaVersion: 2021, + createDefaultProgram: false, }, plugins: ['@typescript-eslint', 'prettier', 'import', 'unused-imports'], // Merged plugins from both files extends: [ @@ -36,8 +37,13 @@ module.exports = { ], '@typescript-eslint/lines-between-class-members': [ 'warn', - 'always', - { exceptAfterSingleLine: true }, + { + enforce: [ + { blankLine: 'any', prev: '*', next: 'field' }, + { blankLine: 'any', prev: 'field', next: '*' }, + { blankLine: 'always', prev: '*', next: 'method' }, + ], + }, ], 'padding-line-between-statements': 'off', '@typescript-eslint/padding-line-between-statements': [ diff --git a/.git-blame-ignore-revs b/.git-blame-ignore-revs index 88c7be3f..5a8fa87c 100644 --- a/.git-blame-ignore-revs +++ b/.git-blame-ignore-revs @@ -3,3 +3,5 @@ # Linting commits 6efc953b9f7f648f2be59295a78ce1180f12b32d +f52bdcb555ffa1ed02ae8918b04500c9cee52bfd +23b7f2d5ca99b69ea1e50667fdb9c9795d55195f diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace index cb438c31..f8e4ec42 100644 --- a/NoteBlockWorld.code-workspace +++ b/NoteBlockWorld.code-workspace @@ -1,36 +1,46 @@ { - "folders": [ - { - "path": ".", - "name": "Root" - }, - { - "path": "./server", - "name": "Backend" - }, - { - "path": "./shared", - "name": "Shared" - }, - { - "path": "./web", - "name": "Frontend" + "folders": [ + { + "path": ".", + "name": "Root" + }, + { + "path": "./server", + "name": "Backend" + }, + { + "path": "./shared", + "name": "Shared" + }, + { + "path": "./web", + "name": "Frontend" + } + ], + "settings": { + "window.title": "${dirty}${rootName}", + "editor.formatOnSave": true, + "eslint.validate": [ + "typescript" + ], + "eslint.run": "onType", + "eslint.format.enable": true, + "mdx.server.enable": true, + "editor.codeActionsOnSave": { + "source.fixAll": "explicit" + }, + "jest.disabledWorkspaceFolders": [ + "Root", + "Frontend" + ], + "search.exclude": { + "**/.git": true, + "**/node_modules": true, + "**/dist": true, + }, + "cSpell.words": [ + "Bentroen", + "Tomast" + ] } - ], - "settings": { - "editor.formatOnSave": true, - "eslint.validate": ["typescript"], - "eslint.run": "onType", - "eslint.format.enable": true, - "mdx.server.enable": true, - "editor.codeActionsOnSave": { - "source.fixAll": "explicit" - }, - "jest.disabledWorkspaceFolders": ["Root", "Frontend"], - "search.exclude": { - "**/.git": true, - "**/node_modules": true, - "**/dist": true, - } - } -} +} \ No newline at end of file diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 22a51f29..3f567c11 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -422,6 +422,9 @@ importers: zod-validation-error: specifier: ^3.4.0 version: 3.4.0(zod@3.24.1) + zustand: + specifier: ^5.0.3 + version: 5.0.3(@types/react@18.2.18)(react@18.2.0) devDependencies: '@shrutibalasa/tailwind-grid-auto-fit': specifier: ^1.1.0 @@ -6989,6 +6992,24 @@ packages: zod@3.24.1: resolution: {integrity: sha512-muH7gBL9sI1nciMZV67X5fTKKBLtwpZ5VBp1vsOQzj1MhrBZ4wlVCm3gedKZWLp0Oyel8sIGfeiz54Su+OVT+A==} + zustand@5.0.3: + resolution: {integrity: sha512-14fwWQtU3pH4dE0dOpdMiWjddcH+QzKIgk1cl8epwSE7yag43k/AD/m4L6+K7DytAOr9gGBe3/EXj9g7cdostg==} + engines: {node: '>=12.20.0'} + peerDependencies: + '@types/react': '>=18.0.0' + immer: '>=9.0.6' + react: '>=18.0.0' + use-sync-external-store: '>=1.2.0' + peerDependenciesMeta: + '@types/react': + optional: true + immer: + optional: true + react: + optional: true + use-sync-external-store: + optional: true + zwitch@2.0.4: resolution: {integrity: sha512-bXE4cR/kVZhKZX/RjPEflHaKVhUVl85noU3v6b8apfQEc1x4A+zBxjZ4lN8LqGd6WZ3dl98pY4o717VFmoPp+A==} @@ -15739,4 +15760,9 @@ snapshots: zod@3.24.1: {} + zustand@5.0.3(@types/react@18.2.18)(react@18.2.0): + optionalDependencies: + '@types/react': 18.2.18 + react: 18.2.0 + zwitch@2.0.4: {} diff --git a/server/src/auth/auth.controller.ts b/server/src/auth/auth.controller.ts index 66e29486..7a4c430f 100644 --- a/server/src/auth/auth.controller.ts +++ b/server/src/auth/auth.controller.ts @@ -22,6 +22,7 @@ import { MagicLinkEmailStrategy } from './strategies/magicLinkEmail.strategy'; @ApiTags('auth') export class AuthController { private readonly logger = new Logger(AuthController.name); + constructor( @Inject(AuthService) private readonly authService: AuthService, diff --git a/server/src/auth/auth.service.ts b/server/src/auth/auth.service.ts index a28ca3eb..ce9ab147 100644 --- a/server/src/auth/auth.service.ts +++ b/server/src/auth/auth.service.ts @@ -16,6 +16,7 @@ import { TokenPayload, Tokens } from './types/token'; @Injectable() export class AuthService { private readonly logger = new Logger(AuthService.name); + constructor( @Inject(UserService) private readonly userService: UserService, diff --git a/server/src/auth/strategies/JWT.strategy.ts b/server/src/auth/strategies/JWT.strategy.ts index 6311d4e4..a06c76e7 100644 --- a/server/src/auth/strategies/JWT.strategy.ts +++ b/server/src/auth/strategies/JWT.strategy.ts @@ -7,6 +7,7 @@ import { ExtractJwt, Strategy } from 'passport-jwt'; @Injectable() export class JwtStrategy extends PassportStrategy(Strategy, 'jwt-refresh') { private static logger = new Logger(JwtStrategy.name); + constructor(@Inject(ConfigService) config: ConfigService) { const JWT_SECRET = config.getOrThrow('JWT_SECRET'); diff --git a/server/src/auth/strategies/discord.strategy/Strategy.ts b/server/src/auth/strategies/discord.strategy/Strategy.ts index d0c16949..5ca74604 100644 --- a/server/src/auth/strategies/discord.strategy/Strategy.ts +++ b/server/src/auth/strategies/discord.strategy/Strategy.ts @@ -35,6 +35,7 @@ export default class Strategy extends OAuth2Strategy { private fetchScopeEnabled: boolean; public override name = 'discord'; prompt?: string; + public constructor(options: DiscordStrategyConfig, verify: VerifyFunction) { super( { diff --git a/server/src/auth/strategies/discord.strategy/index.ts b/server/src/auth/strategies/discord.strategy/index.ts index 61dc578a..9cb2a49f 100644 --- a/server/src/auth/strategies/discord.strategy/index.ts +++ b/server/src/auth/strategies/discord.strategy/index.ts @@ -8,6 +8,7 @@ import { DiscordPermissionScope } from './types'; @Injectable() export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { private static logger = new Logger(DiscordStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/auth/strategies/github.strategy.ts b/server/src/auth/strategies/github.strategy.ts index 27293151..f3ee1d33 100644 --- a/server/src/auth/strategies/github.strategy.ts +++ b/server/src/auth/strategies/github.strategy.ts @@ -6,6 +6,7 @@ import strategy from 'passport-github'; @Injectable() export class GithubStrategy extends PassportStrategy(strategy, 'github') { private static logger = new Logger(GithubStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/auth/strategies/google.strategy.ts b/server/src/auth/strategies/google.strategy.ts index a19e1789..e219916c 100644 --- a/server/src/auth/strategies/google.strategy.ts +++ b/server/src/auth/strategies/google.strategy.ts @@ -6,6 +6,7 @@ import { Strategy, VerifyCallback } from 'passport-google-oauth20'; @Injectable() export class GoogleStrategy extends PassportStrategy(Strategy, 'google') { private static logger = new Logger(GoogleStrategy.name); + constructor( @Inject(ConfigService) configService: ConfigService, diff --git a/server/src/song/entity/song.entity.ts b/server/src/song/entity/song.entity.ts index a678aecb..13722e95 100644 --- a/server/src/song/entity/song.entity.ts +++ b/server/src/song/entity/song.entity.ts @@ -24,10 +24,10 @@ export class Song { publicId: string; @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - createdAt: Date; + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - updatedAt: Date; + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html @Prop({ type: MongooseSchema.Types.ObjectId, required: true, ref: 'User' }) uploader: Types.ObjectId; @@ -57,7 +57,7 @@ export class Song { @Prop({ type: ThumbnailData, required: true }) thumbnailData: ThumbnailData; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) category: CategoryType; @Prop({ type: String, required: true }) @@ -72,13 +72,13 @@ export class Song { @Prop({ type: Boolean, required: true, default: true }) allowDownload: boolean; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) title: string; - @Prop({ type: String, required: false }) + @Prop({ type: String, required: false, index: true }) originalAuthor: string; - @Prop({ type: String, required: false }) + @Prop({ type: String, required: false, index: true }) description: string; // SONG FILE ATTRIBUTES (Populated from NBS file - immutable) diff --git a/server/src/song/song.service.spec.ts b/server/src/song/song.service.spec.ts index 48536450..e5416761 100644 --- a/server/src/song/song.service.spec.ts +++ b/server/src/song/song.service.spec.ts @@ -10,6 +10,7 @@ import mongoose, { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; import { SongDocument, @@ -39,10 +40,17 @@ const mockSongWebhookService = { syncSongWebhook: jest.fn(), }; +const mockUserService = { + getUserByEmailOrId: jest.fn(), + getUserPaginated: jest.fn(), + getSelfUserData: jest.fn(), +}; + describe('SongService', () => { let service: SongService; let fileService: FileService; let songUploadService: SongUploadService; + let userService: UserService; let songModel: Model; beforeEach(async () => { @@ -65,12 +73,17 @@ describe('SongService', () => { provide: SongUploadService, useValue: mockSongUploadService, }, + { + provide: UserService, + useValue: mockUserService, + }, ], }).compile(); service = module.get(SongService); fileService = module.get(FileService); songUploadService = module.get(SongUploadService); + userService = module.get(UserService); songModel = module.get>(getModelToken(SongEntity.name)); }); diff --git a/server/src/song/song.service.ts b/server/src/song/song.service.ts index 07345e65..5a65dc61 100644 --- a/server/src/song/song.service.ts +++ b/server/src/song/song.service.ts @@ -7,6 +7,7 @@ import { } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { BROWSER_SONGS } from '@shared/validation/song/constants'; import { SongPageDto } from '@shared/validation/song/dto/SongPageDto'; import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; @@ -17,6 +18,7 @@ import { Model } from 'mongoose'; import { FileService } from '@server/file/file.service'; import { UserDocument } from '@server/user/entity/user.entity'; +import { UserService } from '@server/user/user.service'; import { Song as SongEntity, SongWithUser } from './entity/song.entity'; import { SongUploadService } from './song-upload/song-upload.service'; @@ -26,6 +28,7 @@ import { removeExtraSpaces } from './song.util'; @Injectable() export class SongService { private logger = new Logger(SongService.name); + constructor( @InjectModel(SongEntity.name) private songModel: Model, @@ -38,6 +41,9 @@ export class SongService { @Inject(SongWebhookService) private songWebhookService: SongWebhookService, + + @Inject(UserService) + private userService: UserService, ) {} public async getSongById(publicId: string) { @@ -190,9 +196,27 @@ export class SongService { ); } + const filter = {}; + + /* + // TODO: Decide if user filtering is necessary + if (user) { + const userDocument = await this.userService.findByUsername(user); + + if (!userDocument) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + filter = { + uploader: userDocument._id, + }; + } + */ + const songs = (await this.songModel .find({ visibility: 'public', + ...filter, }) .sort({ [sort]: order ? 1 : -1, @@ -469,7 +493,135 @@ export class SongService { return songs.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)); } + public async search(queryBody: SearchQueryDTO) { + const { + query = '', + page = 1, + limit = 10, + sort = 'createdAt', + order, + category, + } = queryBody; + + const skip = (page - 1) * limit; + const sortOrder = order ? 1 : -1; + + const songs: SongViewDto[] = await this.songModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + ...(category && { category: category }), + }, + }, + { + $sort: { + [sort]: sortOrder, + }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + { + $lookup: { + from: 'users', // The collection to join + localField: 'uploader', // The field from the input documents (username) + foreignField: 'username', // The field from the documents of the "from" collection (username) + as: 'uploader', // The name of the new array field to add to the input documents + }, + }, + { + $unwind: '$uploader', // Unwind the array to include the user document directly + }, + { + $project: { + publicId: 1, + createdAt: 1, + thumbnailUrl: 1, + playCount: 1, + downloadCount: 1, + likeCount: 1, + allowDownload: 1, + title: 1, + originalAuthor: 1, + description: 1, + category: 1, + license: 1, + customInstruments: 1, + fileSize: 1, + stats: 1, + 'uploader.username': 1, + 'uploader.profileImage': 1, + }, + }, + ]); + + const totalResult = await this.songModel.aggregate([ + { + /** + $search: { + index: 'song_search_index', + text: { + query: query, + }, + }, + */ + $match: { + $text: { + $search: query, + $caseSensitive: false, // Case-insensitive search + $diacriticSensitive: false, // Diacritic-insensitive search + }, + ...(category && { category: category }), + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; + + this.logger.debug( + `Retrieved songs: ${songs.length} documents, with total: ${total}`, + ); + + return { + songs: await this.songModel.populate(songs, { + path: 'uploader', + select: 'username profileImage -_id', + }), + total, + page, + limit, + }; + } + public async getAllSongs() { return this.songModel.find({}); } + + public async createSearchIndexes() { + return this.songModel.collection.createIndex( + { + title: 'text', + originalAuthor: 'text', + description: 'text', + }, + { + weights: { + title: 10, + originalAuthor: 5, + description: 1, + }, + name: 'song_search_index', + }, + ); + } } diff --git a/server/src/user/dto/user.dto.ts b/server/src/user/dto/user.dto.ts index a611c20f..2d74ccad 100644 --- a/server/src/user/dto/user.dto.ts +++ b/server/src/user/dto/user.dto.ts @@ -4,6 +4,7 @@ export class UserDto { username: string; publicName: string; email: string; + static fromEntity(user: User): UserDto { const userDto: UserDto = { username: user.username, diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index 87748e9b..c42868d4 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -1,8 +1,8 @@ import { Prop, Schema, SchemaFactory } from '@nestjs/mongoose'; -import { HydratedDocument, Schema as MongooseSchema } from 'mongoose'; +import { HydratedDocument, Schema as MongooseSchema, Types } from 'mongoose'; @Schema({}) -class SocialLinks { +export class SocialLinks { bandcamp?: string; discord?: string; facebook?: string; @@ -53,10 +53,10 @@ export class User { @Prop({ type: Number, required: true, default: 0 }) playCount: number; - @Prop({ type: String, required: true }) + @Prop({ type: String, required: true, index: true }) username: string; - @Prop({ type: String, required: true, default: '#' }) + @Prop({ type: String, required: true, default: '#', index: true }) publicName: string; @Prop({ type: String, required: true, unique: true }) @@ -84,23 +84,11 @@ export class User { @Prop({ type: Boolean, required: true, default: true }) prefersDarkTheme: boolean; - @Prop({ type: Array, required: true, default: [] }) - likedSongs: string[]; + _id: Types.ObjectId; - @Prop({ type: Array, required: true, default: [] }) - following: string[]; + createdAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html - @Prop({ type: Array, required: true, default: [] }) - likedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - dislikedComments: string[]; - - @Prop({ type: Array, required: true, default: [] }) - notifications: string[]; - - @Prop({ type: Array, required: true, default: [] }) - achievements: string[]; + updatedAt: Date; // Added automatically by Mongoose: https://mongoosejs.com/docs/timestamps.html } export const UserSchema = SchemaFactory.createForClass(User); diff --git a/server/src/user/user.controller.ts b/server/src/user/user.controller.ts index 571a59e9..6abffb88 100644 --- a/server/src/user/user.controller.ts +++ b/server/src/user/user.controller.ts @@ -1,8 +1,20 @@ -import { Body, Controller, Get, Inject, Patch, Query } from '@nestjs/common'; +import { + Body, + Controller, + Get, + Inject, + NotFoundException, + Param, + Patch, + Query, +} from '@nestjs/common'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; -import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; -import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; +import { PageResultDTO } from '@shared/validation/common/dto/PageResult.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; +import { UserQuery } from '@shared/validation/user/dto/UserQuery.dto'; +import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; import { GetRequestToken, validateUser } from '@server/lib/GetRequestUser'; @@ -10,6 +22,8 @@ import { UserDocument } from './entity/user.entity'; import { UserService } from './user.service'; @Controller('user') +@ApiTags('user') +@ApiBearerAuth() export class UserController { constructor( @Inject(UserService) @@ -18,36 +32,48 @@ export class UserController { @Get() @ApiTags('user') - @ApiBearerAuth() - async getUser(@Query() query: GetUser) { - return await this.userService.getUserByEmailOrId(query); - } + @ApiOperation({ summary: 'Get user data' }) + async getUser( + @Query() query: UserQuery, + @GetRequestToken() user: UserDocument | null, + ) { + if ('me' in query && query.me) { + user = validateUser(user); + return await this.userService.getSelfUserData(user); + } - @Get() - @ApiTags('user') - @ApiBearerAuth() - async getUserPaginated(@Query() query: PageQueryDTO) { - return await this.userService.getUserPaginated(query); + const docs = await this.userService.getUserPaginated(query as PageQueryDTO); + + return new PageResultDTO({ + ...docs, + data: docs.data.map((doc) => UserSearchViewDto.fromUserDocument(doc)), + }); } - @Get('me') + @Get(':username') @ApiTags('user') - @ApiBearerAuth() - @ApiOperation({ summary: 'Get the token owner data' }) - async getMe(@GetRequestToken() user: UserDocument | null) { - user = validateUser(user); - return await this.userService.getSelfUserData(user); + @ApiOperation({ summary: 'Get user profile by username' }) + async getUserProfile( + @Param('username') username: string, + ): Promise { + const doc = await this.userService.findByUsername(username); + + if (!doc) { + throw new NotFoundException('User not found'); + } + + return UserProfileViewDto.fromUserDocument(doc); } - @Patch('username') + @Patch() @ApiTags('user') @ApiBearerAuth() - @ApiOperation({ summary: 'Update the username' }) - async updateUsername( + @ApiOperation({ summary: 'Update the profile' }) + async updateProfile( @GetRequestToken() user: UserDocument | null, - @Body() body: UpdateUsernameDto, + @Body() body: UpdateUserProfileDto, ) { user = validateUser(user); - return await this.userService.updateUsername(user, body); + return await this.userService.updateProfile(user, body); } } diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index 8477b011..9382b5f5 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -4,6 +4,7 @@ import { Test, TestingModule } from '@nestjs/testing'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; import { Model } from 'mongoose'; import { User, UserDocument } from './entity/user.entity'; @@ -18,6 +19,7 @@ const mockUserModel = { exec: jest.fn(), select: jest.fn(), countDocuments: jest.fn(), + findOneAndUpdate: jest.fn(), }; describe('UserService', () => { @@ -519,4 +521,72 @@ describe('UserService', () => { ); }); }); + + describe('updateProfile', () => { + it('should update the user profile successfully', async () => { + const user = { + _id: 'userId', + description: 'old description', + socialLinks: {}, + username: 'oldUsername', + } as unknown as UserDocument; + + const body: UpdateUserProfileDto = { + description: 'new description', + socialLinks: { github: 'https://github.com/newuser' }, + username: 'newUsername', + }; + + const updatedUser = { + ...user, + ...body, + }; + + jest + .spyOn(userModel, 'findOneAndUpdate') + .mockResolvedValue(updatedUser as any); + + const result = await service.updateProfile(user, body); + + expect(result).toEqual(updatedUser); + + expect(userModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: user._id }, + user, + { new: true }, + ); + }); + + it('should update only provided fields', async () => { + const user = { + _id: 'userId', + description: 'old description', + socialLinks: {}, + username: 'oldUsername', + } as unknown as UserDocument; + + const body: UpdateUserProfileDto = { + description: 'new description', + }; + + const updatedUser = { + ...user, + description: 'new description', + }; + + jest + .spyOn(userModel, 'findOneAndUpdate') + .mockResolvedValue(updatedUser as any); + + const result = await service.updateProfile(user, body); + + expect(result).toEqual(updatedUser); + + expect(userModel.findOneAndUpdate).toHaveBeenCalledWith( + { _id: user._id }, + user, + { new: true }, + ); + }); + }); }); diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 412da11b..87e38980 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -1,9 +1,12 @@ -import { HttpException, HttpStatus, Injectable } from '@nestjs/common'; +import { HttpException, HttpStatus, Injectable, Logger } from '@nestjs/common'; import { InjectModel } from '@nestjs/mongoose'; import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; +import { SearchQueryDTO } from '@shared/validation/common/dto/SearchQuery.dto'; import { CreateUser } from '@shared/validation/user/dto/CreateUser.dto'; import { GetUser } from '@shared/validation/user/dto/GetUser.dto'; import { UpdateUsernameDto } from '@shared/validation/user/dto/UpdateUsername.dto'; +import { UpdateUserProfileDto } from '@shared/validation/user/dto/UpdateUserProfile.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import { validate } from 'class-validator'; import { Model } from 'mongoose'; @@ -12,6 +15,8 @@ import { User, UserDocument } from './entity/user.entity'; @Injectable() export class UserService { + private readonly logger = new Logger(UserService.name); + constructor(@InjectModel(User.name) private userModel: Model) {} public async create(user_registered: CreateUser) { @@ -70,16 +75,8 @@ export class UserService { return user; } - public async findByPublicName( - publicName: string, - ): Promise { - const user = await this.userModel.findOne({ publicName }); - - return user; - } - public async findByUsername(username: string): Promise { - const user = await this.userModel.findOne({ username }); + const user = await this.userModel.findOne({ username }).exec(); return user; } @@ -87,16 +84,125 @@ export class UserService { public async getUserPaginated(query: PageQueryDTO) { const { page = 1, limit = 10, sort = 'createdAt', order = 'asc' } = query; + const queryText = query.query; + const skip = (page - 1) * limit; const sortOrder = order === 'asc' ? 1 : -1; - const users = await this.userModel - .find({}) - .sort({ [sort]: sortOrder }) - .skip(skip) - .limit(limit); + const users: (UserDocument & { songCount: number })[] = + await this.userModel.aggregate([ + { + $match: queryText + ? { + username: { $regex: queryText, $options: 'i' }, // Case-insensitive regex search + } + : { + _id: { $exists: true }, + }, // If no search query, match all documents + }, + { + $lookup: { + from: 'songs', // The name of the songs collection + localField: '_id', // The field from the users collection + foreignField: 'userId', // The field from the songs collection + as: 'songs', // The array field that will contain the joined songs + }, + }, + { + $addFields: { + songCount: { $size: '$songs' }, // Add a new field with the count of songs + }, + }, + { + $project: { + songs: 0, // Exclude the songs array from the final output + }, + }, + { + $sort: { [sort]: sortOrder }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + ]); + + const total = await this.userModel.countDocuments( + queryText ? { username: { $regex: queryText, $options: 'i' } } : {}, + ); + + return { + data: users, + total, + page, + limit, + }; + } - const total = await this.userModel.countDocuments(); + public async search(queryBody: SearchQueryDTO) { + const { + query = '', + page = 1, + limit = 10, + sort = 'createdAt', + order, + } = queryBody; + + const skip = (page - 1) * limit; + const sortOrder = order ? 1 : -1; + + const users: { + username: string; + profileImage: string; + }[] = await this.userModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + }, + }, + { + $project: { + username: 1, + profileImage: 1, + }, + }, + { + $sort: { [sort]: sortOrder }, + }, + { + $skip: skip, + }, + { + $limit: limit, + }, + ]); + + const totalResult = await this.userModel.aggregate([ + { + $match: { + $text: { + $search: query, + $caseSensitive: false, + $diacriticSensitive: false, + }, + }, + }, + { + $count: 'total', + }, + ]); + + const total = totalResult.length > 0 ? totalResult[0].total : 0; + + this.logger.debug( + `Retrived users: ${users.length} documents, with total: ${total}`, + ); return { users, @@ -108,26 +214,26 @@ export class UserService { public async getUserByEmailOrId(query: GetUser) { const { email, id, username } = query; + let user; if (email) { - return await this.findByEmail(email); - } - - if (id) { - return await this.findByID(id); - } - - if (username) { + user = await this.findByEmail(email); + } else if (id) { + user = await this.findByID(id); + } else if (username) { + user = await this.findByUsername(username); + } else { throw new HttpException( - 'Username is not supported yet', + 'You must provide an email, ID or username', HttpStatus.BAD_REQUEST, ); } - throw new HttpException( - 'You must provide an email or an id', - HttpStatus.BAD_REQUEST, - ); + if (!user) { + throw new HttpException('User not found', HttpStatus.NOT_FOUND); + } + + return UserProfileViewDto.fromUserDocument(user); } public async getHydratedUser(user: UserDocument) { @@ -226,4 +332,34 @@ export class UserService { return UserDto.fromEntity(user); } + + public async updateProfile(user: UserDocument, body: UpdateUserProfileDto) { + const { description, socialLinks, username } = body; + + if (description) user.description = description; + if (socialLinks) user.socialLinks = socialLinks; + if (username) user.username = username; + + return await this.userModel.findOneAndUpdate({ _id: user._id }, user, { + new: true, + }); + } + + public async createSearchIndexes() { + return await this.userModel.collection.createIndex( + { + username: 'text', + publicName: 'text', + description: 'text', + }, + { + weights: { + username: 5, + publicName: 3, + description: 1, + }, + name: 'user_search_index', + }, + ); + } } diff --git a/shared/validation/common/deepFreeze.spec.ts b/shared/validation/common/deepFreeze.spec.ts new file mode 100644 index 00000000..88e296dc --- /dev/null +++ b/shared/validation/common/deepFreeze.spec.ts @@ -0,0 +1,69 @@ +import { deepFreeze } from './deepFreeze'; + +describe('deepFreeze', () => { + it('should deeply freeze an object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(Object.isFrozen(frozenObj)).toBe(true); + expect(Object.isFrozen(frozenObj.b)).toBe(true); + expect(Object.isFrozen(frozenObj.b.d)).toBe(true); + }); + + it('should not allow modification of a deeply frozen object', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.a = 2; + }).toThrow(TypeError); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.b.c = 3; + }).toThrow(TypeError); + + expect(() => { + // eslint-disable-next-line @typescript-eslint/ban-ts-comment + // @ts-ignore + frozenObj.b.d.e = 4; + }).toThrow(TypeError); + }); + + it('should return the same object reference', () => { + const obj = { + a: 1, + b: { + c: 2, + d: { + e: 3, + }, + }, + }; + + const frozenObj = deepFreeze(obj); + + expect(frozenObj).toBe(obj); + }); +}); diff --git a/shared/validation/common/deepFreeze.ts b/shared/validation/common/deepFreeze.ts index 71b93589..adcb029d 100644 --- a/shared/validation/common/deepFreeze.ts +++ b/shared/validation/common/deepFreeze.ts @@ -1,6 +1,6 @@ -export function deepFreeze( +export const deepFreeze = ( object: T, -): Readonly { +): Readonly => { const propNames = Object.getOwnPropertyNames(object); for (const name of propNames) { @@ -13,4 +13,4 @@ export function deepFreeze( } return Object.freeze(object); -} +}; diff --git a/shared/validation/common/dto/PageQuery.dto.ts b/shared/validation/common/dto/PageQuery.dto.ts index b1ea53d3..8004f843 100644 --- a/shared/validation/common/dto/PageQuery.dto.ts +++ b/shared/validation/common/dto/PageQuery.dto.ts @@ -64,6 +64,16 @@ export class PageQueryDTO { }) timespan?: TimespanType; + @IsString() + @IsOptional() + @ApiProperty({ + examples: ['Bentroen', 'Tomast1337', 'Slayer - Raining Blood'], + description: + 'Filters results uploaded by a string matching the specified query.', + required: false, + }) + query?: string; + constructor(partial: Partial) { Object.assign(this, partial); } diff --git a/shared/validation/common/dto/PageResult.dto.ts b/shared/validation/common/dto/PageResult.dto.ts new file mode 100644 index 00000000..28ea5484 --- /dev/null +++ b/shared/validation/common/dto/PageResult.dto.ts @@ -0,0 +1,11 @@ +export class PageResultDTO { + data: T[]; + page: number = 1; + total: number; + + limit: number; + + constructor(data: PageResultDTO) { + Object.assign(this, data); + } +} diff --git a/shared/validation/common/dto/SearchQuery.dto.ts b/shared/validation/common/dto/SearchQuery.dto.ts new file mode 100644 index 00000000..e78167b0 --- /dev/null +++ b/shared/validation/common/dto/SearchQuery.dto.ts @@ -0,0 +1,83 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Transform } from 'class-transformer'; +import { + IsBoolean, + IsNumber, + IsOptional, + IsString, + Max, + Min, +} from 'class-validator'; + +export class SearchQueryDTO { + @IsOptional() + @IsString() + @ApiProperty({ + example: 'Nirvana - Dumb', + description: 'Natural language query.', + }) + query?: string; + + @IsOptional() + @IsString() + @ApiProperty({ + example: 'dubstep', + description: 'Filters the results by the specified category.', + required: false, + }) + category?: string; + + @Min(1) + @ApiProperty({ + example: 1, + description: 'Page number.', + }) + page?: number; + + @IsNumber({ maxDecimalPlaces: 0 }) + @Min(1) + @Max(100) + @ApiProperty({ + example: 20, + description: 'Number of results per page.', + }) + limit?: number = 20; + + @IsString() + @IsOptional() + @ApiProperty({ + example: 'createdAt', + description: 'Sort field.', + required: false, + }) + sort?: string; + + @IsBoolean() + @Transform(({ value }) => value === 'true') + @ApiProperty({ + example: false, + description: 'Sort in ascending order if true, descending if false.', + required: false, + }) + order?: boolean; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Users.', + }) + searchUsers?: boolean; + + @IsOptional() + @IsBoolean() + @ApiProperty({ + example: true, + description: 'Search Songs.', + }) + searchSongs?: boolean; + + constructor(partial: Partial) { + Object.assign(this, partial); + } +} diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts new file mode 100644 index 00000000..5fb2e726 --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.spec.ts @@ -0,0 +1,65 @@ +import { validate } from 'class-validator'; + +import { UserLinks } from './UpdateUserProfile.dto'; + +describe('UpdateUserProfileDto', () => { + describe('UserLinks', () => { + it('should validate valid URLs', async () => { + const userLinks = new UserLinks(); + + userLinks.github = 'https://github.com/tomast1337'; + userLinks.youtube = 'https://www.youtube.com/@Bentroen_'; + + userLinks.spotify = + 'https://open.spotify.com/artist/1McMsnEElThX1knmY4oliG?si=v95i3XbRRgKT9JwyiFiFEg'; + + userLinks.bandcamp = 'https://igorrr.bandcamp.com/'; + userLinks.facebook = 'https://www.facebook.com/MrBean'; + userLinks.reddit = 'https://www.reddit.com/user/Unidan/'; + userLinks.soundcloud = 'https://soundcloud.com/futureisnow'; + userLinks.steam = 'https://steamcommunity.com/id/CattleDecapitation/'; + userLinks.x = 'https://x.com/Trail_Cams'; + userLinks.twitch = 'https://www.twitch.tv/vinesauce'; + userLinks.threads = 'https://www.threads.net/@kimkardashian'; + userLinks.tiktok = 'https://www.tiktok.com/@karolg'; + userLinks.snapchat = 'https://www.snapchat.com/add/username'; + userLinks.instagram = 'https://instagram.com/validuser'; + userLinks.discord = 'https://discord.com/validuser'; + userLinks.telegram = 'https://t.me/validuser'; + + const errors = await validate(userLinks); + console.log(errors); + expect(errors.length).toBe(0); + }); + + it('should invalidate invalid URLs', async () => { + const userLinks = new UserLinks(); + userLinks.bandcamp = 'invalid-url'; + userLinks.discord = 'invalid-url'; + userLinks.facebook = 'invalid-url'; + userLinks.github = 'invalid-url'; + userLinks.instagram = 'invalid-url'; + userLinks.reddit = 'invalid-url'; + userLinks.snapchat = 'invalid-url'; + userLinks.soundcloud = 'invalid-url'; + userLinks.spotify = 'invalid-url'; + userLinks.steam = 'invalid-url'; + userLinks.telegram = 'invalid-url'; + userLinks.tiktok = 'invalid-url'; + userLinks.threads = 'invalid-url'; + userLinks.twitch = 'invalid-url'; + userLinks.x = 'invalid-url'; + userLinks.youtube = 'invalid-url'; + + const errors = await validate(userLinks); + expect(errors.length).toBeGreaterThan(0); + }); + + it('should allow optional fields to be empty', async () => { + const userLinks = new UserLinks(); + + const errors = await validate(userLinks); + expect(errors.length).toBe(0); + }); + }); +}); diff --git a/shared/validation/user/dto/UpdateUserProfile.dto.ts b/shared/validation/user/dto/UpdateUserProfile.dto.ts new file mode 100644 index 00000000..4ecf5909 --- /dev/null +++ b/shared/validation/user/dto/UpdateUserProfile.dto.ts @@ -0,0 +1,160 @@ +import { ApiProperty } from '@nestjs/swagger'; +import { Type } from 'class-transformer'; +import { + IsOptional, + IsString, + IsUrl, + Matches, + MaxLength, + MinLength, +} from 'class-validator'; + +import { deepFreeze } from '@shared/validation/common/deepFreeze'; + +export const LinkRegexes = deepFreeze({ + bandcamp: /https?:\/\/[a-zA-Z0-9_-]+\.bandcamp\.com\/?/, + discord: /https?:\/\/(www\.)?discord\.com\/[a-zA-Z0-9_]+/, + facebook: /https?:\/\/(www\.)?facebook\.com\/[a-zA-Z0-9_]+/, + github: /https?:\/\/(www\.)?github\.com\/[a-zA-Z0-9_-]+/, + instagram: /https?:\/\/(www\.)?instagram\.com\/[a-zA-Z0-9_]+/, + reddit: /https?:\/\/(www\.)?reddit\.com\/user\/[a-zA-Z0-9_-]+/, + snapchat: /https?:\/\/(www\.)?snapchat\.com\/add\/[a-zA-Z0-9_-]+/, + soundcloud: /https?:\/\/(www\.)?soundcloud\.com\/[a-zA-Z0-9_-]+/, + spotify: /https?:\/\/open\.spotify\.com\/artist\/[a-zA-Z0-9?&=]+/, + steam: /https?:\/\/steamcommunity\.com\/id\/[a-zA-Z0-9_-]+/, + telegram: /https?:\/\/(www\.)?t\.me\/[a-zA-Z0-9_]+/, + tiktok: /https?:\/\/(www\.)?tiktok\.com\/@?[a-zA-Z0-9_]+/, + threads: /https?:\/\/(www\.)?threads\.net\/@?[a-zA-Z0-9_]+/, + twitch: /https?:\/\/(www\.)?twitch\.tv\/[a-zA-Z0-9_]+/, + x: /https?:\/\/(www\.)?x\.com\/[a-zA-Z0-9_]+/, + youtube: /https?:\/\/(www\.)?youtube\.com\/@?[a-zA-Z0-9_-]+/, +}); + +export class UserLinks { + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.bandcamp) + bandcamp?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.discord) + discord?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.facebook) + facebook?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.github) + github?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.instagram) + instagram?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.reddit) + reddit?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.snapchat) + snapchat?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.soundcloud) + soundcloud?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.spotify) + spotify?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.steam) + steam?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.telegram) + telegram?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.tiktok) + tiktok?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.threads) + threads?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.twitch) + twitch?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.x) + x?: string; + + @IsOptional() + @IsUrl() + @Matches(LinkRegexes.youtube) + youtube?: string; +} + +export class UpdateUserProfileDto { + @IsString() + @MaxLength(64) + @MinLength(3) + @IsOptional() + @ApiProperty({ + description: 'Username of the user', + example: 'tomast1137', + }) + username?: string; + + @IsOptional() + @IsString() + @MaxLength(1024) + @ApiProperty({ + description: 'Description of the user', + example: 'I using noteblock.world', + }) + description?: string; + + @IsOptional() + @Type(() => UserLinks) + @ApiProperty({ + description: 'Social media links of the user', + example: { + github: 'https://github.com/tomast1337', + youtube: 'https://www.youtube.com/@Bentroen_', + spotify: + 'https://open.spotify.com/artist/1McMsnEElThX1knmY4oliG?si=v95i3XbRRgKT9JwyiFiFEg', + bandcamp: 'https://igorrr.bandcamp.com/', + facebook: 'https://www.facebook.com/MrBean', + reddit: 'https://www.reddit.com/user/Unidan/', + soundcloud: 'https://soundcloud.com/futureisnow', + steam: 'https://steamcommunity.com/id/CattleDecapitation/', + x: 'https://x.com/Trail_Cams', + twitch: 'https://www.twitch.tv/vinesauce', + threads: 'https://www.threads.net/@kimkardashian', + tiktok: 'https://www.tiktok.com/@karolg', + snapchat: 'https://www.snapchat.com/add/username', + instagram: 'https://instagram.com/validuser', + discord: 'https://discord.com/validuser', + telegram: 'https://t.me/validuser', + }, + }) + socialLinks?: UserLinks; +} diff --git a/shared/validation/user/dto/UserPreview.dto.ts b/shared/validation/user/dto/UserPreview.dto.ts new file mode 100644 index 00000000..da3e1c87 --- /dev/null +++ b/shared/validation/user/dto/UserPreview.dto.ts @@ -0,0 +1,8 @@ +export class UserPreviewDto { + username: string; + profileImage: string; + + constructor(partial: UserPreviewDto) { + Object.assign(this, partial); + } +} diff --git a/shared/validation/user/dto/UserProfileView.dto.ts b/shared/validation/user/dto/UserProfileView.dto.ts new file mode 100644 index 00000000..255978e5 --- /dev/null +++ b/shared/validation/user/dto/UserProfileView.dto.ts @@ -0,0 +1,34 @@ +import { SocialLinks, UserDocument } from '@server/user/entity/user.entity'; + +export class UserProfileViewDto { + username: string; + publicName: string; + profileImage: string; + description: string; + lastSeen: Date; + loginCount: number; + loginStreak: number; + playCount: number; + + socialLinks: InstanceType; + + public static fromUserDocument(user: UserDocument): UserProfileViewDto { + return new UserProfileViewDto({ + username: user.username, + publicName: user.publicName, + profileImage: user.profileImage, + description: user.description, + lastSeen: user.lastSeen, + loginCount: user.loginCount, + loginStreak: user.loginStreak, + playCount: user.playCount, + socialLinks: user.socialLinks, + }); + } + + constructor(partial: UserProfileViewDto) { + Object.assign(this, partial); + } +} + +// TODO: refactor all DTOs as ...Request.dto and ...Response.dto diff --git a/shared/validation/user/dto/UserQuery.dto.ts b/shared/validation/user/dto/UserQuery.dto.ts new file mode 100644 index 00000000..c4f3840a --- /dev/null +++ b/shared/validation/user/dto/UserQuery.dto.ts @@ -0,0 +1,10 @@ +import { PartialType } from '@nestjs/mapped-types'; +import { IsBoolean, IsOptional } from 'class-validator'; + +import { PageQueryDTO } from '@shared/validation/common/dto/PageQuery.dto'; + +export class UserQuery extends PartialType(PageQueryDTO) { + @IsBoolean() + @IsOptional() + me?: boolean; +} diff --git a/shared/validation/user/dto/UserSearchView.dto.ts b/shared/validation/user/dto/UserSearchView.dto.ts new file mode 100644 index 00000000..06c73f82 --- /dev/null +++ b/shared/validation/user/dto/UserSearchView.dto.ts @@ -0,0 +1,23 @@ +import { UserDocument } from '@server/user/entity/user.entity'; + +export class UserSearchViewDto { + id: string; + username: string; + profileImage: string; + songCount: number; + createdAt: Date; + updatedAt: Date; + + static fromUserDocument( + doc: UserDocument & { songCount: number }, + ): UserSearchViewDto { + return { + id: doc.publicName, + username: doc.publicName, + profileImage: doc.profileImage, + songCount: doc.songCount, + createdAt: doc.createdAt, + updatedAt: doc.updatedAt, + }; + } +} diff --git a/web/package.json b/web/package.json index 5355eb6b..ef98617f 100644 --- a/web/package.json +++ b/web/package.json @@ -54,7 +54,8 @@ "tailwindcss-animate": "^1.0.7", "typescript": "^5.1.3", "zod": "^3.24.1", - "zod-validation-error": "^3.4.0" + "zod-validation-error": "^3.4.0", + "zustand": "^5.0.3" }, "devDependencies": { "@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0", diff --git a/web/src/app/(content)/search-user/page.tsx b/web/src/app/(content)/search-user/page.tsx new file mode 100644 index 00000000..b96ce76c --- /dev/null +++ b/web/src/app/(content)/search-user/page.tsx @@ -0,0 +1,16 @@ +import { SearchPageComponent } from '@web/src/modules/search/components/SearchPageComponent'; + +interface SearchPageProps { + searchParams: { + query?: string; + page?: string; + limit?: string; + category?: string; + }; +} + +export default async function SearchPage({ searchParams }: SearchPageProps) { + console.log(searchParams); + + return ; +} diff --git a/web/src/app/(content)/user/[id]/page_disable.tsx b/web/src/app/(content)/user/[id]/page_disable.tsx deleted file mode 100644 index d3680b43..00000000 --- a/web/src/app/(content)/user/[id]/page_disable.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import UserProfile from '@web/src/modules/user/components/UserProfile'; -import { getUserProfileData } from '@web/src/modules/user/features/user.util'; - -import Layout from '../../layout'; - -const UserPage = async ({ params }: { params: { id: string } }) => { - const { id } = params; - - try { - const userData = await getUserProfileData(id); - - return ( -
- - - -
- ); - } catch { - return ( -
- -

Failed to get user data

-
-
- ); - } -}; - -export default UserPage; diff --git a/web/src/app/(content)/user/[username]/page.tsx b/web/src/app/(content)/user/[username]/page.tsx new file mode 100644 index 00000000..7cdb1117 --- /dev/null +++ b/web/src/app/(content)/user/[username]/page.tsx @@ -0,0 +1,35 @@ +import { ErrorBox } from '@web/src/modules/shared/components/client/ErrorBox'; +import { UserProfile } from '@web/src/modules/user/components/UserProfile'; +import { + getUserProfileData, + getUserSongs, +} from '@web/src/modules/user/features/user.util'; + +const UserPage = async ({ params }: { params: { username: string } }) => { + const { username } = params; + + let userData = null; + let songData = null; + + try { + userData = await getUserProfileData(username); + } catch (e) { + console.error('Failed to get user data:', e); + } + + try { + songData = await getUserSongs(username); + } catch (e) { + console.error('Failed to get song data:', e); + } + + if (userData) { + // set the page title to the user's name + + return ; + } else { + return ; + } +}; + +export default UserPage; diff --git a/web/src/modules/auth/features/auth.utils.ts b/web/src/modules/auth/features/auth.utils.ts index 17c3f459..37ecab45 100644 --- a/web/src/modules/auth/features/auth.utils.ts +++ b/web/src/modules/auth/features/auth.utils.ts @@ -40,9 +40,11 @@ export const getUserData = async (): Promise => { if (!token) throw new Error('No token found'); if (!token.value) throw new Error('No token found'); + let res; + try { // verify the token with the server - const res = await axiosInstance.get('/user/me', { + res = await axiosInstance.get('/user?me=true', { headers: { authorization: `Bearer ${token.value}`, }, diff --git a/web/src/modules/auth/types/User.ts b/web/src/modules/auth/types/User.ts index 5a5c901b..2ff5ce78 100644 --- a/web/src/modules/auth/types/User.ts +++ b/web/src/modules/auth/types/User.ts @@ -17,50 +17,10 @@ export type LoggedUserData = { prefersDarkTheme: boolean; creationDate: string; lastEdited: string; - lastLogin: string; - likedSongs: string[]; // Assuming the array contains strings - following: string[]; // Assuming the array contains strings - likedComments: string[]; // Assuming the array contains strings - dislikedComments: string[]; // Assuming the array contains strings - notifications: string[]; // Assuming the array contains strings - achievements: string[]; // Assuming the array contains strings + lastSeen: string; createdAt: string; updatedAt: string; id: string; }; -export enum SocialLinksTypes { - BANDCAMP = 'bandcamp', - DISCORD = 'discord', - FACEBOOK = 'facebook', - GITHUB = 'github', - INSTAGRAM = 'instagram', - REDDIT = 'reddit', - SNAPCHAT = 'snapchat', - SOUNDCLOUD = 'soundcloud', - SPOTIFY = 'spotify', - STEAM = 'steam', - TELEGRAM = 'telegram', - TIKTOK = 'tiktok', - THREADS = 'threads', - TWITCH = 'twitch', - X = 'x', - YOUTUBE = 'youtube', -} - -export type SocialLinks = { - [K in SocialLinksTypes]?: string; -}; - -export type UserProfileData = { - lastLogin: Date; - loginStreak: number; - playCount: number; - publicName: string; - description: string; - profileImage: string; - socialLinks: SocialLinks; - likedSongs: string[]; - following: string[]; - achievements: string[]; -}; +// TODO: make this a DTO (part of the validation module) diff --git a/web/src/modules/browse/components/SongCard.tsx b/web/src/modules/browse/components/SongCard.tsx index ef5d4305..3d34c172 100644 --- a/web/src/modules/browse/components/SongCard.tsx +++ b/web/src/modules/browse/components/SongCard.tsx @@ -4,6 +4,7 @@ import { faPlay } from '@fortawesome/free-solid-svg-icons'; import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import { SongPreviewDtoType } from '@shared/validation/song/dto/types'; import Link from 'next/link'; +import { useRouter } from 'next/navigation'; import Skeleton from 'react-loading-skeleton'; import { @@ -14,6 +15,8 @@ import { import SongThumbnail from '../../shared/components/layout/SongThumbnail'; const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { + const router = useRouter(); + return (
{/* Song image */} @@ -48,9 +51,22 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { {!song ? ( ) : ( - `${song.uploader.username} • ${formatTimeAgo( - new Date(song.createdAt), - )}` + <> + {/* TODO: this should be a Link component, but the whole card is a link itself + and the tag can't be nested. Figure out a better way to arrange them (likely + place a link in the image, title and each of the card's components) */} + + {' • '} + {formatTimeAgo(new Date(song.createdAt))} + )}

{/* Play icon & count */} diff --git a/web/src/modules/search/components/SearchPageComponent.tsx b/web/src/modules/search/components/SearchPageComponent.tsx new file mode 100644 index 00000000..564cd533 --- /dev/null +++ b/web/src/modules/search/components/SearchPageComponent.tsx @@ -0,0 +1,162 @@ +'use client'; + +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; +import Image from 'next/image'; +import { useRouter, useSearchParams } from 'next/navigation'; +import { useEffect, useState } from 'react'; + +import { useSearch } from './client/context/useSearch'; + +type UserCardProps = { + user: UserSearchViewDto; +}; + +export const UserCard = ({ user }: UserCardProps) => { + const { id, profileImage, songCount, username } = user; + const router = useRouter(); + return ( +
router.push(`/user/${id}`)} + > + {/* Profile Image */} +
+ {`Profile +
+ + {/* Username */} +

+ {username} +

+ + {/* Song Count */} +

+ {songCount} {songCount === 1 ? 'song' : 'songs'} +

+ + {/* User ID (Optional) */} +

ID: {id}

+
+ ); +}; + +export const UserCardSkeleton = () => { + return ( +
+ {/* Profile Image Skeleton */} +
+
+
+ + {/* Username Skeleton */} +
+ + {/* Song Count Skeleton */} +
+ + {/* User ID Skeleton */} +
+
+ ); +}; + +export const SearchPageComponent = () => { + const searchParams = useSearchParams(); + const [currentPage, setCurrentPage] = useState(1); + + const { data, query, isLoading, limit, fetchSearchResults } = useSearch(); + + const router = useRouter(); + + useEffect(() => { + const query = searchParams.get('query') || ''; + const page = searchParams.get('page') || '1'; + const limit = searchParams.get('limit') || '20'; + + fetchSearchResults(query, parseInt(page), parseInt(limit)); + setCurrentPage(parseInt(page)); + // eslint-disable-next-line react-hooks/exhaustive-deps + }, [searchParams]); + + const handlePageChange = (newPage: number) => { + const query = searchParams.get('query') || ''; + const limit = searchParams.get('limit') || '20'; + + const queryParam = new URLSearchParams({ + page: newPage.toString(), + limit: limit, + query, + }); + + router.push(`/search-user?${queryParam.toString()}`); + }; + + return ( + <> + {/* Search Header */} +
+ +
+ +

+ Search Results +

+ {query && ( +

+ {`Showing results for "${query}"`} +

+ )} + + {/* Loading State */} + {isLoading ? ( +
+ {Array.from({ length: 6 }).map((_, i) => ( + + ))} +
+ ) : data.length === 0 ? ( +
+ No results found. Try searching for something else. +
+ ) : ( + <> + {/* User Cards */} +
+ {data.map((user) => ( + + ))} +
+ + {/* Pagination Controls */} +
+ + +
+ + )} + + ); +}; diff --git a/web/src/modules/search/components/client/context/useSearch.tsx b/web/src/modules/search/components/client/context/useSearch.tsx new file mode 100644 index 00000000..77fa4198 --- /dev/null +++ b/web/src/modules/search/components/client/context/useSearch.tsx @@ -0,0 +1,57 @@ +'use client'; + +import { PageResultDTO } from '@shared/validation/common/dto/PageResult.dto'; +import { UserSearchViewDto } from '@shared/validation/user/dto/UserSearchView.dto'; +import { create } from 'zustand'; + +import axios from '@web/src/lib/axios'; + +type SearchState = { + fetchSearchResults: ( + query: string, + page: number, + limit: number, + ) => Promise; + query: string; + page: number; + limit: number; + data: UserSearchViewDto[]; + isLoading: boolean; +}; + +export const useSearch = create((set) => { + const fetchSearchResults = async ( + query: string, + page: number, + limit: number, + ) => { + set({ isLoading: true }); + + const result = await axios.get>('/user', { + params: { + query: query, + page: page, + limit: limit, + }, + }); + + const { data } = result; + + set({ + query: query, + page: data.page, + limit: data.limit, + data: data.data, + isLoading: false, + }); + }; + + return { + fetchSearchResults, + query: '', + page: 1, + limit: 20, + data: [], + isLoading: false, + }; +}); diff --git a/web/src/modules/search/context/search.ts b/web/src/modules/search/context/search.ts new file mode 100644 index 00000000..e69de29b diff --git a/web/src/modules/shared/components/client/ads/AdSlots.tsx b/web/src/modules/shared/components/client/ads/AdSlots.tsx index 73fa60a6..a8c1f377 100644 --- a/web/src/modules/shared/components/client/ads/AdSlots.tsx +++ b/web/src/modules/shared/components/client/ads/AdSlots.tsx @@ -56,7 +56,11 @@ const AdTemplate = ({ useEffect(() => { if (window) { - (window.adsbygoogle = window.adsbygoogle || []).push({}); + try { + (window.adsbygoogle = window.adsbygoogle || []).push({}); + } catch (e) { + console.error(e); + } } }, []); diff --git a/web/src/modules/shared/components/layout/BlockSearchProps.tsx b/web/src/modules/shared/components/layout/BlockSearchProps.tsx new file mode 100644 index 00000000..01f82a58 --- /dev/null +++ b/web/src/modules/shared/components/layout/BlockSearchProps.tsx @@ -0,0 +1,53 @@ +'use client'; +import { faMagnifyingGlass } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import { useRouter } from 'next/navigation'; +import { useState } from 'react'; + +import { cn } from '@web/src/lib/tailwind.utils'; + +import { Popover, PopoverContent, PopoverTrigger } from './popover'; + +export const BlockSearch = () => { + const [query, setQuery] = useState(''); + const router = useRouter(); + + return ( + + + + Search + + +
+ setQuery(e.target.value)} + placeholder='Search for songs and users' + className='w-full bg-zinc-800 text-white border border-zinc-600 rounded-md p-2' + /> + +
+
+
+ ); +}; diff --git a/web/src/modules/shared/components/layout/BlockTab.tsx b/web/src/modules/shared/components/layout/BlockTab.tsx index b17996ec..16106dea 100644 --- a/web/src/modules/shared/components/layout/BlockTab.tsx +++ b/web/src/modules/shared/components/layout/BlockTab.tsx @@ -6,17 +6,21 @@ import { cn } from '@web/src/lib/tailwind.utils'; import { MusicalNote } from './MusicalNote'; +interface BlockTabProps { + href: string; + icon: IconDefinition; + label: string; + className?: string; + id?: string; +} + export const BlockTab = ({ + id, href, icon, label, className, -}: { - href: string; - icon: IconDefinition; - label: string; - className?: string; -}) => { +}: BlockTabProps) => { return ( {label} diff --git a/web/src/modules/shared/components/layout/Header.tsx b/web/src/modules/shared/components/layout/Header.tsx index e59e33fa..3a383897 100644 --- a/web/src/modules/shared/components/layout/Header.tsx +++ b/web/src/modules/shared/components/layout/Header.tsx @@ -13,11 +13,12 @@ import { getUserData, } from '@web/src/modules/auth/features/auth.utils'; +import { BlockSearch } from './BlockSearchProps'; import { BlockTab } from './BlockTab'; import { NavLinks } from './NavLinks'; import { RandomSongButton } from './RandomSongButton'; -export async function Header() { +export const Header = async () => { let isLogged; let userData; @@ -33,13 +34,19 @@ export async function Header() { } return ( -
+ ); -} +}; diff --git a/web/src/modules/song/components/SongPageButtons.tsx b/web/src/modules/song/components/SongPageButtons.tsx index fa238c56..013634f1 100644 --- a/web/src/modules/song/components/SongPageButtons.tsx +++ b/web/src/modules/song/components/SongPageButtons.tsx @@ -30,17 +30,22 @@ import { downloadSongFile, openSongInNBS } from '../util/downloadSong'; const UploaderBadge = ({ user }: { user: SongViewDtoType['uploader'] }) => { return (
- -
+ + + +

{user.username}

{/*

410 followers

*/} -
+
); }; diff --git a/web/src/modules/user/components/UserBadges.tsx b/web/src/modules/user/components/UserBadges.tsx new file mode 100644 index 00000000..78684ae0 --- /dev/null +++ b/web/src/modules/user/components/UserBadges.tsx @@ -0,0 +1,24 @@ +import { faRocket, faSquareFull } from '@fortawesome/free-solid-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; + +export const EarlySupporterBadge = () => { + return ( + + + + +

+ Early Supporter +

+
+
+ ); +}; diff --git a/web/src/modules/user/components/UserProfile.tsx b/web/src/modules/user/components/UserProfile.tsx index 2f895666..87b8f23a 100644 --- a/web/src/modules/user/components/UserProfile.tsx +++ b/web/src/modules/user/components/UserProfile.tsx @@ -1,63 +1,101 @@ +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; import Image from 'next/image'; -import { SocialLinksTypes, UserProfileData } from '../../auth/types/User'; +import { EarlySupporterBadge } from './UserBadges'; +import UserSocialIcon from './UserSocialIcon'; +import SongCard from '../../browse/components/SongCard'; +import SongCardGroup from '../../browse/components/SongCardGroup'; +import { formatTimeAgo } from '../../shared/util/format'; type UserProfileProps = { - userData: UserProfileData; + userData: UserProfileViewDto; + songData: SongPreviewDto[] | null; }; -const UserProfile = ({ userData }: UserProfileProps) => { - const { - lastLogin, - loginStreak, - playCount, - publicName, - description, - profileImage, - socialLinks, - following, - achievements, - } = userData; +export const UserProfile = ({ userData, songData }: UserProfileProps) => { + const { lastSeen, username, description, profileImage, socialLinks } = + userData; return ( -
- {publicName} -

{publicName}

-

{description}

-

Last Login: {lastLogin.toLocaleString()}

-

Login Streak: {loginStreak}

-

Play Count: {playCount}

-

Following: {following}

-
-
    -
      - {achievements.map((achievement, index) => ( -
    • - {achievement} -
    • - ))} -
    -
    +
    + {/* HEADER */} +
    +
    + {`Profile +
    + {/* Display name */} +
    +

    {username}

    + +
    + + {/* Username/handle */} +

    + {`@${username}`} + {` • ${songData?.length || 0} songs • 2,534 plays`}{' '} + {/* Dynamic song count */} +

    + + {/* Description */} +

    + {description || 'No description available.'}{' '} + {/* Dynamic description */} +

    + + {/* Social links */} +
    + {Object.entries(socialLinks).map(([key, value], i) => ( + + ))} +
    +
    + +
    + +
    + {/* Joined */} +

    Joined

    +

    + {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

    + + {/* Last seen */} +

    Last seen

    +

    + {new Date(lastSeen).toLocaleDateString('en-UK')} + {` (${formatTimeAgo( + new Date(lastSeen), + )})`} +

    +
    +
    +
    + +
    + + {/* UPLOADED SONGS */} +
    +

    Songs

    + {songData ? ( + + {songData.map((song, i) => ( + + ))} + + ) : ( +

    No songs uploaded yet.

    + )} +
    +
    ); }; - -export default UserProfile; diff --git a/web/src/modules/user/components/UserSocialIcon.tsx b/web/src/modules/user/components/UserSocialIcon.tsx new file mode 100644 index 00000000..e3731dec --- /dev/null +++ b/web/src/modules/user/components/UserSocialIcon.tsx @@ -0,0 +1,53 @@ +import { + IconDefinition, + faBandcamp, + faDiscord, + faFacebook, + faGithub, + faInstagram, + faLinkedin, + faPatreon, + faPinterest, + faReddit, + faSnapchat, + faSoundcloud, + faSpotify, + faSteam, + faTiktok, + faTwitch, + faXTwitter, + faYoutube, +} from '@fortawesome/free-brands-svg-icons'; +import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; +import Link from 'next/link'; + +const iconLookup: Record = { + twitter: faXTwitter, + youtube: faYoutube, + github: faGithub, + discord: faDiscord, + bandcamp: faBandcamp, + soundcloud: faSoundcloud, + instagram: faInstagram, + facebook: faFacebook, + patreon: faPatreon, + twitch: faTwitch, + spotify: faSpotify, + tiktok: faTiktok, + linkedin: faLinkedin, + snapchat: faSnapchat, + pinterest: faPinterest, + reddit: faReddit, + steam: faSteam, +}; + +const UserSocialIcon = ({ icon, href }: { href: string; icon: string }) => ( + + + +); + +export default UserSocialIcon; diff --git a/web/src/modules/user/features/user.util.ts b/web/src/modules/user/features/user.util.ts index 70e8dd00..0d8b1211 100644 --- a/web/src/modules/user/features/user.util.ts +++ b/web/src/modules/user/features/user.util.ts @@ -1,14 +1,33 @@ +import { SongPreviewDto } from '@shared/validation/song/dto/SongPreview.dto'; +import { UserProfileViewDto } from '@shared/validation/user/dto/UserProfileView.dto'; + import axiosInstance from '../../../lib/axios'; -import { UserProfileData } from '../../auth/types/User'; -export const getUserProfileData = async ( - id: string, -): Promise => { +export const getUserProfileData = async (username: string) => { try { - const res = await axiosInstance.get(`/user/?id=${id}`); - if (res.status === 200) return res.data as UserProfileData; + const res = await axiosInstance.get( + `/user/${username}`, + ); + + if (res.status === 200) return res.data; else throw new Error('Failed to get user data'); } catch { throw new Error('Failed to get user data'); } }; + +export const getUserSongs = async (username: string) => { + try { + const res = await axiosInstance.get(`/song`, { + params: { + limit: 12, + user: username, + }, + }); + + if (res.status === 200) return res.data; + else throw new Error('Failed to get user songs'); + } catch { + throw new Error('Failed to get user songs'); + } +};