From 5900e1acf104b4f2ce49c93a98921b2e7cd3e602 Mon Sep 17 00:00:00 2001 From: greenthumb80 Date: Wed, 23 Sep 2026 00:21:49 +0200 Subject: [PATCH 1/3] Add Watch Later plugin --- plugins/watchlater/watchlater.css | 152 ++++++++++++ plugins/watchlater/watchlater.js | 378 ++++++++++++++++++++++++++++++ plugins/watchlater/watchlater.yml | 11 + 3 files changed, 541 insertions(+) create mode 100644 plugins/watchlater/watchlater.css create mode 100644 plugins/watchlater/watchlater.js create mode 100644 plugins/watchlater/watchlater.yml diff --git a/plugins/watchlater/watchlater.css b/plugins/watchlater/watchlater.css new file mode 100644 index 00000000..ec9e4c85 --- /dev/null +++ b/plugins/watchlater/watchlater.css @@ -0,0 +1,152 @@ +.watch-later-overlay { + position: absolute; + bottom: 0.4rem; + left: 0.4rem; + z-index: 5; +} +.watch-later-btn { + background: rgba(0, 0, 0, 0.55) !important; + border: none !important; + border-radius: 4px; + padding: 0.2rem 0.4rem !important; + line-height: 1; + /* the icon is an emoji, not a vector glyph: "color" doesn't tint it, only "filter" does */ + filter: grayscale(1) brightness(1.5); +} +.watch-later-btn:hover { + filter: grayscale(1) brightness(2); +} +.watch-later-btn.watch-later-on { + filter: none; +} +.watch-later-btn.watch-later-on:hover { + filter: brightness(1.2); +} + +.watchlater-nav-link { + color: inherit; + text-decoration: none; +} + + +.watchlater-page { + padding: 1rem 1.5rem; +} + +.watchlater-page-header { + display: flex; + align-items: center; + justify-content: space-between; + gap: 1rem; + flex-wrap: wrap; +} + +.watchlater-page-links { + display: flex; + align-items: center; + gap: 0.5rem; + flex-wrap: wrap; +} + +.watchlater-kofi-link { + font-size: 0.85rem; + opacity: 0.8; + text-decoration: none; + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 999px; + padding: 0.25rem 0.75rem; + white-space: nowrap; +} +.watchlater-kofi-link:hover { + opacity: 1; + border-color: rgba(255, 255, 255, 0.35); + color: inherit; +} + +.watchlater-list { + display: flex; + flex-direction: column; + gap: 0.3rem; + margin-top: 1rem; +} + +.watchlater-row { + background: rgba(255, 255, 255, 0.04); + border-radius: 6px; + display: flex; + align-items: center; + gap: 0.7rem; + padding: 0.3rem 0.6rem; +} + +.watchlater-row-thumb-link { + position: relative; + display: block; + flex: 0 0 auto; +} + +.watchlater-row-thumb { + width: 140px; + aspect-ratio: 16 / 9; + object-fit: cover; + display: block; + background: #111; + border-radius: 4px; +} + +.watchlater-row-duration { + position: absolute; + bottom: 0.15rem; + right: 0.15rem; + background: rgba(0, 0, 0, 0.7); + color: #fff; + font-size: 0.65rem; + padding: 0.05rem 0.3rem; + border-radius: 3px; +} + +.watchlater-row-body { + flex: 1 1 auto; + min-width: 0; + display: flex; + flex-direction: column; + gap: 0.1rem; +} + +.watchlater-row-title { + font-weight: 600; + text-decoration: none; + color: inherit; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.watchlater-row-studio { + font-size: 0.8rem; + opacity: 0.7; +} + +.watchlater-row-performers { + font-size: 0.8rem; + opacity: 0.85; +} + +.watchlater-row-tags { + font-size: 0.75rem; + opacity: 0.55; + white-space: nowrap; + overflow: hidden; + text-overflow: ellipsis; +} + +.watchlater-remove-btn { + flex: 0 0 auto; + font-size: 0.9rem; + opacity: 0.6; +} +.watchlater-remove-btn:hover { + opacity: 1; + color: #e05555 !important; +} diff --git a/plugins/watchlater/watchlater.js b/plugins/watchlater/watchlater.js new file mode 100644 index 00000000..084b47df --- /dev/null +++ b/plugins/watchlater/watchlater.js @@ -0,0 +1,378 @@ +(function () { + "use strict"; + const api = window.PluginApi; + const { React, patch, libraries, register } = api; + const { faTrash } = libraries.FontAwesomeSolid; + const { Icon } = api.components; + // Use Link (not a plain ): a plain triggers a full page load, which hits the + // server and 404s on /plugin/watchlater (the route only exists client-side, not server-side) - + // real bug found and fixed by testing live in the browser. + const { Link } = libraries.ReactRouterDOM; + + const TAG_NAME = "Watch Later"; + const KOFI_URL = "https://ko-fi.com/greenthumb80"; + const FEEDBACK_URL = "https://github.com/spesometro2026/stash-watchlater-plugin/issues/new"; + let tagId = null; + let tagPromise = null; + + async function gql(query, variables) { + const res = await fetch("/graphql", { + method: "POST", + headers: { "Content-Type": "application/json" }, + credentials: "include", + body: JSON.stringify({ query, variables }), + }); + const out = await res.json(); + if (out.errors) throw new Error(out.errors[0].message); + return out.data; + } + + function ensureTag() { + if (tagPromise) return tagPromise; + tagPromise = (async () => { + const found = await gql( + "query($f: TagFilterType) { findTags(tag_filter: $f) { tags { id name } } }", + { f: { name: { value: TAG_NAME, modifier: "EQUALS" } } } + ); + if (found.findTags.tags.length) { + tagId = found.findTags.tags[0].id; + return tagId; + } + const created = await gql( + "mutation($input: TagCreateInput!) { tagCreate(input: $input) { id } }", + { input: { name: TAG_NAME, description: "Created by the Watch Later plugin" } } + ); + tagId = created.tagCreate.id; + return tagId; + })(); + return tagPromise; + } + + async function removeFromWatchLater(scene) { + const tid = await ensureTag(); + const currentIds = (scene.tags || []).map((t) => t.id); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }", + { input: { id: scene.id, tag_ids: currentIds.filter((x) => x !== tid) } } + ); + } + + // ------------------------------------------------------------------ card button (grid) + function WatchLaterButton({ scene }) { + // "on" is real React state (not re-derived from scene.tags on every render): mutating + // scene.tags in place, like the previous version did, does NOT trigger a re-render on its + // own - that's the root cause of the old "needs a reload to see the change" bug. Now it's + // flipped explicitly with setOn once the mutation succeeds. + const [on, setOn] = React.useState( + !!(tagId && (scene.tags || []).some((t) => t.id === tagId)) + ); + const [busy, setBusy] = React.useState(false); + + React.useEffect(() => { + let alive = true; + ensureTag().then((tid) => { + if (alive) setOn((scene.tags || []).some((t) => t.id === tid)); + }); + return () => { + alive = false; + }; + }, []); + + async function toggle(e) { + e.preventDefault(); + e.stopPropagation(); + if (busy) return; + setBusy(true); + const nextOn = !on; + try { + const tid = await ensureTag(); + const currentIds = (scene.tags || []).map((t) => t.id); + const newIds = nextOn + ? [...currentIds, tid] + : currentIds.filter((x) => x !== tid); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id tags { id } } }", + { input: { id: scene.id, tag_ids: newIds } } + ); + // Do NOT mutate scene.tags: the object comes from the Apollo cache and is frozen (seen + // in the console: "Cannot assign to read only property") - setOn alone is enough, "on" + // is already independent React state, scene itself doesn't need to stay in sync. + setOn(nextOn); + } catch (err) { + console.error("[Watch Later] toggle error:", err); + } finally { + setBusy(false); + } + } + + return React.createElement( + "div", + { className: "watch-later-overlay" }, + React.createElement( + "button", + { + className: + "watch-later-btn minimal" + (on ? " watch-later-on" : ""), + title: on ? "Remove from Watch Later" : "Add to Watch Later", + onClick: toggle, + disabled: busy, + }, + "🔖" + ) + ); + } + + patch.instead("SceneCard.Overlays", function (props, _ctx, orig) { + return React.createElement( + React.Fragment, + null, + orig(props, _ctx), + React.createElement(WatchLaterButton, { scene: props.scene }) + ); + }); + + // ------------------------------------------------------------------ dedicated page + function fmtDuration(sec) { + if (!sec) return ""; + sec = Math.round(sec); + const h = Math.floor(sec / 3600); + const m = Math.floor((sec % 3600) / 60); + const s = sec % 60; + const pad = (n) => String(n).padStart(2, "0"); + return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; + } + + function WatchLaterCard({ scene, onRemoved }) { + const [removing, setRemoving] = React.useState(false); + const file = scene.files && scene.files[0]; + + async function remove(e) { + e.preventDefault(); + e.stopPropagation(); + if (removing) return; + setRemoving(true); + try { + await removeFromWatchLater(scene); + onRemoved(scene.id); + } catch (err) { + console.error("[Watch Later] remove error:", err); + setRemoving(false); + } + } + + return React.createElement( + "div", + { className: "watchlater-row" }, + React.createElement( + Link, + { to: `/scenes/${scene.id}`, className: "watchlater-row-thumb-link" }, + React.createElement("img", { + className: "watchlater-row-thumb", + loading: "lazy", + src: scene.paths && scene.paths.screenshot, + alt: "", + }), + file && file.duration + ? React.createElement( + "span", + { className: "watchlater-row-duration" }, + fmtDuration(file.duration) + ) + : null + ), + React.createElement( + "div", + { className: "watchlater-row-body" }, + React.createElement( + Link, + { to: `/scenes/${scene.id}`, className: "watchlater-row-title" }, + scene.title || (file ? file.basename : "") || `Scene #${scene.id}` + ), + scene.studio + ? React.createElement( + "div", + { className: "watchlater-row-studio" }, + scene.studio.name + ) + : null, + scene.performers && scene.performers.length + ? React.createElement( + "div", + { className: "watchlater-row-performers" }, + scene.performers.map((p) => p.name).join(", ") + ) + : null, + // the "Watch Later" tag itself isn't informative here, exclude it from the shown list. + scene.tags && scene.tags.some((t) => t.id !== tagId) + ? React.createElement( + "div", + { className: "watchlater-row-tags" }, + scene.tags + .filter((t) => t.id !== tagId) + .map((t) => t.name) + .join(" · ") + ) + : null + ), + React.createElement( + "button", + { + className: "watchlater-remove-btn minimal", + title: "Remove from Watch Later", + onClick: remove, + disabled: removing, + }, + React.createElement(Icon, { icon: faTrash }) + ) + ); + } + + function WatchLaterPage() { + const [scenes, setScenes] = React.useState(null); + const [error, setError] = React.useState(null); + + // "updated_at" is used as a stand-in for "date added to Watch Later": Stash doesn't keep a + // per-tag timestamp, but tagging a scene bumps its updated_at - so the most recently added + // scene ends up on top, as requested ("sort by when it was added, not by name"). + const load = React.useCallback(async () => { + try { + const tid = await ensureTag(); + const d = await gql( + `query($id: ID!) { + findScenes( + scene_filter: { tags: { modifier: INCLUDES, value: [$id] } } + filter: { per_page: -1, sort: "updated_at", direction: DESC } + ) { + count + scenes { + id title date + studio { name } + files { basename duration } + paths { screenshot } + tags { id name } + performers { id name } + } + } + }`, + { id: tid } + ); + setScenes(d.findScenes.scenes); + } catch (err) { + console.error("[Watch Later] load error:", err); + setError(String(err)); + } + }, []); + + React.useEffect(() => { + load(); + }, [load]); + + function onRemoved(id) { + setScenes((prev) => (prev || []).filter((s) => s.id !== id)); + } + + const kofiLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: KOFI_URL, + target: "_blank", + rel: "noopener noreferrer", + title: "Support this plugin", + }, + "☕ Buy me a coffee" + ); + + const feedbackLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: FEEDBACK_URL, + target: "_blank", + rel: "noopener noreferrer", + title: "Suggest a feature or report a bug", + }, + "💡 Suggest a feature" + ); + + const pageLinks = React.createElement( + "div", + { className: "watchlater-page-links" }, + feedbackLink, + kofiLink + ); + + if (error) { + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement("h3", null, "Watch Later"), + React.createElement("p", { className: "text-danger" }, "Error: " + error) + ); + } + + if (scenes === null) { + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement("h3", null, "Watch Later"), + React.createElement("p", null, "Loading…") + ); + } + + return React.createElement( + "div", + { className: "watchlater-page" }, + React.createElement( + "div", + { className: "watchlater-page-header" }, + React.createElement("h3", null, `Watch Later (${scenes.length})`), + pageLinks + ), + scenes.length === 0 + ? React.createElement( + "p", + null, + "Empty for now. Click the bookmark icon on a scene to add it here." + ) + : React.createElement( + "div", + { className: "watchlater-list" }, + scenes.map((s) => + React.createElement(WatchLaterCard, { + key: s.id, + scene: s, + onRemoved, + }) + ) + ) + ); + } + + register.route("/plugin/watchlater", WatchLaterPage); + + patch.before("MainNavBar.UtilityItems", function (props) { + return [ + { + children: React.createElement( + React.Fragment, + null, + props.children, + React.createElement( + Link, + { + className: "nav-utility watchlater-nav-link", + to: "/plugin/watchlater", + title: "Watch Later", + }, + React.createElement( + "button", + { className: "minimal d-flex align-items-center h-100" }, + "🔖" + ) + ) + ), + }, + ]; + }); +})(); diff --git a/plugins/watchlater/watchlater.yml b/plugins/watchlater/watchlater.yml new file mode 100644 index 00000000..b1df2e72 --- /dev/null +++ b/plugins/watchlater/watchlater.yml @@ -0,0 +1,11 @@ +name: Watch Later +description: Adds a quick bookmark button on scene cards to add/remove a "Watch Later" tag (auto-created on first use), plus a dedicated page to browse your queue. +version: 1.0.0 +url: https://github.com/spesometro2026/stash-watchlater-plugin +ui: + javascript: + - watchlater.js + css: + - watchlater.css + assets: + /: . From 4ed980e71c8cb18e9a5c2cbbd19d65664b7c990e Mon Sep 17 00:00:00 2001 From: greenthumb80 Date: Wed, 23 Sep 2026 00:44:56 +0200 Subject: [PATCH 2/3] Update Watch Later plugin: multi-list, auto-remove, export, optimistic UI --- plugins/watchlater/watchlater.css | 38 +++++ plugins/watchlater/watchlater.js | 246 +++++++++++++++++++++++++++--- 2 files changed, 265 insertions(+), 19 deletions(-) diff --git a/plugins/watchlater/watchlater.css b/plugins/watchlater/watchlater.css index ec9e4c85..be0ed333 100644 --- a/plugins/watchlater/watchlater.css +++ b/plugins/watchlater/watchlater.css @@ -41,6 +41,12 @@ flex-wrap: wrap; } +.watchlater-auto-removed-note { + font-size: 0.8rem; + opacity: 0.6; + margin: 0.4rem 0 0; +} + .watchlater-page-links { display: flex; align-items: center; @@ -48,6 +54,38 @@ flex-wrap: wrap; } +.watchlater-list-selector { + display: flex; + align-items: center; + gap: 0.5rem; + margin-top: 0.6rem; +} +.watchlater-list-selector select { + background: rgba(255, 255, 255, 0.06); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 0.2rem 0.4rem; +} +.watchlater-new-list-btn { + font-size: 0.8rem; + opacity: 0.75; +} +.watchlater-new-list-btn:hover { + opacity: 1; +} + +.watchlater-move-select { + flex: 0 0 auto; + max-width: 130px; + background: rgba(255, 255, 255, 0.06); + color: inherit; + border: 1px solid rgba(255, 255, 255, 0.15); + border-radius: 4px; + padding: 0.15rem 0.3rem; + font-size: 0.8rem; +} + .watchlater-kofi-link { font-size: 0.85rem; opacity: 0.8; diff --git a/plugins/watchlater/watchlater.js b/plugins/watchlater/watchlater.js index 084b47df..5f5b26ae 100644 --- a/plugins/watchlater/watchlater.js +++ b/plugins/watchlater/watchlater.js @@ -10,10 +10,14 @@ const { Link } = libraries.ReactRouterDOM; const TAG_NAME = "Watch Later"; + const LIST_PREFIX = "Watch Later: "; const KOFI_URL = "https://ko-fi.com/greenthumb80"; const FEEDBACK_URL = "https://github.com/spesometro2026/stash-watchlater-plugin/issues/new"; let tagId = null; let tagPromise = null; + // ids of the root tag + every sub-list tag, so we can tell "any Watch Later membership" + // apart from unrelated tags when moving a scene between lists. Populated by ensureLists(). + let familyIds = new Set(); async function gql(query, variables) { const res = await fetch("/graphql", { @@ -36,6 +40,7 @@ ); if (found.findTags.tags.length) { tagId = found.findTags.tags[0].id; + familyIds.add(tagId); return tagId; } const created = await gql( @@ -43,13 +48,53 @@ { input: { name: TAG_NAME, description: "Created by the Watch Later plugin" } } ); tagId = created.tagCreate.id; + familyIds.add(tagId); return tagId; })(); return tagPromise; } - async function removeFromWatchLater(scene) { - const tid = await ensureTag(); + // Lists = the root "Watch Later" tag ("All") plus its child tags ("Watch Later: "). + // Kept as plain Stash tag hierarchy - no state of our own, browsable/filterable outside the + // plugin too, same reasoning as the root tag itself. + async function ensureLists() { + const rootId = await ensureTag(); + const d = await gql( + "query($id: ID!) { findTag(id: $id) { children { id name } } }", + { id: rootId } + ); + const children = (d.findTag && d.findTag.children) || []; + familyIds = new Set([rootId, ...children.map((c) => c.id)]); + return { + rootId, + lists: [ + { id: rootId, name: "All" }, + ...children.map((c) => ({ + id: c.id, + name: c.name.startsWith(LIST_PREFIX) ? c.name.slice(LIST_PREFIX.length) : c.name, + })), + ], + }; + } + + async function createList(name) { + const rootId = await ensureTag(); + const created = await gql( + "mutation($input: TagCreateInput!) { tagCreate(input: $input) { id } }", + { + input: { + name: `${LIST_PREFIX}${name}`, + parent_ids: [rootId], + description: "Watch Later list, created by the Watch Later plugin", + }, + } + ); + familyIds.add(created.tagCreate.id); + return created.tagCreate.id; + } + + async function removeFromWatchLater(scene, listTagId) { + const tid = listTagId || (await ensureTag()); const currentIds = (scene.tags || []).map((t) => t.id); await gql( "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }", @@ -57,6 +102,17 @@ ); } + // Moves a scene to exactly one list: drops any other Watch Later family tag it might carry + // (root or another sub-list) and applies the target one, leaving unrelated tags untouched. + async function moveSceneToList(scene, targetListId) { + const currentIds = (scene.tags || []).map((t) => t.id); + const withoutFamily = currentIds.filter((x) => !familyIds.has(x)); + await gql( + "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id } }", + { input: { id: scene.id, tag_ids: [...withoutFamily, targetListId] } } + ); + } + // ------------------------------------------------------------------ card button (grid) function WatchLaterButton({ scene }) { // "on" is real React state (not re-derived from scene.tags on every render): mutating @@ -82,8 +138,12 @@ e.preventDefault(); e.stopPropagation(); if (busy) return; - setBusy(true); + const prevOn = on; const nextOn = !on; + // Optimistic: flip the color immediately, don't make the click feel dead while the + // GraphQL round-trip is in flight. Roll back on failure. + setOn(nextOn); + setBusy(true); try { const tid = await ensureTag(); const currentIds = (scene.tags || []).map((t) => t.id); @@ -94,12 +154,9 @@ "mutation($input: SceneUpdateInput!) { sceneUpdate(input: $input) { id tags { id } } }", { input: { id: scene.id, tag_ids: newIds } } ); - // Do NOT mutate scene.tags: the object comes from the Apollo cache and is frozen (seen - // in the console: "Cannot assign to read only property") - setOn alone is enough, "on" - // is already independent React state, scene itself doesn't need to stay in sync. - setOn(nextOn); } catch (err) { console.error("[Watch Later] toggle error:", err); + setOn(prevOn); } finally { setBusy(false); } @@ -142,8 +199,9 @@ return h > 0 ? `${h}:${pad(m)}:${pad(s)}` : `${m}:${pad(s)}`; } - function WatchLaterCard({ scene, onRemoved }) { + function WatchLaterCard({ scene, onRemoved, lists, activeListId, onMoved }) { const [removing, setRemoving] = React.useState(false); + const [moving, setMoving] = React.useState(false); const file = scene.files && scene.files[0]; async function remove(e) { @@ -152,7 +210,7 @@ if (removing) return; setRemoving(true); try { - await removeFromWatchLater(scene); + await removeFromWatchLater(scene, activeListId); onRemoved(scene.id); } catch (err) { console.error("[Watch Later] remove error:", err); @@ -160,6 +218,19 @@ } } + async function moveTo(e) { + const targetId = e.target.value; + if (!targetId || targetId === activeListId || moving) return; + setMoving(true); + try { + await moveSceneToList(scene, targetId); + onMoved(scene.id); + } catch (err) { + console.error("[Watch Later] move error:", err); + setMoving(false); + } + } + return React.createElement( "div", { className: "watchlater-row" }, @@ -202,23 +273,38 @@ scene.performers.map((p) => p.name).join(", ") ) : null, - // the "Watch Later" tag itself isn't informative here, exclude it from the shown list. - scene.tags && scene.tags.some((t) => t.id !== tagId) + // Watch Later family tags (root + every list) aren't informative here, exclude them. + scene.tags && scene.tags.some((t) => !familyIds.has(t.id)) ? React.createElement( "div", { className: "watchlater-row-tags" }, scene.tags - .filter((t) => t.id !== tagId) + .filter((t) => !familyIds.has(t.id)) .map((t) => t.name) .join(" · ") ) : null ), + lists && lists.length > 1 + ? React.createElement( + "select", + { + className: "watchlater-move-select", + value: activeListId, + onChange: moveTo, + disabled: moving, + title: "Move to another list", + }, + lists.map((l) => + React.createElement("option", { key: l.id, value: l.id }, l.name) + ) + ) + : null, React.createElement( "button", { className: "watchlater-remove-btn minimal", - title: "Remove from Watch Later", + title: "Remove from this list", onClick: remove, disabled: removing, }, @@ -230,13 +316,28 @@ function WatchLaterPage() { const [scenes, setScenes] = React.useState(null); const [error, setError] = React.useState(null); + const [autoRemoved, setAutoRemoved] = React.useState(0); + const [lists, setLists] = React.useState(null); + const [activeListId, setActiveListId] = React.useState(null); - // "updated_at" is used as a stand-in for "date added to Watch Later": Stash doesn't keep a + React.useEffect(() => { + let alive = true; + ensureLists().then(({ rootId, lists: ls }) => { + if (!alive) return; + setLists(ls); + setActiveListId((prev) => prev || rootId); + }); + return () => { + alive = false; + }; + }, []); + + // "updated_at" is used as a stand-in for "date added to a list": Stash doesn't keep a // per-tag timestamp, but tagging a scene bumps its updated_at - so the most recently added // scene ends up on top, as requested ("sort by when it was added, not by name"). const load = React.useCallback(async () => { + if (!activeListId) return; try { - const tid = await ensureTag(); const d = await gql( `query($id: ID!) { findScenes( @@ -245,7 +346,7 @@ ) { count scenes { - id title date + id title date updated_at play_history studio { name } files { basename duration } paths { screenshot } @@ -254,23 +355,95 @@ } } }`, - { id: tid } + { id: activeListId } ); - setScenes(d.findScenes.scenes); + const fetched = d.findScenes.scenes; + + // Auto-remove scenes watched since they were added to this list: "updated_at" is our + // only proxy for "when added" (see the comment above), so a scene counts as + // watched-since-add if any play_history entry is newer than it. Fires the removal in + // the background; the UI already reflects the filtered list. + const stillPending = []; + const toAutoRemove = []; + for (const s of fetched) { + const watchedSinceAdd = (s.play_history || []).some( + (t) => new Date(t) > new Date(s.updated_at) + ); + (watchedSinceAdd ? toAutoRemove : stillPending).push(s); + } + setScenes(stillPending); + setAutoRemoved(toAutoRemove.length); + for (const s of toAutoRemove) { + removeFromWatchLater(s, activeListId).catch((err) => + console.error("[Watch Later] auto-remove error:", err) + ); + } } catch (err) { console.error("[Watch Later] load error:", err); setError(String(err)); } - }, []); + }, [activeListId]); React.useEffect(() => { load(); }, [load]); + async function newList() { + const name = window.prompt("New list name:"); + if (!name || !name.trim()) return; + try { + const id = await createList(name.trim()); + setLists((prev) => [...(prev || []), { id, name: name.trim() }]); + setActiveListId(id); + } catch (err) { + console.error("[Watch Later] create list error:", err); + } + } + + function onMoved(id) { + setScenes((prev) => (prev || []).filter((s) => s.id !== id)); + } + function onRemoved(id) { setScenes((prev) => (prev || []).filter((s) => s.id !== id)); } + function exportList() { + const rows = (scenes || []).map((s) => ({ + title: s.title || (s.files && s.files[0] && s.files[0].basename) || "", + studio: (s.studio && s.studio.name) || "", + performers: (s.performers || []).map((p) => p.name).join(", "), + date: s.date || "", + duration_seconds: (s.files && s.files[0] && s.files[0].duration) || null, + url: `${location.origin}/scenes/${s.id}`, + })); + const blob = new Blob([JSON.stringify(rows, null, 2)], { + type: "application/json", + }); + const url = URL.createObjectURL(blob); + const a = document.createElement("a"); + a.href = url; + a.download = "watch-later.json"; + document.body.appendChild(a); + a.click(); + a.remove(); + URL.revokeObjectURL(url); + } + + const exportLink = React.createElement( + "a", + { + className: "watchlater-kofi-link", + href: "#", + title: "Export this list as JSON", + onClick: (e) => { + e.preventDefault(); + exportList(); + }, + }, + "⬇ Export" + ); + const kofiLink = React.createElement( "a", { @@ -298,10 +471,34 @@ const pageLinks = React.createElement( "div", { className: "watchlater-page-links" }, + exportLink, feedbackLink, kofiLink ); + const listSelector = + lists && lists.length + ? React.createElement( + "div", + { className: "watchlater-list-selector" }, + React.createElement( + "select", + { + value: activeListId || "", + onChange: (e) => setActiveListId(e.target.value), + }, + lists.map((l) => + React.createElement("option", { key: l.id, value: l.id }, l.name) + ) + ), + React.createElement( + "button", + { className: "minimal watchlater-new-list-btn", onClick: newList }, + "+ New list" + ) + ) + : null; + if (error) { return React.createElement( "div", @@ -329,6 +526,14 @@ React.createElement("h3", null, `Watch Later (${scenes.length})`), pageLinks ), + listSelector, + autoRemoved > 0 + ? React.createElement( + "p", + { className: "watchlater-auto-removed-note" }, + `${autoRemoved} scene${autoRemoved === 1 ? "" : "s"} auto-removed: already watched since being added.` + ) + : null, scenes.length === 0 ? React.createElement( "p", @@ -343,6 +548,9 @@ key: s.id, scene: s, onRemoved, + onMoved, + lists, + activeListId, }) ) ) From 6161b0f3c43d4a404f34cf950e0d5d1742c22e95 Mon Sep 17 00:00:00 2001 From: greenthumb80 Date: Wed, 23 Sep 2026 00:55:28 +0200 Subject: [PATCH 3/3] UI fixes: visible list tabs, readable dropdown colors, bookmark icon for remove --- plugins/watchlater/watchlater.css | 39 +++++++++++++++++++------ plugins/watchlater/watchlater.js | 47 +++++++++++++++++-------------- 2 files changed, 57 insertions(+), 29 deletions(-) diff --git a/plugins/watchlater/watchlater.css b/plugins/watchlater/watchlater.css index be0ed333..20e367f7 100644 --- a/plugins/watchlater/watchlater.css +++ b/plugins/watchlater/watchlater.css @@ -57,34 +57,57 @@ .watchlater-list-selector { display: flex; align-items: center; - gap: 0.5rem; + gap: 0.4rem; margin-top: 0.6rem; + flex-wrap: wrap; } -.watchlater-list-selector select { +.watchlater-list-tab { background: rgba(255, 255, 255, 0.06); color: inherit; - border: 1px solid rgba(255, 255, 255, 0.15); - border-radius: 4px; - padding: 0.2rem 0.4rem; + border: 1px solid rgba(255, 255, 255, 0.15) !important; + border-radius: 999px; + padding: 0.2rem 0.8rem !important; + font-size: 0.85rem; + opacity: 0.75; +} +.watchlater-list-tab:hover { + opacity: 1; +} +.watchlater-list-tab-active { + background: rgba(255, 255, 255, 0.16) !important; + border-color: rgba(255, 255, 255, 0.4) !important; + opacity: 1; + font-weight: 600; } .watchlater-new-list-btn { font-size: 0.8rem; - opacity: 0.75; + opacity: 0.6; } .watchlater-new-list-btn:hover { opacity: 1; } +.watchlater-move-select-wrap { + display: flex; + align-items: center; + gap: 0.25rem; + font-size: 0.85rem; + opacity: 0.75; +} .watchlater-move-select { flex: 0 0 auto; max-width: 130px; - background: rgba(255, 255, 255, 0.06); - color: inherit; + background: #fff; + color: #111 !important; border: 1px solid rgba(255, 255, 255, 0.15); border-radius: 4px; padding: 0.15rem 0.3rem; font-size: 0.8rem; } +.watchlater-move-select option { + color: #111; + background: #fff; +} .watchlater-kofi-link { font-size: 0.85rem; diff --git a/plugins/watchlater/watchlater.js b/plugins/watchlater/watchlater.js index 5f5b26ae..00b99d1f 100644 --- a/plugins/watchlater/watchlater.js +++ b/plugins/watchlater/watchlater.js @@ -2,8 +2,6 @@ "use strict"; const api = window.PluginApi; const { React, patch, libraries, register } = api; - const { faTrash } = libraries.FontAwesomeSolid; - const { Icon } = api.components; // Use Link (not a plain ): a plain triggers a full page load, which hits the // server and 404s on /plugin/watchlater (the route only exists client-side, not server-side) - // real bug found and fixed by testing live in the browser. @@ -287,16 +285,20 @@ ), lists && lists.length > 1 ? React.createElement( - "select", - { - className: "watchlater-move-select", - value: activeListId, - onChange: moveTo, - disabled: moving, - title: "Move to another list", - }, - lists.map((l) => - React.createElement("option", { key: l.id, value: l.id }, l.name) + "div", + { className: "watchlater-move-select-wrap", title: "Move to another list" }, + "➜", + React.createElement( + "select", + { + className: "watchlater-move-select", + value: activeListId, + onChange: moveTo, + disabled: moving, + }, + lists.map((l) => + React.createElement("option", { key: l.id, value: l.id }, l.name) + ) ) ) : null, @@ -308,7 +310,7 @@ onClick: remove, disabled: removing, }, - React.createElement(Icon, { icon: faTrash }) + "🔖" ) ); } @@ -481,14 +483,17 @@ ? React.createElement( "div", { className: "watchlater-list-selector" }, - React.createElement( - "select", - { - value: activeListId || "", - onChange: (e) => setActiveListId(e.target.value), - }, - lists.map((l) => - React.createElement("option", { key: l.id, value: l.id }, l.name) + lists.map((l) => + React.createElement( + "button", + { + key: l.id, + className: + "minimal watchlater-list-tab" + + (l.id === activeListId ? " watchlater-list-tab-active" : ""), + onClick: () => setActiveListId(l.id), + }, + l.name ) ), React.createElement(