Files
jellytau/src/lib/components/library/WatchedToggleButton.svelte
T
dtourolle ad48d89dfe chore(format): run prettier over src/ and scripts/
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.
2026-08-21 17:41:44 +02:00

146 lines
4.6 KiB
Svelte

<!--
Mark an episode, season or series watched — or unwatched again.
The backend already had both halves (`mark_played` / `clear_watch_history`,
both recursive over a container on the server) and the sync queue already
replayed the first; nothing in the UI had ever called them, so the only way to
mark something watched was to sit through it. This is that control.
Unlike ClearHistoryButton — which is the *destructive* "erase all history for
this series", confirms, and needs the server — this is an everyday toggle: no
confirmation, and it works offline by queueing, in both directions.
TRACES: UR-073 | DR-158
-->
<script lang="ts">
import { syncService } from "$lib/services/syncService";
import { createLogger } from "$lib/utils/logger";
const log = createLogger("WatchedToggleButton");
interface Props {
/** Episode, season or series id. */
itemId: string;
/** Current watched state, as the caller knows it. */
watched: boolean;
/** What is being marked, for the tooltip wording. */
scope: "episode" | "season" | "series";
size?: "sm" | "lg";
/** Show a text label beside the icon rather than icon-only. */
showLabel?: boolean;
/** Called after a successful toggle so the caller can reload. */
onChanged?: (watched: boolean) => void;
}
let { itemId, watched, scope, size = "lg", showLabel = false, onChanged }: Props = $props();
let busy = $state(false);
// Optimistic state: the caller's `watched` prop only catches up once it has
// reloaded from the repository, which on a season means a round trip. Without
// this the button visibly ignores the first tap.
let optimistic = $state<boolean | null>(null);
const isWatched = $derived(optimistic ?? watched);
// A new item in the same slot (scrolling a virtualised list, switching series)
// must drop the previous item's optimistic state or it shows the wrong tick.
$effect(() => {
// Bare read: registers `itemId` as a dependency of this effect. Svelte 5
// idiom, not a stray expression.
// eslint-disable-next-line @typescript-eslint/no-unused-expressions
itemId;
optimistic = null;
});
const subject = $derived(
scope === "series" ? "series" : scope === "season" ? "season" : "episode",
);
const label = $derived(isWatched ? "Watched" : "Mark watched");
const title = $derived(
isWatched
? `Mark this ${subject} unwatched`
: scope === "episode"
? "Mark this episode watched"
: `Mark every episode in this ${subject} watched`,
);
async function handleClick() {
if (busy) return;
const next = !isWatched;
busy = true;
optimistic = next;
try {
if (next) {
await syncService.queueMarkPlayed(itemId);
} else {
await syncService.queueMarkUnplayed(itemId);
}
onChanged?.(next);
} catch (e) {
// Put the button back where it was — the change did not happen.
optimistic = null;
log.error("Failed to change watched state:", e);
} finally {
busy = false;
}
}
</script>
<button
type="button"
onclick={handleClick}
disabled={busy}
{title}
aria-label={title}
aria-pressed={isWatched}
class="rounded-lg font-medium flex items-center gap-2 transition-colors
disabled:opacity-40 disabled:cursor-not-allowed
{isWatched
? 'bg-[var(--color-jellyfin)]/15 text-[var(--color-jellyfin)] hover:bg-[var(--color-jellyfin)]/25'
: 'bg-[var(--color-surface)] text-gray-300 hover:bg-[var(--color-surface-hover)] hover:text-white'}
{showLabel
? size === 'lg'
? 'px-6 py-2'
: 'px-3 py-1.5 text-sm'
: size === 'lg'
? 'p-2'
: 'p-1.5'}"
>
{#if busy}
<div
class="border-2 border-current border-t-transparent rounded-full animate-spin
{size === 'lg' ? 'w-5 h-5' : 'w-4 h-4'}"
></div>
{:else if isWatched}
<!-- Filled check: this one is done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="currentColor"
viewBox="0 0 24 24"
aria-hidden="true"
>
<path
d="M12 2a10 10 0 1 0 0 20 10 10 0 0 0 0-20zm-1.4 14.6L6 12l1.4-1.4 3.2 3.2
6.4-6.4L18.4 8.8l-7.8 7.8z"
/>
</svg>
{:else}
<!-- Outline check: available, not yet done. -->
<svg
class={size === "lg" ? "w-5 h-5" : "w-4 h-4"}
fill="none"
stroke="currentColor"
stroke-width="2"
viewBox="0 0 24 24"
aria-hidden="true"
>
<circle cx="12" cy="12" r="9" />
<path stroke-linecap="round" stroke-linejoin="round" d="M8 12.5l2.5 2.5L16 9.5" />
</svg>
{/if}
{#if showLabel}
<span>{busy ? "Saving…" : label}</span>
{/if}
</button>