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:
@@ -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 ────────────────────────────────────────────────
|
||||
|
||||
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>
|
||||
Reference in New Issue
Block a user