Files
kite/frontend/src/components/documents/DocumentCard.vue
T
curo1305 939ea79977 refactor(10-12): migrate folder/document/upload/sharing SVGs to AppIcon
- FolderRow.vue: folder icon + dots menu button (fill-based → stroke AppIcon)
- FolderTreeItem.vue: folder icon in #icon slot
- FolderDeleteModal.vue: warning icon
- DocumentCard.vue: document, folderMove, share action icons
- DocumentPreviewModal.vue: x close button (spinner SVG kept)
- ShareModal.vue: x close button
- DropZone.vue: upload icon
- UploadProgress.vue: exclamationCircle (error) + checkCircle (done) icons; spinner kept
2026-06-16 08:37:42 +02:00

224 lines
7.3 KiB
Vue

<template>
<div
class="group bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer relative"
@click="$router.push(`/document/${doc.id}`)"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0 mt-0.5">
<AppIcon name="document" class="w-5 h-5 text-indigo-500" />
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 text-sm truncate">{{ doc.original_name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }}</p>
<!-- Topics (only when classified) -->
<div v-if="doc.status === 'ready'" class="flex flex-wrap gap-1 mt-2">
<TopicBadge
v-for="topicName in doc.topics"
:key="topicName"
:name="topicName"
:color="topicColor(topicName)"
/>
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
</div>
<!-- Processing indicator -->
<div v-else-if="doc.status === 'processing'" class="flex items-center gap-1.5 mt-2">
<span class="animate-spin rounded-full border-2 border-indigo-400 border-t-transparent w-3 h-3 shrink-0"></span>
<span class="text-xs text-indigo-500 font-medium">Classifying</span>
</div>
<!-- Pending / queued indicator -->
<div v-else-if="doc.status === 'pending'" class="mt-2">
<span class="bg-gray-100 text-gray-500 text-xs font-medium px-2 py-1 rounded-full">Queued</span>
</div>
<!-- Shared indicator pill -->
<div v-if="doc.is_shared" class="mt-2">
<span class="bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-1 rounded-full">Shared</span>
</div>
<!-- Classification failed badge + Re-analyze button -->
<div v-if="doc.status === 'classification_failed'" class="mt-2 flex items-center gap-2">
<span class="bg-red-50 text-red-600 text-xs font-medium px-2 py-1 rounded-full">Classification failed</span>
<button
@click.stop="reanalyze"
:disabled="reanalyzing"
class="text-xs text-indigo-600 hover:text-indigo-700 font-semibold disabled:opacity-50"
>
<span v-if="reanalyzing">Re-analyzing</span>
<span v-else>Re-analyze</span>
</button>
</div>
</div>
<!-- Action buttons (hover-reveal) -->
<div class="opacity-0 group-hover:opacity-100 transition-opacity flex items-center gap-1 shrink-0">
<!-- Move to folder -->
<div class="relative">
<button
ref="pickerTriggerEl"
@click.stop="toggleFolderPicker"
aria-label="Move to folder"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
>
<AppIcon name="folderMove" class="w-4 h-4" />
</button>
</div>
<!-- Share -->
<button
@click.stop="openShareModal"
aria-label="Share document"
class="min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors"
>
<AppIcon name="share" class="w-4 h-4" />
</button>
</div>
</div>
<!-- ShareModal -->
<ShareModal
v-if="showShareModal"
:doc="doc"
@close="showShareModal = false"
@unshared="doc.is_shared = false"
/>
</div>
<!-- Teleported folder picker avoids overflow:hidden clipping -->
<Teleport to="body">
<div
v-if="showFolderPicker"
:style="pickerStyle"
data-test="folder-picker"
class="fixed z-[9999] bg-white border border-gray-200 rounded-xl shadow-lg overflow-y-auto py-1"
style="max-height: 240px;"
@click.stop
>
<button
class="w-full text-left px-3 py-2 text-sm text-gray-600 hover:bg-gray-50"
@click.stop="moveToFolder(null)"
>Root (no folder)</button>
<button
v-for="folder in allFolders"
:key="folder.id"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-indigo-50 hover:text-indigo-600 truncate"
@click.stop="moveToFolder(folder.id)"
>{{ folder.name }}</button>
<p v-if="!allFolders.length" class="px-3 py-2 text-xs text-gray-400">No folders yet</p>
</div>
</Teleport>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
import AppIcon from '../ui/AppIcon.vue'
import { useTopicsStore } from '../../stores/topics.js'
import { useFoldersStore } from '../../stores/folders.js'
import { moveDocument, classifyDocument } from '../../api/client.js'
import TopicBadge from '../topics/TopicBadge.vue'
import ShareModal from '../sharing/ShareModal.vue'
import { formatDate, formatSize } from '../../utils/formatters.js'
const props = defineProps({
doc: Object,
})
const emit = defineEmits(['reclassified'])
const topicsStore = useTopicsStore()
const foldersStore = useFoldersStore()
const showShareModal = ref(false)
const showFolderPicker = ref(false)
const reanalyzing = ref(false)
const pickerTriggerEl = ref(null)
const pickerStyle = ref({})
const allFolders = computed(() => foldersStore.rootFolders)
function openShareModal() {
showShareModal.value = true
}
function updatePickerPosition() {
if (!pickerTriggerEl.value) return
const rect = pickerTriggerEl.value.getBoundingClientRect()
const spaceBelow = window.innerHeight - rect.bottom
const dropH = 240
if (spaceBelow >= dropH || spaceBelow > 120) {
pickerStyle.value = {
top: `${rect.bottom + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
} else {
pickerStyle.value = {
bottom: `${window.innerHeight - rect.top + 4}px`,
left: `${rect.left}px`,
width: '192px',
}
}
}
function toggleFolderPicker() {
if (showFolderPicker.value) {
showFolderPicker.value = false
} else {
updatePickerPosition()
showFolderPicker.value = true
}
}
function onScroll() {
if (showFolderPicker.value) updatePickerPosition()
}
function closeFolderPicker(e) {
if (
!e.target.closest('[data-test="folder-picker"]') &&
!e.target.closest('.relative')
) {
showFolderPicker.value = false
}
}
onMounted(() => {
document.addEventListener('click', closeFolderPicker)
window.addEventListener('scroll', onScroll, true)
window.addEventListener('resize', onScroll)
})
onUnmounted(() => {
document.removeEventListener('click', closeFolderPicker)
window.removeEventListener('scroll', onScroll, true)
window.removeEventListener('resize', onScroll)
})
async function moveToFolder(folderId) {
showFolderPicker.value = false
try {
await moveDocument(props.doc.id, folderId)
} catch (e) {
console.error('Move failed:', e.message)
}
}
function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
async function reanalyze() {
reanalyzing.value = true
try {
await classifyDocument(props.doc.id)
emit('reclassified', props.doc.id)
} catch (e) {
console.error('Re-analyze failed:', e.message)
} finally {
setTimeout(() => { reanalyzing.value = false }, 500)
}
}
</script>