feat(web): add focus mode UI and NL parser integration
- FocusView component: duration presets, 'until next timer' mode, pomodoro shortcut, full-screen countdown ring, pause/resume, session summary with stats - Focus page at /focus with minimal header and back navigation - NL parser integrated into CreateTimerModal with live parse preview, Enter-to-create, and type/label/urgency detection display - Dashboard header: added Focus Mode (Eye icon) link
This commit is contained in:
parent
8fe5e8e787
commit
065bb1b1a0
50
web/src/app/focus/page.tsx
Normal file
50
web/src/app/focus/page.tsx
Normal file
@ -0,0 +1,50 @@
|
||||
'use client';
|
||||
|
||||
import { useRouter } from 'next/navigation';
|
||||
import { FocusView } from '@/components/FocusView';
|
||||
import { useTickLoop } from '@/lib/use-tick';
|
||||
import { Clock, ArrowLeft } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
|
||||
export default function FocusPage() {
|
||||
const router = useRouter();
|
||||
|
||||
// Keep the store ticking so "until next timer" works
|
||||
useTickLoop();
|
||||
|
||||
return (
|
||||
<div className="min-h-screen" style={{ backgroundColor: 'var(--cm-bg-canvas)' }}>
|
||||
{/* Minimal 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">
|
||||
<Link
|
||||
href="/"
|
||||
className="flex items-center gap-2 text-sm font-medium transition-colors"
|
||||
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||
>
|
||||
<ArrowLeft size={16} />
|
||||
Back
|
||||
</Link>
|
||||
<div className="flex items-center gap-2">
|
||||
<Clock size={20} style={{ color: 'var(--cm-accent)' }} />
|
||||
<span className="text-sm font-semibold" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
ChronoMind Focus
|
||||
</span>
|
||||
</div>
|
||||
<div className="w-16" /> {/* Spacer for centering */}
|
||||
</div>
|
||||
</header>
|
||||
|
||||
{/* Focus content */}
|
||||
<main className="max-w-3xl mx-auto">
|
||||
<FocusView onExit={() => router.push('/')} />
|
||||
</main>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
@ -6,7 +6,9 @@ import { URGENCY_ORDER, getUrgencyConfig } from '@/lib/urgency';
|
||||
import type { UrgencyLevel } from '@/lib/urgency';
|
||||
import { CASCADE_PRESET_LABELS } from '@/lib/cascade';
|
||||
import type { CascadePreset } from '@/lib/cascade';
|
||||
import { X, AlarmClock, Timer, Coffee } from 'lucide-react';
|
||||
import { X, AlarmClock, Timer, Coffee, Sparkles } from 'lucide-react';
|
||||
import { parseNaturalLanguage } from '@/lib/nl-parser';
|
||||
import type { ParseResult } from '@/lib/nl-parser';
|
||||
|
||||
type TabType = 'alarm' | 'countdown' | 'pomodoro';
|
||||
|
||||
@ -19,6 +21,8 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
const { addAlarm, addCountdown, addPomodoro } = useTimerStore();
|
||||
|
||||
const [tab, setTab] = useState<TabType>('countdown');
|
||||
const [nlInput, setNlInput] = useState('');
|
||||
const [nlResult, setNlResult] = useState<ParseResult | null>(null);
|
||||
const [label, setLabel] = useState('');
|
||||
const [urgency, setUrgency] = useState<UrgencyLevel>('standard');
|
||||
const [cascadePreset, setCascadePreset] = useState<CascadePreset>('standard');
|
||||
@ -39,6 +43,45 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
|
||||
if (!isOpen) return null;
|
||||
|
||||
const handleNlChange = (value: string) => {
|
||||
setNlInput(value);
|
||||
if (value.trim()) {
|
||||
setNlResult(parseNaturalLanguage(value));
|
||||
} else {
|
||||
setNlResult(null);
|
||||
}
|
||||
};
|
||||
|
||||
const handleNlCreate = () => {
|
||||
if (!nlResult?.success || !nlResult.timer) return;
|
||||
const t = nlResult.timer;
|
||||
if (t.type === 'countdown' && t.durationMs) {
|
||||
addCountdown({
|
||||
label: t.label,
|
||||
durationMs: t.durationMs,
|
||||
urgency: t.urgency,
|
||||
cascade: { preset: t.cascade, intervals: [] },
|
||||
});
|
||||
} else if (t.type === 'alarm' && t.targetTime) {
|
||||
addAlarm({
|
||||
label: t.label,
|
||||
targetTime: t.targetTime,
|
||||
urgency: t.urgency,
|
||||
cascade: { preset: t.cascade, intervals: [] },
|
||||
});
|
||||
} else if (t.type === 'pomodoro') {
|
||||
addPomodoro({
|
||||
label: t.label,
|
||||
config: { workMinutes: 25, breakMinutes: 5, longBreakMinutes: 15, rounds: t.pomodoroRounds ?? 4 },
|
||||
urgency: t.urgency,
|
||||
});
|
||||
}
|
||||
setNlInput('');
|
||||
setNlResult(null);
|
||||
setLabel('');
|
||||
onClose();
|
||||
};
|
||||
|
||||
const handleCreate = () => {
|
||||
const cascade = { preset: cascadePreset, intervals: [] as number[] };
|
||||
|
||||
@ -120,6 +163,54 @@ export function CreateTimerModal({ isOpen, onClose }: CreateTimerModalProps) {
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Natural Language Input */}
|
||||
<div className="p-4 border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
||||
<div className="flex items-center gap-2 mb-1">
|
||||
<Sparkles size={14} style={{ color: 'var(--cm-accent-secondary)' }} />
|
||||
<label className="text-xs font-medium" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Quick create — type naturally
|
||||
</label>
|
||||
</div>
|
||||
<div className="flex gap-2">
|
||||
<input
|
||||
type="text"
|
||||
value={nlInput}
|
||||
onChange={(e) => handleNlChange(e.target.value)}
|
||||
onKeyDown={(e) => { if (e.key === 'Enter' && nlResult?.success) handleNlCreate(); }}
|
||||
placeholder='e.g. "meeting in 30 min" or "alarm at 3pm"'
|
||||
className="flex-1 px-3 py-2 rounded-lg border text-sm focus:outline-none focus:ring-2"
|
||||
style={{
|
||||
backgroundColor: 'var(--cm-surface-card)',
|
||||
borderColor: nlResult?.success ? 'var(--cm-accent-secondary)' : 'var(--cm-border)',
|
||||
color: 'var(--cm-text-primary)',
|
||||
}}
|
||||
/>
|
||||
{nlResult?.success && (
|
||||
<button
|
||||
onClick={handleNlCreate}
|
||||
className="px-4 py-2 rounded-lg text-xs font-medium transition-colors cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-accent-secondary)', color: '#000' }}
|
||||
>
|
||||
Create
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
{nlResult && nlInput.trim() && (
|
||||
<div className="mt-2 text-xs" style={{ color: nlResult.success ? 'var(--cm-accent-secondary)' : 'var(--cm-text-tertiary)' }}>
|
||||
{nlResult.success && nlResult.timer ? (
|
||||
<span>
|
||||
{nlResult.timer.type === 'pomodoro' ? 'Pomodoro' : nlResult.timer.type === 'alarm' ? 'Alarm' : 'Countdown'}
|
||||
{' — '}{nlResult.timer.label}
|
||||
{nlResult.timer.durationMs ? ` (${Math.round(nlResult.timer.durationMs / 60000)}m)` : ''}
|
||||
{nlResult.timer.urgency !== 'standard' ? ` [${nlResult.timer.urgency}]` : ''}
|
||||
</span>
|
||||
) : (
|
||||
<span style={{ color: 'var(--cm-text-tertiary)' }}>{nlResult.error}</span>
|
||||
)}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Tabs */}
|
||||
<div className="flex border-b" style={{ borderColor: 'var(--cm-border)' }}>
|
||||
{tabs.map((t) => (
|
||||
|
||||
@ -11,7 +11,7 @@ import { CreateTimerModal } from './CreateTimerModal';
|
||||
import { AlarmOverlay } from './AlarmOverlay';
|
||||
import { requestNotificationPermission } from '@/lib/notifications';
|
||||
import { formatTime, formatDate } from '@/lib/format';
|
||||
import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings } from 'lucide-react';
|
||||
import { Plus, Clock, Bell, Keyboard, Sun, Moon, Settings, Eye } from 'lucide-react';
|
||||
import Link from 'next/link';
|
||||
import { FeedbackButton } from './FeedbackButton';
|
||||
import { useTheme } from '@/lib/use-theme';
|
||||
@ -135,6 +135,14 @@ export function Dashboard() {
|
||||
>
|
||||
{theme === 'dark' ? <Sun size={18} /> : <Moon size={18} />}
|
||||
</button>
|
||||
<Link
|
||||
href="/focus"
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
style={{ color: 'var(--cm-text-tertiary)' }}
|
||||
title="Focus Mode"
|
||||
>
|
||||
<Eye size={18} />
|
||||
</Link>
|
||||
<Link
|
||||
href="/settings"
|
||||
className="p-2 rounded-lg transition-colors"
|
||||
|
||||
377
web/src/components/FocusView.tsx
Normal file
377
web/src/components/FocusView.tsx
Normal file
@ -0,0 +1,377 @@
|
||||
'use client';
|
||||
|
||||
import { useState, useEffect, useCallback, useRef } from 'react';
|
||||
import { CountdownRing } from './CountdownRing';
|
||||
import { formatDuration, formatDurationCompact } from '@/lib/format';
|
||||
import { useTimerStore } from '@/lib/store';
|
||||
import {
|
||||
Eye,
|
||||
Pause,
|
||||
Play,
|
||||
X,
|
||||
Trophy,
|
||||
Clock,
|
||||
Shield,
|
||||
Zap,
|
||||
} from 'lucide-react';
|
||||
|
||||
// ── Types ──────────────────────────────────────────────────────
|
||||
|
||||
export type FocusMode = 'duration' | 'until_next' | 'pomodoro';
|
||||
|
||||
export interface FocusSession {
|
||||
mode: FocusMode;
|
||||
durationMs: number; // total duration in ms (0 if 'until_next')
|
||||
startedAt: number;
|
||||
pausedAt: number | null;
|
||||
elapsedBeforePause: number;
|
||||
isActive: boolean;
|
||||
isPaused: boolean;
|
||||
isCompleted: boolean;
|
||||
blockedCount: number; // notifications blocked during session
|
||||
}
|
||||
|
||||
// ── Duration Presets ───────────────────────────────────────────
|
||||
|
||||
const FOCUS_PRESETS = [
|
||||
{ label: '15m', ms: 15 * 60_000 },
|
||||
{ label: '25m', ms: 25 * 60_000 },
|
||||
{ label: '45m', ms: 45 * 60_000 },
|
||||
{ label: '1h', ms: 60 * 60_000 },
|
||||
{ label: '90m', ms: 90 * 60_000 },
|
||||
{ label: '2h', ms: 120 * 60_000 },
|
||||
];
|
||||
|
||||
// ── Component ──────────────────────────────────────────────────
|
||||
|
||||
interface FocusViewProps {
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
export function FocusView({ onExit }: FocusViewProps) {
|
||||
const [session, setSession] = useState<FocusSession | null>(null);
|
||||
const [now, setNow] = useState(Date.now());
|
||||
const rafRef = useRef<number>(0);
|
||||
const nextTimer = useTimerStore((s) => s.getNextFiringTimer());
|
||||
|
||||
// Tick loop for countdown
|
||||
useEffect(() => {
|
||||
if (!session?.isActive) return;
|
||||
const tick = () => {
|
||||
setNow(Date.now());
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
};
|
||||
rafRef.current = requestAnimationFrame(tick);
|
||||
return () => cancelAnimationFrame(rafRef.current);
|
||||
}, [session?.isActive]);
|
||||
|
||||
const startFocus = useCallback((mode: FocusMode, durationMs: number) => {
|
||||
setSession({
|
||||
mode,
|
||||
durationMs,
|
||||
startedAt: Date.now(),
|
||||
pausedAt: null,
|
||||
elapsedBeforePause: 0,
|
||||
isActive: true,
|
||||
isPaused: false,
|
||||
isCompleted: false,
|
||||
blockedCount: 0,
|
||||
});
|
||||
}, []);
|
||||
|
||||
const pauseFocus = useCallback(() => {
|
||||
setSession((prev) => {
|
||||
if (!prev || !prev.isActive || prev.isPaused) return prev;
|
||||
const elapsed = prev.elapsedBeforePause + (Date.now() - prev.startedAt);
|
||||
return { ...prev, isPaused: true, pausedAt: Date.now(), elapsedBeforePause: elapsed };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const resumeFocus = useCallback(() => {
|
||||
setSession((prev) => {
|
||||
if (!prev || !prev.isPaused) return prev;
|
||||
return { ...prev, isPaused: false, pausedAt: null, startedAt: Date.now() };
|
||||
});
|
||||
}, []);
|
||||
|
||||
const endFocus = useCallback(() => {
|
||||
setSession((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, isActive: false, isCompleted: true };
|
||||
});
|
||||
}, []);
|
||||
|
||||
// Auto-complete when duration is up
|
||||
useEffect(() => {
|
||||
if (!session?.isActive || session.isPaused || session.mode === 'until_next') return;
|
||||
const elapsed = session.elapsedBeforePause + (now - session.startedAt);
|
||||
if (elapsed >= session.durationMs) {
|
||||
endFocus();
|
||||
}
|
||||
}, [now, session, endFocus]);
|
||||
|
||||
// Auto-complete "until next timer" when timer fires
|
||||
useEffect(() => {
|
||||
if (!session?.isActive || session.mode !== 'until_next') return;
|
||||
if (!nextTimer) return;
|
||||
if (now >= nextTimer.targetTime) {
|
||||
endFocus();
|
||||
}
|
||||
}, [now, session, nextTimer, endFocus]);
|
||||
|
||||
// ── Not started: show setup ──────────────────────────────────
|
||||
if (!session) {
|
||||
const untilNextMs = nextTimer ? Math.max(0, nextTimer.targetTime - Date.now()) : 0;
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex flex-col items-center justify-center px-4">
|
||||
<div className="text-center max-w-md w-full">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
style={{ backgroundColor: 'rgba(90, 140, 255, 0.12)' }}
|
||||
>
|
||||
<Eye size={40} style={{ color: 'var(--cm-accent)' }} />
|
||||
</div>
|
||||
<h2
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ color: 'var(--cm-text-primary)' }}
|
||||
>
|
||||
Focus Mode
|
||||
</h2>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Minimize distractions and stay in the zone. Only critical alerts will break through.
|
||||
</p>
|
||||
|
||||
{/* Duration presets */}
|
||||
<div className="mb-6">
|
||||
<p className="text-xs font-semibold uppercase tracking-wider mb-3" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Choose duration
|
||||
</p>
|
||||
<div className="grid grid-cols-3 gap-2">
|
||||
{FOCUS_PRESETS.map((preset) => (
|
||||
<button
|
||||
key={preset.label}
|
||||
onClick={() => startFocus('duration', preset.ms)}
|
||||
className="py-3 rounded-xl text-sm font-medium transition-all cursor-pointer hover:scale-105"
|
||||
style={{
|
||||
backgroundColor: 'var(--cm-surface-card)',
|
||||
color: 'var(--cm-text-primary)',
|
||||
border: '1px solid var(--cm-border)',
|
||||
}}
|
||||
>
|
||||
{preset.label}
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Until next timer */}
|
||||
{nextTimer && untilNextMs > 0 && (
|
||||
<button
|
||||
onClick={() => startFocus('until_next', untilNextMs)}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium transition-all cursor-pointer mb-4"
|
||||
style={{
|
||||
backgroundColor: 'rgba(46, 230, 214, 0.1)',
|
||||
color: 'var(--cm-accent-secondary)',
|
||||
border: '1px solid rgba(46, 230, 214, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Clock size={16} />
|
||||
Until next timer ({formatDurationCompact(untilNextMs)})
|
||||
</div>
|
||||
<div className="text-xs mt-1 opacity-70">
|
||||
{nextTimer.label}
|
||||
</div>
|
||||
</button>
|
||||
)}
|
||||
|
||||
{/* Pomodoro shortcut */}
|
||||
<button
|
||||
onClick={() => startFocus('pomodoro', 25 * 60_000)}
|
||||
className="w-full py-3 rounded-xl text-sm font-medium transition-all cursor-pointer"
|
||||
style={{
|
||||
backgroundColor: 'rgba(90, 140, 255, 0.1)',
|
||||
color: 'var(--cm-accent)',
|
||||
border: '1px solid rgba(90, 140, 255, 0.3)',
|
||||
}}
|
||||
>
|
||||
<div className="flex items-center justify-center gap-2">
|
||||
<Zap size={16} />
|
||||
Pomodoro Focus (25m work)
|
||||
</div>
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Completed: show summary ──────────────────────────────────
|
||||
if (session.isCompleted) {
|
||||
const totalElapsed = session.elapsedBeforePause + (session.isPaused ? 0 : (now - session.startedAt));
|
||||
const focusMinutes = Math.round(totalElapsed / 60_000);
|
||||
|
||||
return (
|
||||
<div className="min-h-[80vh] flex flex-col items-center justify-center px-4">
|
||||
<div className="text-center max-w-md w-full">
|
||||
<div
|
||||
className="w-20 h-20 rounded-full flex items-center justify-center mx-auto mb-6"
|
||||
style={{ backgroundColor: 'rgba(52, 211, 153, 0.12)' }}
|
||||
>
|
||||
<Trophy size={40} style={{ color: 'var(--cm-success)' }} />
|
||||
</div>
|
||||
<h2
|
||||
className="text-2xl font-bold mb-2"
|
||||
style={{ color: 'var(--cm-success)' }}
|
||||
>
|
||||
Focus Complete!
|
||||
</h2>
|
||||
<p className="text-sm mb-8" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Great work staying focused.
|
||||
</p>
|
||||
|
||||
{/* Stats */}
|
||||
<div
|
||||
className="rounded-2xl border p-6 mb-6"
|
||||
style={{ backgroundColor: 'var(--cm-surface-card)', borderColor: 'var(--cm-border)' }}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<div>
|
||||
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
{focusMinutes}m
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Time focused
|
||||
</p>
|
||||
</div>
|
||||
<div>
|
||||
<p className="text-2xl font-bold font-mono" style={{ color: 'var(--cm-text-primary)' }}>
|
||||
{session.blockedCount}
|
||||
</p>
|
||||
<p className="text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Distractions blocked
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
<div className="mt-4 pt-4 border-t" style={{ borderColor: 'var(--cm-border)' }}>
|
||||
<div className="flex items-center justify-center gap-2 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
<Shield size={14} />
|
||||
<span>
|
||||
{session.mode === 'pomodoro' ? 'Pomodoro' :
|
||||
session.mode === 'until_next' ? 'Until next timer' :
|
||||
formatDurationCompact(session.durationMs)} focus session
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="flex gap-3 justify-center">
|
||||
<button
|
||||
onClick={() => setSession(null)}
|
||||
className="px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||
>
|
||||
Start Another
|
||||
</button>
|
||||
{onExit && (
|
||||
<button
|
||||
onClick={onExit}
|
||||
className="px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||
>
|
||||
Back to Dashboard
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
// ── Active session: full-screen focus ────────────────────────
|
||||
const elapsed = session.isPaused
|
||||
? session.elapsedBeforePause
|
||||
: session.elapsedBeforePause + (now - session.startedAt);
|
||||
|
||||
const remaining = session.mode === 'until_next'
|
||||
? (nextTimer ? Math.max(0, nextTimer.targetTime - now) : 0)
|
||||
: Math.max(0, session.durationMs - elapsed);
|
||||
|
||||
const total = session.mode === 'until_next'
|
||||
? (nextTimer ? nextTimer.targetTime - session.startedAt : 1)
|
||||
: session.durationMs;
|
||||
|
||||
const progress = total > 0 ? Math.max(0, remaining / total) : 0;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="min-h-[80vh] flex flex-col items-center justify-center px-4 relative"
|
||||
>
|
||||
{/* Shield badge */}
|
||||
<div className="absolute top-6 left-1/2 -translate-x-1/2 flex items-center gap-2 px-4 py-2 rounded-full"
|
||||
style={{ backgroundColor: 'rgba(90, 140, 255, 0.08)', border: '1px solid rgba(90, 140, 255, 0.2)' }}
|
||||
>
|
||||
<Shield size={14} style={{ color: 'var(--cm-accent)' }} />
|
||||
<span className="text-xs font-medium" style={{ color: 'var(--cm-accent)' }}>
|
||||
Focus Active — Only critical alerts
|
||||
</span>
|
||||
</div>
|
||||
|
||||
<div className="text-center">
|
||||
{/* Countdown ring */}
|
||||
<div className="flex justify-center mb-6">
|
||||
<CountdownRing progress={progress} size={260} strokeWidth={12} color="var(--cm-accent)">
|
||||
<div className="text-center">
|
||||
<div
|
||||
className="text-5xl font-mono font-bold tabular-nums"
|
||||
style={{ color: 'var(--cm-text-primary)' }}
|
||||
>
|
||||
{formatDuration(remaining)}
|
||||
</div>
|
||||
<div className="text-sm mt-2" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
{session.isPaused ? 'Paused' :
|
||||
session.mode === 'until_next' ? `Until ${nextTimer?.label ?? 'next timer'}` :
|
||||
session.mode === 'pomodoro' ? 'Pomodoro Focus' :
|
||||
'Focus Time'}
|
||||
</div>
|
||||
</div>
|
||||
</CountdownRing>
|
||||
</div>
|
||||
|
||||
{/* Controls */}
|
||||
<div className="flex justify-center gap-3">
|
||||
{session.isPaused ? (
|
||||
<button
|
||||
onClick={resumeFocus}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-accent)', color: '#fff' }}
|
||||
>
|
||||
<Play size={16} /> Resume
|
||||
</button>
|
||||
) : (
|
||||
<button
|
||||
onClick={pauseFocus}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'var(--cm-surface-muted)', color: 'var(--cm-text-secondary)' }}
|
||||
>
|
||||
<Pause size={16} /> Pause
|
||||
</button>
|
||||
)}
|
||||
|
||||
<button
|
||||
onClick={endFocus}
|
||||
className="flex items-center gap-2 px-6 py-3 rounded-xl text-sm font-medium cursor-pointer"
|
||||
style={{ backgroundColor: 'rgba(255,71,87,0.15)', color: 'var(--cm-danger)' }}
|
||||
>
|
||||
<X size={16} /> End Session
|
||||
</button>
|
||||
</div>
|
||||
|
||||
{/* Elapsed time */}
|
||||
<div className="mt-6 text-xs" style={{ color: 'var(--cm-text-tertiary)' }}>
|
||||
Focused for {formatDurationCompact(elapsed)}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Loading…
Reference in New Issue
Block a user