From 2d804e20bb3d1d3d67e073968b87c9ccc79862ad Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 1 Jun 2026 21:34:17 +0200 Subject: [PATCH 1/3] Add startup loading screen --- docker-compose.dev.yml | 2 -- docker-compose.yml | 2 -- frontend/src/lib/i18n/locales/de.json | 4 +++- frontend/src/lib/i18n/locales/en.json | 4 +++- frontend/src/routes/+layout.svelte | 34 ++++++++++++++++++++++++++- 5 files changed, 39 insertions(+), 7 deletions(-) diff --git a/docker-compose.dev.yml b/docker-compose.dev.yml index 76e205b..7530043 100644 --- a/docker-compose.dev.yml +++ b/docker-compose.dev.yml @@ -25,6 +25,4 @@ services: GIT_SHA: ${GIT_SHA:-unknown} ports: - "8001:80" - depends_on: - - backend restart: unless-stopped diff --git a/docker-compose.yml b/docker-compose.yml index 801d087..6e1bb0d 100644 --- a/docker-compose.yml +++ b/docker-compose.yml @@ -16,6 +16,4 @@ services: image: ghcr.io/codebude/librislog/librislog:latest ports: - "8001:80" - depends_on: - - backend restart: unless-stopped diff --git a/frontend/src/lib/i18n/locales/de.json b/frontend/src/lib/i18n/locales/de.json index 7406ae0..61befc0 100644 --- a/frontend/src/lib/i18n/locales/de.json +++ b/frontend/src/lib/i18n/locales/de.json @@ -119,7 +119,9 @@ "clickToRate": "Klicke auf einen Stern zum Bewerten", "actionFailed": "{action} fehlgeschlagen", "readMore": "Mehr lesen", - "readLess": "Weniger lesen" + "readLess": "Weniger lesen", + "serverStarting": "Server wird gestartet...", + "serverStartingDesc": "Bitte warte, während der Server hochfährt." }, "book": { "title": "Titel", diff --git a/frontend/src/lib/i18n/locales/en.json b/frontend/src/lib/i18n/locales/en.json index ff63301..f091803 100644 --- a/frontend/src/lib/i18n/locales/en.json +++ b/frontend/src/lib/i18n/locales/en.json @@ -119,7 +119,9 @@ "clickToRate": "Click a star to rate", "actionFailed": "{action} failed", "readMore": "Read more", - "readLess": "Read less" + "readLess": "Read less", + "serverStarting": "Server is starting up...", + "serverStartingDesc": "Please wait while the server finishes booting." }, "book": { "title": "Title", diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index 258dfed..bfcf3c1 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -32,6 +32,7 @@ let addBookOpen = $state(false); let i18nReady = $state(false); let authReady = $state(false); + let backendReady = $state(false); let versionInterval: ReturnType | undefined; const isPublicAuthRoute = $derived( $page.url.pathname.startsWith('/setup') || @@ -45,6 +46,18 @@ import { setContext } from 'svelte'; setContext('openAddBook', () => (addBookOpen = true)); + async function waitForBackend(): Promise { + for (let i = 0; i < 30; i++) { + try { + const res = await fetch('/api/auth/setup-required'); + if (res.ok) return; + } catch { + // backend not ready yet + } + await new Promise(r => setTimeout(r, 2000)); + } + } + onMount(async () => { initAuthSync(() => { currentUser.set(null); @@ -56,7 +69,13 @@ await setupI18n(); i18nReady = true; + // Wait for backend on login/setup/oidc routes so the page doesn't + // render before the server is ready (e.g. OIDC config fetch). const path = $page.url.pathname; + if (path.startsWith('/login') || path.startsWith('/setup') || path.startsWith('/auth/oidc')) { + await waitForBackend(); + } + backendReady = true; const isSetupRoute = path.startsWith('/setup'); const isLoginRoute = path.startsWith('/login'); const isOidcCallbackRoute = path.startsWith('/auth/oidc'); @@ -227,7 +246,20 @@ {pageTitle()} -{#if !i18nReady || !authReady} +{#if !i18nReady} +
+ +
+{:else if !backendReady} +
+ +
+

{$_('common.serverStarting')}

+

{$_('common.serverStartingDesc')}

+
+ +
+{:else if !authReady}
From b7ed2974760070677f5381c818fea6369323945d Mon Sep 17 00:00:00 2001 From: codebude Date: Mon, 1 Jun 2026 23:24:08 +0200 Subject: [PATCH 2/3] Add release update check --- .github/workflows/docs.yml | 6 + .../src/lib/components/VersionLink.svelte | 28 ++- frontend/src/lib/stores/updateCheck.test.ts | 183 ++++++++++++++++++ frontend/src/lib/stores/updateCheck.ts | 76 ++++++++ frontend/src/routes/+layout.svelte | 15 +- 5 files changed, 301 insertions(+), 7 deletions(-) create mode 100644 frontend/src/lib/stores/updateCheck.test.ts create mode 100644 frontend/src/lib/stores/updateCheck.ts diff --git a/.github/workflows/docs.yml b/.github/workflows/docs.yml index f5c565c..c060393 100644 --- a/.github/workflows/docs.yml +++ b/.github/workflows/docs.yml @@ -94,6 +94,12 @@ jobs: if: steps.check-release-docs.outputs.has_docs != 'true' run: cp -a /tmp/dist-nightly/. ./dist/ + - name: Write version.json + run: | + VERSION="${{ steps.release-ref.outputs.ref }}" + RELEASE_URL="https://github.com/${{ github.repository }}/releases/tag/${VERSION}" + printf '{"version":"%s","release_url":"%s"}\n' "$VERSION" "$RELEASE_URL" > ./dist/version.json + - uses: peaceiris/actions-gh-pages@v4 if: steps.check-release-docs.outputs.has_docs == 'true' with: diff --git a/frontend/src/lib/components/VersionLink.svelte b/frontend/src/lib/components/VersionLink.svelte index 3f84ac7..5da7b60 100644 --- a/frontend/src/lib/components/VersionLink.svelte +++ b/frontend/src/lib/components/VersionLink.svelte @@ -1,5 +1,9 @@ -{#if href} - {displayVersion} -{:else} - {displayVersion} -{/if} + + {#if href} + {displayVersion} + {:else} + {displayVersion} + {/if} + {#if updateInfo} +
+ + + +
+ {/if} +
diff --git a/frontend/src/lib/stores/updateCheck.test.ts b/frontend/src/lib/stores/updateCheck.test.ts new file mode 100644 index 0000000..97cf147 --- /dev/null +++ b/frontend/src/lib/stores/updateCheck.test.ts @@ -0,0 +1,183 @@ +import { describe, it, expect, vi, beforeEach, afterEach } from 'vitest'; +import { isNewer, checkForUpdate } from './updateCheck'; + +vi.mock('$lib/version', () => ({ version: 'v1.0.0', gitSha: 'abc1234' })); + +const CHECK_URL = 'https://codebude.github.io/librislog/version.json'; +const STORAGE_KEY = 'librislog_update_check'; + +function mockFetch(data: unknown, ok = true) { + return vi.spyOn(globalThis, 'fetch').mockResolvedValue({ + ok, + json: async () => data, + } as Response); +} + +describe('isNewer', () => { + it('returns true when latest is newer major', () => { + expect(isNewer('v2.0.0', 'v1.0.0')).toBe(true); + }); + + it('returns true when latest is newer minor', () => { + expect(isNewer('v1.2.0', 'v1.1.0')).toBe(true); + }); + + it('returns true when latest is newer patch', () => { + expect(isNewer('v1.1.2', 'v1.1.1')).toBe(true); + }); + + it('returns false when versions are equal', () => { + expect(isNewer('v1.1.1', 'v1.1.1')).toBe(false); + }); + + it('returns false when current is newer', () => { + expect(isNewer('v1.0.0', 'v2.0.0')).toBe(false); + }); + + it('returns false for dev version as latest', () => { + expect(isNewer('v0.0.0-dev', 'v1.0.0')).toBe(false); + }); + + it('handles pre-release tags', () => { + expect(isNewer('v1.2.0-rc1', 'v1.1.0')).toBe(true); + }); + + it('treats final release as newer than its own pre-release', () => { + expect(isNewer('v1.1.0', 'v1.1.0-rc1')).toBe(true); + }); + + it('detects newer pre-release over older release', () => { + expect(isNewer('v1.2.0-rc1', 'v1.1.0')).toBe(true); + }); + + it('treats pre-release as older than next release', () => { + expect(isNewer('v1.2.0', 'v1.1.0-rc1')).toBe(true); + }); + + it('handles version without v prefix', () => { + expect(isNewer('2.0.0', '1.0.0')).toBe(true); + }); +}); + +describe('checkForUpdate', () => { + beforeEach(() => { + localStorage.clear(); + vi.restoreAllMocks(); + vi.useFakeTimers(); + }); + + afterEach(() => { + vi.useRealTimers(); + }); + + it('calls fetch once and returns update info', async () => { + const fetchMock = mockFetch({ version: 'v2.0.0' }); + const result = await checkForUpdate(); + expect(fetchMock).toHaveBeenCalledTimes(1); + expect(result).not.toBeNull(); + }); + + it('returns UpdateInfo when a newer version is available', async () => { + mockFetch({ version: 'v2.0.0', release_url: 'https://github.com/codebude/librislog/releases/tag/v2.0.0' }); + const result = await checkForUpdate(); + expect(result).toEqual({ + latestVersion: 'v2.0.0', + releaseUrl: 'https://github.com/codebude/librislog/releases/tag/v2.0.0', + }); + }); + + it('falls back to generated release URL when release_url is missing', async () => { + mockFetch({ version: 'v2.0.0' }); + const result = await checkForUpdate(); + expect(result?.releaseUrl).toBe('https://github.com/codebude/librislog/releases/tag/v2.0.0'); + }); + + it('returns null when current version is already the latest', async () => { + mockFetch({ version: 'v1.0.0' }); + const result = await checkForUpdate(); + expect(result).toBeNull(); + }); + + it('returns null when current version is newer than the fetched one', async () => { + mockFetch({ version: 'v0.9.0' }); + const result = await checkForUpdate(); + expect(result).toBeNull(); + }); + + it('returns null on network error', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('Network error')); + const result = await checkForUpdate(); + expect(result).toBeNull(); + }); + + it('returns null on non-ok response', async () => { + mockFetch(null, false); + const result = await checkForUpdate(); + expect(result).toBeNull(); + }); + + it('returns null when response has no version field', async () => { + mockFetch({}); + const result = await checkForUpdate(); + expect(result).toBeNull(); + }); + + it('caches the result in localStorage', async () => { + mockFetch({ version: 'v2.0.0' }); + await checkForUpdate(); + const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(cached.data).toEqual({ + latestVersion: 'v2.0.0', + releaseUrl: 'https://github.com/codebude/librislog/releases/tag/v2.0.0', + }); + expect(typeof cached.timestamp).toBe('number'); + }); + + it('reads from localStorage cache within TTL and does not fetch again', async () => { + const cachedData = { + timestamp: Date.now(), + data: { latestVersion: 'v2.0.0', releaseUrl: 'https://github.com/codebude/librislog/releases/tag/v2.0.0' }, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedData)); + const fetchMock = mockFetch({ version: 'v3.0.0' }); + const result = await checkForUpdate(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toEqual(cachedData.data); + }); + + it('fetches again when cache has expired', async () => { + const cachedData = { + timestamp: Date.now() - 61 * 60 * 1000, + data: { latestVersion: 'v2.0.0', releaseUrl: '' }, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedData)); + const fetchMock = mockFetch({ version: 'v3.0.0' }); + const result = await checkForUpdate(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(result?.latestVersion).toBe('v3.0.0'); + }); + + it('ignores malformed cache and fetches fresh', async () => { + localStorage.setItem(STORAGE_KEY, 'not-json'); + const fetchMock = mockFetch({ version: 'v2.0.0' }); + const result = await checkForUpdate(); + expect(fetchMock).toHaveBeenCalledOnce(); + expect(result).not.toBeNull(); + }); + + it('caches null result on failed fetch', async () => { + vi.spyOn(globalThis, 'fetch').mockRejectedValue(new Error('fail')); + await checkForUpdate(); + const cached = JSON.parse(localStorage.getItem(STORAGE_KEY)!); + expect(cached.data).toBeNull(); + }); + + it('skips fetch when cached null result is still valid', async () => { + const cachedData = { timestamp: Date.now(), data: null }; + localStorage.setItem(STORAGE_KEY, JSON.stringify(cachedData)); + const fetchMock = mockFetch({ version: 'v2.0.0' }); + const result = await checkForUpdate(); + expect(fetchMock).not.toHaveBeenCalled(); + expect(result).toBeNull(); + }); +}); diff --git a/frontend/src/lib/stores/updateCheck.ts b/frontend/src/lib/stores/updateCheck.ts new file mode 100644 index 0000000..a82217c --- /dev/null +++ b/frontend/src/lib/stores/updateCheck.ts @@ -0,0 +1,76 @@ +import { version } from '$lib/version'; + +export interface UpdateInfo { + latestVersion: string; + releaseUrl: string; +} + +const STORAGE_KEY = 'librislog_update_check'; +const CACHE_TTL = 60 * 60 * 1000; +const CHECK_URL = 'https://codebude.github.io/librislog/version.json'; + +function parseVersion(v: string): { parts: number[]; isPreRelease: boolean } { + const segments = v.replace(/^v/, '').split(/[.-]/); + const nums = segments.map(Number); + return { + parts: nums.filter(n => !isNaN(n)), + isPreRelease: nums.some(isNaN), + }; +} + +export function isNewer(latest: string, current: string): boolean { + const l = parseVersion(latest); + const c = parseVersion(current); + for (let i = 0; i < Math.max(l.parts.length, c.parts.length); i++) { + const lv = l.parts[i] ?? 0; + const cv = c.parts[i] ?? 0; + if (lv > cv) return true; + if (lv < cv) return false; + } + if (!l.isPreRelease && c.isPreRelease) return true; + if (l.isPreRelease && !c.isPreRelease) return false; + return false; +} + +interface CachedData { + timestamp: number; + data: UpdateInfo | null; +} + +export async function checkForUpdate(): Promise { + if (version === 'v0.0.0-dev') return null; + + const cached = localStorage.getItem(STORAGE_KEY); + if (cached) { + try { + const parsed: CachedData = JSON.parse(cached); + if (Date.now() - parsed.timestamp < CACHE_TTL) { + return parsed.data; + } + } catch { + /* stale cache */ + } + } + + try { + const res = await fetch(CHECK_URL); + if (!res.ok) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ timestamp: Date.now(), data: null })); + return null; + } + const data: { version?: string; release_url?: string } = await res.json(); + if (!data.version || !isNewer(data.version, version)) { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ timestamp: Date.now(), data: null })); + return null; + } + const info: UpdateInfo = { + latestVersion: data.version, + releaseUrl: data.release_url ?? `https://github.com/codebude/librislog/releases/tag/${data.version}`, + }; + localStorage.setItem(STORAGE_KEY, JSON.stringify({ timestamp: Date.now(), data: info })); + return info; + } catch { + localStorage.setItem(STORAGE_KEY, JSON.stringify({ timestamp: Date.now(), data: null })); + return null; + } +} diff --git a/frontend/src/routes/+layout.svelte b/frontend/src/routes/+layout.svelte index bfcf3c1..dc809d0 100644 --- a/frontend/src/routes/+layout.svelte +++ b/frontend/src/routes/+layout.svelte @@ -12,9 +12,10 @@ import { _, setupI18n } from '$lib/i18n'; import { setTimezone, setQuoteServiceEnabled } from '$lib/stores/timezone'; import { loadThemeFromStorage, applyThemeToDocument, setThemeMode, setCustomTheme, saveThemeToStorage, sanitizeThemeMode, THEME_MODE_KEY } from '$lib/stores/theme'; - import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings } from '@lucide/svelte'; + import { LayoutDashboard, BookOpen, ScrollText, BarChart3, Settings, CloudDownload } from '@lucide/svelte'; import { version } from '$lib/version'; import VersionLink from '$lib/components/VersionLink.svelte'; + import { checkForUpdate, type UpdateInfo } from '$lib/stores/updateCheck'; import { toasts } from '$lib/toasts'; import '@fontsource/inter/300.css'; import '@fontsource/inter/400.css'; @@ -34,6 +35,7 @@ let authReady = $state(false); let backendReady = $state(false); let versionInterval: ReturnType | undefined; + let updateInfo: UpdateInfo | null = $state(null); const isPublicAuthRoute = $derived( $page.url.pathname.startsWith('/setup') || $page.url.pathname.startsWith('/login') || @@ -174,6 +176,8 @@ }; checkVersion(); versionInterval = setInterval(checkVersion, 300000); + + checkForUpdate().then(info => { updateInfo = info; }); }); onDestroy(() => clearInterval(versionInterval)); @@ -299,7 +303,7 @@ - + @@ -312,6 +316,13 @@ + {#if updateInfo} +
+ + + +
+ {/if}