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
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>
)}
</>
)
}

View File

@ -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>