feat(fc3a): SubscriptionsView + ArtistSection + SourceRow + router

Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-05-20 12:57:13 -04:00
parent ded5e5f642
commit 79d6d74637
4 changed files with 250 additions and 1 deletions
@@ -0,0 +1,61 @@
<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"
@edit="$emit('edit', $event)"
@remove="$emit('remove', $event)"
@toggle="$emit('toggle-source', $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 },
})
defineEmits(['toggle', 'edit', 'remove', 'toggle-source', 'add-source'])
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>
@@ -0,0 +1,43 @@
<template>
<div class="fc-source-row">
<v-chip size="x-small" variant="tonal" class="fc-source-row__platform">
{{ source.platform }}
</v-chip>
<a :href="source.url" target="_blank" rel="noopener" class="fc-source-row__url">
{{ source.url }}
</a>
<v-switch
:model-value="source.enabled"
density="compact" hide-details color="accent"
@update:model-value="onToggleEnabled"
/>
<v-btn icon="mdi-pencil" size="x-small" variant="text" @click="$emit('edit', source)" />
<v-btn icon="mdi-close" size="x-small" variant="text" @click="$emit('remove', source)" />
</div>
</template>
<script setup>
const props = defineProps({ source: { type: Object, required: true } })
const emit = defineEmits(['edit', 'remove', 'toggle'])
function onToggleEnabled(value) {
emit('toggle', { source: props.source, enabled: value })
}
</script>
<style scoped>
.fc-source-row {
display: grid;
grid-template-columns: 96px 1fr auto auto auto;
gap: 0.75rem;
align-items: center;
padding: 0.4rem 0;
}
.fc-source-row__url {
color: rgb(var(--v-theme-on-surface-variant));
text-decoration: none;
overflow: hidden;
text-overflow: ellipsis;
white-space: nowrap;
}
.fc-source-row__url:hover { color: rgb(var(--v-theme-accent)); }
</style>
+2 -1
View File
@@ -7,6 +7,7 @@ import TagsView from './views/TagsView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesManageView from './views/SeriesManageView.vue'
import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue'
// The application's front door. `/` redirects here. Changing the front door
// is a one-line edit (e.g. '/gallery' or '/tags').
@@ -29,7 +30,7 @@ const routes = [
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone
{ path: '/subscriptions', name: 'subscriptions', component: PlaceholderView, meta: { title: 'Subscriptions' } },
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
{ path: '/credentials', name: 'credentials', component: PlaceholderView, meta: { title: 'Credentials' } },
{ path: '/downloads', name: 'downloads', component: PlaceholderView, meta: { title: 'Downloads' } }
]
+144
View File
@@ -0,0 +1,144 @@
<template>
<v-container fluid class="py-6">
<div class="fc-subs__bar">
<v-btn color="accent" prepend-icon="mdi-plus" @click="openAddSource(null)">
Add subscription
</v-btn>
<v-btn variant="outlined" prepend-icon="mdi-account-plus" @click="showArtistDialog = true">
New artist
</v-btn>
<v-spacer />
<v-btn variant="text" size="small" @click="expandAll = !expandAll">
{{ expandAll ? 'Collapse all' : 'Expand all' }}
</v-btn>
</div>
<v-alert v-if="store.error" type="error" variant="tonal" closable class="mt-4">
{{ String(store.error) }}
</v-alert>
<div v-if="store.loading && groups.length === 0" class="fc-subs__loading">
<v-progress-circular indeterminate color="accent" size="36" />
</div>
<div v-else-if="groups.length === 0" class="fc-subs__empty">
<p>No subscriptions yet. Add your first artist.</p>
</div>
<div v-else>
<ArtistSection
v-for="g in groups" :key="g.artist.id"
:artist="g.artist" :sources="g.sources"
:open="isOpen(g.artist.id)"
@toggle="toggleSection(g.artist.id)"
@edit="openEditSource"
@remove="removeSource"
@toggle-source="toggleSourceEnabled"
@add-source="openAddSource"
/>
</div>
<SourceFormDialog
v-model="showSourceDialog"
:source="editingSource"
:initial-artist="editingArtist"
@saved="onSourceSaved"
/>
<ArtistCreateDialog v-model="showArtistDialog" @created="onArtistCreated" />
</v-container>
</template>
<script setup>
import { computed, onMounted, ref, watch } from 'vue'
import { useRoute } from 'vue-router'
import { useSourcesStore } from '../stores/sources.js'
import ArtistSection from '../components/subscriptions/ArtistSection.vue'
import SourceFormDialog from '../components/subscriptions/SourceFormDialog.vue'
import ArtistCreateDialog from '../components/subscriptions/ArtistCreateDialog.vue'
const route = useRoute()
const store = useSourcesStore()
const artistFilter = computed(() => {
const raw = route.query.artist_id
return raw == null ? null : Number(raw)
})
const expandAll = ref(false)
const openSections = ref(new Set())
const showSourceDialog = ref(false)
const editingSource = ref(null)
const editingArtist = ref(null)
const showArtistDialog = ref(false)
async function refresh() {
await store.loadAll()
await store.loadPlatforms()
if (artistFilter.value != null) {
openSections.value = new Set([artistFilter.value])
expandAll.value = false
}
}
onMounted(refresh)
watch(() => route.query.artist_id, refresh)
const groups = computed(() => {
const all = store.sourcesByArtistGrouped()
if (artistFilter.value == null) return all
return all.filter(g => g.artist.id === artistFilter.value)
})
function isOpen(artistId) {
return expandAll.value || openSections.value.has(artistId)
}
function toggleSection(artistId) {
if (openSections.value.has(artistId)) openSections.value.delete(artistId)
else openSections.value.add(artistId)
}
function openAddSource(artist) {
editingSource.value = null
editingArtist.value = artist
showSourceDialog.value = true
}
function openEditSource(source) {
editingSource.value = source
editingArtist.value = { id: source.artist_id, name: source.artist_name, slug: source.artist_slug }
showSourceDialog.value = true
}
async function removeSource(source) {
await store.remove(source.id, source.artist_id)
await refresh()
}
async function toggleSourceEnabled({ source, enabled }) {
await store.update(source.id, { enabled }, source.artist_id)
await refresh()
}
async function onSourceSaved() {
showSourceDialog.value = false
await refresh()
}
function onArtistCreated(artist) {
showArtistDialog.value = false
// Move into Add Source for the new artist immediately.
openAddSource(artist)
}
</script>
<style scoped>
.fc-subs__bar {
display: flex; gap: 0.75rem; align-items: center;
padding-bottom: 1rem;
}
.fc-subs__loading, .fc-subs__empty {
display: flex; justify-content: center; padding: 2rem;
color: rgb(var(--v-theme-on-surface-variant));
}
</style>