Redesign doc service UX for scale + add group-based document sharing

- Three-column layout: Sidebar + SourcePanel (views + searchable category tree) + main
- DocumentSlideOver (480px right panel): inline editing, type picker, AI suggestion confirm/reject,
  categories combobox, tags editor, sharing section, raw text, re-analyse/delete actions
- ManageCategoriesDialog: inline rename, delete with confirm, search filter
- DocumentsPage rewrite: filter chip system, multi-file upload queue, drag-and-drop overlay,
  bulk actions bar (share/delete), smart TanStack Query polling, URL-driven view state
- Sidebar simplified: per-category NavLinks removed; Documents = single NavLink under Apps
- Backend: document_shares table (migration 0004), share CRUD endpoints, shared-with-me view,
  N+1-safe share_count via GROUP BY, recipient download access, X-User-Groups header enforcement
- Gateway proxy: injects X-User-Groups header into all document + category proxy requests
- Backend users: GET /api/users/me/groups endpoint for share picker combobox
- CLAUDE.md, STATUS.md files, and changelog updated

Co-Authored-By: Claude Sonnet 4.6 <noreply@anthropic.com>
This commit is contained in:
curo1305
2026-04-18 12:46:43 +02:00
parent 08e7caac4c
commit 94901fc30f
23 changed files with 2603 additions and 900 deletions
+205
View File
@@ -0,0 +1,205 @@
import { useState, useRef, useEffect } from "react";
import { useSearchParams } from "react-router-dom";
import { useQuery, useMutation, useQueryClient } from "@tanstack/react-query";
import { Files, User, Users, Folder, Plus, Settings2, Check, X } from "lucide-react";
import { listCategories, createCategory, CategoryOut } from "@/api/client";
import { Input } from "@/components/ui/input";
import { cn } from "@/lib/utils";
import ManageCategoriesDialog from "@/components/ManageCategoriesDialog";
export default function SourcePanel() {
const [searchParams, setSearchParams] = useSearchParams();
const queryClient = useQueryClient();
const currentView = searchParams.get("view") ?? "all";
const currentCategoryId = searchParams.get("category_id");
const [catSearch, setCatSearch] = useState("");
const [addingCat, setAddingCat] = useState(false);
const [newCatName, setNewCatName] = useState("");
const [manageOpen, setManageOpen] = useState(false);
const addInputRef = useRef<HTMLInputElement>(null);
const { data: categories = [] } = useQuery({
queryKey: ["categories"],
queryFn: listCategories,
});
const createMut = useMutation({
mutationFn: createCategory,
onSuccess: () => {
queryClient.invalidateQueries({ queryKey: ["categories"] });
setNewCatName("");
setAddingCat(false);
},
});
useEffect(() => {
if (addingCat) addInputRef.current?.focus();
}, [addingCat]);
const filteredCats = catSearch
? categories.filter((c) => c.name.toLowerCase().includes(catSearch.toLowerCase()))
: categories;
function setView(view: string) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.set("view", view);
next.delete("category_id");
next.set("page", "1");
return next;
});
}
function selectCategory(cat: CategoryOut) {
setSearchParams((prev) => {
const next = new URLSearchParams(prev);
next.delete("view");
next.set("category_id", cat.id);
next.set("page", "1");
return next;
});
}
const viewItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1.5 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
const catItemClass = (active: boolean) =>
cn(
"flex items-center gap-2 w-full px-2 py-1 rounded-md text-sm transition-colors",
active
? "bg-primary/10 text-primary font-medium"
: "text-muted hover:bg-muted/20 hover:text-foreground"
);
return (
<>
<aside className="w-56 flex flex-col border-r border-border bg-surface shrink-0 h-screen">
{/* Views */}
<div className="p-3 border-b border-border">
<p className="text-xs font-semibold text-muted uppercase tracking-wider mb-1.5 px-1">
Views
</p>
<button
className={viewItemClass(!currentCategoryId && (currentView === "all" || !currentView))}
onClick={() => setView("all")}
>
<Files className="h-4 w-4 shrink-0" />
All Documents
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "mine")}
onClick={() => setView("mine")}
>
<User className="h-4 w-4 shrink-0" />
Mine
</button>
<button
className={viewItemClass(!currentCategoryId && currentView === "shared")}
onClick={() => setView("shared")}
>
<Users className="h-4 w-4 shrink-0" />
Shared with me
</button>
</div>
{/* Categories */}
<div className="flex-1 flex flex-col min-h-0 p-3">
<div className="flex items-center justify-between mb-1.5 px-1">
<p className="text-xs font-semibold text-muted uppercase tracking-wider">
Categories
</p>
<button
onClick={() => setManageOpen(true)}
className="text-muted hover:text-foreground transition-colors"
title="Manage categories"
>
<Settings2 className="h-3.5 w-3.5" />
</button>
</div>
{categories.length > 4 && (
<Input
placeholder="Search…"
value={catSearch}
onChange={(e) => setCatSearch(e.target.value)}
className="h-7 text-xs mb-2"
/>
)}
<div className="flex-1 overflow-y-auto space-y-0.5 min-h-0">
{filteredCats.map((cat) => (
<button
key={cat.id}
className={catItemClass(currentCategoryId === cat.id)}
onClick={() => selectCategory(cat)}
>
<Folder className="h-3.5 w-3.5 shrink-0" />
<span className="truncate">{cat.name}</span>
</button>
))}
{filteredCats.length === 0 && catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories match</p>
)}
{categories.length === 0 && !catSearch && (
<p className="text-xs text-muted px-2 py-1">No categories yet</p>
)}
</div>
{/* Add new category */}
<div className="pt-2 border-t border-border mt-2">
{addingCat ? (
<form
onSubmit={(e) => {
e.preventDefault();
if (newCatName.trim()) createMut.mutate(newCatName.trim());
}}
className="flex items-center gap-1"
>
<Input
ref={addInputRef}
value={newCatName}
onChange={(e) => setNewCatName(e.target.value)}
placeholder="Category name"
className="h-7 text-xs flex-1"
disabled={createMut.isPending}
/>
<button
type="submit"
disabled={!newCatName.trim() || createMut.isPending}
className="text-primary hover:text-primary/80 disabled:opacity-50"
>
<Check className="h-4 w-4" />
</button>
<button
type="button"
onClick={() => { setAddingCat(false); setNewCatName(""); }}
className="text-muted hover:text-foreground"
>
<X className="h-4 w-4" />
</button>
</form>
) : (
<button
onClick={() => setAddingCat(true)}
className="flex items-center gap-1.5 text-xs text-muted hover:text-foreground transition-colors w-full px-2 py-1"
>
<Plus className="h-3.5 w-3.5" />
New category
</button>
)}
</div>
</div>
</aside>
{manageOpen && (
<ManageCategoriesDialog onClose={() => setManageOpen(false)} />
)}
</>
);
}