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:
@@ -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)} />
|
||||
)}
|
||||
</>
|
||||
);
|
||||
}
|
||||
Reference in New Issue
Block a user