Modal Esc/overflow polish, artist-scoped post scroll, failing-sources Logs button #43
@@ -92,7 +92,16 @@ let prevBodyOverflow = null
|
|||||||
// own keystrokes.
|
// own keystrokes.
|
||||||
function onKeyDown(ev) {
|
function onKeyDown(ev) {
|
||||||
if (ev.key === 'Escape') {
|
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()
|
ev.preventDefault()
|
||||||
emit('close')
|
emit('close')
|
||||||
} else if (ev.key === 'ArrowLeft') {
|
} else if (ev.key === 'ArrowLeft') {
|
||||||
|
|||||||
@@ -10,7 +10,11 @@
|
|||||||
density="compact"
|
density="compact"
|
||||||
>{{ state.error }}</v-alert>
|
>{{ 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
|
<article
|
||||||
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
|
v-for="e in state.entries" :key="e.provenance_id" class="fc-prov__card"
|
||||||
>
|
>
|
||||||
@@ -58,7 +62,7 @@
|
|||||||
</RouterLink>
|
</RouterLink>
|
||||||
</div>
|
</div>
|
||||||
</article>
|
</article>
|
||||||
</template>
|
</div>
|
||||||
|
|
||||||
<div v-if="attachments.length" class="fc-prov__attach">
|
<div v-if="attachments.length" class="fc-prov__attach">
|
||||||
<h4 class="fc-prov__attach-title">Attachments</h4>
|
<h4 class="fc-prov__attach-title">Attachments</h4>
|
||||||
@@ -159,6 +163,18 @@ function openPost(postId, artistId) {
|
|||||||
color: rgb(var(--v-theme-on-surface));
|
color: rgb(var(--v-theme-on-surface));
|
||||||
margin-bottom: 12px;
|
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 {
|
.fc-prov__card {
|
||||||
border: 1px solid rgb(var(--v-theme-surface-light));
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
||||||
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
border-radius: 6px; padding: 10px 12px; margin-bottom: 10px;
|
||||||
|
|||||||
@@ -58,6 +58,7 @@
|
|||||||
:retrying-all="retryingAll"
|
:retrying-all="retryingAll"
|
||||||
@retry="onRetrySource"
|
@retry="onRetrySource"
|
||||||
@retry-all="onRetryAll"
|
@retry-all="onRetryAll"
|
||||||
|
@view-logs="onViewFailingLogs"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<div v-if="store.loading && store.events.length === 0" class="fc-dl__loading">
|
<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) {
|
async function openDetail(id) {
|
||||||
await store.loadOne(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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -29,6 +29,14 @@
|
|||||||
{{ s.last_error || 'no error message recorded' }}
|
{{ s.last_error || 'no error message recorded' }}
|
||||||
</span>
|
</span>
|
||||||
<v-spacer />
|
<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
|
<v-btn
|
||||||
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
size="x-small" variant="text" prepend-icon="mdi-refresh"
|
||||||
:loading="retryingIds.has(s.id)"
|
:loading="retryingIds.has(s.id)"
|
||||||
@@ -52,9 +60,23 @@ defineProps({
|
|||||||
retryingIds: { type: Set, default: () => new Set() },
|
retryingIds: { type: Set, default: () => new Set() },
|
||||||
retryingAll: { type: Boolean, default: false },
|
retryingAll: { type: Boolean, default: false },
|
||||||
})
|
})
|
||||||
defineEmits(['retry', 'retry-all'])
|
const emit = defineEmits(['retry', 'retry-all', 'view-logs'])
|
||||||
|
|
||||||
const open = ref(true)
|
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>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
|
|||||||
@@ -55,6 +55,21 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return selected.value
|
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) {
|
async function applyFilter(patch) {
|
||||||
filter.value = { ...filter.value, ...patch }
|
filter.value = { ...filter.value, ...patch }
|
||||||
await loadFirst()
|
await loadFirst()
|
||||||
@@ -98,7 +113,8 @@ export const useDownloadsStore = defineStore('downloads', () => {
|
|||||||
return {
|
return {
|
||||||
events, cursor, hasMore, filter, selected, loading, error, stats,
|
events, cursor, hasMore, filter, selected, loading, error, stats,
|
||||||
activity, failing, activeEvents,
|
activity, failing, activeEvents,
|
||||||
loadFirst, loadMore, loadOne, applyFilter, closeDetail, loadStats,
|
loadFirst, loadMore, loadOne, loadLastForSource, applyFilter,
|
||||||
|
closeDetail, loadStats,
|
||||||
loadActivity, loadFailing, loadActive, recoverStalled,
|
loadActivity, loadFailing, loadActive, recoverStalled,
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -59,14 +59,37 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
return await api.get(`/api/posts/${id}`)
|
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
|
// Load a window centered on `postId`: newer posts above, the post, older
|
||||||
// posts below. Sets both directional cursors for subsequent scrolling.
|
// 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
|
loading.value = true
|
||||||
error.value = null
|
error.value = null
|
||||||
anchorId.value = null
|
anchorId.value = null
|
||||||
try {
|
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
|
items.value = body.items
|
||||||
cursorOlder.value = body.cursor_older
|
cursorOlder.value = body.cursor_older
|
||||||
cursorNewer.value = body.cursor_newer
|
cursorNewer.value = body.cursor_newer
|
||||||
@@ -85,7 +108,9 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
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)
|
items.value.push(...body.items)
|
||||||
cursorOlder.value = body.next_cursor
|
cursorOlder.value = body.next_cursor
|
||||||
@@ -102,7 +127,9 @@ export const usePostsStore = defineStore('posts', () => {
|
|||||||
loading.value = true
|
loading.value = true
|
||||||
try {
|
try {
|
||||||
const body = await api.get('/api/posts', {
|
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)
|
items.value.unshift(...body.items)
|
||||||
cursorNewer.value = body.next_cursor
|
cursorNewer.value = body.next_cursor
|
||||||
|
|||||||
@@ -161,7 +161,15 @@ function setupAroundObservers() {
|
|||||||
}
|
}
|
||||||
async function loadAroundAndAnchor() {
|
async function loadAroundAndAnchor() {
|
||||||
teardownFeed()
|
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()
|
await nextTick()
|
||||||
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
const el = document.getElementById(`fc-post-${store.anchorId}`)
|
||||||
if (el) el.scrollIntoView({ block: 'center' })
|
if (el) el.scrollIntoView({ block: 'center' })
|
||||||
|
|||||||
Reference in New Issue
Block a user