Skip to content
Closed
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
129 changes: 82 additions & 47 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -25,16 +25,16 @@ export default function App() {
const progress = clamp(cd.remainingMs / total, 0, 1);
return {
ringProgress: progress,
waterLevel: progress,
waterLevel: 1 - progress,
waterVariant: "countdown",
title: "倒數",
subtitle: `總長 ${formatHMS(cd.totalMs)}`,
title: `倒數時長 ${formatHMS(cd.totalMs).slice(0, 5)}`,
subtitle: "",
flashKey: null,
};
}

// Stopwatch: make it feel calm by looping visuals each minute
const loop = 60 * 1000;
const loop = 10 * 1000;
const phase = (sw.elapsedMs % loop) / loop;
return {
ringProgress: phase,
Expand Down Expand Up @@ -69,7 +69,11 @@ export default function App() {
};

const onPickPreset = (ms) => {
cd.setTotalWhileStopped(ms);
cd.addTotalWhileStopped(ms);
};

const onClearPreset = () => {
cd.clearTotalWhileStopped();
};

return (
Expand All @@ -87,51 +91,82 @@ export default function App() {
}} />
</header>

<main className="main">
<section className="card breathe" aria-label="Timer">
<div className="vizWrap">
<Ring progress={viz.ringProgress} />
{mode === "stopwatch" && viz.flashKey !== null ? (
<div className="ringFlash" key={viz.flashKey} />
) : null}
<WaterGauge level={viz.waterLevel} variant={viz.waterVariant} />
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay primary={primaryTime} secondary={viz.subtitle} />
</div>
<main className={`main mode-${mode}`}>
{mode === "stopwatch" ? (
<div className="stopwatchGrid">
<section className="card breathe timerCard" aria-label="Stopwatch">
<div className="vizWrap">
<Ring progress={viz.ringProgress} size={300} />
<WaterGauge level={viz.waterLevel} variant={viz.waterVariant} size={260} />
{mode === "stopwatch" && viz.flashKey !== null ? (
<div className="ringFlash" key={viz.flashKey} />
) : null}
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay primary={primaryTime} secondary={viz.subtitle} />
</div>
</div>

<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={
<button
className="btn btnLap"
onClick={onLap}
disabled={!sw.isRunning}
>
記錄
</button>
}
/>
</section>

<LapList laps={laps} />
</div>
) : (
<div className="stopwatchGrid">
<section className="card breathe timerCard countdownCard" aria-label="Countdown">
<div className="vizWrap">
<Ring progress={viz.ringProgress} size={300} />
<WaterGauge level={viz.waterLevel} variant={viz.waterVariant} size={260} />
<div className="vizOverlay">
<div className="modeTitle">{viz.title}</div>
<TimeDisplay primary={primaryTime} secondary={viz.subtitle} />
</div>
</div>

{mode === "countdown" ? (
<div className="countdownTools">
<CountdownPresets disabled={cd.isRunning} onPick={onPickPreset} />
<div className="hint">
小提示:使用快速鍵,依點擊次數,初始時間可累加,或按 "清除" 重新設定。
<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={null}
/>

{mode === "countdown" && cd.remainingMs === 0 ? (
<div className="done" role="status">
完成 ✨
</div>
) : null}
</section>

<section className="card sideCard" aria-label="Countdown presets">
<div className="countdownTools">
<CountdownPresets
disabled={cd.isRunning}
onPick={onPickPreset}
onClear={onClearPreset}
/>
<div className="hint">
按多次可累加時間,或按「清除」重新設定。
</div>
</div>
</div>
) : null}

<Controls
isRunning={active.isRunning}
onStart={active.start}
onPause={active.pause}
onReset={onReset}
extraLeft={
mode === "stopwatch" ? (
<button className="btn" onClick={onLap} disabled={!sw.isRunning}>
記錄
</button>
) : null
}
/>

{mode === "stopwatch" ? <LapList laps={laps} /> : null}

{mode === "countdown" && cd.remainingMs === 0 ? (
<div className="done" role="status">
完成 ✨
</div>
) : null}
</section>
</section>
</div>
)}
</main>

<footer className="footer">
Expand Down
10 changes: 9 additions & 1 deletion src/components/CountdownPresets.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -8,7 +8,7 @@ const PRESETS = [
{ label: "25 分", ms: 25 * 60 * 1000 },
];

export default function CountdownPresets({ disabled, onPick }) {
export default function CountdownPresets({ disabled, onPick, onClear }) {
return (
<div className="presets">
{PRESETS.map((p) => (
Expand All @@ -21,6 +21,14 @@ export default function CountdownPresets({ disabled, onPick }) {
{p.label}
</button>
))}
<button
className="chip chipClear"
onClick={onClear}
disabled={disabled}
aria-label="清除預設時間"
>
清除
</button>
</div>
);
}
29 changes: 17 additions & 12 deletions src/components/LapList.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -2,18 +2,23 @@ import React from "react";
import { formatHMS } from "../lib/time.js";

export default function LapList({ laps }) {
if (!laps.length) return null;
return (
<div className="laps">
<div className="lapsTitle">記錄</div>
<ol className="lapsList">
{laps.map((ms, idx) => (
<li key={idx} className="lapItem">
<span className="lapIndex">{String(idx + 1).padStart(2, "0")}</span>
<span className="lapTime">{formatHMS(ms, { showMs: true })}</span>
</li>
))}
</ol>
</div>
<section className="card lapCard" aria-label="Laps">
<div className="laps">
<div className="lapsTitle">記錄</div>
{laps.length ? (
<ol className="lapsList">
{laps.map((ms, idx) => (
<li key={idx} className="lapItem">
<span className="lapIndex">{String(idx + 1).padStart(2, "0")}</span>
<span className="lapTime">{formatHMS(ms, { showMs: true })}</span>
</li>
))}
</ol>
) : (
<div className="lapsEmpty">尚無紀錄</div>
)}
</div>
</section>
);
}
19 changes: 15 additions & 4 deletions src/components/WaterGauge.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -6,10 +6,14 @@ import { clamp } from "../lib/time.js";
* or static dark plate for stopwatch.
* level: 0..1 (1 = full) for animated mode
*/
export default function WaterGauge({ level = 0.5, variant = "countdown" }) {
export default function WaterGauge({ level = 0.5, variant = "countdown", size = 220 }) {
if (variant === "stopwatch") {
return (
<div className="waterGauge waterGaugeStatic" aria-hidden="true">
<div
className="waterGauge waterGaugeStatic"
aria-hidden="true"
style={{ width: size, height: size }}
>
<div className="waterPlate" />
</div>
);
Expand All @@ -19,9 +23,16 @@ export default function WaterGauge({ level = 0.5, variant = "countdown" }) {
const y = `${(1 - l) * 100}%`;

return (
<div className="waterGauge" aria-hidden="true">
<div
className={`waterGauge ${variant === "countdown" ? "waterGaugeCountdown" : ""}`}
aria-hidden="true"
style={{ width: size, height: size }}
>
<div className="waterSurface" style={{ top: y }} />
<div className="waterFill" style={{ transform: `translateY(${(1 - l) * 100}%)` }}>
<div
className="waterFill"
style={{ "--water-shift": `${(1 - l) * 100}%` }}
>
<div className="wave wave1" />
<div className="wave wave2" />
</div>
Expand Down
17 changes: 17 additions & 0 deletions src/lib/hooks.js
Original file line number Diff line number Diff line change
Expand Up @@ -114,6 +114,21 @@ export function useCountdown(initialTotalMs = 5 * 60 * 1000) {
setRemainingMs(clamped);
};

const addTotalWhileStopped = (ms) => {
if (isRunning) return;
const next = Math.max(0, totalMs + ms);
setTotalMs(next);
baseRemainingRef.current = next;
setRemainingMs(next);
};

const clearTotalWhileStopped = () => {
if (isRunning) return;
setTotalMs(0);
baseRemainingRef.current = 0;
setRemainingMs(0);
};

useRafTicker(isRunning, (t) => {
const spent = t - startRef.current;
const next = Math.max(0, baseRemainingRef.current - spent);
Expand All @@ -133,5 +148,7 @@ export function useCountdown(initialTotalMs = 5 * 60 * 1000) {
pause,
reset,
setTotalWhileStopped,
addTotalWhileStopped,
clearTotalWhileStopped,
};
}
Loading