Three gaps in the frontend tooling, all in the package.json script surface.
1. No JS/TS linter or formatter existed at all for 274 TS/Svelte files.
Adds an ESLint flat config (typescript-eslint + eslint-plugin-svelte,
Svelte 5 + TS strict) and prettier + prettier-plugin-svelte, plus the
`lint`, `lint:fix`, `format`, `format:check` scripts.
The tree is error-clean (`npx eslint .` exits 0). Getting there needed
seven real one-line fixes (braced switch cases that leaked `const` across
arms, a useless regex escape, two `let`s that never change, a thrown Error
that dropped its `cause`, and two `// eslint-disable-next-line` comments
documenting the Svelte 5 bare-read-for-dependency idiom). Everything else
that fires is set to `warn` with the reason written next to it in
eslint.config.js — notably ~94 dead bindings and `any` at the IPC
boundary. Those are real findings to drive to zero, not noise to delete.
`no-console` is OFF for now: a parallel change is moving all ~468 console
calls onto a logger facade, and turning the rule on today would collide
with it. eslint.config.js says so, and says to flip it to `error` once
that lands.
`prettier --write` is deliberately NOT run here — it would rewrite ~200
files and swamp every other diff in flight. The gate is available; the
sweep is a separate commit. Markdown and CI YAML are in .prettierignore
because both are hand-laid-out (and docs/traceability.md is generated).
2. `bun run test` was bare `vitest`, i.e. watch mode — while CLAUDE.md's
"Before Committing" list tells people to run it. It is now `vitest run`,
with `test:watch` and `test:coverage` (also `--run`-ified) alongside.
scripts/test-all.sh drops the now-redundant `--run`, and
scripts/test-frontend.sh keeps `--watch`/`--ui`/`-w` working by routing
them to a long-running vitest instead of the single-pass one.
3. The webdriverio e2e suite is deleted. It was last touched in January
("First working POC"), has never run since, and is not in CI — five
devDependencies and two scripts of pure decoration. Removes e2e/,
wdio.conf.ts, the two `test:e2e*` scripts, the @wdio/* + webdriverio
devDeps, and the WebdriverIO block in .gitignore.
The package.json diff also carries `hooks:install` and `check:links`, wired
up by the following commits.
144 lines
4.5 KiB
Svelte
144 lines
4.5 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";
|
|
|
|
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;
|
|
console.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>
|