From 4c56cf121ffcb6418a1f6ec5221e3409527a2c0e Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 08:17:54 -0400 Subject: [PATCH 1/3] fix(modal): Esc closes from tag input; Provenance cards scroll at ~2.5 MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Two operator-flagged UX gaps from 2026-06-01: 1. **Esc trapped inside the tag-entry field** The autofocused TagAutocomplete input made Esc-to-close unreachable because ImageViewer's keydown handler bailed early on `isTextEntry(ev.target)` for every key including Escape. Now Escape closes the modal from text inputs too — except when a Vuetify overlay is open (FandomPicker, autocomplete dropdown, suggestion 3-dot menu), in which case that overlay's own Esc-handling fires instead of closing the whole modal mid-interaction. Detected via `.v-overlay--active`. Arrow keys still gate on isTextEntry so the tag input handles typing without navigating images. 2. **Provenance cards expanded the side panel unbounded** When an image had many ImageProvenance entries the cards stack pushed the Tags section below the fold. Wrapped the cards in a `.fc-prov__cards` container with max-height 270px (≈2.5 cards) and thin overflow scrollbar. Title stays anchored at top; Tags panel sits at a consistent position below regardless of provenance count. --- frontend/src/components/modal/ImageViewer.vue | 11 +++++++++- .../src/components/modal/ProvenancePanel.vue | 20 +++++++++++++++++-- 2 files changed, 28 insertions(+), 3 deletions(-) diff --git a/frontend/src/components/modal/ImageViewer.vue b/frontend/src/components/modal/ImageViewer.vue index 0ea01bc..84cb495 100644 --- a/frontend/src/components/modal/ImageViewer.vue +++ b/frontend/src/components/modal/ImageViewer.vue @@ -92,7 +92,16 @@ let prevBodyOverflow = null // own keystrokes. function onKeyDown(ev) { if (ev.key === 'Escape') { - if (isTextEntry(ev.target)) return + // Escape closes the modal even from inside a text input — that's + // the universal "get me out of here" expectation, and the + // autofocused tag-entry field would otherwise trap focus with no + // visible escape (operator-flagged 2026-06-01). EXCEPTION: when a + // nested Vuetify overlay is open (v-menu autocomplete dropdown, + // FandomPicker v-dialog, per-suggestion 3-dot menu), let that + // overlay's own Esc handling fire instead of closing the whole + // modal mid-interaction. Vuetify marks open overlays with + // `.v-overlay--active`. + if (document.querySelector('.v-overlay--active')) return ev.preventDefault() emit('close') } else if (ev.key === 'ArrowLeft') { diff --git a/frontend/src/components/modal/ProvenancePanel.vue b/frontend/src/components/modal/ProvenancePanel.vue index 1d8e5ee..5c36fd4 100644 --- a/frontend/src/components/modal/ProvenancePanel.vue +++ b/frontend/src/components/modal/ProvenancePanel.vue @@ -10,7 +10,11 @@ density="compact" >{{ state.error }} - +

Attachments

