9726d6f4b5
When "Show hidden" is on, a review strip appears atop the gallery listing the auto-hidden chrome flagged "also looks like content" (most-concerning first): thumbnail + "also looks like <X>" + Keep hidden / Un-hide. Un-hide removes the presentation tag (image returns to the gallery) and trains the head; Keep resolves the flag. Self-hides when there's nothing to review; theme-token styled. Co-Authored-By: Claude Opus 4.8 (1M context) <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CDgx8bQS5YrGRK76v8HUnM
152 lines
5.1 KiB
Vue
152 lines
5.1 KiB
Vue
<template>
|
|
<!-- Shown atop the gallery when "Show hidden" is on: auto-hidden chrome that
|
|
ALSO looked like real content, most-concerning first, with keep / un-hide
|
|
(#141). Renders nothing when there's nothing to review. -->
|
|
<section v-if="items.length" class="fc-review" aria-label="Hidden images to review">
|
|
<div class="fc-review__head">
|
|
<v-icon size="18" color="warning">mdi-alert-outline</v-icon>
|
|
<span class="fc-review__title">
|
|
{{ items.length }} auto-hidden {{ items.length === 1 ? 'image' : 'images' }}
|
|
may be real content — review before they stay hidden
|
|
</span>
|
|
</div>
|
|
<div class="fc-review__cards">
|
|
<div
|
|
v-for="it in items" :key="keyOf(it)" class="fc-review-card"
|
|
>
|
|
<img
|
|
:src="it.thumbnail_url" :alt="it.tag_name"
|
|
class="fc-review-card__thumb" loading="lazy"
|
|
>
|
|
<div class="fc-review-card__body">
|
|
<div
|
|
class="fc-review-card__conflict"
|
|
:title="`Scored ${Math.round(it.conflict_score * 100)}% on “${it.conflict_name || 'a content tag'}”`"
|
|
>
|
|
also looks like <strong>{{ it.conflict_name || 'content' }}</strong>
|
|
</div>
|
|
<div class="fc-review-card__tag">hidden as {{ it.tag_name }}</div>
|
|
<div class="fc-review-card__acts">
|
|
<button
|
|
type="button" class="fc-review-btn fc-review-btn--keep"
|
|
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'keep')"
|
|
>Keep hidden</button>
|
|
<button
|
|
type="button" class="fc-review-btn fc-review-btn--unhide"
|
|
:disabled="busy.includes(keyOf(it))" @click="resolve(it, 'unhide')"
|
|
>Un-hide</button>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</div>
|
|
</section>
|
|
</template>
|
|
|
|
<script setup>
|
|
import { ref, watch } from 'vue'
|
|
import { useApi } from '../../composables/useApi.js'
|
|
import { useGalleryStore } from '../../stores/gallery.js'
|
|
import { toast } from '../../utils/toast.js'
|
|
|
|
const api = useApi()
|
|
const store = useGalleryStore()
|
|
|
|
const items = ref([])
|
|
const busy = ref([])
|
|
|
|
function keyOf(it) { return `${it.image_id}:${it.tag_id}` }
|
|
|
|
async function load() {
|
|
if (!store.filter.include_hidden) { items.value = []; return }
|
|
try {
|
|
const body = await api.get('/api/gallery/hidden-review')
|
|
items.value = body.items || []
|
|
} catch { items.value = [] }
|
|
}
|
|
|
|
async function resolve(it, action) {
|
|
const k = keyOf(it)
|
|
busy.value = [...busy.value, k]
|
|
try {
|
|
await api.post(`/api/gallery/hidden-review/${it.image_id}/${it.tag_id}/${action}`)
|
|
items.value = items.value.filter((x) => keyOf(x) !== k)
|
|
if (action === 'unhide') {
|
|
toast({ text: `Un-hidden — “${it.tag_name}” removed; it'll train the head`, type: 'success' })
|
|
}
|
|
} catch (e) {
|
|
toast({
|
|
text: `Could not ${action === 'keep' ? 'keep hidden' : 'un-hide'}: ${e.message}`,
|
|
type: 'error',
|
|
})
|
|
} finally {
|
|
busy.value = busy.value.filter((x) => x !== k)
|
|
}
|
|
}
|
|
|
|
// Fetch whenever "Show hidden" flips on (immediate covers a mount already-on).
|
|
watch(() => store.filter.include_hidden, load, { immediate: true })
|
|
</script>
|
|
|
|
<style scoped>
|
|
.fc-review {
|
|
margin-bottom: 14px;
|
|
padding: 12px;
|
|
border: 1px solid rgb(var(--v-theme-warning), 0.4);
|
|
background: rgb(var(--v-theme-warning), 0.06);
|
|
border-radius: 8px;
|
|
}
|
|
.fc-review__head {
|
|
display: flex; align-items: center; gap: 8px; margin-bottom: 10px;
|
|
}
|
|
.fc-review__title {
|
|
font-size: 14px; font-weight: 600;
|
|
color: rgb(var(--v-theme-on-surface));
|
|
}
|
|
.fc-review__cards {
|
|
display: flex; gap: 10px; overflow-x: auto; padding-bottom: 4px;
|
|
}
|
|
.fc-review-card {
|
|
flex: 0 0 auto; width: 150px;
|
|
display: flex; flex-direction: column;
|
|
border: 1px solid rgb(var(--v-theme-surface-light));
|
|
border-radius: 6px; overflow: hidden;
|
|
background: rgb(var(--v-theme-surface));
|
|
}
|
|
.fc-review-card__thumb {
|
|
width: 100%; height: 96px; object-fit: cover; display: block;
|
|
background: rgb(var(--v-theme-surface-light));
|
|
}
|
|
.fc-review-card__body { padding: 6px 8px; }
|
|
.fc-review-card__conflict {
|
|
font-size: 11px; color: rgb(var(--v-theme-on-surface));
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-review-card__conflict strong { color: rgb(var(--v-theme-warning)); }
|
|
.fc-review-card__tag {
|
|
font-size: 10px; color: rgb(var(--v-theme-on-surface-variant));
|
|
margin: 1px 0 6px;
|
|
overflow: hidden; text-overflow: ellipsis; white-space: nowrap;
|
|
}
|
|
.fc-review-card__acts { display: flex; gap: 4px; }
|
|
.fc-review-btn {
|
|
flex: 1; font-size: 11px; padding: 3px 4px; border-radius: 4px;
|
|
cursor: pointer; border: 1px solid transparent;
|
|
}
|
|
.fc-review-btn:disabled { opacity: 0.5; cursor: default; }
|
|
.fc-review-btn--keep {
|
|
background: transparent;
|
|
color: rgb(var(--v-theme-on-surface-variant));
|
|
border-color: rgb(var(--v-theme-on-surface-variant), 0.4);
|
|
}
|
|
.fc-review-btn--keep:hover:not(:disabled) {
|
|
background: rgb(var(--v-theme-on-surface-variant), 0.1);
|
|
}
|
|
.fc-review-btn--unhide {
|
|
background: rgb(var(--v-theme-accent)); color: #fff;
|
|
}
|
|
.fc-review-btn--unhide:hover:not(:disabled) { opacity: 0.9; }
|
|
.fc-review-btn:focus-visible {
|
|
outline: 2px solid rgb(var(--v-theme-accent)); outline-offset: 1px;
|
|
}
|
|
</style>
|