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
@@ -1,6 +1,6 @@
<template> <template>
<div <div
class="bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer" class="group bg-white border border-gray-200 rounded-xl p-4 hover:border-indigo-300 hover:shadow-sm transition-all cursor-pointer relative"
@click="$router.push(`/document/${doc.id}`)" @click="$router.push(`/document/${doc.id}`)"
> >
<div class="flex items-start gap-3"> <div class="flex items-start gap-3">
@@ -26,20 +26,51 @@
/> />
<span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span> <span v-if="!doc.topics?.length" class="text-xs text-gray-300 italic">unclassified</span>
</div> </div>
<!-- Shared indicator pill -->
<div v-if="doc.share_count > 0" class="mt-2">
<span class="bg-indigo-50 text-indigo-600 text-xs font-medium px-2 py-1 rounded-full">Shared</span>
</div> </div>
</div> </div>
<!-- Share button (hover-reveal) -->
<button
@click.stop="openShareModal"
aria-label="Share document"
class="opacity-0 group-hover:opacity-100 transition-opacity min-h-[44px] min-w-[44px] flex items-center justify-center p-2 rounded-md text-gray-400 hover:text-indigo-600 hover:bg-indigo-50 transition-colors shrink-0"
>
<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="M8.684 13.342C8.886 12.938 9 12.482 9 12c0-.482-.114-.938-.316-1.342m0 2.684a3 3 0 110-2.684m0 2.684l6.632 3.316m-6.632-6l6.632-3.316m0 0a3 3 0 105.367-2.684 3 3 0 00-5.367 2.684zm0 9.316a3 3 0 105.368 2.684 3 3 0 00-5.368-2.684z" />
</svg>
</button>
</div>
<!-- ShareModal -->
<ShareModal
v-if="showShareModal"
:doc="doc"
@close="showShareModal = false"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref } from 'vue'
import { useTopicsStore } from '../../stores/topics.js' import { useTopicsStore } from '../../stores/topics.js'
import TopicBadge from '../topics/TopicBadge.vue' import TopicBadge from '../topics/TopicBadge.vue'
import ShareModal from '../sharing/ShareModal.vue'
const props = defineProps({ const props = defineProps({
doc: Object, doc: Object,
}) })
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const showShareModal = ref(false)
function openShareModal() {
showShareModal.value = true
}
function topicColor(name) { function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1' return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
+112 -1
View File
@@ -32,6 +32,70 @@
All Topics All Topics
</router-link> </router-link>
<!-- Shared with me entry -->
<router-link
to="/shared"
class="nav-link"
:class="{ 'nav-link-active': $route.path === '/shared' }"
>
<div class="w-4 h-4 mr-2 shrink-0 rounded bg-purple-50 flex items-center justify-center">
<svg class="w-3 h-3 text-purple-500" fill="none" stroke="currentColor" viewBox="0 0 24 24">
<path stroke-linecap="round" stroke-linejoin="round" stroke-width="2"
d="M20 13V6a2 2 0 00-2-2H6a2 2 0 00-2 2v7m16 0v5a2 2 0 01-2 2H6a2 2 0 01-2-2v-5m16 0h-2.586a1 1 0 00-.707.293l-2.414 2.414a1 1 0 01-.707.293h-3.172a1 1 0 01-.707-.293l-2.414-2.414A1 1 0 006.586 13H4" />
</svg>
</div>
<span class="flex-1">Shared with me</span>
<span
v-if="sharedCount > 0"
class="ml-auto bg-purple-100 text-purple-600 text-xs font-semibold rounded-full px-2 min-w-[18px] text-center"
>
{{ sharedCount }}
</span>
</router-link>
<!-- Folders section -->
<div class="mt-3">
<div class="flex items-center justify-between px-3 mb-1">
<p class="text-xs font-semibold text-gray-400 uppercase tracking-wider">Folders</p>
<button
@click="startNewFolder"
class="text-xs text-indigo-600 hover:underline"
>
New folder
</button>
</div>
<!-- New folder inline input -->
<div v-if="showNewFolderInput" class="px-3 mb-2">
<input
v-model="newFolderName"
type="text"
placeholder="Folder name"
class="block w-full border border-gray-300 rounded-lg px-2 py-1 text-xs focus:outline-none focus:ring-2 focus:ring-indigo-500"
@keydown.enter="submitNewFolder"
@keydown.escape="cancelNewFolder"
autofocus
/>
<p v-if="newFolderError" class="text-red-500 text-xs mt-1">{{ newFolderError }}</p>
</div>
<!-- Folder list -->
<div v-if="foldersStore.loading && foldersStore.folders.length === 0" class="px-3 py-1 text-xs text-gray-400">Loading</div>
<router-link
v-for="folder in foldersStore.folders"
:key="folder.id"
:to="`/folders/${folder.id}`"
class="nav-link text-sm"
:class="{ 'nav-link-active': $route.params.folderId === folder.id || $route.params.folderId === String(folder.id) }"
>
<svg class="w-4 h-4 mr-2 shrink-0 text-gray-400" 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>
<span class="truncate flex-1">{{ folder.name }}</span>
</router-link>
</div>
<!-- Topics list --> <!-- Topics list -->
<div class="mt-3"> <div class="mt-3">
<p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p> <p class="px-3 text-xs font-semibold text-gray-400 uppercase tracking-wider mb-1">Topics</p>
@@ -108,19 +172,66 @@
</template> </template>
<script setup> <script setup>
import { useRouter } from 'vue-router' import { ref, onMounted } from 'vue'
import { useRouter, useRoute } from 'vue-router'
import { useTopicsStore } from '../../stores/topics.js' import { useTopicsStore } from '../../stores/topics.js'
import { useAuthStore } from '../../stores/auth.js' import { useAuthStore } from '../../stores/auth.js'
import { useFoldersStore } from '../../stores/folders.js'
import QuotaBar from './QuotaBar.vue' import QuotaBar from './QuotaBar.vue'
import * as api from '../../api/client.js'
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const authStore = useAuthStore() const authStore = useAuthStore()
const foldersStore = useFoldersStore()
const router = useRouter() const router = useRouter()
const route = useRoute()
const sharedCount = ref(0)
const showNewFolderInput = ref(false)
const newFolderName = ref('')
const newFolderError = ref('')
onMounted(async () => {
await foldersStore.fetchFolders(null)
try {
const data = await api.getSharedWithMe()
const items = Array.isArray(data) ? data : (data.items ?? [])
sharedCount.value = items.length
} catch {
sharedCount.value = 0
}
})
async function signOut() { async function signOut() {
await authStore.logout() await authStore.logout()
router.push('/login') router.push('/login')
} }
function startNewFolder() {
newFolderName.value = ''
newFolderError.value = ''
showNewFolderInput.value = true
}
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, null)
showNewFolderInput.value = false
newFolderError.value = ''
} catch (e) {
newFolderError.value = e.message || 'Failed to create folder.'
}
}
</script> </script>
<style scoped> <style scoped>
+3
View File
@@ -21,6 +21,7 @@
<AdminUsersTab v-if="activeTab === 'users'" /> <AdminUsersTab v-if="activeTab === 'users'" />
<AdminQuotasTab v-if="activeTab === 'quotas'" /> <AdminQuotasTab v-if="activeTab === 'quotas'" />
<AdminAiConfigTab v-if="activeTab === 'ai'" /> <AdminAiConfigTab v-if="activeTab === 'ai'" />
<AuditLogTab v-if="activeTab === 'audit'" />
</div> </div>
</template> </template>
@@ -29,11 +30,13 @@ import { ref } from 'vue'
import AdminUsersTab from '../components/admin/AdminUsersTab.vue' import AdminUsersTab from '../components/admin/AdminUsersTab.vue'
import AdminQuotasTab from '../components/admin/AdminQuotasTab.vue' import AdminQuotasTab from '../components/admin/AdminQuotasTab.vue'
import AdminAiConfigTab from '../components/admin/AdminAiConfigTab.vue' import AdminAiConfigTab from '../components/admin/AdminAiConfigTab.vue'
import AuditLogTab from '../components/admin/AuditLogTab.vue'
const tabs = [ const tabs = [
{ id: 'users', label: 'Users' }, { id: 'users', label: 'Users' },
{ id: 'quotas', label: 'Quotas' }, { id: 'quotas', label: 'Quotas' },
{ id: 'ai', label: 'AI Config' }, { id: 'ai', label: 'AI Config' },
{ id: 'audit', label: 'Audit Log' },
] ]
const activeTab = ref('users') const activeTab = ref('users')
+45 -3
View File
@@ -17,11 +17,21 @@
Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }} Uploaded {{ formatDate(doc.created_at) }} · {{ formatSize(doc.size_bytes) }} · {{ doc.mime_type }}
</p> </p>
</div> </div>
<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 <button
@click="confirmDelete" @click="confirmDelete"
class="text-sm text-red-500 hover:text-red-700 shrink-0" class="text-sm text-red-500 hover:text-red-700"
>Delete</button> >Delete</button>
</div> </div>
</div>
<!-- Topics --> <!-- Topics -->
<div class="bg-white border border-gray-200 rounded-xl p-5 mb-5"> <div class="bg-white border border-gray-200 rounded-xl p-5 mb-5">
@@ -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> <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> </div>
</template> </template>
<!-- PDF in-app preview modal -->
<DocumentPreviewModal
v-if="showPreviewModal && doc"
:doc="doc"
@close="showPreviewModal = false"
/>
</div> </div>
</template> </template>
<script setup> <script setup>
import { ref, onMounted } from 'vue' import { ref, computed, onMounted } from 'vue'
import { useRoute, useRouter } from 'vue-router' import { useRoute, useRouter } from 'vue-router'
import TopicBadge from '../components/topics/TopicBadge.vue' import TopicBadge from '../components/topics/TopicBadge.vue'
import DocumentPreviewModal from '../components/documents/DocumentPreviewModal.vue'
import { useDocumentsStore } from '../stores/documents.js' import { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js' import { useTopicsStore } from '../stores/topics.js'
import * as api from '../api/client.js' import * as api from '../api/client.js'
@@ -114,6 +132,15 @@ const suggesting = ref(false)
const classifyError = ref(null) const classifyError = ref(null)
const suggestions = ref([]) const suggestions = ref([])
const selectedSuggestions = 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 () => { onMounted(async () => {
try { try {
@@ -121,8 +148,23 @@ onMounted(async () => {
} finally { } finally {
loading.value = false 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) { function topicColor(name) {
return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1' return topicsStore.topics.find(t => t.name === name)?.color ?? '#6366f1'
} }
@@ -166,7 +208,7 @@ async function createSelectedTopics() {
async function confirmDelete() { async function confirmDelete() {
if (!confirm(`Delete "${doc.value.original_name}"?`)) return if (!confirm(`Delete "${doc.value.original_name}"?`)) return
await api.deleteDocument(doc.value.id) await docsStore.remove(doc.value.id)
router.push('/') 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>
+26 -2
View File
@@ -10,12 +10,23 @@
<div class="mt-10"> <div class="mt-10">
<div class="flex items-center justify-between mb-4"> <div class="flex items-center justify-between mb-4">
<h3 class="text-lg font-semibold text-gray-800">Recent Documents</h3> <h3 class="text-lg font-semibold text-gray-800">Recent Documents</h3>
<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> <span class="text-sm text-gray-400">{{ docsStore.total }} total</span>
</div> </div>
</div>
<div v-if="docsStore.loading" class="text-sm text-gray-400">Loading</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"> <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>
<div v-else class="grid gap-3"> <div v-else class="grid gap-3">
<DocumentCard v-for="doc in docsStore.documents" :key="doc.id" :doc="doc" /> <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 DropZone from '../components/upload/DropZone.vue'
import UploadProgress from '../components/upload/UploadProgress.vue' import UploadProgress from '../components/upload/UploadProgress.vue'
import DocumentCard from '../components/documents/DocumentCard.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 { useDocumentsStore } from '../stores/documents.js'
import { useTopicsStore } from '../stores/topics.js' import { useTopicsStore } from '../stores/topics.js'
import { useFoldersStore } from '../stores/folders.js'
const docsStore = useDocumentsStore() const docsStore = useDocumentsStore()
const topicsStore = useTopicsStore() const topicsStore = useTopicsStore()
const foldersStore = useFoldersStore()
const uploadQueue = ref([]) 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 }) { async function onFilesSelected({ files, autoClassify }) {
// Build queue items // Build queue items
+63 -3
View File
@@ -3,17 +3,77 @@
<h2 class="text-2xl font-semibold text-gray-900 mb-1">Settings</h2> <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> <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> <h3 class="text-xl font-semibold text-gray-800 mb-2">AI configuration</h3>
<p class="text-sm text-gray-600"> <p class="text-sm text-gray-600">
AI provider and model are managed by your administrator. Contact your admin AI provider and model are managed by your administrator. Contact your admin
to request changes to which AI provider is used for your documents. to request changes to which AI provider is used for your documents.
</p> </p>
</section> </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> </div>
</template> </template>
<script setup> <script setup>
// SettingsView is a static placeholder after Phase 3 D-12 settings retirement. import { ref, watch, onMounted } from 'vue'
// No store usage, no API calls — AI config is admin-only via /api/admin/users/{id}/ai-config. 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> </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>