feat(phase-4-09): create new components — FolderRow, FolderBreadcrumb, FolderDeleteModal, ShareModal, DocumentPreviewModal, SearchBar, SortControls, AuditLogTab

- FolderRow: inline rename, three-dot menu, delete/rename callbacks, outside-click close
- FolderBreadcrumb: truncation at depth > 4, nav aria-label, ol structure
- FolderDeleteModal: role=dialog, warning icon, doc count in body, Keep/Delete buttons
- ShareModal: handle input, recipients list with revoke, 404/409 error handling
- DocumentPreviewModal: iframe with proxy URL only (never presigned), Escape/overlay close
- SearchBar: role=search, aria-label, Escape clears
- SortControls: aria-pressed, direction indicator, toggle vs switch logic
- AuditLogTab: filters, paginated table, CSV export via window.location.href
- api/client.js: add adminListAuditLog function
This commit is contained in:
curo1305
2026-05-25 22:10:23 +02:00
parent 437c9d134b
commit 36721575a5
9 changed files with 845 additions and 0 deletions
@@ -0,0 +1,162 @@
<template>
<!-- Overlay -->
<div
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@click.self="emit('close')"
>
<!-- Panel -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="share-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4 relative"
>
<!-- Close button -->
<button
@click="emit('close')"
aria-label="Close"
class="absolute top-4 right-4 text-gray-400 hover:text-gray-600 transition-colors"
>
<svg class="w-5 h-5" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M6 18L18 6M6 6l12 12" />
</svg>
</button>
<!-- Title -->
<h2 id="share-modal-title" class="text-lg font-semibold text-gray-900 mb-4">
Share document
</h2>
<!-- Handle input row -->
<div class="flex gap-2">
<input
v-model="handle"
type="text"
placeholder="Enter username handle"
class="flex-1 border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitShare"
/>
<button
@click="submitShare"
:disabled="submitting || !handle.trim()"
class="bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors shrink-0"
>
<span v-if="submitting" class="flex items-center gap-1.5">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-3 h-3"></span>
Sharing
</span>
<span v-else>Share document</span>
</button>
</div>
<!-- Error -->
<p v-if="error" class="text-xs text-red-600 mt-2">{{ error }}</p>
<!-- Separator -->
<div class="border-t border-gray-100 my-4"></div>
<!-- Loading shares -->
<div v-if="loadingShares" class="text-sm text-gray-400 py-2">Loading</div>
<!-- Empty state -->
<p v-else-if="shares.length === 0" class="text-sm text-gray-400 italic py-2">
Not shared with anyone yet.
</p>
<!-- Recipients list -->
<ul v-else class="space-y-1">
<li
v-for="share in shares"
:key="share.id"
class="flex items-center justify-between py-2"
>
<div class="flex items-center gap-2">
<span class="text-sm text-gray-900">{{ share.recipient_handle }}</span>
<span class="text-xs bg-gray-100 text-gray-600 px-2 py-1 rounded-full font-medium">view</span>
</div>
<button
@click="handleRevoke(share.id)"
class="text-xs text-red-500 hover:text-red-700 font-medium transition-colors"
>
Remove access
</button>
</li>
</ul>
</div>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { useDocumentsStore } from '../../stores/documents.js'
const props = defineProps({
doc: {
type: Object,
required: true,
},
})
const emit = defineEmits(['close'])
const docsStore = useDocumentsStore()
const handle = ref('')
const submitting = ref(false)
const error = ref(null)
const shares = ref([])
const loadingShares = ref(false)
onMounted(async () => {
loadingShares.value = true
try {
const data = await docsStore.listShares(props.doc.id)
shares.value = Array.isArray(data) ? data : (data.items ?? [])
} catch (e) {
// silently fail for list — show empty state
} finally {
loadingShares.value = false
}
})
async function submitShare() {
const trimmed = handle.value.trim()
if (!trimmed || submitting.value) return
submitting.value = true
error.value = null
try {
const newShare = await docsStore.shareDocument(props.doc.id, trimmed)
shares.value.push(newShare)
handle.value = ''
} catch (e) {
if (e.status === 404) {
error.value = 'User not found. Check the handle and try again.'
} else if (e.status === 409) {
error.value = 'This document is already shared with that user.'
} else {
error.value = e.message || 'Something went wrong. Please try again.'
}
} finally {
submitting.value = false
}
}
async function handleRevoke(shareId) {
// Optimistic removal
const removedIdx = shares.value.findIndex(s => s.id === shareId)
const removed = shares.value[removedIdx]
if (removedIdx !== -1) shares.value.splice(removedIdx, 1)
try {
await docsStore.revokeShare(shareId)
} catch (e) {
// Re-add on failure
if (removed && removedIdx !== -1) {
shares.value.splice(removedIdx, 0, removed)
}
error.value = e.message || 'Failed to remove access.'
}
}
</script>