feat(tags): show a character's fandom on its chip (truncated)
CI / lint (push) Successful in 3s
CI / frontend-build (push) Successful in 23s
CI / backend-lint-and-test (push) Successful in 43s
CI / integration (push) Successful in 3m27s

A character chip with a fandom only rendered a bare arrow. Surface the fandom
NAME inline, truncated to 15 chars (full name in the tooltip). Resolve the name
via a Tag self-join in both tag paths the modal uses — list_for_image
(/api/images/<id>/tags) and gallery get_image_with_tags
(/api/gallery/image/<id>) — so chips show the fandom on first open and after any
reload. Falls back to the bare arrow when only fandom_id is known. Operator-asked
2026-06-09.

Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com>
This commit is contained in:
2026-06-09 19:35:59 -04:00
parent e4cebf70d1
commit 978f49adcc
4 changed files with 53 additions and 10 deletions
+1
View File
@@ -172,6 +172,7 @@ async def list_tags_for_image(image_id: int):
"name": t.name,
"kind": t.kind.value,
"fandom_id": t.fandom_id,
"fandom_name": t.fandom_name,
}
for t in tags
]
+17 -3
View File
@@ -558,13 +558,26 @@ class GalleryService:
record = await self.session.get(ImageRecord, image_id)
if record is None:
return None
# Self-join Tag to resolve a character's fandom NAME (not just id) so the
# modal chip can label it without an N+1 — mirrors list_for_image.
fandom_alias = Tag.__table__.alias("fandom_lookup")
tag_stmt = (
select(Tag)
.join(image_tag, image_tag.c.tag_id == Tag.id)
select(
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
)
.select_from(
Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id)
.outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id)
)
.where(image_tag.c.image_record_id == image_id)
.order_by(Tag.kind.asc(), Tag.name.asc())
)
tags = (await self.session.execute(tag_stmt)).scalars().all()
tags = (await self.session.execute(tag_stmt)).all()
# Fetch the canonical post.post_date for this image (if any) so
# the modal can show "Posted on <date>" alongside import date.
posted_at = None
@@ -608,6 +621,7 @@ class GalleryService:
"name": t.name,
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
"fandom_id": t.fandom_id,
"fandom_name": t.fandom_name,
}
for t in tags
],
+19 -4
View File
@@ -225,14 +225,29 @@ class TagService:
)
)
async def list_for_image(self, image_id: int) -> Sequence[Tag]:
async def list_for_image(self, image_id: int) -> Sequence:
"""Tags on an image, ordered (kind, name). Each row carries the fandom's
NAME (not just fandom_id) via a self-join on Tag, so the UI can label a
character chip with its fandom without an N+1 (mirrors the
autocomplete/directory resolution)."""
fandom_alias = Tag.__table__.alias("fandom_lookup")
stmt = (
select(Tag)
.join(image_tag, image_tag.c.tag_id == Tag.id)
select(
Tag.id,
Tag.name,
Tag.kind,
Tag.fandom_id,
fandom_alias.c.name.label("fandom_name"),
)
.select_from(
Tag.__table__
.join(image_tag, image_tag.c.tag_id == Tag.id)
.outerjoin(fandom_alias, Tag.fandom_id == fandom_alias.c.id)
)
.where(image_tag.c.image_record_id == image_id)
.order_by(Tag.kind.asc(), Tag.name.asc())
)
return (await self.session.execute(stmt)).scalars().all()
return (await self.session.execute(stmt)).all()
async def _keep_as_alias(self, tag_id: int) -> bool:
"""A merged-away tag's old name must survive as an alias iff the ML
+16 -3
View File
@@ -11,7 +11,10 @@
@click:close="$emit('remove', tag.id)"
>
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
{{ tag.name }}<span v-if="tag.fandom_id"></span>
{{ tag.name }}<span
v-if="tag.fandom_id" class="fc-tag-chip__fandom"
:title="tag.fandom_name ? `Fandom: ${tag.fandom_name}` : 'Has a fandom'"
><template v-if="fandomLabel">&nbsp;{{ fandomLabel }}</template></span>
</v-chip>
<span class="fc-tag-chip__menu-wrap">
<v-btn
@@ -40,15 +43,24 @@
</template>
<script setup>
import { ref } from 'vue'
import { computed, ref } from 'vue'
import { useTagStore } from '../../stores/tags.js'
defineProps({ tag: { type: Object, required: true } })
const props = defineProps({ tag: { type: Object, required: true } })
defineEmits(['remove', 'rename', 'set-fandom'])
const store = useTagStore()
const menuOpen = ref(false)
// Show a character's fandom inline (truncated). Falls back to a bare arrow when
// only fandom_id is known but the name wasn't resolved (older payloads).
const FANDOM_MAX = 15
const fandomLabel = computed(() => {
const n = props.tag.fandom_name
if (!n) return ''
return n.length > FANDOM_MAX ? `${n.slice(0, FANDOM_MAX)}` : n
})
const KIND_ICONS = {
general: 'mdi-tag', character: 'mdi-account-circle',
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
@@ -62,4 +74,5 @@ function iconFor (k) { return KIND_ICONS[k] || 'mdi-tag' }
.fc-tag-chip__menu-wrap { display: inline-flex; align-items: center; }
.fc-tag-chip__kebab { opacity: 0.7; }
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
</style>