feat(mukke): display album cover art in library, playlists, and song lists

Add batch cover-url endpoint (POST /library/cover-urls) to efficiently
resolve multiple cover art presigned URLs in a single request. Integrate
cover art display across all UI surfaces: album grid, album detail header,
song list thumbnails, playlist grid, and playlist detail song list.

Co-Authored-By: Claude Opus 4.6 (1M context) <noreply@anthropic.com>
This commit is contained in:
Till JS 2026-03-19 20:59:45 +01:00
parent 789ce0a435
commit e848fa5342
81 changed files with 376 additions and 58 deletions

View file

@ -0,0 +1 @@
export const APP_VERSION = '1.0.0';

View file

@ -14,6 +14,7 @@
FilterDropdown,
type FilterDropdownOption,
} from '@manacore/shared-ui';
import { APP_VERSION } from '$lib/version';
import type { CalendarViewType, Calendar } from '@calendar/shared';
// Calendar management state
@ -651,6 +652,8 @@
</div>
</SettingsCard>
</SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>
<style>

View file

@ -9,6 +9,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage (set by AuthGateModal)
@ -66,6 +67,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.3.0';

View file

@ -9,6 +9,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Dev credentials - pre-filled in development mode
@ -74,6 +75,7 @@
{verified}
{initialEmail}
{initialPassword}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -171,4 +172,6 @@
<a href="#" class="text-[hsl(var(--primary))] hover:underline">Hilfe & Support</a>
</div>
</SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -3,6 +3,7 @@
import { _ } from 'svelte-i18n';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { GlobalSettingsSection } from '@manacore/shared-ui';
import { APP_VERSION } from '$lib/version';
onMount(async () => {
await userSettings.load();
@ -102,4 +103,6 @@
Töne können für einzelne Wecker und Timer in deren Einstellungen angepasst werden.
</p>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>

View file

@ -7,6 +7,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Read verification status from query params (set after email verification)
@ -64,4 +65,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '1.0.0';

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
contactsSettings,
type ContactView,
@ -638,4 +639,6 @@
{/snippet}
</SettingsDangerButton>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -9,6 +9,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -63,6 +64,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -16,6 +16,7 @@ scores:
security: 82
ux: 88
status: 'production'
version: '1.0.0'
stats:
backendModules: 13
webRoutes: 19

View file

@ -16,6 +16,7 @@ scores:
security: 82
ux: 80
status: 'production'
version: '0.3.0'
stats:
backendModules: 9
webRoutes: 24

View file

@ -16,6 +16,7 @@ scores:
security: 60
ux: 55
status: 'beta'
version: '0.2.0'
stats:
backendModules: 7
webRoutes: 17

View file

@ -16,6 +16,7 @@ scores:
security: 85
ux: 85
status: 'production'
version: '1.0.0'
stats:
backendModules: 14
webRoutes: 20

View file

@ -16,6 +16,7 @@ scores:
security: 68
ux: 65
status: 'beta'
version: '0.1.0'
stats:
backendModules: 5
webRoutes: 15

View file

@ -16,6 +16,7 @@ scores:
security: 72
ux: 75
status: 'beta'
version: '0.2.0'
stats:
backendModules: 0
webRoutes: 33

View file

@ -16,6 +16,7 @@ scores:
security: 55
ux: 68
status: 'alpha'
version: '0.2.0'
stats:
backendModules: 2
webRoutes: 19

View file

@ -16,6 +16,7 @@ scores:
security: 88
ux: 82
status: 'production'
version: '0.2.0'
stats:
backendModules: 0
webRoutes: 7

View file

@ -16,6 +16,7 @@ scores:
security: 78
ux: 60
status: 'beta'
version: '0.2.0'
stats:
backendModules: 11
webRoutes: 16

View file

@ -16,6 +16,7 @@ scores:
security: 68
ux: 55
status: 'beta'
version: '0.2.0'
stats:
backendModules: 8
webRoutes: 10

View file

@ -16,6 +16,7 @@ scores:
security: 65
ux: 55
status: 'beta'
version: '0.2.0'
stats:
backendModules: 7
webRoutes: 12

View file

@ -16,6 +16,7 @@ scores:
security: 80
ux: 78
status: 'production'
version: '0.3.0'
stats:
backendModules: 11
webRoutes: 19

View file

@ -16,6 +16,7 @@ scores:
security: 55
ux: 50
status: 'alpha'
version: '0.1.0'
stats:
backendModules: 6
webRoutes: 12

View file

@ -16,6 +16,7 @@ scores:
security: 55
ux: 68
status: 'beta'
version: '0.2.0'
stats:
backendModules: 7
webRoutes: 16

View file

@ -16,6 +16,7 @@ scores:
security: 55
ux: 55
status: 'alpha'
version: '0.1.0'
stats:
backendModules: 8
webRoutes: 12

View file

@ -16,6 +16,7 @@ scores:
security: 65
ux: 72
status: 'beta'
version: '0.2.0'
stats:
backendModules: 4
webRoutes: 6

View file

@ -16,6 +16,7 @@ scores:
security: 72
ux: 55
status: 'beta'
version: '0.2.0'
stats:
backendModules: 10
webRoutes: 17

View file

@ -16,6 +16,7 @@ scores:
security: 82
ux: 85
status: 'production'
version: '1.0.0'
stats:
backendModules: 7
webRoutes: 13

View file

@ -16,6 +16,7 @@ scores:
security: 55
ux: 35
status: 'alpha'
version: '0.0.1'
stats:
backendModules: 7
webRoutes: 0

View file

@ -16,6 +16,7 @@ scores:
security: 70
ux: 75
status: 'beta'
version: '0.2.0'
stats:
backendModules: 5
webRoutes: 13

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -5,6 +5,7 @@
import { creditsService } from '$lib/api/credits';
import type { CreditBalance } from '$lib/api/credits';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
let loading = $state(true);
let savingProfile = $state(false);
@ -333,5 +334,7 @@
</div>
</Card>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
{/if}
</div>

View file

@ -8,6 +8,7 @@
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
@ -42,6 +43,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -41,4 +42,6 @@
</SettingsRow>
</SettingsCard>
</SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -8,6 +8,7 @@
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
// Get translations based on current locale
const translations = $derived(getLoginTranslations($locale || 'de'));
@ -42,6 +43,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { matrixStore } from '$lib/matrix';
import { goto } from '$app/navigation';
import { APP_VERSION } from '$lib/version';
import {
ArrowLeft,
User,
@ -228,8 +229,8 @@
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'light'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('light')}
>
<Sun class="h-6 w-6" />
@ -239,8 +240,8 @@
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'dark'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('dark')}
>
<Moon class="h-6 w-6" />
@ -250,8 +251,8 @@
<button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'system'
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('system')}
>
<Desktop class="h-6 w-6" />
@ -309,7 +310,9 @@
{:else}
<div class="space-y-3">
<!-- Enable/Disable Toggle -->
<label class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer">
<label
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer"
>
<div class="flex items-center gap-3">
<BellRinging class="h-6 w-6" />
<div>
@ -330,7 +333,9 @@
<!-- Sound Toggle -->
<label
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled ? 'opacity-50' : ''}"
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled
? 'opacity-50'
: ''}"
>
<div class="flex items-center gap-3">
<SpeakerHigh class="h-6 w-6" />
@ -350,7 +355,9 @@
<!-- Preview Toggle -->
<label
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled ? 'opacity-50' : ''}"
class="flex items-center justify-between rounded-lg bg-muted p-4 cursor-pointer {!notificationSettings.enabled
? 'opacity-50'
: ''}"
>
<div class="flex items-center gap-3">
<Eye class="h-6 w-6" />
@ -386,6 +393,8 @@
</button>
</section>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>
</div>

