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:
@@ -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>
|
||||||
</>
|
</>
|
||||||
)
|
)
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user