228 lines
8.5 KiB
TypeScript
228 lines
8.5 KiB
TypeScript
'use client';
|
|
|
|
import { useState, useEffect, useCallback } from 'react';
|
|
import { useTimerStore } from '@/lib/store';
|
|
import { useTickLoop } from '@/lib/use-tick';
|
|
import { useKeyboardShortcuts, SHORTCUT_MAP } from '@/lib/use-keyboard-shortcuts';
|
|
import { TimerCard } from './TimerCard';
|
|
import { PomodoroView } from './PomodoroView';
|
|
import { QuickTimerBar } from './QuickTimerBar';
|
|
import { CreateTimerModal } from './CreateTimerModal';
|
|
import { AlarmOverlay } from './AlarmOverlay';
|
|
import { requestNotificationPermission } from '@/lib/notifications';
|
|
import { formatTime, formatDate } from '@/lib/format';
|
|
import { Plus, Clock, Bell, Keyboard } from 'lucide-react';
|
|
|
|
export function Dashboard() {
|
|
const [isCreateOpen, setIsCreateOpen] = useState(false);
|
|
const [showShortcuts, setShowShortcuts] = useState(false);
|
|
const [mounted, setMounted] = useState(false);
|
|
const timers = useTimerStore((s) => s.timers);
|
|
const now = useTimerStore((s) => s.now);
|
|
const { pause, resume } = useTimerStore();
|
|
|
|
// Start the tick loop
|
|
useTickLoop();
|
|
|
|
// Keyboard shortcuts
|
|
const getFirstActiveTimer = useCallback(() => {
|
|
return timers.find((t) => ['active', 'warning', 'paused'].includes(t.state));
|
|
}, [timers]);
|
|
|
|
useKeyboardShortcuts({
|
|
onNewTimer: () => setIsCreateOpen(true),
|
|
onQuickTimer: () => setIsCreateOpen(true),
|
|
onTogglePause: () => {
|
|
const t = getFirstActiveTimer();
|
|
if (!t) return;
|
|
if (t.state === 'paused') resume(t.id);
|
|
else if (t.type !== 'alarm') pause(t.id);
|
|
},
|
|
onDismiss: () => {
|
|
if (isCreateOpen) setIsCreateOpen(false);
|
|
else if (showShortcuts) setShowShortcuts(false);
|
|
},
|
|
onShowHelp: () => setShowShortcuts((p) => !p),
|
|
});
|
|
|
|
// Hydration guard
|
|
useEffect(() => {
|
|
setMounted(true);
|
|
requestNotificationPermission();
|
|
}, []);
|
|
|
|
if (!mounted) {
|
|
return (
|
|
<div
|
|
className="min-h-screen flex items-center justify-center"
|
|
style={{ backgroundColor: 'var(--cm-bg-canvas)' }}
|
|
>
|
|
<Clock size={32} className="animate-spin" style={{ color: 'var(--cm-accent)' }} />
|
|
</div>
|
|
);
|
|
}
|
|
|
|
const activeTimers = timers.filter((t) =>
|
|
['active', 'warning', 'snoozed', 'firing', 'paused'].includes(t.state)
|
|
);
|
|
const completedTimers = timers
|
|
.filter((t) => ['dismissed', 'completed'].includes(t.state))
|
|
.slice(-10)
|
|
.reverse();
|
|
|
|
// Tab title update
|
|
useEffect(() => {
|
|
const next = activeTimers
|
|
.filter((t) => ['active', 'warning'].includes(t.state))
|
|
.sort((a, b) => a.targetTime - b.targetTime)[0];
|
|
|
|
if (next) {
|
|
const remaining = Math.max(0, next.targetTime - now);
|
|
const mins = Math.floor(remaining / 60000);
|
|
const secs = Math.floor((remaining % 60000) / 1000);
|
|
document.title = `${String(mins).padStart(2, '0')}:${String(secs).padStart(2, '0')} — ${next.label} | ChronoMind`;
|
|
} else {
|
|
document.title = 'ChronoMind — Smart Pre-Warning Timer';
|
|
}
|
|
}, [now, activeTimers]);
|
|
|
|
return (
|
|
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
|
{/* Alarm overlay for firing timers */}
|
|
<AlarmOverlay />
|
|
|
|
{/* Header */}
|
|
<header
|
|
className="sticky top-0 z-40 border-b backdrop-blur-md"
|
|
style={{
|
|
backgroundColor: 'rgba(6, 7, 10, 0.85)',
|
|
borderColor: 'var(--cm-border)',
|
|
}}
|
|
>
|
|
<div className="max-w-3xl mx-auto px-4 py-3 flex items-center justify-between">
|
|
<div className="flex items-center gap-3">
|
|
<Clock size={24} style={{ color: 'var(--cm-accent)' }} />
|
|
<h1 className="text-lg font-bold" style={{ color: 'var(--cm-text-primary)' }}>
|
|
ChronoMind
|
|
</h1>
|
|
</div>
|
|
<div className="flex items-center gap-3">
|
|
<span className="text-sm font-mono" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
{formatTime(now)} · {formatDate(now)}
|
|
</span>
|
|
<button
|
|
onClick={() => setShowShortcuts((p) => !p)}
|
|
className="p-2 rounded-lg transition-colors cursor-pointer"
|
|
style={{ color: 'var(--cm-text-tertiary)' }}
|
|
title="Keyboard shortcuts (?)"
|
|
>
|
|
<Keyboard size={18} />
|
|
</button>
|
|
<button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="flex items-center gap-2 px-4 py-2 rounded-xl text-sm font-medium transition-all cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
|
>
|
|
<Plus size={16} /> New Timer
|
|
</button>
|
|
</div>
|
|
</div>
|
|
</header>
|
|
|
|
{/* Keyboard shortcuts overlay */}
|
|
{showShortcuts && (
|
|
<div className="fixed inset-0 z-50 flex items-center justify-center">
|
|
<div className="absolute inset-0 bg-black/60" onClick={() => setShowShortcuts(false)} />
|
|
<div
|
|
className="relative rounded-2xl border p-6 max-w-sm w-full mx-4"
|
|
style={{ backgroundColor: 'var(--cm-bg-elevated)', borderColor: 'var(--cm-border)' }}
|
|
>
|
|
<h3 className="text-lg font-semibold mb-4" style={{ color: 'var(--cm-text-primary)' }}>
|
|
Keyboard Shortcuts
|
|
</h3>
|
|
<div className="space-y-2">
|
|
{SHORTCUT_MAP.map((s) => (
|
|
<div key={s.key} className="flex items-center justify-between">
|
|
<span className="text-sm" style={{ color: 'var(--cm-text-secondary)' }}>{s.description}</span>
|
|
<kbd
|
|
className="px-2 py-1 rounded text-xs font-mono"
|
|
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-primary)' }}
|
|
>
|
|
{s.key}
|
|
</kbd>
|
|
</div>
|
|
))}
|
|
</div>
|
|
</div>
|
|
</div>
|
|
)}
|
|
|
|
{/* Main content */}
|
|
<main className="max-w-3xl mx-auto px-4 py-6">
|
|
{/* Quick timer bar */}
|
|
<div className="mb-6">
|
|
<QuickTimerBar />
|
|
</div>
|
|
|
|
{/* Active timers */}
|
|
{activeTimers.length > 0 ? (
|
|
<div className="space-y-3">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider flex items-center gap-2" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
<Bell size={14} /> Active ({activeTimers.length})
|
|
</h2>
|
|
{activeTimers
|
|
.sort((a, b) => a.targetTime - b.targetTime)
|
|
.map((timer) =>
|
|
timer.type === 'pomodoro' ? (
|
|
<PomodoroView key={timer.id} timer={timer} />
|
|
) : (
|
|
<TimerCard key={timer.id} timer={timer} />
|
|
)
|
|
)}
|
|
</div>
|
|
) : (
|
|
/* Empty state */
|
|
<div className="text-center py-20">
|
|
<Clock size={64} className="mx-auto mb-4 opacity-20" style={{ color: 'var(--cm-text-tertiary)' }} />
|
|
<h2 className="text-xl font-semibold mb-2" style={{ color: 'var(--cm-text-primary)' }}>
|
|
No active timers
|
|
</h2>
|
|
<p className="mb-6" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Create your first timer and never be caught off-guard again.
|
|
</p>
|
|
<button
|
|
onClick={() => setIsCreateOpen(true)}
|
|
className="inline-flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-semibold transition-all cursor-pointer"
|
|
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
|
>
|
|
<Plus size={18} /> Create Timer
|
|
</button>
|
|
</div>
|
|
)}
|
|
|
|
{/* Completed timers */}
|
|
{completedTimers.length > 0 && (
|
|
<div className="mt-8 space-y-3">
|
|
<h2 className="text-sm font-semibold uppercase tracking-wider" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
Recent ({completedTimers.length})
|
|
</h2>
|
|
{completedTimers.map((timer) => (
|
|
<TimerCard key={timer.id} timer={timer} />
|
|
))}
|
|
</div>
|
|
)}
|
|
</main>
|
|
|
|
{/* Create Timer Modal */}
|
|
<CreateTimerModal isOpen={isCreateOpen} onClose={() => setIsCreateOpen(false)} />
|
|
|
|
{/* Footer */}
|
|
<footer className="max-w-3xl mx-auto px-4 py-8 text-center">
|
|
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
|
ChronoMind — Press <kbd className="px-1 py-0.5 rounded text-xs font-mono" style={{ backgroundColor: 'var(--cm-surface-muted)' }}>?</kbd> for shortcuts
|
|
</p>
|
|
</footer>
|
|
</div>
|
|
);
|
|
}
|