first commit

This commit is contained in:
Beyhan Oğur
2026-04-26 21:52:23 +03:00
commit 880f412e2c
2662 changed files with 866266 additions and 0 deletions

View File

@@ -0,0 +1,584 @@
import { Button } from "@/components/ui/button";
import {
DropdownMenu,
DropdownMenuContent,
DropdownMenuItem,
DropdownMenuSeparator,
DropdownMenuTrigger,
} from "@/components/ui/dropdownMenu";
import { Input } from "@/components/ui/input";
import { ScrollArea } from "@/components/ui/scrollArea";
import { Folder, Prompt } from "@/lib/types/prompts";
import { cn } from "@/lib/utils";
import { DragDropProvider, useDraggable, useDroppable } from "@dnd-kit/react";
import {
ChevronDown,
ChevronRight,
FileText,
Folder as FolderIcon,
FolderOpen,
MoreHorizontal,
Pencil,
Plus,
PlusIcon,
Search,
Trash2,
} from "lucide-react";
import { useCallback, useEffect, useMemo, useState } from "react";
import { usePromptContext } from "../context";
/**
* Renders the prompt-manager sidebar including search, folder hierarchy, root prompts, and drag-and-drop reorganization.
*
* The sidebar supports creating, renaming, and deleting folders and prompts (when permitted), selecting prompts, auto-expanding the folder that contains the selected prompt, filtering by search query, and dragging prompts between folders or to the root. Visual drag-over feedback and permission gating for create/update/delete actions are applied.
*
* @returns The sidebar React element containing the search input, folder list, root prompt drop zone, and drag-and-drop provider.
*/
export function PromptSidebar() {
const {
folders,
prompts,
selectedPromptId,
handleSelectPrompt: onSelectPrompt,
setFolderSheet,
setDeleteFolderDialog,
setPromptSheet,
setDeletePromptDialog,
handleMovePrompt: onMovePrompt,
canCreate,
canUpdate,
canDelete,
} = usePromptContext();
const onCreateFolder = useCallback(() => setFolderSheet({ open: true }), [setFolderSheet]);
const onEditFolder = useCallback((folder: Folder) => setFolderSheet({ open: true, folder }), [setFolderSheet]);
const onDeleteFolder = useCallback((folder: Folder) => setDeleteFolderDialog({ open: true, folder }), [setDeleteFolderDialog]);
const onCreatePrompt = useCallback((folderId?: string) => setPromptSheet({ open: true, folderId }), [setPromptSheet]);
const onEditPrompt = useCallback((prompt: Prompt) => setPromptSheet({ open: true, prompt }), [setPromptSheet]);
const onDeletePrompt = useCallback((prompt: Prompt) => setDeletePromptDialog({ open: true, prompt }), [setDeletePromptDialog]);
const [expandedFolders, setExpandedFolders] = useState<Set<string>>(new Set());
const [searchQuery, setSearchQuery] = useState("");
const [dragOverTarget, setDragOverTarget] = useState<string | null>(null);
// Auto-expand the folder containing the selected prompt
useEffect(() => {
if (!selectedPromptId) return;
const prompt = prompts.find((p) => p.id === selectedPromptId);
if (prompt?.folder_id) {
setExpandedFolders((prev) => {
if (prev.has(prompt.folder_id!)) return prev;
const next = new Set(prev);
next.add(prompt.folder_id!);
return next;
});
}
}, [selectedPromptId, prompts]);
const toggleFolder = useCallback((folderId: string) => {
setExpandedFolders((prev) => {
const next = new Set(prev);
if (next.has(folderId)) {
next.delete(folderId);
} else {
next.add(folderId);
}
return next;
});
}, []);
// Group prompts by folder, root prompts have no folder_id
const { promptsByFolder, rootPrompts } = useMemo(() => {
const map = new Map<string, Prompt[]>();
const root: Prompt[] = [];
for (const prompt of prompts) {
if (!prompt.folder_id) {
root.push(prompt);
} else {
const list = map.get(prompt.folder_id) || [];
list.push(prompt);
map.set(prompt.folder_id, list);
}
}
return { promptsByFolder: map, rootPrompts: root };
}, [prompts]);
// Filter folders and prompts based on search
const filteredData = useMemo(() => {
if (!searchQuery.trim()) {
return { folders, promptsByFolder, rootPrompts };
}
const query = searchQuery.toLowerCase();
const matchedFolderIds = new Set<string>();
const filteredPromptsByFolder = new Map<string, Prompt[]>();
const filteredRootPrompts: Prompt[] = [];
for (const prompt of prompts) {
if (prompt.name.toLowerCase().includes(query)) {
if (!prompt.folder_id) {
filteredRootPrompts.push(prompt);
} else {
matchedFolderIds.add(prompt.folder_id);
const list = filteredPromptsByFolder.get(prompt.folder_id) || [];
list.push(prompt);
filteredPromptsByFolder.set(prompt.folder_id, list);
}
}
}
const filteredFolders = folders.filter((folder) => folder.name.toLowerCase().includes(query) || matchedFolderIds.has(folder.id));
return {
folders: filteredFolders,
promptsByFolder: filteredPromptsByFolder,
rootPrompts: filteredRootPrompts,
};
}, [folders, prompts, promptsByFolder, rootPrompts, searchQuery]);
// Prompt lookup for drag events
const promptMap = useMemo(() => {
const map = new Map<string, Prompt>();
for (const p of prompts) map.set(p.id, p);
return map;
}, [prompts]);
return (
<DragDropProvider
onDragOver={(event) => {
if (!canUpdate) return;
const targetId = event.operation.target?.id as string | undefined;
setDragOverTarget(targetId ?? null);
}}
onDragEnd={(event) => {
setDragOverTarget(null);
if (!canUpdate) return;
if (event.canceled || !onMovePrompt) return;
const sourceId = event.operation.source?.id as string | undefined;
const targetId = event.operation.target?.id as string | undefined;
if (!sourceId || !targetId) return;
const promptId = sourceId.startsWith("prompt-") ? sourceId.slice(7) : null;
if (!promptId) return;
const prompt = promptMap.get(promptId);
if (!prompt) return;
let targetFolderId: string | null = null;
if (targetId === "root-drop-zone") {
targetFolderId = null;
} else if (targetId.startsWith("folder-")) {
targetFolderId = targetId.slice(7);
} else {
return;
}
if ((prompt.folder_id ?? null) === targetFolderId) return;
onMovePrompt(promptId, targetFolderId);
}}
>
<div className="flex h-full flex-col">
{/* Search */}
<div className="flex items-center gap-2 border-b p-3">
<div className="relative grow">
<Search className="text-muted-foreground absolute top-1/2 left-2.5 h-4 w-4 -translate-y-1/2" />
<Input
placeholder="Search prompts..."
value={searchQuery}
onChange={(e) => setSearchQuery(e.target.value)}
data-testid="sidebar-search"
className="h-8 pl-8"
/>
</div>
{canCreate && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
variant="outline"
className="h-8 w-8 shrink-0 bg-transparent"
data-testid="sidebar-create-menu"
aria-label="Create prompt or folder"
>
<PlusIcon className="h-3.5 w-3.5" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem
data-testid="sidebar-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
New Prompt
</DropdownMenuItem>
<DropdownMenuItem
data-testid="sidebar-create-folder"
onClick={(e) => {
e.stopPropagation();
onCreateFolder();
}}
>
New Folder
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
<ScrollArea className="grow overflow-y-auto" viewportClassName="no-table viewport-table-height-full">
<div className="flex flex-col p-2 px-3">
{filteredData.folders.length === 0 && filteredData.rootPrompts.length === 0 ? (
<div className="text-muted-foreground py-8 text-center text-sm">{searchQuery ? "No results found" : "No prompts yet"}</div>
) : (
<>
{filteredData.folders.map((folder) => (
<DroppableFolder
key={folder.id}
folder={folder}
prompts={filteredData.promptsByFolder.get(folder.id) || promptsByFolder.get(folder.id) || []}
isExpanded={expandedFolders.has(folder.id) || !!searchQuery}
isDragOver={dragOverTarget === `folder-${folder.id}`}
selectedPromptId={selectedPromptId}
onToggle={() => toggleFolder(folder.id)}
onSelectPrompt={onSelectPrompt}
onEdit={() => onEditFolder(folder)}
onDelete={() => onDeleteFolder(folder)}
onCreatePrompt={() => onCreatePrompt(folder.id)}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canCreate={canCreate}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
<RootDropZone
isDragOver={dragOverTarget === "root-drop-zone"}
rootPrompts={filteredData.rootPrompts}
selectedPromptId={selectedPromptId}
onSelectPrompt={onSelectPrompt}
onEditPrompt={onEditPrompt}
onDeletePrompt={onDeletePrompt}
canUpdate={canUpdate}
canDelete={canDelete}
/>
</>
)}
</div>
</ScrollArea>
</div>
</DragDropProvider>
);
}
interface RootDropZoneProps {
isDragOver: boolean;
rootPrompts: Prompt[];
selectedPromptId?: string | null;
onSelectPrompt: (promptId: string) => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders the droppable root area that lists and hosts draggable root-level prompts.
*
* @param isDragOver - Whether a draggable item is currently over the root drop zone (applies drag-over styling).
* @param rootPrompts - Array of prompts that belong at the root (no folder).
* @param selectedPromptId - ID of the currently selected prompt, used to mark its item as selected.
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected.
* @param onEditPrompt - Callback invoked with a prompt when the prompt's edit action is triggered.
* @param onDeletePrompt - Callback invoked with a prompt when the prompt's delete action is triggered.
* @param canUpdate - Whether prompts are movable/editable (enables dragging).
* @param canDelete - Whether prompts may be deleted (controls delete action visibility).
* @returns The JSX element for the root drop zone containing draggable prompt items.
*/
function RootDropZone({
isDragOver,
rootPrompts,
selectedPromptId,
onSelectPrompt,
onEditPrompt,
onDeletePrompt,
canUpdate,
canDelete,
}: RootDropZoneProps) {
const { ref } = useDroppable({ id: "root-drop-zone" });
return (
<div ref={ref} className={cn("min-h-[8px] grow rounded-sm transition-colors", isDragOver && "bg-primary/10 ring-primary/30 ring-1")}>
{rootPrompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))}
</div>
);
}
interface DroppableFolderProps {
folder: Folder;
prompts: Prompt[];
isExpanded: boolean;
isDragOver: boolean;
selectedPromptId?: string | null;
onToggle: () => void;
onSelectPrompt: (promptId: string) => void;
onEdit: () => void;
onDelete: () => void;
onCreatePrompt: () => void;
onEditPrompt: (prompt: Prompt) => void;
onDeletePrompt: (prompt: Prompt) => void;
canCreate: boolean;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a droppable folder header with optional action menu and its list of prompts.
*
* @param folder - Folder metadata (id, name, etc.)
* @param prompts - Prompts that belong to this folder
* @param isExpanded - Whether the folder is expanded to show its prompts
* @param isDragOver - Whether a draggable item is currently over this folder (affects visual state)
* @param selectedPromptId - ID of the currently selected prompt, used to highlight an item
* @param onToggle - Callback invoked to toggle the folder's expanded state
* @param onSelectPrompt - Callback invoked with a prompt ID when a prompt is selected
* @param onEdit - Callback invoked to start editing the folder
* @param onDelete - Callback invoked to start deleting the folder
* @param onCreatePrompt - Callback invoked to create a new prompt inside this folder
* @param onEditPrompt - Callback invoked with a prompt to start editing that prompt
* @param onDeletePrompt - Callback invoked with a prompt to start deleting that prompt
* @param canCreate - Whether the current user may create prompts in this folder
* @param canUpdate - Whether the current user may move/rename prompts or edit the folder
* @param canDelete - Whether the current user may delete prompts or the folder
* @returns A JSX element containing the folder row and, when expanded, its nested prompt items
*/
function DroppableFolder({
folder,
prompts,
isExpanded,
isDragOver,
selectedPromptId,
onToggle,
onSelectPrompt,
onEdit,
onDelete,
onCreatePrompt,
onEditPrompt,
onDeletePrompt,
canCreate,
canUpdate,
canDelete,
}: DroppableFolderProps) {
const { ref } = useDroppable({ id: `folder-${folder.id}` });
const showActions = canCreate || canUpdate || canDelete;
return (
<div ref={ref} className="mb-1 last:mb-0">
<div
className={cn(
"hover:bg-muted/50 group relative flex h-[30px] cursor-pointer items-center gap-1 rounded-sm px-2 transition-colors",
isDragOver && "bg-primary/10 ring-primary/30 ring-1",
)}
onClick={onToggle}
data-testid={`sidebar-folder-${folder.id}`}
>
<button className="flex shrink-0 items-center" aria-label="Toggle folder">
{isExpanded ? (
<ChevronDown className="text-muted-foreground h-4 w-4" />
) : (
<ChevronRight className="text-muted-foreground h-4 w-4" />
)}
</button>
{isExpanded ? (
<FolderOpen className="text-muted-foreground h-4 w-4 shrink-0" />
) : (
<FolderIcon className="text-muted-foreground h-4 w-4 shrink-0" />
)}
<span className="flex-1 truncate text-sm font-medium">{folder.name}</span>
<span className="text-muted-foreground mr-1 shrink-0 text-xs">{prompts.length}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()} className="bg-card absolute top-1/2 right-2 -translate-y-1/2">
<Button
variant="ghost"
size="icon"
className="h-6 w-6 shrink-0 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-folder-actions-${folder.id}`}
aria-label="Folder actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canCreate && (
<DropdownMenuItem
data-testid="folder-create-prompt"
onClick={(e) => {
e.stopPropagation();
onCreatePrompt();
}}
>
<Plus className="mr-2 h-4 w-4" />
New Prompt
</DropdownMenuItem>
)}
{canCreate && (canUpdate || canDelete) && <DropdownMenuSeparator />}
{canUpdate && (
<DropdownMenuItem
data-testid="folder-action-edit"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Edit Folder
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
data-testid="folder-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete Folder
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
{isExpanded && (
<div className="ml-4 border-l pl-2">
{prompts.length === 0 ? (
<div className="text-muted-foreground py-2 pl-4 text-xs">{isDragOver ? "Drop here" : "No prompts"}</div>
) : (
prompts.map((prompt) => (
<DraggablePromptItem
key={prompt.id}
prompt={prompt}
isSelected={selectedPromptId === prompt.id}
onSelect={() => onSelectPrompt(prompt.id)}
onEdit={() => onEditPrompt(prompt)}
onDelete={() => onDeletePrompt(prompt)}
canUpdate={canUpdate}
canDelete={canDelete}
/>
))
)}
</div>
)}
</div>
);
}
interface DraggablePromptItemProps {
prompt: Prompt;
isSelected: boolean;
onSelect: () => void;
onEdit: () => void;
onDelete: () => void;
canUpdate: boolean;
canDelete: boolean;
}
/**
* Renders a draggable prompt list item that shows the prompt name, selection/drag states, and an actions menu when permitted.
*
* Displays a file icon and truncated prompt name, applies visual styles for selection and dragging, prevents selection while dragging, and exposes rename/delete actions via a dropdown when `canUpdate` or `canDelete` are true.
*
* @param prompt - The prompt object to render.
* @param isSelected - Whether this prompt is currently selected; used for styling.
* @param onSelect - Callback invoked when the item is clicked (not invoked if the item is being dragged).
* @param onEdit - Callback invoked to start editing/renaming the prompt.
* @param onDelete - Callback invoked to delete the prompt.
* @param canUpdate - When true, enables dragging and shows the rename action.
* @param canDelete - When true, shows the delete action.
* @returns The rendered prompt item JSX element.
*/
function DraggablePromptItem({ prompt, isSelected, onSelect, onEdit, onDelete, canUpdate, canDelete }: DraggablePromptItemProps) {
const { ref, isDragging } = useDraggable({
id: `prompt-${prompt.id}`,
disabled: !canUpdate,
});
const showActions = canUpdate || canDelete;
return (
<div
ref={ref}
data-testid={`sidebar-prompt-${prompt.id}`}
className={cn(
"group mb-1 flex h-[30px] cursor-pointer items-center gap-2 rounded-sm px-2 last:mb-0",
isSelected ? "bg-primary/10 text-primary" : "hover:bg-muted/50",
isDragging && "opacity-50",
)}
onClick={() => {
// Don't navigate if this was a drag
if (isDragging) return;
onSelect();
}}
>
<FileText className="h-4 w-4 shrink-0" />
<span className="flex-1 truncate text-sm">{prompt.name}</span>
{showActions && (
<DropdownMenu>
<DropdownMenuTrigger asChild onClick={(e) => e.stopPropagation()}>
<Button
variant="ghost"
size="icon"
className="h-6 w-6 opacity-0 group-focus-within:opacity-100 group-hover:opacity-100 focus-visible:opacity-100"
data-testid={`sidebar-prompt-actions-${prompt.id}`}
aria-label="Prompt actions"
>
<MoreHorizontal className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{canUpdate && (
<DropdownMenuItem
className="cursor-pointer"
data-testid="prompt-action-rename"
onClick={(e) => {
e.stopPropagation();
onEdit();
}}
>
<Pencil className="h-4 w-4" />
Rename
</DropdownMenuItem>
)}
{canDelete && (
<DropdownMenuItem
variant="destructive"
className="cursor-pointer"
data-testid="prompt-action-delete"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2 className="h-4 w-4" />
Delete
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
);
}