feat: add Dunk It button animation with CSS transitions (#427)

Add a CSS-only Dunk It button to task cards with a 4-phase state
machine (idle, success, error, dismissing). Uses inline CSS
transitions for animations with no external dependencies.

Supersedes #373
This commit is contained in:
nyk 2026-03-17 13:52:34 +07:00 committed by GitHub
parent 2f2531f3d1
commit d0cd8701c7
No known key found for this signature in database
GPG Key ID: B5690EEEBB952194
11 changed files with 74 additions and 10 deletions

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "ملاحظات المراجعة...",
"submit": "إرسال",
"loadingTranscript": "جارٍ تحميل النص...",
"noSessionMessages": "لا توجد رسائل جلسة"
"noSessionMessages": "لا توجد رسائل جلسة",
"dunkIt": "أنجزها"
},
"cronManagement": {
"title": "إدارة Cron",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "Prüfnotizen...",
"submit": "Einreichen",
"loadingTranscript": "Transkript laden...",
"noSessionMessages": "Keine Sitzungsnachrichten"
"noSessionMessages": "Keine Sitzungsnachrichten",
"dunkIt": "Erledigen"
},
"cronManagement": {
"title": "Cron-Verwaltung",

View File

@ -819,7 +819,8 @@
"reviewNotesPlaceholder": "Review notes...",
"submit": "Submit",
"loadingTranscript": "Loading transcript...",
"noSessionMessages": "No session messages"
"noSessionMessages": "No session messages",
"dunkIt": "Dunk It"
},
"cronManagement": {
"title": "Cron Management",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "Notas de revisión...",
"submit": "Enviar",
"loadingTranscript": "Cargando transcripción...",
"noSessionMessages": "Sin mensajes de sesión"
"noSessionMessages": "Sin mensajes de sesión",
"dunkIt": "Completar"
},
"cronManagement": {
"title": "Gestión de cron",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "Notes de révision...",
"submit": "Soumettre",
"loadingTranscript": "Chargement de la transcription...",
"noSessionMessages": "Aucun message de session"
"noSessionMessages": "Aucun message de session",
"dunkIt": "Terminer"
},
"cronManagement": {
"title": "Gestion des crons",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "レビューノート...",
"submit": "送信",
"loadingTranscript": "トランスクリプトを読み込み中...",
"noSessionMessages": "セッションメッセージなし"
"noSessionMessages": "セッションメッセージなし",
"dunkIt": "完了"
},
"cronManagement": {
"title": "Cron管理",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "검토 메모...",
"submit": "제출",
"loadingTranscript": "기록 로딩 중...",
"noSessionMessages": "세션 메시지 없음"
"noSessionMessages": "세션 메시지 없음",
"dunkIt": "완료"
},
"cronManagement": {
"title": "크론 관리",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "Notas de revisão...",
"submit": "Enviar",
"loadingTranscript": "Carregando transcrição...",
"noSessionMessages": "Sem mensagens de sessão"
"noSessionMessages": "Sem mensagens de sessão",
"dunkIt": "Concluir"
},
"cronManagement": {
"title": "Gerenciamento de cron",

View File

@ -670,7 +670,8 @@
"reviewNotesPlaceholder": "Заметки проверки...",
"submit": "Отправить",
"loadingTranscript": "Загрузка транскрипта...",
"noSessionMessages": "Нет сообщений сессии"
"noSessionMessages": "Нет сообщений сессии",
"dunkIt": "Завершить"
},
"cronManagement": {
"title": "Управление cron",

View File

@ -908,7 +908,8 @@
"reviewNotesPlaceholder": "审查备注...",
"submit": "提交",
"loadingTranscript": "加载记录中...",
"noSessionMessages": "无会话消息"
"noSessionMessages": "无会话消息",
"dunkIt": "完成"
},
"cronManagement": {
"title": "定时任务管理",

View File

@ -310,6 +310,57 @@ function MentionTextarea({
)
}
type DunkPhase = 'idle' | 'success' | 'error' | 'dismissing'
function DunkItButton({ taskId, onDunked }: { taskId: number; onDunked: (id: number) => void }) {
const t = useTranslations('taskBoard')
const [phase, setPhase] = useState<DunkPhase>('idle')
const handleClick = async (e: React.MouseEvent) => {
e.stopPropagation()
if (phase !== 'idle') return
try {
const res = await fetch(`/api/tasks/${taskId}`, {
method: 'PUT',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ status: 'done' }),
})
if (!res.ok) throw new Error('Failed')
setPhase('success')
setTimeout(() => {
setPhase('dismissing')
setTimeout(() => onDunked(taskId), 400)
}, 600)
} catch {
setPhase('error')
setTimeout(() => setPhase('idle'), 1500)
}
}
return (
<button
onClick={handleClick}
disabled={phase !== 'idle' && phase !== 'error'}
title={t('dunkIt')}
style={{
padding: '2px 8px',
fontSize: '11px',
borderRadius: '4px',
border: '1px solid',
cursor: phase === 'idle' ? 'pointer' : 'default',
transition: 'all 0.3s ease',
transform: phase === 'success' ? 'scale(1.15)' : phase === 'dismissing' ? 'scale(0.8) translateY(-10px)' : 'scale(1)',
opacity: phase === 'dismissing' ? 0 : 1,
borderColor: phase === 'success' ? 'rgb(34 197 94 / 0.5)' : phase === 'error' ? 'rgb(239 68 68 / 0.5)' : 'hsl(var(--border))',
backgroundColor: phase === 'success' ? 'rgb(34 197 94 / 0.15)' : phase === 'error' ? 'rgb(239 68 68 / 0.15)' : 'transparent',
color: phase === 'success' ? 'rgb(34 197 94)' : phase === 'error' ? 'rgb(239 68 68)' : 'inherit',
}}
>
{phase === 'success' ? '!' : phase === 'error' ? '!!' : phase === 'dismissing' ? '!' : 'Dunk'}
</button>
)
}
interface SpawnFormData {
task: string
model: string
@ -990,6 +1041,9 @@ export function TaskBoardPanel() {
)}
</span>
<div className="flex items-center gap-1.5 shrink-0">
{task.status !== 'done' && (
<DunkItButton taskId={task.id} onDunked={() => fetchData()} />
)}
<span className={`text-[10px] px-1.5 py-0.5 rounded font-medium ${
task.priority === 'critical' ? 'bg-red-500/20 text-red-400' :
task.priority === 'high' ? 'bg-orange-500/20 text-orange-400' :