Merge pull request 'Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button' (#43) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 8s
CI / frontend-build (push) Successful in 21s
CI / backend-lint-and-test (push) Successful in 31s
Build images / build-web (push) Successful in 2m45s
Build images / build-ml (push) Successful in 3m31s
CI / intimp (push) Successful in 3m48s
CI / intapi (push) Successful in 7m11s
CI / intcore (push) Successful in 7m47s

This commit was merged in pull request #43.
This commit is contained in:
2026-06-01 12:10:11 -04:00
7 changed files with 126 additions and 10 deletions
+10 -1
View File
@@ -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') {
@@ -10,7 +10,11 @@
density="compact"
>{{ state.error }}</v-alert>
<template v-else>
<!-- Cards scroll independently of the section title + attachments
below them. Cap at ~2.5 cards visible (operator-asked 2026-06-01:
keeps the Tags section anchored below at a consistent position;
the half-visible third card hints there's more). -->
<div v-else class="fc-prov__cards">
<article
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
>
@@ -58,7 +62,7 @@
</RouterLink>
</div>
</article>
</template>
</div>
<div v-if="attachments.length" class="fc-prov__attach">
<h4 class="fc-prov__attach-title">Attachments</h4>
@@ -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;
@@ -58,6 +58,7 @@
:retrying-all="retryingAll"
@retry="onRetrySource"
@retry-all="onRetryAll"
@view-logs="onViewFailingLogs"
/>
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
@@ -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' })
}
}
</script>
<style scoped>
@@ -29,6 +29,14 @@
{{ s.last_error || 'no error message recorded' }}
</span>
<v-spacer />
<v-btn
size="x-small" variant="text" prepend-icon="mdi-text-box-search-outline"
:loading="logLoadingIds.has(s.id)"
@click="onViewLogs(s)"
title="Show the most recent download event's stdout/stderr/error"
>
Logs
</v-btn>
<v-btn
size="x-small" variant="text" prepend-icon="mdi-refresh"
:loading="retryingIds.has(s.id)"
@@ -52,9 +60,23 @@ defineProps({
retryingIds: { type: Set, default: () => new Set() },
retryingAll: { type: Boolean, default: false },
})
defineEmits(['retry', 'retry-all'])
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
const open = ref(true)
// Per-row loading flag so the spinner lives on the row whose Logs
// button was clicked, not on every row.
const logLoadingIds = ref(new Set())
async function onViewLogs(s) {
if (logLoadingIds.value.has(s.id)) return
logLoadingIds.value = new Set(logLoadingIds.value).add(s.id)
try {
await emit('view-logs', s)
} finally {
const next = new Set(logLoadingIds.value)
next.delete(s.id)
logLoadingIds.value = next
}
}
</script>
<style scoped>
+17 -1
View File
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
return selected.value
}
// Open the detail modal for the most recent DownloadEvent of a given
// source. Used by the failing-sources rollup's "Logs" button so the
// operator can troubleshoot without leaving the Downloads tab to find
// the row (operator-flagged 2026-06-01).
async function loadLastForSource(sourceId) {
const events = await api.get('/api/downloads', {
params: { source_id: sourceId, limit: 1 },
})
if (!events.length) {
selected.value = null
return null
}
return await loadOne(events[0].id)
}
async function applyFilter(patch) {
filter.value = { ...filter.value, ...patch }
await loadFirst()
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
return {
events, cursor, hasMore, filter, selected, loading, error, stats,
activity, failing, activeEvents,
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
closeDetail, loadStats,
loadActivity, loadFailing, loadActive, recoverStalled,
}
})
+31 -4
View File
@@ -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
+9 -1
View File
@@ -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' })