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 ArtistView from './views/ArtistView.vue'
|
||||||
import SeriesManageView from './views/SeriesManageView.vue'
|
import SeriesManageView from './views/SeriesManageView.vue'
|
||||||
import SeriesReaderView from './views/SeriesReaderView.vue'
|
import SeriesReaderView from './views/SeriesReaderView.vue'
|
||||||
|
import SubscriptionsView from './views/SubscriptionsView.vue'
|
||||||
|
|
||||||
// The application's front door. `/` redirects here. Changing the front door
|
// The application's front door. `/` redirects here. Changing the front door
|
||||||
// is a one-line edit (e.g. '/gallery' or '/tags').
|
// 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' } },
|
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
|
||||||
|
|
||||||
// FC-3: subscription backbone
|
// 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: '/credentials', name: 'credentials', component: PlaceholderView, meta: { title: 'Credentials' } },
|
||||||
{ path: '/downloads', name: 'downloads', component: PlaceholderView, meta: { title: 'Downloads' } }
|
{ 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