feat: [US-015] - Rename project
Add rename functionality to project cards on the dashboard: - Edit/rename icon button on each project card (visible on hover) - Modal dialog with project name input field - Supabase update for project name - Real-time UI update without page reload - Success toast notification after rename - Enter key support for quick rename - Error handling and display Co-Authored-By: Claude Opus 4.5 <noreply@anthropic.com>
This commit is contained in:
parent
9e03a2b9b3
commit
4d3f288784
|
|
@ -9,6 +9,7 @@ interface ProjectCardProps {
|
|||
name: string
|
||||
updatedAt: string
|
||||
onDelete: (id: string) => void
|
||||
onRename: (id: string, newName: string) => void
|
||||
}
|
||||
|
||||
function formatDate(dateString: string): string {
|
||||
|
|
@ -27,9 +28,14 @@ export default function ProjectCard({
|
|||
name,
|
||||
updatedAt,
|
||||
onDelete,
|
||||
onRename,
|
||||
}: ProjectCardProps) {
|
||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false)
|
||||
const [isDeleting, setIsDeleting] = useState(false)
|
||||
const [showRenameDialog, setShowRenameDialog] = useState(false)
|
||||
const [isRenaming, setIsRenaming] = useState(false)
|
||||
const [newName, setNewName] = useState(name)
|
||||
const [renameError, setRenameError] = useState<string | null>(null)
|
||||
const router = useRouter()
|
||||
|
||||
const handleCardClick = () => {
|
||||
|
|
@ -65,31 +71,97 @@ export default function ProjectCard({
|
|||
}
|
||||
}
|
||||
|
||||
const handleRenameClick = (e: React.MouseEvent) => {
|
||||
e.stopPropagation()
|
||||
setNewName(name)
|
||||
setRenameError(null)
|
||||
setShowRenameDialog(true)
|
||||
}
|
||||
|
||||
const handleConfirmRename = async () => {
|
||||
if (!newName.trim()) {
|
||||
setRenameError('Project name cannot be empty')
|
||||
return
|
||||
}
|
||||
|
||||
if (newName.trim() === name) {
|
||||
setShowRenameDialog(false)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(true)
|
||||
setRenameError(null)
|
||||
|
||||
const supabase = createClient()
|
||||
const { error } = await supabase
|
||||
.from('projects')
|
||||
.update({ name: newName.trim() })
|
||||
.eq('id', id)
|
||||
|
||||
if (error) {
|
||||
setIsRenaming(false)
|
||||
setRenameError('Failed to rename project: ' + error.message)
|
||||
return
|
||||
}
|
||||
|
||||
setIsRenaming(false)
|
||||
setShowRenameDialog(false)
|
||||
onRename(id, newName.trim())
|
||||
}
|
||||
|
||||
const handleCancelRename = () => {
|
||||
if (!isRenaming) {
|
||||
setShowRenameDialog(false)
|
||||
setRenameError(null)
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
onClick={handleCardClick}
|
||||
className="group relative cursor-pointer rounded-lg border border-zinc-200 bg-white p-6 transition-all hover:border-blue-300 hover:shadow-md dark:border-zinc-700 dark:bg-zinc-800 dark:hover:border-blue-600"
|
||||
>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="absolute right-3 top-3 rounded p-1 text-zinc-400 opacity-0 transition-opacity hover:bg-red-100 hover:text-red-600 group-hover:opacity-100 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
<div className="absolute right-3 top-3 flex gap-1 opacity-0 transition-opacity group-hover:opacity-100">
|
||||
<button
|
||||
onClick={handleRenameClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-blue-100 hover:text-blue-600 dark:hover:bg-blue-900/30 dark:hover:text-blue-400"
|
||||
title="Rename project"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
<button
|
||||
onClick={handleDeleteClick}
|
||||
className="rounded p-1 text-zinc-400 hover:bg-red-100 hover:text-red-600 dark:hover:bg-red-900/30 dark:hover:text-red-400"
|
||||
title="Delete project"
|
||||
>
|
||||
<svg
|
||||
className="h-5 w-5"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M19 7l-.867 12.142A2 2 0 0116.138 21H7.862a2 2 0 01-1.995-1.858L5 7m5 4v6m4-6v6m1-10V4a1 1 0 00-1-1h-4a1 1 0 00-1 1v3M4 7h16"
|
||||
/>
|
||||
</svg>
|
||||
</button>
|
||||
</div>
|
||||
<h2 className="pr-8 text-lg font-semibold text-zinc-900 group-hover:text-blue-600 dark:text-zinc-50 dark:group-hover:text-blue-400">
|
||||
{name}
|
||||
</h2>
|
||||
|
|
@ -155,6 +227,87 @@ export default function ProjectCard({
|
|||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{showRenameDialog && (
|
||||
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
||||
<div
|
||||
className="absolute inset-0 bg-black/50"
|
||||
onClick={handleCancelRename}
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<div className="relative w-full max-w-md rounded-lg bg-white p-6 shadow-xl dark:bg-zinc-800">
|
||||
<div className="flex items-start gap-4">
|
||||
<div className="flex h-10 w-10 shrink-0 items-center justify-center rounded-full bg-blue-100 dark:bg-blue-900/30">
|
||||
<svg
|
||||
className="h-5 w-5 text-blue-600 dark:text-blue-400"
|
||||
fill="none"
|
||||
viewBox="0 0 24 24"
|
||||
stroke="currentColor"
|
||||
>
|
||||
<path
|
||||
strokeLinecap="round"
|
||||
strokeLinejoin="round"
|
||||
strokeWidth={2}
|
||||
d="M11 5H6a2 2 0 00-2 2v11a2 2 0 002 2h11a2 2 0 002-2v-5m-1.414-9.414a2 2 0 112.828 2.828L11.828 15H9v-2.828l8.586-8.586z"
|
||||
/>
|
||||
</svg>
|
||||
</div>
|
||||
<div className="flex-1">
|
||||
<h3 className="text-lg font-semibold text-zinc-900 dark:text-zinc-50">
|
||||
Rename Project
|
||||
</h3>
|
||||
<div className="mt-4">
|
||||
<label
|
||||
htmlFor="project-name"
|
||||
className="block text-sm font-medium text-zinc-700 dark:text-zinc-300"
|
||||
>
|
||||
Project Name
|
||||
</label>
|
||||
<input
|
||||
type="text"
|
||||
id="project-name"
|
||||
value={newName}
|
||||
onChange={(e) => setNewName(e.target.value)}
|
||||
onKeyDown={(e) => {
|
||||
if (e.key === 'Enter' && !isRenaming) {
|
||||
handleConfirmRename()
|
||||
}
|
||||
}}
|
||||
autoFocus
|
||||
disabled={isRenaming}
|
||||
className="mt-1 block w-full rounded-md border border-zinc-300 bg-white px-3 py-2 text-zinc-900 placeholder-zinc-400 shadow-sm focus:border-blue-500 focus:outline-none focus:ring-1 focus:ring-blue-500 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-50 dark:placeholder-zinc-500 dark:focus:border-blue-400 dark:focus:ring-blue-400"
|
||||
placeholder="Enter project name"
|
||||
/>
|
||||
{renameError && (
|
||||
<p className="mt-2 text-sm text-red-600 dark:text-red-400">
|
||||
{renameError}
|
||||
</p>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="mt-6 flex justify-end gap-3">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleCancelRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md border border-zinc-300 bg-white px-4 py-2 text-sm font-medium text-zinc-700 transition-colors hover:bg-zinc-50 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:border-zinc-600 dark:bg-zinc-700 dark:text-zinc-300 dark:hover:bg-zinc-600 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
Cancel
|
||||
</button>
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleConfirmRename}
|
||||
disabled={isRenaming}
|
||||
className="rounded-md bg-blue-600 px-4 py-2 text-sm font-medium text-white transition-colors hover:bg-blue-700 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:ring-offset-2 disabled:cursor-not-allowed disabled:opacity-50 dark:focus:ring-offset-zinc-800"
|
||||
>
|
||||
{isRenaming ? 'Renaming...' : 'Rename'}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
|
|
@ -26,6 +26,13 @@ export default function ProjectList({ initialProjects }: ProjectListProps) {
|
|||
setToast({ message: 'Project deleted successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleRename = useCallback((id: string, newName: string) => {
|
||||
setProjects((prev) =>
|
||||
prev.map((p) => (p.id === id ? { ...p, name: newName } : p))
|
||||
)
|
||||
setToast({ message: 'Project renamed successfully', type: 'success' })
|
||||
}, [])
|
||||
|
||||
const handleCloseToast = useCallback(() => {
|
||||
setToast(null)
|
||||
}, [])
|
||||
|
|
@ -67,6 +74,7 @@ export default function ProjectList({ initialProjects }: ProjectListProps) {
|
|||
name={project.name}
|
||||
updatedAt={project.updated_at}
|
||||
onDelete={handleDelete}
|
||||
onRename={handleRename}
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
|
|
|
|||
Loading…
Reference in New Issue