feat(ui): redesign detail panel — frosted glass, spring animation, refined hierarchy

Co-Authored-By: Claude Opus 4.6 <noreply@anthropic.com>
This commit is contained in:
2026-02-27 00:07:28 -05:00
parent 5ec4ca9a62
commit 420ba50517

View File

@@ -1,4 +1,5 @@
import { useState, useEffect, useRef, useCallback } from 'react' import { useState, useEffect, useRef, useCallback } from 'react'
import { motion } from 'framer-motion'
import { X, Loader2 } from 'lucide-react' import { X, Loader2 } from 'lucide-react'
import { WorkTaskStatus } from '../types/index.ts' import { WorkTaskStatus } from '../types/index.ts'
import { import {
@@ -40,9 +41,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
const completeTask = useCompleteTask() const completeTask = useCompleteTask()
const abandonTask = useAbandonTask() const abandonTask = useAbandonTask()
// Slide-in animation state
const [visible, setVisible] = useState(false)
// Inline editing states // Inline editing states
const [editingTitle, setEditingTitle] = useState(false) const [editingTitle, setEditingTitle] = useState(false)
const [titleValue, setTitleValue] = useState('') const [titleValue, setTitleValue] = useState('')
@@ -58,11 +56,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
const categoryInputRef = useRef<HTMLInputElement>(null) const categoryInputRef = useRef<HTMLInputElement>(null)
const estimateInputRef = useRef<HTMLInputElement>(null) const estimateInputRef = useRef<HTMLInputElement>(null)
// Trigger slide-in
useEffect(() => {
requestAnimationFrame(() => setVisible(true))
}, [])
// Escape key handler // Escape key handler
const handleEscape = useCallback( const handleEscape = useCallback(
(e: KeyboardEvent) => { (e: KeyboardEvent) => {
@@ -75,10 +68,10 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
setEditingEstimate(false) setEditingEstimate(false)
return return
} }
handleClose() onClose()
} }
}, },
[editingTitle, editingDesc, editingCategory, editingEstimate] // eslint-disable-line react-hooks/exhaustive-deps [editingTitle, editingDesc, editingCategory, editingEstimate, onClose]
) )
useEffect(() => { useEffect(() => {
@@ -100,11 +93,6 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (editingEstimate) estimateInputRef.current?.focus() if (editingEstimate) estimateInputRef.current?.focus()
}, [editingEstimate]) }, [editingEstimate])
function handleClose() {
setVisible(false)
setTimeout(onClose, 200) // wait for slide-out animation
}
// --- Save handlers --- // --- Save handlers ---
function saveTitle() { function saveTitle() {
if (task && titleValue.trim() && titleValue.trim() !== task.title) { if (task && titleValue.trim() && titleValue.trim() !== task.title) {
@@ -153,22 +141,25 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
return ( return (
<> <>
{/* Overlay */} {/* Overlay */}
<div <motion.div
className={`fixed inset-0 z-40 transition-opacity duration-200 ${ className="fixed inset-0 z-40 bg-black/50 backdrop-blur-sm"
visible ? 'bg-black/50' : 'bg-black/0' initial={{ opacity: 0 }}
}`} animate={{ opacity: 1 }}
onClick={handleClose} exit={{ opacity: 0 }}
onClick={onClose}
/> />
{/* Panel */} {/* Panel */}
<div <motion.div
className={`fixed top-0 right-0 h-full w-[400px] z-50 bg-[#1a1d27] shadow-2xl flex flex-col transition-transform duration-200 ease-out ${ className="fixed top-0 right-0 h-full w-[480px] z-50 bg-[var(--color-elevated)]/95 backdrop-blur-xl border-l border-[var(--color-border)] shadow-2xl flex flex-col"
visible ? 'translate-x-0' : 'translate-x-full' initial={{ x: '100%' }}
}`} animate={{ x: 0 }}
exit={{ x: '100%' }}
transition={{ type: 'spring', damping: 30, stiffness: 300 }}
> >
{isLoading || !task ? ( {isLoading || !task ? (
<div className="flex items-center justify-center h-full"> <div className="flex items-center justify-center h-full">
<Loader2 className="animate-spin text-[#64748b]" size={32} /> <Loader2 className="animate-spin text-[var(--color-text-secondary)]" size={32} />
</div> </div>
) : ( ) : (
<> <>
@@ -176,47 +167,47 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<div className="flex-1 overflow-y-auto"> <div className="flex-1 overflow-y-auto">
{/* Header */} {/* Header */}
<div className="p-5 pb-4"> <div className="p-5 pb-4">
{/* Close button */} {/* Title row with close button inline */}
<div className="flex justify-end mb-3"> <div className="flex items-start gap-3">
<div className="flex-1 min-w-0">
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[var(--color-page)] text-xl font-semibold text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none"
/>
) : (
<h2
className="text-xl font-semibold text-[var(--color-text-primary)] cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
</div>
<button <button
onClick={handleClose} onClick={onClose}
className="p-1 rounded hover:bg-white/10 text-[#64748b] hover:text-white transition-colors" className="p-1.5 rounded-lg hover:bg-white/10 text-[var(--color-text-secondary)] hover:text-[var(--color-text-primary)] transition-colors shrink-0 mt-0.5"
> >
<X size={18} /> <X size={18} />
</button> </button>
</div> </div>
{/* Title */}
{editingTitle ? (
<input
ref={titleInputRef}
type="text"
value={titleValue}
onChange={(e) => setTitleValue(e.target.value)}
onBlur={saveTitle}
onKeyDown={(e) => {
if (e.key === 'Enter') saveTitle()
if (e.key === 'Escape') setEditingTitle(false)
}}
className="w-full bg-[#0f1117] text-lg font-semibold text-white px-3 py-2 rounded border border-indigo-500 outline-none"
/>
) : (
<h2
className="text-lg font-semibold text-white cursor-pointer hover:text-indigo-300 transition-colors"
onClick={() => {
setTitleValue(task.title)
setEditingTitle(true)
}}
>
{task.title}
</h2>
)}
{/* Status badge + Category */} {/* Status badge + Category */}
<div className="flex items-center gap-2 mt-3"> <div className="flex items-center gap-2 mt-3">
{statusConfig && ( {statusConfig && (
<span <span
className="text-[11px] font-semibold uppercase px-2.5 py-1 rounded-full" className="text-[10px] px-2.5 py-1 rounded-full"
style={{ style={{
backgroundColor: statusConfig.color + '20', backgroundColor: statusConfig.color + '20',
color: statusConfig.color, color: statusConfig.color,
@@ -238,11 +229,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (e.key === 'Escape') setEditingCategory(false) if (e.key === 'Escape') setEditingCategory(false)
}} }}
placeholder="Category..." placeholder="Category..."
className="bg-[#0f1117] text-xs text-white px-2 py-1 rounded border border-indigo-500 outline-none w-28" className="bg-[var(--color-page)] text-xs text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none w-28"
/> />
) : ( ) : (
<span <span
className="text-[11px] text-[#64748b] cursor-pointer hover:text-white transition-colors px-2.5 py-1 rounded-full bg-white/5" className="text-[11px] text-[var(--color-text-secondary)] cursor-pointer hover:text-[var(--color-text-primary)] transition-colors px-2.5 py-1 rounded-full bg-white/5"
onClick={() => { onClick={() => {
setCategoryValue(task.category ?? '') setCategoryValue(task.category ?? '')
setEditingCategory(true) setEditingCategory(true)
@@ -254,11 +245,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
</div> </div>
</div> </div>
<div className="border-t border-[#2a2d37]" /> <div className="border-t border-[var(--color-border)]" />
{/* Description */} {/* Description */}
<div className="p-5"> <div className="p-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-2"> <h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-2">
Description Description
</h3> </h3>
{editingDesc ? ( {editingDesc ? (
@@ -274,13 +265,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
} }
}} }}
rows={4} rows={4}
className="w-full bg-[#0f1117] text-sm text-white px-3 py-2 rounded border border-indigo-500 outline-none resize-none" className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-3 py-2 rounded border border-[var(--color-accent)] outline-none resize-none"
placeholder="Add a description..." placeholder="Add a description..."
/> />
) : ( ) : (
<p <p
className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${ className={`text-sm cursor-pointer rounded px-3 py-2 hover:bg-white/5 transition-colors ${
task.description ? 'text-[#c4c9d4]' : 'text-[#64748b] italic' task.description ? 'text-[var(--color-text-primary)]' : 'text-[var(--color-text-secondary)] italic'
}`} }`}
onClick={() => { onClick={() => {
setDescValue(task.description ?? '') setDescValue(task.description ?? '')
@@ -292,23 +283,23 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)} )}
</div> </div>
<div className="border-t border-[#2a2d37]" /> <div className="border-t border-[var(--color-border)]" />
{/* Time */} {/* Time */}
<div className="p-5"> <div className="p-5">
<h3 className="text-xs font-semibold uppercase tracking-wider text-[#64748b] mb-3"> <h3 className="text-[11px] font-medium text-[var(--color-text-secondary)] mb-3">
Time Time
</h3> </h3>
<div className="grid grid-cols-2 gap-4 mb-3"> <div className="grid grid-cols-2 gap-4 mb-3">
<div> <div>
<span className="text-[11px] text-[#64748b] block mb-1">Elapsed</span> <span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Elapsed</span>
<span className="text-sm text-white font-medium"> <span className="text-sm text-[var(--color-text-primary)] font-medium">
{task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'} {task.startedAt ? formatElapsed(task.startedAt, task.completedAt) : '--'}
</span> </span>
</div> </div>
<div> <div>
<span className="text-[11px] text-[#64748b] block mb-1">Estimate</span> <span className="text-[11px] text-[var(--color-text-secondary)] block mb-1">Estimate</span>
{editingEstimate ? ( {editingEstimate ? (
<input <input
ref={estimateInputRef} ref={estimateInputRef}
@@ -321,11 +312,11 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
if (e.key === 'Escape') setEditingEstimate(false) if (e.key === 'Escape') setEditingEstimate(false)
}} }}
placeholder="min" placeholder="min"
className="w-full bg-[#0f1117] text-sm text-white px-2 py-1 rounded border border-indigo-500 outline-none" className="w-full bg-[var(--color-page)] text-sm text-[var(--color-text-primary)] px-2 py-1 rounded border border-[var(--color-accent)] outline-none"
/> />
) : ( ) : (
<span <span
className="text-sm text-white font-medium cursor-pointer hover:text-indigo-300 transition-colors" className="text-sm text-[var(--color-text-primary)] font-medium cursor-pointer hover:text-[var(--color-accent)] transition-colors"
onClick={() => { onClick={() => {
setEstimateValue(task.estimatedMinutes?.toString() ?? '') setEstimateValue(task.estimatedMinutes?.toString() ?? '')
setEditingEstimate(true) setEditingEstimate(true)
@@ -342,7 +333,9 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<div className="h-2 w-full bg-white/5 rounded-full overflow-hidden"> <div className="h-2 w-full bg-white/5 rounded-full overflow-hidden">
<div <div
className={`h-full rounded-full transition-all duration-300 ${ className={`h-full rounded-full transition-all duration-300 ${
progressPercent >= 100 ? 'bg-rose-500' : 'bg-indigo-500' progressPercent >= 100
? 'bg-rose-500'
: 'bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)]'
}`} }`}
style={{ width: `${Math.min(progressPercent, 100)}%` }} style={{ width: `${Math.min(progressPercent, 100)}%` }}
/> />
@@ -350,14 +343,14 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)} )}
</div> </div>
<div className="border-t border-[#2a2d37]" /> <div className="border-t border-[var(--color-border)]" />
{/* Subtasks */} {/* Subtasks */}
<div className="p-5"> <div className="p-5">
<SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} /> <SubtaskList taskId={taskId} subtasks={task.subTasks ?? []} />
</div> </div>
<div className="border-t border-[#2a2d37]" /> <div className="border-t border-[var(--color-border)]" />
{/* Notes */} {/* Notes */}
<div className="p-5"> <div className="p-5">
@@ -367,13 +360,13 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
{/* Action buttons - fixed at bottom */} {/* Action buttons - fixed at bottom */}
{task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && ( {task.status !== WorkTaskStatus.Completed && task.status !== WorkTaskStatus.Abandoned && (
<div className="border-t border-[#2a2d37] p-5 space-y-2"> <div className="border-t border-[var(--color-border)] p-5 space-y-2">
{task.status === WorkTaskStatus.Pending && ( {task.status === WorkTaskStatus.Pending && (
<> <>
<button <button
onClick={() => startTask.mutate(taskId)} onClick={() => startTask.mutate(taskId)}
disabled={startTask.isPending} disabled={startTask.isPending}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50" className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
> >
Start Start
</button> </button>
@@ -418,7 +411,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
<button <button
onClick={() => resumeTask.mutate({ id: taskId })} onClick={() => resumeTask.mutate({ id: taskId })}
disabled={resumeTask.isPending} disabled={resumeTask.isPending}
className="w-full py-2.5 rounded-lg bg-indigo-600 hover:bg-indigo-500 text-white text-sm font-medium transition-colors disabled:opacity-50" className="w-full py-2.5 rounded-lg bg-gradient-to-r from-[var(--color-accent)] to-[var(--color-accent-end)] hover:brightness-110 text-white text-sm font-medium transition-all disabled:opacity-50"
> >
Resume Resume
</button> </button>
@@ -442,7 +435,7 @@ export default function TaskDetailPanel({ taskId, onClose }: TaskDetailPanelProp
)} )}
</> </>
)} )}
</div> </motion.div>
</> </>
) )
} }