From ab838bdf262f8993155986ac1475f0382d6aa48a Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 01:19:03 +0100 Subject: [PATCH 1/4] =?UTF-8?q?=F0=9F=9A=80=20Redesign=20Hitstar=20game=20?= =?UTF-8?q?UI=20with=20icon=20buttons,=20round=20tracker,=20and=20inline?= =?UTF-8?q?=20input.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 4 + .../src/lib/components/hitstar/Hitstar.svelte | 247 +++++++++++++----- 2 files changed, 184 insertions(+), 67 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 500e184b..e5346f27 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -16,6 +16,10 @@ 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] 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/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index 03eaed77..47472654 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'); @@ -183,11 +186,7 @@ {#if errorMessage} - + {/if} {#if savedGameState} @@ -200,9 +199,7 @@ {$t('page.hitstar.menu.startGame')} {:else} - + {/if} @@ -216,14 +213,31 @@ {:else if gameState === 'GUESSING'}
-

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

-

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

+ +
+

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

+
+ + +
+ {#each roundIndices as _, i} + {@const roundNum = i + 1} + {@const result = roundResults.find((r) => r.round === roundNum)} +
+ {/each} +
@@ -237,37 +251,57 @@ > {/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) })} -

+ +
+

+ {$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} +
@@ -320,9 +354,6 @@ ? $t('page.hitstar.reveal.nextRound') : $t('page.hitstar.reveal.seeResults')} -
@@ -348,9 +379,7 @@ round: String(result.round), })} - {result.track.name} – {result.track.artist} + {result.track.name} – {result.track.artist} {result.correct ? $t('page.hitstar.results.correct') @@ -373,9 +402,7 @@ {/each}
- + {/if} @@ -406,6 +433,81 @@ width: 100%; } + /* ── 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; + } + + .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,24 +539,14 @@ 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; } + /* ── Reveal links ─────────────────────────────────────── */ .track-links { display: flex; gap: 1rem; @@ -468,12 +560,7 @@ text-decoration: underline; } - .year-input-row { - width: 100%; - max-width: 300px; - } - - + /* ── Results ──────────────────────────────────────────── */ .results-table { display: flex; flex-direction: column; @@ -512,6 +599,7 @@ margin-top: 0.3rem; } + /* ── Shake animation ──────────────────────────────────── */ @keyframes shake { 0%, 100% { @@ -535,4 +623,29 @@ .shake { animation: shake 0.5s ease-in-out; } + + /* ── Mobile adjustments ───────────────────────────────── */ + @media (max-width: 480px) { + .hitstar-container { + padding: 1rem 0.75rem; + gap: 1rem; + } + + .guessing-screen, + .reveal-screen { + gap: 1rem; + } + + .game-question { + font-size: 0.95rem; + } + + .input-submit-row { + max-width: 100%; + } + + .round-dot { + height: 8px; + } + } From 8d1eb309eb1de9981d14061a85b6ce9e21bfd935 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 02:12:58 +0100 Subject: [PATCH 2/4] =?UTF-8?q?=F0=9F=93=B1=20Improve=20Hitstar=20mobile?= =?UTF-8?q?=20layout=20with=20top/middle/bottom=20section=20structure=20an?= =?UTF-8?q?d=20responsive=20scaling.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 6 + .../src/lib/components/hitstar/Hitstar.svelte | 328 +++++++++++------- .../lib/components/hitstar/HitstarCard.svelte | 27 +- .../src/lib/components/jokes/Jokes.svelte | 3 - .../lib/components/shared/GlobalMenu.svelte | 9 +- frontend/src/routes/+layout.svelte | 3 +- 6 files changed, 239 insertions(+), 137 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index e5346f27..312ea43a 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,12 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [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). diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index 47472654..e4dca215 100644 --- a/frontend/src/lib/components/hitstar/Hitstar.svelte +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -186,7 +186,11 @@ {#if errorMessage} - + {/if} {#if savedGameState} @@ -199,7 +203,9 @@ {$t('page.hitstar.menu.startGame')} {:else} - + {/if} @@ -213,45 +219,49 @@ {:else if gameState === 'GUESSING'}
- -
-

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

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

- {$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} + +
+
+

+ {$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 isCorrect} - - {:else} - - {/if} + {#if isCorrect} + + {:else} + + {/if} - {#if currentTrack?.previewUrl} - - {/if} + {#if currentTrack?.previewUrl} + + {/if} - {#if currentTrack} - - {/if} + {#if currentTrack} + + {/if} +
- + +
+ +
@@ -379,7 +399,9 @@ round: String(result.round), })} - {result.track.name} – {result.track.artist} + {result.track.name} – {result.track.artist} {result.correct ? $t('page.hitstar.results.correct') @@ -402,7 +424,9 @@ {/each}
- +
{/if}
@@ -413,18 +437,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 { display: flex; flex-direction: column; @@ -433,6 +453,41 @@ width: 100%; } + .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; @@ -463,7 +518,9 @@ 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; + transition: + background 0.3s, + border-color 0.3s; } .round-dot.correct { @@ -482,6 +539,8 @@ align-items: flex-end; width: 100%; max-width: 360px; + padding-top: 0.75rem; + padding-bottom: 0.75rem; } .input-wrapper { @@ -627,25 +686,40 @@ /* ── Mobile adjustments ───────────────────────────────── */ @media (max-width: 480px) { .hitstar-container { - padding: 1rem 0.75rem; - gap: 1rem; + padding: 0.5rem 0.75rem; + gap: 0; } - .guessing-screen, - .reveal-screen { - gap: 1rem; + .game-top-section { + gap: 0.5rem; + padding-bottom: 0.25rem; } .game-question { - font-size: 0.95rem; + 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: 8px; + height: 6px; + } + + .audio-player { + max-height: 40px; + } + + .track-links { + display: none; } } diff --git a/frontend/src/lib/components/hitstar/HitstarCard.svelte b/frontend/src/lib/components/hitstar/HitstarCard.svelte index de135637..e0b0ae31 100644 --- a/frontend/src/lib/components/hitstar/HitstarCard.svelte +++ b/frontend/src/lib/components/hitstar/HitstarCard.svelte @@ -31,9 +31,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; From bcc238a9950b3f880a4b7f2be20e5b4e10a10279 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 02:22:26 +0100 Subject: [PATCH 3/4] =?UTF-8?q?=F0=9F=9A=80=20Switch=20Hitstar=20backend?= =?UTF-8?q?=20from=20iTunes=20to=20Spotify=20API=20with=20OAuth2=20token?= =?UTF-8?q?=20caching.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 1 + backend/apps/tilloh-dev/src/env.validation.ts | 6 ++ .../libs/spotify/src/lib/spotify.module.ts | 3 +- .../spotify/src/lib/spotify.service.spec.ts | 61 ++++++++----- .../libs/spotify/src/lib/spotify.service.ts | 86 +++++++++++++------ 5 files changed, 110 insertions(+), 47 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 312ea43a..2f35f4ec 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Changed +- [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. 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, }; } From 5b6271ca64bc1dd1e5aea33ec2aef6d238255cf2 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 02:37:56 +0100 Subject: [PATCH 4/4] =?UTF-8?q?=F0=9F=9A=80=20Redesign=20Hitstar=20results?= =?UTF-8?q?=20screen=20with=20colored=20indicators=20and=20card=20border?= =?UTF-8?q?=20feedback.?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Co-Authored-By: Claude Sonnet 4.6 --- CHANGELOG.md | 3 + .../src/lib/components/hitstar/Hitstar.svelte | 233 +++++++++--------- .../lib/components/hitstar/HitstarCard.svelte | 32 ++- 3 files changed, 151 insertions(+), 117 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index 2f35f4ec..825f1699 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -19,6 +19,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### 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. diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index e4dca215..23d7f063 100644 --- a/frontend/src/lib/components/hitstar/Hitstar.svelte +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -218,7 +218,7 @@ {:else if gameState === 'GUESSING'} -
+
@@ -315,27 +315,11 @@
- +
- - - {#if isCorrect} - - {:else} - - {/if} +
+ +
{#if currentTrack?.previewUrl} {/if} - - {#if currentTrack} - - {/if}
@@ -379,54 +344,63 @@ {: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}
@@ -444,8 +418,7 @@ } .menu-screen, - .loading-screen, - .results-screen { + .loading-screen { display: flex; flex-direction: column; align-items: center; @@ -453,6 +426,30 @@ 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; @@ -605,38 +602,55 @@ border-radius: 8px; } - /* ── Reveal links ─────────────────────────────────────── */ - .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; } - /* ── Results ──────────────────────────────────────────── */ - .results-table { + :global(.result-tile.wrong) { + border-left: 4px solid #da1e28 !important; + } + + .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 { @@ -644,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 { @@ -655,7 +667,8 @@ 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 ──────────────────────────────────── */ @@ -717,9 +730,5 @@ .audio-player { max-height: 40px; } - - .track-links { - display: none; - } } diff --git a/frontend/src/lib/components/hitstar/HitstarCard.svelte b/frontend/src/lib/components/hitstar/HitstarCard.svelte index e0b0ae31..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(); -
+
@@ -67,7 +68,8 @@ .card-front { background: linear-gradient(135deg, #1a1a2e 0%, #16213e 50%, #0f3460 100%); - border: 2px solid #e94560; + border: 2px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 0 20px rgba(80, 120, 220, 0.15); } .question-mark { @@ -80,9 +82,21 @@ .card-back { background: linear-gradient(135deg, #1e1e2e 0%, #2a2a3e 100%); transform: rotateY(180deg); - border: 2px solid #1db954; + border: 2px solid rgba(255, 255, 255, 0.18); + box-shadow: 0 0 20px rgba(80, 120, 220, 0.15); gap: 0.4rem; overflow: hidden; + transition: border-color 0.4s ease, box-shadow 0.4s ease; + } + + .card-scene.correct .card-back { + border-color: #24a148; + box-shadow: 0 0 24px rgba(36, 161, 72, 0.35); + } + + .card-scene.wrong .card-back { + border-color: #da1e28; + box-shadow: 0 0 24px rgba(218, 30, 40, 0.35); } .album-cover { @@ -130,10 +144,18 @@ .track-year { font-size: 1.4rem; font-weight: bold; - color: #1db954; + color: #ffffff; margin: 0.2rem 0 0; } + .card-scene.correct .track-year { + color: #24a148; + } + + .card-scene.wrong .track-year { + color: #da1e28; + } + @media (max-width: 480px) { .card-scene { width: min(190px, 60vw);