feat(fc3a): SubscriptionsView + ArtistSection + SourceRow + router
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -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>
|
||||
@@ -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' } }
|
||||
]
|
||||
|
||||
@@ -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>
|
||||
Reference in New Issue
Block a user