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>
|
<p v-else>No subscriptions match the current filter.</p>
|
||||||
</div>
|
</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
|
<v-data-table
|
||||||
:headers="headers"
|
:headers="headers"
|
||||||
:items="filteredGroups"
|
:items="filteredGroups"
|
||||||
@@ -86,7 +86,6 @@
|
|||||||
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
:items-per-page-options="ITEMS_PER_PAGE_OPTIONS"
|
||||||
density="comfortable"
|
density="comfortable"
|
||||||
hover show-select show-expand
|
hover show-select show-expand
|
||||||
:mobile-breakpoint="600"
|
|
||||||
@click:row="onRowClick"
|
@click:row="onRowClick"
|
||||||
>
|
>
|
||||||
<template #item.name="{ item }">
|
<template #item.name="{ item }">
|
||||||
@@ -190,6 +189,76 @@
|
|||||||
</v-data-table>
|
</v-data-table>
|
||||||
</v-card>
|
</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
|
<SourceFormDialog
|
||||||
v-model="showSourceDialog"
|
v-model="showSourceDialog"
|
||||||
:source="editingSource"
|
:source="editingSource"
|
||||||
@@ -203,11 +272,13 @@
|
|||||||
<script setup>
|
<script setup>
|
||||||
import { toast } from '../../utils/toast.js'
|
import { toast } from '../../utils/toast.js'
|
||||||
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
import { computed, onMounted, onUnmounted, ref, watch } from 'vue'
|
||||||
|
import { useDisplay } from 'vuetify'
|
||||||
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 { useImportStore } from '../../stores/import.js'
|
import { useImportStore } from '../../stores/import.js'
|
||||||
import SourceRow from './SourceRow.vue'
|
import SourceRow from './SourceRow.vue'
|
||||||
|
import SourceCard from './SourceCard.vue'
|
||||||
import SourceHealthDot from './SourceHealthDot.vue'
|
import SourceHealthDot from './SourceHealthDot.vue'
|
||||||
import SourceFormDialog from './SourceFormDialog.vue'
|
import SourceFormDialog from './SourceFormDialog.vue'
|
||||||
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
import ArtistCreateDialog from './ArtistCreateDialog.vue'
|
||||||
@@ -231,6 +302,10 @@ const STATUS_OPTIONS = [
|
|||||||
|
|
||||||
const route = useRoute()
|
const route = useRoute()
|
||||||
const router = useRouter()
|
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 store = useSourcesStore()
|
||||||
const platformsStore = usePlatformsStore()
|
const platformsStore = usePlatformsStore()
|
||||||
const importStore = useImportStore()
|
const importStore = useImportStore()
|
||||||
@@ -240,6 +315,19 @@ const statusFilter = ref('all')
|
|||||||
const needsAttention = ref(false)
|
const needsAttention = ref(false)
|
||||||
const expanded = ref([])
|
const expanded = ref([])
|
||||||
const selected = 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 showSourceDialog = ref(false)
|
||||||
const editingSource = ref(null)
|
const editingSource = ref(null)
|
||||||
const editingArtist = ref(null)
|
const editingArtist = ref(null)
|
||||||
@@ -588,6 +676,30 @@ async function bulkDelete() {
|
|||||||
.fc-subs__card {
|
.fc-subs__card {
|
||||||
background: rgb(var(--v-theme-surface));
|
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__name { font-weight: 600; }
|
||||||
.fc-subs__chips {
|
.fc-subs__chips {
|
||||||
display: flex; flex-wrap: wrap; gap: 4px;
|
display: flex; flex-wrap: wrap; gap: 4px;
|
||||||
|
|||||||
Reference in New Issue
Block a user