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

View file

@ -9,6 +9,7 @@
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte'; import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Get redirect URL from query params or sessionStorage (set by AuthGateModal) // Get redirect URL from query params or sessionStorage (set by AuthGateModal)
@ -66,6 +67,7 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {initialEmail}
version={APP_VERSION}
> >
{#snippet headerControls()} {#snippet headerControls()}
<LanguageSelector /> <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 { authStore } from '$lib/stores/auth.svelte';
import AppSlider from '$lib/components/AppSlider.svelte'; import AppSlider from '$lib/components/AppSlider.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Dev credentials - pre-filled in development mode // Dev credentials - pre-filled in development mode
@ -74,6 +75,7 @@
{verified} {verified}
{initialEmail} {initialEmail}
{initialPassword} {initialPassword}
version={APP_VERSION}
> >
{#snippet headerControls()} {#snippet headerControls()}
<LanguageSelector /> <LanguageSelector />

View file

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

View file

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

View file

@ -3,6 +3,7 @@
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import { GlobalSettingsSection } from '@manacore/shared-ui'; import { GlobalSettingsSection } from '@manacore/shared-ui';
import { APP_VERSION } from '$lib/version';
onMount(async () => { onMount(async () => {
await userSettings.load(); await userSettings.load();
@ -102,4 +103,6 @@
Töne können für einzelne Wecker und Timer in deren Einstellungen angepasst werden. Töne können für einzelne Wecker und Timer in deren Einstellungen angepasst werden.
</p> </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> </div>

View file

@ -7,6 +7,7 @@
import { getLoginTranslations } from '@manacore/shared-i18n'; import { getLoginTranslations } from '@manacore/shared-i18n';
import { ClockLogo } from '@manacore/shared-branding'; import { ClockLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Read verification status from query params (set after email verification) // Read verification status from query params (set after email verification)
@ -64,4 +65,5 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {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 { goto } from '$app/navigation';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import { import {
contactsSettings, contactsSettings,
type ContactView, type ContactView,
@ -638,4 +639,6 @@
{/snippet} {/snippet}
</SettingsDangerButton> </SettingsDangerButton>
</SettingsDangerZone> </SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage> </SettingsPage>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -16,6 +16,7 @@ scores:
security: 70 security: 70
ux: 75 ux: 75
status: 'beta' status: 'beta'
version: '0.2.0'
stats: stats:
backendModules: 5 backendModules: 5
webRoutes: 13 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 { creditsService } from '$lib/api/credits';
import type { CreditBalance } from '$lib/api/credits'; import type { CreditBalance } from '$lib/api/credits';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
let loading = $state(true); let loading = $state(true);
let savingProfile = $state(false); let savingProfile = $state(false);
@ -333,5 +334,7 @@
</div> </div>
</Card> </Card>
</div> </div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
{/if} {/if}
</div> </div>

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { matrixStore } from '$lib/matrix'; import { matrixStore } from '$lib/matrix';
import { goto } from '$app/navigation'; import { goto } from '$app/navigation';
import { APP_VERSION } from '$lib/version';
import { import {
ArrowLeft, ArrowLeft,
User, User,
@ -228,8 +229,8 @@
<button <button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'light' {theme.mode === 'light'
? 'bg-primary text-primary-foreground ring-2 ring-primary' ? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}" : 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('light')} onclick={() => theme.setMode('light')}
> >
<Sun class="h-6 w-6" /> <Sun class="h-6 w-6" />
@ -239,8 +240,8 @@
<button <button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'dark' {theme.mode === 'dark'
? 'bg-primary text-primary-foreground ring-2 ring-primary' ? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}" : 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('dark')} onclick={() => theme.setMode('dark')}
> >
<Moon class="h-6 w-6" /> <Moon class="h-6 w-6" />
@ -250,8 +251,8 @@
<button <button
class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all class="flex flex-col items-center gap-2 rounded-xl p-4 transition-all
{theme.mode === 'system' {theme.mode === 'system'
? 'bg-primary text-primary-foreground ring-2 ring-primary' ? 'bg-primary text-primary-foreground ring-2 ring-primary'
: 'bg-muted hover:bg-muted/80'}" : 'bg-muted hover:bg-muted/80'}"
onclick={() => theme.setMode('system')} onclick={() => theme.setMode('system')}
> >
<Desktop class="h-6 w-6" /> <Desktop class="h-6 w-6" />
@ -309,7 +310,9 @@
{:else} {:else}
<div class="space-y-3"> <div class="space-y-3">
<!-- Enable/Disable Toggle --> <!-- 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"> <div class="flex items-center gap-3">
<BellRinging class="h-6 w-6" /> <BellRinging class="h-6 w-6" />
<div> <div>
@ -330,7 +333,9 @@
<!-- Sound Toggle --> <!-- Sound Toggle -->
<label <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"> <div class="flex items-center gap-3">
<SpeakerHigh class="h-6 w-6" /> <SpeakerHigh class="h-6 w-6" />
@ -350,7 +355,9 @@
<!-- Preview Toggle --> <!-- Preview Toggle -->
<label <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"> <div class="flex items-center gap-3">
<Eye class="h-6 w-6" /> <Eye class="h-6 w-6" />
@ -386,6 +393,8 @@
</button> </button>
</section> </section>
</div> </div>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</div> </div>
</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 { JwtAuthGuard, CurrentUser, CurrentUserData } from '@manacore/shared-nestjs-auth';
import { LibraryService } from './library.service'; import { LibraryService } from './library.service';
@ -7,6 +7,12 @@ import { LibraryService } from './library.service';
export class LibraryController { export class LibraryController {
constructor(private readonly libraryService: LibraryService) {} 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') @Get('albums')
async getAlbums(@CurrentUser() user: CurrentUserData) { async getAlbums(@CurrentUser() user: CurrentUserData) {
const albums = await this.libraryService.getAlbums(user.userId); 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_CONNECTION } from '../db/database.module';
import { Database } from '../db/connection'; import { Database } from '../db/connection';
import { songs } from '../db/schema'; import { songs } from '../db/schema';
import { createMukkeStorage, type StorageClient } from '@manacore/shared-storage';
@Injectable() @Injectable()
export class LibraryService { 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) { async getAlbums(userId: string) {
const result = await this.db.execute<{ const result = await this.db.execute<{
@ -93,4 +98,26 @@ export class LibraryService {
.where(and(eq(songs.userId, userId), eq(songs.genre, genreName))) .where(and(eq(songs.userId, userId), eq(songs.genre, genreName)))
.orderBy(asc(songs.title)); .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[]; artists: Artist[];
genres: Genre[]; genres: Genre[];
stats: LibraryStats | null; stats: LibraryStats | null;
coverUrls: Record<string, string>;
activeTab: 'songs' | 'albums' | 'artists' | 'genres'; activeTab: 'songs' | 'albums' | 'artists' | 'genres';
sortField: SortField; sortField: SortField;
sortDirection: SortDirection; sortDirection: SortDirection;
@ -40,6 +41,7 @@ function createLibraryStore() {
artists: [], artists: [],
genres: [], genres: [],
stats: null, stats: null,
coverUrls: {},
activeTab: 'songs', activeTab: 'songs',
sortField: 'addedAt' as SortField, sortField: 'addedAt' as SortField,
sortDirection: 'desc' as SortDirection, sortDirection: 'desc' as SortDirection,
@ -94,10 +96,27 @@ function createLibraryStore() {
get isLoading() { get isLoading() {
return state.isLoading; return state.isLoading;
}, },
get coverUrls() {
return state.coverUrls;
},
get error() { get error() {
return state.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() { async loadSongs() {
state.isLoading = true; state.isLoading = true;
state.error = null; state.error = null;
@ -106,6 +125,8 @@ function createLibraryStore() {
`/songs?sort=${state.sortField}&direction=${state.sortDirection}` `/songs?sort=${state.sortField}&direction=${state.sortDirection}`
); );
state.songs = data.songs; 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) { } catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load songs'; state.error = e instanceof Error ? e.message : 'Failed to load songs';
} }
@ -118,6 +139,8 @@ function createLibraryStore() {
try { try {
const data = await fetchApi<{ albums: Album[] }>('/library/albums'); const data = await fetchApi<{ albums: Album[] }>('/library/albums');
state.albums = data.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) { } catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load albums'; state.error = e instanceof Error ? e.message : 'Failed to load albums';
} }

View file

@ -4,6 +4,7 @@ import { authStore } from './auth.svelte';
interface PlaylistState { interface PlaylistState {
playlists: Playlist[]; playlists: Playlist[];
currentPlaylist: PlaylistWithSongs | null; currentPlaylist: PlaylistWithSongs | null;
coverUrls: Record<string, string>;
isLoading: boolean; isLoading: boolean;
error: string | null; error: string | null;
} }
@ -23,6 +24,7 @@ function createPlaylistStore() {
let state = $state<PlaylistState>({ let state = $state<PlaylistState>({
playlists: [], playlists: [],
currentPlaylist: null, currentPlaylist: null,
coverUrls: {},
isLoading: false, isLoading: false,
error: null, error: null,
}); });
@ -56,16 +58,37 @@ function createPlaylistStore() {
get isLoading() { get isLoading() {
return state.isLoading; return state.isLoading;
}, },
get coverUrls() {
return state.coverUrls;
},
get error() { get error() {
return state.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() { async loadPlaylists() {
state.isLoading = true; state.isLoading = true;
state.error = null; state.error = null;
try { try {
const data = await fetchApi<{ playlists: Playlist[] }>('/playlists'); const data = await fetchApi<{ playlists: Playlist[] }>('/playlists');
state.playlists = data.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) { } catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlists'; state.error = e instanceof Error ? e.message : 'Failed to load playlists';
} }
@ -78,6 +101,10 @@ function createPlaylistStore() {
try { try {
const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`); const data = await fetchApi<{ playlist: PlaylistWithSongs }>(`/playlists/${id}`);
state.currentPlaylist = data.playlist; 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) { } catch (e) {
state.error = e instanceof Error ? e.message : 'Failed to load playlist'; 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"> <div class="bg-surface rounded-lg overflow-hidden">
<!-- Header --> <!-- Header -->
<div <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>Title</span>
<span>Artist</span> <span>Artist</span>
<span>Album</span> <span>Album</span>
@ -149,8 +150,33 @@
<!-- Song rows --> <!-- Song rows -->
{#each libraryStore.songs as song} {#each libraryStore.songs as song}
<div <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 font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span> <span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? '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" class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group"
> >
<div <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 {#if album.coverArtPath && libraryStore.coverUrls[album.coverArtPath]}
class="w-12 h-12 text-foreground-secondary" <img
fill="none" src={libraryStore.coverUrls[album.coverArtPath]}
stroke="currentColor" alt={album.album}
viewBox="0 0 24 24" class="w-full h-full object-cover"
>
<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> {: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> </div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors"> <h3 class="font-medium truncate group-hover:text-primary transition-colors">
{album.album} {album.album}

View file

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

View file

@ -99,20 +99,30 @@
href="/playlists/{playlist.id}" href="/playlists/{playlist.id}"
class="bg-surface rounded-lg p-4 hover:bg-surface-hover transition-colors group relative" 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"> <div
<svg class="aspect-square bg-background rounded-lg mb-3 flex items-center justify-center overflow-hidden"
class="w-12 h-12 text-foreground-secondary" >
fill="none" {#if playlist.coverArtPath && playlistStore.coverUrls[playlist.coverArtPath]}
stroke="currentColor" <img
viewBox="0 0 24 24" src={playlistStore.coverUrls[playlist.coverArtPath]}
> alt={playlist.name}
<path class="w-full h-full object-cover"
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> {: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> </div>
<h3 class="font-medium truncate group-hover:text-primary transition-colors"> <h3 class="font-medium truncate group-hover:text-primary transition-colors">
{playlist.name} {playlist.name}

View file

@ -205,8 +205,9 @@
<div class="bg-surface rounded-lg overflow-hidden"> <div class="bg-surface rounded-lg overflow-hidden">
<!-- Header --> <!-- Header -->
<div <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>Title</span>
<span>Artist</span> <span>Artist</span>
<span>Album</span> <span>Album</span>
@ -216,7 +217,7 @@
<!-- Song rows --> <!-- Song rows -->
{#each playlistStore.currentPlaylist.songs as song, index} {#each playlistStore.currentPlaylist.songs as song, index}
<div <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)} onclick={() => handlePlaySong(song, index)}
role="button" role="button"
tabindex="0" tabindex="0"
@ -224,6 +225,31 @@
if (e.key === 'Enter') handlePlaySong(song, index); 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 font-medium">{song.title}</span>
<span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span> <span class="truncate text-foreground-secondary">{song.artist ?? 'Unknown'}</span>
<span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span> <span class="truncate text-foreground-secondary">{song.album ?? 'Unknown'}</span>

View file

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

View file

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

View file

@ -4,6 +4,7 @@
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client'; import { apiClient } from '$lib/api/client';
import { DEFAULT_DAILY_VALUES } from '@nutriphi/shared'; import { DEFAULT_DAILY_VALUES } from '@nutriphi/shared';
import { APP_VERSION } from '$lib/version';
import { import {
ArrowLeft, ArrowLeft,
FloppyDisk, FloppyDisk,
@ -278,8 +279,10 @@
<!-- App Info --> <!-- App Info -->
<section class="text-center text-sm text-[var(--color-text-muted)] py-4"> <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> <p class="mt-1">KI-gestützte Ernährungsanalyse</p>
</section> </section>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</main> </main>
</div> </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 { theme } from '$lib/stores/theme';
import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n'; import { setLocale, supportedLocales, type SupportedLocale } from '$lib/i18n';
import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme'; import { THEME_DEFINITIONS, DEFAULT_THEME_VARIANTS } from '@manacore/shared-theme';
import { APP_VERSION } from '$lib/version';
let selectedLocale = $state<SupportedLocale>('de'); let selectedLocale = $state<SupportedLocale>('de');
@ -135,6 +136,8 @@
</div> </div>
</div> </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> </div>
<style> <style>

View file

@ -6,6 +6,7 @@
import { LoginPage } from '@manacore/shared-auth-ui'; import { LoginPage } from '@manacore/shared-auth-ui';
import { getLoginTranslations } from '@manacore/shared-i18n'; import { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
let redirectTo = $state('/'); let redirectTo = $state('/');
@ -53,4 +54,5 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {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"> <script lang="ts">
import { onMount } from 'svelte'; import { onMount } from 'svelte';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import { import {
SettingsPage, SettingsPage,
SettingsSection, SettingsSection,
@ -41,4 +42,6 @@
</SettingsRow> </SettingsRow>
</SettingsCard> </SettingsCard>
</SettingsSection> </SettingsSection>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage> </SettingsPage>

View file

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

View file

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

View file

@ -1,6 +1,7 @@
<script lang="ts"> <script lang="ts">
import { theme } from '$lib/stores/theme'; import { theme } from '$lib/stores/theme';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
let isDark = $derived(theme.isDark); let isDark = $derived(theme.isDark);
</script> </script>
@ -54,6 +55,8 @@
Planta hilft dir, deine Pflanzen zu dokumentieren und zu pflegen. Mache ein Foto und die KI 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. erstellt automatisch einen Steckbrief mit Pflegehinweisen und Gießvorschlägen.
</p> </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> </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

@ -7,6 +7,7 @@
import { locale } from 'svelte-i18n'; import { locale } from 'svelte-i18n';
import { PlantaLogo } from '@manacore/shared-branding'; import { PlantaLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Get redirect URL from query params or sessionStorage // Get redirect URL from query params or sessionStorage
@ -62,4 +63,5 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {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 { auth } from '$lib/stores/auth.svelte';
import { theme } from '$lib/stores/theme'; import { theme } from '$lib/stores/theme';
import { userSettings } from '$lib/stores/user-settings.svelte'; import { userSettings } from '$lib/stores/user-settings.svelte';
import { APP_VERSION } from '$lib/version';
import { import {
SettingsPage, SettingsPage,
SettingsSection, SettingsSection,
@ -191,4 +192,6 @@
{/snippet} {/snippet}
</SettingsDangerButton> </SettingsDangerButton>
</SettingsDangerZone> </SettingsDangerZone>
<p class="mt-8 pb-4 text-center text-xs text-gray-400 dark:text-gray-600">v{APP_VERSION}</p>
</SettingsPage> </SettingsPage>

View file

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

View file

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

View file

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

@ -8,6 +8,7 @@
import { QuestionsLogo } from '@manacore/shared-branding'; import { QuestionsLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client'; import { apiClient } from '$lib/api/client';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Get redirect URL from query params or sessionStorage // Get redirect URL from query params or sessionStorage
@ -68,4 +69,5 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {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 { SkillTreeLogo } from '@manacore/shared-branding';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import { apiClient } from '$lib/api/client'; import { apiClient } from '$lib/api/client';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
// Get redirect URL from query params or sessionStorage // Get redirect URL from query params or sessionStorage
@ -68,4 +69,5 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {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 { getLoginTranslations } from '@manacore/shared-i18n';
import { authStore } from '$lib/stores/auth.svelte'; import { authStore } from '$lib/stores/auth.svelte';
import LanguageSelector from '$lib/components/LanguageSelector.svelte'; import LanguageSelector from '$lib/components/LanguageSelector.svelte';
import { APP_VERSION } from '$lib/version';
import '$lib/i18n'; import '$lib/i18n';
const translations = $derived(getLoginTranslations($locale || 'de')); const translations = $derived(getLoginTranslations($locale || 'de'));
@ -43,6 +44,7 @@
{translations} {translations}
{verified} {verified}
{initialEmail} {initialEmail}
version={APP_VERSION}
> >
{#snippet headerControls()} {#snippet headerControls()}
<LanguageSelector /> <LanguageSelector />

View file

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

View file

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

View file

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

View file

@ -2,6 +2,7 @@
import { _ } from 'svelte-i18n'; import { _ } from 'svelte-i18n';
import { quotesStore } from '$lib/stores/quotes.svelte'; import { quotesStore } from '$lib/stores/quotes.svelte';
import type { SupportedLanguage } from '@zitare/content'; import type { SupportedLanguage } from '@zitare/content';
import { APP_VERSION } from '$lib/version';
// Language options for quotes // Language options for quotes
const languageOptions: { value: SupportedLanguage; label: string }[] = [ const languageOptions: { value: SupportedLanguage; label: string }[] = [
@ -56,4 +57,6 @@
</p> </p>
</div> </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> </div>

View file

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

View file

@ -91,6 +91,8 @@
initialEmail?: string; initialEmail?: string;
/** Pre-fill password field (for dev mode) */ /** Pre-fill password field (for dev mode) */
initialPassword?: string; initialPassword?: string;
/** App version string to display */
version?: string;
} }
let { let {
@ -115,6 +117,7 @@
verified = false, verified = false,
initialEmail = '', initialEmail = '',
initialPassword = '', initialPassword = '',
version = '',
}: Props = $props(); }: Props = $props();
const t = $derived({ ...defaultTranslations, ...translations }); const t = $derived({ ...defaultTranslations, ...translations });
@ -542,6 +545,10 @@
{@render appSlider()} {@render appSlider()}
</footer> </footer>
{/if} {/if}
{#if version}
<p class="version-label">v{version}</p>
{/if}
</div> </div>
<style> <style>
@ -1012,6 +1019,21 @@
padding: 0 0 1rem; 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 */ /* Entrance Animations */
@keyframes fadeInUp { @keyframes fadeInUp {
from { from {