@@ -159,6 +163,18 @@ function openPost(postId, artistId) { color: rgb(var(--v-theme-on-surface)); margin-bottom: 12px; } +.fc-prov__cards { + /* 2.5 cards-worth at the typical collapsed card height (~108px each + incl. 10px gap). Slightly under to ensure the third card's bottom + edge is clipped — the visual cue that there's more below. */ + max-height: 270px; + overflow-y: auto; + /* Hairline scrollbar that doesn't compete with content. */ + scrollbar-width: thin; + scrollbar-color: rgb(var(--v-theme-surface-light)) transparent; + /* Pad-right so the scrollbar gutter doesn't squeeze card borders. */ + padding-right: 4px; +} .fc-prov__card { border: 1px solid rgb(var(--v-theme-surface-light)); border-radius: 6px; padding: 10px 12px; margin-bottom: 10px; From fb605af959fb033f67bb45592f9d721045af4824 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 08:24:43 -0400 Subject: [PATCH 2/3] fix(posts): anchored view scroll inherits artist_id/platform filters MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-flagged 2026-06-01: clicking a Provenance post title from the view modal opens the post in the posts feed scoped to that artist (`/posts?post_id=N&artist_id=A`), but the older/newer infinite-scroll loaded UNFILTERED global posts instead of staying in the artist's stream. Root cause: the posts store's loadAround/loadOlder/loadNewer sent only `{around, cursor, direction}` — never the `artist_id` / `platform` filters. Backend `/api/posts` accepts them on every path (post_feed_service.scroll filters via `Source.artist_id`); the frontend just wasn't passing them. Fix: - `posts.js` `loadAround(postId, filters)` now takes the filter snapshot and stores it. `_aroundParams(extra)` mixes the active filters into around/cursor calls. - `PostsView.vue` `loadAroundAndAnchor` passes `artist_id` + `platform` from the route query. --- frontend/src/stores/posts.js | 35 ++++++++++++++++++++++++++++---- frontend/src/views/PostsView.vue | 10 ++++++++- 2 files changed, 40 insertions(+), 5 deletions(-) diff --git a/frontend/src/stores/posts.js b/frontend/src/stores/posts.js index f02e980..ac45a6e 100644 --- a/frontend/src/stores/posts.js +++ b/frontend/src/stores/posts.js @@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => { return await api.get(`/api/posts/${id}`) } + // Filter overlay for the around/older/newer (in-context anchored) + // path. Keep this distinct from `filters.value` (the down-only feed) + // so a normal-feed filter change doesn't leak into an active anchored + // view (or vice versa). Caller of loadAround passes the snapshot; the + // subsequent loadOlder/loadNewer use it verbatim. + function _aroundParams(extra) { + const p = { ...extra } + if (filters.value.artist_id != null) p.artist_id = filters.value.artist_id + if (filters.value.platform) p.platform = filters.value.platform + return p + } + // Load a window centered on `postId`: newer posts above, the post, older // posts below. Sets both directional cursors for subsequent scrolling. - async function loadAround(postId) { + // Accepts the same filter shape as loadInitial so the anchored view + // stays artist/platform-scoped (operator-flagged 2026-06-01: clicking a + // post title from the modal's Provenance card opens the post in the + // posts feed; without this the older/newer scroll loaded unfiltered + // global posts instead of staying in the artist's stream). + async function loadAround(postId, newFilters) { + filters.value = { + artist_id: newFilters?.artist_id ?? null, + platform: newFilters?.platform ?? null, + } loading.value = true error.value = null anchorId.value = null try { - const body = await api.get('/api/posts', { params: { around: postId } }) + const body = await api.get('/api/posts', { + params: _aroundParams({ around: postId }), + }) items.value = body.items cursorOlder.value = body.cursor_older cursorNewer.value = body.cursor_newer @@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => { loading.value = true try { const body = await api.get('/api/posts', { - params: { cursor: cursorOlder.value, direction: 'older' }, + params: _aroundParams({ + cursor: cursorOlder.value, direction: 'older', + }), }) items.value.push(...body.items) cursorOlder.value = body.next_cursor @@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => { loading.value = true try { const body = await api.get('/api/posts', { - params: { cursor: cursorNewer.value, direction: 'newer' }, + params: _aroundParams({ + cursor: cursorNewer.value, direction: 'newer', + }), }) items.value.unshift(...body.items) cursorNewer.value = body.next_cursor diff --git a/frontend/src/views/PostsView.vue b/frontend/src/views/PostsView.vue index a08a09d..0485a5a 100644 --- a/frontend/src/views/PostsView.vue +++ b/frontend/src/views/PostsView.vue @@ -161,7 +161,15 @@ function setupAroundObservers() { } async function loadAroundAndAnchor() { teardownFeed() - await store.loadAround(postIdFilter.value) + // Pass artist_id + platform through so the anchored view stays + // scoped — the older/newer infinite scrolls then read these filters + // back via the store's _aroundParams (operator-flagged 2026-06-01: + // post-title click from the modal landed scoped but the scroll then + // pulled unfiltered global posts). + await store.loadAround(postIdFilter.value, { + artist_id: artistFilter.value, + platform: platformFilter.value, + }) await nextTick() const el = document.getElementById(`fc-post-${store.anchorId}`) if (el) el.scrollIntoView({ block: 'center' }) From 94e7d20792aeb309fb80ba220e808b99aa3167d8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Mon, 1 Jun 2026 10:04:22 -0400 Subject: [PATCH 3/3] feat(downloads): Logs button on failing-sources rollup rows MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit Operator-asked 2026-06-01: each row in the "X sources are failing" rollup needs a button to surface the last run's stdout/stderr/error without leaving the Downloads tab to find the matching event row. Wired: - `downloadsStore.loadLastForSource(sourceId)` two-steps via `GET /api/downloads?source_id=N&limit=1` (most recent event) then the existing `loadOne(id)` for the full detail payload. Returns null if no events exist (toast warns). - `FailingSourcesCard` row: new text button `Logs` with `mdi-text-box-search-outline`, per-row spinner via `logLoadingIds`, emits `view-logs` with the source record. - `DownloadsTab.onViewFailingLogs` is the handler — same `DownloadDetailModal` instance the event-row clicks use. --- .../components/subscriptions/DownloadsTab.vue | 18 ++++++++++++++ .../subscriptions/FailingSourcesCard.vue | 24 ++++++++++++++++++- frontend/src/stores/downloads.js | 18 +++++++++++++- 3 files changed, 58 insertions(+), 2 deletions(-) diff --git a/frontend/src/components/subscriptions/DownloadsTab.vue b/frontend/src/components/subscriptions/DownloadsTab.vue index 9a48144..5c26b07 100644 --- a/frontend/src/components/subscriptions/DownloadsTab.vue +++ b/frontend/src/components/subscriptions/DownloadsTab.vue @@ -58,6 +58,7 @@ :retrying-all="retryingAll" @retry="onRetrySource" @retry-all="onRetryAll" + @view-logs="onViewFailingLogs" />
@@ -364,6 +365,23 @@ watch(filterModel, async (m) => { async function openDetail(id) { await store.loadOne(id) } + +async function onViewFailingLogs(source) { + // Find and open the most recent DownloadEvent for this source. + // Reuses the existing DownloadDetailModal — same stdout/stderr/error + // surface the row-click in the events feed shows. + try { + const ev = await store.loadLastForSource(source.id) + if (!ev) { + toast({ + text: `No download events recorded for ${source.artist_name || source.platform} yet.`, + type: 'warning', + }) + } + } catch (e) { + toast({ text: `Failed to load logs: ${e.message}`, type: 'error' }) + } +}