Files
i-tools/app/pomodoro/page.tsx
yfan 3d175d75af
Some checks failed
Build and Push Docker Image / build (push) Has been cancelled
Sync to CNB / sync (push) Has been cancelled
Delete old workflow runs / del_runs (push) Has been cancelled
Upstream Sync / Sync latest commits from upstream repo (push) Has been cancelled
first commit
2026-01-30 16:57:44 +08:00

163 lines
6.1 KiB
TypeScript
Raw Permalink Blame History

This file contains ambiguous Unicode characters
This file contains Unicode characters that might be confused with other characters. If you think that this is intentional, you can safely ignore this warning. Use the Escape button to reveal them.
"use client";
import { useState, useEffect, useRef } from "react";
import { Timer, Play, Pause, RotateCcw, Coffee, Zap } from "lucide-react";
import { Button } from "@/components/ui/button";
import { Card } from "@/components/ui/card";
import { cn } from "@/lib/utils";
import { toast } from "sonner";
type Mode = "WORK" | "SHORT_BREAK" | "LONG_BREAK";
const CONFIG = {
WORK: { time: 25 * 60, label: "工作", color: "text-rose-500", bg: "bg-rose-500" },
SHORT_BREAK: { time: 5 * 60, label: "短休", color: "text-emerald-500", bg: "bg-emerald-500" },
LONG_BREAK: { time: 15 * 60, label: "长休", color: "text-blue-500", bg: "bg-blue-500" }
};
export default function PomodoroPage() {
const [mode, setMode] = useState<Mode>("WORK");
const [timeLeft, setTimeLeft] = useState(CONFIG.WORK.time);
const [isActive, setIsActive] = useState(false);
const timerRef = useRef<any>(null);
useEffect(() => {
if (isActive && timeLeft > 0) {
timerRef.current = setInterval(() => {
setTimeLeft((prev) => prev - 1);
}, 1000);
} else if (timeLeft === 0) {
setIsActive(false);
handleFinished();
} else {
clearInterval(timerRef.current);
}
return () => clearInterval(timerRef.current);
}, [isActive, timeLeft]);
const handleFinished = () => {
const icon = mode === "WORK" ? <Coffee className="text-emerald-500" /> : <Zap className="text-rose-500" />;
const msg = mode === "WORK" ? "工作结束,休息一下吧!" : "休息结束,开始专注吧!";
toast.success(msg, { icon, duration: 5000 });
// Play sound
try {
const audioCtx = new (window.AudioContext || (window as any).webkitAudioContext)();
const oscillator = audioCtx.createOscillator();
oscillator.type = 'sine';
oscillator.frequency.setValueAtTime(523.25, audioCtx.currentTime); // C5
oscillator.connect(audioCtx.destination);
oscillator.start();
oscillator.stop(audioCtx.currentTime + 0.5);
} catch {}
};
const toggleTimer = () => setIsActive(!isActive);
const resetTimer = () => {
setIsActive(false);
setTimeLeft(CONFIG[mode].time);
};
const changeMode = (newMode: Mode) => {
setMode(newMode);
setIsActive(false);
setTimeLeft(CONFIG[newMode].time);
};
const formatTime = (seconds: number) => {
const mins = Math.floor(seconds / 60);
const secs = seconds % 60;
return `${mins.toString().padStart(2, '0')}:${secs.toString().padStart(2, '0')}`;
};
const progress = timeLeft / CONFIG[mode].time;
return (
<div className="space-y-6 animate-in fade-in slide-in-from-bottom-4 duration-500">
<div className="flex items-center space-x-4 border-b pb-4">
<div className="flex h-12 w-12 items-center justify-center rounded-xl bg-linear-to-br from-rose-500 to-orange-600 shadow-lg">
<Timer className="h-6 w-6 text-white" />
</div>
<div>
<h1 className="text-2xl font-bold tracking-tight"></h1>
<p className="text-muted-foreground"> (Pomodoro Technique)</p>
</div>
</div>
<div className="max-w-md mx-auto space-y-8 pt-8">
<div className="flex justify-center gap-2 p-1 bg-muted rounded-full">
<ModeButton active={mode === "WORK"} label="工作" onClick={() => changeMode("WORK")} />
<ModeButton active={mode === "SHORT_BREAK"} label="短休" onClick={() => changeMode("SHORT_BREAK")} />
<ModeButton active={mode === "LONG_BREAK"} label="长休" onClick={() => changeMode("LONG_BREAK")} />
</div>
<Card className="p-8 flex flex-col items-center space-y-10 border-none shadow-2xl bg-card/50 backdrop-blur">
<div className="relative flex items-center justify-center">
<svg className="w-72 h-72 transform -rotate-90">
<circle
cx="144" cy="144" r="136"
stroke="currentColor" strokeWidth="4"
fill="transparent" className="text-muted/20"
/>
<circle
cx="144" cy="144" r="136"
stroke="currentColor" strokeWidth="8"
fill="transparent"
strokeDasharray={2 * Math.PI * 136}
strokeDashoffset={2 * Math.PI * 136 * (1 - progress)}
strokeLinecap="round"
className={cn("transition-all duration-1000 ease-linear", CONFIG[mode].color)}
/>
</svg>
<div className="absolute flex flex-col items-center">
<span className="text-7xl font-mono font-black tabular-nums tracking-tighter">
{formatTime(timeLeft)}
</span>
<span className={cn("text-xs font-bold uppercase tracking-widest mt-2", CONFIG[mode].color)}>
{CONFIG[mode].label}ING
</span>
</div>
</div>
<div className="flex items-center gap-6">
<Button
size="lg"
variant="ghost"
className="h-14 w-14 rounded-full"
onClick={resetTimer}
>
<RotateCcw className="h-6 w-6 text-muted-foreground" />
</Button>
<Button
size="lg"
className={cn("h-20 w-20 rounded-full shadow-xl hover:scale-105 active:scale-95 transition-all p-0", CONFIG[mode].bg, "text-white")}
onClick={toggleTimer}
>
{isActive ? <Pause className="h-10 w-10 fill-current" /> : <Play className="h-10 w-10 fill-current ml-1" />}
</Button>
<div className="h-14 w-14" /> {/* Spacer */}
</div>
</Card>
</div>
</div>
);
}
function ModeButton({ active, label, onClick }: { active: boolean, label: string, onClick: () => void }) {
return (
<button
onClick={onClick}
className={cn(
"px-6 py-2 rounded-full text-sm font-bold transition-all flex-1",
active ? "bg-background text-foreground shadow-sm" : "text-muted-foreground hover:text-foreground"
)}
>
{label}
</button>
);
}