feat(workspace): überarbeite geteilte Notizen und ganze Ordner teilen
MindSift CI & Docker Release / Build, Test in Container & Push (push) Successful in 1m1s

This commit is contained in:
sandrodach committed 2026-10-26 08:26:24 +01:00
1 parent 3fcd164fd8
commit 35fd682fcd
6 files changed
+1213 -93

No files matched your search

+221 -26
View File
@@ -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<WorkspaceViewProps> = ({
const [sharingLoading, setSharingLoading] = useState(false);
const [shareError, setShareError] = useState<string | null>(null);
// --- FOLDER SHARING STATE ---
const [sharedFolders, setSharedFolders] = useState<SharedFolder[]>([]);
const [sharingFolder, setSharingFolder] = useState<SharedFolder | null>(null);
const [showFolderShareModal, setShowFolderShareModal] = useState(false);
const [folderShares, setFolderShares] = useState<FolderShare[]>([]);
// --- DOCUMENT LOCKING STATE ---
const [activeLock, setActiveLock] = useState<DocLockInfo | null>(null);
const [holdsLock, setHoldsLock] = useState(false);
@@ -411,14 +420,16 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
}
} 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<WorkspaceViewProps> = ({
}
};
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<WorkspaceViewProps> = ({
// Compute all unique folder paths
const allFolderPaths = useMemo(() => {
const set = new Set<string>(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<WorkspaceViewProps> = ({
}
});
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
};
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
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<WorkspaceViewProps> = ({
<Folder className="w-4 h-4 sm:w-3.5 sm:h-3.5 text-muted flex-shrink-0" />
)}
<span className="font-medium text-sm sm:text-xs text-main truncate">{node.name}</span>
{node.isShared && (
<span
title={node.sharedFolder?.isOwner ? 'Geteilter Ordner (Eigentümer)' : 'Geteilter Ordner'}
className="text-accent flex-shrink-0"
>
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</span>
)}
<span className="text-[10px] text-faint font-mono">({totalItems})</span>
</div>
<div className="opacity-100 sm:opacity-0 sm:group-hover:opacity-100 flex items-center gap-1 transition-opacity">
<button
type="button"
onClick={(e) => {
e.stopPropagation();
openFolderShareModal(node);
}}
className="p-1.5 sm:p-1 hover:bg-panel-elevated text-muted hover:text-accent rounded-btn"
title="Ordner freigeben / teilen"
>
<Users className="w-3.5 h-3.5 sm:w-3 sm:h-3" />
</button>
<button
type="button"
onClick={(e) => {
@@ -4926,6 +5070,57 @@ export const WorkspaceView: React.FC<WorkspaceViewProps> = ({
/>
)}
{/* UNIFIED FOLDER SHARING MODAL */}
{showFolderShareModal && sharingFolder && (
<UnifiedShareModal
isOpen={showFolderShareModal}
onClose={() => {
setShowFolderShareModal(false);
setSharingFolder(null);
}}
title={`Ordner teilen: ${sharingFolder.name}`}
resourceName={sharingFolder.name}
resourceIcon="📁"
isOwner={sharingFolder.isOwner}
currentUserId={currentUser?.id}
shares={(folderShares || []).map((sh) => ({
id: sh.id,
targetType: sh.targetType,
targetId: sh.targetId,
targetName: sh.targetName || (sh.targetType === 'team' ? 'Gruppe' : sh.email || 'Benutzer'),
targetEmail: sh.email,
avatarUrl: sh.avatarUrl,
permission: sh.permission === 'read' ? ('viewer' as const) : ('editor' as const),
role: sh.permission === 'read' ? 'viewer' : 'editor',
createdAt: sh.createdAt,
}))}
teams={shareTeams}
collaborators={collaborators}
allowRoleSelection={true}
onAddShare={async (targetType, targetId, permission) => {
const perm = permission === 'viewer' ? 'read' : 'write';
await api.createFolderShare(sharingFolder.id, {
targetType,
targetId,
permission: perm,
});
const updated = await api.getFolderShares(sharingFolder.id);
setFolderShares(updated);
const sfList = await api.getSharedFolders();
setSharedFolders(sfList);
await loadDocs(selectedDoc?.id);
}}
onRemoveShare={async (shareId) => {
await api.deleteFolderShare(sharingFolder.id, shareId);
const updated = await api.getFolderShares(sharingFolder.id);
setFolderShares(updated);
const sfList = await api.getSharedFolders();
setSharedFolders(sfList);
await loadDocs(selectedDoc?.id);
}}
/>
)}
{/* LINK INSERTION MODAL (/link, /notiz, /web) */}
{linkModalBlockId && (
<div
+61
View File
@@ -12,6 +12,8 @@ import {
Organization,
PriorityLevel,
SharedList,
SharedFolder,
FolderShare,
Space,
SpaceMember,
SpaceShare,
@@ -432,6 +434,53 @@ export const api = {
);
},
// --- WORKSPACE SHARED FOLDERS ---
async getSharedFolders(): Promise<SharedFolder[]> {
const res = await request<SharedFolder[]>('/api/v1/workspace/folders/shared');
return ensureArray(res);
},
async createSharedFolder(payload: { name: string; mountPath?: string }): Promise<SharedFolder> {
return request<SharedFolder>('/api/v1/workspace/folders/shared', {
method: 'POST',
body: JSON.stringify(payload),
});
},
async getSharedFolder(id: string): Promise<SharedFolder> {
return request<SharedFolder>(`/api/v1/workspace/folders/shared/${id}`);
},
async deleteSharedFolder(id: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${id}`, { method: 'DELETE' });
},
async updateFolderMount(id: string, mountPath: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${id}/mount`, {
method: 'PUT',
body: JSON.stringify({ mountPath }),
});
},
async getFolderShares(folderId: string): Promise<FolderShare[]> {
const res = await request<FolderShare[]>(`/api/v1/workspace/folders/shared/${folderId}/shares`);
return ensureArray(res);
},
async createFolderShare(
folderId: string,
payload: { targetType?: 'team' | 'user'; targetId?: string; userId?: string; email?: string; teamId?: string; permission: 'read' | 'write' }
): Promise<FolderShare> {
return request<FolderShare>(`/api/v1/workspace/folders/shared/${folderId}/shares`, {
method: 'POST',
body: JSON.stringify(payload),
});
},
async deleteFolderShare(folderId: string, shareId: string): Promise<void> {
await request(`/api/v1/workspace/folders/shared/${folderId}/shares/${shareId}`, { method: 'DELETE' });
},
// --- WORKSPACE TAGS ---
async getWorkspaceTags(): Promise<TagSummary[]> {
const res = await request<TagSummary[]>('/api/v1/workspace/tags');
@@ -531,6 +580,18 @@ export const api = {
await request(`/api/v1/documents/${id}`, { method: 'DELETE' });
},
async moveDocument(id: string, folderPath: string): Promise<Document> {
const d = await request<Document>(`/api/v1/documents/${id}/move`, {
method: 'PUT',
body: JSON.stringify({ folderPath }),
});
return {
...d,
blocks: ensureArray(d.blocks),
tags: ensureArray(d.tags),
};
},
// --- DOCUMENT SHARING ---
async getDocShares(docId: string): Promise<DocShare[]> {
const res = await request<DocShare[]>(`/api/v1/documents/${docId}/shares`);
+30
View File
@@ -207,6 +207,36 @@ export interface Document {
isOwner?: boolean;
ownerName?: string;
sharedWith?: DocShare[];
sharedFolderId?: string;
subFolderPath?: string;
isSharedFolder?: boolean;
folderPermission?: string;
}
export interface SharedFolder {
id: string;
ownerId: string;
ownerName?: string;
name: string;
mountPath: string;
isOwner: boolean;
permission: 'owner' | 'write' | 'read';
shares?: FolderShare[];
createdAt: string;
updatedAt: string;
}
export interface FolderShare {
id: string;
folderId: string;
targetType: 'user' | 'team';
targetId: string;
targetName?: string;
email?: string;
avatarUrl?: string;
permission: 'read' | 'write';
createdBy: string;
createdAt: string;
}
export interface TagSummary {