089da94d8b
- get_document: strip extracted_text for share recipients (T-04-04-03 consistency) - ShareModal: emit 'unshared' when last recipient is revoked; DocumentCard clears is_shared badge Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
215 lines
7.0 KiB
Vue
215 lines
7.0 KiB
Vue
<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 flex-col gap-2">
|
|
<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"
|
|
/>
|
|
<select
|
|
v-model="permission"
|
|
aria-label="Permission level"
|
|
class="border border-gray-300 rounded-lg px-3 py-2 text-sm bg-white focus:outline-none focus:ring-2 focus:ring-indigo-500 shrink-0"
|
|
>
|
|
<option value="view">Can view</option>
|
|
<option value="edit">Can edit</option>
|
|
</select>
|
|
</div>
|
|
<button
|
|
@click="submitShare"
|
|
:disabled="submitting || !handle.trim()"
|
|
class="w-full bg-indigo-600 hover:bg-indigo-700 text-white text-sm px-4 py-2 rounded-lg disabled:opacity-50 transition-colors"
|
|
>
|
|
<span v-if="submitting" class="flex items-center justify-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>
|
|
<p v-if="permissionError" class="text-xs text-red-600 mt-2">{{ permissionError }}</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>
|
|
<div
|
|
role="group"
|
|
:aria-label="`Permission for ${share.recipient_handle}`"
|
|
class="flex"
|
|
:class="{ 'opacity-50 pointer-events-none': updatingPermission.has(share.id) }"
|
|
>
|
|
<button
|
|
v-for="level in ['view', 'edit']"
|
|
:key="level"
|
|
:aria-pressed="share.permission === level"
|
|
:aria-label="`Change permission for ${share.recipient_handle} to ${level}`"
|
|
class="text-xs px-2 py-1 rounded-full font-medium transition-colors first:rounded-r-none last:rounded-l-none"
|
|
:class="share.permission === level
|
|
? 'bg-indigo-50 text-indigo-600 font-medium'
|
|
: 'bg-gray-100 text-gray-600'"
|
|
@click="share.permission !== level && handlePermissionChange(share.id, level)"
|
|
>{{ level === 'view' ? 'View' : 'Edit' }}</button>
|
|
</div>
|
|
</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', 'unshared'])
|
|
|
|
const docsStore = useDocumentsStore()
|
|
|
|
const handle = ref('')
|
|
const permission = ref('view')
|
|
const submitting = ref(false)
|
|
const error = ref(null)
|
|
const permissionError = ref(null)
|
|
const shares = ref([])
|
|
const loadingShares = ref(false)
|
|
const updatingPermission = ref(new Set())
|
|
|
|
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, permission.value)
|
|
shares.value.push(newShare)
|
|
handle.value = ''
|
|
permission.value = 'view'
|
|
} 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 handlePermissionChange(shareId, newPermission) {
|
|
const share = shares.value.find(s => s.id === shareId)
|
|
if (!share) return
|
|
const oldPermission = share.permission
|
|
share.permission = newPermission
|
|
updatingPermission.value = new Set([...updatingPermission.value, shareId])
|
|
permissionError.value = null
|
|
try {
|
|
await docsStore.updateSharePermission(shareId, newPermission)
|
|
} catch (e) {
|
|
share.permission = oldPermission
|
|
permissionError.value = 'Failed to update permission.'
|
|
} finally {
|
|
const next = new Set(updatingPermission.value)
|
|
next.delete(shareId)
|
|
updatingPermission.value = next
|
|
}
|
|
}
|
|
|
|
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)
|
|
if (shares.value.length === 0) emit('unshared', props.doc.id)
|
|
} 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>
|