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.
146 lines
4.6 KiB
Svelte
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>
|