Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension


Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
6 changes: 6 additions & 0 deletions .github/workflows/docs.yml
Original file line number Diff line number Diff line change
Expand Up @@ -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:
Expand Down
2 changes: 0 additions & 2 deletions docker-compose.dev.yml
Original file line number Diff line number Diff line change
Expand Up @@ -25,6 +25,4 @@ services:
GIT_SHA: ${GIT_SHA:-unknown}
ports:
- "8001:80"
depends_on:
- backend
restart: unless-stopped
2 changes: 0 additions & 2 deletions docker-compose.yml
Original file line number Diff line number Diff line change
Expand Up @@ -16,6 +16,4 @@ services:
image: ghcr.io/codebude/librislog/librislog:latest
ports:
- "8001:80"
depends_on:
- backend
restart: unless-stopped
47 changes: 31 additions & 16 deletions frontend/src/lib/components/UserMenu.svelte
Original file line number Diff line number Diff line change
Expand Up @@ -2,12 +2,13 @@
import { goto } from '$app/navigation';
import { api } from '$lib/api';
import { broadcastLogout, currentUser, csrfToken } from '$lib/stores/auth';
import type { UpdateInfo } from '$lib/stores/updateCheck';
import { _ } from '$lib/i18n';
import { cycleTheme, applyThemeToDocument, saveThemeToStorage, getThemeMode, getThemeIcon, getCustomTheme, getThemeVersion } from '$lib/stores/theme';
import AnimalAvatar from '$lib/components/AnimalAvatar.svelte';
import { Sun, Moon, Palette } from '@lucide/svelte';
import { Sun, Moon, Palette, CloudDownload } from '@lucide/svelte';

let { floating = true }: { floating?: boolean } = $props();
let { floating = true, updateInfo = null }: { floating?: boolean; updateInfo?: UpdateInfo | null } = $props();

let open = $state(false);
let themeIcon = $state(getThemeIcon());
Expand Down Expand Up @@ -71,26 +72,40 @@
</script>

<div class="{floating ? 'fixed top-4 right-4 z-50' : 'relative'}">
<button
type="button"
class="btn btn-ghost btn-circle"
onclick={onMenuToggle}
aria-label={$_('user.menu')}
>
{#if user}
<AnimalAvatar seed={user.email} size={36} class="w-9 h-9" />
{:else}
<div class="w-9 h-9 rounded-full bg-primary text-primary-content text-xs grid place-items-center font-semibold">
{initials}
</div>
<div class="indicator">
{#if updateInfo}
<span class="indicator-item badge badge-success badge-xs border-base-100 shadow-sm mt-1 mr-1"></span>
{/if}
</button>
<button
type="button"
class="btn btn-ghost btn-circle"
onclick={onMenuToggle}
aria-label={$_('user.menu')}
>
{#if user}
<AnimalAvatar seed={user.email} size={36} class="w-9 h-9" />
{:else}
<div class="w-9 h-9 rounded-full bg-primary text-primary-content text-xs grid place-items-center font-semibold">
{initials}
</div>
{/if}
</button>
</div>

{#if open}
<ul
tabindex="-1"
class="menu menu-sm dropdown-content absolute right-0 mt-3 w-40 rounded-xl bg-base-100 shadow z-50 p-2"
class="menu menu-sm dropdown-content absolute right-0 mt-3 w-48 rounded-xl bg-base-100 shadow z-50 p-2"
>
{#if updateInfo}
<li>
<a href={updateInfo.releaseUrl} target="_blank" rel="noopener noreferrer" class="text-success font-medium" onclick={() => (open = false)}>
<CloudDownload class="w-4 h-4" />
{$_('toasts.newVersion', { values: { version: updateInfo.latestVersion } })}
</a>
</li>
<li><hr class="menu-divider opacity-30 mt-2 mb-2 rounded-none" style="padding: 0;"></li>
{/if}
<li><a href="/profile" onclick={() => (open = false)}>{$_('user.profile')}</a></li>
<li><a href="/about" onclick={() => (open = false)}>{$_('user.about')}</a></li>
<li><hr class="menu-divider opacity-30 mt-2 mb-2 rounded-none" style="padding: 0;"></li>
Expand Down
28 changes: 23 additions & 5 deletions frontend/src/lib/components/VersionLink.svelte
Original file line number Diff line number Diff line change
@@ -1,5 +1,9 @@
<script lang="ts">
import { version, gitSha } from '$lib/version';
import { _ } from '$lib/i18n';
import type { UpdateInfo } from '$lib/stores/updateCheck';

let { updateInfo = null }: { updateInfo?: UpdateInfo | null } = $props();

const knownSha = gitSha && gitSha !== 'unknown';
const isPreRelease = version.includes('-');
Expand All @@ -13,10 +17,24 @@
: knownSha
? `https://github.com/codebude/librislog/releases/tag/${version}`
: null;

const tooltip = $derived.by(() => {
if (!updateInfo) return '';
return $_('toasts.newVersion', { values: { version: updateInfo.latestVersion } });
});
</script>

{#if href}
<a {href} target="_blank" rel="noopener noreferrer" class="hover:underline">{displayVersion}</a>
{:else}
{displayVersion}
{/if}
<span class="inline-flex items-center gap-1">
{#if href}
<a {href} target="_blank" rel="noopener noreferrer" class="hover:underline">{displayVersion}</a>
{:else}
{displayVersion}
{/if}
{#if updateInfo}
<div class="tooltip tooltip-right" data-tip={tooltip}>
<a href={updateInfo.releaseUrl} target="_blank" rel="noopener noreferrer" aria-label={tooltip}>
<span class="w-1.5 h-1.5 rounded-full bg-green-500 inline-block"></span>
</a>
</div>
{/if}
</span>
4 changes: 3 additions & 1 deletion frontend/src/lib/i18n/locales/de.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
4 changes: 3 additions & 1 deletion frontend/src/lib/i18n/locales/en.json
Original file line number Diff line number Diff line change
Expand Up @@ -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",
Expand Down
183 changes: 183 additions & 0 deletions frontend/src/lib/stores/updateCheck.test.ts
Original file line number Diff line number Diff line change
@@ -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();
});
});
Loading
Loading