Fix delete button behavior in Project Settings (#699)

This commit is contained in:
Wendong-Fan 2025-11-20 00:05:48 +08:00 committed by GitHub
commit 330fa3b762
No known key found for this signature in database
GPG key ID: B5690EEEBB952194
3 changed files with 290 additions and 262 deletions

View file

@ -1,249 +1,273 @@
import { useState, useEffect, useRef } from "react";
import { ProjectGroup } from "@/types/history";
import {
Dialog,
DialogContent,
DialogHeader,
DialogContentSection,
DialogFooter,
Dialog,
DialogContent,
DialogHeader,
DialogContentSection,
DialogFooter,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import { Button } from "@/components/ui/button";
import TaskItem from "./TaskItem";
import { useTranslation } from "react-i18next";
import { Hash, CheckCircle, Clock, Pin, Loader2, LoaderCircle } from "lucide-react";
import {
Hash,
CheckCircle,
Clock,
Pin,
Loader2,
LoaderCircle,
} from "lucide-react";
import { useProjectStore } from "@/store/projectStore";
interface ProjectDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
project: ProjectGroup;
onProjectRename: (projectId: string, newName: string) => void;
onTaskSelect: (projectId: string, question: string, historyId: string) => void;
onTaskDelete: (taskId: string) => void;
onTaskShare: (taskId: string) => void;
activeTaskId?: string;
open: boolean;
onOpenChange: (open: boolean) => void;
project: ProjectGroup;
onProjectRename: (projectId: string, newName: string) => void;
onTaskSelect: (
projectId: string,
question: string,
historyId: string
) => void;
onTaskDelete: (taskId: string) => void;
onTaskShare: (taskId: string) => void;
activeTaskId?: string;
}
export default function ProjectDialog({
open,
onOpenChange,
project,
onProjectRename,
onTaskSelect,
onTaskDelete,
onTaskShare,
activeTaskId,
open,
onOpenChange,
project,
onProjectRename,
onTaskSelect,
onTaskDelete,
onTaskShare,
activeTaskId,
}: ProjectDialogProps) {
const { t } = useTranslation();
const projectStore = useProjectStore();
const [projectName, setProjectName] = useState(project.project_name || t("layout.new-project"));
const [isSaving, setIsSaving] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedNameRef = useRef<string>(project.project_name || t("layout.new-project"));
const { t } = useTranslation();
const projectStore = useProjectStore();
const [projectName, setProjectName] = useState(
project.project_name || t("layout.new-project")
);
const [isSaving, setIsSaving] = useState(false);
const saveTimeoutRef = useRef<NodeJS.Timeout | null>(null);
const lastSavedNameRef = useRef<string>(
project.project_name || t("layout.new-project")
);
// Update state when project changes
useEffect(() => {
const name = project.project_name || t("layout.new-project");
setProjectName(name);
lastSavedNameRef.current = name;
}, [project.project_name, project.project_id, t]);
// Update state when project changes
useEffect(() => {
const name = project.project_name || t("layout.new-project");
setProjectName(name);
lastSavedNameRef.current = name;
}, [project.project_name, project.project_id, t]);
// Auto-save with debouncing
useEffect(() => {
// Clear any existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
// Auto-save with debouncing
useEffect(() => {
// Clear any existing timeout
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
const trimmedName = projectName.trim();
// Only save if the name has actually changed and is not empty
if (trimmedName && trimmedName !== lastSavedNameRef.current) {
setIsSaving(true);
// Debounce: wait 800ms after user stops typing
saveTimeoutRef.current = setTimeout(() => {
// Update via callback (for history API)
onProjectRename(project.project_id, trimmedName);
// Also update in projectStore if the project exists there
const storeProject = projectStore.getProjectById(project.project_id);
if (storeProject) {
projectStore.updateProject(project.project_id, { name: trimmedName });
}
lastSavedNameRef.current = trimmedName;
setIsSaving(false);
}, 800);
} else if (!trimmedName) {
// If empty, don't show saving state
setIsSaving(false);
}
const trimmedName = projectName.trim();
// Cleanup timeout on unmount or when projectName changes
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [projectName, project.project_id, onProjectRename, projectStore]);
// Only save if the name has actually changed and is not empty
if (trimmedName && trimmedName !== lastSavedNameRef.current) {
setIsSaving(true);
// Cleanup on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
// Debounce: wait 800ms after user stops typing
saveTimeoutRef.current = setTimeout(() => {
// Update via callback (for history API)
onProjectRename(project.project_id, trimmedName);
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="md"
className="max-h-[80vh] flex flex-col h-full"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<DialogHeader
title={t("layout.project-settings")}
subtitle={t("layout.manage-project-details")}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
// Also update in projectStore if the project exists there
const storeProject = projectStore.getProjectById(project.project_id);
if (storeProject) {
projectStore.updateProject(project.project_id, { name: trimmedName });
}
<DialogContentSection
className="flex flex-col overflow-y-auto scrollbar"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Project Name Section - Inline Edit with Auto-Save */}
<div
className="flex flex-col gap-sm mb-lg"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<label className="text-text-label font-bold text-label-sm">
{t("layout.project-name")}
</label>
<div
className="flex items-center gap-sm"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Input
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
placeholder={t("layout.enter-project-name")}
/>
{isSaving ? (
<Loader2 className="w-4 h-4 text-icon-action animate-spin flex-shrink-0" />
) : (
<></>
)}
</div>
</div>
lastSavedNameRef.current = trimmedName;
setIsSaving(false);
}, 800);
} else if (!trimmedName) {
// If empty, don't show saving state
setIsSaving(false);
}
{/* Project Stats */}
<div className="grid grid-cols-4 gap-lg border-solid border-t-0 border-x-0 border-border-disabled pb-md">
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.total-tokens")}
</span>
<div className="flex flex-row items-center gap-sm">
<Hash className="w-4 h-4 text-icon-primary" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_tokens.toLocaleString()}
</span>
</div>
</div>
// Cleanup timeout on unmount or when projectName changes
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, [projectName, project.project_id, onProjectRename, projectStore]);
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.total-tasks")}
</span>
<div className="flex flex-row items-center gap-sm">
<Pin className="w-4 h-4 text-icon-primary" />
<span className="text-text-heading text-body-lg font-bold">
{project.task_count}
</span>
</div>
</div>
// Cleanup on unmount
useEffect(() => {
return () => {
if (saveTimeoutRef.current) {
clearTimeout(saveTimeoutRef.current);
}
};
}, []);
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.completed")}
</span>
<div className="flex flex-row items-center gap-sm">
<CheckCircle className="w-4 h-4 text-icon-success" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_completed_tasks}
</span>
</div>
</div>
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent
size="md"
className="max-h-[80vh] flex flex-col h-full"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<DialogHeader
title={t("layout.project-settings")}
subtitle={t("layout.manage-project-details")}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
/>
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.ongoing")}
</span>
<div className="flex flex-row items-center gap-sm">
<LoaderCircle className="w-4 h-4 text-icon-information" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_ongoing_tasks}
</span>
</div>
</div>
</div>
<DialogContentSection
className="flex flex-col overflow-y-auto scrollbar"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
{/* Project Name Section - Inline Edit with Auto-Save */}
<div
className="flex flex-col gap-sm mb-lg"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<label className="text-text-label font-bold text-label-sm">
{t("layout.project-name")}
</label>
<div
className="flex items-center gap-sm"
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Input
disabled={true}
value={projectName}
onChange={(e) => setProjectName(e.target.value)}
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
onFocus={(e) => e.stopPropagation()}
placeholder={t("layout.enter-project-name")}
/>
{isSaving ? (
<Loader2 className="w-4 h-4 text-icon-action animate-spin flex-shrink-0" />
) : (
<></>
)}
</div>
</div>
{/* Tasks List */}
<div className="flex flex-col h-full gap-sm overflow-y-auto scrollbar mt-4">
<div className="flex flex-col h-full gap-sm overflow-y-auto scrollbar">
{project.tasks.length > 0 ? (
project.tasks.map((task, index) => (
<TaskItem
key={task.id}
task={task}
isActive={activeTaskId === task.id.toString()}
onSelect={() => onTaskSelect(project.project_id, task.question, task.id.toString())}
onDelete={() => onTaskDelete(task.id.toString())}
onShare={() => onTaskShare(task.id.toString())}
isLast={index === project.tasks.length - 1}
/>
))
) : (
<div className="
{/* Project Stats */}
<div className="grid grid-cols-4 gap-lg border-solid border-t-0 border-x-0 border-border-disabled pb-md">
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.total-tokens")}
</span>
<div className="flex flex-row items-center gap-sm">
<Hash className="w-4 h-4 text-icon-primary" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_tokens.toLocaleString()}
</span>
</div>
</div>
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.total-tasks")}
</span>
<div className="flex flex-row items-center gap-sm">
<Pin className="w-4 h-4 text-icon-primary" />
<span className="text-text-heading text-body-lg font-bold">
{project.task_count}
</span>
</div>
</div>
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.completed")}
</span>
<div className="flex flex-row items-center gap-sm">
<CheckCircle className="w-4 h-4 text-icon-success" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_completed_tasks}
</span>
</div>
</div>
<div className="flex flex-col gap-xs">
<span className="text-text-label text-label-sm font-normal">
{t("layout.ongoing")}
</span>
<div className="flex flex-row items-center gap-sm">
<LoaderCircle className="w-4 h-4 text-icon-information" />
<span className="text-text-heading text-body-lg font-bold">
{project.total_ongoing_tasks}
</span>
</div>
</div>
</div>
{/* Tasks List */}
<div className="flex flex-col h-full gap-sm overflow-y-auto scrollbar mt-4">
<div className="flex flex-col h-full gap-sm overflow-y-auto scrollbar">
{project.tasks.length > 0 ? (
project.tasks.map((task, index) => (
<TaskItem
key={task.id}
task={task}
isActive={activeTaskId === task.id.toString()}
onSelect={() =>
onTaskSelect(
project.project_id,
task.question,
task.id.toString()
)
}
onDelete={() => onTaskDelete(task.id.toString())}
onShare={() => onTaskShare(task.id.toString())}
isLast={index === project.tasks.length - 1}
showActions={false}
/>
))
) : (
<div
className="
text-center py-lg
text-text-label text-sm
">
<Clock className="w-8 h-8 mx-auto mb-sm text-icon-secondary opacity-50" />
{t("layout.no-tasks-in-project")}
</div>
)}
</div>
</div>
</DialogContentSection>
"
>
<Clock className="w-8 h-8 mx-auto mb-sm text-icon-secondary opacity-50" />
{t("layout.no-tasks-in-project")}
</div>
)}
</div>
</div>
</DialogContentSection>
<DialogFooter
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
}}
>
{t("layout.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
<DialogFooter
onClick={(e) => e.stopPropagation()}
onMouseDown={(e) => e.stopPropagation()}
>
<Button
variant="ghost"
size="sm"
onClick={(e) => {
e.stopPropagation();
onOpenChange(false);
}}
>
{t("layout.close")}
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}

View file

@ -23,6 +23,7 @@ interface TaskItemProps {
isOngoing?: boolean;
onPause?: () => void;
onResume?: () => void;
showActions?: boolean;
}
export default function TaskItem({
@ -34,7 +35,8 @@ export default function TaskItem({
isLast,
isOngoing = false,
onPause,
onResume
onResume,
showActions = true
}: TaskItemProps) {
const { t } = useTranslation();
@ -146,23 +148,40 @@ export default function TaskItem({
</Tag>
)}
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="rounded-full"
{showActions && (
<Popover>
<PopoverTrigger asChild>
<Button
size="icon"
onClick={(e) => e.stopPropagation()}
variant="ghost"
className="rounded-full"
>
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[98px] p-sm rounded-[12px] bg-dropdown-bg border border-solid border-dropdown-border"
>
<Ellipsis />
</Button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-[98px] p-sm rounded-[12px] bg-dropdown-bg border border-solid border-dropdown-border"
>
<div className="space-y-1">
{!isOngoing && (
<div className="space-y-1">
{!isOngoing && (
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={(e) => {
e.stopPropagation();
onShare();
}}
>
<Share size={14} />
{t("layout.share")}
</Button>
</PopoverClose>
)}
<PopoverClose asChild>
<Button
variant="ghost"
@ -170,35 +189,20 @@ export default function TaskItem({
className="w-full justify-start"
onClick={(e) => {
e.stopPropagation();
onShare();
onDelete();
}}
>
<Share size={14} />
{t("layout.share")}
<Trash2
size={14}
className="text-icon-primary group-hover:text-icon-cuation"
/>
{t("layout.delete")}
</Button>
</PopoverClose>
)}
<PopoverClose asChild>
<Button
variant="ghost"
size="sm"
className="w-full justify-start"
onClick={(e) => {
e.stopPropagation();
onDelete();
}}
>
<Trash2
size={14}
className="text-icon-primary group-hover:text-icon-cuation"
/>
{t("layout.delete")}
</Button>
</PopoverClose>
</div>
</PopoverContent>
</Popover>
</div>
</PopoverContent>
</Popover>
)}
</div>
</div>
);

View file

@ -33,7 +33,7 @@ export default function ConfirmModal({
initial={{ opacity: 0 }}
animate={{ opacity: 1 }}
exit={{ opacity: 0 }}
className="fixed inset-0 bg-white/5 backdrop-blur-sm z-100 alert-dialog"
className="fixed inset-0 bg-white/5 z-100 alert-dialog"
onClick={onClose}
/>
@ -42,7 +42,7 @@ export default function ConfirmModal({
initial={{ opacity: 0, scale: 0.9, y: 20 }}
animate={{ opacity: 1, scale: 1, y: 0 }}
exit={{ opacity: 0, scale: 0.9, y: 20 }}
className="fixed max-w-md alert-dialog-wrapper rounded-xl backdrop-blur-xl shadow-perfect"
className="fixed max-w-md alert-dialog-wrapper rounded-xl shadow-perfect"
>
<div className="p-6">
<span className="text-body-lg font-bold text-text-primary mb-2">