feat(web): MobileAppDownload component on login + settings (#397 follow-up)
Closes the discoverability gap on the in-app update flow — the /api/client/apk endpoint exists but had no web UI surface to find it. User asked to be able to "visit the site on my phone and download the apk from there." - web/src/lib/components/MobileAppDownload.svelte — fetches /api/client/version once on mount; renders a download link with version + size when 200; renders nothing on 404 (no APK bundled, graceful degradation in dev environments and pre-CI-wiring images). - Mounted on the login page (below the Register link) so the link is discoverable without authentication. The /api/client/* endpoints are themselves unauthed, so the flow works end-to-end for any visitor on a phone. - Also mounted in Settings → Mobile app section for logged-in users who want to grab the matching APK for sideloading on a different device. Browser handles the download via the server's existing Content-Disposition: attachment; filename="minstrel.apk" header. Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,54 @@
|
|||||||
|
<script lang="ts">
|
||||||
|
import { onMount } from 'svelte';
|
||||||
|
import { Smartphone } from 'lucide-svelte';
|
||||||
|
|
||||||
|
// Polls /api/client/version once on mount. Renders nothing if the
|
||||||
|
// server has no APK bundled (404) — graceful degradation in dev
|
||||||
|
// environments and pre-CI-wiring images.
|
||||||
|
//
|
||||||
|
// Mounted on the login page so the download is discoverable without
|
||||||
|
// authentication; the /api/client/* endpoints are themselves unauthed.
|
||||||
|
// Also reusable in Settings for logged-in users who want to grab the
|
||||||
|
// matching APK for sideloading on a different device.
|
||||||
|
|
||||||
|
type Info = { version: string; apkUrl: string; sizeBytes: number };
|
||||||
|
|
||||||
|
let info = $state<Info | null>(null);
|
||||||
|
|
||||||
|
function formatSize(bytes: number): string {
|
||||||
|
if (bytes < 1024 * 1024) return `${(bytes / 1024).toFixed(0)} KB`;
|
||||||
|
return `${(bytes / 1024 / 1024).toFixed(1)} MB`;
|
||||||
|
}
|
||||||
|
|
||||||
|
onMount(async () => {
|
||||||
|
try {
|
||||||
|
const res = await fetch('/api/client/version');
|
||||||
|
if (!res.ok) return; // 404 = no bundled APK; stay hidden
|
||||||
|
const body = (await res.json()) as Partial<Info>;
|
||||||
|
if (!body.version || !body.apkUrl) return;
|
||||||
|
info = {
|
||||||
|
version: body.version,
|
||||||
|
apkUrl: body.apkUrl,
|
||||||
|
sizeBytes: body.sizeBytes ?? 0
|
||||||
|
};
|
||||||
|
} catch {
|
||||||
|
// Network error / non-JSON response — silent fail, no banner.
|
||||||
|
}
|
||||||
|
});
|
||||||
|
</script>
|
||||||
|
|
||||||
|
{#if info}
|
||||||
|
<a
|
||||||
|
href={info.apkUrl}
|
||||||
|
download="minstrel.apk"
|
||||||
|
class="mobile-app-download flex items-center gap-3 rounded-md border border-border bg-surface px-3 py-2 text-sm text-text-primary no-underline transition-colors hover:bg-surface-hover hover:text-accent focus-visible:ring-2 focus-visible:ring-accent"
|
||||||
|
>
|
||||||
|
<Smartphone size={18} strokeWidth={1.5} class="shrink-0 text-accent" />
|
||||||
|
<span class="flex flex-col leading-tight">
|
||||||
|
<span class="font-medium">Get the Android app</span>
|
||||||
|
<span class="text-xs text-text-secondary">
|
||||||
|
{info.version}{info.sizeBytes > 0 ? ` · ${formatSize(info.sizeBytes)}` : ''}
|
||||||
|
</span>
|
||||||
|
</span>
|
||||||
|
</a>
|
||||||
|
{/if}
|
||||||
@@ -4,6 +4,7 @@
|
|||||||
import { goto } from '$app/navigation';
|
import { goto } from '$app/navigation';
|
||||||
import { login } from '$lib/auth/store.svelte';
|
import { login } from '$lib/auth/store.svelte';
|
||||||
import type { ApiError } from '$lib/api/client';
|
import type { ApiError } from '$lib/api/client';
|
||||||
|
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||||
|
|
||||||
let username = $state('');
|
let username = $state('');
|
||||||
let password = $state('');
|
let password = $state('');
|
||||||
@@ -88,5 +89,9 @@
|
|||||||
Don't have an account?
|
Don't have an account?
|
||||||
<a href="/register" class="text-accent hover:underline">Register</a>
|
<a href="/register" class="text-accent hover:underline">Register</a>
|
||||||
</p>
|
</p>
|
||||||
|
|
||||||
|
<div class="mt-6">
|
||||||
|
<MobileAppDownload />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</main>
|
</main>
|
||||||
|
|||||||
@@ -17,6 +17,7 @@
|
|||||||
} from '$lib/api/me';
|
} from '$lib/api/me';
|
||||||
import { errCode } from '$lib/api/errors';
|
import { errCode } from '$lib/api/errors';
|
||||||
import { pushToast } from '$lib/stores/toast.svelte';
|
import { pushToast } from '$lib/stores/toast.svelte';
|
||||||
|
import MobileAppDownload from '$lib/components/MobileAppDownload.svelte';
|
||||||
|
|
||||||
const queryClient = useQueryClient();
|
const queryClient = useQueryClient();
|
||||||
|
|
||||||
@@ -323,4 +324,12 @@
|
|||||||
</li>
|
</li>
|
||||||
</ul>
|
</ul>
|
||||||
</section>
|
</section>
|
||||||
|
|
||||||
|
<section class="space-y-3 rounded border border-border bg-surface p-4">
|
||||||
|
<h2 class="text-lg font-semibold">Mobile app</h2>
|
||||||
|
<p class="text-sm text-text-secondary">
|
||||||
|
Install the Android app paired to this server. Sign in with the same account.
|
||||||
|
</p>
|
||||||
|
<MobileAppDownload />
|
||||||
|
</section>
|
||||||
</div>
|
</div>
|
||||||
|
|||||||
Reference in New Issue
Block a user