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
+13
View File
@@ -345,6 +345,19 @@ export function updateMyPreferences(payload) {
}) })
} }
// ── Audit Log ─────────────────────────────────────────────────────────────────
export function adminListAuditLog({ start, end, user_id, event_type, page = 1, per_page = 50 } = {}) {
const params = new URLSearchParams()
if (start) params.set('start', start)
if (end) params.set('end', end)
if (user_id) params.set('user_id', user_id)
if (event_type) params.set('event_type', event_type)
params.set('page', page)
params.set('per_page', per_page)
return request(`/api/admin/audit-log?${params}`)
}
// ── Document content proxy URL ──────────────────────────────────────────────── // ── Document content proxy URL ────────────────────────────────────────────────
export function getDocumentContentUrl(docId) { export function getDocumentContentUrl(docId) {
@@ -0,0 +1,212 @@
<template>
<div>
<!-- Filter bar -->
<div class="flex flex-wrap gap-3 mb-4 items-end">
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">From</label>
<input
v-model="filters.start"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">To</label>
<input
v-model="filters.end"
type="date"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">User</label>
<input
v-model="filters.user_id"
type="text"
placeholder="All users"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 w-36"
/>
</div>
<div>
<label class="block text-xs font-semibold text-gray-500 mb-1">Action</label>
<select
v-model="filters.event_type"
class="text-sm border border-gray-300 rounded-lg px-3 py-2 focus:outline-none focus:ring-2 focus:ring-indigo-500 bg-white"
>
<option value="">All actions</option>
<option value="auth">Auth</option>
<option value="document">Document</option>
<option value="folder">Folder</option>
<option value="share">Share</option>
<option value="admin">Admin</option>
</select>
</div>
<button
@click="applyFilters"
class="bg-indigo-600 text-white text-sm px-4 py-2 rounded-lg hover:bg-indigo-700 transition-colors"
>
Apply filters
</button>
<button
@click="exportCsv"
class="border border-gray-300 text-gray-700 text-sm px-4 py-2 rounded-lg hover:bg-gray-50 transition-colors"
>
Export CSV
</button>
</div>
<!-- Loading state -->
<div v-if="loading" class="bg-white rounded-xl border border-gray-200 p-8 text-center">
<div class="flex items-center justify-center gap-2 text-gray-400 text-sm">
<span class="animate-spin rounded-full border-2 border-current border-t-transparent w-4 h-4"></span>
Loading audit log
</div>
</div>
<!-- Empty state -->
<div v-else-if="entries.length === 0" class="text-center py-12 text-gray-400 text-sm">
No audit log entries match the selected filters.
</div>
<!-- Table -->
<div v-else class="bg-white rounded-xl border border-gray-200 overflow-hidden">
<table class="w-full text-sm border-collapse">
<thead>
<tr class="bg-gray-50 border-b border-gray-200">
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Timestamp</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">User</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">Action Type</th>
<th scope="col" class="px-4 py-3 text-left text-xs font-semibold text-gray-400 uppercase tracking-wider">IP Address</th>
</tr>
</thead>
<tbody>
<tr
v-for="entry in entries"
:key="entry.id"
class="border-b border-gray-100 hover:bg-gray-50 transition-colors"
>
<td class="px-4 py-3 font-mono text-xs text-gray-500">{{ formatTimestamp(entry.created_at) }}</td>
<td class="px-4 py-3 text-sm text-gray-700">{{ entry.user_handle || entry.user_id || '—' }}</td>
<td class="px-4 py-3">
<span
class="text-xs px-2 py-1 rounded-full font-medium"
:class="actionTypeClass(entry.event_type)"
>
{{ entry.event_type }}
</span>
</td>
<td class="px-4 py-3 text-sm text-gray-700 font-mono text-xs">{{ entry.ip_address || '—' }}</td>
</tr>
</tbody>
</table>
</div>
<!-- Pagination -->
<div v-if="!loading && entries.length > 0" class="flex items-center justify-between mt-4">
<button
@click="prevPage"
:disabled="page <= 1"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Previous
</button>
<span class="text-sm text-gray-500">Page {{ page }}</span>
<button
@click="nextPage"
:disabled="entries.length < perPage"
class="text-sm text-gray-600 hover:text-gray-900 disabled:opacity-40 disabled:cursor-not-allowed px-3 py-1.5 border border-gray-200 rounded-lg hover:bg-gray-50 transition-colors"
>
Next
</button>
</div>
</div>
</template>
<script setup>
import { ref, reactive, onMounted } from 'vue'
import * as api from '../../api/client.js'
const entries = ref([])
const total = ref(0)
const page = ref(1)
const perPage = 50
const loading = ref(false)
const filters = reactive({
start: '',
end: '',
user_id: '',
event_type: '',
})
onMounted(() => {
fetchLog()
})
async function fetchLog() {
loading.value = true
try {
const data = await api.adminListAuditLog({
start: filters.start || undefined,
end: filters.end || undefined,
user_id: filters.user_id || undefined,
event_type: filters.event_type || undefined,
page: page.value,
per_page: perPage,
})
entries.value = Array.isArray(data) ? data : (data.items ?? [])
total.value = data.total ?? entries.value.length
} catch (e) {
entries.value = []
} finally {
loading.value = false
}
}
function applyFilters() {
page.value = 1
fetchLog()
}
function prevPage() {
if (page.value > 1) {
page.value--
fetchLog()
}
}
function nextPage() {
if (entries.value.length >= perPage) {
page.value++
fetchLog()
}
}
function exportCsv() {
const params = new URLSearchParams({ format: 'csv' })
if (filters.start) params.set('start', filters.start)
if (filters.end) params.set('end', filters.end)
if (filters.user_id) params.set('user_id', filters.user_id)
if (filters.event_type) params.set('event_type', filters.event_type)
window.location.href = `/api/admin/audit-log/export?${params}`
}
function formatTimestamp(iso) {
if (!iso) return '—'
try {
return new Date(iso).toISOString().replace('T', ' ').slice(0, 19)
} catch {
return iso
}
}
function actionTypeClass(eventType) {
if (!eventType) return 'bg-gray-100 text-gray-600'
const t = eventType.toLowerCase()
if (t.startsWith('auth')) return 'bg-blue-50 text-blue-600'
if (t.startsWith('document')) return 'bg-gray-100 text-gray-600'
if (t.startsWith('folder') || t.startsWith('share')) return 'bg-purple-50 text-purple-600'
if (t.startsWith('admin')) return 'bg-amber-50 text-amber-700'
return 'bg-gray-100 text-gray-600'
}
</script>
@@ -0,0 +1,71 @@
<template>
<!-- Overlay -->
<div
ref="overlayRef"
class="fixed inset-0 bg-black/60 z-50 flex flex-col"
role="dialog"
aria-modal="true"
aria-label="Document preview"
@click="handleOverlayClick"
>
<!-- Header bar -->
<div class="bg-white border-b border-gray-200 px-6 py-3 flex items-center justify-between shrink-0">
<span class="text-sm font-medium text-gray-900 truncate max-w-xs">{{ doc.filename || doc.original_name }}</span>
<button
@click="emit('close')"
aria-label="Close preview"
class="text-gray-400 hover:text-gray-600 transition-colors ml-4 shrink-0"
>
<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>
</div>
<!-- iframe content -->
<div class="flex-1 overflow-hidden">
<iframe
class="w-full h-full border-0"
:src="proxyUrl"
title="Document preview"
></iframe>
</div>
</div>
</template>
<script setup>
import { ref, computed, onMounted, onUnmounted } from 'vue'
const props = defineProps({
doc: {
type: Object,
required: true,
},
})
const emit = defineEmits(['close'])
const overlayRef = ref(null)
const proxyUrl = computed(() => `/api/documents/${props.doc.id}/content`)
function handleOverlayClick(e) {
if (e.target === overlayRef.value) {
emit('close')
}
}
function handleKeydown(e) {
if (e.key === 'Escape') {
emit('close')
}
}
onMounted(() => {
document.addEventListener('keydown', handleKeydown)
})
onUnmounted(() => {
document.removeEventListener('keydown', handleKeydown)
})
</script>
@@ -0,0 +1,28 @@
<template>
<div role="search">
<input
:value="modelValue"
type="search"
:placeholder="placeholder"
aria-label="Search documents"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm w-56 focus:outline-none focus:ring-2 focus:ring-indigo-500 focus:border-transparent"
@input="emit('update:modelValue', $event.target.value)"
@keydown.escape="emit('update:modelValue', '')"
/>
</div>
</template>
<script setup>
defineProps({
modelValue: {
type: String,
default: '',
},
placeholder: {
type: String,
default: 'Search documents...',
},
})
const emit = defineEmits(['update:modelValue'])
</script>
@@ -0,0 +1,49 @@
<template>
<div class="flex items-center gap-1">
<span class="sr-only">Sort by:</span>
<button
v-for="option in sortOptions"
:key="option.value"
@click="handleClick(option.value)"
:aria-pressed="sort === option.value"
:aria-label="sort === option.value ? `Sort by ${option.label}, ${order === 'asc' ? 'ascending' : 'descending'}` : `Sort by ${option.label}`"
class="text-xs font-medium px-2 py-1 rounded transition-colors"
:class="sort === option.value
? 'text-indigo-600 font-semibold bg-indigo-50'
: 'text-gray-500 hover:text-gray-900'"
>
{{ option.label }}{{ sort === option.value ? (order === 'asc' ? ' ↑' : ' ↓') : '' }}
</button>
</div>
</template>
<script setup>
const props = defineProps({
sort: {
type: String,
default: 'date',
},
order: {
type: String,
default: 'desc',
},
})
const emit = defineEmits(['change'])
const sortOptions = [
{ value: 'name', label: 'Name' },
{ value: 'date', label: 'Date' },
{ value: 'size', label: 'Size' },
]
function handleClick(value) {
if (props.sort === value) {
// Toggle order
emit('change', { sort: value, order: props.order === 'asc' ? 'desc' : 'asc' })
} else {
// Switch to new sort, default desc
emit('change', { sort: value, order: 'desc' })
}
}
</script>
@@ -0,0 +1,68 @@
<template>
<nav aria-label="Folder navigation">
<ol class="flex items-center gap-1 text-sm flex-wrap">
<!-- Root "Home" segment -->
<li class="flex items-center gap-1">
<button
@click="emit('navigate', null)"
class="text-indigo-600 hover:underline font-medium"
>
Home
</button>
</li>
<template v-for="(segment, idx) in visibleSegments" :key="segment.id ?? 'ellipsis-' + idx">
<!-- Separator -->
<li class="shrink-0" aria-hidden="true">
<svg class="w-3 h-3 text-gray-400" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M9 5l7 7-7 7" />
</svg>
</li>
<!-- Ellipsis (non-navigable) -->
<li v-if="segment.id === 'ellipsis'" class="flex items-center">
<span class="px-2 py-1 text-gray-400"></span>
</li>
<!-- Last segment (current folder, non-clickable) -->
<li v-else-if="idx === visibleSegments.length - 1" class="flex items-center">
<span class="text-gray-900 font-medium">{{ segment.name }}</span>
</li>
<!-- Clickable segment -->
<li v-else class="flex items-center">
<button
@click="emit('navigate', segment.id)"
class="text-indigo-600 hover:underline font-medium"
>
{{ segment.name }}
</button>
</li>
</template>
</ol>
</nav>
</template>
<script setup>
import { computed } from 'vue'
const props = defineProps({
segments: {
type: Array,
default: () => [],
},
})
const emit = defineEmits(['navigate'])
const visibleSegments = computed(() => {
if (props.segments.length > 4) {
return [
props.segments[0],
{ id: 'ellipsis', name: '…' },
...props.segments.slice(-2),
]
}
return props.segments
})
</script>
@@ -0,0 +1,81 @@
<template>
<!-- Overlay -->
<div
class="fixed inset-0 bg-black/40 flex items-center justify-center z-50"
@click.self="handleCancel"
>
<!-- Panel -->
<div
role="dialog"
aria-modal="true"
aria-labelledby="delete-modal-title"
class="bg-white rounded-2xl shadow-xl p-6 max-w-md w-full mx-4"
>
<!-- Warning icon -->
<div class="flex justify-center">
<div class="w-10 h-10 bg-red-50 rounded-full flex items-center justify-center">
<svg class="w-5 h-5 text-red-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M12 9v2m0 4h.01M10.29 3.86L1.82 18a2 2 0 001.71 3h16.94a2 2 0 001.71-3L13.71 3.86a2 2 0 00-3.42 0z" />
</svg>
</div>
</div>
<!-- Heading -->
<h2 id="delete-modal-title" class="text-lg font-semibold text-gray-900 mt-4 text-center">
Delete folder?
</h2>
<!-- Body -->
<p class="text-sm text-gray-600 text-center mt-2 mb-6">
This folder contains {{ folder.doc_count ?? 0 }} document{{ (folder.doc_count ?? 0) !== 1 ? 's' : '' }}.
Deleting it will permanently delete all documents inside. This cannot be undone.
</p>
<!-- Buttons -->
<div class="flex gap-3 justify-end">
<button
@click="handleCancel"
class="text-sm text-gray-600 hover:text-gray-800 px-4 py-2 rounded-lg border border-gray-200 hover:bg-gray-50 transition-colors"
>
Keep folder
</button>
<button
@click="handleConfirm"
class="bg-red-600 hover:bg-red-700 text-white text-sm font-semibold px-4 py-2 rounded-lg min-h-[44px] transition-colors"
>
Delete folder and documents
</button>
</div>
</div>
</div>
</template>
<script setup>
const props = defineProps({
folder: {
type: Object,
required: true,
},
onConfirm: {
type: Function,
default: null,
},
onCancel: {
type: Function,
default: null,
},
})
const emit = defineEmits(['confirm', 'cancel'])
function handleConfirm() {
emit('confirm')
if (props.onConfirm) props.onConfirm()
}
function handleCancel() {
emit('cancel')
if (props.onCancel) props.onCancel()
}
</script>
@@ -0,0 +1,161 @@
<template>
<div
class="flex items-center gap-3 px-4 py-3 bg-white border border-gray-200 rounded-xl hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer"
@click="handleNavigate"
>
<!-- Folder icon -->
<div class="w-9 h-9 rounded-lg bg-gray-100 flex items-center justify-center shrink-0">
<svg class="w-5 h-5 text-gray-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M3 7a2 2 0 012-2h4l2 2h8a2 2 0 012 2v9a2 2 0 01-2 2H5a2 2 0 01-2-2V7z" />
</svg>
</div>
<!-- Name and doc count -->
<div class="flex-1 min-w-0">
<!-- Rename mode: inline input -->
<div v-if="renaming" @click.stop>
<input
ref="renameInputRef"
v-model="renameValue"
type="text"
class="border border-gray-300 rounded-lg px-2 py-1 text-sm w-full focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitRename"
@keydown.escape="cancelRename"
@click.stop
/>
<p v-if="renameError" class="text-red-500 text-xs mt-1">{{ renameError }}</p>
</div>
<!-- Normal display mode -->
<template v-else>
<p class="font-medium text-gray-900 text-sm truncate">{{ folder.name }}</p>
<p class="text-xs text-gray-400 mt-0.5">{{ folder.doc_count ?? 0 }} document{{ (folder.doc_count ?? 0) !== 1 ? 's' : '' }}</p>
</template>
</div>
<!-- Three-dot menu -->
<div class="relative shrink-0" @click.stop>
<button
@click="toggleMenu"
aria-label="Folder actions"
class="p-1.5 rounded-md text-gray-400 hover:text-gray-600 hover:bg-gray-100 transition-colors min-h-[36px] min-w-[36px] flex items-center justify-center"
>
<svg class="w-4 h-4" fill="currentColor" viewBox="0 0 20 20">
<path d="M10 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4zm0 6a2 2 0 110-4 2 2 0 010 4z" />
</svg>
</button>
<!-- Dropdown menu -->
<div
v-if="menuOpen"
class="absolute right-0 top-full mt-1 bg-white border border-gray-200 rounded-lg shadow-md py-1 min-w-[140px] z-10"
>
<button
@click="startRename"
class="w-full text-left px-3 py-2 text-sm text-gray-700 hover:bg-gray-50 transition-colors"
>
Rename
</button>
<button
@click="handleDelete"
class="w-full text-left px-3 py-2 text-sm text-red-600 hover:bg-red-50 transition-colors"
>
Delete folder
</button>
</div>
</div>
</div>
</template>
<script setup>
import { ref, nextTick, onMounted, onUnmounted } from 'vue'
const props = defineProps({
folder: {
type: Object,
required: true,
},
onNavigate: {
type: Function,
default: null,
},
onRename: {
type: Function,
default: null,
},
onDelete: {
type: Function,
default: null,
},
})
const menuOpen = ref(false)
const renaming = ref(false)
const renameValue = ref('')
const renameError = ref('')
const renameInputRef = ref(null)
function handleNavigate() {
if (renaming.value) return
if (props.onNavigate) props.onNavigate(props.folder.id)
}
function toggleMenu() {
menuOpen.value = !menuOpen.value
}
function closeMenu() {
menuOpen.value = false
}
function startRename() {
closeMenu()
renameValue.value = props.folder.name
renameError.value = ''
renaming.value = true
nextTick(() => {
renameInputRef.value?.focus()
renameInputRef.value?.select()
})
}
function cancelRename() {
renaming.value = false
renameError.value = ''
}
async function submitRename() {
const trimmed = renameValue.value.trim()
if (!trimmed) {
renameError.value = 'Folder name cannot be empty.'
return
}
try {
if (props.onRename) await props.onRename(props.folder.id, trimmed)
renaming.value = false
renameError.value = ''
} catch (e) {
renameError.value = e.message || 'Failed to rename folder.'
}
}
function handleDelete() {
closeMenu()
if (props.onDelete) props.onDelete(props.folder)
}
// Close menu on outside click
function handleOutsideClick(e) {
if (!e.target.closest('.relative')) {
closeMenu()
}
}
onMounted(() => {
document.addEventListener('click', handleOutsideClick)
})
onUnmounted(() => {
document.removeEventListener('click', handleOutsideClick)
})
</script>
@@ -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>