feat(bulk): IR-style BulkEditorPanel + GalleryView select toggle and wiring
Co-Authored-By: Claude Opus 4.7 (1M context) <noreply@anthropic.com>
This commit is contained in:
@@ -0,0 +1,150 @@
|
||||
<template>
|
||||
<aside class="fc-bulk-panel" :class="{ active: sel.isSelectMode }">
|
||||
<div class="fc-bulk-panel__head">
|
||||
<h3>Bulk Edit</h3>
|
||||
<v-btn icon="mdi-close" variant="text" size="small" @click="sel.exitSelectMode()" />
|
||||
</div>
|
||||
<div class="fc-bulk-panel__stat">{{ sel.count }} image(s) selected</div>
|
||||
|
||||
<div class="fc-bulk-panel__section">
|
||||
<h4>Add tag</h4>
|
||||
<v-autocomplete
|
||||
v-model="addModel" :items="addHits" item-title="name" item-value="id"
|
||||
:loading="addLoading" density="compact" variant="outlined" hide-details
|
||||
placeholder="Search tags…" no-filter
|
||||
@update:search="onAddSearch" @update:model-value="onAddPick"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="fc-bulk-panel__section">
|
||||
<h4>Remove common tag</h4>
|
||||
<div v-if="sel.commonTags.length === 0" class="fc-bulk-panel__hint">
|
||||
No tags common to all selected images.
|
||||
</div>
|
||||
<v-chip
|
||||
v-for="t in sel.commonTags" :key="t.id" size="small" label
|
||||
class="ma-1" closable @click:close="sel.bulkRemove(t.id)"
|
||||
>{{ t.name }}</v-chip>
|
||||
</div>
|
||||
|
||||
<div class="fc-bulk-panel__section">
|
||||
<h4>Consensus suggestions</h4>
|
||||
<div v-if="sel.count < 2" class="fc-bulk-panel__hint">
|
||||
Select 2+ images for consensus suggestions.
|
||||
</div>
|
||||
<template v-else>
|
||||
<div
|
||||
v-for="(items, cat) in sel.consensus" :key="cat"
|
||||
class="fc-bulk-panel__cat"
|
||||
>
|
||||
<div class="fc-bulk-panel__cat-name">{{ cat }}</div>
|
||||
<div
|
||||
v-for="s in items" :key="s.canonical_tag_id"
|
||||
class="fc-bulk-panel__sug"
|
||||
>
|
||||
<span>{{ s.name }}</span>
|
||||
<span class="fc-bulk-panel__cov">
|
||||
{{ Math.round(s.coverage * 100) }}% · {{ s.confidence.toFixed(2) }}
|
||||
</span>
|
||||
<v-btn
|
||||
size="x-small" variant="tonal" color="accent"
|
||||
@click="sel.bulkAdd(s.canonical_tag_id, 'ml_accepted')"
|
||||
>Accept</v-btn>
|
||||
</div>
|
||||
</div>
|
||||
</template>
|
||||
</div>
|
||||
|
||||
<div class="fc-bulk-panel__foot">
|
||||
<v-btn variant="text" block @click="sel.clear()">Clear selection</v-btn>
|
||||
</div>
|
||||
</aside>
|
||||
</template>
|
||||
|
||||
<script setup>
|
||||
import { ref, watch } from 'vue'
|
||||
import { useGallerySelectionStore } from '../../stores/gallerySelection.js'
|
||||
import { useApi } from '../../composables/useApi.js'
|
||||
|
||||
const sel = useGallerySelectionStore()
|
||||
const api = useApi()
|
||||
|
||||
const addModel = ref(null)
|
||||
const addHits = ref([])
|
||||
const addLoading = ref(false)
|
||||
let addDebounce = null
|
||||
|
||||
function onAddSearch(q) {
|
||||
if (addDebounce) clearTimeout(addDebounce)
|
||||
if (!q) { addHits.value = []; return }
|
||||
addDebounce = setTimeout(async () => {
|
||||
addLoading.value = true
|
||||
try {
|
||||
addHits.value = await api.get('/api/tags/autocomplete', {
|
||||
params: { q, limit: 20 }
|
||||
})
|
||||
} finally {
|
||||
addLoading.value = false
|
||||
}
|
||||
}, 250)
|
||||
}
|
||||
async function onAddPick(id) {
|
||||
if (id == null) return
|
||||
await sel.bulkAdd(id)
|
||||
addModel.value = null
|
||||
addHits.value = []
|
||||
}
|
||||
|
||||
// Refresh common tags + consensus whenever the selection changes.
|
||||
watch(() => sel.order.length, () => {
|
||||
if (sel.isSelectMode) sel.refresh()
|
||||
})
|
||||
</script>
|
||||
|
||||
<style scoped>
|
||||
.fc-bulk-panel {
|
||||
position: fixed; top: 0; right: 0;
|
||||
width: 320px; height: 100vh; z-index: 1100;
|
||||
background: rgb(var(--v-theme-surface));
|
||||
border-left: 1px solid rgb(var(--v-theme-surface-light));
|
||||
box-shadow: -2px 0 16px rgba(0, 0, 0, 0.4);
|
||||
transform: translateX(100%);
|
||||
transition: transform 0.3s ease;
|
||||
display: flex; flex-direction: column;
|
||||
overflow-y: auto;
|
||||
}
|
||||
.fc-bulk-panel.active { transform: translateX(0); }
|
||||
.fc-bulk-panel__head {
|
||||
display: flex; align-items: center; justify-content: space-between;
|
||||
padding: 12px 16px;
|
||||
border-bottom: 1px solid rgb(var(--v-theme-surface-light));
|
||||
}
|
||||
.fc-bulk-panel__head h3 {
|
||||
font-family: 'Fraunces', Georgia, serif; font-size: 18px; margin: 0;
|
||||
}
|
||||
.fc-bulk-panel__stat {
|
||||
padding: 10px 16px; font-size: 13px; opacity: 0.8;
|
||||
background: rgba(232, 228, 216, 0.05);
|
||||
}
|
||||
.fc-bulk-panel__section {
|
||||
padding: 16px;
|
||||
border-bottom: 1px solid rgba(232, 228, 216, 0.06);
|
||||
}
|
||||
.fc-bulk-panel__section h4 {
|
||||
margin: 0 0 10px; font-size: 12px; text-transform: uppercase;
|
||||
letter-spacing: 0.04em; opacity: 0.7;
|
||||
}
|
||||
.fc-bulk-panel__hint { font-size: 12px; opacity: 0.6; }
|
||||
.fc-bulk-panel__cat-name {
|
||||
font-size: 11px; text-transform: uppercase; opacity: 0.5; margin: 8px 0 4px;
|
||||
}
|
||||
.fc-bulk-panel__sug {
|
||||
display: flex; align-items: center; gap: 8px;
|
||||
padding: 4px 0; font-size: 13px;
|
||||
}
|
||||
.fc-bulk-panel__sug span:first-child { flex: 1; min-width: 0; }
|
||||
.fc-bulk-panel__cov {
|
||||
font-variant-numeric: tabular-nums; opacity: 0.6; font-size: 11px;
|
||||
}
|
||||
.fc-bulk-panel__foot { margin-top: auto; padding: 12px 16px; }
|
||||
</style>
|
||||
@@ -1,5 +1,13 @@
|
||||
<template>
|
||||
<v-container fluid class="py-6">
|
||||
<v-btn
|
||||
class="fc-select-toggle"
|
||||
:color="sel.isSelectMode ? 'accent' : undefined"
|
||||
:variant="sel.isSelectMode ? 'flat' : 'tonal'"
|
||||
size="small"
|
||||
@click="sel.isSelectMode ? sel.exitSelectMode() : sel.enterSelectMode()"
|
||||
>{{ sel.isSelectMode ? 'Done' : 'Select' }}</v-btn>
|
||||
|
||||
<div class="fc-gallery-layout">
|
||||
<div class="fc-gallery-layout__main">
|
||||
<EmptyState v-if="store.isEmpty" />
|
||||
@@ -12,6 +20,7 @@
|
||||
v-if="modal.currentImageId !== null"
|
||||
@close="closeImage"
|
||||
/>
|
||||
<BulkEditorPanel />
|
||||
</v-container>
|
||||
</template>
|
||||
|
||||
@@ -24,9 +33,12 @@ import GalleryGrid from '../components/gallery/GalleryGrid.vue'
|
||||
import TimelineSidebar from '../components/gallery/TimelineSidebar.vue'
|
||||
import EmptyState from '../components/gallery/EmptyState.vue'
|
||||
import ImageViewer from '../components/modal/ImageViewer.vue'
|
||||
import BulkEditorPanel from '../components/gallery/BulkEditorPanel.vue'
|
||||
import { useGallerySelectionStore } from '../stores/gallerySelection.js'
|
||||
|
||||
const store = useGalleryStore()
|
||||
const modal = useModalStore()
|
||||
const sel = useGallerySelectionStore()
|
||||
const router = useRouter()
|
||||
const route = useRoute()
|
||||
|
||||
@@ -41,6 +53,7 @@ onMounted(async () => {
|
||||
})
|
||||
|
||||
watch(() => route.query.tag_id, (q) => {
|
||||
sel.clear() // result set changed — selected ids are no longer valid
|
||||
const tagId = parseInt(q, 10)
|
||||
store.setTagFilter(isNaN(tagId) ? null : tagId)
|
||||
})
|
||||
@@ -69,6 +82,9 @@ function closeImage() {
|
||||
}
|
||||
.fc-gallery-layout__main { flex: 1; min-width: 0; }
|
||||
.fc-gallery-layout__sidebar { flex-shrink: 0; }
|
||||
.fc-select-toggle {
|
||||
position: absolute; top: 12px; right: 16px; z-index: 1101;
|
||||
}
|
||||
|
||||
@media (max-width: 900px) {
|
||||
.fc-gallery-layout { flex-direction: column-reverse; }
|
||||
|
||||
Reference in New Issue
Block a user