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