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
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:
@@ -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;
|
||||
|
||||
Reference in New Issue
Block a user