feat(ui): redesign /subscriptions as v-data-table with expandable source rows (GS-style)
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -1,64 +0,0 @@
|
|||||||
<template>
|
|
||||||
<section class="fc-artist-section">
|
|
||||||
<header class="fc-artist-section__head" @click="$emit('toggle')">
|
|
||||||
<v-icon :icon="open ? 'mdi-chevron-down' : 'mdi-chevron-right'" size="small" />
|
|
||||||
<span class="fc-artist-section__name">{{ artist.name }}</span>
|
|
||||||
<span class="fc-artist-section__meta">
|
|
||||||
{{ sources.length }} source{{ sources.length === 1 ? '' : 's' }}
|
|
||||||
<template v-if="latestChecked">
|
|
||||||
· last check {{ latestChecked }}
|
|
||||||
</template>
|
|
||||||
</span>
|
|
||||||
</header>
|
|
||||||
<div v-if="open" class="fc-artist-section__body">
|
|
||||||
<SourceRow
|
|
||||||
v-for="s in sources" :key="s.id" :source="s"
|
|
||||||
:checking="checkingIds.has(s.id)"
|
|
||||||
@edit="$emit('edit', $event)"
|
|
||||||
@remove="$emit('remove', $event)"
|
|
||||||
@toggle="$emit('toggle-source', $event)"
|
|
||||||
@check="$emit('check', $event)"
|
|
||||||
/>
|
|
||||||
<v-btn
|
|
||||||
size="small" variant="text" prepend-icon="mdi-plus"
|
|
||||||
class="fc-artist-section__add"
|
|
||||||
@click="$emit('add-source', artist)"
|
|
||||||
>Add source to {{ artist.name }}</v-btn>
|
|
||||||
</div>
|
|
||||||
</section>
|
|
||||||
</template>
|
|
||||||
|
|
||||||
<script setup>
|
|
||||||
import { computed } from 'vue'
|
|
||||||
import SourceRow from './SourceRow.vue'
|
|
||||||
|
|
||||||
const props = defineProps({
|
|
||||||
artist: { type: Object, required: true },
|
|
||||||
sources: { type: Array, required: true },
|
|
||||||
open: { type: Boolean, default: false },
|
|
||||||
checkingIds: { type: Set, default: () => new Set() },
|
|
||||||
})
|
|
||||||
defineEmits(['toggle', 'edit', 'remove', 'toggle-source', 'add-source', 'check'])
|
|
||||||
|
|
||||||
const latestChecked = computed(() => {
|
|
||||||
const dates = props.sources.map(s => s.last_checked_at).filter(Boolean)
|
|
||||||
if (dates.length === 0) return null
|
|
||||||
const latest = dates.sort().slice(-1)[0]
|
|
||||||
return latest.slice(0, 10)
|
|
||||||
})
|
|
||||||
</script>
|
|
||||||
|
|
||||||
<style scoped>
|
|
||||||
.fc-artist-section { border-bottom: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.18); }
|
|
||||||
.fc-artist-section__head {
|
|
||||||
display: flex; align-items: center; gap: 0.5rem;
|
|
||||||
padding: 0.75rem 0; cursor: pointer; user-select: none;
|
|
||||||
}
|
|
||||||
.fc-artist-section__name { font-weight: 600; }
|
|
||||||
.fc-artist-section__meta {
|
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
|
||||||
font-size: 0.85rem;
|
|
||||||
}
|
|
||||||
.fc-artist-section__body { padding: 0 0 0.75rem 1.5rem; }
|
|
||||||
.fc-artist-section__add { margin-top: 0.25rem; }
|
|
||||||
</style>
|
|
||||||
@@ -1,25 +1,63 @@
|
|||||||
<template>
|
<template>
|
||||||
<div class="fc-source-row">
|
<tr class="fc-source-row">
|
||||||
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
|
<td class="fc-source-row__health">
|
||||||
<v-chip size="x-small" variant="tonal" class="fc-source-row__platform">
|
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
|
||||||
{{ source.platform }}
|
</td>
|
||||||
</v-chip>
|
<td>
|
||||||
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url">
|
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
|
||||||
{{ source.url }}
|
</td>
|
||||||
</a>
|
<td class="fc-source-row__url-cell">
|
||||||
<v-switch
|
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url"
|
||||||
:model-value="source.enabled"
|
@click.stop>
|
||||||
density="compact" hide-details color="accent"
|
{{ source.url }}
|
||||||
@update:model-value="onToggleEnabled"
|
</a>
|
||||||
/>
|
</td>
|
||||||
<v-btn
|
<td>
|
||||||
icon="mdi-play" size="x-small" variant="text"
|
<v-switch
|
||||||
:loading="checking"
|
:model-value="source.enabled"
|
||||||
@click="$emit('check', source)"
|
density="compact" hide-details color="accent"
|
||||||
/>
|
@click.stop
|
||||||
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="$emit('edit', source)" />
|
@update:model-value="onToggleEnabled"
|
||||||
<v-btn icon="mdi-close" size="x-small" variant="text" @click="$emit('remove', source)" />
|
/>
|
||||||
</div>
|
</td>
|
||||||
|
<td class="fc-source-row__when">
|
||||||
|
{{ formatRelative(source.last_checked_at) }}
|
||||||
|
</td>
|
||||||
|
<td class="fc-source-row__when">
|
||||||
|
{{ formatRelative(source.next_check_at, { future: true }) }}
|
||||||
|
</td>
|
||||||
|
<td>
|
||||||
|
<v-chip
|
||||||
|
v-if="(source.consecutive_failures || 0) > 0"
|
||||||
|
size="x-small" color="error" variant="tonal" label
|
||||||
|
>{{ source.consecutive_failures }}</v-chip>
|
||||||
|
<span v-else class="fc-source-row__zero">0</span>
|
||||||
|
</td>
|
||||||
|
<td class="fc-source-row__actions">
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-play" size="x-small" variant="text"
|
||||||
|
:loading="checking"
|
||||||
|
@click.stop="$emit('check', source)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-play</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Check now</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-pencil" size="x-small" variant="text"
|
||||||
|
@click.stop="$emit('edit', source)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-pencil</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Edit</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon="mdi-close" size="x-small" variant="text" color="error"
|
||||||
|
@click.stop="$emit('remove', source)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-close</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Remove</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
@@ -31,25 +69,59 @@ const props = defineProps({
|
|||||||
warningThreshold: { type: Number, default: 5 },
|
warningThreshold: { type: Number, default: 5 },
|
||||||
})
|
})
|
||||||
const emit = defineEmits(['edit', 'remove', 'toggle', 'check'])
|
const emit = defineEmits(['edit', 'remove', 'toggle', 'check'])
|
||||||
|
|
||||||
function onToggleEnabled(value) {
|
function onToggleEnabled(value) {
|
||||||
emit('toggle', { source: props.source, enabled: value })
|
emit('toggle', { source: props.source, enabled: value })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso, opts = {}) {
|
||||||
|
if (!iso) return opts.future ? '—' : 'Never'
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
const now = Date.now()
|
||||||
|
const diff = (then - now) / 1000 // seconds; positive = future, negative = past
|
||||||
|
const abs = Math.abs(diff)
|
||||||
|
|
||||||
|
let body
|
||||||
|
if (abs < 60) body = `${Math.floor(abs)}s`
|
||||||
|
else if (abs < 3600) body = `${Math.floor(abs / 60)}m`
|
||||||
|
else if (abs < 86400) body = `${Math.floor(abs / 3600)}h`
|
||||||
|
else body = `${Math.floor(abs / 86400)}d`
|
||||||
|
|
||||||
|
if (opts.future) return diff <= 0 ? 'imminent' : `in ${body}`
|
||||||
|
return `${body} ago`
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-source-row {
|
.fc-source-row__health {
|
||||||
display: grid;
|
width: 24px;
|
||||||
grid-template-columns: auto 96px 1fr auto auto auto auto;
|
padding-right: 0 !important;
|
||||||
gap: 0.75rem;
|
}
|
||||||
align-items: center;
|
.fc-source-row__url-cell {
|
||||||
padding: 0.4rem 0;
|
max-width: 400px;
|
||||||
|
overflow: hidden;
|
||||||
}
|
}
|
||||||
.fc-source-row__url {
|
.fc-source-row__url {
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
text-decoration: none;
|
text-decoration: none;
|
||||||
|
display: inline-block;
|
||||||
|
max-width: 100%;
|
||||||
overflow: hidden;
|
overflow: hidden;
|
||||||
text-overflow: ellipsis;
|
text-overflow: ellipsis;
|
||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
.fc-source-row__url:hover { color: rgb(var(--v-theme-accent)); }
|
.fc-source-row__url:hover { color: rgb(var(--v-theme-accent)); }
|
||||||
|
.fc-source-row__when {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.fc-source-row__zero {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.fc-source-row__actions {
|
||||||
|
white-space: nowrap;
|
||||||
|
text-align: right;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
@@ -8,9 +8,13 @@
|
|||||||
New artist
|
New artist
|
||||||
</v-btn>
|
</v-btn>
|
||||||
<v-spacer />
|
<v-spacer />
|
||||||
<v-btn variant="text" size="small" @click="expandAll = !expandAll">
|
<v-text-field
|
||||||
{{ expandAll ? 'Collapse all' : 'Expand all' }}
|
v-model="search"
|
||||||
</v-btn>
|
density="compact" variant="outlined" hide-details clearable
|
||||||
|
prepend-inner-icon="mdi-magnify"
|
||||||
|
placeholder="Search subscriptions"
|
||||||
|
style="max-width: 320px"
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
|
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
|
||||||
@@ -21,24 +25,121 @@
|
|||||||
<v-progress-circular indeterminate color="accent" size="36" />
|
<v-progress-circular indeterminate color="accent" size="36" />
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else-if="groups.length === 0" class="fc-subs__empty">
|
<div v-else-if="filteredGroups.length === 0" class="fc-subs__empty">
|
||||||
<p>No subscriptions yet. Add your first artist.</p>
|
<p v-if="groups.length === 0">No subscriptions yet. Add your first artist.</p>
|
||||||
|
<p v-else>No subscriptions match "{{ search }}".</p>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div v-else>
|
<v-card v-else class="fc-subs__card" variant="outlined">
|
||||||
<ArtistSection
|
<v-data-table
|
||||||
v-for="g in groups" :key="g.artist.id"
|
:headers="headers"
|
||||||
:artist="g.artist" :sources="g.sources"
|
:items="filteredGroups"
|
||||||
:open="isOpen(g.artist.id)"
|
item-value="key"
|
||||||
:checking-ids="store.checkingIds"
|
v-model:expanded="expanded"
|
||||||
@toggle="toggleSection(g.artist.id)"
|
:items-per-page="50"
|
||||||
@edit="openEditSource"
|
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
||||||
@remove="removeSource"
|
density="comfortable"
|
||||||
@toggle-source="toggleSourceEnabled"
|
hover
|
||||||
@add-source="openAddSource"
|
show-expand
|
||||||
@check="onCheck"
|
@click:row="onRowClick"
|
||||||
/>
|
>
|
||||||
</div>
|
<template #item.name="{ item }">
|
||||||
|
<span class="fc-subs__name">{{ item.artist.name }}</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.sources_count="{ item }">
|
||||||
|
<v-chip size="x-small" variant="tonal" label>
|
||||||
|
{{ item.sources.length }} source{{ item.sources.length === 1 ? '' : 's' }}
|
||||||
|
</v-chip>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.health="{ item }">
|
||||||
|
<SourceHealthDot
|
||||||
|
v-if="item.worstSource"
|
||||||
|
:source="item.worstSource"
|
||||||
|
:warning-threshold="failureThreshold"
|
||||||
|
/>
|
||||||
|
<span v-else class="fc-subs__zero">—</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.last_activity="{ item }">
|
||||||
|
<span class="fc-subs__when">
|
||||||
|
{{ formatRelative(item.lastActivity) }}
|
||||||
|
</span>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #item.actions="{ item }">
|
||||||
|
<v-btn
|
||||||
|
icon size="small" variant="text"
|
||||||
|
:loading="anyChecking(item.sources)"
|
||||||
|
@click.stop="checkAll(item)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-refresh</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Check all sources</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon size="small" variant="text"
|
||||||
|
@click.stop="openAddSource(item.artist)"
|
||||||
|
>
|
||||||
|
<v-icon>mdi-plus</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon size="small" variant="text"
|
||||||
|
:to="`/posts?artist_id=${item.artist.id}`"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<v-icon>mdi-rss</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
<v-btn
|
||||||
|
icon size="small" variant="text"
|
||||||
|
:to="`/artist/${item.artist.slug}`"
|
||||||
|
@click.stop
|
||||||
|
>
|
||||||
|
<v-icon>mdi-account</v-icon>
|
||||||
|
<v-tooltip activator="parent" location="top">Open artist page</v-tooltip>
|
||||||
|
</v-btn>
|
||||||
|
</template>
|
||||||
|
|
||||||
|
<template #expanded-row="{ columns, item }">
|
||||||
|
<tr class="fc-subs__sources-row">
|
||||||
|
<td :colspan="columns.length" class="fc-subs__sources-cell">
|
||||||
|
<v-table density="compact" class="fc-subs__sources-table">
|
||||||
|
<thead>
|
||||||
|
<tr>
|
||||||
|
<th></th>
|
||||||
|
<th>Platform</th>
|
||||||
|
<th>URL</th>
|
||||||
|
<th>Enabled</th>
|
||||||
|
<th>Last check</th>
|
||||||
|
<th>Next check</th>
|
||||||
|
<th>Errors</th>
|
||||||
|
<th class="text-right">Actions</th>
|
||||||
|
</tr>
|
||||||
|
</thead>
|
||||||
|
<tbody>
|
||||||
|
<SourceRow
|
||||||
|
v-for="s in item.sources" :key="s.id" :source="s"
|
||||||
|
:checking="store.checkingIds.has(s.id)"
|
||||||
|
:warning-threshold="failureThreshold"
|
||||||
|
@edit="openEditSource"
|
||||||
|
@remove="removeSource"
|
||||||
|
@toggle="toggleSourceEnabled"
|
||||||
|
@check="onCheck"
|
||||||
|
/>
|
||||||
|
<tr v-if="item.sources.length === 0">
|
||||||
|
<td colspan="8" class="fc-subs__sources-empty">
|
||||||
|
No sources yet. Click + to add one.
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</tbody>
|
||||||
|
</v-table>
|
||||||
|
</td>
|
||||||
|
</tr>
|
||||||
|
</template>
|
||||||
|
</v-data-table>
|
||||||
|
</v-card>
|
||||||
|
|
||||||
<SourceFormDialog
|
<SourceFormDialog
|
||||||
v-model="showSourceDialog"
|
v-model="showSourceDialog"
|
||||||
@@ -55,52 +156,140 @@ import { computed, onMounted, ref, watch } from 'vue'
|
|||||||
import { useRoute, useRouter } from 'vue-router'
|
import { useRoute, useRouter } from 'vue-router'
|
||||||
import { useSourcesStore } from '../stores/sources.js'
|
import { useSourcesStore } from '../stores/sources.js'
|
||||||
import { usePlatformsStore } from '../stores/platforms.js'
|
import { usePlatformsStore } from '../stores/platforms.js'
|
||||||
import ArtistSection from '../components/subscriptions/ArtistSection.vue'
|
import { useImportStore } from '../stores/import.js'
|
||||||
|
import SourceRow from '../components/subscriptions/SourceRow.vue'
|
||||||
|
import SourceHealthDot from '../components/subscriptions/SourceHealthDot.vue'
|
||||||
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
|
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
|
||||||
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
|
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
|
||||||
|
|
||||||
|
const ITEMS_PER_PAGE_OPTIONS = [
|
||||||
|
{ value: 25, title: '25' },
|
||||||
|
{ value: 50, title: '50' },
|
||||||
|
{ value: 100, title: '100' },
|
||||||
|
{ value: -1, title: 'All' },
|
||||||
|
]
|
||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useSourcesStore()
|
const store = useSourcesStore()
|
||||||
const platformsStore = usePlatformsStore()
|
const platformsStore = usePlatformsStore()
|
||||||
|
const importStore = useImportStore()
|
||||||
|
|
||||||
|
const search = ref('')
|
||||||
|
const expanded = ref([])
|
||||||
|
const showSourceDialog = ref(false)
|
||||||
|
const editingSource = ref(null)
|
||||||
|
const editingArtist = ref(null)
|
||||||
|
const showArtistDialog = ref(false)
|
||||||
|
|
||||||
const artistFilter = computed(() => {
|
const artistFilter = computed(() => {
|
||||||
const raw = route.query.artist_id
|
const raw = route.query.artist_id
|
||||||
return raw == null ? null : Number(raw)
|
return raw == null ? null : Number(raw)
|
||||||
})
|
})
|
||||||
|
|
||||||
const expandAll = ref(false)
|
const failureThreshold = computed(() =>
|
||||||
const openSections = ref(new Set())
|
importStore.settings?.download_failure_warning_threshold ?? 5
|
||||||
const showSourceDialog = ref(false)
|
)
|
||||||
const editingSource = ref(null)
|
|
||||||
const editingArtist = ref(null)
|
|
||||||
const showArtistDialog = ref(false)
|
|
||||||
|
|
||||||
async function refresh() {
|
async function refresh() {
|
||||||
await store.loadAll()
|
await store.loadAll()
|
||||||
await platformsStore.loadAll()
|
await platformsStore.loadAll()
|
||||||
if (artistFilter.value != null) {
|
if (!importStore.settings) await importStore.loadSettings()
|
||||||
openSections.value = new Set([artistFilter.value])
|
|
||||||
expandAll.value = false
|
|
||||||
}
|
|
||||||
}
|
}
|
||||||
|
|
||||||
onMounted(refresh)
|
onMounted(() => {
|
||||||
|
refresh()
|
||||||
|
if (artistFilter.value != null) {
|
||||||
|
// Pre-expand the row that the deep-link refers to.
|
||||||
|
expanded.value = [`artist-${artistFilter.value}`]
|
||||||
|
}
|
||||||
|
})
|
||||||
watch(() => route.query.artist_id, refresh)
|
watch(() => route.query.artist_id, refresh)
|
||||||
|
|
||||||
|
const headers = [
|
||||||
|
{ title: 'Subscription', key: 'name', sortable: true, align: 'start' },
|
||||||
|
{ title: 'Sources', key: 'sources_count', sortable: true, align: 'start', width: 110 },
|
||||||
|
{ title: 'Health', key: 'health', sortable: false, align: 'start', width: 80 },
|
||||||
|
{ title: 'Last activity',key: 'last_activity', sortable: true, align: 'start', width: 140 },
|
||||||
|
{ title: 'Actions', key: 'actions', sortable: false, align: 'end', width: 200 },
|
||||||
|
]
|
||||||
|
|
||||||
const groups = computed(() => {
|
const groups = computed(() => {
|
||||||
const all = store.sourcesByArtistGrouped()
|
const all = store.sourcesByArtistGrouped()
|
||||||
if (artistFilter.value == null) return all
|
return all.map(g => {
|
||||||
return all.filter(g => g.artist.id === artistFilter.value)
|
const worstSource = pickWorstSource(g.sources, failureThreshold.value)
|
||||||
|
const lastActivity = pickLastActivity(g.sources)
|
||||||
|
return {
|
||||||
|
key: `artist-${g.artist.id}`,
|
||||||
|
artist: g.artist,
|
||||||
|
sources: g.sources,
|
||||||
|
sources_count: g.sources.length,
|
||||||
|
worstSource,
|
||||||
|
lastActivity,
|
||||||
|
name: g.artist.name, // for sortable column
|
||||||
|
last_activity: lastActivity ?? '', // for sortable column
|
||||||
|
}
|
||||||
|
})
|
||||||
})
|
})
|
||||||
|
|
||||||
function isOpen(artistId) {
|
const filteredGroups = computed(() => {
|
||||||
return expandAll.value || openSections.value.has(artistId)
|
let arr = groups.value
|
||||||
|
if (artistFilter.value != null) {
|
||||||
|
arr = arr.filter(g => g.artist.id === artistFilter.value)
|
||||||
|
}
|
||||||
|
const q = search.value?.trim().toLowerCase()
|
||||||
|
if (q) {
|
||||||
|
arr = arr.filter(g =>
|
||||||
|
g.artist.name.toLowerCase().includes(q)
|
||||||
|
|| g.sources.some(s => (s.url || '').toLowerCase().includes(q)
|
||||||
|
|| (s.platform || '').toLowerCase().includes(q))
|
||||||
|
)
|
||||||
|
}
|
||||||
|
return arr
|
||||||
|
})
|
||||||
|
|
||||||
|
function pickLastActivity(sources) {
|
||||||
|
let max = null
|
||||||
|
for (const s of sources) {
|
||||||
|
if (s.last_checked_at && (!max || s.last_checked_at > max)) max = s.last_checked_at
|
||||||
|
}
|
||||||
|
return max
|
||||||
}
|
}
|
||||||
|
|
||||||
function toggleSection(artistId) {
|
function pickWorstSource(sources, threshold) {
|
||||||
if (openSections.value.has(artistId)) openSections.value.delete(artistId)
|
// Health order (worst → best): critical, warning, healthy, unchecked.
|
||||||
else openSections.value.add(artistId)
|
// Picks the source with the worst level so the row's dot reflects the
|
||||||
|
// worst-case state. Within a level, the first is fine.
|
||||||
|
if (!sources || sources.length === 0) return null
|
||||||
|
function level(s) {
|
||||||
|
if (!s.last_checked_at) return 0 // unchecked
|
||||||
|
const f = s.consecutive_failures || 0
|
||||||
|
if (f === 0) return 1 // healthy
|
||||||
|
if (f < threshold) return 2 // warning
|
||||||
|
return 3 // critical
|
||||||
|
}
|
||||||
|
return sources.reduce((worst, s) =>
|
||||||
|
level(s) > level(worst) ? s : worst,
|
||||||
|
sources[0],
|
||||||
|
)
|
||||||
|
}
|
||||||
|
|
||||||
|
function formatRelative(iso) {
|
||||||
|
if (!iso) return 'Never'
|
||||||
|
const then = new Date(iso).getTime()
|
||||||
|
const diff = (Date.now() - then) / 1000
|
||||||
|
if (diff < 60) return `${Math.floor(diff)}s ago`
|
||||||
|
if (diff < 3600) return `${Math.floor(diff / 60)}m ago`
|
||||||
|
if (diff < 86400) return `${Math.floor(diff / 3600)}h ago`
|
||||||
|
return `${Math.floor(diff / 86400)}d ago`
|
||||||
|
}
|
||||||
|
|
||||||
|
function onRowClick(_evt, { item, internalItem }) {
|
||||||
|
// Toggle expansion on row click (in addition to the chevron).
|
||||||
|
const key = item.key
|
||||||
|
const idx = expanded.value.indexOf(key)
|
||||||
|
if (idx === -1) expanded.value = [...expanded.value, key]
|
||||||
|
else expanded.value = expanded.value.filter(k => k !== key)
|
||||||
}
|
}
|
||||||
|
|
||||||
function openAddSource(artist) {
|
function openAddSource(artist) {
|
||||||
@@ -132,7 +321,6 @@ async function onSourceSaved() {
|
|||||||
|
|
||||||
function onArtistCreated(artist) {
|
function onArtistCreated(artist) {
|
||||||
showArtistDialog.value = false
|
showArtistDialog.value = false
|
||||||
// Move into Add Source for the new artist immediately.
|
|
||||||
openAddSource(artist)
|
openAddSource(artist)
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -158,6 +346,31 @@ async function onCheck(source) {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
async function checkAll(group) {
|
||||||
|
let ok = 0
|
||||||
|
let conflict = 0
|
||||||
|
for (const s of group.sources) {
|
||||||
|
if (!s.enabled) continue
|
||||||
|
try {
|
||||||
|
await store.checkNow(s.id)
|
||||||
|
ok += 1
|
||||||
|
} catch (e) {
|
||||||
|
if (e?.body?.download_event_id) conflict += 1
|
||||||
|
}
|
||||||
|
}
|
||||||
|
const parts = []
|
||||||
|
if (ok) parts.push(`${ok} queued`)
|
||||||
|
if (conflict) parts.push(`${conflict} already running`)
|
||||||
|
globalThis.window?.__fcToast?.({
|
||||||
|
text: parts.join(', ') || 'Nothing to check (no enabled sources)',
|
||||||
|
type: 'info',
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
|
function anyChecking(sources) {
|
||||||
|
return sources.some(s => store.checkingIds.has(s.id))
|
||||||
|
}
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
@@ -169,4 +382,35 @@ async function onCheck(source) {
|
|||||||
display: flex; justify-content: center; padding: 2rem;
|
display: flex; justify-content: center; padding: 2rem;
|
||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
|
.fc-subs__card {
|
||||||
|
background: rgb(var(--v-theme-surface));
|
||||||
|
}
|
||||||
|
.fc-subs__name {
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
.fc-subs__when {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
white-space: nowrap;
|
||||||
|
font-variant-numeric: tabular-nums;
|
||||||
|
}
|
||||||
|
.fc-subs__zero {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
.fc-subs__sources-row td {
|
||||||
|
padding: 0 !important;
|
||||||
|
background: rgb(var(--v-theme-surface-light));
|
||||||
|
}
|
||||||
|
.fc-subs__sources-cell {
|
||||||
|
padding-left: 2rem !important;
|
||||||
|
border-top: 1px solid rgb(var(--v-theme-on-surface-variant) / 0.15);
|
||||||
|
}
|
||||||
|
.fc-subs__sources-table {
|
||||||
|
background: transparent !important;
|
||||||
|
}
|
||||||
|
.fc-subs__sources-empty {
|
||||||
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
|
text-align: center;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user