feat(tags): show a character's fandom on its chip (truncated)
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:
@@ -172,6 +172,7 @@ async def list_tags_for_image(image_id: int):
|
|||||||
"name": t.name,
|
"name": t.name,
|
||||||
"kind": t.kind.value,
|
"kind": t.kind.value,
|
||||||
"fandom_id": t.fandom_id,
|
"fandom_id": t.fandom_id,
|
||||||
|
"fandom_name": t.fandom_name,
|
||||||
}
|
}
|
||||||
for t in tags
|
for t in tags
|
||||||
]
|
]
|
||||||
|
|||||||
@@ -558,13 +558,26 @@ class GalleryService:
|
|||||||
record = await self.session.get(ImageRecord, image_id)
|
record = await self.session.get(ImageRecord, image_id)
|
||||||
if record is None:
|
if record is None:
|
||||||
return 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 = (
|
tag_stmt = (
|
||||||
select(Tag)
|
select(
|
||||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
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)
|
.where(image_tag.c.image_record_id == image_id)
|
||||||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
.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
|
# Fetch the canonical post.post_date for this image (if any) so
|
||||||
# the modal can show "Posted on <date>" alongside import date.
|
# the modal can show "Posted on <date>" alongside import date.
|
||||||
posted_at = None
|
posted_at = None
|
||||||
@@ -608,6 +621,7 @@ class GalleryService:
|
|||||||
"name": t.name,
|
"name": t.name,
|
||||||
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
|
"kind": t.kind.value if hasattr(t.kind, "value") else t.kind,
|
||||||
"fandom_id": t.fandom_id,
|
"fandom_id": t.fandom_id,
|
||||||
|
"fandom_name": t.fandom_name,
|
||||||
}
|
}
|
||||||
for t in tags
|
for t in tags
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -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 = (
|
stmt = (
|
||||||
select(Tag)
|
select(
|
||||||
.join(image_tag, image_tag.c.tag_id == Tag.id)
|
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)
|
.where(image_tag.c.image_record_id == image_id)
|
||||||
.order_by(Tag.kind.asc(), Tag.name.asc())
|
.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:
|
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
|
"""A merged-away tag's old name must survive as an alias iff the ML
|
||||||
|
|||||||
@@ -11,7 +11,10 @@
|
|||||||
@click:close="$emit('remove', tag.id)"
|
@click:close="$emit('remove', tag.id)"
|
||||||
>
|
>
|
||||||
<v-icon start size="x-small">{{ iconFor(tag.kind) }}</v-icon>
|
<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"> {{ fandomLabel }}</template></span>
|
||||||
</v-chip>
|
</v-chip>
|
||||||
<span class="fc-tag-chip__menu-wrap">
|
<span class="fc-tag-chip__menu-wrap">
|
||||||
<v-btn
|
<v-btn
|
||||||
@@ -40,15 +43,24 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { ref } from 'vue'
|
import { computed, ref } from 'vue'
|
||||||
import { useTagStore } from '../../stores/tags.js'
|
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'])
|
defineEmits(['remove', 'rename', 'set-fandom'])
|
||||||
|
|
||||||
const store = useTagStore()
|
const store = useTagStore()
|
||||||
const menuOpen = ref(false)
|
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 = {
|
const KIND_ICONS = {
|
||||||
general: 'mdi-tag', character: 'mdi-account-circle',
|
general: 'mdi-tag', character: 'mdi-account-circle',
|
||||||
fandom: 'mdi-book-open-page-variant', series: 'mdi-bookshelf',
|
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__menu-wrap { display: inline-flex; align-items: center; }
|
||||||
.fc-tag-chip__kebab { opacity: 0.7; }
|
.fc-tag-chip__kebab { opacity: 0.7; }
|
||||||
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
.fc-tag-chip:hover .fc-tag-chip__kebab { opacity: 1; }
|
||||||
|
.fc-tag-chip__fandom { opacity: 0.7; font-size: 0.85em; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user