From 5417f26b93532a224e7ded3b2ff17aa54ec138ef Mon Sep 17 00:00:00 2001 From: curo1305 Date: Mon, 25 May 2026 21:58:38 +0200 Subject: [PATCH] =?UTF-8?q?feat(phase-4):=20frontend=20data=20layer=20?= =?UTF-8?q?=E2=80=94=20API=20client=20(13=20new=20functions),=20folders=20?= =?UTF-8?q?store,=20documents=20store=20extensions,=20routes?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit - Extended listDocuments to accept folderId, q, sort, order query params - Added 6 folder API functions: listFolders, createFolder, getFolder, renameFolder, deleteFolder, moveDocument - Added 4 share API functions: createShare, listShares, deleteShare, getSharedWithMe - Added 2 preference API functions: getMyPreferences, updateMyPreferences - Added getDocumentContentUrl helper (returns URL string, no fetch) - Created useFoldersStore with full CRUD, navigation state, and breadcrumb support - Extended useDocumentsStore with currentFolderId, searchQuery, sortField, sortOrder refs - Added debounced searchQuery watcher (300ms, 2-char minimum, T-04-08-03) - Added shareDocument, revokeShare, listShares actions to documents store - Added /folders/:folderId and /shared routes with requiresAuth guard --- frontend/src/api/client.js | 89 +++++++++++++++++++++++++++++++- frontend/src/router/index.js | 14 +++++ frontend/src/stores/documents.js | 37 +++++++++++-- frontend/src/stores/folders.js | 85 ++++++++++++++++++++++++++++++ 4 files changed, 220 insertions(+), 5 deletions(-) create mode 100644 frontend/src/stores/folders.js diff --git a/frontend/src/api/client.js b/frontend/src/api/client.js index 330bed5..985481c 100644 --- a/frontend/src/api/client.js +++ b/frontend/src/api/client.js @@ -57,9 +57,13 @@ async function request(path, options = {}) { // ── Documents ──────────────────────────────────────────────────────────────── -export function listDocuments({ topic, page = 1, perPage = 20 } = {}) { +export function listDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) { const params = new URLSearchParams({ page, per_page: perPage }) if (topic) params.set('topic', topic) + if (folderId != null) params.set('folder_id', folderId) + if (q) params.set('q', q) + if (sort) params.set('sort', sort) + if (order) params.set('order', order) return request(`/api/documents?${params}`) } @@ -263,3 +267,86 @@ export function adminUpdateAiConfig(id, provider, model) { body: JSON.stringify({ ai_provider: provider, ai_model: model }), }) } + +// ── Folders ─────────────────────────────────────────────────────────────────── + +export function listFolders(parentId = null) { + const params = new URLSearchParams() + if (parentId != null) params.set('parent_id', parentId) + const qs = params.toString() + return request(`/api/folders${qs ? `?${qs}` : ''}`) +} + +export function createFolder(name, parentId = null) { + return request('/api/folders', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name, parent_id: parentId || null }), + }) +} + +export function getFolder(folderId) { + return request(`/api/folders/${folderId}`) +} + +export function renameFolder(folderId, name) { + return request(`/api/folders/${folderId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name }), + }) +} + +export function deleteFolder(folderId) { + return request(`/api/folders/${folderId}`, { method: 'DELETE' }) +} + +export function moveDocument(docId, folderId) { + return request(`/api/documents/${docId}/folder`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ folder_id: folderId || null }), + }) +} + +// ── Shares ──────────────────────────────────────────────────────────────────── + +export function createShare(docId, recipientHandle) { + return request('/api/shares', { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ document_id: docId, recipient_handle: recipientHandle }), + }) +} + +export function listShares(docId) { + return request(`/api/shares?document_id=${docId}`) +} + +export function deleteShare(shareId) { + return request(`/api/shares/${shareId}`, { method: 'DELETE' }) +} + +export function getSharedWithMe() { + return request('/api/shares/received') +} + +// ── Preferences ─────────────────────────────────────────────────────────────── + +export function getMyPreferences() { + return request('/api/auth/me/preferences') +} + +export function updateMyPreferences(payload) { + return request('/api/auth/me/preferences', { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify(payload), + }) +} + +// ── Document content proxy URL ──────────────────────────────────────────────── + +export function getDocumentContentUrl(docId) { + return `/api/documents/${docId}/content` +} diff --git a/frontend/src/router/index.js b/frontend/src/router/index.js index 0323ca0..d6e135e 100644 --- a/frontend/src/router/index.js +++ b/frontend/src/router/index.js @@ -38,6 +38,20 @@ const routes = [ // Phase 2 — authenticated routes { path: '/account', component: () => import('../views/AccountView.vue') }, { path: '/admin', component: () => import('../views/AdminView.vue') }, + + // Phase 4 — folder and sharing routes + { + path: '/folders/:folderId', + name: 'folder', + component: () => import('../views/FolderView.vue'), + meta: { requiresAuth: true }, + }, + { + path: '/shared', + name: 'shared', + component: () => import('../views/SharedView.vue'), + meta: { requiresAuth: true }, + }, ] const router = createRouter({ diff --git a/frontend/src/stores/documents.js b/frontend/src/stores/documents.js index a60272d..b8cef62 100644 --- a/frontend/src/stores/documents.js +++ b/frontend/src/stores/documents.js @@ -1,5 +1,5 @@ import { defineStore } from 'pinia' -import { ref } from 'vue' +import { ref, watch } from 'vue' import * as api from '../api/client.js' import { useAuthStore } from './auth.js' @@ -34,12 +34,16 @@ export const useDocumentsStore = defineStore('documents', () => { const error = ref(null) // uploadProgress: keyed by "${file.name}__${Date.now()}" to prevent collision (T-03-25) const uploadProgress = ref({}) + const currentFolderId = ref(null) + const searchQuery = ref('') + const sortField = ref('date') + const sortOrder = ref('desc') - async function fetchDocuments({ topic, page = 1, perPage = 20 } = {}) { + async function fetchDocuments({ topic, page = 1, perPage = 20, folderId = null, q = null, sort = null, order = null } = {}) { loading.value = true error.value = null try { - const data = await api.listDocuments({ topic, page, perPage }) + const data = await api.listDocuments({ topic, page, perPage, folderId, q, sort, order }) documents.value = data.items total.value = data.total } catch (e) { @@ -125,5 +129,30 @@ export const useDocumentsStore = defineStore('documents', () => { return result.topics } - return { documents, total, loading, error, uploadProgress, fetchDocuments, upload, remove, reclassify } + // Debounced search watcher: fires after 300 ms; ignores inputs shorter than 2 chars (T-04-08-03) + let _searchTimer = null + watch(searchQuery, (newVal) => { + clearTimeout(_searchTimer) + if (newVal.length < 2) { + fetchDocuments({ folderId: currentFolderId.value }) + return + } + _searchTimer = setTimeout(() => { + fetchDocuments({ q: newVal, folderId: currentFolderId.value, sort: sortField.value, order: sortOrder.value }) + }, 300) + }) + + async function shareDocument(docId, recipientHandle) { + try { return await api.createShare(docId, recipientHandle) } catch (e) { throw e } + } + + async function revokeShare(shareId) { + try { await api.deleteShare(shareId) } catch (e) { throw e } + } + + async function listShares(docId) { + try { return await api.listShares(docId) } catch (e) { throw e } + } + + return { documents, total, loading, error, uploadProgress, currentFolderId, searchQuery, sortField, sortOrder, fetchDocuments, upload, remove, reclassify, shareDocument, revokeShare, listShares } }) diff --git a/frontend/src/stores/folders.js b/frontend/src/stores/folders.js new file mode 100644 index 0000000..22801bb --- /dev/null +++ b/frontend/src/stores/folders.js @@ -0,0 +1,85 @@ +import { defineStore } from 'pinia' +import { ref } from 'vue' +import * as api from '../api/client.js' + +export const useFoldersStore = defineStore('folders', () => { + const folders = ref([]) + const currentFolderId = ref(null) + const breadcrumb = ref([]) + const loading = ref(false) + const error = ref(null) + + async function fetchFolders(parentId = null) { + loading.value = true + error.value = null + try { + const data = await api.listFolders(parentId) + folders.value = data + } catch (e) { + error.value = e.message || 'Failed to load folders' + } finally { + loading.value = false + } + } + + async function createFolder(name, parentId = null) { + loading.value = true + error.value = null + try { + const folder = await api.createFolder(name, parentId) + folders.value.push(folder) + return folder + } catch (e) { + error.value = e.message || 'Failed to create folder' + throw e + } finally { + loading.value = false + } + } + + async function renameFolder(folderId, name) { + loading.value = true + error.value = null + try { + const updated = await api.renameFolder(folderId, name) + const idx = folders.value.findIndex(f => f.id === folderId) + if (idx !== -1) folders.value[idx] = updated + return updated + } catch (e) { + error.value = e.message || 'Failed to rename folder' + throw e + } finally { + loading.value = false + } + } + + async function deleteFolder(folderId) { + loading.value = true + error.value = null + try { + await api.deleteFolder(folderId) + folders.value = folders.value.filter(f => f.id !== folderId) + } catch (e) { + error.value = e.message || 'Failed to delete folder' + throw e + } finally { + loading.value = false + } + } + + async function navigateTo(folderId) { + currentFolderId.value = folderId + if (folderId != null) { + try { + const data = await api.getFolder(folderId) + breadcrumb.value = data.breadcrumb || [] + } catch (e) { + breadcrumb.value = [] + } + } else { + breadcrumb.value = [] + } + } + + return { folders, currentFolderId, breadcrumb, loading, error, fetchFolders, createFolder, renameFolder, deleteFolder, navigateTo } +})