Merge pull request 'dev→main: purpose-built mobile layout for subscriptions hub' (#65) from dev into main
Build images / sign-extension (push) Successful in 3s
CI / lint (push) Successful in 3s
Build images / build-ml (push) Successful in 6s
CI / backend-lint-and-test (push) Successful in 14s
Build images / build-web (push) Successful in 9s
CI / frontend-build (push) Successful in 38s
CI / integration (push) Successful in 2m56s

This commit was merged in pull request #65.
This commit is contained in:
2026-06-04 13:43:56 -04:00
2 changed files with 221 additions and 2 deletions
@@ -0,0 +1,107 @@
<template>
<div class="fc-source-card">
<div class="fc-source-card__top">
<SourceHealthDot :source="source" :warning-threshold="warningThreshold" />
<v-chip size="x-small" variant="tonal" label>{{ source.platform }}</v-chip>
<v-spacer />
<v-switch
:model-value="source.enabled"
density="compact" hide-details color="accent"
@click.stop
@update:model-value="onToggleEnabled"
/>
</div>
<a
:href="source.url" target="_blank" rel="noopener"
class="fc-source-card__url" @click.stop
>{{ source.url }}</a>
<div class="fc-source-card__meta">
<span>Last {{ formatRelative(source.last_checked_at) }}</span>
<span>Next {{ formatRelative(source.next_check_at, { future: true }) }}</span>
<v-chip
v-if="(source.consecutive_failures || 0) > 0"
size="x-small" color="error" variant="tonal" label
>{{ source.consecutive_failures }} err</v-chip>
<v-chip
v-else-if="(source.backfill_runs_remaining || 0) > 0"
size="x-small" color="info" variant="tonal" label
>backfill ({{ source.backfill_runs_remaining }}×)</v-chip>
</div>
<div class="fc-source-card__actions">
<v-btn
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
size="x-small" variant="text"
:disabled="(source.backfill_runs_remaining || 0) > 0"
@click.stop="$emit('backfill', source)"
>
<v-icon>mdi-magnify-scan</v-icon>
<v-tooltip activator="parent" location="top">Deep scan</v-tooltip>
</v-btn>
<v-btn 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
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>
</div>
</div>
</template>
<script setup>
import SourceHealthDot from './SourceHealthDot.vue'
import { formatRelative } from '../../utils/date.js'
// Mobile-stacked equivalent of SourceRow (the desktop <tr>) — same data and
// emits, but laid out vertically so the wide source columns never force the
// lateral scroll the operator flagged on phones.
const props = defineProps({
source: { type: Object, required: true },
checking: { type: Boolean, default: false },
warningThreshold: { type: Number, default: 5 },
})
const emit = defineEmits(['edit', 'remove', 'toggle', 'check', 'backfill'])
function onToggleEnabled(value) {
emit('toggle', { source: props.source, enabled: value })
}
</script>
<style scoped>
.fc-source-card {
padding: 8px 10px;
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 6px;
background: rgb(var(--v-theme-surface));
display: flex; flex-direction: column; gap: 6px;
}
.fc-source-card__top { display: flex; align-items: center; gap: 8px; }
.fc-source-card__url {
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: none;
font-size: 0.8rem;
word-break: break-all;
}
.fc-source-card__url:hover { color: rgb(var(--v-theme-accent)); }
.fc-source-card__meta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px 12px;
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-source-card__actions {
display: flex; gap: 2px; justify-content: flex-end;
}
</style>
@@ -75,7 +75,7 @@
<p v-else>No subscriptions match the current filter.</p>
</div>
<v-card v-else class="fc-subs__card" variant="outlined">
<v-card v-else-if="!isMobile" class="fc-subs__card" variant="outlined">
<v-data-table
:headers="headers"
:items="filteredGroups"
@@ -86,7 +86,6 @@
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
density="comfortable"
hover show-select show-expand
:mobile-breakpoint="600"
@click:row="onRowClick"
>
<template #item.name="{ item }">
@@ -190,6 +189,76 @@
</v-data-table>
</v-card>
<!-- Mobile: compact cards (several fit per screen), expanding to STACKED
source cards so the wide source columns never force lateral scroll. -->
<div v-else class="fc-subs__mlist">
<div
v-for="item in filteredGroups" :key="item.key"
class="fc-subs__mcard"
>
<div class="fc-subs__mhead" @click="toggleExpand(item)">
<v-checkbox-btn
:model-value="isSelected(item)" density="compact" hide-details
@click.stop @update:model-value="toggleSelect(item)"
/>
<span class="fc-subs__name">{{ item.artist.name }}</span>
<v-spacer />
<SourceHealthDot
v-if="item.worstSource"
:source="item.worstSource" :warning-threshold="failureThreshold"
/>
<v-icon size="small">
{{ isExpanded(item) ? 'mdi-chevron-up' : 'mdi-chevron-down' }}
</v-icon>
</div>
<div class="fc-subs__mmeta">
<PlatformChip
v-for="p in item.platforms" :key="p" :platform="p" size="x-small"
/>
<span class="fc-subs__mmeta-text">
{{ item.sources.length }} src · {{ formatRelative(item.lastActivity) }}
</span>
</div>
<div v-if="isExpanded(item)" class="fc-subs__mbody">
<div class="fc-subs__mactions">
<v-btn
size="small" variant="text" :loading="anyChecking(item.sources)"
@click="checkAll(item)"
>
<v-icon>mdi-refresh</v-icon>
<v-tooltip activator="parent" location="top">Check all</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" @click="openAddSource(item.artist)">
<v-icon>mdi-plus</v-icon>
<v-tooltip activator="parent" location="top">Add source</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/posts?artist_id=${item.artist.id}`">
<v-icon>mdi-rss</v-icon>
<v-tooltip activator="parent" location="top">View posts</v-tooltip>
</v-btn>
<v-btn size="small" variant="text" :to="`/artist/${item.artist.slug}`">
<v-icon>mdi-account</v-icon>
<v-tooltip activator="parent" location="top">Artist page</v-tooltip>
</v-btn>
</div>
<SourceCard
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"
@backfill="onBackfill"
/>
<div v-if="item.sources.length === 0" class="fc-subs__sources-empty">
No sources yet. Tap + to add one.
</div>
</div>
</div>
</div>
<SourceFormDialog
v-model="showSourceDialog"
:source="editingSource"
@@ -203,11 +272,13 @@
<script setup>
import { toast } from '../../utils/toast.js'
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
import { useDisplay } from 'vuetify'
import { useRoute, useRouter } from 'vue-router'
import { useSourcesStore } from '../../stores/sources.js'
import { usePlatformsStore } from '../../stores/platforms.js'
import { useImportStore } from '../../stores/import.js'
import SourceRow from './SourceRow.vue'
import SourceCard from './SourceCard.vue'
import SourceHealthDot from './SourceHealthDot.vue'
import SourceFormDialog from './SourceFormDialog.vue'
import ArtistCreateDialog from './ArtistCreateDialog.vue'
@@ -231,6 +302,10 @@ const STATUS_OPTIONS = [
const route = useRoute()
const router = useRouter()
const display = useDisplay()
// Match the 600px breakpoint the rest of the hub uses; below it the table is
// replaced by the custom compact-card list.
const isMobile = computed(() => display.width.value < 600)
const store = useSourcesStore()
const platformsStore = usePlatformsStore()
const importStore = useImportStore()
@@ -240,6 +315,19 @@ const statusFilter = ref('all')
const needsAttention = ref(false)
const expanded = ref([])
const selected = ref([])
// Mobile card list drives the same `selected`/`expanded` key arrays the
// desktop v-data-table binds, so selection + bulk actions work identically.
function _toggleKey(arr, key) {
const i = arr.value.indexOf(key)
if (i === -1) arr.value = [...arr.value, key]
else arr.value = arr.value.filter((k) => k !== key)
}
function isSelected(item) { return selected.value.includes(item.key) }
function toggleSelect(item) { _toggleKey(selected, item.key) }
function isExpanded(item) { return expanded.value.includes(item.key) }
function toggleExpand(item) { _toggleKey(expanded, item.key) }
const showSourceDialog = ref(false)
const editingSource = ref(null)
const editingArtist = ref(null)
@@ -588,6 +676,30 @@ async function bulkDelete() {
.fc-subs__card {
background: rgb(var(--v-theme-surface));
}
/* Mobile compact-card list (replaces the data-table <600px). */
.fc-subs__mlist { display: flex; flex-direction: column; gap: 8px; }
.fc-subs__mcard {
border: 1px solid rgb(var(--v-theme-surface-light));
border-radius: 8px;
background: rgb(var(--v-theme-surface));
padding: 8px 10px;
}
.fc-subs__mhead { display: flex; align-items: center; gap: 6px; cursor: pointer; }
.fc-subs__mmeta {
display: flex; flex-wrap: wrap; align-items: center; gap: 6px;
margin-top: 4px;
}
.fc-subs__mmeta-text {
font-size: 0.78rem; color: rgb(var(--v-theme-on-surface-variant));
font-variant-numeric: tabular-nums;
}
.fc-subs__mbody {
margin-top: 8px; padding-top: 8px;
border-top: 1px solid rgb(var(--v-theme-surface-light));
display: flex; flex-direction: column; gap: 6px;
}
.fc-subs__mactions { display: flex; gap: 2px; }
.fc-subs__name { font-weight: 600; }
.fc-subs__chips {
display: flex; flex-wrap: wrap; gap: 4px;