diff --git a/CHANGELOG.md b/CHANGELOG.md index 500e184b..825f1699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,20 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [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. + +### Changed + +- [hitstar] Reworked results screen with top/middle/bottom layout, colored per-round indicator squares (green/red), left border on result tiles, and removed score headline and Spotify/Apple Music links. +- [hitstar] Card border and year color now signal correct (green glow) or wrong (red glow) answer in reveal state; neutral border uses modern semi-transparent style. +- [hitstar] Shake animation on wrong answer moved from guessing screen to the card in reveal state. +- [hitstar] Switched backend track search from iTunes to Spotify API with OAuth2 client credentials token caching, market=DE filter, and `q=* year:1950-2025` query. +- [hitstar] Refactored GUESSING/REVEAL layout into top/middle/bottom sections: headline+exit+tracker pinned to top, card+audio centered vertically, year input pinned to bottom. +- [hitstar] Made HitstarCard fully responsive using `min()` and `aspect-ratio` instead of fixed pixel dimensions. +- [hitstar] Improved mobile layout: no horizontal or vertical scrollbars; components scale smaller on small screens. +- [frontend] Replaced `overflow: auto` on `main` with `overflow-x: hidden; overflow-y: auto` to prevent horizontal scrollbars. +- [jokes] Removed fixed height and margin from jokes container to avoid unwanted scroll areas. +- [global] Reduced GlobalMenu font sizes on mobile for better fit. +- [hitstar] Redesigned GUESSING/REVEAL UI: question headline + small danger Exit icon button in top bar, round progress tracker (gray/green/red bars), year input and submit icon button inline, submit icon centered via flex override. - [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/env.validation.ts b/backend/apps/tilloh-dev/src/env.validation.ts index b1e682f2..3c6cb7c0 100644 --- a/backend/apps/tilloh-dev/src/env.validation.ts +++ b/backend/apps/tilloh-dev/src/env.validation.ts @@ -22,6 +22,12 @@ export class EnvironmentVariables { @IsString() OCR_SPACE_API_KEY: string; + + @IsString() + SPOTIFY_CLIENT_ID: string; + + @IsString() + SPOTIFY_CLIENT_SECRET: string; } export function validate( diff --git a/backend/libs/spotify/src/lib/spotify.module.ts b/backend/libs/spotify/src/lib/spotify.module.ts index a0785e37..992daae1 100644 --- a/backend/libs/spotify/src/lib/spotify.module.ts +++ b/backend/libs/spotify/src/lib/spotify.module.ts @@ -1,10 +1,11 @@ import { HttpModule } from '@nestjs/axios'; import { Module } from '@nestjs/common'; +import { ConfigModule } from '@nestjs/config'; import { SpotifyController } from './spotify.controller'; import { SpotifyService } from './spotify.service'; @Module({ - imports: [HttpModule], + imports: [HttpModule, ConfigModule], controllers: [SpotifyController], providers: [SpotifyService], exports: [], diff --git a/backend/libs/spotify/src/lib/spotify.service.spec.ts b/backend/libs/spotify/src/lib/spotify.service.spec.ts index 3141fb56..a56a9ffc 100644 --- a/backend/libs/spotify/src/lib/spotify.service.spec.ts +++ b/backend/libs/spotify/src/lib/spotify.service.spec.ts @@ -1,23 +1,34 @@ import { HttpService } from '@nestjs/axios'; +import { ConfigService } from '@nestjs/config'; import { Test, TestingModule } from '@nestjs/testing'; import { of } from 'rxjs'; import { SpotifyService } from './spotify.service'; -const mockItunesResponse = { +const mockTokenResponse = { 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', - }, - ], + access_token: 'mock_token_123', + expires_in: 3600, + }, +}; + +const mockSpotifyResponse = { + data: { + tracks: { + items: [ + { + id: 'abc123', + name: 'Test Song', + artists: [{ name: 'Artist A' }], + album: { + name: 'Test Album', + release_date: '1985-06-15', + images: [{ url: 'https://example.com/cover.jpg' }], + }, + external_urls: { spotify: 'https://open.spotify.com/track/abc123' }, + preview_url: 'https://audio.spotify.com/preview/abc123.mp3', + }, + ], + }, }, }; @@ -31,7 +42,11 @@ describe('SpotifyService', () => { SpotifyService, { provide: HttpService, - useValue: { get: jest.fn() }, + useValue: { get: jest.fn(), post: jest.fn() }, + }, + { + provide: ConfigService, + useValue: { get: jest.fn().mockReturnValue('mock_value') }, }, ], }).compile(); @@ -46,24 +61,28 @@ describe('SpotifyService', () => { 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)); + jest.spyOn(httpService, 'post').mockReturnValue(of(mockTokenResponse as any)); + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(httpService, 'get').mockReturnValue(of(mockSpotifyResponse as any)); const result = await service.getRandomTrack(); - expect(result.id).toBe('123456789'); + expect(result.id).toBe('abc123'); 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'); + expect(result.albumCover).toBe('https://example.com/cover.jpg'); + expect(result.spotifyUrl).toBe('https://open.spotify.com/track/abc123'); + expect(result.previewUrl).toBe('https://audio.spotify.com/preview/abc123.mp3'); }); it('should throw after 10 attempts when no results', async () => { + // eslint-disable-next-line @typescript-eslint/no-explicit-any + jest.spyOn(httpService, 'post').mockReturnValue(of(mockTokenResponse as any)); jest.spyOn(httpService, 'get').mockReturnValue( // eslint-disable-next-line @typescript-eslint/no-explicit-any - of({ data: { resultCount: 0, results: [] } } as any), + of({ data: { tracks: { items: [] } } } as any), ); await expect(service.getRandomTrack()).rejects.toThrow( diff --git a/backend/libs/spotify/src/lib/spotify.service.ts b/backend/libs/spotify/src/lib/spotify.service.ts index c9518233..d38faa03 100644 --- a/backend/libs/spotify/src/lib/spotify.service.ts +++ b/backend/libs/spotify/src/lib/spotify.service.ts @@ -1,54 +1,90 @@ import { SpotifyTrackDto } from '@backend/shared-types'; import { HttpService } from '@nestjs/axios'; import { Injectable, Logger } from '@nestjs/common'; +import { ConfigService } from '@nestjs/config'; import { firstValueFrom } from 'rxjs'; @Injectable() export class SpotifyService { private readonly logger = new Logger(SpotifyService.name); + private accessToken: string | null = null; + private tokenExpiresAt = 0; - constructor(private httpService: HttpService) {} + constructor( + private httpService: HttpService, + private configService: ConfigService, + ) {} + + private async getAccessToken(): Promise { + if (this.accessToken && Date.now() < this.tokenExpiresAt - 60_000) { + return this.accessToken; + } + + const clientId = this.configService.get('SPOTIFY_CLIENT_ID'); + const clientSecret = this.configService.get('SPOTIFY_CLIENT_SECRET'); + const credentials = Buffer.from(`${clientId}:${clientSecret}`).toString('base64'); + + const response = await firstValueFrom( + this.httpService.post( + 'https://accounts.spotify.com/api/token', + 'grant_type=client_credentials', + { + headers: { + Authorization: `Basic ${credentials}`, + 'Content-Type': 'application/x-www-form-urlencoded', + }, + }, + ), + ); + + this.accessToken = response.data.access_token as string; + this.tokenExpiresAt = Date.now() + (response.data.expires_in as number) * 1000; + return this.accessToken; + } async getRandomTrack(): Promise { const maxAttempts = 10; for (let attempt = 0; attempt < maxAttempts; attempt++) { - const char = String.fromCharCode(97 + Math.floor(Math.random() * 26)); + const offset = Math.floor(Math.random() * 50); + const token = await this.getAccessToken(); - this.logger.log(`Attempt ${attempt + 1}: searching iTunes for char="${char}"`); + this.logger.log(`Attempt ${attempt + 1}: searching Spotify (offset=${offset})`); try { - const searchUrl = `https://itunes.apple.com/search?term=${encodeURIComponent(char)}&entity=song&limit=20`; - const response = await firstValueFrom(this.httpService.get(searchUrl)); + const searchUrl = `https://api.spotify.com/v1/search?q=*+year%3A1950-2025&type=track&market=DE&limit=1&offset=${offset}`; + const response = await firstValueFrom( + this.httpService.get(searchUrl, { + headers: { Authorization: `Bearer ${token}` }, + }), + ); // 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; - }); + const items: any[] = response.data?.tracks?.items ?? []; + const validTracks = items.filter((t) => t.preview_url && t.album?.release_date); 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') - : ''; + const track = validTracks[0]; + const releaseYear = parseInt( + (track.album.release_date as string).substring(0, 4), + 10, + ); + const albumCover: string = track.album?.images?.[0]?.url ?? ''; + const artist: string = (track.artists as { name: string }[]) + .map((a) => a.name) + .join(', '); - this.logger.log(`Found: "${track.trackName}" by ${track.artistName} (${releaseYear})`); + this.logger.log(`Found: "${track.name as string}" by ${artist} (${releaseYear})`); return { - id: String(track.trackId), - name: track.trackName as string, - artist: track.artistName as string, - album: track.collectionName as string, + id: track.id as string, + name: track.name as string, + artist, + album: track.album.name as string, albumCover, releaseYear, - spotifyUrl: track.trackViewUrl as string, - previewUrl: track.previewUrl as string, + spotifyUrl: (track.external_urls?.spotify as string) ?? '', + previewUrl: track.preview_url as string, }; } diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index 03eaed77..23d7f063 100644 --- a/frontend/src/lib/components/hitstar/Hitstar.svelte +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -17,12 +17,15 @@ import NumberInput from 'carbon-components-svelte/src/NumberInput/NumberInput.svelte'; import ProgressBar from 'carbon-components-svelte/src/ProgressBar/ProgressBar.svelte'; import Tile from 'carbon-components-svelte/src/Tile/Tile.svelte'; + import ContinueFilled from 'carbon-icons-svelte/lib/ContinueFilled.svelte'; + import Exit from 'carbon-icons-svelte/lib/Exit.svelte'; import { onMount } from 'svelte'; import HitstarCard from './HitstarCard.svelte'; // 3. CONST type GameState = 'MENU' | 'LOADING' | 'GUESSING' | 'REVEAL' | 'RESULTS'; const TOTAL_ROUNDS = 10; + const roundIndices = Array(TOTAL_ROUNDS).fill(0); // 4. STATE let gameState = $state('MENU'); @@ -215,167 +218,189 @@ {: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')} +

+
+
+ {#each roundIndices as _, i} + {@const roundNum = i + 1} + {@const result = roundResults.find((r) => r.round === roundNum)} +
+ {/each} +
+
-

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

+ +
+ + {#if currentTrack?.previewUrl} + + {/if} +
-
- +
+
+ +
+
- - -
{: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} + +
+
+

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

+
+
+ {#each roundIndices as _, i} + {@const roundNum = i + 1} + {@const result = roundResults.find((r) => r.round === roundNum)} +
+ {/each} +
+
- {#if currentTrack} - {:else if gameState === 'RESULTS'}
-

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

-

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

- - {#if isNewBestScore && score > 0} - - {/if} + +
+

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

+ {#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} + +
+
+ {#each roundResults as result} + +
+
+
+ {$t('page.hitstar.results.round', { + round: String(result.round), + })} + {result.track.name} – {result.track.artist} +
+
+
+ {$t('page.hitstar.results.yourGuess', { + year: String(result.guessedYear), + })} + {$t('page.hitstar.results.correctYear', { + year: String(result.track.releaseYear), + })} +
+
+ {/each} +
- + +
+ +
{/if}
@@ -386,19 +411,14 @@ display: flex; flex-direction: column; align-items: center; - padding: 2rem 1rem; - min-height: 80vh; - width: 100%; - max-width: 600px; - margin: 0 auto; + padding: 1.5rem 1rem; gap: 1.5rem; + flex: 1; + box-sizing: border-box; } .menu-screen, - .loading-screen, - .guessing-screen, - .reveal-screen, - .results-screen { + .loading-screen { display: flex; flex-direction: column; align-items: center; @@ -406,6 +426,144 @@ width: 100%; } + .results-screen { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + flex: 1; + } + + .results-top-section { + display: flex; + flex-direction: column; + align-items: center; + gap: 0.75rem; + width: 100%; + padding-bottom: 0.75rem; + } + + .results-middle-section { + flex: 1; + width: 100%; + overflow-y: auto; + padding-right: 2px; + } + + .guessing-screen, + .reveal-screen { + display: flex; + flex-direction: column; + align-items: center; + width: 100%; + flex: 1; + } + + /* ── Section layout within game screens ───────────────── */ + .game-top-section { + display: flex; + flex-direction: column; + gap: 0.75rem; + width: 100%; + } + + .game-middle-section { + flex: 1; + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + gap: var(--default_padding); + width: 100%; + } + + .bottom-action { + padding-top: 0.75rem; + padding-bottom: 0.75rem; + width: 100%; + display: flex; + justify-content: center; + } + + /* ── Top bar: headline + exit button ─────────────────── */ + .game-top-bar { + display: flex; + align-items: center; + justify-content: space-between; + width: 100%; + gap: 0.75rem; + } + + .game-question { + font-size: 1.1rem; + font-weight: 600; + margin: 0; + flex: 1; + line-height: 1.3; + } + + /* ── Round progress tracker ───────────────────────────── */ + .round-tracker { + display: flex; + gap: 5px; + width: 100%; + } + + .round-dot { + flex: 1; + height: 10px; + border-radius: 3px; + background: var(--cds-layer-02, #393939); + border: 1px solid var(--cds-border-subtle-01, #525252); + transition: + background 0.3s, + border-color 0.3s; + } + + .round-dot.correct { + background: var(--cds-support-success, #24a148); + border-color: var(--cds-support-success, #24a148); + } + + .round-dot.wrong { + background: var(--cds-support-error, #da1e28); + border-color: var(--cds-support-error, #da1e28); + } + + /* ── Year input + submit inline ───────────────────────── */ + .input-submit-row { + display: flex; + align-items: flex-end; + width: 100%; + max-width: 360px; + padding-top: 0.75rem; + padding-bottom: 0.75rem; + } + + .input-wrapper { + flex: 1; + min-width: 0; + } + + /* Force submit button to match NumberInput height (40px) and center icon */ + .input-submit-row :global(button) { + height: 2.5rem; + width: 2.5rem; + min-height: unset; + margin-left: 0; + flex-shrink: 0; + display: flex; + align-items: center; + justify-content: center; + padding: 0; + } + + .input-submit-row :global(button svg) { + margin: 0; + position: static; + } + + /* ── Menu styles ──────────────────────────────────────── */ .headline { font-size: 2rem; font-weight: bold; @@ -437,60 +595,62 @@ color: var(--cds-text-secondary); } - .score-display { - font-size: 1rem; - color: var(--cds-text-secondary); - margin: 0; - } - - .card-hint { - text-align: center; - color: var(--cds-text-secondary); - margin: 0; - } - + /* ── Audio ────────────────────────────────────────────── */ .audio-player { width: 100%; max-width: 400px; border-radius: 8px; } - .track-links { + /* ── Results ──────────────────────────────────────────── */ + .results-table { display: flex; - gap: 1rem; - flex-wrap: wrap; - justify-content: center; + flex-direction: column; + gap: 0.5rem; + width: 100%; } - .track-link { - font-size: 0.85rem; - color: var(--cds-link-primary); - text-decoration: underline; + :global(.result-tile.correct) { + border-left: 4px solid #24a148 !important; } - .year-input-row { - width: 100%; - max-width: 300px; + :global(.result-tile.wrong) { + border-left: 4px solid #da1e28 !important; } - - .results-table { + .result-row { display: flex; - flex-direction: column; + align-items: center; gap: 0.75rem; - width: 100%; } - .result-row { + .result-indicator { + width: 14px; + height: 14px; + border-radius: 3px; + flex-shrink: 0; + } + + .indicator-correct { + background: #24a148; + } + + .indicator-wrong { + background: #da1e28; + } + + .result-content { display: flex; align-items: center; gap: 0.5rem; - flex-wrap: wrap; + flex: 1; + min-width: 0; } .result-round { font-weight: bold; - min-width: 70px; + white-space: nowrap; + flex-shrink: 0; } .result-track { @@ -498,10 +658,8 @@ overflow: hidden; text-overflow: ellipsis; white-space: nowrap; - } - - .result-status { - font-weight: bold; + font-size: 0.9rem; + color: var(--cds-text-secondary); } .result-years { @@ -509,9 +667,11 @@ gap: 1rem; font-size: 0.8rem; color: var(--cds-text-secondary); - margin-top: 0.3rem; + margin-top: 0.25rem; + padding-left: 1.75rem; } + /* ── Shake animation ──────────────────────────────────── */ @keyframes shake { 0%, 100% { @@ -535,4 +695,40 @@ .shake { animation: shake 0.5s ease-in-out; } + + /* ── Mobile adjustments ───────────────────────────────── */ + @media (max-width: 480px) { + .hitstar-container { + padding: 0.5rem 0.75rem; + gap: 0; + } + + .game-top-section { + gap: 0.5rem; + padding-bottom: 0.25rem; + } + + .game-question { + font-size: 0.9rem; + } + + .input-submit-row { + max-width: 100%; + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .bottom-action { + padding-top: 0.5rem; + padding-bottom: 0.5rem; + } + + .round-dot { + height: 6px; + } + + .audio-player { + max-height: 40px; + } + } diff --git a/frontend/src/lib/components/hitstar/HitstarCard.svelte b/frontend/src/lib/components/hitstar/HitstarCard.svelte index de135637..6b1dc4b4 100644 --- a/frontend/src/lib/components/hitstar/HitstarCard.svelte +++ b/frontend/src/lib/components/hitstar/HitstarCard.svelte @@ -6,10 +6,11 @@ let { flipped = false, track = null, - }: { flipped: boolean; track: SpotifyTrackDto | null } = $props(); + correct = null, + }: { flipped: boolean; track: SpotifyTrackDto | null; correct: boolean | null } = $props(); -
+
@@ -31,9 +32,10 @@
diff --git a/frontend/src/lib/components/jokes/Jokes.svelte b/frontend/src/lib/components/jokes/Jokes.svelte index b1f1f2b0..3b8ed1ac 100644 --- a/frontend/src/lib/components/jokes/Jokes.svelte +++ b/frontend/src/lib/components/jokes/Jokes.svelte @@ -139,9 +139,6 @@ flex-direction: column; align-items: center; max-width: 90vw; - margin: 0 auto 3rem auto; - height: 85vh; - height: 85dvh; overflow-y: auto; overflow-x: hidden; diff --git a/frontend/src/lib/components/shared/GlobalMenu.svelte b/frontend/src/lib/components/shared/GlobalMenu.svelte index 0ff6ad43..ac94e9e2 100644 --- a/frontend/src/lib/components/shared/GlobalMenu.svelte +++ b/frontend/src/lib/components/shared/GlobalMenu.svelte @@ -168,7 +168,7 @@ font-size: 2.5rem; @media #{$phone} { - font-size: x-large; + font-size: large; } } @@ -214,6 +214,10 @@ color: var(--cds-text-01); font-size: 1.5rem; text-shadow: 0.5px 0.5px black; + + @media #{$phone} { + font-size: 0.7rem; + } } footer { @@ -228,7 +232,7 @@ justify-content: center; @media #{$phone} { - font-size: 0.8rem; + font-size: 0.7rem; flex-wrap: wrap; gap: 0.25rem; } @@ -237,6 +241,7 @@ margin: 0 2rem 0 2rem; @media #{$phone} { + font-size: 0.7rem; margin: 0; width: 100%; order: -1; diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 48113548..f62a5ac9 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -70,7 +70,8 @@ flex-direction: column; padding: 1rem; width: 100%; - overflow: auto; + overflow-x: hidden; + overflow-y: auto; flex: 1; margin: 0 auto; box-sizing: border-box;