From 95d213453a43d4199ea9660035f80a7a95744cf9 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 11:38:07 +0100 Subject: [PATCH 01/11] =?UTF-8?q?=F0=9F=90=9B=20Fixed=20home=20page=20spac?= =?UTF-8?q?ing?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- frontend/src/routes/+page.svelte | 4 ++-- 1 file changed, 2 insertions(+), 2 deletions(-) diff --git a/frontend/src/routes/+page.svelte b/frontend/src/routes/+page.svelte index 320a6dec..fc09008f 100644 --- a/frontend/src/routes/+page.svelte +++ b/frontend/src/routes/+page.svelte @@ -37,7 +37,7 @@ -
+
{#if randomJokeToggle} @@ -54,7 +54,7 @@ justify-content: center; align-items: center; max-width: 90vw; - margin: 0 auto 3rem auto; + margin: var(--default_padding) auto; flex: 0.2; } From f1be59ae0888c1e54ab1ca8d3cf53a6e012dfa42 Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 13:06:45 +0100 Subject: [PATCH 02/11] =?UTF-8?q?=E2=9C=A8=20Add=20toggle=20auto-seed=20on?= =?UTF-8?q?=20startup=20and=20filter=20navigation=20routes=20by=20toggle?= =?UTF-8?q?=20value.?= 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 + .../keystore-persistence/src/index.ts | 2 + .../lib/shared-keystore-persistence.module.ts | 3 +- .../src/lib/toggle-seed.config.ts | 23 ++++ .../src/lib/toggle-seed.service.spec.ts | 109 ++++++++++++++++++ .../src/lib/toggle-seed.service.ts | 51 ++++++++ .../lib/components/memorandum/Startup.svelte | 6 - .../lib/components/shared/GlobalMenu.svelte | 4 +- frontend/src/lib/config/applications.ts | 6 +- frontend/src/routes/+page.svelte | 4 +- 10 files changed, 197 insertions(+), 12 deletions(-) create mode 100644 backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts create mode 100644 backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts create mode 100644 backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts diff --git a/CHANGELOG.md b/CHANGELOG.md index 825f1699..5c82b56c 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -8,6 +8,7 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 ### Added +- [backend] Auto-seed toggles on application startup: missing toggles are created with default values from config; existing toggles are never overwritten. - [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). diff --git a/backend/libs/shared/provider/keystore-persistence/src/index.ts b/backend/libs/shared/provider/keystore-persistence/src/index.ts index 2cb6bab7..2ce60d16 100644 --- a/backend/libs/shared/provider/keystore-persistence/src/index.ts +++ b/backend/libs/shared/provider/keystore-persistence/src/index.ts @@ -1,3 +1,5 @@ export * from './lib/keystore-mongodb.service'; export * from './lib/schema/keystore.schema'; export * from './lib/shared-keystore-persistence.module'; +export * from './lib/toggle-seed.config'; +export * from './lib/toggle-seed.service'; diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts index 87d43671..c6c094aa 100644 --- a/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/shared-keystore-persistence.module.ts @@ -2,6 +2,7 @@ import { Module } from '@nestjs/common'; import { MongooseModule } from '@nestjs/mongoose'; import { KeystoreMongoDbService } from './keystore-mongodb.service'; import { Keystore, KeystoreSchema } from './schema/keystore.schema'; +import { ToggleSeedService } from './toggle-seed.service'; @Module({ imports: [ @@ -10,7 +11,7 @@ import { Keystore, KeystoreSchema } from './schema/keystore.schema'; ]), ], controllers: [], - providers: [KeystoreMongoDbService], + providers: [KeystoreMongoDbService, ToggleSeedService], exports: [KeystoreMongoDbService], }) export class SharedKeystorePersistenceModule {} diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts new file mode 100644 index 00000000..4a04fd12 --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.config.ts @@ -0,0 +1,23 @@ +export const TOGGLE_IDENTIFIER = 'tilloh-toggles'; + +export interface ToggleSeedEntry { + key: string; + defaultValue: string; +} + +export const TOGGLE_SEED_CONFIG: ToggleSeedEntry[] = [ + { key: 'TOGGLE_RANDOM_JOKE', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_MEMORANDUM', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_TODO', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_FOOD_SCAN', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_JOKES', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_CATCH_EM_ALL', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_UNO_SORT', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_ABOUT', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_DASHBOARD', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_ACTIVITIES', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_IDENTIFIERS', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_JOKES', defaultValue: 'true' }, + { key: 'TOGGLE_ADMIN_LINK_PRESETS', defaultValue: 'true' }, + { key: 'TOGGLE_NAV_HITSTAR', defaultValue: 'true' }, +]; diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts new file mode 100644 index 00000000..19757ced --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.spec.ts @@ -0,0 +1,109 @@ +import { mockKeystoreDto } from '@backend/util'; +import { Test, TestingModule } from '@nestjs/testing'; +import { KeystoreMongoDbService } from './keystore-mongodb.service'; +import { TOGGLE_IDENTIFIER, TOGGLE_SEED_CONFIG } from './toggle-seed.config'; +import { ToggleSeedService } from './toggle-seed.service'; + +describe('ToggleSeedService', () => { + let service: ToggleSeedService; + let keystoreService: jest.Mocked; + + beforeEach(async () => { + const module: TestingModule = await Test.createTestingModule({ + providers: [ + ToggleSeedService, + { + provide: KeystoreMongoDbService, + useValue: { + findAll: jest.fn(), + create: jest.fn(), + }, + }, + ], + }).compile(); + + service = module.get(ToggleSeedService); + keystoreService = module.get(KeystoreMongoDbService); + }); + + afterEach(() => { + jest.clearAllMocks(); + }); + + it('should be defined.', () => { + expect(service).toBeDefined(); + }); + + describe('onApplicationBootstrap', () => { + it('should create all toggles when DB is empty', async () => { + // arrange + keystoreService.findAll.mockResolvedValue([]); + keystoreService.create.mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.findAll).toHaveBeenCalledWith({ + identifier: TOGGLE_IDENTIFIER, + }); + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length, + ); + }); + + it('should not create any toggles when all already exist', async () => { + // arrange + const existingToggles = TOGGLE_SEED_CONFIG.map((entry) => + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER, key: entry.key }), + ); + keystoreService.findAll.mockResolvedValue(existingToggles); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).not.toHaveBeenCalled(); + }); + + it('should only create missing toggles when DB is partially populated', async () => { + // arrange + const existingKeys = TOGGLE_SEED_CONFIG.slice(0, 5); + const existingToggles = existingKeys.map((entry) => + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER, key: entry.key }), + ); + keystoreService.findAll.mockResolvedValue(existingToggles); + keystoreService.create.mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length - existingKeys.length, + ); + }); + + it('should continue seeding remaining toggles when one create fails', async () => { + // arrange + keystoreService.findAll.mockResolvedValue([]); + keystoreService.create + .mockRejectedValueOnce(new Error('create failed')) + .mockResolvedValue( + mockKeystoreDto({ identifier: TOGGLE_IDENTIFIER }), + ); + + // act + await service.onApplicationBootstrap(); + + // assert + expect(keystoreService.create).toHaveBeenCalledTimes( + TOGGLE_SEED_CONFIG.length, + ); + }); + }); +}); diff --git a/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts new file mode 100644 index 00000000..209c7179 --- /dev/null +++ b/backend/libs/shared/provider/keystore-persistence/src/lib/toggle-seed.service.ts @@ -0,0 +1,51 @@ +import { Injectable, Logger, OnApplicationBootstrap } from '@nestjs/common'; +import { KeystoreMongoDbService } from './keystore-mongodb.service'; +import { + TOGGLE_IDENTIFIER, + TOGGLE_SEED_CONFIG, +} from './toggle-seed.config'; + +@Injectable() +export class ToggleSeedService implements OnApplicationBootstrap { + private readonly logger = new Logger(ToggleSeedService.name); + + constructor(private readonly keystoreService: KeystoreMongoDbService) {} + + async onApplicationBootstrap(): Promise { + this.logger.debug('Starting toggle seed check...'); + + const existingToggles = await this.keystoreService.findAll({ + identifier: TOGGLE_IDENTIFIER, + }); + + const existingKeys = new Set(existingToggles.map((t) => t.key)); + + let created = 0; + let alreadyExisted = 0; + + for (const entry of TOGGLE_SEED_CONFIG) { + if (existingKeys.has(entry.key)) { + alreadyExisted++; + continue; + } + + try { + await this.keystoreService.create( + TOGGLE_IDENTIFIER, + entry.key, + entry.defaultValue, + ); + this.logger.debug(`Toggle created: ${entry.key}=${entry.defaultValue}`); + created++; + } catch (error) { + this.logger.error( + `Failed to create toggle ${entry.key}: ${(error as Error).message}`, + ); + } + } + + this.logger.log( + `Toggle seed complete: ${created} created, ${alreadyExisted} already existed.`, + ); + } +} diff --git a/frontend/src/lib/components/memorandum/Startup.svelte b/frontend/src/lib/components/memorandum/Startup.svelte index dfa1ac9e..821f1e29 100644 --- a/frontend/src/lib/components/memorandum/Startup.svelte +++ b/frontend/src/lib/components/memorandum/Startup.svelte @@ -88,12 +88,6 @@ } } - h3 { - margin: 0; - font-size: 1.1rem; - font-weight: 600; - } - p { margin: 0; font-size: 0.875rem; diff --git a/frontend/src/lib/components/shared/GlobalMenu.svelte b/frontend/src/lib/components/shared/GlobalMenu.svelte index ac94e9e2..e8db6567 100644 --- a/frontend/src/lib/components/shared/GlobalMenu.svelte +++ b/frontend/src/lib/components/shared/GlobalMenu.svelte @@ -1,5 +1,5 @@ {#if $initialized} -
- - {#if gameState === 'MENU'} - - - - {:else if gameState === 'LOADING'} -
-

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

- -
- - - {: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} -
-
- - -
- - {#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} -
-
- - -
-
- -
- - {#if currentTrack?.previewUrl} - - {/if} -
- - -
- -
-
- - - {:else if gameState === 'RESULTS'} -
- -
-

{$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} -
-
-
- {$t('page.hitstar.results.yourGuess', { - year: String(result.guessedYear), - })} - {$t('page.hitstar.results.correctYear', { - year: String(result.track.releaseYear), - })} -
-
- {/each} -
-
- - -
- -
-
- {/if} +
+ + + + + + + + + + + +
{/if} @@ -447,366 +40,36 @@ display: flex; flex-direction: column; align-items: center; - padding: 1.5rem 1rem; - gap: 1.5rem; flex: 1; box-sizing: border-box; - } - - .menu-screen, - .loading-screen { - display: flex; - flex-direction: column; - align-items: center; - gap: 1.5rem; - 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; + /* Tabs always visible as horizontal bar, no dropdown */ + .hitstar-container :global(.bx--tabs-trigger) { + display: none; } - .results-middle-section { - flex: 1; - width: 100%; - overflow-y: auto; - padding-right: 2px; + .hitstar-container :global(.bx--tabs__nav--hidden) { + display: flex !important; } - .guessing-screen, - .reveal-screen { - display: flex; - flex-direction: column; - align-items: center; + /* Full-width tab nav */ + .hitstar-container :global(.bx--tabs) { 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; + .hitstar-container :global(.bx--tabs__nav) { 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; + /* TabContent padding */ + .hitstar-container :global(.bx--tab-content) { padding: 0; } - .input-submit-row :global(button svg) { - margin: 0; - position: static; - } - - /* ── Step cards ───────────────────────────────────────── */ - .step-cards { - display: flex; - gap: 0.5rem; - width: 100%; - max-width: 520px; - justify-content: center; - } - - .step-card { - display: flex; - flex-direction: column; - align-items: center; - gap: 0.25rem; - padding: 0.75rem 0.25rem; - border-radius: 6px; - border: 2px solid var(--cds-border-subtle-01, #525252); - background: var(--cds-layer-02, #393939); - flex: 1; - min-width: 0; - transition: - border-color 0.3s, - background 0.3s, - transform 0.3s, - opacity 0.3s; - opacity: 0.45; - } - - .step-card.active { - border-color: var(--cds-interactive, #0f62fe); - background: var(--cds-layer-selected-01, #4c4c4c); - opacity: 1; - transform: scale(1.1); - } - - .step-label { - font-size: 0.7rem; - text-align: center; - color: var(--cds-text-secondary); - line-height: 1.2; - } - - .step-card.active .step-label { - color: var(--cds-text-primary); - font-weight: 500; - } - - /* ── Menu styles ──────────────────────────────────────── */ - .headline { - font-size: 2rem; - font-weight: bold; - text-align: center; - margin: 0; - } - - .description { - text-align: center; - color: var(--cds-text-secondary); - margin: 0; - max-width: 480px; - } - - .best-score-label { - font-size: 0.85rem; - color: var(--cds-text-secondary); - margin: 0 0 0.3rem; - } - - .best-score-value { - font-size: 1.1rem; - font-weight: bold; - margin: 0; - } - - /* ── Audio ────────────────────────────────────────────── */ - .audio-player { - width: 100%; - max-width: 400px; - border-radius: 8px; - } - - /* ── Results ──────────────────────────────────────────── */ - .results-table { - display: flex; - flex-direction: column; - gap: 0.5rem; - width: 100%; - } - - :global(.result-tile.correct) { - border-left: 4px solid #24a148 !important; - } - - :global(.result-tile.wrong) { - border-left: 4px solid #da1e28 !important; - } - - .result-row { - display: flex; - align-items: center; - gap: 0.75rem; - } - - .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: 1; - min-width: 0; - } - - .result-round { - font-weight: bold; - white-space: nowrap; - flex-shrink: 0; - } - - .result-track { - flex: 1; - overflow: hidden; - text-overflow: ellipsis; - white-space: nowrap; - font-size: 0.9rem; - color: var(--cds-text-secondary); - } - - .result-years { - display: flex; - gap: 1rem; - font-size: 0.8rem; - color: var(--cds-text-secondary); - margin-top: 0.25rem; - padding-left: 1.75rem; - } - - /* ── Shake animation ──────────────────────────────────── */ - @keyframes shake { - 0%, - 100% { - transform: translateX(0); - } - 10%, - 30%, - 50%, - 70%, - 90% { - transform: translateX(-5px); - } - 20%, - 40%, - 60%, - 80% { - transform: translateX(5px); - } - } - - .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; - } + /* Hide tabs during active game */ + .game-active :global(.bx--tabs) { + display: none; } diff --git a/frontend/src/lib/components/hitstar/HitstarClassic.svelte b/frontend/src/lib/components/hitstar/HitstarClassic.svelte new file mode 100644 index 00000000..54b1ac1d --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarClassic.svelte @@ -0,0 +1,498 @@ + + + +{#if gameState === 'MENU'} + + + +{:else if gameState === 'LOADING'} + + + +{:else if gameState === 'GUESSING'} +
+
+ + +
+ +
+ + +
+ +
+
+ +
+
+
+ + +{:else if gameState === 'REVEAL'} +
+
+ + +
+ +
+
+ +
+ +
+ +
+ +
+
+ + +{:else if gameState === 'RESULTS'} + + {#snippet guessDisplay(result)} + {$t('page.hitstar.results.yourGuess', { year: String(result.guessedYear) })} + {/snippet} + +{/if} + + diff --git a/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte b/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte new file mode 100644 index 00000000..266c587a --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarGameTopBar.svelte @@ -0,0 +1,48 @@ + + +
+

{headline}

+
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte b/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte new file mode 100644 index 00000000..9035e289 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarLoadingScreen.svelte @@ -0,0 +1,22 @@ + + +
+

{headline}

+ +
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarRange.svelte b/frontend/src/lib/components/hitstar/HitstarRange.svelte new file mode 100644 index 00000000..aa26a5cf --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarRange.svelte @@ -0,0 +1,585 @@ + + + +{#if gameState === 'MENU'} + + + +{:else if gameState === 'LOADING'} + + + +{:else if gameState === 'GUESSING'} +
+
+ + +
+ +
+ + +
+ +
+
+
+ +
+
+ +
+
+ {#if validationError} +

{validationError}

+ {:else if potentialPoints > 0} +

+ {$t('page.hitstar.range.guessing.potentialPoints', { + points: String(potentialPoints), + })} +

+ {/if} +
+
+ + +{:else if gameState === 'REVEAL'} +
+
+ + +
+ +
+
+ +
+ +
+ +
+ +
+
+ + +{:else if gameState === 'RESULTS'} + + {#snippet guessDisplay(result)} + + {$t('page.hitstar.range.results.yourGuess', { + fromYear: String(result.rangeFrom ?? result.guessedYear), + toYear: String(result.rangeTo ?? result.guessedYear), + })} + + {#if result.pointsEarned && result.pointsEarned > 0} + + {$t('page.hitstar.range.results.points', { points: String(result.pointsEarned) })} + + {/if} + {/snippet} + +{/if} + + diff --git a/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte b/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte new file mode 100644 index 00000000..040d502d --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarResultsScreen.svelte @@ -0,0 +1,186 @@ + + +
+
+

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

+

{score} / {maxScore}

+ {#if isNewBestScore && score > 0} + + {/if} +
+ +
+
+ {#each roundResults as result} + +
+
+
+ {$t('page.hitstar.results.round', { + round: String(result.round), + })} + {result.track.name} – {result.track.artist} +
+
+
+ {@render guessDisplay(result)} + {$t('page.hitstar.results.correctYear', { + year: String(result.track.releaseYear), + })} +
+
+ {/each} +
+
+ +
+ +
+
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte b/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte new file mode 100644 index 00000000..8acfaf92 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarRoundTracker.svelte @@ -0,0 +1,55 @@ + + +
+ {#each { length: totalRounds } as _, i} + {@const roundNum = i + 1} + {@const result = roundResults.find((r) => r.round === roundNum)} +
+ {/each} +
+ + diff --git a/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte new file mode 100644 index 00000000..9452cf25 --- /dev/null +++ b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte @@ -0,0 +1,67 @@ + + +{#if trackId} +
+ + {#if masked} + + {/if} +
+{/if} + + diff --git a/frontend/src/lib/config/de.json b/frontend/src/lib/config/de.json index 7fedc727..5deff2c0 100644 --- a/frontend/src/lib/config/de.json +++ b/frontend/src/lib/config/de.json @@ -341,5 +341,19 @@ "page.hitstar.menu.step2": "Jahr raten", "page.hitstar.menu.step3": "Aufdecken", "page.hitstar.menu.step4": "10 Runden", - "page.hitstar.menu.step5": "Wertung" + "page.hitstar.menu.step5": "Wertung", + "page.hitstar.tabs.classic": "Klassisch", + "page.hitstar.tabs.range": "Zeitraum", + "page.hitstar.range.menu.headline": "Der Zeitraum-Modus", + "page.hitstar.range.menu.description": "Setze einen Zeitraum von 1 bis 5 Jahren – je schmaler der Bereich, desto mehr Punkte. Max. 50 Punkte!", + "page.hitstar.range.menu.step2": "Zeitraum setzen", + "page.hitstar.range.menu.bestScoreValue": "{{score}} / 50 am {{date}}", + "page.hitstar.range.guessing.cardHint": "In welchem Zeitraum wurde dieser Song veröffentlicht?", + "page.hitstar.range.guessing.fromLabel": "Von", + "page.hitstar.range.guessing.toLabel": "Bis", + "page.hitstar.range.guessing.potentialPoints": "Mögliche Punkte: {{points}}", + "page.hitstar.range.guessing.errorFromTo": "\"Bis\" muss größer oder gleich \"Von\" sein.", + "page.hitstar.range.guessing.errorRangeSize": "Der Zeitraum darf maximal 5 Jahre umfassen.", + "page.hitstar.range.results.yourGuess": "Dein Zeitraum: {{fromYear}}–{{toYear}}", + "page.hitstar.range.results.points": "+{{points}} Pkt." } \ No newline at end of file diff --git a/frontend/src/lib/config/en.json b/frontend/src/lib/config/en.json index 38e8ad53..20f80127 100644 --- a/frontend/src/lib/config/en.json +++ b/frontend/src/lib/config/en.json @@ -341,5 +341,19 @@ "page.hitstar.menu.step2": "Guess", "page.hitstar.menu.step3": "Reveal", "page.hitstar.menu.step4": "10 Rounds", - "page.hitstar.menu.step5": "Score" + "page.hitstar.menu.step5": "Score", + "page.hitstar.tabs.classic": "Classic", + "page.hitstar.tabs.range": "Range", + "page.hitstar.range.menu.headline": "Range Mode", + "page.hitstar.range.menu.description": "Set a 1–5 year range – the narrower the range, the more points you earn. Max. 50 points!", + "page.hitstar.range.menu.step2": "Set range", + "page.hitstar.range.menu.bestScoreValue": "{{score}} / 50 on {{date}}", + "page.hitstar.range.guessing.cardHint": "In which year range was this song released?", + "page.hitstar.range.guessing.fromLabel": "From", + "page.hitstar.range.guessing.toLabel": "To", + "page.hitstar.range.guessing.potentialPoints": "Potential points: {{points}}", + "page.hitstar.range.guessing.errorFromTo": "\"To\" must be greater than or equal to \"From\".", + "page.hitstar.range.guessing.errorRangeSize": "The range may span at most 5 years.", + "page.hitstar.range.results.yourGuess": "Your range: {{fromYear}}–{{toYear}}", + "page.hitstar.range.results.points": "+{{points}} pts." } \ No newline at end of file diff --git a/frontend/src/lib/types/spotify.dto.ts b/frontend/src/lib/types/spotify.dto.ts index 85693230..ffb8c3f6 100644 --- a/frontend/src/lib/types/spotify.dto.ts +++ b/frontend/src/lib/types/spotify.dto.ts @@ -14,6 +14,9 @@ export interface HitstarRoundResult { track: SpotifyTrackDto; guessedYear: number; correct: boolean; + rangeFrom?: number; + rangeTo?: number; + pointsEarned?: number; } export interface HitstarBestRound { diff --git a/frontend/src/lib/util/stores/store-hitstar.ts b/frontend/src/lib/util/stores/store-hitstar.ts index 58e80fe9..b968bc34 100644 --- a/frontend/src/lib/util/stores/store-hitstar.ts +++ b/frontend/src/lib/util/stores/store-hitstar.ts @@ -2,12 +2,28 @@ 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 classicBestRoundKey = 'hitstar.classic.bestRound'; +const classicGameStateKey = 'hitstar.classic.gameState'; +const rangeBestRoundKey = 'hitstar.range.bestRound'; +const rangeGameStateKey = 'hitstar.range.gameState'; -const getInitialBestRound = (): HitstarBestRound | null => { +// Migration: alte Keys auf neue Keys +if (browser) { + const oldBestRound = localStorage.getItem('hitstar.bestRound'); + if (oldBestRound) { + localStorage.setItem(classicBestRoundKey, oldBestRound); + localStorage.removeItem('hitstar.bestRound'); + } + const oldGameState = localStorage.getItem('hitstar.gameState'); + if (oldGameState) { + localStorage.setItem(classicGameStateKey, oldGameState); + localStorage.removeItem('hitstar.gameState'); + } +} + +const getInitialBestRound = (key: string): HitstarBestRound | null => { if (!browser) return null; - const stored = localStorage.getItem(hitstarBestRoundKey); + const stored = localStorage.getItem(key); if (!stored) return null; try { return JSON.parse(stored) as HitstarBestRound; @@ -16,9 +32,9 @@ const getInitialBestRound = (): HitstarBestRound | null => { } }; -const getInitialGameState = (): HitstarGameState | null => { +const getInitialGameState = (key: string): HitstarGameState | null => { if (!browser) return null; - const stored = localStorage.getItem(hitstarGameStateKey); + const stored = localStorage.getItem(key); if (!stored) return null; try { return JSON.parse(stored) as HitstarGameState; @@ -27,28 +43,52 @@ const getInitialGameState = (): HitstarGameState | null => { } }; -export const hitstarBestRoundStore = writable( - getInitialBestRound(), +export const hitstarClassicBestRoundStore = writable( + getInitialBestRound(classicBestRoundKey), ); -export const hitstarGameStateStore = writable( - getInitialGameState(), +export const hitstarClassicGameStateStore = writable( + getInitialGameState(classicGameStateKey), +); + +export const hitstarRangeBestRoundStore = writable( + getInitialBestRound(rangeBestRoundKey), +); + +export const hitstarRangeGameStateStore = writable( + getInitialGameState(rangeGameStateKey), ); if (browser) { - hitstarBestRoundStore.subscribe((val) => { + hitstarClassicBestRoundStore.subscribe((val) => { + if (val === null) { + localStorage.removeItem(classicBestRoundKey); + } else { + localStorage.setItem(classicBestRoundKey, JSON.stringify(val)); + } + }); + + hitstarClassicGameStateStore.subscribe((val) => { + if (val === null) { + localStorage.removeItem(classicGameStateKey); + } else { + localStorage.setItem(classicGameStateKey, JSON.stringify(val)); + } + }); + + hitstarRangeBestRoundStore.subscribe((val) => { if (val === null) { - localStorage.removeItem(hitstarBestRoundKey); + localStorage.removeItem(rangeBestRoundKey); } else { - localStorage.setItem(hitstarBestRoundKey, JSON.stringify(val)); + localStorage.setItem(rangeBestRoundKey, JSON.stringify(val)); } }); - hitstarGameStateStore.subscribe((val) => { + hitstarRangeGameStateStore.subscribe((val) => { if (val === null) { - localStorage.removeItem(hitstarGameStateKey); + localStorage.removeItem(rangeGameStateKey); } else { - localStorage.setItem(hitstarGameStateKey, JSON.stringify(val)); + localStorage.setItem(rangeGameStateKey, JSON.stringify(val)); } }); } diff --git a/frontend/src/lib/util/translations.ts b/frontend/src/lib/util/translations.ts index f4bc3c54..10d7bd6c 100644 --- a/frontend/src/lib/util/translations.ts +++ b/frontend/src/lib/util/translations.ts @@ -16,6 +16,9 @@ interface Params { round: string; year: string; date: string; + fromYear: string; + toYear: string; + points: string; } const config: Config> = { From 8b3a46faa6cfb96479fa6f598106a5aaf4b8bc1d Mon Sep 17 00:00:00 2001 From: timlohse1104 Date: Sat, 14 Mar 2026 17:27:24 +0100 Subject: [PATCH 05/11] =?UTF-8?q?=F0=9F=90=9B=20Fix=20Carbon=20Tabs=20mobi?= =?UTF-8?q?le=20layout=20and=20replace=20Spotify=20embed=20with=20iFrame?= =?UTF-8?q?=20API=20player.?= 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 | 43 +++- .../hitstar/HitstarSpotifyEmbed.svelte | 210 +++++++++++++++--- 3 files changed, 217 insertions(+), 39 deletions(-) diff --git a/CHANGELOG.md b/CHANGELOG.md index aa26d4d8..bcd7ea29 100644 --- a/CHANGELOG.md +++ b/CHANGELOG.md @@ -10,8 +10,9 @@ and this project adheres to [Semantic Versioning](https://semver.org/spec/v2.0.0 - [backend] Auto-seed toggles on application startup: missing toggles are created with default values from config; existing toggles are never overwritten. - [hitstar] Add animated step-card tutorial in the menu that loops through 5 game steps with icons and auto-highlights each card every 2 seconds. -- [hitstar] Multi-mode architecture with Carbon Tabs (horizontal, no dropdown) for mode selection on the Hitstar start page. +- [hitstar] Multi-mode architecture with Carbon Tabs (horizontal, compact, centered, no dropdown on any viewport) with spacing below for mode selection on the Hitstar start page. - [hitstar] New "Range" game mode: set a 1–5 year range instead of guessing exactly; narrower ranges earn more points (max 50). +- [hitstar] Replace Spotify iframe embed in guessing phase with Spotify iFrame API: hidden iframe controlled by custom Play/Pause and Replay buttons for a clean, maskless player UI. ### Changed diff --git a/frontend/src/lib/components/hitstar/Hitstar.svelte b/frontend/src/lib/components/hitstar/Hitstar.svelte index 1aeeaab1..092a063a 100644 --- a/frontend/src/lib/components/hitstar/Hitstar.svelte +++ b/frontend/src/lib/components/hitstar/Hitstar.svelte @@ -52,20 +52,57 @@ .hitstar-container :global(.bx--tabs__nav--hidden) { display: flex !important; + flex-direction: row !important; + max-height: unset !important; + overflow: visible !important; + position: static !important; } - /* Full-width tab nav */ + /* Full-width tab container, nav centered and only as wide as its items */ .hitstar-container :global(.bx--tabs) { width: 100%; + display: flex; + justify-content: center; } .hitstar-container :global(.bx--tabs__nav) { - width: 100%; + width: fit-content; + } + + .hitstar-container :global(.bx--tabs__nav-item) { + display: flex !important; + flex: unset; + } + + /* Active tab indicator (Carbon hides selected item on mobile by default) */ + .hitstar-container :global(.bx--tabs__nav-item--selected .bx--tabs__nav-link) { + border-bottom: 2px solid var(--cds-interactive-04, #0f62fe) !important; + color: var(--cds-text-01, #f4f4f4) !important; + font-weight: 600; + } + + /* Remove focus ring on tab links — selection shown by bottom border only */ + .hitstar-container :global(.bx--tabs__nav-link:focus), + .hitstar-container :global(.bx--tabs__nav-link:active) { + outline: none; + box-shadow: none; + } + + /* Prevent Carbon from resizing tab links on focus/active on mobile + (Carbon sets width:100%, padding-left:16px, margin:0 which causes visual shift) */ + @media (max-width: 41.9375rem) { + .hitstar-container :global(.bx--tabs__nav-link:focus), + .hitstar-container :global(.bx--tabs__nav-link:active) { + width: calc(100% - 32px) !important; + padding-left: 0 !important; + margin: 0 var(--cds-spacing-05, 1rem) !important; + } } - /* TabContent padding */ + /* Margin between tab bar and mode content */ .hitstar-container :global(.bx--tab-content) { padding: 0; + margin-top: calc(2 * var(--default_padding)); } /* Hide tabs during active game */ diff --git a/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte index 9452cf25..9de664cf 100644 --- a/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte +++ b/frontend/src/lib/components/hitstar/HitstarSpotifyEmbed.svelte @@ -1,65 +1,205 @@ {#if trackId} -
- - {#if masked} - - {/if} -
+ {#if masked} + +
+
+
+
+ + +
+ {:else} +
+ +
+ {/if} {/if}