feat(ui): hidden-view review strip — flagged auto-hides with keep / un-hide (#141 step 5)
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
This commit is contained in:
@@ -0,0 +1,151 @@
|
||||
<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>
|
||||
@@ -13,6 +13,7 @@
|
||||
<div class="fc-gallery-layout__main">
|
||||
<PostInfoHeader />
|
||||
<GalleryFilterBar v-if="store.filter.post_id == null" />
|
||||
<HiddenReviewStrip />
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
<GalleryGrid v-else @open="openImage" />
|
||||
</div>
|
||||
@@ -33,6 +34,7 @@ import { useGalleryStore } from '../stores/gallery.js'
|
||||
import { useModalStore } from '../stores/modal.js'
|
||||
import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||
import GalleryFilterBar from '../components/gallery/GalleryFilterBar.vue'
|
||||
import HiddenReviewStrip from '../components/gallery/HiddenReviewStrip.vue'
|
||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||
import PostInfoHeader from '../components/gallery/PostInfoHeader.vue'
|
||||
|
||||
Reference in New Issue
Block a user