diff --git a/CHANGELOG.md b/CHANGELOG.md index 5460fac3..500e184b 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,14 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [hitstar] New "Hitstar" music year guessing game: listen to a 30-second preview and guess the release year across 10 rounds. +- [hitstar] Backend NX library `spotify` with iTunes Search API integration and random track endpoint (`GET /spotify/random-track`). +- [hitstar] Frontend route `/hitstar` with state machine (MENU → LOADING → GUESSING → REVEAL → RESULTS). +- [hitstar] Audio preview plays automatically in GUESSING state via iTunes preview URL. +- [hitstar] CSS 3D flip card animation on answer reveal, confetti for correct guesses, shake animation for wrong answers. +- [hitstar] Links to search on Spotify and open on Apple Music shown after reveal. +- [hitstar] Best score and active game state persisted in localStorage; interrupted games can be resumed. +- [hitstar] Abort button to cancel the current game at any time. - [global] Claude Code `/commit-push` Skill für automatisierten Commit-und-Push-Workflow. - [global] Post-Commit Hook erweitert mit Unicode-Gitmojis und zusätzlichen Keywords (test, style, update, improve, move, breaking, access, database, responsive, animation, i18n, clean). - [frontend] Added `viewport-fit=cover` to enable iPhone safe-area support (Notch/Home-Indicator) across all pages. diff --git a/backend/apps/tilloh-dev/src/main.ts b/backend/apps/tilloh-dev/src/main.ts index 4833ed1a..5f56ddf5 100644 --- a/backend/apps/tilloh-dev/src/main.ts +++ b/backend/apps/tilloh-dev/src/main.ts @@ -1,5 +1,6 @@ import { AdminModule } from '@backend/admin'; import { JokesModule } from '@backend/jokes'; +import { SpotifyModule } from '@backend/spotify'; import { MemorandumModule } from '@backend/memorandum'; import { OcrModule } from '@backend/ocr'; import { SharedControllerHealthModule } from '@backend/shared-controller-health'; @@ -71,6 +72,7 @@ import { EnvironmentVariables, validate } from './env.validation'; MemorandumModule, JokesModule, OcrModule, + SpotifyModule, TodoControllerModule, ], providers: [ diff --git a/backend/libs/shared/common/types/src/index.ts b/backend/libs/shared/common/types/src/index.ts index bbdedc24..541765dc 100644 --- a/backend/libs/shared/common/types/src/index.ts +++ b/backend/libs/shared/common/types/src/index.ts @@ -3,4 +3,5 @@ export * from './lib/identifiers.dto'; export * from './lib/jokes.dto'; export * from './lib/keystore.dto'; export * from './lib/ocr-space.dto'; +export * from './lib/spotify.dto'; export * from './lib/todo.dto'; diff --git a/backend/libs/shared/common/types/src/lib/spotify.dto.ts b/backend/libs/shared/common/types/src/lib/spotify.dto.ts new file mode 100644 index 00000000..64f68aa5 --- /dev/null +++ b/backend/libs/shared/common/types/src/lib/spotify.dto.ts @@ -0,0 +1,27 @@ +import { ApiProperty } from '@nestjs/swagger'; + +export class SpotifyTrackDto { + @ApiProperty({ description: 'Spotify track ID' }) + id: string; + + @ApiProperty({ description: 'Track name' }) + name: string; + + @ApiProperty({ description: 'Comma-separated artist names' }) + artist: string; + + @ApiProperty({ description: 'Album name' }) + album: string; + + @ApiProperty({ description: 'Album cover image URL' }) + albumCover: string; + + @ApiProperty({ description: 'Release year of the track' }) + releaseYear: number; + + @ApiProperty({ description: 'Spotify track URL' }) + spotifyUrl: string; + + @ApiProperty({ description: '30-second MP3 preview URL (null if not available)' }) + previewUrl: string | null; +} diff --git a/backend/libs/spotify/.eslintrc.json b/backend/libs/spotify/.eslintrc.json new file mode 100644 index 00000000..9d9c0db5 --- /dev/null +++ b/backend/libs/spotify/.eslintrc.json @@ -0,0 +1,18 @@ +{ + "extends": ["../../.eslintrc.json"], + "ignorePatterns": ["!**/*"], + "overrides": [ + { + "files": ["*.ts", "*.tsx", "*.js", "*.jsx"], + "rules": {} + }, + { + "files": ["*.ts", "*.tsx"], + "rules": {} + }, + { + "files": ["*.js", "*.jsx"], + "rules": {} + } + ] +} diff --git a/backend/libs/spotify/jest.config.ts b/backend/libs/spotify/jest.config.ts new file mode 100644 index 00000000..22344168 --- /dev/null +++ b/backend/libs/spotify/jest.config.ts @@ -0,0 +1,11 @@ +/* eslint-disable */ +export default { + displayName: 'spotify', + preset: '../../jest.preset.js', + testEnvironment: 'node', + transform: { + '^.+\\.[tj]s$': ['ts-jest', { tsconfig: '/tsconfig.spec.json' }], + }, + moduleFileExtensions: ['ts', 'js', 'html'], + coverageDirectory: '../../coverage/libs/spotify', +}; diff --git a/backend/libs/spotify/project.json b/backend/libs/spotify/project.json new file mode 100644 index 00000000..d0da463f --- /dev/null +++ b/backend/libs/spotify/project.json @@ -0,0 +1,24 @@ +{ + "name": "spotify", + "$schema": "../../node_modules/nx/schemas/project-schema.json", + "sourceRoot": "libs/spotify/src", + "projectType": "library", + "tags": [], + "targets": { + "lint": { + "executor": "@nx/eslint:lint", + "outputs": [ + "{options.outputFile}" + ] + }, + "test": { + "executor": "@nx/jest:jest", + "outputs": [ + "{workspaceRoot}/coverage/{projectRoot}" + ], + "options": { + "jestConfig": "libs/spotify/jest.config.ts" + } + } + } +} diff --git a/backend/libs/spotify/src/index.ts b/backend/libs/spotify/src/index.ts new file mode 100644 index 00000000..096a05e8 --- /dev/null +++ b/backend/libs/spotify/src/index.ts @@ -0,0 +1,2 @@ +export * from './lib/spotify.controller'; +export * from './lib/spotify.module'; diff --git a/backend/libs/spotify/src/lib/spotify.controller.spec.ts b/backend/libs/spotify/src/lib/spotify.controller.spec.ts new file mode 100644 index 00000000..0873a941 --- /dev/null +++ b/backend/libs/spotify/src/lib/spotify.controller.spec.ts @@ -0,0 +1,46 @@ +import { Test, TestingModule } from '@nestjs/testing'; +import { SpotifyController } from './spotify.controller'; +import { SpotifyService } from './spotify.service'; + +const mockTrack = { + id: 'track-id-1', + name: 'Test Song', + artist: 'Artist A', + album: 'Test Album', + albumCover: 'https://example.com/cover.jpg', + releaseYear: 1985, + spotifyUrl: 'https://open.spotify.com/track/track-id-1', + previewUrl: 'https://p.scdn.co/mp3-preview/abc123', +}; + +describe('SpotifyController', () => { + let controller: SpotifyController; + let service: SpotifyService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + controllers: [SpotifyController], + providers: [ + { + provide: SpotifyService, + useValue: { + getRandomTrack: jest.fn().mockResolvedValue(mockTrack), + }, + }, + ], + }).compile(); + + controller = module.get(SpotifyController); + service = module.get(SpotifyService); + }); + + it('should be defined', () => { + expect(controller).toBeDefined(); + }); + + it('should call getRandomTrack and return a track', async () => { + const result = await controller.getRandomTrack(); + expect(service.getRandomTrack).toHaveBeenCalled(); + expect(result).toEqual(mockTrack); + }); +}); diff --git a/backend/libs/spotify/src/lib/spotify.controller.ts b/backend/libs/spotify/src/lib/spotify.controller.ts new file mode 100644 index 00000000..b19795f3 --- /dev/null +++ b/backend/libs/spotify/src/lib/spotify.controller.ts @@ -0,0 +1,26 @@ +import { SpotifyTrackDto } from '@backend/shared-types'; +import { Public } from '@backend/util'; +import { Controller, Get } from '@nestjs/common'; +import { + ApiBadRequestResponse, + ApiOkResponse, + ApiTags, +} from '@nestjs/swagger'; +import { SpotifyService } from './spotify.service'; + +@ApiTags('spotify') +@Controller('/spotify') +export class SpotifyController { + constructor(private spotifyService: SpotifyService) {} + + @Public() + @ApiOkResponse({ + description: 'Random Spotify track for the Hitstar game.', + type: SpotifyTrackDto, + }) + @ApiBadRequestResponse({ description: 'Bad or malformed request.' }) + @Get('/random-track') + getRandomTrack() { + return this.spotifyService.getRandomTrack(); + } +} diff --git a/backend/libs/spotify/src/lib/spotify.module.ts b/backend/libs/spotify/src/lib/spotify.module.ts new file mode 100644 index 00000000..a0785e37 --- /dev/null +++ b/backend/libs/spotify/src/lib/spotify.module.ts @@ -0,0 +1,12 @@ +import { HttpModule } from '@nestjs/axios'; +import { Module } from '@nestjs/common'; +import { SpotifyController } from './spotify.controller'; +import { SpotifyService } from './spotify.service'; + +@Module({ + imports: [HttpModule], + controllers: [SpotifyController], + providers: [SpotifyService], + exports: [], +}) +export class SpotifyModule {} diff --git a/backend/libs/spotify/src/lib/spotify.service.spec.ts b/backend/libs/spotify/src/lib/spotify.service.spec.ts new file mode 100644 index 00000000..3141fb56 --- /dev/null +++ b/backend/libs/spotify/src/lib/spotify.service.spec.ts @@ -0,0 +1,73 @@ +import { HttpService } from '@nestjs/axios'; +import { Test, TestingModule } from '@nestjs/testing'; +import { of } from 'rxjs'; +import { SpotifyService } from './spotify.service'; + +const mockItunesResponse = { + data: { + resultCount: 1, + results: [ + { + trackId: 123456789, + trackName: 'Test Song', + artistName: 'Artist A', + collectionName: 'Test Album', + artworkUrl100: 'https://example.com/100x100bb.jpg', + previewUrl: 'https://audio-ssl.itunes.apple.com/preview/abc123.m4a', + releaseDate: '1985-06-15T00:00:00Z', + trackViewUrl: 'https://music.apple.com/us/album/test-song/123456789', + }, + ], + }, +}; + +describe('SpotifyService', () => { + let service: SpotifyService; + let httpService: HttpService; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + SpotifyService, + { + provide: HttpService, + useValue: { get: jest.fn() }, + }, + ], + }).compile(); + + service = module.get(SpotifyService); + httpService = module.get(HttpService); + }); + + it('should be defined', () => { + expect(service).toBeDefined(); + }); + + it('should return a SpotifyTrackDto on getRandomTrack()', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(httpService, 'get').mockReturnValue(of(mockItunesResponse as any)); + + const result = await service.getRandomTrack(); + + expect(result.id).toBe('123456789'); + expect(result.name).toBe('Test Song'); + expect(result.artist).toBe('Artist A'); + expect(result.album).toBe('Test Album'); + expect(result.releaseYear).toBe(1985); + expect(result.albumCover).toBe('https://example.com/600x600bb.jpg'); + expect(result.spotifyUrl).toBe('https://music.apple.com/us/album/test-song/123456789'); + expect(result.previewUrl).toBe('https://audio-ssl.itunes.apple.com/preview/abc123.m4a'); + }); + + it('should throw after 10 attempts when no results', async () => { + jest.spyOn(httpService, 'get').mockReturnValue( + // eslint-disable-next-line @typescript-eslint/no-explicit-any + of({ data: { resultCount: 0, results: [] } } as any), + ); + + await expect(service.getRandomTrack()).rejects.toThrow( + 'Could not find a random track after 10 attempts.', + ); + }); +}); diff --git a/backend/libs/spotify/src/lib/spotify.service.ts b/backend/libs/spotify/src/lib/spotify.service.ts new file mode 100644 index 00000000..c9518233 --- /dev/null +++ b/backend/libs/spotify/src/lib/spotify.service.ts @@ -0,0 +1,63 @@ +import { SpotifyTrackDto } from '@backend/shared-types'; +import { HttpService } from '@nestjs/axios'; +import { Injectable, Logger } from '@nestjs/common'; +import { firstValueFrom } from 'rxjs'; + +@Injectable() +export class SpotifyService { + private readonly logger = new Logger(SpotifyService.name); + + constructor(private httpService: HttpService) {} + + async getRandomTrack(): Promise { + const maxAttempts = 10; + + for (let attempt = 0; attempt < maxAttempts; attempt++) { + const char = String.fromCharCode(97 + Math.floor(Math.random() * 26)); + + this.logger.log(`Attempt ${attempt + 1}: searching iTunes for char="${char}"`); + + try { + const searchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(char)}&entity=song&limit=20`; + const response = await firstValueFrom(this.httpService.get(searchUrl)); + + // eslint-disable-next-line @typescript-eslint/no-explicit-any + const results: any[] = response.data?.results ?? []; + + const validTracks = results.filter((track) => { + if (!track.previewUrl) return false; + if (!track.releaseDate) return false; + const year = parseInt((track.releaseDate as string).substring(0, 4), 10); + return !isNaN(year) && year >= 1950 && year <= 2026; + }); + + if (validTracks.length > 0) { + const track = validTracks[Math.floor(Math.random() * validTracks.length)]; + const releaseYear = parseInt((track.releaseDate as string).substring(0, 4), 10); + const albumCover = track.artworkUrl100 + ? (track.artworkUrl100 as string).replace('100x100bb', '600x600bb') + : ''; + + this.logger.log(`Found: "${track.trackName}" by ${track.artistName} (${releaseYear})`); + + return { + id: String(track.trackId), + name: track.trackName as string, + artist: track.artistName as string, + album: track.collectionName as string, + albumCover, + releaseYear, + spotifyUrl: track.trackViewUrl as string, + previewUrl: track.previewUrl as string, + }; + } + + this.logger.warn(`Attempt ${attempt + 1}: no usable track found, retrying.`); + } catch (err) { + this.logger.warn(`Attempt ${attempt + 1} failed: ${err}`); + } + } + + throw new Error(`Could not find a random track after ${maxAttempts} attempts.`); + } +} diff --git a/backend/libs/spotify/tsconfig.json b/backend/libs/spotify/tsconfig.json new file mode 100644 index 00000000..f5b85657 --- /dev/null +++ b/backend/libs/spotify/tsconfig.json @@ -0,0 +1,22 @@ +{ + "extends": "../../tsconfig.base.json", + "compilerOptions": { + "module": "commonjs", + "forceConsistentCasingInFileNames": true, + "strict": true, + "noImplicitOverride": true, + "noPropertyAccessFromIndexSignature": true, + "noImplicitReturns": true, + "noFallthroughCasesInSwitch": true + }, + "files": [], + "include": [], + "references": [ + { + "path": "./tsconfig.lib.json" + }, + { + "path": "./tsconfig.spec.json" + } + ] +} diff --git a/backend/libs/spotify/tsconfig.lib.json b/backend/libs/spotify/tsconfig.lib.json new file mode 100644 index 00000000..c297a248 --- /dev/null +++ b/backend/libs/spotify/tsconfig.lib.json @@ -0,0 +1,16 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "declaration": true, + "types": ["node"], + "target": "es2021", + "strictNullChecks": true, + "noImplicitAny": true, + "strictBindCallApply": true, + "forceConsistentCasingInFileNames": true, + "noFallthroughCasesInSwitch": true + }, + "include": ["src/**/*.ts"], + "exclude": ["jest.config.ts", "src/**/*.spec.ts", "src/**/*.test.ts"] +} diff --git a/backend/libs/spotify/tsconfig.spec.json b/backend/libs/spotify/tsconfig.spec.json new file mode 100644 index 00000000..9b2a121d --- /dev/null +++ b/backend/libs/spotify/tsconfig.spec.json @@ -0,0 +1,14 @@ +{ + "extends": "./tsconfig.json", + "compilerOptions": { + "outDir": "../../dist/out-tsc", + "module": "commonjs", + "types": ["jest", "node"] + }, + "include": [ + "jest.config.ts", + "src/**/*.test.ts", + "src/**/*.spec.ts", + "src/**/*.d.ts" + ] +} diff --git a/backend/tsconfig.base.json b/backend/tsconfig.base.json index 5c0280c2..38ea8d32 100644 --- a/backend/tsconfig.base.json +++ b/backend/tsconfig.base.json @@ -43,7 +43,8 @@ "@backend/util": ["libs/shared/util/src/index.ts"], "@backend/shared-identifiers": [ "libs/shared/provider/identifiers/src/index.ts" - ] + ], + "@backend/spotify": ["libs/spotify/src/index.ts"] } }, "exclude": ["node_modules", "tmp"] diff --git a/frontend/src/lib/api/spotify.api.ts b/frontend/src/lib/api/spotify.api.ts new file mode 100644 index 00000000..ef020d28 --- /dev/null +++ b/frontend/src/lib/api/spotify.api.ts @@ -0,0 +1,11 @@ +import type { SpotifyTrackDto } from '$lib/types/spotify.dto'; +import { getApiURL } from '$lib/util/environment'; + +export const getRandomTrack = async (): Promise => { + const res = await fetch(`${getApiURL()}/spotify/random-track`); + if (!res.ok) { + const err = await res.json().catch(() => ({})); + throw new Error(err?.message ?? `HTTP ${res.status}`); + } + return res.json(); +}; diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte new file mode 100644 index 00000000..03eaed77 --- /dev/null +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -0,0 +1,538 @@ + + +{#if $initialized} +
+ + {#if gameState === 'MENU'} + + + + {:else if gameState === 'LOADING'} +
+

{$t('page.hitstar.loading.headline')}

+ +
+ + + {:else if gameState === 'GUESSING'} +
+

+ {$t('page.hitstar.guessing.headline', { + round: String(currentRound), + })} +

+

+ {$t('page.hitstar.reveal.score', { score: String(score) })} +

+ + + + {#if currentTrack?.previewUrl} + + {/if} + +

{$t('page.hitstar.guessing.cardHint')}

+ +
+ +
+ + + +
+ + + {:else if gameState === 'REVEAL'} +
+

+ {$t('page.hitstar.guessing.headline', { + round: String(currentRound), + })} +

+

+ {$t('page.hitstar.reveal.score', { score: String(score) })} +

+ + + + {#if isCorrect} + + {:else} + + {/if} + + {#if currentTrack?.previewUrl} + + {/if} + + {#if currentTrack} + + {/if} + + + +
+ + + {:else if gameState === 'RESULTS'} +
+

{$t('page.hitstar.results.headline')}

+

{$t('page.hitstar.results.score', { score: String(score) })}

+ + {#if isNewBestScore && score > 0} + + {/if} + +
+ {#each roundResults as result} + +
+ {$t('page.hitstar.results.round', { + round: String(result.round), + })} + {result.track.name} – {result.track.artist} + + {result.correct + ? $t('page.hitstar.results.correct') + : $t('page.hitstar.results.wrong')} + +
+
+ {$t('page.hitstar.results.yourGuess', { + year: String(result.guessedYear), + })} + {$t('page.hitstar.results.correctYear', { + year: String(result.track.releaseYear), + })} +
+
+ {/each} +
+ + +
+ {/if} +
+{/if} + + diff --git a/frontend/src/lib/components/hitstar/HitstarCard.svelte b/frontend/src/lib/components/hitstar/HitstarCard.svelte new file mode 100644 index 00000000..de135637 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarCard.svelte @@ -0,0 +1,135 @@ + + +
+
+ +
+ ? +
+ +
+ {#if track} + {#if track.albumCover} + {track.album} + {/if} +

{track.name}

+

{track.artist}

+

{track.album}

+

{track.releaseYear}

+ {/if} +
+
+
+ + diff --git a/frontend/src/lib/config/applications.ts b/frontend/src/lib/config/applications.ts index e1f5405e..9e1de0f2 100644 --- a/frontend/src/lib/config/applications.ts +++ b/frontend/src/lib/config/applications.ts @@ -6,6 +6,7 @@ import Bullhorn from 'carbon-icons-svelte/lib/Bullhorn.svelte'; import Cognitive from 'carbon-icons-svelte/lib/Cognitive.svelte'; import FingerprintRecognition from 'carbon-icons-svelte/lib/FingerprintRecognition.svelte'; import GameConsole from 'carbon-icons-svelte/lib/GameConsole.svelte'; +import Music from 'carbon-icons-svelte/lib/Music.svelte'; import Home from 'carbon-icons-svelte/lib/Home.svelte'; import Identification from 'carbon-icons-svelte/lib/Identification.svelte'; import ListBoxes from 'carbon-icons-svelte/lib/ListBoxes.svelte'; @@ -23,6 +24,7 @@ export type ApplicationRoutes = { 'food-scan': Route; 'catch-em-all': Route; 'uno-sort': Route; + hitstar: Route; about: Route; }; @@ -34,6 +36,7 @@ const loadApplications = async (): Promise => { foodScanToggle, catchEmAllToggle, unoSortToggle, + hitstarToggle, aboutToggle, ] = await Promise.all([ getToggleValue(TogglesEnum.memorandum), @@ -42,6 +45,7 @@ const loadApplications = async (): Promise => { getToggleValue(TogglesEnum.foodScan), getToggleValue(TogglesEnum.catchEmAll), getToggleValue(TogglesEnum.unoSort), + getToggleValue(TogglesEnum.hitstar), getToggleValue(TogglesEnum.about), ]); @@ -53,6 +57,7 @@ const loadApplications = async (): Promise => { 'food-scan': { id: 'food-scan', name: { en: 'Food Scan', de: 'Essen-Scan' }, path: '/food-scan', icon: Restaurant, toggle: foodScanToggle }, 'catch-em-all': { id: 'catch-em-all', name: { en: 'Catch-em-all', de: 'Catch-em-all' }, path: '/catch-em-all', icon: GameConsole, toggle: catchEmAllToggle }, 'uno-sort': { id: 'uno-sort', name: { en: 'Uno sort', de: 'Uno Sortierung' }, path: '/uno-sort', icon: GameConsole, toggle: unoSortToggle }, + hitstar: { id: 'hitstar', name: { en: 'Hitstar', de: 'Hitstar' }, path: '/hitstar', icon: Music, toggle: hitstarToggle }, about: { id: 'about', name: { en: 'About me', de: 'Über mich' }, path: '/about', icon: Identification, toggle: aboutToggle }, }; }; @@ -65,6 +70,7 @@ const defaultApplicationRoutes: ApplicationRoutes = { 'food-scan': { id: 'food-scan', name: { en: 'Food Scan', de: 'Essen-Scan' }, path: '/food-scan', icon: Restaurant, toggle: false }, 'catch-em-all': { id: 'catch-em-all', name: { en: 'Catch-em-all', de: 'Catch-em-all' }, path: '/catch-em-all', icon: GameConsole, toggle: false }, 'uno-sort': { id: 'uno-sort', name: { en: 'Uno sort', de: 'Uno Sortierung' }, path: '/uno-sort', icon: GameConsole, toggle: false }, + hitstar: { id: 'hitstar', name: { en: 'Hitstar', de: 'Hitstar' }, path: '/hitstar', icon: Music, toggle: false }, about: { id: 'about', name: { en: 'About me', de: 'Über mich' }, path: '/about', icon: Identification, toggle: false }, }; diff --git a/frontend/src/lib/config/de.json b/frontend/src/lib/config/de.json index 556d9dda..03123c91 100644 --- a/frontend/src/lib/config/de.json +++ b/frontend/src/lib/config/de.json @@ -304,5 +304,37 @@ "page.shared.user.verificationError": "User ID konnte nicht verifiziert werden.", "page.shared.selectEmoji": "Emoji auswählen", "page.shared.searchEmoji": "Emoji suchen...", - "page.shared.noEmojisFound": "Keine Emojis gefunden" + "page.shared.noEmojisFound": "Keine Emojis gefunden", + "page.hitstar.title": "Hitstar", + "page.hitstar.menu.headline": "Hitstar – Musik-Ratespiel", + "page.hitstar.menu.description": "Kannst du das Erscheinungsjahr eines zufälligen Songs erraten? Du hast 10 Runden – viel Erfolg!", + "page.hitstar.menu.bestScore": "Beste Wertung", + "page.hitstar.menu.bestScoreValue": "{{score}} / 10 am {{date}}", + "page.hitstar.menu.noScore": "Noch kein Rekord", + "page.hitstar.menu.startGame": "Neues Spiel", + "page.hitstar.menu.resumeInfo": "Du hast ein laufendes Spiel. Ein neues Spiel verwirft den aktuellen Stand.", + "page.hitstar.loading.headline": "Nächsten Song laden...", + "page.hitstar.guessing.headline": "Runde {{round}} / 10", + "page.hitstar.guessing.cardHint": "In welchem Jahr wurde dieser Song veröffentlicht?", + "page.hitstar.guessing.yearLabel": "Erscheinungsjahr", + "page.hitstar.guessing.yearPlaceholder": "z. B. 1995", + "page.hitstar.guessing.submitButton": "Antwort abgeben", + "page.hitstar.guessing.listeningHint": "Höre das Lied und errate das Erscheinungsjahr", + "page.hitstar.reveal.correct": "Richtig! Der Song erschien {{year}}.", + "page.hitstar.reveal.wrong": "Falsch! Das richtige Jahr war {{year}}.", + "page.hitstar.reveal.score": "Punkte: {{score}}", + "page.hitstar.reveal.nextRound": "Nächste Runde", + "page.hitstar.reveal.seeResults": "Ergebnisse ansehen", + "page.hitstar.reveal.openSpotify": "Auf Spotify suchen", + "page.hitstar.reveal.openAppleMusic": "Auf Apple Music öffnen", + "page.hitstar.results.headline": "Spiel beendet!", + "page.hitstar.results.score": "Dein Ergebnis: {{score}} / 10", + "page.hitstar.results.newRecord": "Neuer Rekord!", + "page.hitstar.results.round": "Runde {{round}}", + "page.hitstar.results.correct": "Richtig", + "page.hitstar.results.wrong": "Falsch", + "page.hitstar.results.yourGuess": "Deine Antwort: {{year}}", + "page.hitstar.results.correctYear": "Richtiges Jahr: {{year}}", + "page.hitstar.results.playAgain": "Nochmal spielen", + "page.hitstar.abort": "Spiel abbrechen" } \ No newline at end of file diff --git a/frontend/src/lib/config/en.json b/frontend/src/lib/config/en.json index 18a3e81f..ecf7289c 100644 --- a/frontend/src/lib/config/en.json +++ b/frontend/src/lib/config/en.json @@ -304,5 +304,37 @@ "page.shared.user.verificationError": "User ID could not be verified.", "page.shared.selectEmoji": "Select emoji", "page.shared.searchEmoji": "Search emoji...", - "page.shared.noEmojisFound": "No emojis found" + "page.shared.noEmojisFound": "No emojis found", + "page.hitstar.title": "Hitstar", + "page.hitstar.menu.headline": "Hitstar – Music Year Game", + "page.hitstar.menu.description": "Can you guess the release year of a random song? You have 10 rounds – good luck!", + "page.hitstar.menu.bestScore": "Best score", + "page.hitstar.menu.bestScoreValue": "{{score}} / 10 on {{date}}", + "page.hitstar.menu.noScore": "No record yet", + "page.hitstar.menu.startGame": "Start game", + "page.hitstar.menu.resumeInfo": "You have an unfinished game. Starting a new game will discard it.", + "page.hitstar.loading.headline": "Loading next song...", + "page.hitstar.guessing.headline": "Round {{round}} / 10", + "page.hitstar.guessing.cardHint": "Which year was this song released?", + "page.hitstar.guessing.yearLabel": "Release year", + "page.hitstar.guessing.yearPlaceholder": "e.g. 1995", + "page.hitstar.guessing.submitButton": "Submit guess", + "page.hitstar.guessing.listeningHint": "Listen to the song and guess the release year", + "page.hitstar.reveal.correct": "Correct! The song was released in {{year}}.", + "page.hitstar.reveal.wrong": "Wrong! The correct year was {{year}}.", + "page.hitstar.reveal.score": "Score: {{score}}", + "page.hitstar.reveal.nextRound": "Next round", + "page.hitstar.reveal.seeResults": "See results", + "page.hitstar.reveal.openSpotify": "Search on Spotify", + "page.hitstar.reveal.openAppleMusic": "Open on Apple Music", + "page.hitstar.results.headline": "Game over!", + "page.hitstar.results.score": "Your score: {{score}} / 10", + "page.hitstar.results.newRecord": "New record!", + "page.hitstar.results.round": "Round {{round}}", + "page.hitstar.results.correct": "Correct", + "page.hitstar.results.wrong": "Wrong", + "page.hitstar.results.yourGuess": "Your guess: {{year}}", + "page.hitstar.results.correctYear": "Correct year: {{year}}", + "page.hitstar.results.playAgain": "Play again", + "page.hitstar.abort": "Abort game" } \ No newline at end of file diff --git a/frontend/src/lib/types/spotify.dto.ts b/frontend/src/lib/types/spotify.dto.ts new file mode 100644 index 00000000..85693230 --- /dev/null +++ b/frontend/src/lib/types/spotify.dto.ts @@ -0,0 +1,29 @@ +export interface SpotifyTrackDto { + id: string; + name: string; + artist: string; + album: string; + albumCover: string; + releaseYear: number; + spotifyUrl: string; + previewUrl: string | null; +} + +export interface HitstarRoundResult { + round: number; + track: SpotifyTrackDto; + guessedYear: number; + correct: boolean; +} + +export interface HitstarBestRound { + score: number; + date: string; +} + +export interface HitstarGameState { + currentRound: number; + score: number; + currentTrack: SpotifyTrackDto | null; + roundResults: HitstarRoundResult[]; +} diff --git a/frontend/src/lib/types/toggle.dto.ts b/frontend/src/lib/types/toggle.dto.ts index 08cfb66f..c9be21e9 100644 --- a/frontend/src/lib/types/toggle.dto.ts +++ b/frontend/src/lib/types/toggle.dto.ts @@ -12,4 +12,5 @@ export enum TogglesEnum { adminIdentifiers = 'TOGGLE_ADMIN_IDENTIFIERS', adminJokes = 'TOGGLE_ADMIN_JOKES', adminLinkPreset = 'TOGGLE_ADMIN_LINK_PRESETS', + hitstar = 'TOGGLE_NAV_HITSTAR', } diff --git a/frontend/src/lib/util/stores/store-hitstar.ts b/frontend/src/lib/util/stores/store-hitstar.ts new file mode 100644 index 00000000..58e80fe9 --- /dev/null +++ b/frontend/src/lib/util/stores/store-hitstar.ts @@ -0,0 +1,54 @@ +import { browser } from '$app/environment'; +import type { HitstarBestRound, HitstarGameState } from '$lib/types/spotify.dto'; +import { writable } from 'svelte/store'; + +const hitstarBestRoundKey = 'hitstar.bestRound'; +const hitstarGameStateKey = 'hitstar.gameState'; + +const getInitialBestRound = (): HitstarBestRound | null => { + if (!browser) return null; + const stored = localStorage.getItem(hitstarBestRoundKey); + if (!stored) return null; + try { + return JSON.parse(stored) as HitstarBestRound; + } catch { + return null; + } +}; + +const getInitialGameState = (): HitstarGameState | null => { + if (!browser) return null; + const stored = localStorage.getItem(hitstarGameStateKey); + if (!stored) return null; + try { + return JSON.parse(stored) as HitstarGameState; + } catch { + return null; + } +}; + +export const hitstarBestRoundStore = writable( + getInitialBestRound(), +); + +export const hitstarGameStateStore = writable( + getInitialGameState(), +); + +if (browser) { + hitstarBestRoundStore.subscribe((val) => { + if (val === null) { + localStorage.removeItem(hitstarBestRoundKey); + } else { + localStorage.setItem(hitstarBestRoundKey, JSON.stringify(val)); + } + }); + + hitstarGameStateStore.subscribe((val) => { + if (val === null) { + localStorage.removeItem(hitstarGameStateKey); + } else { + localStorage.setItem(hitstarGameStateKey, JSON.stringify(val)); + } + }); +} diff --git a/frontend/src/lib/util/translations.ts b/frontend/src/lib/util/translations.ts index 3b5e7c05..f4bc3c54 100644 --- a/frontend/src/lib/util/translations.ts +++ b/frontend/src/lib/util/translations.ts @@ -12,6 +12,10 @@ interface Params { joke: string; id: string; modelName: string; + score: string; + round: string; + year: string; + date: string; } const config: Config> = { diff --git a/frontend/src/routes/hitstar/+page.svelte b/frontend/src/routes/hitstar/+page.svelte new file mode 100644 index 00000000..87832d5f --- /dev/null +++ b/frontend/src/routes/hitstar/+page.svelte @@ -0,0 +1,40 @@ + + + + {hitstarRoute.name[locale]} + + + +{#if hitstarRoute.toggle} + +{:else} + +{/if} diff --git a/frontend/src/routes/hitstar/+page.ts b/frontend/src/routes/hitstar/+page.ts new file mode 100644 index 00000000..189f71e2 --- /dev/null +++ b/frontend/src/routes/hitstar/+page.ts @@ -0,0 +1 @@ +export const prerender = true;