feat(phase-4-09): wire components into views — sidebar, cards, home, folder, shared, settings, admin

- AppSidebar: add 'Shared with me' entry (purple icon, count badge) and Folders section with New folder CTA
- DocumentCard: add group class, hover-reveal share button, ShareModal v-if, shared indicator pill
- HomeView: add SearchBar + SortControls above document list; fetchFolders on mount
- FolderView: new view with FolderBreadcrumb, FolderRow list, inline new-subfolder input, document list
- SharedView: new view fetching /api/shares/received with owner_handle display and empty state
- DocumentView: add PDF preview logic (in_app=DocumentPreviewModal, new_tab=window.open); load preferences on mount
- SettingsView: add Document Preferences card with pdf_open_mode radio buttons, auto-save on change
- AdminView: add Audit Log tab alongside Users/Quotas/AI Config tabs
This commit is contained in:
curo1305
2026-05-25 22:14:12 +02:00
parent 36721575a5
commit a3f5fc2e69
8 changed files with 578 additions and 14 deletions
+3
View File
@@ -21,6 +21,7 @@
<AdminUsersTab v-if="activeTab === 'users'" />
<AdminQuotasTab v-if="activeTab === 'quotas'" />
<AdminAiConfigTab v-if="activeTab === 'ai'" />
<AuditLogTab v-if="activeTab === 'audit'" />
</div>
</template>
@@ -29,11 +30,13 @@ import { ref } from 'vue'
import AdminUsersTab from '../components/admin/AdminUsersTab.vue'
import AdminQuotasTab from '../components/admin/AdminQuotasTab.vue'
import AdminAiConfigTab from '../components/admin/AdminAiConfigTab.vue'
import AuditLogTab from '../components/admin/AuditLogTab.vue'
const tabs = [
{ id: 'users', label: 'Users' },
{ id: 'quotas', label: 'Quotas' },
{ id: 'ai', label: 'AI Config' },
{ id: 'audit', label: 'Audit Log' },
]
const activeTab = ref('users')
+48 -6
View File
@@ -17,10 +17,20 @@
Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }}
</p>
</div>
<button
@click="confirmDelete"
class="text-sm text-red-500 hover:text-red-700 shrink-0"
>Delete</button>
<div class="flex items-center gap-2 shrink-0">
<!-- Open/Preview button for PDFs -->
<button
v-if="isPdf"
@click="openPdf"
class="text-sm px-3 py-1.5 bg-indigo-600 text-white rounded-lg hover:bg-indigo-700 transition-colors"
>
{{ pdfOpenMode === 'in_app' ? 'Preview' : 'Open' }}
</button>
<button
@click="confirmDelete"
class="text-sm text-red-500 hover:text-red-700"
>Delete</button>
</div>
</div>
<!-- Topics -->
@@ -91,13 +101,21 @@
<pre class="text-xs text-gray-600 whitespace-pre-wrap font-mono bg-gray-50 rounded-lg p-4 max-h-96 overflow-y-auto">{{ doc.extracted_text || '(no text extracted)' }}</pre>
</div>
</template>
<!-- PDF in-app preview modal -->
<DocumentPreviewModal
v-if="showPreviewModal && doc"
:doc="doc"
@close="showPreviewModal = false"
/>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import TopicBadge from '../components/topics/TopicBadge.vue'
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js'
import * as api from '../api/client.js'
@@ -114,6 +132,15 @@ const suggesting = ref(false)
const classifyError = ref(null)
const suggestions = ref([])
const selectedSuggestions = ref([])
const showPreviewModal = ref(false)
const pdfOpenMode = ref('new_tab')
const isPdf = computed(() => {
if (!doc.value) return false
const mime = doc.value.mime_type || ''
const name = doc.value.original_name || ''
return mime === 'application/pdf' || name.toLowerCase().endsWith('.pdf')
})
onMounted(async () => {
try {
@@ -121,8 +148,23 @@ onMounted(async () => {
} finally {
loading.value = false
}
// Load user preferences for PDF open mode
try {
const prefs = await api.getMyPreferences()
pdfOpenMode.value = prefs.pdf_open_mode || 'new_tab'
} catch {
pdfOpenMode.value = 'new_tab'
}
})
function openPdf() {
if (pdfOpenMode.value === 'in_app') {
showPreviewModal.value = true
} else {
window.open(api.getDocumentContentUrl(doc.value.id), '_blank')
}
}
function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
}
@@ -166,7 +208,7 @@ async function createSelectedTopics() {
async function confirmDelete() {
if (!confirm(`Delete "${doc.value.original_name}"?`)) return
await api.deleteDocument(doc.value.id)
await docsStore.remove(doc.value.id)
router.push('/')
}
+211
View File
@@ -0,0 +1,211 @@
<template>
<div class="p-8 max-w-4xl mx-auto">
<!-- Breadcrumb -->
<div class="mb-6">
<FolderBreadcrumb
:segments="foldersStore.breadcrumb"
@navigate="handleBreadcrumbNavigate"
/>
</div>
<!-- Folder heading -->
<h2 class="text-2xl font-bold text-gray-900 mb-1">
{{ currentFolderName || 'Folder' }}
</h2>
<p class="text-gray-500 text-sm mb-6">Documents and sub-folders in this folder.</p>
<!-- Sub-folders section -->
<div v-if="foldersStore.folders.length > 0" class="mb-6">
<h3 class="text-sm font-semibold text-gray-500 uppercase tracking-wider mb-3">Folders</h3>
<div class="grid gap-2">
<FolderRow
v-for="folder in foldersStore.folders"
:key="folder.id"
:folder="folder"
:on-navigate="navigateToFolder"
:on-rename="handleRenameFolder"
:on-delete="handleDeleteFolder"
/>
</div>
</div>
<!-- New subfolder button / inline input -->
<div class="mb-6">
<div v-if="showNewFolderInput" class="flex items-center gap-2">
<input
ref="newFolderInputRef"
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="border border-gray-300 rounded-lg px-3 py-2 text-sm focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
/>
<button
@click="submitNewFolder"
class="text-sm text-indigo-600 hover:underline"
>Save</button>
<button
@click="cancelNewFolder"
class="text-sm text-gray-500 hover:text-gray-700"
>Cancel</button>
<p v-if="newFolderError" class="text-xs text-red-500 ml-2">{{ newFolderError }}</p>
</div>
<button
v-else
@click="startNewFolder"
class="text-sm text-indigo-600 hover:underline flex items-center gap-1"
>
<svg class="w-4 h-4" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2" d="M12 4v16m8-8H4" />
</svg>
New subfolder
</button>
</div>
<!-- Documents section -->
<div>
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">Documents</h3>
<div class="flex items-center gap-3">
<SortControls
:sort="docsStore.sortField"
:order="docsStore.sortOrder"
@change="handleSortChange"
/>
<SearchBar v-model="docsStore.searchQuery" />
</div>
</div>
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading</div>
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
<p class="text-sm" v-if="docsStore.searchQuery">
No documents match "{{ docsStore.searchQuery }}". Try a different search term.
</p>
<div v-else>
<p class="text-sm">This folder is empty.</p>
<p class="text-xs mt-1">Upload documents or create sub-folders to organize your files.</p>
</div>
</div>
<div v-else class="grid gap-3">
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
</div>
</div>
<!-- Delete confirmation modal -->
<FolderDeleteModal
v-if="folderToDelete"
:folder="folderToDelete"
@confirm="confirmDeleteFolder"
@cancel="folderToDelete = null"
/>
</div>
</template>
<script setup>
import { ref, computed, watch, nextTick, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router'
import { useFoldersStore } from '../stores/folders.js'
import { useDocumentsStore } from '../stores/documents.js'
import FolderBreadcrumb from '../components/folders/FolderBreadcrumb.vue'
import FolderRow from '../components/folders/FolderRow.vue'
import FolderDeleteModal from '../components/folders/FolderDeleteModal.vue'
import DocumentCard from '../components/documents/DocumentCard.vue'
import SearchBar from '../components/documents/SearchBar.vue'
import SortControls from '../components/documents/SortControls.vue'
const route = useRoute()
const router = useRouter()
const foldersStore = useFoldersStore()
const docsStore = useDocumentsStore()
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
const newFolderInputRef = ref(null)
const folderToDelete = ref(null)
const currentFolderName = computed(() => {
const breadcrumb = foldersStore.breadcrumb
return breadcrumb.length > 0 ? breadcrumb[breadcrumb.length - 1].name : ''
})
async function loadFolder(folderId) {
await foldersStore.navigateTo(folderId)
await foldersStore.fetchFolders(folderId)
docsStore.currentFolderId = folderId
await docsStore.fetchDocuments({ folderId, sort: docsStore.sortField, order: docsStore.sortOrder })
}
onMounted(() => {
const folderId = route.params.folderId
if (folderId) loadFolder(folderId)
})
watch(() => route.params.folderId, (newId) => {
if (newId) loadFolder(newId)
})
function navigateToFolder(folderId) {
router.push(`/folders/${folderId}`)
}
function handleBreadcrumbNavigate(folderId) {
if (folderId == null) {
router.push('/')
} else {
router.push(`/folders/${folderId}`)
}
}
function handleSortChange({ sort, order }) {
docsStore.sortField = sort
docsStore.sortOrder = order
docsStore.fetchDocuments({ folderId: route.params.folderId, sort, order })
}
function startNewFolder() {
newFolderName.value = ''
newFolderError.value = ''
showNewFolderInput.value = true
nextTick(() => newFolderInputRef.value?.focus())
}
function cancelNewFolder() {
showNewFolderInput.value = false
newFolderError.value = ''
}
async function submitNewFolder() {
const trimmed = newFolderName.value.trim()
if (!trimmed) {
newFolderError.value = 'Folder name cannot be empty.'
return
}
try {
await foldersStore.createFolder(trimmed, route.params.folderId)
showNewFolderInput.value = false
newFolderError.value = ''
} catch (e) {
newFolderError.value = e.message || 'Failed to create folder.'
}
}
async function handleRenameFolder(folderId, name) {
await foldersStore.renameFolder(folderId, name)
}
function handleDeleteFolder(folder) {
folderToDelete.value = folder
}
async function confirmDeleteFolder() {
if (!folderToDelete.value) return
try {
await foldersStore.deleteFolder(folderToDelete.value.id)
folderToDelete.value = null
} catch (e) {
folderToDelete.value = null
}
}
</script>
+27 -3
View File
@@ -10,12 +10,23 @@
<div class="mt-10">
<div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">Recent Documents</h3>
<span class="text-sm text-gray-400">{{ docsStore.total }} total</span>
<div class="flex items-center gap-3">
<SortControls
:sort="docsStore.sortField"
:order="docsStore.sortOrder"
@change="handleSortChange"
/>
<SearchBar v-model="docsStore.searchQuery" />
<span class="text-sm text-gray-400">{{ docsStore.total }} total</span>
</div>
</div>
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading</div>
<div v-else-if="docsStore.documents.length === 0" class="text-center py-12 text-gray-400">
<p class="text-sm">No documents yet. Upload one above.</p>
<p class="text-sm" v-if="docsStore.searchQuery">
No documents match "{{ docsStore.searchQuery }}". Try a different search term.
</p>
<p class="text-sm" v-else>No documents yet. Upload one above.</p>
</div>
<div v-else class="grid gap-3">
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" />
@@ -29,14 +40,27 @@ import { ref, onMounted } from 'vue'
import DropZone from '../components/upload/DropZone.vue'
import UploadProgress from '../components/upload/UploadProgress.vue'
import DocumentCard from '../components/documents/DocumentCard.vue'
import SearchBar from '../components/documents/SearchBar.vue'
import SortControls from '../components/documents/SortControls.vue'
import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js'
import { useFoldersStore } from '../stores/folders.js'
const docsStore = useDocumentsStore()
const topicsStore = useTopicsStore()
const foldersStore = useFoldersStore()
const uploadQueue = ref([])
onMounted(() => docsStore.fetchDocuments())
onMounted(() => {
docsStore.fetchDocuments()
foldersStore.fetchFolders(null)
})
function handleSortChange({ sort, order }) {
docsStore.sortField = sort
docsStore.sortOrder = order
docsStore.fetchDocuments({ sort, order })
}
async function onFilesSelected({ files, autoClassify }) {
// Build queue items
+63 -3
View File
@@ -3,17 +3,77 @@
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Settings</h2>
<p class="text-sm text-gray-500 mb-8">Account-level options for your DocuVault workspace.</p>
<section class="bg-white border border-gray-200 rounded-xl p-6">
<section class="bg-white border border-gray-200 rounded-xl p-6 mb-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
<p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents.
</p>
</section>
<!-- Document Preferences section -->
<section class="bg-white border border-gray-200 rounded-xl p-6">
<h3 class="text-xl font-semibold text-gray-800 mb-2">Document Preferences</h3>
<p class="text-sm text-gray-600 mb-4">Choose how PDF documents open when you click on them.</p>
<div class="space-y-3">
<label class="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="pdf_open_mode"
value="in_app"
v-model="pdfOpenMode"
class="text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700">Open documents in-app</span>
</label>
<label class="flex items-center gap-3 cursor-pointer">
<input
type="radio"
name="pdf_open_mode"
value="new_tab"
v-model="pdfOpenMode"
class="text-indigo-600 focus:ring-indigo-500"
/>
<span class="text-sm text-gray-700">Open documents in new tab</span>
</label>
</div>
<!-- Save feedback -->
<p v-if="saveFeedback" class="text-xs text-green-600 mt-3">{{ saveFeedback }}</p>
<p v-if="saveError" class="text-xs text-red-600 mt-3">{{ saveError }}</p>
</section>
</div>
</template>
<script setup>
// SettingsView is a static placeholder after Phase 3 D-12 settings retirement.
// No store usage, no API calls — AI config is admin-only via /api/admin/users/{id}/ai-config.
import { ref, watch, onMounted } from 'vue'
import * as api from '../api/client.js'
const pdfOpenMode = ref('new_tab')
const saveFeedback = ref('')
const saveError = ref('')
let feedbackTimer = null
onMounted(async () => {
try {
const prefs = await api.getMyPreferences()
pdfOpenMode.value = prefs.pdf_open_mode || 'new_tab'
} catch {
// Default to new_tab if preferences can't be loaded
}
})
watch(pdfOpenMode, async (newValue) => {
saveFeedback.value = ''
saveError.value = ''
clearTimeout(feedbackTimer)
try {
await api.updateMyPreferences({ pdf_open_mode: newValue })
saveFeedback.value = 'Preferences saved.'
feedbackTimer = setTimeout(() => { saveFeedback.value = '' }, 3000)
} catch (e) {
saveError.value = e.message || 'Failed to save preferences.'
}
})
</script>
+82
View File
@@ -0,0 +1,82 @@
<template>
<div class="p-8 max-w-4xl mx-auto">
<h2 class="text-2xl font-bold text-gray-900 mb-1">Shared with me</h2>
<p class="text-gray-500 text-sm mb-6">Documents other users have shared with you.</p>
<div v-if="loading" class="text-sm text-gray-400">Loading</div>
<!-- Empty state -->
<div v-else-if="sharedDocs.length === 0" class="text-center py-12 text-gray-400">
<p class="text-sm font-medium text-gray-500">No documents shared with you yet.</p>
<p class="text-xs mt-1">When someone shares a document with you, it will appear here.</p>
</div>
<!-- Shared documents list -->
<div v-else class="grid gap-3">
<div
v-for="share in sharedDocs"
:key="share.id"
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer"
@click="$router.push(`/document/${share.document_id || share.id}`)"
>
<div class="flex items-start gap-3">
<!-- Icon -->
<div class="w-9 h-9 rounded-lg bg-indigo-50 flex items-center justify-center shrink-0 mt-0.5">
<svg class="w-5 h-5 text-indigo-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M9 12h6m-6 4h6m2 5H7a2 2 0 01-2-2V5a2 2 0 012-2h5.586a1 1 0 01.707.293l5.414 5.414a1 1 0 01.293.707V19a2 2 0 01-2 2z" />
</svg>
</div>
<div class="flex-1 min-w-0">
<p class="font-medium text-gray-900 text-sm truncate">
{{ share.document?.original_name || share.filename || 'Document' }}
</p>
<p class="text-xs text-gray-400 mt-0.5">
Shared by <span class="font-medium text-gray-600">{{ share.owner_handle || share.shared_by || '—' }}</span>
</p>
<p v-if="share.document?.created_at" class="text-xs text-gray-400 mt-0.5">
{{ formatDate(share.document.created_at) }} · {{ formatSize(share.document.size_bytes) }}
</p>
</div>
</div>
</div>
</div>
<p v-if="error" class="mt-4 text-sm text-red-600">{{ error }}</p>
</div>
</template>
<script setup>
import { ref, onMounted } from 'vue'
import * as api from '../api/client.js'
const sharedDocs = ref([])
const loading = ref(false)
const error = ref(null)
onMounted(async () => {
loading.value = true
error.value = null
try {
const data = await api.getSharedWithMe()
sharedDocs.value = Array.isArray(data) ? data : (data.items ?? [])
} catch (e) {
error.value = e.message || 'Something went wrong. Please try again.'
} finally {
loading.value = false
}
})
function formatDate(iso) {
if (!iso) return ''
return new Date(iso).toLocaleDateString(undefined, { month: 'short', day: 'numeric', year: 'numeric' })
}
function formatSize(bytes) {
if (!bytes) return ''
if (bytes < 1024) return bytes + ' B'
if (bytes < 1024 * 1024) return (bytes / 1024).toFixed(1) + ' KB'
return (bytes / (1024 * 1024)).toFixed(1) + ' MB'
}
</script>