From d5d23a92f2c96bb907ad4fc7a32ce48321394a8a Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 9 Jun 2026 21:18:02 -0400 Subject: [PATCH 01/10] feat(nav): consolidate Posts/Artists/Tags into a Browse hub MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit 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) --- frontend/src/router.js | 35 ++++++++++++++--------- frontend/src/views/BrowseView.vue | 47 +++++++++++++++++++++++++++++++ frontend/test/router.spec.js | 20 +++++++++++-- 3 files changed, 86 insertions(+), 16 deletions(-) create mode 100644 frontend/src/views/BrowseView.vue diff --git a/frontend/src/router.js b/frontend/src/router.js index 2073c84..fa86c0f 100644 --- a/frontend/src/router.js +++ b/frontend/src/router.js @@ -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' } ] diff --git a/frontend/src/views/BrowseView.vue b/frontend/src/views/BrowseView.vue new file mode 100644 index 0000000..76a8da4 --- /dev/null +++ b/frontend/src/views/BrowseView.vue @@ -0,0 +1,47 @@ + + + diff --git a/frontend/test/router.spec.js b/frontend/test/router.spec.js index c976fd3..338412f 100644 --- a/frontend/test/router.spec.js +++ b/frontend/test/router.spec.js @@ -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') From c774042a85f428881b9b216a405a436a147be9a8 Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 9 Jun 2026 22:25:11 -0400 Subject: [PATCH 02/10] refactor(ui): consolidate 7 hand-rolled kebabs into one KebabMenu (DRY pattern sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit First pattern-consistency DRY pass (process #594). The overflow kebab was hand-rolled 7 ways in two divergent activator strategies — Pattern A (#activator + v-bind) which silently breaks inside the teleported image modal (#711), and Pattern B (manual v-model + activator=parent + open-on-click=false + z-index 2400) the modal kebabs needed as a workaround. New (components/common) bakes in the modal-safe strategy UNIVERSALLY, so every kebab works in modal and non-modal contexts — folding the latent #711-class bug fix into all five Pattern-A sites. Menu items go in the default slot; variations (size/variant/location/label/min-width) are props. Adopted across all 7: TagChip, SuggestionItem, TagCard, SeriesView card, SeriesManageView, BackupRunsTable, SourceActions. Exhaustiveness (§8b): mdi-dots-vertical now lives only in KebabMenu. Labeled dropdowns / nav menus / filter popovers are a different concept and left alone. Seeded the pattern catalog so new code reuses the primitive. Test: KebabMenu renders slot items + trigger label/glyph + presentational props. Co-Authored-By: Claude Opus 4.8 (1M context) --- frontend/src/components/common/KebabMenu.vue | 50 +++++++++++++++++ frontend/src/components/discovery/TagCard.vue | 48 +++++++--------- .../src/components/modal/SuggestionItem.vue | 52 +++++------------- frontend/src/components/modal/TagChip.vue | 43 ++++----------- .../components/settings/BackupRunsTable.vue | 34 +++++------- .../subscriptions/SourceActions.vue | 51 ++++++++--------- frontend/src/views/SeriesManageView.vue | 55 ++++++++----------- frontend/src/views/SeriesView.vue | 36 +++++------- frontend/test/components/kebabMenu.spec.js | 38 +++++++++++++ 9 files changed, 211 insertions(+), 196 deletions(-) create mode 100644 frontend/src/components/common/KebabMenu.vue create mode 100644 frontend/test/components/kebabMenu.spec.js diff --git a/frontend/src/components/common/KebabMenu.vue b/frontend/src/components/common/KebabMenu.vue new file mode 100644 index 0000000..0052df7 --- /dev/null +++ b/frontend/src/components/common/KebabMenu.vue @@ -0,0 +1,50 @@ + + + + + diff --git a/frontend/src/components/discovery/TagCard.vue b/frontend/src/components/discovery/TagCard.vue index db7b3df..81e5376 100644 --- a/frontend/src/components/discovery/TagCard.vue +++ b/frontend/src/components/discovery/TagCard.vue @@ -46,34 +46,25 @@ {{ card.kind }}
{{ card.image_count }} - - - - - - - - + + + + +
@@ -82,6 +73,7 @@ @@ -102,11 +85,6 @@ const scorePct = computed(() => `${Math.round(props.suggestion.score * 100)}%`) .fc-suggestion__accept :deep(.v-btn__content) { font-size: 12px; letter-spacing: 0.02em; } -.fc-suggestion__menu-wrap { - flex: 0 0 auto; - display: inline-flex; - align-items: center; -} .fc-suggestion__menu { flex: 0 0 auto; } diff --git a/frontend/src/components/modal/TagChip.vue b/frontend/src/components/modal/TagChip.vue index b6df29d..1dc5699 100644 --- a/frontend/src/components/modal/TagChip.vue +++ b/frontend/src/components/modal/TagChip.vue @@ -1,9 +1,4 @@ + + diff --git a/frontend/src/components/settings/PostMaintenanceCard.vue b/frontend/src/components/settings/PostMaintenanceCard.vue index 8c41f11..7e2c348 100644 --- a/frontend/src/components/settings/PostMaintenanceCard.vue +++ b/frontend/src/components/settings/PostMaintenanceCard.vue @@ -36,11 +36,10 @@ Nothing to clean up.

-
- - {{ n }} - -
+ .fc-post-maint { border-radius: 8px; } -.fc-name-grid { - display: flex; flex-wrap: wrap; gap: 4px 8px; - max-height: 200px; overflow-y: auto; - padding: 8px; border-radius: 4px; - background: rgb(var(--v-theme-surface-light)); -} -.fc-name { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: rgb(var(--v-theme-on-surface-variant)); -} diff --git a/frontend/src/components/settings/TagMaintenanceCard.vue b/frontend/src/components/settings/TagMaintenanceCard.vue index 46e2f8f..dca22a6 100644 --- a/frontend/src/components/settings/TagMaintenanceCard.vue +++ b/frontend/src/components/settings/TagMaintenanceCard.vue @@ -31,11 +31,10 @@ Showing first 50 names.

-
- - {{ n }} - -
+

-
- - {{ n }} - -
+ {{ resetPreview.applications }} image application(s).

-
- - {{ n }} - -
+ {{ normPreview.collisions }} collision(s) merging {{ normPreview.tags_to_merge }} tag(s) away.

-
+ {{ s.to }} -
+
.fc-tag-maint { border-radius: 8px; } -.fc-name-grid { - display: flex; flex-wrap: wrap; gap: 4px 8px; - max-height: 200px; overflow-y: auto; - padding: 8px; border-radius: 4px; - background: rgb(var(--v-theme-surface-light)); -} -.fc-name { - font-family: 'JetBrains Mono', monospace; - font-size: 12px; - color: rgb(var(--v-theme-on-surface-variant)); -} From 9deebfa1339a864336e204adf9f5993fc2847f9c Mon Sep 17 00:00:00 2001 From: Bryan Van Deusen Date: Tue, 9 Jun 2026 23:22:01 -0400 Subject: [PATCH 06/10] refactor(ui): CardHeading primitive for icon+title card/dialog headings (DRY pattern sweep) MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit The icon+title v-card-title heading (d-flex align-center + gap + + ) was hand-rolled identically in 13 cards/dialogs (15 heading instances). Consolidate to (components/common) with an iconColor prop (error headings) and a default slot for trailing content (spacer+actions, inline status chip). Adopted everywhere the pattern appears — all-or-nothing per the hardened DRY process. Over-DRY guard: plain text-only one-liners are NOT this pattern and stay; DownloadDetailModal leads with a status CHIP (not an icon), a different concept, left alone. §8b: the only remaining d-flex align-center v-card-title is that intentional variant. Catalog updated. Co-Authored-By: Claude Opus 4.8 (1M context) --- .../components/artist/ArtistDangerZone.vue | 6 ++--- .../components/cleanup/MinDimensionCard.vue | 6 ++--- .../cleanup/SingleColorAuditCard.vue | 6 ++--- .../cleanup/TransparencyAuditCard.vue | 6 ++--- .../src/components/common/CardHeading.vue | 25 +++++++++++++++++++ .../components/common/ErrorDetailModal.vue | 10 +++++--- .../src/components/settings/BackupCard.vue | 6 ++--- .../settings/BrowserExtensionCard.vue | 7 +++--- .../components/settings/ImportTaskList.vue | 6 ++--- .../settings/PostMaintenanceCard.vue | 6 ++--- .../settings/SystemActivitySummary.vue | 7 +++--- .../components/settings/SystemActivityTab.vue | 19 ++++++-------- .../settings/TagMaintenanceCard.vue | 6 ++--- .../subscriptions/PreviewDialog.vue | 9 ++++--- 14 files changed, 66 insertions(+), 59 deletions(-) create mode 100644 frontend/src/components/common/CardHeading.vue diff --git a/frontend/src/components/artist/ArtistDangerZone.vue b/frontend/src/components/artist/ArtistDangerZone.vue index 0de1ced..275c4a4 100644 --- a/frontend/src/components/artist/ArtistDangerZone.vue +++ b/frontend/src/components/artist/ArtistDangerZone.vue @@ -1,9 +1,6 @@