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:
Gustavo Henrique Santos Souza de Miranda 2026-01-21 04:21:57 -03:00
parent 9e03a2b9b3
commit 4d3f288784
2 changed files with 179 additions and 18 deletions

View File

@ -9,6 +9,7 @@ interface ProjectCardProps {
name: string name: string
updatedAt: string updatedAt: string
onDelete: (id: string) => void onDelete: (id: string) => void
onRename: (id: string, newName: string) => void
} }
function formatDate(dateString: string): string { function formatDate(dateString: string): string {
@ -27,9 +28,14 @@ export default function ProjectCard({
name, name,
updatedAt, updatedAt,
onDelete, onDelete,
onRename,
}: ProjectCardProps) { }: ProjectCardProps) {
const [showDeleteDialog, setShowDeleteDialog] = useState(false) const [showDeleteDialog, setShowDeleteDialog] = useState(false)
const [isDeleting, setIsDeleting] = 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 router = useRouter()
const handleCardClick = () => { const handleCardClick = () => {
@ -65,15 +71,80 @@ 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 ( return (
<> <>
<div <div
onClick={handleCardClick} 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" 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"
> >
<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"
>
<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 <button
onClick={handleDeleteClick} 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" 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" title="Delete project"
> >
<svg <svg
@ -90,6 +161,7 @@ export default function ProjectCard({
/> />
</svg> </svg>
</button> </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"> <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} {name}
</h2> </h2>
@ -155,6 +227,87 @@ export default function ProjectCard({
</div> </div>
</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>
)}
</> </>
) )
} }

View File

@ -26,6 +26,13 @@ export default function ProjectList({ initialProjects }: ProjectListProps) {
setToast({ message: 'Project deleted successfully', type: 'success' }) 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(() => { const handleCloseToast = useCallback(() => {
setToast(null) setToast(null)
}, []) }, [])
@ -67,6 +74,7 @@ export default function ProjectList({ initialProjects }: ProjectListProps) {
name={project.name} name={project.name}
updatedAt={project.updated_at} updatedAt={project.updated_at}
onDelete={handleDelete} onDelete={handleDelete}
onRename={handleRename}
/> />
))} ))}
</div> </div>