feat(nav): consolidate Posts/Artists/Tags into a Browse hub
CI / lint (push) Successful in 2s
CI / frontend-build (push) Successful in 24s
CI / backend-lint-and-test (push) Successful in 37s
CI / integration (push) Successful in 3m13s

Posts, Artists, and Tags are the three 'browse the library by an axis'
surfaces; Subscriptions stays purely management (operator-asked 2026-06-09).
New BrowseView renders them as tabs (?tab=posts|artists|tags); only the active
tab mounts. The old standalone paths become redirects into the matching tab,
preserving deep-link query (/posts?post_id=N → /browse?tab=posts&post_id=N) and
keeping the route names so existing { name: 'posts'|'artists'|'tags' } links and
path pushes still resolve. Nav now reads Showcase · Gallery · Browse · Series ·
Subscriptions, with Settings pinned right.

Test: /browse resolves; /tags and /artists redirect into their tabs; a posts
deep link survives the redirect.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 21:18:02 -04:00
parent a50902071a
commit d5d23a92f2
3 changed files with 86 additions and 16 deletions
+21 -14
View File
@@ -2,14 +2,12 @@ import { createRouter, createWebHistory, createMemoryHistory } from 'vue-router'
import SettingsView from './views/SettingsView.vue'
import GalleryView from './views/GalleryView.vue'
import ShowcaseView from './views/ShowcaseView.vue'
import TagsView from './views/TagsView.vue'
import BrowseView from './views/BrowseView.vue'
import ArtistView from './views/ArtistView.vue'
import SeriesView from './views/SeriesView.vue'
import SeriesManageView from './views/SeriesManageView.vue'
import SeriesReaderView from './views/SeriesReaderView.vue'
import SubscriptionsView from './views/SubscriptionsView.vue'
import PostsView from './views/PostsView.vue'
import ArtistsView from './views/ArtistsView.vue'
// The application's front door. `/` redirects here. Changing the front door
// is a one-line edit (e.g. '/gallery' or '/tags').
@@ -22,27 +20,36 @@ const routes = [
// FC-2: image backbone
{ path: '/showcase', name: 'showcase', component: ShowcaseView, meta: { title: 'Showcase' } },
{ path: '/gallery', name: 'gallery', component: GalleryView, meta: { title: 'Gallery' } },
{ path: '/artists', name: 'artists', component: ArtistsView, meta: { title: 'Artists' } },
{ path: '/tags', name: 'tags', component: TagsView, meta: { title: 'Tags' } },
// Browse hub (operator-asked 2026-06-09): Posts / Artists / Tags as tabs —
// the three "browse the library by an axis" surfaces. One nav entry; the old
// standalone paths redirect into the matching tab (below).
{ path: '/browse', name: 'browse', component: BrowseView, meta: { title: 'Browse' } },
// Artist detail — no meta.title (reached by clicking an artist, not nav).
{ path: '/artist/:slug', name: 'artist', component: ArtistView },
// Series browse — a nav entry (meta.title). Peer of Posts.
// Series browse — a nav entry (meta.title).
{ path: '/series', name: 'series', component: SeriesView, meta: { title: 'Series' } },
// Series management — no meta.title (reached from a series card/tag).
{ path: '/series/:tagId', name: 'series-manage', component: SeriesManageView },
// Series reader — immersive (no top nav, no meta.title).
{ path: '/series/:tagId/read', name: 'series-read', component: SeriesReaderView, meta: { immersive: true } },
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// FC-3: subscription backbone
// /credentials and /downloads were folded into /subscriptions as subtabs
// 2026-05-27 (?tab=settings and ?tab=downloads). The hub view owns the
// whole download pipeline domain.
{ path: '/posts', name: 'posts', component: PostsView, meta: { title: 'Posts' } },
// FC-3: subscription backbone — purely management (sources/downloads),
// distinct from the Browse hub.
{ path: '/subscriptions', name: 'subscriptions', component: SubscriptionsView, meta: { title: 'Subscriptions' } },
// Bookmark/back-button safety net for the routes that got folded in
// (no meta.title — stay out of TopNav).
// Settings — config, pinned to the right of the nav (TopNav special-cases it).
{ path: '/settings', name: 'settings', component: SettingsView, meta: { title: 'Settings' } },
// The old standalone paths now redirect into the Browse hub, preserving any
// deep-link query (e.g. /posts?post_id=N → /browse?tab=posts&post_id=N). The
// route NAMES stay so existing { name: 'posts' | 'artists' | 'tags' } links
// and path pushes keep resolving.
{ path: '/posts', name: 'posts', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'posts' } }) },
{ path: '/artists', name: 'artists', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'artists' } }) },
{ path: '/tags', name: 'tags', redirect: (to) => ({ name: 'browse', query: { ...to.query, tab: 'tags' } }) },
// Bookmark/back-button safety net for the routes that got folded into
// /subscriptions (no meta.title — stay out of TopNav).
{ path: '/credentials', redirect: '/subscriptions?tab=settings' },
{ path: '/downloads', redirect: '/subscriptions?tab=downloads' }
]
+47
View File
@@ -0,0 +1,47 @@
<template>
<div class="fc-browse">
<v-container fluid class="pt-2 pb-0">
<v-tabs v-model="tab" density="compact">
<v-tab value="posts">Posts</v-tab>
<v-tab value="artists">Artists</v-tab>
<v-tab value="tags">Tags</v-tab>
</v-tabs>
</v-container>
<!-- Each tab's view is self-contained (its own container + state). Only the
active one is mounted; switching tabs swaps it. The Posts tab still
reads its deep-link (post_id/artist_id/platform) from route.query, so
/browse?tab=posts&post_id=N works exactly like the old /posts deep link.
(FC nav consolidation, operator-asked 2026-06-09: Posts/Artists/Tags are
the three "browse the library by an axis" surfaces.) -->
<component :is="activeComponent" :key="tab" />
</div>
</template>
<script setup>
import { computed } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import ArtistsView from './ArtistsView.vue'
import PostsView from './PostsView.vue'
import TagsView from './TagsView.vue'
const TABS = { posts: PostsView, artists: ArtistsView, tags: TagsView }
const route = useRoute()
const router = useRouter()
const tab = computed({
get() {
const t = route.query.tab
return typeof t === 'string' && t in TABS ? t : 'posts'
},
set(value) {
// Switching tabs starts a clean tab — a posts deep link (post_id, etc.)
// belongs only to the Posts tab and shouldn't bleed into Artists/Tags.
router.replace({ name: 'browse', query: { tab: value } })
},
})
const activeComponent = computed(() => TABS[tab.value])
</script>
+18 -2
View File
@@ -17,13 +17,29 @@ describe('router', () => {
expect(router.resolve('/gallery').name).toBe('gallery')
})
it('showcase, tags, artist resolve to named routes', () => {
it('showcase, artist resolve to named routes', () => {
expect(router.resolve('/showcase').name).toBe('showcase')
expect(router.resolve('/tags').name).toBe('tags')
expect(router.resolve('/artist/some-slug').name).toBe('artist')
expect(router.resolve('/artist/some-slug').params.slug).toBe('some-slug')
})
it('browse hub is reachable and old axis paths redirect into its tabs', async () => {
expect(router.resolve('/browse').name).toBe('browse')
await router.push('/tags')
expect(router.currentRoute.value.name).toBe('browse')
expect(router.currentRoute.value.query.tab).toBe('tags')
await router.push('/artists')
expect(router.currentRoute.value.query.tab).toBe('artists')
// A posts deep link survives the redirect into the hub tab.
await router.push({ path: '/posts', query: { post_id: '7' } })
expect(router.currentRoute.value.name).toBe('browse')
expect(router.currentRoute.value.query.tab).toBe('posts')
expect(router.currentRoute.value.query.post_id).toBe('7')
})
it('series-read is an immersive route', () => {
const r = router.resolve('/series/5/read')
expect(r.name).toBe('series-read')