diff --git a/NoteBlockWorld.code-workspace b/NoteBlockWorld.code-workspace index 25a7ce54..cb438c31 100644 --- a/NoteBlockWorld.code-workspace +++ b/NoteBlockWorld.code-workspace @@ -26,6 +26,11 @@ "editor.codeActionsOnSave": { "source.fixAll": "explicit" }, - "jest.disabledWorkspaceFolders": ["Root", "Frontend"] + "jest.disabledWorkspaceFolders": ["Root", "Frontend"], + "search.exclude": { + "**/.git": true, + "**/node_modules": true, + "**/dist": true, + } } } diff --git a/server/src/user/entity/user.entity.ts b/server/src/user/entity/user.entity.ts index d450dc19..eec64637 100644 --- a/server/src/user/entity/user.entity.ts +++ b/server/src/user/entity/user.entity.ts @@ -19,13 +19,13 @@ export class User { lastEdited: Date; @Prop({ type: MongooseSchema.Types.Date, required: true, default: Date.now }) - lastLogin: Date; + lastSeen: Date; @Prop({ type: Number, required: true, default: 0 }) - loginStreak: number; + loginCount: number; @Prop({ type: Number, required: true, default: 0 }) - loginCount: number; + loginStreak: number; @Prop({ type: Number, required: true, default: 0 }) playCount: number; diff --git a/server/src/user/user.service.spec.ts b/server/src/user/user.service.spec.ts index e43fa894..882a2278 100644 --- a/server/src/user/user.service.spec.ts +++ b/server/src/user/user.service.spec.ts @@ -198,7 +198,7 @@ describe('UserService', () => { describe('getSelfUserData', () => { it('should return self user data', async () => { const user = { _id: 'test-id' } as UserDocument; - const userData = { ...user } as UserDocument; + const userData = { ...user, lastSeen: new Date() } as UserDocument; jest.spyOn(service, 'findByID').mockResolvedValue(userData); @@ -217,6 +217,115 @@ describe('UserService', () => { new HttpException('user not found', HttpStatus.NOT_FOUND), ); }); + + it('should update lastSeen and increment loginStreak if lastSeen is before today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen: yesterday, + loginStreak: 1, + save: jest.fn().mockResolvedValue(true), + } as unknown as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginStreak).toBe(2); + expect(userData.save).toHaveBeenCalled(); + }); + + it('should not update lastSeen or increment loginStreak if lastSeen is today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen: today, + loginStreak: 1, + save: jest.fn().mockResolvedValue(true), + } as unknown as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result.lastSeen).toEqual(today); + expect(result.loginStreak).toBe(1); + expect(userData.save).not.toHaveBeenCalled(); + }); + + it('should reset loginStreak if lastSeen is not yesterday', async () => { + const user = { _id: 'test-id' } as UserDocument; + const twoDaysAgo = new Date(); + twoDaysAgo.setDate(twoDaysAgo.getDate() - 2); + twoDaysAgo.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen: twoDaysAgo, + loginStreak: 5, + save: jest.fn().mockResolvedValue(true), + } as unknown as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginStreak).toBe(1); + expect(userData.save).toHaveBeenCalled(); + }); + + it('should increment loginCount if lastSeen is not today', async () => { + const user = { _id: 'test-id' } as UserDocument; + const yesterday = new Date(); + yesterday.setDate(yesterday.getDate() - 1); + yesterday.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen: yesterday, + loginCount: 5, + save: jest.fn().mockResolvedValue(true), + } as unknown as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result.lastSeen).toBeInstanceOf(Date); + expect(result.loginCount).toBe(6); + expect(userData.save).toHaveBeenCalled(); + }); + + it('should not increment loginCount if lastSeen is today', async () => { + const user = { _id: 'test-id' } as UserDocument; + + const today = new Date(); + today.setHours(0, 0, 0, 0); + + const userData = { + ...user, + lastSeen: today, + loginCount: 5, + save: jest.fn().mockResolvedValue(true), + } as unknown as UserDocument; + + jest.spyOn(service, 'findByID').mockResolvedValue(userData); + + const result = await service.getSelfUserData(user); + + expect(result.lastSeen).toEqual(today); + expect(result.loginCount).toBe(5); + expect(userData.save).not.toHaveBeenCalled(); + }); }); describe('usernameExists', () => { diff --git a/server/src/user/user.service.ts b/server/src/user/user.service.ts index 87506b4f..27001c2b 100644 --- a/server/src/user/user.service.ts +++ b/server/src/user/user.service.ts @@ -92,11 +92,32 @@ export class UserService { } public async getSelfUserData(user: UserDocument) { - const usedData = await this.findByID(user._id.toString()); - if (!usedData) + const userData = await this.findByID(user._id.toString()); + if (!userData) throw new HttpException('user not found', HttpStatus.NOT_FOUND); - return usedData; + const today = new Date(); + today.setHours(0, 0, 0, 0); // Set the time to the start of the day + + const lastSeenDate = new Date(userData.lastSeen); + lastSeenDate.setHours(0, 0, 0, 0); // Set the time to the start of the day + + if (lastSeenDate < today) { + userData.lastSeen = new Date(); + + // if the last seen date is not yesterday, reset the login streak + const yesterday = new Date(today); + yesterday.setDate(today.getDate() - 1); + + if (lastSeenDate < yesterday) userData.loginStreak = 1; + else userData.loginStreak += 1; + + userData.loginCount++; + + userData.save(); // no need to await this, we already have the data to send back + } // if equal or greater, do nothing about the login streak + + return userData; } public async usernameExists(username: string) { diff --git a/web/posts/help/5_custom-instruments.md b/web/posts/help/5_custom-instruments.md index e90da21d..bb26b867 100644 --- a/web/posts/help/5_custom-instruments.md +++ b/web/posts/help/5_custom-instruments.md @@ -54,7 +54,7 @@ Note Block World lets you use any sound in the latest Minecraft version in your When you upload a song that uses custom instruments to Note Block World, you'll need to manually select the sound files for each custom instrument you used. This ensures that your song sounds the way you intended it to sound, even if the listener doesn't have the custom instruments installed. -Uploading a song with custom instruments follows the same process as [uploading a regular song](/help/1-creating-song), with the addition of manually selecting the sound files for your custom instruments. Here's how you can do it: +Uploading a song with custom instruments follows the same process as [uploading a regular song](/help/creating-song), with the addition of manually selecting the sound files for your custom instruments. Here's how you can do it: 1. After you've filled in the song's title, description, and other metadata, you'll see a section labeled _Custom instruments_. diff --git a/web/public/ads.txt b/web/public/ads.txt index 07bfb1fb..192ecbfe 100644 --- a/web/public/ads.txt +++ b/web/public/ads.txt @@ -1 +1,2 @@ google.com, pub-2486912467787383, DIRECT, f08c47fec0942fa0 +google.com, pub-6165475566660433, DIRECT, f08c47fec0942fa0 \ No newline at end of file diff --git a/web/src/app/(content)/(info)/blog/[id]/page.tsx b/web/src/app/(content)/(info)/blog/[id]/page.tsx index 66c04353..264dae1f 100644 --- a/web/src/app/(content)/(info)/blog/[id]/page.tsx +++ b/web/src/app/(content)/(info)/blog/[id]/page.tsx @@ -1,9 +1,9 @@ import type { Metadata } from 'next'; import Image from 'next/image'; +import Link from 'next/link'; import { notFound } from 'next/navigation'; import { PostType, getPostData } from '@web/src/lib/posts'; -import BackButton from '@web/src/modules/shared/components/client/BackButton'; import { CustomMarkdown } from '@web/src/modules/shared/components/CustomMarkdown'; type BlogPageProps = { @@ -45,9 +45,12 @@ const BlogPost = ({ params }: BlogPageProps) => { return ( <>
- - {'< Back to Help'} - + + {'< Back to Blog'} +

{post.title}

{/* Author */} diff --git a/web/src/app/(content)/(info)/help/[id]/page.tsx b/web/src/app/(content)/(info)/help/[id]/page.tsx index 3e100ef7..a47ec2a9 100644 --- a/web/src/app/(content)/(info)/help/[id]/page.tsx +++ b/web/src/app/(content)/(info)/help/[id]/page.tsx @@ -4,7 +4,6 @@ import Link from 'next/link'; import { notFound } from 'next/navigation'; import { PostType, getPostData } from '@web/src/lib/posts'; -import BackButton from '@web/src/modules/shared/components/client/BackButton'; import { CustomMarkdown } from '@web/src/modules/shared/components/CustomMarkdown'; type HelpPageProps = { @@ -46,9 +45,12 @@ const HelpPost = ({ params }: HelpPageProps) => { return ( <>
- + {'< Back to Help'} - +

{post.title}

{/* Author */} diff --git a/web/src/app/globals.css b/web/src/app/globals.css index 88470965..d23e7f6e 100644 --- a/web/src/app/globals.css +++ b/web/src/app/globals.css @@ -150,3 +150,12 @@ body { transform: translatex(100%) skewy(-45deg); top: -4px; } + + +/************** Google AdSense **************/ + +/* Hide unfilled ads */ +/* https://support.google.com/adsense/answer/10762946?hl=en */ +ins.adsbygoogle[data-ad-status="unfilled"] { + display: none !important; +} diff --git a/web/src/modules/browse/components/SongCard.tsx b/web/src/modules/browse/components/SongCard.tsx index d9a4328d..ef5d4305 100644 --- a/web/src/modules/browse/components/SongCard.tsx +++ b/web/src/modules/browse/components/SongCard.tsx @@ -73,7 +73,7 @@ const SongCard = ({ song }: { song: SongPreviewDtoType | null }) => { return !song ? ( ) : ( - +
{ // height with this class: "max-h-[calc(100vh-9rem)]", but then the container doesn't fit to // the ad content height, always occupying the full viewport height instead. So we use 'max-w-fit' // to cap the max height to that of the ad. - 'flex-0 sticky mb-8 top-24 p-2 min-h-96 max-h-fit hidden xl:block w-36 min-w-36 bg-zinc-800/50 rounded-xl', + 'flex-0 sticky mb-8 top-24 max-h-fit hidden xl:block w-36 min-w-36 bg-zinc-800/50 rounded-xl', className, )} adSlot='4995642586' @@ -152,7 +152,7 @@ export const MultiplexAdSlot = ({ className }: { className?: string }) => { return ( { return ( );