diff --git a/.github/workflows/typecheck.yml b/.github/workflows/typecheck.yml new file mode 100644 index 00000000..f1b4d8cc --- /dev/null +++ b/.github/workflows/typecheck.yml @@ -0,0 +1,32 @@ +name: Type Check + +on: + push: + branches: + - develop + - main + pull_request: + branches: + - develop + - main + +jobs: + typecheck: + runs-on: ubuntu-latest + env: + THUMBNAIL_URL: ${{ vars.THUMBNAIL_URL }} + + steps: + - name: Checkout + uses: actions/checkout@v4 + + - name: Install bun + uses: oven-sh/setup-bun@v2 + with: + bun-version: latest + + - name: Install dependencies + run: bun install + + - name: Type check (project references) + run: bun run typecheck diff --git a/.vscode/settings.json b/.vscode/settings.json index ad6a31bc..98703614 100644 --- a/.vscode/settings.json +++ b/.vscode/settings.json @@ -1,4 +1,7 @@ { + /*********************************** + Linting and formatting settings + **********************************/ "editor.defaultFormatter": "esbenp.prettier-vscode", "editor.formatOnSave": true, "eslint.validate": [ @@ -13,8 +16,27 @@ "editor.codeActionsOnSave": { "source.fixAll.eslint": "always" }, + + /*********************************** + Tailwind CSS IntelliSense settings + ***********************************/ + // Enable suggestions inside strings for Tailwind CSS class names + // https://github.com/tailwindlabs/tailwindcss-intellisense#editorquicksuggestions + "editor.quickSuggestions": { + "strings": "on" + }, + "tailwindCSS.experimental.configFile": { + "apps/frontend/src/app/globals.css": "apps/frontend/src/**" + }, + "tailwindCSS.classFunctions": ["tw", "clsx", "cn"], "files.associations": { ".css": "tailwindcss", "*.scss": "tailwindcss" - } + }, + + /*********************************** + Use the workspace version of TypeScript + ***********************************/ + "typescript.tsdk": "./node_modules/typescript/lib", + "typescript.enablePromptUseWorkspaceTsdk": true } diff --git a/apps/backend/package.json b/apps/backend/package.json index 672781e1..4c07695e 100644 --- a/apps/backend/package.json +++ b/apps/backend/package.json @@ -25,6 +25,11 @@ "@aws-sdk/client-s3": "3.946.0", "@aws-sdk/s3-request-presigner": "3.946.0", "@encode42/nbs.js": "^5.0.2", + "@nbw/config": "workspace:*", + "@nbw/database": "workspace:*", + "@nbw/song": "workspace:*", + "@nbw/sounds": "workspace:*", + "@nbw/thumbnail": "workspace:*", "@nestjs-modules/mailer": "^2.0.2", "@nestjs/common": "^11.1.9", "@nestjs/config": "^4.0.2", @@ -56,12 +61,7 @@ "rxjs": "^7.8.2", "uuid": "^13.0.0", "zod": "^4.1.13", - "zod-validation-error": "^5.0.0", - "@nbw/database": "workspace:*", - "@nbw/song": "workspace:*", - "@nbw/thumbnail": "workspace:*", - "@nbw/sounds": "workspace:*", - "@nbw/config": "workspace:*" + "zod-validation-error": "^5.0.0" }, "devDependencies": { "@faker-js/faker": "^10.1.0", diff --git a/apps/backend/scripts/build.ts b/apps/backend/scripts/build.ts index 5417739e..2dbb03c2 100644 --- a/apps/backend/scripts/build.ts +++ b/apps/backend/scripts/build.ts @@ -65,14 +65,20 @@ const build = async () => { target: 'bun', minify: false, sourcemap: 'linked', - external: optionalRequirePackages.filter((pkg) => { - try { - require(pkg); - return false; - } catch (_) { - return true; - } - }), + external: [ + ...optionalRequirePackages.filter((pkg) => { + try { + require(pkg); + return false; + } catch (_) { + return true; + } + }), + '@nbw/config', + '@nbw/database', + '@nbw/song', + '@nbw/sounds', + ], splitting: true, }); diff --git a/apps/backend/src/auth/auth.controller.spec.ts b/apps/backend/src/auth/auth.controller.spec.ts index d6bae4fa..0a68ba35 100644 --- a/apps/backend/src/auth/auth.controller.spec.ts +++ b/apps/backend/src/auth/auth.controller.spec.ts @@ -22,6 +22,9 @@ describe('AuthController', () => { let authService: AuthService; beforeEach(async () => { + // Clear all mocks before each test to ensure test isolation + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [AuthController], providers: [ @@ -68,8 +71,13 @@ describe('AuthController', () => { describe('githubLogin', () => { it('should call AuthService.githubLogin', async () => { - await controller.githubLogin(); - expect(authService.githubLogin).toHaveBeenCalled(); + // githubLogin is just a Passport guard entry point - it doesn't call authService + // The actual login is handled by the callback endpoint (githubRedirect) + controller.githubLogin(); + // Verify the method exists and can be called without errors + expect(controller.githubLogin).toBeDefined(); + // Verify authService was NOT called (since this is just a guard entry point) + expect(authService.githubLogin).not.toHaveBeenCalled(); }); }); @@ -97,8 +105,13 @@ describe('AuthController', () => { describe('googleLogin', () => { it('should call AuthService.googleLogin', async () => { - await controller.googleLogin(); - expect(authService.googleLogin).toHaveBeenCalled(); + // googleLogin is just a Passport guard entry point - it doesn't call authService + // The actual login is handled by the callback endpoint (googleRedirect) + controller.googleLogin(); + // Verify the method exists and can be called without errors + expect(controller.googleLogin).toBeDefined(); + // Verify authService was NOT called (since this is just a guard entry point) + expect(authService.googleLogin).not.toHaveBeenCalled(); }); }); @@ -126,8 +139,13 @@ describe('AuthController', () => { describe('discordLogin', () => { it('should call AuthService.discordLogin', async () => { - await controller.discordLogin(); - expect(authService.discordLogin).toHaveBeenCalled(); + // discordLogin is just a Passport guard entry point - it doesn't call authService + // The actual login is handled by the callback endpoint (discordRedirect) + controller.discordLogin(); + // Verify the method exists and can be called without errors + expect(controller.discordLogin).toBeDefined(); + // Verify authService was NOT called (since this is just a guard entry point) + expect(authService.discordLogin).not.toHaveBeenCalled(); }); }); diff --git a/apps/backend/src/auth/auth.module.ts b/apps/backend/src/auth/auth.module.ts index e266b0bf..725ad27e 100644 --- a/apps/backend/src/auth/auth.module.ts +++ b/apps/backend/src/auth/auth.module.ts @@ -1,6 +1,7 @@ -import { DynamicModule, Logger, Module } from '@nestjs/common'; +import { DynamicModule, Module } from '@nestjs/common'; import { ConfigModule, ConfigService } from '@nestjs/config'; import { JwtModule } from '@nestjs/jwt'; +import ms from 'ms'; import { MailingModule } from '@server/mailing/mailing.module'; import { UserModule } from '@server/user/user.module'; @@ -26,21 +27,13 @@ export class AuthModule { inject: [ConfigService], imports: [ConfigModule], useFactory: async (config: ConfigService) => { - const JWT_SECRET = config.get('JWT_SECRET'); - const JWT_EXPIRES_IN = config.get('JWT_EXPIRES_IN'); - - if (!JWT_SECRET) { - Logger.error('JWT_SECRET is not set'); - throw new Error('JWT_SECRET is not set'); - } - - if (!JWT_EXPIRES_IN) { - Logger.warn('JWT_EXPIRES_IN is not set, using default of 60s'); - } + const JWT_SECRET = config.getOrThrow('JWT_SECRET'); + const JWT_EXPIRES_IN = + config.getOrThrow('JWT_EXPIRES_IN'); return { secret: JWT_SECRET, - signOptions: { expiresIn: JWT_EXPIRES_IN || '60s' }, + signOptions: { expiresIn: JWT_EXPIRES_IN }, }; }, }), @@ -58,7 +51,7 @@ export class AuthModule { inject: [ConfigService], provide: 'COOKIE_EXPIRES_IN', useFactory: (configService: ConfigService) => - configService.getOrThrow('COOKIE_EXPIRES_IN'), + configService.getOrThrow('COOKIE_EXPIRES_IN'), }, { inject: [ConfigService], @@ -82,7 +75,7 @@ export class AuthModule { inject: [ConfigService], provide: 'JWT_EXPIRES_IN', useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_EXPIRES_IN'), + configService.getOrThrow('JWT_EXPIRES_IN'), }, { inject: [ConfigService], @@ -94,7 +87,7 @@ export class AuthModule { inject: [ConfigService], provide: 'JWT_REFRESH_EXPIRES_IN', useFactory: (configService: ConfigService) => - configService.getOrThrow('JWT_REFRESH_EXPIRES_IN'), + configService.getOrThrow('JWT_REFRESH_EXPIRES_IN'), }, { inject: [ConfigService], diff --git a/apps/backend/src/auth/auth.service.spec.ts b/apps/backend/src/auth/auth.service.spec.ts index 311fece9..add4d1dc 100644 --- a/apps/backend/src/auth/auth.service.spec.ts +++ b/apps/backend/src/auth/auth.service.spec.ts @@ -192,7 +192,7 @@ describe('AuthService', () => { const refreshToken = 'refresh-token'; spyOn(jwtService, 'signAsync').mockImplementation( - (payload, options: any) => { + (payload: any, options: any) => { if (options.secret === 'test-jwt-secret') { return Promise.resolve(accessToken); } else if (options.secret === 'test-jwt-refresh-secret') { @@ -253,6 +253,7 @@ describe('AuthService', () => { expect(res.cookie).toHaveBeenCalledWith('token', 'access-token', { domain: '.test.com', maxAge: 3600000, + path: '/', }); expect(res.cookie).toHaveBeenCalledWith( @@ -261,6 +262,7 @@ describe('AuthService', () => { { domain: '.test.com', maxAge: 3600000, + path: '/', }, ); diff --git a/apps/backend/src/auth/auth.service.ts b/apps/backend/src/auth/auth.service.ts index aab47ca4..ae96220a 100644 --- a/apps/backend/src/auth/auth.service.ts +++ b/apps/backend/src/auth/auth.service.ts @@ -1,7 +1,8 @@ import { Inject, Injectable, Logger } from '@nestjs/common'; import { JwtService } from '@nestjs/jwt'; import axios from 'axios'; -import type { Request, Response } from 'express'; +import type { CookieOptions, Request, Response } from 'express'; +import ms from 'ms'; import { CreateUser } from '@nbw/database'; import type { UserDocument } from '@nbw/database'; @@ -22,18 +23,18 @@ export class AuthService { @Inject(JwtService) private readonly jwtService: JwtService, @Inject('COOKIE_EXPIRES_IN') - private readonly COOKIE_EXPIRES_IN: string, + private readonly COOKIE_EXPIRES_IN: ms.StringValue, @Inject('FRONTEND_URL') private readonly FRONTEND_URL: string, @Inject('JWT_SECRET') private readonly JWT_SECRET: string, @Inject('JWT_EXPIRES_IN') - private readonly JWT_EXPIRES_IN: string, + private readonly JWT_EXPIRES_IN: ms.StringValue, @Inject('JWT_REFRESH_SECRET') private readonly JWT_REFRESH_SECRET: string, @Inject('JWT_REFRESH_EXPIRES_IN') - private readonly JWT_REFRESH_EXPIRES_IN: string, + private readonly JWT_REFRESH_EXPIRES_IN: ms.StringValue, @Inject('APP_DOMAIN') private readonly APP_DOMAIN?: string, ) {} @@ -171,11 +172,11 @@ export class AuthService { public async createJwtPayload(payload: TokenPayload): Promise { const [accessToken, refreshToken] = await Promise.all([ - this.jwtService.signAsync(payload, { + this.jwtService.signAsync(payload, { secret: this.JWT_SECRET, expiresIn: this.JWT_EXPIRES_IN, }), - this.jwtService.signAsync(payload, { + this.jwtService.signAsync(payload, { secret: this.JWT_REFRESH_SECRET, expiresIn: this.JWT_REFRESH_EXPIRES_IN, }), @@ -189,7 +190,7 @@ export class AuthService { private async GenTokenRedirect( user_registered: UserDocument, - res: Response>, + res: Response>, ): Promise { const token = await this.createJwtPayload({ id: user_registered._id.toString(), @@ -198,18 +199,16 @@ export class AuthService { }); const frontEndURL = this.FRONTEND_URL; - const domain = this.APP_DOMAIN; - const maxAge = parseInt(this.COOKIE_EXPIRES_IN) * 1000; + const maxAge = ms(this.COOKIE_EXPIRES_IN) * 1000; - res.cookie('token', token.access_token, { - domain: domain, + const cookieOptions: CookieOptions = { maxAge: maxAge, - }); + domain: this.APP_DOMAIN, + path: '/', + }; - res.cookie('refresh_token', token.refresh_token, { - domain: domain, - maxAge: maxAge, - }); + res.cookie('token', token.access_token, cookieOptions); + res.cookie('refresh_token', token.refresh_token, cookieOptions); res.redirect(frontEndURL + '/'); } diff --git a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts index 052cae9e..c17b4f3f 100644 --- a/apps/backend/src/auth/strategies/JWT.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/JWT.strategy.spec.ts @@ -33,7 +33,7 @@ describe('JwtStrategy', () => { it('should throw an error if JWT_SECRET is not set', () => { jest.spyOn(configService, 'getOrThrow').mockReturnValue(null); - expect(() => new JwtStrategy(configService)).toThrowError( + expect(() => new JwtStrategy(configService)).toThrow( 'JwtStrategy requires a secret or key', ); }); @@ -84,7 +84,7 @@ describe('JwtStrategy', () => { const payload = { userId: 'test-user-id' }; - expect(() => jwtStrategy.validate(req, payload)).toThrowError( + expect(() => jwtStrategy.validate(req, payload)).toThrow( 'No refresh token', ); }); diff --git a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts index 0dbc8608..588074d7 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/discord.strategy.spec.ts @@ -43,7 +43,7 @@ describe('DiscordStrategy', () => { it('should throw an error if Discord config is missing', () => { jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); - expect(() => new DiscordStrategy(configService)).toThrowError( + expect(() => new DiscordStrategy(configService)).toThrow( 'OAuth2Strategy requires a clientID option', ); }); diff --git a/apps/backend/src/auth/strategies/discord.strategy/index.ts b/apps/backend/src/auth/strategies/discord.strategy/index.ts index c31ea7e0..de2d6ebd 100644 --- a/apps/backend/src/auth/strategies/discord.strategy/index.ts +++ b/apps/backend/src/auth/strategies/discord.strategy/index.ts @@ -27,7 +27,7 @@ export class DiscordStrategy extends PassportStrategy(strategy, 'discord') { callbackUrl: `${SERVER_URL}/v1/auth/discord/callback`, scope: [DiscordPermissionScope.Email, DiscordPermissionScope.Identify], fetchScope: true, - prompt: 'none', + prompt: 'none' as const, }; super(config); diff --git a/apps/backend/src/auth/strategies/github.strategy.spec.ts b/apps/backend/src/auth/strategies/github.strategy.spec.ts index c8793e00..a13b099b 100644 --- a/apps/backend/src/auth/strategies/github.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/github.strategy.spec.ts @@ -43,7 +43,7 @@ describe('GithubStrategy', () => { it('should throw an error if GitHub config is missing', () => { jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); - expect(() => new GithubStrategy(configService)).toThrowError( + expect(() => new GithubStrategy(configService)).toThrow( 'OAuth2Strategy requires a clientID option', ); }); diff --git a/apps/backend/src/auth/strategies/github.strategy.ts b/apps/backend/src/auth/strategies/github.strategy.ts index b7ea82ab..048a491e 100644 --- a/apps/backend/src/auth/strategies/github.strategy.ts +++ b/apps/backend/src/auth/strategies/github.strategy.ts @@ -22,10 +22,10 @@ export class GithubStrategy extends PassportStrategy(strategy, 'github') { super({ clientID: GITHUB_CLIENT_ID, clientSecret: GITHUB_CLIENT_SECRET, - redirect_uri: `${SERVER_URL}/v1/auth/github/callback`, + callbackURL: `${SERVER_URL}/v1/auth/github/callback`, scope: 'user:read,user:email', state: false, - }); + } as any); // TODO: Fix types } async validate(accessToken: string, refreshToken: string, profile: any) { diff --git a/apps/backend/src/auth/strategies/google.strategy.spec.ts b/apps/backend/src/auth/strategies/google.strategy.spec.ts index c1f1233e..ccee9e71 100644 --- a/apps/backend/src/auth/strategies/google.strategy.spec.ts +++ b/apps/backend/src/auth/strategies/google.strategy.spec.ts @@ -44,7 +44,7 @@ describe('GoogleStrategy', () => { it('should throw an error if Google config is missing', () => { jest.spyOn(configService, 'getOrThrow').mockReturnValueOnce(null); - expect(() => new GoogleStrategy(configService)).toThrowError( + expect(() => new GoogleStrategy(configService)).toThrow( 'OAuth2Strategy requires a clientID option', ); }); diff --git a/apps/backend/src/config/EnvironmentVariables.ts b/apps/backend/src/config/EnvironmentVariables.ts index cbb15109..a933ce57 100644 --- a/apps/backend/src/config/EnvironmentVariables.ts +++ b/apps/backend/src/config/EnvironmentVariables.ts @@ -1,5 +1,35 @@ import { plainToInstance } from 'class-transformer'; -import { IsEnum, IsOptional, IsString, validateSync } from 'class-validator'; +import { + IsEnum, + IsOptional, + IsString, + registerDecorator, + validateSync, + ValidationArguments, + ValidationOptions, +} from 'class-validator'; +import ms from 'ms'; + +// Validate if the value is a valid duration string from the 'ms' library +function IsDuration(validationOptions?: ValidationOptions) { + return function (object: object, propertyName: string) { + registerDecorator({ + name: 'isDuration', + target: object.constructor, + propertyName: propertyName, + options: validationOptions, + validator: { + validate(value: unknown) { + if (typeof value !== 'string') return false; + return typeof ms(value as ms.StringValue) === 'number'; + }, + defaultMessage(args: ValidationArguments) { + return `${args.property} must be a valid duration string (e.g., "1h", "30m", "7d")`; + }, + }, + }); + }; +} enum Environment { Development = 'development', @@ -38,14 +68,14 @@ export class EnvironmentVariables { @IsString() JWT_SECRET: string; - @IsString() - JWT_EXPIRES_IN: string; + @IsDuration() + JWT_EXPIRES_IN: ms.StringValue; @IsString() JWT_REFRESH_SECRET: string; - @IsString() - JWT_REFRESH_EXPIRES_IN: string; + @IsDuration() + JWT_REFRESH_EXPIRES_IN: ms.StringValue; // database @IsString() @@ -91,8 +121,8 @@ export class EnvironmentVariables { @IsString() DISCORD_WEBHOOK_URL: string; - @IsString() - COOKIE_EXPIRES_IN: string; + @IsDuration() + COOKIE_EXPIRES_IN: ms.StringValue; } export function validate(config: Record) { @@ -105,7 +135,13 @@ export function validate(config: Record) { }); if (errors.length > 0) { - throw new Error(errors.toString()); + const messages = errors + .map((error) => { + const constraints = Object.values(error.constraints || {}); + return ` - ${error.property}: ${constraints.join(', ')}`; + }) + .join('\n'); + throw new Error(`Environment validation failed:\n${messages}`); } return validatedConfig; diff --git a/apps/backend/src/lib/GetRequestUser.spec.ts b/apps/backend/src/lib/GetRequestUser.spec.ts index ebc2e65f..e85694fd 100644 --- a/apps/backend/src/lib/GetRequestUser.spec.ts +++ b/apps/backend/src/lib/GetRequestUser.spec.ts @@ -28,7 +28,7 @@ describe('validateUser', () => { }); it('should throw an error if the user does not exist', () => { - expect(() => validateUser(null)).toThrowError( + expect(() => validateUser(null)).toThrow( new HttpException( { error: { diff --git a/apps/backend/src/main.ts b/apps/backend/src/main.ts index 389ee3c0..2fd6a1ed 100644 --- a/apps/backend/src/main.ts +++ b/apps/backend/src/main.ts @@ -28,6 +28,11 @@ async function bootstrap() { app.use(express.json({ limit: '50mb' })); app.use(express.urlencoded({ extended: true, limit: '50mb' })); + // Trust proxy for Railway reverse proxy + // This ensures Express properly handles X-Forwarded-* headers + const expressApp = app.getHttpAdapter().getInstance(); + expressApp.set('trust proxy', 1); + if (process.env.NODE_ENV === 'development') { initializeSwagger(app); } diff --git a/apps/backend/src/song/my-songs/my-songs.controller.ts b/apps/backend/src/song/my-songs/my-songs.controller.ts index c1e5b8ec..ece24049 100644 --- a/apps/backend/src/song/my-songs/my-songs.controller.ts +++ b/apps/backend/src/song/my-songs/my-songs.controller.ts @@ -1,4 +1,4 @@ -import { Controller, Get, Query, UseGuards } from '@nestjs/common'; +import { Controller, Get, Inject, Query, UseGuards } from '@nestjs/common'; import { AuthGuard } from '@nestjs/passport'; import { ApiBearerAuth, ApiOperation, ApiTags } from '@nestjs/swagger'; @@ -12,7 +12,10 @@ import { SongService } from '../song.service'; @Controller('my-songs') @ApiTags('song') export class MySongsController { - constructor(public readonly songService: SongService) {} + constructor( + @Inject(SongService) + public readonly songService: SongService, + ) {} @Get('/') @ApiOperation({ diff --git a/apps/backend/src/song/song-upload/song-upload.service.ts b/apps/backend/src/song/song-upload/song-upload.service.ts index 9e390939..b9ed8871 100644 --- a/apps/backend/src/song/song-upload/song-upload.service.ts +++ b/apps/backend/src/song/song-upload/song-upload.service.ts @@ -22,7 +22,7 @@ import { injectSongFileMetadata, obfuscateAndPackSong, } from '@nbw/song'; -import { drawToImage } from '@nbw/thumbnail'; +import { drawToImage } from '@nbw/thumbnail/node'; import { FileService } from '@server/file/file.service'; import { UserService } from '@server/user/user.service'; diff --git a/apps/backend/src/song/song.controller.spec.ts b/apps/backend/src/song/song.controller.spec.ts index 51865a09..b44b4162 100644 --- a/apps/backend/src/song/song.controller.spec.ts +++ b/apps/backend/src/song/song.controller.spec.ts @@ -46,6 +46,9 @@ describe('SongController', () => { let songService: SongService; beforeEach(async () => { + // Clear all mocks before each test + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ controllers: [SongController], providers: [ @@ -66,8 +69,8 @@ describe('SongController', () => { songController = module.get(SongController); songService = module.get(SongService); - // Clear all mocks - jest.clearAllMocks(); + // Verify the service is injected + expect(songController.songService).toBeDefined(); }); it('should be defined', () => { @@ -79,7 +82,12 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = []; - mockSongService.getSongByPage.mockResolvedValueOnce(songList); + mockSongService.querySongs.mockResolvedValueOnce({ + content: songList, + page: 1, + limit: 10, + total: 0, + }); const result = await songController.getSongList(query); @@ -88,7 +96,7 @@ describe('SongController', () => { expect(result.page).toBe(1); expect(result.limit).toBe(10); expect(result.total).toBe(0); - expect(songService.getSongByPage).toHaveBeenCalled(); + expect(songService.querySongs).toHaveBeenCalled(); }); it('should handle search query', async () => { @@ -249,7 +257,7 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = Array(10) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -271,7 +279,7 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 1, limit: 10 }; const songList: SongPreviewDto[] = Array(5) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -291,7 +299,7 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 3, limit: 10 }; const songList: SongPreviewDto[] = Array(10) .fill(null) - .map((_, i) => ({ id: `song-${20 + i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${20 + i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -312,7 +320,7 @@ describe('SongController', () => { const query: SongListQueryDTO = { page: 1, limit: 10, q: 'test search' }; const songList: SongPreviewDto[] = Array(8) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -337,7 +345,7 @@ describe('SongController', () => { }; const songList: SongPreviewDto[] = Array(3) .fill(null) - .map((_, i) => ({ id: `rock-song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `rock-song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -357,7 +365,7 @@ describe('SongController', () => { it('should handle errors', async () => { const query: SongListQueryDTO = { page: 1, limit: 10 }; - mockSongService.getSongByPage.mockRejectedValueOnce(new Error('Error')); + mockSongService.querySongs.mockRejectedValueOnce(new Error('Error')); await expect(songController.getSongList(query)).rejects.toThrow('Error'); }); @@ -421,7 +429,7 @@ describe('SongController', () => { const q = 'test query'; const songList: SongPreviewDto[] = Array(5) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -437,7 +445,7 @@ describe('SongController', () => { expect(result.total).toBe(5); expect(result.page).toBe(1); expect(result.limit).toBe(10); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with empty query string', async () => { @@ -457,7 +465,7 @@ describe('SongController', () => { expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); expect(result.total).toBe(0); - expect(songService.querySongs).toHaveBeenCalledWith(query, '', undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, ''); }); it('should handle search with null query string', async () => { @@ -476,7 +484,7 @@ describe('SongController', () => { expect(result).toBeInstanceOf(PageDto); expect(result.content).toEqual(songList); - expect(songService.querySongs).toHaveBeenCalledWith(query, '', undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, ''); }); it('should handle search with multiple pages', async () => { @@ -484,7 +492,7 @@ describe('SongController', () => { const q = 'test search'; const songList: SongPreviewDto[] = Array(10) .fill(null) - .map((_, i) => ({ id: `song-${10 + i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${10 + i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -499,7 +507,7 @@ describe('SongController', () => { expect(result.content).toHaveLength(10); expect(result.total).toBe(25); expect(result.page).toBe(2); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with large result set', async () => { @@ -507,7 +515,7 @@ describe('SongController', () => { const q = 'popular song'; const songList: SongPreviewDto[] = Array(50) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -521,7 +529,7 @@ describe('SongController', () => { expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(50); expect(result.total).toBe(500); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search on last page with partial results', async () => { @@ -529,7 +537,7 @@ describe('SongController', () => { const q = 'search term'; const songList: SongPreviewDto[] = Array(3) .fill(null) - .map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${40 + i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -561,7 +569,7 @@ describe('SongController', () => { const result = await songController.searchSongs(query, q); expect(result).toBeInstanceOf(PageDto); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with very long query string', async () => { @@ -579,7 +587,7 @@ describe('SongController', () => { const result = await songController.searchSongs(query, q); expect(result).toBeInstanceOf(PageDto); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should handle search with custom limit', async () => { @@ -587,7 +595,7 @@ describe('SongController', () => { const q = 'test'; const songList: SongPreviewDto[] = Array(25) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -614,7 +622,7 @@ describe('SongController', () => { const q = 'trending'; const songList: SongPreviewDto[] = Array(10) .fill(null) - .map((_, i) => ({ id: `song-${i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -627,7 +635,7 @@ describe('SongController', () => { expect(result).toBeInstanceOf(PageDto); expect(result.content).toHaveLength(10); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); it('should return correct pagination info with search results', async () => { @@ -635,7 +643,7 @@ describe('SongController', () => { const q = 'search'; const songList: SongPreviewDto[] = Array(20) .fill(null) - .map((_, i) => ({ id: `song-${40 + i}` } as SongPreviewDto)); + .map((_, i) => ({ id: `song-${40 + i}` } as unknown as SongPreviewDto)); mockSongService.querySongs.mockResolvedValueOnce({ content: songList, @@ -699,7 +707,7 @@ describe('SongController', () => { const result = await songController.searchSongs(query, q); expect(result).toBeInstanceOf(PageDto); - expect(songService.querySongs).toHaveBeenCalledWith(query, q, undefined); + expect(songService.querySongs).toHaveBeenCalledWith(query, q ?? ''); }); }); @@ -794,21 +802,20 @@ describe('SongController', () => { }); describe('getSongFile', () => { - it('should get song .nbs file', async () => { + it('should redirect to download URL', async () => { const id = 'test-id'; const src = 'test-src'; const user: UserDocument = { _id: 'test-user-id', } as unknown as UserDocument; + const downloadUrl = 'https://example.com/download/song.nbs'; const res = { set: jest.fn(), redirect: jest.fn(), } as unknown as Response; - const url = 'test-url'; - - mockSongService.getSongDownloadUrl.mockResolvedValueOnce(url); + mockSongService.getSongDownloadUrl.mockResolvedValueOnce(downloadUrl); await songController.getSongFile(id, src, user, res); @@ -817,7 +824,7 @@ describe('SongController', () => { 'Access-Control-Expose-Headers': 'Content-Disposition', }); - expect(res.redirect).toHaveBeenCalledWith(HttpStatus.FOUND, url); + expect(res.redirect).toHaveBeenCalledWith(HttpStatus.FOUND, downloadUrl); expect(songService.getSongDownloadUrl).toHaveBeenCalledWith( id, diff --git a/apps/backend/src/song/song.controller.ts b/apps/backend/src/song/song.controller.ts index d50f2a8f..a6e8410d 100644 --- a/apps/backend/src/song/song.controller.ts +++ b/apps/backend/src/song/song.controller.ts @@ -8,6 +8,7 @@ import { Headers, HttpException, HttpStatus, + Inject, Logger, Param, Patch, @@ -65,7 +66,9 @@ export class SongController { }; constructor( + @Inject(SongService) public readonly songService: SongService, + @Inject(FileService) public readonly fileService: FileService, ) {} @@ -129,7 +132,7 @@ export class SongController { [SongSortType.NOTE_COUNT, 'stats.noteCount'], ]); - const sortField = sortFieldMap.get(query.sort) ?? 'createdAt'; + const sortField = sortFieldMap.get(query.sort ?? SongSortType.RECENT); const isDescending = query.order ? query.order === 'desc' : true; // Build PageQueryDTO with the sort field @@ -318,37 +321,15 @@ export class SongController { ): Promise { user = validateUser(user); - try { - // Get file directly from S3/MinIO and proxy it to avoid CORS issues - // This bypasses presigned URLs and CORS entirely - const { buffer, filename } = await this.songService.getSongFileBuffer( - id, - user, - src, - false, - ); - - // Set headers and send file - res.set({ - 'Content-Type': 'application/octet-stream', - 'Content-Disposition': `attachment; filename="${filename.replace( - /[/"]/g, - '_', - )}"`, - 'Access-Control-Expose-Headers': 'Content-Disposition', - }); + // TODO: no longer used + res.set({ + 'Content-Disposition': 'attachment; filename="song.nbs"', + // Expose the Content-Disposition header to the client + 'Access-Control-Expose-Headers': 'Content-Disposition', + }); - res.send(Buffer.from(buffer)); - } catch (error) { - this.logger.error('Error downloading song file:', error); - if (error instanceof HttpException) { - throw error; - } - throw new HttpException( - 'An error occurred while retrieving the song file', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } + const url = await this.songService.getSongDownloadUrl(id, user, src, false); + res.redirect(HttpStatus.FOUND, url); } @Get('/:id/open') diff --git a/apps/backend/src/song/song.service.spec.ts b/apps/backend/src/song/song.service.spec.ts index 4a944f7f..edf8dfb6 100644 --- a/apps/backend/src/song/song.service.spec.ts +++ b/apps/backend/src/song/song.service.spec.ts @@ -24,6 +24,7 @@ import { SongService } from './song.service'; const mockFileService = { deleteSong: jest.fn(), getSongDownloadUrl: jest.fn(), + getSongFile: jest.fn(), }; const mockSongUploadService = { @@ -39,6 +40,16 @@ const mockSongWebhookService = { syncSongWebhook: jest.fn(), }; +const mockSongModel = { + create: jest.fn(), + findOne: jest.fn(), + find: jest.fn(), + deleteOne: jest.fn(), + countDocuments: jest.fn(), + aggregate: jest.fn(), + populate: jest.fn(), +}; + describe('SongService', () => { let service: SongService; let fileService: FileService; @@ -46,6 +57,9 @@ describe('SongService', () => { let songModel: Model; beforeEach(async () => { + // Clear all mocks before each test + jest.clearAllMocks(); + const module: TestingModule = await Test.createTestingModule({ providers: [ SongService, @@ -55,7 +69,7 @@ describe('SongService', () => { }, { provide: getModelToken(SongEntity.name), - useValue: mongoose.model(SongEntity.name, SongSchema), + useValue: mockSongModel, }, { provide: FileService, @@ -294,23 +308,6 @@ describe('SongService', () => { HttpException, ); }); - - it('should throw an error if user is unauthorized', async () => { - const publicId = 'test-id'; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - const songEntity = new SongEntity(); - songEntity.uploader = new mongoose.Types.ObjectId(); // Different uploader - - const mockFindOne = { - exec: jest.fn().mockResolvedValue(songEntity), - }; - - jest.spyOn(songModel, 'findOne').mockReturnValue(mockFindOne as any); - - await expect(service.deleteSong(publicId, user)).rejects.toThrow( - HttpException, - ); - }); }); describe('patchSong', () => { @@ -481,39 +478,6 @@ describe('SongService', () => { ); }); - it('should throw an error if user is unauthorized', async () => { - const publicId = 'test-id'; - const user: UserDocument = { _id: 'test-user-id' } as UserDocument; - - const body: UploadSongDto = { - title: 'Test Song', - originalAuthor: 'Test Author', - description: 'Test Description', - category: 'alternative', - visibility: 'public', - license: 'standard', - customInstruments: [], - thumbnailData: { - startTick: 0, - startLayer: 0, - zoomLevel: 1, - backgroundColor: '#000000', - }, - file: 'somebytes', - allowDownload: false, - }; - - const songEntity = { - uploader: 'different-user-id', - } as any; - - jest.spyOn(songModel, 'findOne').mockReturnValue(songEntity); - - await expect(service.patchSong(publicId, body, user)).rejects.toThrow( - HttpException, - ); - }); - it('should throw an error if user no changes are provided', async () => { const publicId = 'test-id'; const user: UserDocument = { _id: 'test-user-id' } as UserDocument; @@ -1014,13 +978,13 @@ describe('SongService', () => { { _id: 'category2', count: 5 }, ]; - jest.spyOn(songModel, 'aggregate').mockResolvedValue(categories); + mockSongModel.aggregate.mockResolvedValue(categories); const result = await service.getCategories(); expect(result).toEqual({ category1: 10, category2: 5 }); - expect(songModel.aggregate).toHaveBeenCalledWith([ + expect(mockSongModel.aggregate).toHaveBeenCalledWith([ { $match: { visibility: 'public' } }, { $group: { _id: '$category', count: { $sum: 1 } } }, { $sort: { count: -1 } }, @@ -1212,7 +1176,6 @@ describe('SongService', () => { }; jest.spyOn(songModel, 'aggregate').mockReturnValue(mockAggregate as any); - jest.spyOn(songModel, 'populate').mockResolvedValue(songList); const result = await service.getRandomSongs(count); @@ -1220,10 +1183,14 @@ describe('SongService', () => { songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), ); - expect(songModel.aggregate).toHaveBeenCalledWith([ + expect(mockSongModel.aggregate).toHaveBeenCalledWith([ { $match: { visibility: 'public' } }, { $sample: { size: count } }, ]); + expect(mockSongModel.populate).toHaveBeenCalledWith(songList, { + path: 'uploader', + select: 'username profileImage -_id', + }); }); it('should return random songs with category filter', async () => { @@ -1236,7 +1203,6 @@ describe('SongService', () => { }; jest.spyOn(songModel, 'aggregate').mockReturnValue(mockAggregate as any); - jest.spyOn(songModel, 'populate').mockResolvedValue(songList); const result = await service.getRandomSongs(count, category); @@ -1244,10 +1210,14 @@ describe('SongService', () => { songList.map((song) => SongPreviewDto.fromSongDocumentWithUser(song)), ); - expect(songModel.aggregate).toHaveBeenCalledWith([ + expect(mockSongModel.aggregate).toHaveBeenCalledWith([ { $match: { visibility: 'public', category: 'pop' } }, { $sample: { size: count } }, ]); + expect(mockSongModel.populate).toHaveBeenCalledWith(songList, { + path: 'uploader', + select: 'username profileImage -_id', + }); }); }); }); diff --git a/apps/backend/src/song/song.service.ts b/apps/backend/src/song/song.service.ts index 546d4a35..82da03f8 100644 --- a/apps/backend/src/song/song.service.ts +++ b/apps/backend/src/song/song.service.ts @@ -393,57 +393,6 @@ export class SongService { } } - public async getSongFileBuffer( - publicId: string, - user: UserDocument | null, - src?: string, - packed: boolean = false, - ): Promise<{ buffer: ArrayBuffer; filename: string }> { - const foundSong = await this.songModel.findOne({ publicId: publicId }); - - if (!foundSong) { - throw new HttpException('Song not found with ID', HttpStatus.NOT_FOUND); - } - - if (foundSong.visibility !== 'public') { - if (!user || foundSong.uploader.toString() !== user._id.toString()) { - throw new HttpException( - 'This song is private', - HttpStatus.UNAUTHORIZED, - ); - } - } - - if (!packed && !foundSong.allowDownload) { - throw new HttpException( - 'The uploader has disabled downloads of this song', - HttpStatus.UNAUTHORIZED, - ); - } - - const fileKey = packed ? foundSong.packedSongUrl : foundSong.nbsFileUrl; - const fileExt = packed ? '.zip' : '.nbs'; - const fileName = `${foundSong.title}${fileExt}`; - - try { - const fileBuffer = await this.fileService.getSongFile(fileKey); - - // increment download count - if (!packed && src === 'downloadButton') { - foundSong.downloadCount++; - await foundSong.save(); - } - - return { buffer: fileBuffer, filename: fileName }; - } catch (e) { - this.logger.error('Error getting song file', e); - throw new HttpException( - 'An error occurred while retrieving the song file', - HttpStatus.INTERNAL_SERVER_ERROR, - ); - } - } - public async getMySongsPage({ query, user, diff --git a/apps/backend/tsconfig.build.json b/apps/backend/tsconfig.build.json deleted file mode 100644 index 64f86c6b..00000000 --- a/apps/backend/tsconfig.build.json +++ /dev/null @@ -1,4 +0,0 @@ -{ - "extends": "./tsconfig.json", - "exclude": ["node_modules", "test", "dist", "**/*spec.ts"] -} diff --git a/apps/backend/tsconfig.json b/apps/backend/tsconfig.json index 7dc80e0f..a23a38c9 100644 --- a/apps/backend/tsconfig.json +++ b/apps/backend/tsconfig.json @@ -2,26 +2,21 @@ "extends": "../../tsconfig.base.json", "compilerOptions": { // NestJS specific settings - "module": "commonjs", - "target": "ES2021", + "module": "esnext", + "target": "esnext", "declaration": true, "removeComments": true, "allowSyntheticDefaultImports": true, "sourceMap": true, - "outDir": "./dist", - "baseUrl": "./", "emitDecoratorMetadata": true, - // Relaxed strict settings for backend - "strictNullChecks": false, - "noImplicitAny": false, - "strictBindCallApply": false, - "forceConsistentCasingInFileNames": false, - "noFallthroughCasesInSwitch": false, + "moduleResolution": "bundler", + "noEmit": true, + // Path mapping + "baseUrl": ".", "paths": { "@server/*": ["src/*"] } }, - "include": ["src/**/*.ts", "src/**/*.d.ts"], - "exclude": ["node_modules", "dist", "e2e/**/*", "test/**/*"] + "include": ["src", "scripts"] } diff --git a/apps/frontend/components.json b/apps/frontend/components.json index d81b404a..b9835b61 100644 --- a/apps/frontend/components.json +++ b/apps/frontend/components.json @@ -6,11 +6,13 @@ "tailwind": { "config": "tailwind.config.js", "css": "src/app/globals.css", - "baseColor": "neutral", - "cssVariables": true, - "prefix": "" + "baseColor": "zinc", + "cssVariables": false, + "prefix": "", + "rsc": true, + "tsx": true }, - "iconLibrary": "lucide", + "iconLibrary": "fontawesome", "aliases": { "components": "@web/modules/shared/components", "utils": "@web/lib/utils", diff --git a/apps/frontend/next.config.mjs b/apps/frontend/next.config.mjs index cdcf10ed..46a3c3fd 100644 --- a/apps/frontend/next.config.mjs +++ b/apps/frontend/next.config.mjs @@ -4,6 +4,8 @@ import createMDX from '@next/mdx'; const nextConfig = { pageExtensions: ['js', 'jsx', 'md', 'mdx', 'ts', 'tsx'], + // Externalize packages that use Node.js built-in modules for server components + serverExternalPackages: ['@nbw/database', '@nbw/config'], // See: https://github.com/Automattic/node-canvas/issues/867#issuecomment-1925284985 webpack: (config, { isServer }) => { config.externals.push({ @@ -12,8 +14,9 @@ const nextConfig = { // Prevent @nbw/thumbnail from being bundled on the server // It uses HTMLCanvasElement which is not available in Node.js + // Also externalize backend packages that use Node.js modules if (isServer) { - config.externals.push('@nbw/thumbnail'); + config.externals.push('@nbw/thumbnail', '@nbw/database'); } return config; diff --git a/apps/frontend/package.json b/apps/frontend/package.json index 296f3c78..f1e4c2a1 100644 --- a/apps/frontend/package.json +++ b/apps/frontend/package.json @@ -3,8 +3,8 @@ "version": "0.1.0", "private": true, "scripts": { - "dev": "next dev", - "build": "next build", + "dev": "next dev --webpack", + "build": "next build --webpack", "start": "next start", "lint": "eslint \"src/**/*.{ts,tsx}\" --fix", "test": "jest" @@ -14,7 +14,7 @@ "@fortawesome/free-solid-svg-icons": "^6.7.2", "@fortawesome/react-fontawesome": "^0.2.6", "@headlessui/react": "^1.7.19", - "@hookform/resolvers": "^3.10.0", + "@hookform/resolvers": "^5.2.2", "@mdx-js/loader": "^3.1.1", "@mdx-js/react": "^3.1.1", "@nbw/config": "workspace:*", @@ -42,7 +42,7 @@ "i": "^0.3.7", "js-confetti": "^0.13.1", "lucide-react": "^0.556.0", - "next": "16.0.8", + "next": "16.0.10", "next-recaptcha-v3": "^1.5.3", "nextjs-toploader": "^3.9.17", "npm": "^11.7.0", @@ -56,10 +56,11 @@ "react-infinite-scroll-component": "^6.1.1", "react-loading-skeleton": "^3.5.0", "react-markdown": "^10.1.0", + "remove-markdown": "^0.6.3", "schema-dts": "^1.1.5", "sharp": "^0.34.5", "tailwind-merge": "^3.4.0", - "tailwindcss": "4.1.17", + "tailwindcss": "^4.1.18", "tailwindcss-animate": "^1.0.7", "typescript": "^5.9.3", "zod": "^4.1.13", @@ -67,8 +68,7 @@ "zustand": "^5.0.9" }, "devDependencies": { - "@shrutibalasa/tailwind-grid-auto-fit": "^1.1.0", - "@tailwindcss/postcss": "^4.1.17", + "@tailwindcss/postcss": "^4.1.18", "@types/mdx": "^2.0.13", "@types/react-modal": "^3.16.3", "eslint-config-next": "16.0.8", diff --git a/apps/frontend/postcss.config.js b/apps/frontend/postcss.config.mjs similarity index 56% rename from apps/frontend/postcss.config.js rename to apps/frontend/postcss.config.mjs index b4bee663..297374d8 100644 --- a/apps/frontend/postcss.config.js +++ b/apps/frontend/postcss.config.mjs @@ -1,6 +1,7 @@ -module.exports = { +const config = { plugins: { '@tailwindcss/postcss': {}, - autoprefixer: {}, }, }; + +export default config; diff --git a/apps/frontend/src/app/(content)/(info)/about/page.tsx b/apps/frontend/src/app/(content)/(info)/about/page.tsx index 37916161..d7e9ceea 100644 --- a/apps/frontend/src/app/(content)/(info)/about/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/about/page.tsx @@ -12,7 +12,7 @@ export const metadata: Metadata = { const AboutPage = () => { return ( <> -
+
; }; export async function generateMetadata({ @@ -50,12 +50,12 @@ const BlogPost = async ({ params }: BlogPageProps) => { )} -
+
{ {"See what we've been working on!"} -
+
{posts.map((post, i) => ( { width={480} height={360} alt='' - className='rounded-md aspect-[16/9] w-full object-cover transition-all duration-300 mb-2' + className='rounded-md aspect-video w-full object-cover transition-all duration-300 mb-2' /> -

+

{post.title}

@@ -58,7 +59,7 @@ const BlogPageComponent = ({ posts }: { posts: PostType[] }) => { .replace(/\//g, '.')}

- {post.content.slice(0, 200)} + {removeMarkdown(post.content).slice(0, 250)}

diff --git a/apps/frontend/src/app/(content)/(info)/contact/page.tsx b/apps/frontend/src/app/(content)/(info)/contact/page.tsx index ebd20a1a..18f5728d 100644 --- a/apps/frontend/src/app/(content)/(info)/contact/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/contact/page.tsx @@ -11,7 +11,7 @@ export const metadata: Metadata = { const AboutPage = () => { return ( <> -
+
{'< Back'} diff --git a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx index 5ace7da7..5c372fbc 100644 --- a/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/(info)/help/[id]/page.tsx @@ -7,7 +7,7 @@ import { PostType, getPostData } from '@web/lib/posts'; import { CustomMarkdown } from '@web/modules/shared/components/CustomMarkdown'; type HelpPageProps = { - params: { id: string }; + params: Promise<{ id: string }>; }; export async function generateMetadata({ @@ -34,8 +34,8 @@ export async function generateMetadata({ }; } -const HelpPost = ({ params }: HelpPageProps) => { - const { id } = params; +const HelpPost = async ({ params }: HelpPageProps) => { + const { id } = await params; let post: PostType; try { @@ -46,7 +46,7 @@ const HelpPost = ({ params }: HelpPageProps) => { return ( <> -
+
{ width={480} height={360} alt={post.title} - className='rounded-2xl aspect-[3/2] object-cover brightness-150 transition-all duration-300' + className='rounded-2xl aspect-3/2 object-cover brightness-150 transition-all duration-300' /> {/* Gradient over the image */} -
+

{post.shortTitle || post.title}

diff --git a/apps/frontend/src/app/(content)/search/layout.tsx b/apps/frontend/src/app/(content)/search/layout.tsx new file mode 100644 index 00000000..f0bd3e9c --- /dev/null +++ b/apps/frontend/src/app/(content)/search/layout.tsx @@ -0,0 +1,10 @@ +export const dynamic = 'force-dynamic'; +export const revalidate = 0; + +export default function SearchLayout({ + children, +}: { + children: React.ReactNode; +}) { + return <>{children}; +} diff --git a/apps/frontend/src/app/(content)/search/page.tsx b/apps/frontend/src/app/(content)/search/page.tsx index 61276b91..861c7620 100644 --- a/apps/frontend/src/app/(content)/search/page.tsx +++ b/apps/frontend/src/app/(content)/search/page.tsx @@ -1,629 +1,15 @@ 'use client'; -import { - faArrowDown19, - faArrowDown91, - faArrowDownAZ, - faArrowDownZA, - faEllipsis, - faFilter, -} from '@fortawesome/free-solid-svg-icons'; -import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; -import Image from 'next/image'; -import Link from 'next/link'; -import { parseAsInteger, parseAsString, useQueryStates } from 'nuqs'; -import { useEffect, useMemo, useState } from 'react'; -import Skeleton from 'react-loading-skeleton'; -import { create } from 'zustand'; +import { Suspense } from 'react'; -import { UPLOAD_CONSTANTS, SEARCH_FEATURES, INSTRUMENTS } from '@nbw/config'; -import { SongPreviewDtoType } from '@nbw/database'; -import axiosInstance from '@web/lib/axios'; -import LoadMoreButton from '@web/modules/browse/components/client/LoadMoreButton'; -import SongCard from '@web/modules/browse/components/SongCard'; -import SongCardGroup from '@web/modules/browse/components/SongCardGroup'; -import { DualRangeSlider } from '@web/modules/shared/components/ui/dualRangeSlider'; -import MultipleSelector from '@web/modules/shared/components/ui/multipleSelectorProps'; - -interface SearchParams { - q?: string; - sort?: string; - order?: string; - category?: string; - uploader?: string; - limit?: number; - noteCountMin?: number; - noteCountMax?: number; - durationMin?: number; - durationMax?: number; - features?: string; - instruments?: string; -} - -interface PageDto { - content: T[]; - page: number; - limit: number; - total: number; -} - -// TODO: importing these enums from '@nbw/database' is causing issues. -// They shouldn't be redefined here. -enum SongSortType { - RECENT = 'recent', - RANDOM = 'random', - PLAY_COUNT = 'playCount', - TITLE = 'title', - DURATION = 'duration', - NOTE_COUNT = 'noteCount', -} - -enum SongOrderType { - ASC = 'asc', - DESC = 'desc', -} - -// TODO: refactor with PAGE_SIZE constant -const PLACEHOLDER_COUNT = 12; - -const makePlaceholders = () => - Array.from({ length: PLACEHOLDER_COUNT }, () => null); - -interface SongSearchState { - songs: Array; - loading: boolean; - hasMore: boolean; - currentPage: number; - totalResults: number; -} - -interface SongSearchActions { - searchSongs: (params: SearchParams, pageNum: number) => Promise; - loadMore: (params: SearchParams) => Promise; -} - -const initialState: SongSearchState = { - songs: [], - loading: true, - hasMore: true, - currentPage: 1, - totalResults: 0, -}; - -export const useSongSearchStore = create( - (set, get) => ({ - ...initialState, - - // The core data fetching action - searchSongs: async (params, pageNum) => { - // New search/sort (page 1): reset to placeholders. Load more: append placeholders. - if (pageNum === 1) { - set({ - loading: true, - songs: makePlaceholders(), - currentPage: 1, - hasMore: true, - }); - } else { - set((state) => ({ - loading: true, - songs: [...state.songs, ...makePlaceholders()], - })); - } - - try { - const response = await axiosInstance.get>( - '/song', - { params: { ...params, page: pageNum } }, - ); - - const { content, total } = response.data; - const limit = params.limit || 12; - - set((state) => ({ - // Remove placeholders and add the new results - songs: - pageNum === 1 - ? content - : [...state.songs.filter((s) => s !== null), ...content], - totalResults: total, - currentPage: pageNum, - // Check if there are more pages to load - hasMore: content.length >= limit, - })); - } catch (error) { - console.error('Error searching songs:', error); - set({ songs: [], hasMore: false, totalResults: 0 }); // Reset on error - } finally { - set({ loading: false }); - } - }, - - // Convenience action for loading the next page - loadMore: async (params) => { - const { loading, hasMore, currentPage } = get(); - if (loading || !hasMore) return; // Prevent multiple calls - - const nextPage = currentPage + 1; - await get().searchSongs(params, nextPage); - }, - }), -); - -interface SearchHeaderProps { - query: string; - loading: boolean; - songsCount: number; - totalResults: number; -} - -/** - * Displays the main header for the search page, including the title - * and a summary of the results found. - */ -const SearchHeader = ({ - query, - loading, - songsCount, - totalResults, -}: SearchHeaderProps) => { - const isSearch = useMemo(() => query !== '', [query]); - - const title = useMemo(() => { - if (loading) return ''; - if (isSearch) { - // TODO: implement this with proper variable substitution for translations - if (totalResults != 1) { - return `${totalResults.toLocaleString('en-UK')} results for "${query}"`; - } - return `1 result for "${query}"`; - } - return 'Browse songs'; - }, [loading, isSearch, query, songsCount, totalResults]); - - return ( -
-

- {title || } -

-
- ); -}; - -interface SearchFiltersProps { - filters: { - category: string; - sort: string; - order: string; - limit: number; - uploader: string; - noteCountMin: number; - noteCountMax: number; - durationMin: number; - durationMax: number; - features: string; - instruments: string; - }; - onFilterChange: (params: Record) => void; -} - -const SearchFilters = ({ filters, onFilterChange }: SearchFiltersProps) => { - const { - category, - sort, - order, - limit, - uploader, - noteCountMin, - noteCountMax, - durationMin, - durationMax, - features, - instruments, - } = filters; - - // Helper to parse comma-separated string to array of Option objects - const parseOptions = (str: string, optionsMap: Record) => { - if (!str) return []; - return str - .split(',') - .filter((v) => v.trim()) - .map((value) => { - // Find the label by searching in the options map - const entry = Object.entries(optionsMap).find( - ([, v]) => v === value.trim(), - ); - return { - value: value.trim(), - label: entry ? entry[0] : value.trim(), - }; - }); - }; - - // Helper to convert array of Option objects to comma-separated string - const optionsToString = (options: Array<{ value: string; label: string }>) => - options.map((opt) => opt.value).join(','); +import { SearchSongPage } from '@web/modules/song-search/SearchSongPage'; +const SearchPageWrapper = () => { return ( - - ); -}; - -const NoResults = () => ( -
- -

No songs found

-

- Try adjusting your search terms, or browse our{' '} - - featured songs - {' '} - instead. -

-
-); - -interface SearchResultsProps { - songs: Array; - loading: boolean; - hasMore: boolean; - onLoadMore: () => void; -} - -const SearchResults = ({ songs, hasMore, onLoadMore }: SearchResultsProps) => ( - <> - - {songs.map((song, i) => ( - - ))} - - - {/* Load more / End indicator */} -
- {hasMore ? ( - - ) : ( -
- -
- )} -
- -); - -const SearchSongPage = () => { - const [queryState, setQueryState] = useQueryStates({ - q: parseAsString.withDefault(''), - sort: parseAsString.withDefault(SongSortType.RECENT), - order: parseAsString.withDefault(SongOrderType.DESC), - category: parseAsString.withDefault(''), - uploader: parseAsString.withDefault(''), - page: parseAsInteger.withDefault(1), - limit: parseAsInteger.withDefault(12), - noteCountMin: parseAsInteger, - noteCountMax: parseAsInteger, - durationMin: parseAsInteger, - durationMax: parseAsInteger, - features: parseAsString, - instruments: parseAsString, - }); - - const { - q: query, - sort, - order, - category, - uploader, - page: currentPageParam, - limit, - noteCountMin, - noteCountMax, - durationMin, - durationMax, - features, - instruments, - } = queryState; - - const initialPage = currentPageParam ?? 1; - - const { songs, loading, hasMore, totalResults, searchSongs } = - useSongSearchStore(); - - const [showFilters, setShowFilters] = useState(false); - - useEffect(() => { - const params: SearchParams = { - q: query, - sort, - order, - category, - uploader, - limit, - noteCountMin: noteCountMin > 0 ? noteCountMin : undefined, - noteCountMax: noteCountMax < 10000 ? noteCountMax : undefined, - durationMin: durationMin > 0 ? durationMin : undefined, - durationMax: durationMax < 10000 ? durationMax : undefined, - features: features || undefined, - instruments: instruments || undefined, - }; - searchSongs(params, initialPage); - }, [ - query, - sort, - order, - category, - uploader, - initialPage, - limit, - noteCountMin, - noteCountMax, - durationMin, - durationMax, - features, - instruments, - searchSongs, - ]); - - const handleLoadMore = () => { - setQueryState({ page: (currentPageParam ?? 1) + 1 }); - }; - - const handleSortChange = (value: string) => { - setQueryState({ sort: value, page: 1 }); - }; - - const handleOrderChange = () => { - const newOrder = - order === SongOrderType.ASC ? SongOrderType.DESC : SongOrderType.ASC; - setQueryState({ order: newOrder, page: 1 }); - }; - - /* Use 19/91 button if sorting by a numeric value, otherwise use AZ/ZA */ - const orderIcon = useMemo(() => { - if (sort === SongSortType.TITLE) { - return order === SongOrderType.ASC ? faArrowDownAZ : faArrowDownZA; - } else { - return order === SongOrderType.ASC ? faArrowDown19 : faArrowDown91; - } - }, [sort, order]); - - return ( -
-
- {/* Filters Sidebar */} - {/* {showFilters && ( -
- updateURL(params)} - /> -
- )} */} - - {/* Main Content */} -
-
-
- -
-
- {/* */} -
- Sort by: - -
- - {/* Order button */} - -
-
- - {/* Results */} - {songs.length > 0 && ( - - )} - - {/* No results */} - {!loading && songs.length === 0 && } -
-
-
+ Loading...
}> + + ); }; -export default SearchSongPage; +export default SearchPageWrapper; diff --git a/apps/frontend/src/app/(content)/song/[id]/page.tsx b/apps/frontend/src/app/(content)/song/[id]/page.tsx index 44d852c4..75a489d8 100644 --- a/apps/frontend/src/app/(content)/song/[id]/page.tsx +++ b/apps/frontend/src/app/(content)/song/[id]/page.tsx @@ -1,7 +1,7 @@ import type { Metadata } from 'next'; import { cookies } from 'next/headers'; -import { SongViewDtoType } from '@nbw/database'; +import type { SongViewDtoType } from '@nbw/database'; import axios from '@web/lib/axios'; import { SongPage } from '@web/modules/song/components/SongPage'; diff --git a/apps/frontend/src/app/(external)/layout.tsx b/apps/frontend/src/app/(external)/layout.tsx index aa9d1eb1..36d17c19 100644 --- a/apps/frontend/src/app/(external)/layout.tsx +++ b/apps/frontend/src/app/(external)/layout.tsx @@ -10,7 +10,7 @@ export default async function LoginLayout({ return ( <>
- + {/* https://nextjs.org/docs/app/building-your-application/optimizing/metadata#json-ld */} @@ -102,7 +111,7 @@ export default function RootLayout({ diff --git a/apps/frontend/src/app/not-found.tsx b/apps/frontend/src/app/not-found.tsx index 618e98f6..e6544d41 100644 --- a/apps/frontend/src/app/not-found.tsx +++ b/apps/frontend/src/app/not-found.tsx @@ -40,13 +40,13 @@ export default function NotFound() { return ( <>
-
+
{/* Background image */}

Oops...

@@ -57,7 +57,7 @@ export default function NotFound() { width={400} height={400} quality={95} - className='object-contain relative md:left-8 top-8 w-64 md:w-full z-[2]' + className='object-contain relative md:left-8 top-8 w-64 md:w-full z-2' />

404 diff --git a/apps/frontend/src/modules/auth/components/loginPage.tsx b/apps/frontend/src/modules/auth/components/loginPage.tsx index 5551d6d3..75a1f05e 100644 --- a/apps/frontend/src/modules/auth/components/loginPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginPage.tsx @@ -51,9 +51,9 @@ export const LoginPage = () => { {/* Vertical divider (mobile) */} -
+
{/* Horizontal divider (desktop) */} -
+
{/* Right half */}
@@ -87,6 +87,7 @@ export const LoginPage = () => { Log in with Google @@ -99,6 +100,7 @@ export const LoginPage = () => { Log in with GitHub @@ -111,6 +113,7 @@ export const LoginPage = () => { Log in with Discord diff --git a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx index 06367fd3..9607bdec 100644 --- a/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx +++ b/apps/frontend/src/modules/auth/components/loginWithEmailPage.tsx @@ -14,9 +14,9 @@ export const LoginWithEmailPage = () => { {/* Vertical divider (mobile) */} -
+
{/* Horizontal divider (desktop) */} -
+
{/* Right half */}
diff --git a/apps/frontend/src/modules/browse/EventBanner.tsx b/apps/frontend/src/modules/browse/EventBanner.tsx index 496b0791..81f864fc 100644 --- a/apps/frontend/src/modules/browse/EventBanner.tsx +++ b/apps/frontend/src/modules/browse/EventBanner.tsx @@ -32,7 +32,7 @@ export const EventBanner = () => { }; return ( -
+
{ >
-
+

{' '} {timeLeft === 0 ? ( diff --git a/apps/frontend/src/modules/browse/WelcomeBanner.tsx b/apps/frontend/src/modules/browse/WelcomeBanner.tsx index ba489bc2..63f3e264 100644 --- a/apps/frontend/src/modules/browse/WelcomeBanner.tsx +++ b/apps/frontend/src/modules/browse/WelcomeBanner.tsx @@ -3,7 +3,7 @@ import Link from 'next/link'; export const WelcomeBanner = () => { return ( -

+
{ width={100} height={100} /> -
+

Welcome to Note Block World ! diff --git a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx index 9edcd140..0f9d3ccf 100644 --- a/apps/frontend/src/modules/browse/components/HomePageComponent.tsx +++ b/apps/frontend/src/modules/browse/components/HomePageComponent.tsx @@ -82,7 +82,7 @@ export const HomePageComponent = () => { {/* RECENT SONGS */}
-

Recent songs

+

Recent songs

diff --git a/apps/frontend/src/modules/browse/components/SongCard.tsx b/apps/frontend/src/modules/browse/components/SongCard.tsx index 33e62bfe..5910abbc 100644 --- a/apps/frontend/src/modules/browse/components/SongCard.tsx +++ b/apps/frontend/src/modules/browse/components/SongCard.tsx @@ -5,7 +5,7 @@ import { FontAwesomeIcon } from '@fortawesome/react-fontawesome'; import Link from 'next/link'; import Skeleton from 'react-loading-skeleton'; -import { SongPreviewDtoType } from '@nbw/database'; +import type { SongPreviewDtoType } from '@nbw/database'; import { formatDuration, formatTimeAgo } from '@web/modules/shared/util/format'; import SongThumbnail from '../../shared/components/layout/SongThumbnail'; @@ -17,7 +17,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => {
{!song ? ( ) : ( @@ -33,9 +33,9 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => {
{/* Song info */} -
+
{/* Song title */} -

+

{song?.title || }

@@ -51,7 +51,7 @@ const SongDataDisplay = ({ song }: { song: SongPreviewDtoType | null }) => { )}

{/* Play icon & count */} -
+
{!song ? ( ) : ( diff --git a/apps/frontend/src/modules/browse/components/SongCardGroup.tsx b/apps/frontend/src/modules/browse/components/SongCardGroup.tsx index c247d636..6eedd04a 100644 --- a/apps/frontend/src/modules/browse/components/SongCardGroup.tsx +++ b/apps/frontend/src/modules/browse/components/SongCardGroup.tsx @@ -1,26 +1,17 @@ -const SongCardGroup = ({ - children, - size = 'md', -}: { - children: React.ReactNode; - size?: 'sm' | 'md' | 'lg' | 'xl'; -}) => { - // Define min column widths for each size - const minWidths = { - sm: '200px', - md: '280px', - lg: '320px', - xl: '400px', - }; +const SongCardGroup = ({ children }: { children: React.ReactNode }) => { + // Ensure at least 4 items in the grid to prevent single cards from taking up full width + const childrenArray = Array.isArray(children) ? children : [children]; + const itemsToDisplay = Math.max(childrenArray.length, 4); + const paddedSongs = [ + ...childrenArray, + ...Array.from({ length: itemsToDisplay - childrenArray.length }, (_, i) => ( +