View file

@ -1,4 +1,4 @@
import { Controller, Get, Param, UseGuards } from '@nestjs/common';
import { Controller, Get, Post, Param, Body, UseGuards } from '@nestjs/common';
import { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LibraryService } from './library.service';
@ -7,6 +7,12 @@ import { LibraryService } from './library.service';
export class LibraryController {
constructor(private readonly libraryService: LibraryService) {}
@Post('cover-urls')
async getCoverUrls(@Body() body: { paths: string[] }) {
const urls = await this.libraryService.getCoverUrls(body.paths ?? []);
return { urls };
}
@Get('albums')
async getAlbums(@CurrentUser() user: CurrentUserData) {
const albums = await this.libraryService.getAlbums(user.userId);

View file

@ -3,10 +3,15 @@ import { eq, and, asc, sql } from 'drizzle-orm';
import { DATABASE_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection';
import { songs } from '../db/schema';
import { createMukkeStorage, type StorageClient } from '@manacore/shared-storage';
@Injectable()
export class LibraryService {
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {}
private storage: StorageClient;
constructor(@Inject(DATABASE_CONNECTION) private db: Database) {
this.storage = createMukkeStorage();
}
async getAlbums(userId: string) {
const result = await this.db.execute<{
@ -93,4 +98,26 @@ export class LibraryService {
.where(and(eq(songs.userId, userId), eq(songs.genre, genreName)))
.orderBy(asc(songs.title));
}
async getCoverUrls(paths: string[]): Promise<Record<string, string>> {
const uniquePaths = [...new Set(paths.filter(Boolean))];
if (uniquePaths.length === 0) return {};
const entries = await Promise.all(
uniquePaths.map(async (path) => {
try {
const url = await this.storage.getDownloadUrl(path, { expiresIn: 3600 });
return [path, url] as const;
} catch {
return null;
}
})
);
const result: Record<string, string> = {};
for (const entry of entries) {
if (entry) result[entry[0]] = entry[1];
}
return result;
}
}

View file

@ -15,6 +15,7 @@ interface LibraryState {
artists: Artist[];
genres: Genre[];
stats: LibraryStats | null;
coverUrls: Record<string, string>;
activeTab: 'songs' | 'albums' | 'artists' | 'genres';
sortField: SortField;
sortDirection: SortDirection;
@ -40,6 +41,7 @@ function createLibraryStore() {
artists: [],
genres: [],
stats: null,
coverUrls: {},
activeTab: 'songs',
sortField: 'addedAt' as SortField,
sortDirection: 'desc' as SortDirection,
@ -94,10 +96,27 @@ function createLibraryStore() {
get isLoading() {
return state.isLoading;
},
get coverUrls() {
return state.coverUrls;
},
get error() {
return state.error;
},
async loadCoverUrls(paths: string[]) {
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
if (uncached.length === 0) return;
try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
method: 'POST',
body: JSON.stringify({ paths: uncached }),
});
state.coverUrls = { ...state.coverUrls, ...data.urls };
} catch {
// Cover URLs are non-critical, don't set error
}
},
async loadSongs() {
state.isLoading = true;
state.error = null;
@ -106,6 +125,8 @@ function createLibraryStore() {
`/songs?sort=${state.sortField}&direction=${state.sortDirection}`
);
state.songs = data.songs;
const coverPaths = data.songs.map((s) => s.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load songs';
}
@ -118,6 +139,8 @@ function createLibraryStore() {
try {
const data = await fetchApi<{ albums: Album[] }>('/library/albums');
state.albums = data.albums;
const coverPaths = data.albums.map((a) => a.coverArtPath).filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load albums';
}

View file

@ -4,6 +4,7 @@ import { authStore } from './auth.svelte';
interface PlaylistState {
playlists: Playlist[];
currentPlaylist: PlaylistWithSongs | null;
coverUrls: Record<string, string>;
isLoading: boolean;
error: string | null;
}
@ -23,6 +24,7 @@ function createPlaylistStore() {
let state = $state<PlaylistState>({
playlists: [],
currentPlaylist: null,
coverUrls: {},
isLoading: false,
error: null,
});
@ -56,16 +58,37 @@ function createPlaylistStore() {
get isLoading() {
return state.isLoading;
},
get coverUrls() {
return state.coverUrls;
},
get error() {
return state.error;
},
async loadCoverUrls(paths: string[]) {
const uncached = paths.filter((p) => p && !state.coverUrls[p]);
if (uncached.length === 0) return;
try {
const data = await fetchApi<{ urls: Record<string, string> }>('/library/cover-urls', {
method: 'POST',
body: JSON.stringify({ paths: uncached }),
});
state.coverUrls = { ...state.coverUrls, ...data.urls };
} catch {
// Cover URLs are non-critical
}
},
async loadPlaylists() {
state.isLoading = true;
state.error = null;
try {
const data = await fetchApi<{ playlists: Playlist[] }>('/playlists');
state.playlists = data.playlists;
const coverPaths = data.playlists
.map((p) => p.coverArtPath)
.filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlists';
}
@ -78,6 +101,10 @@ function createPlaylistStore() {
try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`);
state.currentPlaylist = data.playlist;
const coverPaths = data.playlist.songs
.map((s) => s.coverArtPath)
.filter((p): p is string => !!p);
if (coverPaths.length > 0) this.loadCoverUrls(coverPaths);
} catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlist';
}

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -136,8 +136,9 @@
<div class="bg-surface rounded-lg overflow-hidden">
<!-- Header -->
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
>
<span></span>
<span>Title</span>
<span>Artist</span>
<span>Album</span>
@ -149,8 +150,33 @@
<!-- Song rows -->
{#each libraryStore.songs as song}
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px_40px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center"
>
<div
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
>
{#if song.coverArtPath && libraryStore.coverUrls[song.coverArtPath]}
<img
src={libraryStore.coverUrls[song.coverArtPath]}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<svg
class="w-5 h-5 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<span class="truncate font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>
@ -226,21 +252,29 @@
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group"
>
<div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center"
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
>
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
{#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath]}
<img
src={libraryStore.coverUrls[album.coverArtPath]}
alt={album.album}
class="w-full h-full object-cover"
/>
</svg>
{:else}
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
{album.album}

View file

@ -29,6 +29,7 @@
let songs = $state<Song[]>([]);
let isLoading = $state(true);
let error = $state<string | null>(null);
let coverUrl = $state<string | null>(null);
let albumName = $derived(decodeURIComponent($page.params.name ?? ''));
let albumArtist = $derived(
@ -42,10 +43,17 @@
isLoading = true;
error = null;
coverUrl = null;
fetchApi<{ songs: Song[] }>(`/library/albums/${encodeURIComponent(decodeURIComponent(name))}`)
.then((data) => {
songs = data.songs;
const songWithCover = data.songs.find((s) => s.coverArtPath);
if (songWithCover) {
fetchApi<{ url: string | null }>(`/songs/${songWithCover.id}/cover-url`).then((res) => {
coverUrl = res.url;
});
}
})
.catch((e) => {
error = e instanceof Error ? e.message : 'Failed to load album';
@ -100,20 +108,26 @@
{:else}
<!-- Album header -->
<div class="flex items-end gap-6 mb-8">
<div class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0">
<svg
class="w-16 h-16 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
<div
class="w-48 h-48 bg-surface rounded-lg flex items-center justify-center flex-shrink-0 overflow-hidden"
>
{#if coverUrl}
<img src={coverUrl} alt={albumName} class="w-full h-full object-cover" />
{:else}
<svg
class="w-16 h-16 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<div>
<h1 class="text-3xl font-bold mb-1">{albumName}</h1>

View file

@ -99,20 +99,30 @@
href="/playlists/{playlist.id}"
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group relative"
>
<div class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center">
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
<div
class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
>
{#if playlist.coverArtPath && playlistStore.coverUrls[playlist.coverArtPath]}
<img
src={playlistStore.coverUrls[playlist.coverArtPath]}
alt={playlist.name}
class="w-full h-full object-cover"
/>
</svg>
{:else}
<svg
class="w-12 h-12 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors">
{playlist.name}

View file

@ -205,8 +205,9 @@
<div class="bg-surface rounded-lg overflow-hidden">
<!-- Header -->
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 text-xs font-medium text-foreground-secondary uppercase tracking-wide border-b border-border"
>
<span></span>
<span>Title</span>
<span>Artist</span>
<span>Album</span>
@ -216,7 +217,7 @@
<!-- Song rows -->
{#each playlistStore.currentPlaylist.songs as song, index}
<div
class="grid grid-cols-[1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
class="grid grid-cols-[40px_1fr_1fr_1fr_80px_40px] gap-4 px-4 py-3 hover:bg-background transition-colors items-center cursor-pointer"
onclick={() => handlePlaySong(song, index)}
role="button"
tabindex="0"
@ -224,6 +225,31 @@
if (e.key === 'Enter') handlePlaySong(song, index);
}}
>
<div
class="w-10 h-10 rounded bg-background flex items-center justify-center overflow-hidden flex-shrink-0"
>
{#if song.coverArtPath && playlistStore.coverUrls[song.coverArtPath]}
<img
src={playlistStore.coverUrls[song.coverArtPath]}
alt=""
class="w-full h-full object-cover"
/>
{:else}
<svg
class="w-5 h-5 text-foreground-secondary"
fill="none"
stroke="currentColor"
viewBox="0 0 24 24"
>
<path
stroke-linecap="round"
stroke-linejoin="round"
stroke-width="2"
d="M9 19V6l12-3v13M9 19c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zm12-3c0 1.105-1.343 2-3 2s-3-.895-3-2 1.343-2 3-2 3 .895 3 2zM9 10l12-3"
/>
</svg>
{/if}
</div>
<span class="truncate font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -60,4 +61,6 @@
onclick={handleLogout}
/>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -6,6 +6,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n';
import { MukkeLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
// Get redirect URL from query params or sessionStorage
const redirectTo = $derived.by(() => {
@ -60,4 +61,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -7,6 +7,7 @@
import { locale } from 'svelte-i18n';
import { NutriPhiLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -62,4 +63,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -4,6 +4,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
import { DEFAULT_DAILY_VALUES } from '@nutriphi/shared';
import { APP_VERSION } from '$lib/version';
import {
ArrowLeft,
FloppyDisk,
@ -278,8 +279,10 @@
<!-- App Info -->
<section class="text-center text-sm text-[var(--color-text-muted)] py-4">
<p>NutriPhi v1.0.0</p>
<p>NutriPhi v{APP_VERSION}</p>
<p class="mt-1">KI-gestützte Ernährungsanalyse</p>
</section>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</main>
</div>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -3,6 +3,7 @@
import { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import { APP_VERSION } from '$lib/version';
let selectedLocale = $state<SupportedLocale>('de');
@ -135,6 +136,8 @@
</div>
</div>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>
<style>

View file

@ -6,6 +6,7 @@
import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
let redirectTo = $state('/');
@ -53,4 +54,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.3.0';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { onMount } from 'svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -41,4 +42,6 @@
</SettingsRow>
</SettingsCard>
</SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -8,6 +8,7 @@
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import { onMount } from 'svelte';
import { PUBLIC_GOOGLE_CLIENT_ID, PUBLIC_APPLE_CLIENT_ID } from '$env/static/public';
@ -66,6 +67,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.1.0';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
let isDark = $derived(theme.isDark);
</script>
@ -54,6 +55,8 @@
Planta hilft dir, deine Pflanzen zu dokumentieren und zu pflegen. Mache ein Foto und die KI
erstellt automatisch einen Steckbrief mit Pflegehinweisen und Gießvorschlägen.
</p>
<p class="text-sm text-muted-foreground mt-2">Version 1.0.0</p>
<p class="text-sm text-muted-foreground mt-2">Version {APP_VERSION}</p>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>

View file

@ -7,6 +7,7 @@
import { locale } from 'svelte-i18n';
import { PlantaLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -62,4 +63,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -4,6 +4,7 @@
import { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -191,4 +192,6 @@
{/snippet}
</SettingsDangerButton>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -8,6 +8,7 @@
import { auth } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params
@ -50,6 +51,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.1.0';

View file

@ -1,6 +1,7 @@
<script lang="ts">
import { authStore } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme';
import { APP_VERSION } from '$lib/version';
import { ArrowLeft, User, Moon, Sun, Desktop, Bell, Shield, Trash } from '@manacore/shared-icons';
let currentTheme = $state(theme.current);
@ -169,10 +170,12 @@
<section class="mb-8">
<div class="rounded-xl border border-border bg-card p-6 text-center">
<p class="text-sm text-muted-foreground">
Questions App v1.0.0
Questions App v{APP_VERSION}
<br />
Powered by mana-search
</p>
</div>
</section>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>

View file

@ -8,6 +8,7 @@
import { QuestionsLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -68,4 +69,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -8,6 +8,7 @@
import { SkillTreeLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -68,4 +69,5 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
/>

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -7,6 +7,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
const translations = $derived(getLoginTranslations($locale || 'de'));
@ -43,6 +44,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -3,6 +3,7 @@
import { theme } from '$lib/stores/theme.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { THEME_DEFINITIONS } from '@manacore/shared-theme';
import { APP_VERSION } from '$lib/version';
import {
SettingsPage,
SettingsSection,
@ -121,4 +122,6 @@
</SettingsRow>
</SettingsCard>
</SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -0,0 +1 @@
export const APP_VERSION = '1.0.0';

View file

@ -3,6 +3,7 @@
import { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import { todoSettings, type TodoView, type KanbanCardSize } from '$lib/stores/settings.svelte';
import { projectsStore } from '$lib/stores/projects.svelte';
import type { TaskPriority } from '@todo/shared';
@ -698,4 +699,6 @@
{/snippet}
</SettingsDangerButton>
</SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage>

View file

@ -9,6 +9,7 @@
import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
// Get redirect URL from query params or sessionStorage (set by AuthGateModal)
const redirectTo = $derived.by(() => {
@ -65,6 +66,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -0,0 +1 @@
export const APP_VERSION = '0.2.0';

View file

@ -2,6 +2,7 @@
import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/stores/quotes.svelte';
import type { SupportedLanguage } from '@zitare/content';
import { APP_VERSION } from '$lib/version';
// Language options for quotes
const languageOptions: { value: SupportedLanguage; label: string }[] = [
@ -56,4 +57,6 @@
</p>
</div>
</div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div>

View file

@ -8,6 +8,7 @@
import { ZitareLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n';
// Get redirect URL from query params or sessionStorage
@ -63,6 +64,7 @@
{translations}
{verified}
{initialEmail}
version={APP_VERSION}
>
{#snippet headerControls()}
<LanguageSelector />

View file

@ -91,6 +91,8 @@
initialEmail?: string;
/** Pre-fill password field (for dev mode) */
initialPassword?: string;
/** App version string to display */
version?: string;
}
let {
@ -115,6 +117,7 @@
verified = false,
initialEmail = '',
initialPassword = '',
version = '',
}: Props = $props();
const t = $derived({ ...defaultTranslations, ...translations });
@ -542,6 +545,10 @@
{@render appSlider()}
</footer>
{/if}
{#if version}
<p class="version-label">v{version}</p>
{/if}
</div>
<style>
@ -1012,6 +1019,21 @@
padding: 0 0 1rem;
}
.version-label {
position: fixed;
bottom: 0.5rem;
right: 0.75rem;
font-size: 10px;
color: rgba(156, 163, 175, 0.6);
user-select: none;
pointer-events: none;
margin: 0;
}
.light .version-label {
color: rgba(156, 163, 175, 0.6);
}
/* Entrance Animations */
@keyframes fadeInUp {
from {