From 35fd682fcd88c5766a11f51c414f234c9f62dd37 Mon Sep 17 00:00:00 2001 From: Sandro Date: Mon, 26 Oct 2026 08:26:24 +0100 Subject: [PATCH] =?UTF-8?q?feat(workspace):=20=C3=BCberarbeite=20geteilte?= =?UTF-8?q?=20Notizen=20und=20ganze=20Ordner=20teilen?= MIME-Version: 1.0 Content-Type: text/plain; charset=UTF-8 Content-Transfer-Encoding: 8bit --- .../src/features/workspace/WorkspaceView.tsx | 247 +++++- frontend/src/lib/api.ts | 61 ++ frontend/src/lib/types.ts | 30 + internal/api/server.go | 163 ++++ internal/domain/models.go | 38 +- internal/storage/sqlite.go | 767 ++++++++++++++++-- 6 files changed, 1213 insertions(+), 93 deletions(-) diff --git a/frontend/src/features/workspace/WorkspaceView.tsx b/frontend/src/features/workspace/WorkspaceView.tsx index 0127323..024a247 100644 --- a/frontend/src/features/workspace/WorkspaceView.tsx +++ b/frontend/src/features/workspace/WorkspaceView.tsx @@ -48,7 +48,7 @@ import { Hash, User as UserIcon, } from 'lucide-react'; -import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, TagDetails, TagSummary, Team, User } from '../../lib/types'; +import { BlockType, Document, DocumentBlock, DocLockInfo, DocShare, FolderShare, SharedFolder, TagDetails, TagSummary, Team, User } from '../../lib/types'; import { api, getToken, triggerHaptic } from '../../lib/api'; import { CodeBlockItem } from './CodeBlockItem'; import { UserAutocomplete } from '../UserAutocomplete'; @@ -69,6 +69,9 @@ interface FolderTreeItem { depth: number; subfolders: FolderTreeItem[]; docs: Document[]; + sharedFolder?: SharedFolder; + isShared?: boolean; + isSharedRoot?: boolean; } interface SlashCommandItem { @@ -199,6 +202,12 @@ export const WorkspaceView: React.FC = ({ const [sharingLoading, setSharingLoading] = useState(false); const [shareError, setShareError] = useState(null); + // --- FOLDER SHARING STATE --- + const [sharedFolders, setSharedFolders] = useState([]); + const [sharingFolder, setSharingFolder] = useState(null); + const [showFolderShareModal, setShowFolderShareModal] = useState(false); + const [folderShares, setFolderShares] = useState([]); + // --- DOCUMENT LOCKING STATE --- const [activeLock, setActiveLock] = useState(null); const [holdsLock, setHoldsLock] = useState(false); @@ -411,14 +420,16 @@ export const WorkspaceView: React.FC = ({ const loadDocs = async (selectId?: string | null) => { try { - const [list, serverFolders, tags] = await Promise.all([ + const [list, serverFolders, tags, sFolders] = await Promise.all([ api.getDocuments(), api.getFolders(), api.getWorkspaceTags().catch(() => []), + api.getSharedFolders().catch(() => []), ]); setDocs(list); setCustomFolders(serverFolders); setWorkspaceTags(tags); + setSharedFolders(sFolders); if (selectId) { const found = list.find((d) => d.id === selectId); if (found) { @@ -637,6 +648,8 @@ export const WorkspaceView: React.FC = ({ } } else if (msg.event === 'WORKSPACE_FOLDERS_UPDATED') { api.getFolders().then(setCustomFolders).catch(() => {}); + api.getSharedFolders().then(setSharedFolders).catch(() => {}); + api.getDocuments().then(setDocs).catch(() => {}); } }; @@ -665,6 +678,30 @@ export const WorkspaceView: React.FC = ({ } }; + const openFolderShareModal = async (node: FolderTreeItem) => { + try { + let sf = node.sharedFolder; + if (!sf) { + sf = await api.createSharedFolder({ name: node.name, mountPath: node.path }); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + } + const [shares, usersList, teamsList] = await Promise.all([ + api.getFolderShares(sf.id).catch(() => []), + api.getUsersDirectory ? api.getUsersDirectory().catch(() => api.getCollaborators().catch(() => [])) : api.getCollaborators().catch(() => []), + api.getUserTeams().catch(() => []), + ]); + setFolderShares(shares); + setCollaborators(usersList); + setShareTeams(teamsList); + setSharingFolder(sf); + setShowFolderShareModal(true); + } catch (err) { + console.error('Failed to open folder share modal', err); + alert('Fehler beim Öffnen der Ordnerfreigabe'); + } + }; + const handleAddShare = async (e: React.FormEvent) => { e.preventDefault(); if (!selectedDoc) return; @@ -796,6 +833,16 @@ export const WorkspaceView: React.FC = ({ // Compute all unique folder paths const allFolderPaths = useMemo(() => { const set = new Set(customFolders); + sharedFolders.forEach((sf) => { + if (sf.mountPath) { + const parts = sf.mountPath.split('/'); + let cur = ''; + for (const p of parts) { + cur = cur ? `${cur}/${p}` : p; + set.add(cur); + } + } + }); docs.forEach((doc) => { const f = getDocFolder(doc); if (f) { @@ -808,7 +855,7 @@ export const WorkspaceView: React.FC = ({ } }); return Array.from(set).sort((a, b) => a.localeCompare(b)); - }, [customFolders, docs]); + }, [customFolders, sharedFolders, docs]); // Keep folders expanded by default useEffect(() => { @@ -919,6 +966,27 @@ export const WorkspaceView: React.FC = ({ triggerHaptic(15); + // Check if sourcePath is the root mount of a shared folder + const matchingSF = sharedFolders.find((sf) => sf.mountPath === cleanSource); + if (matchingSF) { + try { + await api.updateFolderMount(matchingSF.id, newFolderPath); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + await loadDocs(selectedDoc?.id); + setExpandedFolders((prev) => { + const next = new Set(prev); + next.add(newFolderPath); + if (cleanTarget) next.add(cleanTarget); + return next; + }); + } catch (err: any) { + console.error('Failed to move shared folder mount', err); + alert(err.message || 'Fehler beim Verschieben des geteilten Ordners'); + } + return; + } + // 1. Update all documents that are in cleanSource or any subfolder of cleanSource const docsToUpdate = docs.filter((d) => { const f = getDocFolder(d); @@ -948,7 +1016,7 @@ export const WorkspaceView: React.FC = ({ if (found) setSelectedDoc(found); } - // Persist all affected docs to backend + // Persist all affected docs to backend using api.moveDocument for (const d of docsToUpdate) { const f = getDocFolder(d); let nextFolder = newFolderPath; @@ -956,15 +1024,7 @@ export const WorkspaceView: React.FC = ({ const suffix = f.slice(cleanSource.length); nextFolder = `${newFolderPath}${suffix}`; } - const updatedTags = (d.tags || []).filter((t) => !t.startsWith('folder:')); - if (nextFolder) updatedTags.push(`folder:${nextFolder}`); - - api - .updateDocument(d.id, { - folderPath: nextFolder, - tags: updatedTags, - }) - .catch((err) => console.error('Failed to update doc folder', err)); + api.moveDocument(d.id, nextFolder).catch((err) => console.error('Failed to move doc', err)); } // 2. Sync folder structure with server @@ -995,18 +1055,41 @@ export const WorkspaceView: React.FC = ({ if (getDocFolder(doc) === cleanPath) return; triggerHaptic(15); - const updatedTags = (doc.tags || []).filter((t) => !t.startsWith('folder:')); - if (cleanPath) { - updatedTags.push(`folder:${cleanPath}`); - } - const updatedDoc: Document = { - ...doc, - folderPath: cleanPath, - tags: updatedTags, - }; - await persistDocument(updatedDoc, true); - if (cleanPath) { - setExpandedFolders((prev) => new Set([...prev, cleanPath])); + try { + const updatedDoc = await api.moveDocument(doc.id, cleanPath); + setDocs((prev) => + prev.map((d) => + d.id === doc.id + ? { + ...d, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : d + ) + ); + if (selectedDoc?.id === doc.id) { + setSelectedDoc((prev) => + prev + ? { + ...prev, + folderPath: updatedDoc.folderPath, + sharedFolderId: updatedDoc.sharedFolderId, + subFolderPath: updatedDoc.subFolderPath, + isSharedFolder: updatedDoc.isSharedFolder, + } + : null + ); + } + if (cleanPath) { + setExpandedFolders((prev) => new Set([...prev, cleanPath])); + } + api.getFolders().then(setCustomFolders).catch(() => {}); + } catch (err: any) { + console.error('Failed to move doc', err); + alert(err.message || 'Fehler beim Verschieben der Notiz'); } }; @@ -1043,6 +1126,30 @@ export const WorkspaceView: React.FC = ({ }; const handleDeleteFolder = async (folderPath: string, folderName: string) => { + const matchingSF = sharedFolders.find((sf) => sf.mountPath === folderPath); + if (matchingSF) { + if (matchingSF.isOwner) { + if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" wirklich löschen? Die Freigabe wird für alle Teilnehmer aufgehoben.`)) { + return; + } + } else { + if (!window.confirm(`Möchtest du den geteilten Ordner "${folderName}" aus deinem Workspace entfernen? (Die Quell-Dateien des Eigentümers bleiben erhalten).`)) { + return; + } + } + try { + await api.deleteSharedFolder(matchingSF.id); + const sfList = await api.getSharedFolders(); + setSharedFolders(sfList); + await loadDocs(selectedDoc?.id); + return; + } catch (err: any) { + console.error('Failed to delete shared folder', err); + alert(err.message || 'Fehler beim Löschen des geteilten Ordners'); + return; + } + } + // Find all documents in this folder and subfolders const docsInFolder = docs.filter((d) => { const f = getDocFolder(d); @@ -2787,12 +2894,30 @@ export const WorkspaceView: React.FC = ({ allFolderPaths.forEach((path) => { const parts = path.split('/'); const name = parts[parts.length - 1]; + + let matchingSF: SharedFolder | undefined; + let isRoot = false; + for (const sf of sharedFolders) { + if (sf.mountPath === path) { + matchingSF = sf; + isRoot = true; + break; + } else if (path.startsWith(sf.mountPath + '/')) { + matchingSF = sf; + isRoot = false; + break; + } + } + folderMap.set(path, { path, name, depth: parts.length - 1, subfolders: [], docs: [], + sharedFolder: matchingSF, + isShared: !!matchingSF, + isSharedRoot: isRoot, }); }); @@ -2822,7 +2947,7 @@ export const WorkspaceView: React.FC = ({ const rootOnlyDocs = filteredDocs.filter((d) => !getDocFolder(d)); return { rootTree: rootFolders, rootDocs: rootOnlyDocs }; - }, [allFolderPaths, filteredDocs]); + }, [allFolderPaths, filteredDocs, sharedFolders]); const pinnedDocs = useMemo(() => { return docs.filter((d) => d.isPinned); @@ -3091,10 +3216,29 @@ export const WorkspaceView: React.FC = ({ )} {node.name} + {node.isShared && ( + + + + )} ({totalItems})
+