fix: Library thumbnail loading + People right-click context menu
This commit is contained in:
@@ -64,7 +64,8 @@
|
|||||||
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
<div v-for="f in sortedFilteredFiles" :key="f.file_uuid" class="mp-file-card" :class="{ 'is-completed': f.isRegistered, 'is-selected': selectedFiles.includes(f.file_uuid) }" @click="toggleSelect(f)">
|
||||||
<div class="mp-thumb-wrap">
|
<div class="mp-thumb-wrap">
|
||||||
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
<div class="mp-badge-type">{{ isVideo(f) ? 'VIDEO' : (isPhoto(f) ? 'PHOTO' : 'DOC') }}</div>
|
||||||
<img v-if="isPhoto(f) || isVideo(f)" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" loading="lazy" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
|
<img v-if="isPhoto(f) || isVideo(f)" v-show="thumbnails[f.file_uuid]" class="lt-thumb" :src="thumbnails[f.file_uuid]" alt="" @error="handleThumbError" @vue:mounted="loadThumbnail(f.file_uuid)">
|
||||||
|
<div v-if="(isPhoto(f) || isVideo(f)) && !thumbnails[f.file_uuid]" class="mp-thumb-loading">⟳</div>
|
||||||
<div v-else class="mp-doc-thumb">
|
<div v-else class="mp-doc-thumb">
|
||||||
<span class="mp-doc-icon">📄</span>
|
<span class="mp-doc-icon">📄</span>
|
||||||
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
<span class="mp-doc-ext">{{ getFileExt(f.file_name) }}</span>
|
||||||
@@ -278,6 +279,8 @@ function resetFilters() {
|
|||||||
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
|
.mp-file-card { border-radius: 14px; cursor: pointer; position: relative; }
|
||||||
.mp-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
.mp-thumb-wrap { position: relative; border-radius: 12px; overflow: hidden; background: #eef2f7; aspect-ratio: 4 / 3; border: 1px solid #e6ebf1; box-shadow: 0 0 0 2px #d9dde3 inset; }
|
||||||
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
.lt-thumb { width: 100%; height: 100%; object-fit: cover; display: block; }
|
||||||
|
.mp-thumb-loading { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; font-size: 1.5rem; color: #9aa0a6; animation: spin 1s linear infinite; }
|
||||||
|
@keyframes spin { to { transform: rotate(360deg); } }
|
||||||
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
|
.mp-doc-thumb { width: 100%; height: 100%; display: flex; align-items: center; justify-content: center; flex-direction: column; gap: 8px; background: linear-gradient(180deg, #f7f9fc, #eef2f7); color: #6b7280; text-align: center; padding: 14px; }
|
||||||
.mp-doc-icon { font-size: 38px; line-height: 1; }
|
.mp-doc-icon { font-size: 38px; line-height: 1; }
|
||||||
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
|
.mp-doc-ext { font-size: 11px; font-weight: 700; letter-spacing: .5px; color: #7a818c; text-transform: uppercase; }
|
||||||
|
|||||||
@@ -14,7 +14,7 @@
|
|||||||
</div>
|
</div>
|
||||||
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div>
|
<div v-else-if="filteredPeople.length === 0" class="empty">No results</div>
|
||||||
<div v-else class="ms-ppl-face-grid">
|
<div v-else class="ms-ppl-face-grid">
|
||||||
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)">
|
<div v-for="p in filteredPeople" :key="p.identity_uuid" class="ms-ppl-face-card" @click="selectPerson(p)" @contextmenu.prevent="showContextMenu($event, p)">
|
||||||
<div class="ms-ppl-face-img-wrap">
|
<div class="ms-ppl-face-img-wrap">
|
||||||
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
<img v-if="profiles[p.identity_uuid]" :src="profiles[p.identity_uuid]" alt="" @vue:mounted="loadProfile(p.identity_uuid)">
|
||||||
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" viewBox="0 0 120 120" fill="none">
|
<svg v-else class="ms-silhouette" @vue:mounted="loadProfile(p.identity_uuid)" viewBox="0 0 120 120" fill="none">
|
||||||
@@ -104,12 +104,19 @@
|
|||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<div v-if="ctxMenu.show" class="ctx-menu" :style="{ left: ctxMenu.x + 'px', top: ctxMenu.y + 'px' }">
|
||||||
|
<div class="ctx-item" @click="ctxAction('rename')">✎ Rename</div>
|
||||||
|
<div class="ctx-item" @click="ctxAction('merge')">⇄ Merge</div>
|
||||||
|
<div class="ctx-divider"></div>
|
||||||
|
<div class="ctx-item danger" @click="ctxAction('delete')">✕ Delete</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
<VideoPlayer v-if="playing" :file-uuid="currentVideo.fileUuid" :start-time="currentVideo.startTime" :end-time="currentVideo.endTime" :title="currentVideo.title" @close="playing = false" />
|
||||||
</div>
|
</div>
|
||||||
</template>
|
</template>
|
||||||
|
|
||||||
<script setup lang="ts">
|
<script setup lang="ts">
|
||||||
import { ref, computed, onMounted, watch } from 'vue'
|
import { ref, computed, onMounted, onUnmounted, watch } from 'vue'
|
||||||
import { invoke } from '@tauri-apps/api/core'
|
import { invoke } from '@tauri-apps/api/core'
|
||||||
import VideoPlayer from '../components/VideoPlayer.vue'
|
import VideoPlayer from '../components/VideoPlayer.vue'
|
||||||
|
|
||||||
@@ -133,6 +140,7 @@ const showMerge = ref(false)
|
|||||||
const mergeTarget = ref('')
|
const mergeTarget = ref('')
|
||||||
const candidates = ref<any[]>([])
|
const candidates = ref<any[]>([])
|
||||||
const candidateThumbs = ref<Record<string, string>>({})
|
const candidateThumbs = ref<Record<string, string>>({})
|
||||||
|
const ctxMenu = ref({ show: false, x: 0, y: 0, person: null as any })
|
||||||
|
|
||||||
const filteredPeople = computed(() => {
|
const filteredPeople = computed(() => {
|
||||||
if (isSearching.value && searchResults.value.length) return searchResults.value
|
if (isSearching.value && searchResults.value.length) return searchResults.value
|
||||||
@@ -244,6 +252,37 @@ function playTrace(t: any) {
|
|||||||
playing.value = true
|
playing.value = true
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function showContextMenu(e: MouseEvent, p: any) {
|
||||||
|
ctxMenu.value = { show: true, x: e.clientX, y: e.clientY, person: p }
|
||||||
|
}
|
||||||
|
|
||||||
|
function ctxAction(action: string) {
|
||||||
|
const p = ctxMenu.value.person
|
||||||
|
if (!p) return
|
||||||
|
ctxMenu.value.show = false
|
||||||
|
if (action === 'rename') {
|
||||||
|
selectPerson(p)
|
||||||
|
setTimeout(() => startEditName(), 100)
|
||||||
|
} else if (action === 'merge') {
|
||||||
|
selectPerson(p)
|
||||||
|
setTimeout(() => { showMerge.value = true }, 100)
|
||||||
|
} else if (action === 'delete') {
|
||||||
|
if (confirm(`Delete "${p.name}"?`)) {
|
||||||
|
invoke('delete_identity', { uuid: p.identity_uuid }).then(() => {
|
||||||
|
people.value = people.value.filter((x: any) => x.identity_uuid !== p.identity_uuid)
|
||||||
|
}).catch(e => console.error('Delete failed:', e))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
function closeCtxMenu() { ctxMenu.value.show = false }
|
||||||
|
onMounted(() => {
|
||||||
|
document.addEventListener('click', closeCtxMenu)
|
||||||
|
})
|
||||||
|
onUnmounted(() => {
|
||||||
|
document.removeEventListener('click', closeCtxMenu)
|
||||||
|
})
|
||||||
|
|
||||||
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
watch(showCandidates, (v) => { if (v) loadCandidates() })
|
||||||
</script>
|
</script>
|
||||||
|
|
||||||
@@ -278,4 +317,9 @@ h1 { margin: 0; }
|
|||||||
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
.ms-modal-actions { display: flex; justify-content: flex-end; }
|
||||||
h2 { margin: 0 0 16px; font-size: 1rem; }
|
h2 { margin: 0 0 16px; font-size: 1rem; }
|
||||||
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
|
.ms-merge-grid { display: flex; flex-wrap: wrap; gap: 16px; max-height: 50vh; overflow-y: auto; }
|
||||||
|
.ctx-menu { position: fixed; z-index: 9999; background: #fff; border-radius: 12px; box-shadow: 0 8px 30px rgba(0,0,0,.15); padding: 6px; min-width: 140px; }
|
||||||
|
.ctx-item { display: flex; align-items: center; gap: 8px; padding: 8px 12px; cursor: pointer; border-radius: 8px; font-size: 0.85rem; color: #222; }
|
||||||
|
.ctx-item:hover { background: #f3f4f6; }
|
||||||
|
.ctx-item.danger { color: #d93025; }
|
||||||
|
.ctx-divider { height: 1px; background: #eee; margin: 4px 8px; }
|
||||||
</style>
|
</style>
|
||||||
|
|||||||
Reference in New Issue
Block a user