Formatting was configured but never enforced: `bun run format:check` reported 199 unformatted files and ran in no workflow and in no git hook, so .prettierrc (printWidth 100, trailing commas) described an intention rather than the tree. This is the one-time sweep that makes the check gateable. Whitespace and token-reflow only -- no behavioural change: `bun run check` reports 0 errors and all 1053 frontend tests pass before and after. Kept out of every other commit on purpose. A 199-file diff mixed with real changes is unreviewable, and the next commit turns format:check into a hard CI gate so this cannot silently accumulate again.
138 lines
4.5 KiB
Svelte
138 lines
4.5 KiB
Svelte
<!--
|
|
What the "N waiting to sync" badge actually stands for.
|
|
|
|
These are outgoing changes — watch positions, watched flags — made while the
|
|
server was unreachable and still waiting to reach Jellyfin. They are *not*
|
|
downloads, which is where the badge used to send people looking: the Downloads
|
|
page lists the `downloads` table and structurally cannot show these.
|
|
|
|
Rows push themselves on reconnect (DR-131); "Sync now" only asks for that to
|
|
happen immediately.
|
|
|
|
TRACES: UR-025 | DR-132
|
|
-->
|
|
<script lang="ts">
|
|
import { onMount } from "svelte";
|
|
import { commands, type SyncQueueItem } from "$lib/api/bindings";
|
|
import { syncService } from "$lib/services/syncService";
|
|
import { pendingSyncCount } from "$lib/stores/appState";
|
|
import { isConnected } from "$lib/stores/connectivity";
|
|
import {
|
|
describeOperation,
|
|
describeSubject,
|
|
isStuck,
|
|
summarize,
|
|
sortForDisplay,
|
|
} from "$lib/services/pendingSync.logic";
|
|
|
|
let items = $state<SyncQueueItem[]>([]);
|
|
let loading = $state(true);
|
|
let syncing = $state(false);
|
|
let error = $state<string | null>(null);
|
|
let lastResult = $state<string | null>(null);
|
|
|
|
const summary = $derived(summarize(items));
|
|
|
|
export async function refresh() {
|
|
try {
|
|
loading = true;
|
|
error = null;
|
|
items = sortForDisplay(await syncService.getPending());
|
|
pendingSyncCount.set(items.length);
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
loading = false;
|
|
}
|
|
}
|
|
|
|
onMount(refresh);
|
|
|
|
async function syncNow() {
|
|
try {
|
|
syncing = true;
|
|
error = null;
|
|
const report = await commands.syncProcessPending();
|
|
lastResult =
|
|
report.pushed > 0
|
|
? `Sent ${report.pushed} update${report.pushed === 1 ? "" : "s"}.`
|
|
: "Nothing could be sent — the server is still unreachable.";
|
|
await refresh();
|
|
} catch (err) {
|
|
error = err instanceof Error ? err.message : String(err);
|
|
} finally {
|
|
syncing = false;
|
|
}
|
|
}
|
|
|
|
function formatQueuedAt(createdAt: string | null): string {
|
|
if (!createdAt) return "";
|
|
const parsed = Date.parse(createdAt);
|
|
return Number.isNaN(parsed) ? "" : new Date(parsed).toLocaleString();
|
|
}
|
|
</script>
|
|
|
|
<div class="space-y-4">
|
|
<p class="text-sm text-gray-400">
|
|
Changes made while the server was unreachable — watch positions and watched flags — waiting to
|
|
reach Jellyfin. They send themselves when the server comes back. This is not the download queue;
|
|
downloaded media lives under Downloads.
|
|
</p>
|
|
|
|
{#if loading}
|
|
<p class="py-6 text-center text-sm text-gray-400">Loading queued updates…</p>
|
|
{:else if items.length === 0}
|
|
<p class="py-6 text-center text-sm text-gray-500">Everything is synced.</p>
|
|
{:else}
|
|
<div class="flex flex-wrap items-center gap-3">
|
|
<button
|
|
onclick={syncNow}
|
|
disabled={syncing || !$isConnected}
|
|
class="rounded-lg bg-[var(--color-jellyfin)] px-4 py-2 text-sm font-medium text-white transition hover:opacity-90 disabled:cursor-not-allowed disabled:opacity-40"
|
|
title={$isConnected ? "Send these now" : "Needs a reachable server"}
|
|
>
|
|
{syncing ? "Sending…" : "Sync now"}
|
|
</button>
|
|
{#if summary.stuck > 0}
|
|
<span class="text-xs text-amber-400">
|
|
{summary.stuck} failed and will be retried
|
|
</span>
|
|
{/if}
|
|
{#if lastResult}
|
|
<span class="text-xs text-gray-400">{lastResult}</span>
|
|
{/if}
|
|
</div>
|
|
|
|
<ul class="space-y-2">
|
|
{#each items as item (item.id)}
|
|
<li
|
|
class="rounded-lg border-l-4 bg-[var(--color-surface)] p-3 {isStuck(item)
|
|
? 'border-amber-500/60'
|
|
: 'border-gray-600/60'}"
|
|
>
|
|
<div class="flex items-start justify-between gap-3">
|
|
<div class="min-w-0">
|
|
<p class="truncate text-sm font-medium text-white">
|
|
{describeOperation(item.operation)}
|
|
</p>
|
|
<p class="truncate text-xs text-gray-400">{describeSubject(item)}</p>
|
|
{#if item.errorMessage}
|
|
<p class="mt-1 text-xs text-amber-400">
|
|
{item.errorMessage}{item.retryCount > 0 ? ` (attempt ${item.retryCount})` : ""}
|
|
</p>
|
|
{/if}
|
|
</div>
|
|
<span class="shrink-0 text-[11px] text-gray-500">
|
|
{formatQueuedAt(item.createdAt)}
|
|
</span>
|
|
</div>
|
|
</li>
|
|
{/each}
|
|
</ul>
|
|
{/if}
|
|
|
|
{#if error}
|
|
<p class="text-sm text-red-400">{error}</p>
|
|
{/if}
|
|
</div>
|