UI batch: tagging flow, series browse, fandom chips, nav #89
@@ -11,8 +11,21 @@ suggestions_bp = Blueprint("suggestions", __name__, url_prefix="/api")
|
|||||||
|
|
||||||
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
@suggestions_bp.route("/images/<int:image_id>/suggestions", methods=["GET"])
|
||||||
async def get_suggestions(image_id: int):
|
async def get_suggestions(image_id: int):
|
||||||
|
# ?min=<float> overrides the configured per-category thresholds so the typed
|
||||||
|
# tag-input dropdown can surface EVERY stored prediction (min=0), including
|
||||||
|
# low-confidence actions/features, in canonical formatting. Omitted → the
|
||||||
|
# curated above-threshold list the Suggestions panel uses.
|
||||||
|
override = None
|
||||||
|
raw_min = request.args.get("min")
|
||||||
|
if raw_min is not None:
|
||||||
|
try:
|
||||||
|
override = min(1.0, max(0.0, float(raw_min)))
|
||||||
|
except ValueError:
|
||||||
|
return jsonify({"error": "min must be a float in [0,1]"}), 400
|
||||||
async with get_session() as session:
|
async with get_session() as session:
|
||||||
sl = await SuggestionService(session).for_image(image_id)
|
sl = await SuggestionService(session).for_image(
|
||||||
|
image_id, threshold_override=override
|
||||||
|
)
|
||||||
return jsonify(
|
return jsonify(
|
||||||
{
|
{
|
||||||
"by_category": {
|
"by_category": {
|
||||||
|
|||||||
@@ -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
|
||||||
],
|
],
|
||||||
|
|||||||
@@ -48,16 +48,33 @@ class SuggestionService:
|
|||||||
await self.session.execute(select(MLSettings).where(MLSettings.id == 1))
|
await self.session.execute(select(MLSettings).where(MLSettings.id == 1))
|
||||||
).scalar_one()
|
).scalar_one()
|
||||||
|
|
||||||
def _threshold_for(self, s: MLSettings, category: str) -> float:
|
def _threshold_for(
|
||||||
|
self, s: MLSettings, category: str, override: float | None = None,
|
||||||
|
) -> float:
|
||||||
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
# 'artist' (FC-2d-vii-c) and 'copyright' (2026-06-01) retired;
|
||||||
# both fall through to the 1.01 "never surfaces" default like any
|
# both fall through to the 1.01 "never surfaces" default like any
|
||||||
# unsurfaced category.
|
# unsurfaced category.
|
||||||
|
# override (the typed-dropdown "show everything the model saw" mode)
|
||||||
|
# applies to the surfaced categories only — unsurfaced ones are already
|
||||||
|
# skipped before the threshold check, so they can't leak in.
|
||||||
|
if override is not None:
|
||||||
|
return override
|
||||||
return {
|
return {
|
||||||
"character": s.suggestion_threshold_character,
|
"character": s.suggestion_threshold_character,
|
||||||
"general": s.suggestion_threshold_general,
|
"general": s.suggestion_threshold_general,
|
||||||
}.get(category, 1.01)
|
}.get(category, 1.01)
|
||||||
|
|
||||||
async def for_image(self, image_id: int) -> SuggestionList:
|
async def for_image(
|
||||||
|
self, image_id: int, *, threshold_override: float | None = None,
|
||||||
|
) -> SuggestionList:
|
||||||
|
"""Ranked suggestions for one image.
|
||||||
|
|
||||||
|
threshold_override surfaces EVERY stored tagger prediction (down to the
|
||||||
|
ingest STORE_FLOOR) regardless of the configured per-category suggestion
|
||||||
|
thresholds — backs the tag-input dropdown's "search all of the model's
|
||||||
|
predictions, including low-confidence ones, in the canonical formatting"
|
||||||
|
mode (operator-asked 2026-06-09). The Suggestions panel still calls with
|
||||||
|
no override so it stays the curated above-threshold list."""
|
||||||
img = await self.session.get(ImageRecord, image_id)
|
img = await self.session.get(ImageRecord, image_id)
|
||||||
if img is None:
|
if img is None:
|
||||||
return SuggestionList()
|
return SuggestionList()
|
||||||
@@ -96,7 +113,7 @@ class SuggestionService:
|
|||||||
if category not in SURFACED_CATEGORIES:
|
if category not in SURFACED_CATEGORIES:
|
||||||
continue
|
continue
|
||||||
conf = float(p.get("confidence", 0.0))
|
conf = float(p.get("confidence", 0.0))
|
||||||
if conf < self._threshold_for(settings, category):
|
if conf < self._threshold_for(settings, category, threshold_override):
|
||||||
continue
|
continue
|
||||||
display = normalize_tag_name(name)
|
display = normalize_tag_name(name)
|
||||||
if display is None:
|
if display is None:
|
||||||
|
|||||||
@@ -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
|
||||||
|
|||||||
@@ -15,7 +15,7 @@
|
|||||||
where they fold into the hamburger menu on the right. -->
|
where they fold into the hamburger menu on the right. -->
|
||||||
<nav class="fc-links">
|
<nav class="fc-links">
|
||||||
<RouterLink
|
<RouterLink
|
||||||
v-for="r in navRoutes"
|
v-for="r in contentRoutes"
|
||||||
:key="r.name"
|
:key="r.name"
|
||||||
:to="{ name: r.name }"
|
:to="{ name: r.name }"
|
||||||
class="fc-link"
|
class="fc-link"
|
||||||
@@ -27,6 +27,19 @@
|
|||||||
Gallery: Select). TopNav owns the slot, not its contents. -->
|
Gallery: Select). TopNav owns the slot, not its contents. -->
|
||||||
<div id="fc-nav-actions" class="fc-nav-actions" />
|
<div id="fc-nav-actions" class="fc-nav-actions" />
|
||||||
|
|
||||||
|
<!-- Settings is config, not content — pinned to the right edge,
|
||||||
|
separated from the content nav (desktop). On mobile it lives in
|
||||||
|
the hamburger menu below like every other route. -->
|
||||||
|
<RouterLink
|
||||||
|
v-if="settingsRoute"
|
||||||
|
:to="{ name: settingsRoute.name }"
|
||||||
|
class="fc-link fc-link--settings"
|
||||||
|
:aria-label="settingsRoute.meta.title"
|
||||||
|
>
|
||||||
|
<v-icon size="small">mdi-cog-outline</v-icon>
|
||||||
|
<span class="fc-link--settings__label">{{ settingsRoute.meta.title }}</span>
|
||||||
|
</RouterLink>
|
||||||
|
|
||||||
<!-- Mobile nav: the link row collides with brand + actions below ~768px
|
<!-- Mobile nav: the link row collides with brand + actions below ~768px
|
||||||
(7+ links in one flex row), so collapse it into a menu. -->
|
(7+ links in one flex row), so collapse it into a menu. -->
|
||||||
<v-menu location="bottom end">
|
<v-menu location="bottom end">
|
||||||
@@ -64,6 +77,14 @@ onMounted(() => system.refreshHealth())
|
|||||||
const navRoutes = computed(() =>
|
const navRoutes = computed(() =>
|
||||||
router.getRoutes().filter(r => r.meta?.title)
|
router.getRoutes().filter(r => r.meta?.title)
|
||||||
)
|
)
|
||||||
|
// Content links for the centered desktop row — everything EXCEPT Settings,
|
||||||
|
// which is config and gets pinned to the right edge instead.
|
||||||
|
const contentRoutes = computed(() =>
|
||||||
|
navRoutes.value.filter(r => r.name !== 'settings')
|
||||||
|
)
|
||||||
|
const settingsRoute = computed(() =>
|
||||||
|
navRoutes.value.find(r => r.name === 'settings') || null
|
||||||
|
)
|
||||||
|
|
||||||
const health = computed(() => {
|
const health = computed(() => {
|
||||||
if (system.healthy === null) {
|
if (system.healthy === null) {
|
||||||
@@ -147,6 +168,13 @@ const health = computed(() => {
|
|||||||
.fc-link.router-link-exact-active {
|
.fc-link.router-link-exact-active {
|
||||||
color: rgb(var(--v-theme-accent));
|
color: rgb(var(--v-theme-accent));
|
||||||
}
|
}
|
||||||
|
/* Settings: gear + label, pinned right of the content nav. */
|
||||||
|
.fc-link--settings {
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 6px;
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
.fc-nav-left {
|
.fc-nav-left {
|
||||||
flex: 1 1 0;
|
flex: 1 1 0;
|
||||||
@@ -181,6 +209,8 @@ const health = computed(() => {
|
|||||||
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
|
.fc-topnav { gap: 0.5rem; padding: 0.6rem 0.75rem; }
|
||||||
/* Fold the link row into the hamburger menu. */
|
/* Fold the link row into the hamburger menu. */
|
||||||
.fc-links { display: none; }
|
.fc-links { display: none; }
|
||||||
|
/* Settings is in the hamburger menu on mobile (navRoutes includes it). */
|
||||||
|
.fc-link--settings { display: none; }
|
||||||
.fc-nav-burger { display: inline-flex; }
|
.fc-nav-burger { display: inline-flex; }
|
||||||
}
|
}
|
||||||
@media (max-width: 480px) {
|
@media (max-width: 480px) {
|
||||||
|
|||||||
@@ -46,6 +46,9 @@ import SuggestionsCategoryGroup from './SuggestionsCategoryGroup.vue'
|
|||||||
import AliasPickerDialog from './AliasPickerDialog.vue'
|
import AliasPickerDialog from './AliasPickerDialog.vue'
|
||||||
|
|
||||||
const props = defineProps({ imageId: { type: Number, required: true } })
|
const props = defineProps({ imageId: { type: Number, required: true } })
|
||||||
|
// 'accepted' lets the parent return focus to the tag input after a suggestion is
|
||||||
|
// applied (operator-asked 2026-06-08).
|
||||||
|
const emit = defineEmits(['accepted'])
|
||||||
const store = useSuggestionsStore()
|
const store = useSuggestionsStore()
|
||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
|
|
||||||
@@ -59,7 +62,11 @@ const isEmpty = computed(() =>
|
|||||||
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
Object.values(store.byCategory).every(list => !list || list.length === 0)
|
||||||
)
|
)
|
||||||
|
|
||||||
watch(() => props.imageId, (id) => { if (id != null) store.load(id) }, { immediate: true })
|
watch(() => props.imageId, (id) => {
|
||||||
|
if (id == null) return
|
||||||
|
store.load(id) // panel: curated, ≥ threshold
|
||||||
|
store.loadAll(id) // dropdown: full prediction set (low-confidence included)
|
||||||
|
}, { immediate: true })
|
||||||
|
|
||||||
// After a successful accept/alias-accept, refresh the modal's current
|
// After a successful accept/alias-accept, refresh the modal's current
|
||||||
// tag list so TagPanel's chip rail reflects the newly-attached tag.
|
// tag list so TagPanel's chip rail reflects the newly-attached tag.
|
||||||
@@ -72,6 +79,7 @@ async function onAccept(s) {
|
|||||||
try {
|
try {
|
||||||
await store.accept(s)
|
await store.accept(s)
|
||||||
await modal.reloadTags()
|
await modal.reloadTags()
|
||||||
|
emit('accepted')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
toast({ text: `Accept failed: ${e.message}`, type: 'error' })
|
||||||
}
|
}
|
||||||
@@ -85,6 +93,7 @@ async function onAliasConfirm(canonicalTagId) {
|
|||||||
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
await store.aliasAccept(aliasTarget.value, canonicalTagId)
|
||||||
aliasDialog.value = false
|
aliasDialog.value = false
|
||||||
await modal.reloadTags()
|
await modal.reloadTags()
|
||||||
|
emit('accepted')
|
||||||
} catch (e) {
|
} catch (e) {
|
||||||
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
toast({ text: `Alias failed: ${e.message}`, type: 'error' })
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -123,6 +123,10 @@ function focusInput () {
|
|||||||
nextTick(() => inputRef.value?.focus?.())
|
nextTick(() => inputRef.value?.focus?.())
|
||||||
}
|
}
|
||||||
onMounted(focusInput)
|
onMounted(focusInput)
|
||||||
|
// Exposed so the parent (TagPanel) can hand focus back to this field after an
|
||||||
|
// auto-suggestion is accepted, keeping the keyboard flow on the input instead
|
||||||
|
// of dropping to <body> (operator-asked 2026-06-08). Mobile guard preserved.
|
||||||
|
defineExpose({ focus: focusInput })
|
||||||
|
|
||||||
// Single text input; no kind dropdown. Client-side mirror of the
|
// Single text input; no kind dropdown. Client-side mirror of the
|
||||||
// backend's parse_kind_prefix lives below — kept in sync with
|
// backend's parse_kind_prefix lives below — kept in sync with
|
||||||
@@ -186,12 +190,17 @@ function scorePct (s) { return `${Math.round(s.score * 100)}%` }
|
|||||||
|
|
||||||
// This image's suggestions that match the typed query, minus any the server
|
// This image's suggestions that match the typed query, minus any the server
|
||||||
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
// autocomplete already returned (same name+kind) so a tag never shows twice.
|
||||||
|
// Sources the FULL prediction set (allByCategory, down to the store floor) — NOT
|
||||||
|
// the threshold-filtered panel list — so a low-confidence action/feature the
|
||||||
|
// model saw can be typed and accepted in canonical formatting instead of being
|
||||||
|
// hand-entered as a custom tag (operator-asked 2026-06-09). The typed query is
|
||||||
|
// the only filter; the threshold no longer hides anything here.
|
||||||
const suggestionHits = computed(() => {
|
const suggestionHits = computed(() => {
|
||||||
const q = parsedName.value.toLowerCase()
|
const q = parsedName.value.toLowerCase()
|
||||||
if (!q) return []
|
if (!q) return []
|
||||||
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
const seen = new Set(hits.value.map(h => `${h.kind}:${h.name.toLowerCase()}`))
|
||||||
const out = []
|
const out = []
|
||||||
for (const list of Object.values(suggestions.byCategory)) {
|
for (const list of Object.values(suggestions.allByCategory)) {
|
||||||
for (const s of list || []) {
|
for (const s of list || []) {
|
||||||
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
const key = `${s.category}:${s.display_name.toLowerCase()}`
|
||||||
if (!s.display_name.toLowerCase().includes(q)) continue
|
if (!s.display_name.toLowerCase().includes(q)) continue
|
||||||
@@ -200,9 +209,10 @@ const suggestionHits = computed(() => {
|
|||||||
out.push(s)
|
out.push(s)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
// Best matches first; cap so the dropdown stays scannable.
|
// Best matches first; cap generously so a specific typed query surfaces its
|
||||||
|
// matches even when many predictions exist, while the list stays scrollable.
|
||||||
out.sort((a, b) => b.score - a.score)
|
out.sort((a, b) => b.score - a.score)
|
||||||
return out.slice(0, 6)
|
return out.slice(0, 20)
|
||||||
})
|
})
|
||||||
|
|
||||||
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
// One ordered list backing both the rendered dropdown and keyboard nav, so the
|
||||||
|
|||||||
@@ -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>
|
||||||
|
|||||||
@@ -13,6 +13,7 @@
|
|||||||
<v-divider class="my-3" />
|
<v-divider class="my-3" />
|
||||||
|
|
||||||
<TagAutocomplete
|
<TagAutocomplete
|
||||||
|
ref="tagInputRef"
|
||||||
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
@pick-existing="onPickExisting" @pick-new="onPickNew"
|
||||||
@accept-suggestion="onAcceptSuggestion"
|
@accept-suggestion="onAcceptSuggestion"
|
||||||
/>
|
/>
|
||||||
@@ -24,6 +25,7 @@
|
|||||||
<SuggestionsPanel
|
<SuggestionsPanel
|
||||||
v-if="modal.currentImageId != null"
|
v-if="modal.currentImageId != null"
|
||||||
:image-id="modal.currentImageId"
|
:image-id="modal.currentImageId"
|
||||||
|
@accepted="focusTagInput"
|
||||||
/>
|
/>
|
||||||
|
|
||||||
<v-dialog v-model="renameDialog" max-width="420">
|
<v-dialog v-model="renameDialog" max-width="420">
|
||||||
@@ -60,6 +62,12 @@ import FandomSetDialog from './FandomSetDialog.vue'
|
|||||||
const modal = useModalStore()
|
const modal = useModalStore()
|
||||||
const suggestions = useSuggestionsStore()
|
const suggestions = useSuggestionsStore()
|
||||||
const errorMsg = ref(null)
|
const errorMsg = ref(null)
|
||||||
|
const tagInputRef = ref(null)
|
||||||
|
|
||||||
|
// Return focus to the tag input after a suggestion is accepted (from the
|
||||||
|
// Suggestions panel or the autocomplete dropdown) so the operator can keep
|
||||||
|
// typing the next tag without re-clicking the field (operator-asked 2026-06-08).
|
||||||
|
function focusTagInput() { tagInputRef.value?.focus?.() }
|
||||||
|
|
||||||
async function onRemove(tagId) {
|
async function onRemove(tagId) {
|
||||||
errorMsg.value = null
|
errorMsg.value = null
|
||||||
@@ -84,6 +92,7 @@ async function onAcceptSuggestion(s) {
|
|||||||
try {
|
try {
|
||||||
await suggestions.accept(s)
|
await suggestions.accept(s)
|
||||||
await modal.reloadTags()
|
await modal.reloadTags()
|
||||||
|
focusTagInput()
|
||||||
} catch (e) { errorMsg.value = e.message }
|
} catch (e) { errorMsg.value = e.message }
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
@@ -27,5 +27,22 @@ export const useSeriesBrowseStore = defineStore('seriesBrowse', () => {
|
|||||||
return load()
|
return load()
|
||||||
}
|
}
|
||||||
|
|
||||||
return { series, sort, artistId, loading, error, load, setSort }
|
// A series IS a Tag(kind=series); deleting it removes the series structure
|
||||||
|
// (pages/chapters cascade) via the shared admin tag-delete. Splice the card
|
||||||
|
// out for instant feedback rather than reloading the whole grid.
|
||||||
|
async function remove(id) {
|
||||||
|
await api.delete(`/api/admin/tags/${id}`)
|
||||||
|
series.value = series.value.filter(s => s.id !== id)
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reflect an in-place rename (PATCH /api/tags/<id>) without a full reload.
|
||||||
|
function applyRename(id, name) {
|
||||||
|
const row = series.value.find(s => s.id === id)
|
||||||
|
if (row) row.name = name
|
||||||
|
}
|
||||||
|
|
||||||
|
return {
|
||||||
|
series, sort, artistId, loading, error,
|
||||||
|
load, setSort, remove, applyRename,
|
||||||
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -16,9 +16,14 @@ export const CATEGORY_LABELS = {
|
|||||||
|
|
||||||
export const useSuggestionsStore = defineStore('suggestions', () => {
|
export const useSuggestionsStore = defineStore('suggestions', () => {
|
||||||
const api = useApi()
|
const api = useApi()
|
||||||
const byCategory = ref({}) // { category: [suggestion, ...] }
|
const byCategory = ref({}) // { category: [suggestion, ...] } — panel (≥ threshold)
|
||||||
|
// The typed tag-input dropdown searches the model's FULL prediction set for
|
||||||
|
// the image (min=0, down to the store floor) so low-confidence actions/
|
||||||
|
// features can be picked in canonical formatting (operator-asked 2026-06-09).
|
||||||
|
const allByCategory = ref({})
|
||||||
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
const { loading, error, run } = useAsyncAction({ errorAs: 'message' })
|
||||||
let currentImageId = null
|
let currentImageId = null
|
||||||
|
const inflightAll = useInflightToken()
|
||||||
// Audit 2026-06-02: this store had no inflight guard — a late
|
// Audit 2026-06-02: this store had no inflight guard — a late
|
||||||
// /suggestions response from a prior image could overwrite
|
// /suggestions response from a prior image could overwrite
|
||||||
// byCategory while currentImageId pointed at a new one, and
|
// byCategory while currentImageId pointed at a new one, and
|
||||||
@@ -42,10 +47,41 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
|
|
||||||
function _drop(category, predicate) {
|
// Load the full prediction set for the dropdown (separate inflight guard so a
|
||||||
const list = byCategory.value[category]
|
// late response from a prior image can't overwrite the current one's list).
|
||||||
if (!list) return
|
async function loadAll(imageId) {
|
||||||
byCategory.value[category] = list.filter(s => !predicate(s))
|
inflightAll.cancel()
|
||||||
|
allByCategory.value = {}
|
||||||
|
const t = inflightAll.claim()
|
||||||
|
try {
|
||||||
|
const body = await api.get(`/api/images/${imageId}/suggestions`, {
|
||||||
|
params: { min: 0 },
|
||||||
|
})
|
||||||
|
if (!t.isCurrent()) return
|
||||||
|
allByCategory.value = body.by_category || {}
|
||||||
|
} catch {
|
||||||
|
// Dropdown is best-effort — the panel surfaces load errors.
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// A stable identity for a suggestion across the two lists (panel vs dropdown
|
||||||
|
// are separate fetches, so object identity differs): tag id when known, else
|
||||||
|
// the raw display key.
|
||||||
|
function _keyOf(s) {
|
||||||
|
return s.canonical_tag_id != null
|
||||||
|
? `id:${s.canonical_tag_id}`
|
||||||
|
: `raw:${s.category}:${(s.display_name || '').toLowerCase()}`
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drop an accepted/dismissed suggestion from BOTH the panel list and the
|
||||||
|
// dropdown's full list so it can't reappear in either surface.
|
||||||
|
function _dropEverywhere(suggestion) {
|
||||||
|
const key = _keyOf(suggestion)
|
||||||
|
const cat = suggestion.category
|
||||||
|
for (const map of [byCategory.value, allByCategory.value]) {
|
||||||
|
const list = map[cat]
|
||||||
|
if (list) map[cat] = list.filter(s => _keyOf(s) !== key)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
async function accept(suggestion) {
|
async function accept(suggestion) {
|
||||||
@@ -70,7 +106,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
// Only drop from THIS image's category list — if the user navigated,
|
// Only drop from THIS image's category list — if the user navigated,
|
||||||
// the new image has its own suggestions and this drop would corrupt them.
|
// the new image has its own suggestions and this drop would corrupt them.
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
text: `Tagged: ${suggestion.display_name}`,
|
text: `Tagged: ${suggestion.display_name}`,
|
||||||
@@ -89,7 +125,7 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
}
|
}
|
||||||
})
|
})
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
toast({
|
toast({
|
||||||
text: `Aliased & tagged: ${suggestion.display_name}`,
|
text: `Aliased & tagged: ${suggestion.display_name}`,
|
||||||
@@ -109,12 +145,12 @@ export const useSuggestionsStore = defineStore('suggestions', () => {
|
|||||||
})
|
})
|
||||||
}
|
}
|
||||||
if (currentImageId === imageId) {
|
if (currentImageId === imageId) {
|
||||||
_drop(suggestion.category, s => s === suggestion)
|
_dropEverywhere(suggestion)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
return {
|
return {
|
||||||
byCategory, loading, error,
|
byCategory, allByCategory, loading, error,
|
||||||
load, accept, aliasAccept, dismiss
|
load, loadAll, accept, aliasAccept, dismiss
|
||||||
}
|
}
|
||||||
})
|
})
|
||||||
|
|||||||
@@ -15,6 +15,12 @@
|
|||||||
<!-- ---- Browse ---- -->
|
<!-- ---- Browse ---- -->
|
||||||
<v-window-item value="browse">
|
<v-window-item value="browse">
|
||||||
<div class="fc-series-browse__controls">
|
<div class="fc-series-browse__controls">
|
||||||
|
<v-text-field
|
||||||
|
v-model="query"
|
||||||
|
density="compact" variant="outlined" hide-details clearable
|
||||||
|
label="Search series" prepend-inner-icon="mdi-magnify"
|
||||||
|
style="max-width: 320px;"
|
||||||
|
/>
|
||||||
<v-select
|
<v-select
|
||||||
:model-value="store.sort"
|
:model-value="store.sort"
|
||||||
:items="sortItems"
|
:items="sortItems"
|
||||||
@@ -34,10 +40,16 @@
|
|||||||
No series yet. Make one from a post's “Series ▾ → New series from this
|
No series yet. Make one from a post's “Series ▾ → New series from this
|
||||||
post”, or create a <code>series:</code> tag and add images.
|
post”, or create a <code>series:</code> tag and add images.
|
||||||
</div>
|
</div>
|
||||||
|
<div
|
||||||
|
v-else-if="!store.loading && visibleSeries.length === 0"
|
||||||
|
class="fc-series-browse__empty"
|
||||||
|
>
|
||||||
|
No series match “{{ query }}”.
|
||||||
|
</div>
|
||||||
|
|
||||||
<div class="fc-series-browse__grid">
|
<div class="fc-series-browse__grid">
|
||||||
<article
|
<article
|
||||||
v-for="s in store.series" :key="s.id" class="fc-sbcard"
|
v-for="s in visibleSeries" :key="s.id" class="fc-sbcard"
|
||||||
@click="manage(s.id)"
|
@click="manage(s.id)"
|
||||||
>
|
>
|
||||||
<div class="fc-sbcard__cover">
|
<div class="fc-sbcard__cover">
|
||||||
@@ -48,6 +60,27 @@
|
|||||||
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
<span v-if="s.has_gap" class="fc-sbcard__gap" title="Has a missing-page gap">
|
||||||
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
<v-icon size="x-small">mdi-alert-outline</v-icon> gap
|
||||||
</span>
|
</span>
|
||||||
|
<v-menu>
|
||||||
|
<template #activator="{ props: menuProps }">
|
||||||
|
<v-btn
|
||||||
|
v-bind="menuProps"
|
||||||
|
icon="mdi-dots-vertical" size="x-small" variant="flat"
|
||||||
|
class="fc-sbcard__kebab" :aria-label="`Actions for ${s.name}`"
|
||||||
|
@click.stop
|
||||||
|
/>
|
||||||
|
</template>
|
||||||
|
<v-list density="compact">
|
||||||
|
<v-list-item prepend-icon="mdi-pencil" @click.stop="openRename(s)">
|
||||||
|
<v-list-item-title>Rename</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
<v-list-item
|
||||||
|
prepend-icon="mdi-delete" base-color="error"
|
||||||
|
@click.stop="openDelete(s)"
|
||||||
|
>
|
||||||
|
<v-list-item-title>Delete</v-list-item-title>
|
||||||
|
</v-list-item>
|
||||||
|
</v-list>
|
||||||
|
</v-menu>
|
||||||
</div>
|
</div>
|
||||||
<div class="fc-sbcard__body">
|
<div class="fc-sbcard__body">
|
||||||
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
<h3 class="fc-sbcard__name">{{ s.name }}</h3>
|
||||||
@@ -73,6 +106,38 @@
|
|||||||
</article>
|
</article>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<v-dialog v-model="renameDialog" max-width="420">
|
||||||
|
<TagRenameDialog
|
||||||
|
v-if="renameTarget"
|
||||||
|
:tag="renameTarget"
|
||||||
|
@renamed="onRenamed" @cancel="renameDialog = false"
|
||||||
|
/>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
|
<v-dialog v-model="deleteDialog" max-width="440">
|
||||||
|
<v-card v-if="deleteTarget">
|
||||||
|
<v-card-title class="text-body-1">Delete “{{ deleteTarget.name }}”?</v-card-title>
|
||||||
|
<v-card-text>
|
||||||
|
<p class="text-body-2">
|
||||||
|
Removes the series and its chapter/page ordering. The images
|
||||||
|
themselves are kept — only the series grouping is deleted.
|
||||||
|
</p>
|
||||||
|
<v-alert
|
||||||
|
v-if="deleteError" type="error" variant="tonal"
|
||||||
|
density="compact" class="mt-3"
|
||||||
|
>{{ deleteError }}</v-alert>
|
||||||
|
</v-card-text>
|
||||||
|
<v-card-actions>
|
||||||
|
<v-spacer />
|
||||||
|
<v-btn :disabled="deleting" @click="deleteDialog = false">Cancel</v-btn>
|
||||||
|
<v-btn
|
||||||
|
color="error" variant="flat" rounded="pill"
|
||||||
|
:loading="deleting" @click="confirmDelete"
|
||||||
|
>Delete series</v-btn>
|
||||||
|
</v-card-actions>
|
||||||
|
</v-card>
|
||||||
|
</v-dialog>
|
||||||
|
|
||||||
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
<div v-if="store.loading" class="fc-series-browse__sentinel">
|
||||||
<v-progress-circular indeterminate color="accent" size="28" />
|
<v-progress-circular indeterminate color="accent" size="28" />
|
||||||
</div>
|
</div>
|
||||||
@@ -140,10 +205,11 @@
|
|||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup>
|
<script setup>
|
||||||
import { onMounted, ref } from 'vue'
|
import { computed, onMounted, ref } from 'vue'
|
||||||
import { useRouter } from 'vue-router'
|
import { useRouter } from 'vue-router'
|
||||||
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
import { useSeriesBrowseStore } from '../stores/seriesBrowse.js'
|
||||||
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
import { useSeriesSuggestionsStore } from '../stores/seriesSuggestions.js'
|
||||||
|
import TagRenameDialog from '../components/modal/TagRenameDialog.vue'
|
||||||
|
|
||||||
const router = useRouter()
|
const router = useRouter()
|
||||||
const store = useSeriesBrowseStore()
|
const store = useSeriesBrowseStore()
|
||||||
@@ -156,6 +222,18 @@ const sortItems = [
|
|||||||
{ title: 'Size', value: 'size' }
|
{ title: 'Size', value: 'size' }
|
||||||
]
|
]
|
||||||
|
|
||||||
|
// Client-side name/artist filter — the whole list is already loaded (homelab
|
||||||
|
// scale), so search is instant and needs no round-trip.
|
||||||
|
const query = ref('')
|
||||||
|
const visibleSeries = computed(() => {
|
||||||
|
const q = (query.value || '').trim().toLowerCase()
|
||||||
|
if (!q) return store.series
|
||||||
|
return store.series.filter(s =>
|
||||||
|
s.name?.toLowerCase().includes(q)
|
||||||
|
|| s.artist_name?.toLowerCase().includes(q)
|
||||||
|
)
|
||||||
|
})
|
||||||
|
|
||||||
function manage(id) {
|
function manage(id) {
|
||||||
router.push({ name: 'series-manage', params: { tagId: id } })
|
router.push({ name: 'series-manage', params: { tagId: id } })
|
||||||
}
|
}
|
||||||
@@ -163,6 +241,44 @@ function read(id) {
|
|||||||
router.push({ name: 'series-read', params: { tagId: id } })
|
router.push({ name: 'series-read', params: { tagId: id } })
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// --- per-series kebab: rename + delete ----------------------------------
|
||||||
|
const renameDialog = ref(false)
|
||||||
|
const renameTarget = ref(null)
|
||||||
|
function openRename(s) {
|
||||||
|
// TagRenameDialog renames the underlying Tag(kind=series); pass the kind so
|
||||||
|
// its collision copy reads correctly.
|
||||||
|
renameTarget.value = { id: s.id, name: s.name, kind: 'series' }
|
||||||
|
renameDialog.value = true
|
||||||
|
}
|
||||||
|
function onRenamed(updated) {
|
||||||
|
renameDialog.value = false
|
||||||
|
if (updated?.id != null && updated?.name) {
|
||||||
|
store.applyRename(updated.id, updated.name)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
const deleteDialog = ref(false)
|
||||||
|
const deleteTarget = ref(null)
|
||||||
|
const deleting = ref(false)
|
||||||
|
const deleteError = ref(null)
|
||||||
|
function openDelete(s) {
|
||||||
|
deleteTarget.value = s
|
||||||
|
deleteError.value = null
|
||||||
|
deleteDialog.value = true
|
||||||
|
}
|
||||||
|
async function confirmDelete() {
|
||||||
|
deleting.value = true
|
||||||
|
deleteError.value = null
|
||||||
|
try {
|
||||||
|
await store.remove(deleteTarget.value.id)
|
||||||
|
deleteDialog.value = false
|
||||||
|
} catch (e) {
|
||||||
|
deleteError.value = e.message
|
||||||
|
} finally {
|
||||||
|
deleting.value = false
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
|
function pct(v) { return `${Math.round((v || 0) * 100)}%` }
|
||||||
// Only the signals that actually fired (strength > 0), in a stable order.
|
// Only the signals that actually fired (strength > 0), in a stable order.
|
||||||
function signalKeys(s) {
|
function signalKeys(s) {
|
||||||
@@ -178,7 +294,7 @@ onMounted(() => {
|
|||||||
|
|
||||||
<style scoped>
|
<style scoped>
|
||||||
.fc-series-browse__controls {
|
.fc-series-browse__controls {
|
||||||
display: flex; gap: 12px; margin-bottom: 16px;
|
display: flex; flex-wrap: wrap; gap: 12px; margin-bottom: 16px;
|
||||||
}
|
}
|
||||||
.fc-series-browse__empty {
|
.fc-series-browse__empty {
|
||||||
padding: 48px; text-align: center;
|
padding: 48px; text-align: center;
|
||||||
@@ -209,12 +325,20 @@ onMounted(() => {
|
|||||||
color: rgb(var(--v-theme-on-surface-variant));
|
color: rgb(var(--v-theme-on-surface-variant));
|
||||||
}
|
}
|
||||||
.fc-sbcard__gap {
|
.fc-sbcard__gap {
|
||||||
position: absolute; top: 6px; right: 6px;
|
position: absolute; top: 6px; left: 6px;
|
||||||
display: inline-flex; align-items: center; gap: 2px;
|
display: inline-flex; align-items: center; gap: 2px;
|
||||||
padding: 1px 6px; border-radius: 999px; font-size: 11px;
|
padding: 1px 6px; border-radius: 999px; font-size: 11px;
|
||||||
background: rgba(20, 23, 26, 0.75);
|
background: rgba(20, 23, 26, 0.75);
|
||||||
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
color: rgb(var(--v-theme-warning, var(--v-theme-accent)));
|
||||||
}
|
}
|
||||||
|
/* Kebab sits top-right of the cover, opposite the gap badge. Tinted backing so
|
||||||
|
it stays legible over any cover image. */
|
||||||
|
.fc-sbcard__kebab {
|
||||||
|
position: absolute; top: 4px; right: 4px;
|
||||||
|
background: rgba(20, 23, 26, 0.6) !important;
|
||||||
|
color: rgb(var(--v-theme-on-surface));
|
||||||
|
}
|
||||||
|
.fc-sbcard__kebab:hover { background: rgba(20, 23, 26, 0.85) !important; }
|
||||||
.fc-sbcard__body { padding: 8px 10px; }
|
.fc-sbcard__body { padding: 8px 10px; }
|
||||||
.fc-sbcard__name {
|
.fc-sbcard__name {
|
||||||
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
|
font-family: 'Fraunces', Georgia, serif; font-size: 15px; font-weight: 600;
|
||||||
|
|||||||
@@ -44,6 +44,33 @@ async def test_threshold_filters_low_confidence_general(db):
|
|||||||
assert "Lowconf" not in names
|
assert "Lowconf" not in names
|
||||||
|
|
||||||
|
|
||||||
|
@pytest.mark.asyncio
|
||||||
|
async def test_threshold_override_surfaces_low_confidence(db):
|
||||||
|
# The typed-dropdown "show everything the model saw" mode: threshold_override
|
||||||
|
# surfaces stored predictions below the configured threshold (in canonical
|
||||||
|
# formatting) so they can be picked instead of hand-typed (2026-06-09).
|
||||||
|
img = _img(
|
||||||
|
"d" * 64,
|
||||||
|
{
|
||||||
|
"lowconf": {"category": "general", "confidence": 0.30},
|
||||||
|
"sword": {"category": "general", "confidence": 0.97},
|
||||||
|
},
|
||||||
|
)
|
||||||
|
db.add(img)
|
||||||
|
await db.flush()
|
||||||
|
sl = await SuggestionService(db).for_image(img.id, threshold_override=0.0)
|
||||||
|
names = [s.display_name for s in sl.by_category.get("general", [])]
|
||||||
|
assert "Sword" in names
|
||||||
|
assert "Lowconf" in names # below the configured threshold, surfaced anyway
|
||||||
|
|
||||||
|
# Unsurfaced categories are still excluded even with the override.
|
||||||
|
img2 = _img("e" * 64, {"safe": {"category": "rating", "confidence": 0.99}})
|
||||||
|
db.add(img2)
|
||||||
|
await db.flush()
|
||||||
|
sl2 = await SuggestionService(db).for_image(img2.id, threshold_override=0.0)
|
||||||
|
assert "rating" not in sl2.by_category
|
||||||
|
|
||||||
|
|
||||||
@pytest.mark.asyncio
|
@pytest.mark.asyncio
|
||||||
async def test_unsurfaced_category_dropped(db):
|
async def test_unsurfaced_category_dropped(db):
|
||||||
img = _img(
|
img = _img(
|
||||||
|
|||||||
Reference in New Issue
Block a user