feat: remove all tools

This commit is contained in:
MAZE
2024-08-31 00:10:58 +03:30
parent 47a63a774e
commit 2bbdc7e09e
53 changed files with 0 additions and 2064 deletions

View File

@@ -3,7 +3,6 @@ import { useEffect } from 'react';
import { useSoundStore } from '@/stores/sound';
import { useNoteStore } from '@/stores/note';
import { usePresetStore } from '@/stores/preset';
import { useTimers } from '@/stores/timers';
interface StoreConsumerProps {
children: React.ReactNode;
@@ -14,7 +13,6 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
useSoundStore.persist.rehydrate();
useNoteStore.persist.rehydrate();
usePresetStore.persist.rehydrate();
useTimers.persist.rehydrate();
}, []);
return <>{children}</>;

View File

@@ -1,54 +0,0 @@
---
import { Container } from '../container';
import { SpecialButton } from '../special-button';
interface Props {
text: string;
}
const { text } = Astro.props;
---
<div class="about">
<Container tight>
<h3 class="title">About This Tool</h3>
<p class="text">{text}</p>
<h3 class="title">About Moodist</h3>
<p class="text">
Immerse yourself in tranquility with Moodist, a simple ambient sound
generator. Create the perfect atmosphere for relaxation, focus, or sleep
by customizing soothing soundscapes to match your mood.
</p>
<div class="button">
<SpecialButton href="/" internal>Use Moodist</SpecialButton>
</div>
</Container>
</div>
<style>
.about {
margin: 75px 0;
}
.title {
margin-bottom: 8px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
&:not(:first-of-type) {
margin-top: 40px;
}
}
.text {
line-height: 1.6;
color: var(--color-foreground-subtle);
}
.button {
margin-top: 24px;
}
</style>

View File

@@ -1 +0,0 @@
/* WIP */

View File

@@ -1,10 +0,0 @@
import { Container } from '@/components/container';
import { Exercise } from './exercise';
export function BreathingExercises() {
return (
<Container tight>
<Exercise />
</Container>
);
}

View File

@@ -1,47 +0,0 @@
.exercise {
position: relative;
z-index: 1;
display: flex;
align-items: center;
justify-content: center;
padding: 75px 0;
margin-top: 12px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
& .phase {
font-family: var(--font-display);
font-size: var(--font-lg);
font-weight: 600;
}
& .circle {
position: absolute;
top: 50%;
left: 50%;
z-index: -1;
height: 55%;
aspect-ratio: 1 / 1;
background-image: radial-gradient(
var(--color-neutral-50),
var(--color-neutral-100)
);
border: 1px solid var(--color-neutral-200);
border-radius: 50%;
transform: translate(-50%, -50%);
}
}
.selectBox {
width: 100%;
min-width: 0;
height: 45px;
padding: 0 12px;
margin-top: 8px;
font-size: var(--font-sm);
color: var(--color-foreground);
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
}

View File

@@ -1,109 +0,0 @@
import { useState, useEffect, useMemo, useCallback } from 'react';
import { motion } from 'framer-motion';
import styles from './exercise.module.css';
type Exercise = 'Box Breathing' | 'Resonant Breathing' | '4-7-8 Breathing';
type Phase = 'inhale' | 'exhale' | 'holdInhale' | 'holdExhale';
const EXERCISE_PHASES: Record<Exercise, Phase[]> = {
'4-7-8 Breathing': ['inhale', 'holdInhale', 'exhale'],
'Box Breathing': ['inhale', 'holdInhale', 'exhale', 'holdExhale'],
'Resonant Breathing': ['inhale', 'exhale'],
};
const EXERCISE_DURATIONS: Record<Exercise, Partial<Record<Phase, number>>> = {
'4-7-8 Breathing': { exhale: 8, holdInhale: 7, inhale: 4 },
'Box Breathing': { exhale: 4, holdExhale: 4, holdInhale: 4, inhale: 4 },
'Resonant Breathing': { exhale: 5, inhale: 5 }, // No holdExhale
};
const PHASE_LABELS: Record<Phase, string> = {
exhale: 'Exhale',
holdExhale: 'Hold',
holdInhale: 'Hold',
inhale: 'Inhale',
};
export function Exercise() {
const [selectedExercise, setSelectedExercise] =
useState<Exercise>('4-7-8 Breathing');
const [phaseIndex, setPhaseIndex] = useState(0);
const phases = useMemo(
() => EXERCISE_PHASES[selectedExercise],
[selectedExercise],
);
const durations = useMemo(
() => EXERCISE_DURATIONS[selectedExercise],
[selectedExercise],
);
const currentPhase = phases[phaseIndex];
const animationVariants = useMemo(
() => ({
exhale: {
transform: 'translate(-50%, -50%) scale(1)',
transition: { duration: durations.exhale },
},
holdExhale: {
transform: 'translate(-50%, -50%) scale(1)',
transition: { duration: durations.holdExhale },
},
holdInhale: {
transform: 'translate(-50%, -50%) scale(1.5)',
transition: { duration: durations.holdInhale },
},
inhale: {
transform: 'translate(-50%, -50%) scale(1.5)',
transition: { duration: durations.inhale },
},
}),
[durations],
);
const resetExercise = useCallback(() => {
setPhaseIndex(0);
}, []);
const updatePhase = useCallback(() => {
setPhaseIndex(prevIndex => (prevIndex + 1) % phases.length);
}, [phases.length]);
useEffect(() => {
resetExercise();
}, [selectedExercise, resetExercise]);
useEffect(() => {
const intervalDuration = (durations[currentPhase] || 4) * 1000;
const interval = setInterval(updatePhase, intervalDuration);
return () => clearInterval(interval);
}, [currentPhase, durations, updatePhase]);
return (
<>
<div className={styles.exercise}>
<motion.div
animate={currentPhase}
className={styles.circle}
key={selectedExercise}
variants={animationVariants}
/>
<p className={styles.phase}>{PHASE_LABELS[currentPhase]}</p>
</div>
<select
className={styles.selectBox}
value={selectedExercise}
onChange={e => setSelectedExercise(e.target.value as Exercise)}
>
{Object.keys(EXERCISE_PHASES).map(exercise => (
<option key={exercise} value={exercise}>
{exercise}
</option>
))}
</select>
</>
);
}

View File

@@ -1 +0,0 @@
export { Exercise } from './exercise';

View File

@@ -1 +0,0 @@
export { BreathingExercises } from './breathing';

View File

@@ -1,34 +0,0 @@
.button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
font-size: var(--font-sm);
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
transition: 0.2s;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
&:hover,
&:focus-visible {
background-color: var(--color-neutral-200);
}
&.smallIcon {
font-size: var(--font-xsm);
}
&:disabled {
cursor: not-allowed;
opacity: 0.4;
}
}

View File

@@ -1,33 +0,0 @@
import { Tooltip } from '@/components/tooltip';
import { cn } from '@/helpers/styles';
import styles from './button.module.css';
interface ButtonProps {
disabled?: boolean;
icon: React.ReactElement;
onClick: () => void;
smallIcon?: boolean;
tooltip: string;
}
export function Button({
disabled = false,
icon,
onClick,
smallIcon,
tooltip,
}: ButtonProps) {
return (
<Tooltip content={tooltip} placement="bottom" showDelay={0}>
<button
className={cn(styles.button, smallIcon && styles.smallIcon)}
disabled={disabled}
onClick={onClick}
>
{icon}
</button>
</Tooltip>
);
}

View File

@@ -1 +0,0 @@
export { Button } from './button';

View File

@@ -1,114 +0,0 @@
---
import { Container } from '../container';
interface Props {
desc: string;
title: string;
}
const { desc, title } = Astro.props;
---
<div class="hero">
<Container>
<div class="wrapper">
<div class="pattern"></div>
<img
alt="Faded Moodist Logo"
aria-hidden="true"
class="logo"
height={45}
src="/logo.svg"
width={45}
/>
<h2 class="title"><span>{title}</span></h2>
<h1 class="desc">{desc}</h1>
<p class="moodist">Part of <a href="/">Moodist</a></p>
</div>
</Container>
</div>
<style>
.hero {
text-align: center;
.wrapper {
position: relative;
padding: 120px 0 50px;
& .pattern {
position: absolute;
top: 0;
left: 0;
z-index: -1;
width: 100%;
height: 100%;
background-image: radial-gradient(
var(--color-neutral-500) 5%,
transparent 5%
);
background-position: top center;
background-size: 21px 21px;
opacity: 0.8;
mask-image: linear-gradient(#fff, transparent, transparent);
}
}
& .logo {
display: block;
width: 45px;
margin: 0 auto 16px;
animation-name: logo;
animation-duration: 30s;
animation-timing-function: linear;
animation-iteration-count: infinite;
}
& .title {
font-family: var(--font-display);
font-size: var(--font-lg);
font-weight: 600;
line-height: 1;
& span {
background: linear-gradient(
135deg,
var(--color-foreground),
var(--color-foreground-subtle)
);
background-clip: text;
-webkit-text-fill-color: transparent;
}
}
& .desc {
margin-top: 4px;
line-height: 1.6;
color: var(--color-foreground-subtle);
}
& .moodist {
margin-top: 12px;
font-size: var(--font-sm);
color: var(--color-foreground-subtle);
& a {
font-weight: 500;
color: var(--color-foreground);
text-decoration: none;
}
}
}
@keyframes logo {
0% {
transform: rotate(0);
}
100% {
transform: rotate(360deg);
}
}
</style>

View File

@@ -1,45 +0,0 @@
.button {
display: flex;
align-items: center;
justify-content: center;
width: 30px;
height: 30px;
font-size: var(--font-sm);
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
transition: 0.2s;
transition-property: border-color, color, background-color;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
&.critical {
color: #f43f5e;
border-color: #f43f5e;
&:hover {
background-color: rgb(244 63 94 / 10%);
}
}
&.recommended {
font-size: var(--font-xsm);
color: #22c55e;
border-color: #22c55e;
&:hover {
background-color: rgb(34 197 94 / 10%);
}
}
&:hover,
&:focus-visible {
background-color: var(--color-neutral-200);
}
}

View File

@@ -1,36 +0,0 @@
import { Tooltip } from '@/components/tooltip';
import { cn } from '@/helpers/styles';
import styles from './button.module.css';
interface ButtonProps {
critical?: boolean;
icon: React.ReactElement;
onClick: () => void;
recommended?: boolean;
tooltip: string;
}
export function Button({
critical,
icon,
onClick,
recommended,
tooltip,
}: ButtonProps) {
return (
<Tooltip content={tooltip} placement="bottom" showDelay={0}>
<button
className={cn(
styles.button,
critical && styles.critical,
recommended && styles.recommended,
)}
onClick={onClick}
>
{icon}
</button>
</Tooltip>
);
}

View File

@@ -1 +0,0 @@
export { Button } from './button';

View File

@@ -1,11 +0,0 @@
import { StoreConsumer } from '@/components/store-consumer';
import { Notepad as NotepadComponent } from './notepad';
export function Notepad() {
return (
<StoreConsumer>
<NotepadComponent />
</StoreConsumer>
);
}

View File

@@ -1,44 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
& .label {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground-subtle);
}
& .buttons {
display: flex;
column-gap: 4px;
align-items: center;
}
}
.textarea {
width: 100%;
height: 350px;
padding: 12px;
line-height: 1.6;
color: var(--color-foreground-subtle);
resize: none;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
scroll-padding-bottom: 12px;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
}
.counter {
margin-top: 8px;
font-size: var(--font-xsm);
color: var(--color-foreground-subtle);
text-align: center;
}

View File

@@ -1,66 +0,0 @@
import { BiTrash } from 'react-icons/bi/index';
import { LuCopy, LuDownload } from 'react-icons/lu/index';
import { FaCheck } from 'react-icons/fa6/index';
import { FaUndo } from 'react-icons/fa/index';
import { Button } from './button';
import { useNoteStore } from '@/stores/note';
import { useCopy } from '@/hooks/use-copy';
import { download } from '@/helpers/download';
import styles from './notepad.module.css';
import { Container } from '@/components/container';
export function Notepad() {
const note = useNoteStore(state => state.note);
const history = useNoteStore(state => state.history);
const write = useNoteStore(state => state.write);
const words = useNoteStore(state => state.words());
const characters = useNoteStore(state => state.characters());
const clear = useNoteStore(state => state.clear);
const restore = useNoteStore(state => state.restore);
const { copy, copying } = useCopy();
return (
<Container>
<header className={styles.header}>
<h2 className={styles.label}>Your Note</h2>
<div className={styles.buttons}>
<Button
icon={copying ? <FaCheck /> : <LuCopy />}
tooltip="Copy Note"
onClick={() => copy(note)}
/>
<Button
icon={<LuDownload />}
tooltip="Download Note"
onClick={() => download('Moodit Note.txt', note)}
/>
<Button
critical={!history}
icon={history ? <FaUndo /> : <BiTrash />}
recommended={!!history}
tooltip={history ? 'Restore Note' : 'Clear Note'}
onClick={() => (history ? restore() : clear())}
/>
</div>
</header>
<textarea
className={styles.textarea}
dir="auto"
placeholder="What is on your mind?"
spellCheck={false}
value={note}
onChange={e => write(e.target.value)}
/>
<p className={styles.counter}>
{characters} character{characters !== 1 && 's'} {words} word
{words !== 1 && 's'}
</p>
</Container>
);
}

View File

@@ -1 +0,0 @@
export { Pomodoro } from './pomodoro';

View File

@@ -1,36 +0,0 @@
.header {
display: flex;
align-items: center;
justify-content: space-between;
margin-bottom: 8px;
& .title {
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground-subtle);
}
& .buttons {
display: flex;
column-gap: 4px;
align-items: center;
}
}
.control {
display: flex;
align-items: center;
justify-content: space-between;
margin-top: 8px;
& .completed {
font-size: var(--font-xsm);
color: var(--color-foreground-subtle);
}
& .buttons {
display: flex;
column-gap: 4px;
align-items: center;
}
}

View File

@@ -1,168 +0,0 @@
import { useState, useEffect, useRef, useMemo } from 'react';
import { FaUndo, FaPlay, FaPause } from 'react-icons/fa/index';
import { IoMdSettings } from 'react-icons/io/index';
import { Button } from '../generics/button';
import { Timer } from '@/components/timer';
import { Tabs } from './tabs';
import { Setting } from './setting';
import { useLocalStorage } from '@/hooks/use-local-storage';
import { useSoundEffect } from '@/hooks/use-sound-effect';
import { usePomodoroStore } from '@/stores/pomodoro';
import { useCloseListener } from '@/hooks/use-close-listener';
import styles from './pomodoro.module.css';
import { Container } from '@/components/container';
export function Pomodoro() {
const [showSetting, setShowSetting] = useState(false);
const [selectedTab, setSelectedTab] = useState('pomodoro');
const running = usePomodoroStore(state => state.running);
const setRunning = usePomodoroStore(state => state.setRunning);
const [timer, setTimer] = useState(0);
const interval = useRef<ReturnType<typeof setInterval> | null>(null);
const alarm = useSoundEffect('/sounds/alarm.mp3');
const defaultTimes = useMemo(
() => ({
long: 15 * 60,
pomodoro: 25 * 60,
short: 5 * 60,
}),
[],
);
const [times, setTimes] = useLocalStorage<Record<string, number>>(
'moodist-pomodoro-setting',
defaultTimes,
);
const [completions, setCompletions] = useState<Record<string, number>>({
long: 0,
pomodoro: 0,
short: 0,
});
const tabs = useMemo(
() => [
{ id: 'pomodoro', label: 'Pomodoro' },
{ id: 'short', label: 'Break' },
{ id: 'long', label: 'Long Break' },
],
[],
);
useCloseListener(() => setShowSetting(false));
useEffect(() => {
if (running) {
if (interval.current) clearInterval(interval.current);
interval.current = setInterval(() => {
setTimer(prev => prev - 1);
}, 1000);
} else {
if (interval.current) clearInterval(interval.current);
}
}, [running]);
useEffect(() => {
if (timer <= 0 && running) {
if (interval.current) clearInterval(interval.current);
alarm.play();
setRunning(false);
setCompletions(prev => ({
...prev,
[selectedTab]: prev[selectedTab] + 1,
}));
}
}, [timer, selectedTab, running, setRunning, alarm]);
useEffect(() => {
const time = times[selectedTab] || 10;
if (interval.current) clearInterval(interval.current);
setRunning(false);
setTimer(time);
}, [selectedTab, times, setRunning]);
const toggleRunning = () => {
if (running) setRunning(false);
else if (timer <= 0) {
const time = times[selectedTab] || 10;
setTimer(time);
setRunning(true);
} else setRunning(true);
};
const restart = () => {
if (interval.current) clearInterval(interval.current);
const time = times[selectedTab] || 10;
setRunning(false);
setTimer(time);
};
return (
<Container tight>
<header className={styles.header}>
<h2 className={styles.title}>Pomodoro Timer</h2>
<div className={styles.button}>
<Button
icon={<IoMdSettings />}
tooltip="Change Times"
onClick={() => {
setShowSetting(true);
}}
/>
</div>
</header>
<Tabs selectedTab={selectedTab} tabs={tabs} onSelect={setSelectedTab} />
<Timer tall timer={timer} />
<div className={styles.control}>
<p className={styles.completed}>
{completions[selectedTab] || 0} completed
</p>
<div className={styles.buttons}>
<Button
icon={<FaUndo />}
smallIcon
tooltip="Restart"
onClick={restart}
/>
<Button
icon={running ? <FaPause /> : <FaPlay />}
smallIcon
tooltip={running ? 'Pause' : 'Start'}
onClick={toggleRunning}
/>
</div>
</div>
<Setting
show={showSetting}
times={times}
onChange={times => {
setShowSetting(false);
setTimes(times);
}}
onClose={() => {
setShowSetting(false);
}}
/>
</Container>
);
}

View File

@@ -1 +0,0 @@
export { Setting } from './setting';

View File

@@ -1,76 +0,0 @@
.title {
margin-bottom: 16px;
font-family: var(--font-heading);
font-size: var(--font-md);
font-weight: 600;
}
& .form {
display: flex;
flex-direction: column;
& .field {
display: flex;
flex-direction: column;
row-gap: 8px;
margin-bottom: 16px;
& .label {
font-size: var(--font-sm);
color: var(--color-foreground);
& span {
color: var(--color-foreground-subtle);
}
}
& .input {
display: block;
height: 40px;
padding: 0 8px;
color: var(--color-foreground);
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
outline: none;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
}
}
& .buttons {
display: flex;
column-gap: 8px;
align-items: center;
justify-content: flex-end;
& button {
display: flex;
align-items: center;
justify-content: center;
height: 40px;
padding: 0 16px;
font-size: var(--font-sm);
font-weight: 500;
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-200);
border: none;
border-radius: 4px;
outline: none;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
&.primary {
color: var(--color-neutral-100);
background-color: var(--color-neutral-950);
}
}
}
}

View File

@@ -1,110 +0,0 @@
import { useEffect, useState } from 'react';
import { Modal } from '@/components/modal';
import styles from './setting.module.css';
interface SettingProps {
onChange: (newTimes: Record<string, number>) => void;
onClose: () => void;
show: boolean;
times: Record<string, number>;
}
export function Setting({ onChange, onClose, show, times }: SettingProps) {
const [values, setValues] = useState<Record<string, number | string>>(times);
useEffect(() => {
if (show) setValues(times);
}, [times, show]);
const handleChange = (id: string) => (value: number | string) => {
setValues(prev => ({
...prev,
[id]: typeof value === 'number' ? value * 60 : '',
}));
};
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
const newValues: Record<string, number> = {};
Object.keys(values).forEach(name => {
newValues[name] =
typeof values[name] === 'number' ? values[name] : times[name];
});
onChange(newValues);
};
const handleCancel = (e: React.MouseEvent<HTMLButtonElement, MouseEvent>) => {
e.preventDefault();
onClose();
};
return (
<Modal lockBody={false} show={show} onClose={onClose}>
<h2 className={styles.title}>Change Times</h2>
<form className={styles.form} onSubmit={handleSubmit}>
<Field
id="pomodoro"
label="Pomodoro"
value={values.pomodoro}
onChange={handleChange('pomodoro')}
/>
<Field
id="short"
label="Short Break"
value={values.short}
onChange={handleChange('short')}
/>
<Field
id="long"
label="Long Break"
value={values.long}
onChange={handleChange('long')}
/>
<div className={styles.buttons}>
<button type="button" onClick={handleCancel}>
Cancel
</button>
<button className={styles.primary} type="submit">
Save
</button>
</div>
</form>
</Modal>
);
}
interface FieldProps {
id: string;
label: string;
onChange: (value: number | string) => void;
value: number | string;
}
function Field({ id, label, onChange, value }: FieldProps) {
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={id}>
{label} <span>(minutes)</span>
</label>
<input
className={styles.input}
max={120}
min={1}
required
type="number"
value={typeof value === 'number' ? value / 60 : ''}
onChange={e => {
onChange(e.target.value === '' ? '' : Number(e.target.value));
}}
/>
</div>
);
}

View File

@@ -1 +0,0 @@
export { Tabs } from './tabs';

View File

@@ -1,43 +0,0 @@
.tabs {
display: flex;
column-gap: 4px;
align-items: center;
padding: 4px;
margin: 8px 0;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
& .tab {
display: flex;
flex-grow: 1;
align-items: center;
justify-content: center;
height: 45px;
font-size: var(--font-sm);
color: var(--color-foreground-subtle);
cursor: pointer;
background-color: transparent;
border: 1px solid transparent;
border-radius: 4px;
outline: none;
transition: 0.2s;
&:focus-visible {
outline: 2px solid var(--color-neutral-400);
outline-offset: 2px;
}
&.selected {
color: var(--color-foreground);
background-color: var(--color-neutral-200);
border-color: var(--color-neutral-300);
}
&:not(.selected):hover,
&:not(.selected):focus-visible {
color: var(--color-foreground);
background-color: var(--color-neutral-100);
}
}
}

View File

@@ -1,25 +0,0 @@
import { cn } from '@/helpers/styles';
import styles from './tabs.module.css';
interface TabsProps {
onSelect: (id: string) => void;
selectedTab: string;
tabs: Array<{ id: string; label: string }>;
}
export function Tabs({ onSelect, selectedTab, tabs }: TabsProps) {
return (
<div className={styles.tabs}>
{tabs.map(tab => (
<button
className={cn(styles.tab, selectedTab === tab.id && styles.selected)}
key={tab.id}
onClick={() => onSelect(tab.id)}
>
{tab.label}
</button>
))}
</div>
);
}

View File

@@ -1,27 +0,0 @@
.field {
flex-grow: 1;
& .label {
display: block;
margin-bottom: 8px;
font-size: var(--font-sm);
font-weight: 500;
& .optional {
font-weight: 400;
color: var(--color-foreground-subtle);
}
}
& .input {
width: 100%;
min-width: 0;
height: 40px;
padding: 0 16px;
color: var(--color-foreground);
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
outline: none;
}
}

View File

@@ -1,51 +0,0 @@
import styles from './field.module.css';
interface FieldProps {
children?: React.ReactNode;
label: string;
onChange: (value: string | number) => void;
optional?: boolean;
type: 'text' | 'select';
value: string | number;
}
export function Field({
children,
label,
onChange,
optional,
type,
value,
}: FieldProps) {
return (
<div className={styles.field}>
<label className={styles.label} htmlFor={label.toLowerCase()}>
{label}{' '}
{optional && <span className={styles.optional}>(optional)</span>}
</label>
{type === 'text' && (
<input
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
type="text"
value={value}
onChange={e => onChange(e.target.value)}
/>
)}
{type === 'select' && (
<select
autoComplete="off"
className={styles.input}
id={label.toLowerCase()}
value={value}
onChange={e => onChange(parseInt(e.target.value))}
>
{children}
</select>
)}
</div>
);
}

View File

@@ -1 +0,0 @@
export { Field } from './field';

View File

@@ -1,28 +0,0 @@
.form {
display: flex;
flex-direction: column;
row-gap: 28px;
& .button {
display: flex;
align-items: center;
justify-content: center;
width: 100%;
height: 45px;
font-weight: 500;
color: var(--color-neutral-50);
cursor: pointer;
background-color: var(--color-neutral-950);
border: none;
border-radius: 8px;
outline: none;
box-shadow: inset 0 -3px 0 var(--color-neutral-700);
}
}
.timeFields {
display: flex;
column-gap: 12px;
align-items: flex-end;
justify-content: space-between;
}

View File

@@ -1,97 +0,0 @@
import { useState, useMemo } from 'react';
import { Field } from './field';
import { useTimers } from '@/stores/timers';
import styles from './form.module.css';
export function Form() {
const [name, setName] = useState('');
const [hours, setHours] = useState(0);
const [minutes, setMinutes] = useState(10);
const [seconds, setSeconds] = useState(0);
const totalSeconds = useMemo(
() => hours * 60 * 60 + minutes * 60 + seconds,
[hours, minutes, seconds],
);
const add = useTimers(state => state.add);
const handleSubmit = (e: React.FormEvent<HTMLFormElement>) => {
e.preventDefault();
if (totalSeconds === 0) return;
add({
name,
total: totalSeconds,
});
setName('');
};
return (
<form className={styles.form} onSubmit={handleSubmit}>
<Field
label="Timer Name"
optional
type="text"
value={name}
onChange={value => setName(value as string)}
/>
<div className={styles.timeFields}>
<Field
label="Hours"
type="select"
value={hours}
onChange={value => setHours(value as number)}
>
{Array(13)
.fill(null)
.map((_, index) => (
<option key={`hour-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Minutes"
type="select"
value={minutes}
onChange={value => setMinutes(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`minutes-${index}`} value={index}>
{index}
</option>
))}
</Field>
<Field
label="Seconds"
type="select"
value={seconds}
onChange={value => setSeconds(value as number)}
>
{Array(60)
.fill(null)
.map((_, index) => (
<option key={`seconds-${index}`} value={index}>
{index}
</option>
))}
</Field>
</div>
<button className={styles.button} type="submit">
Add Timer
</button>
</form>
);
}

View File

@@ -1 +0,0 @@
export { Form } from './form';

View File

@@ -1 +0,0 @@
export { Timer } from './timer';

View File

@@ -1,15 +0,0 @@
import { Container } from '@/components/container';
import { Timers } from './timers';
import { Form } from './form';
import { StoreConsumer } from '@/components/store-consumer';
export function Timer() {
return (
<Container tight>
<StoreConsumer>
<Form />
<Timers />
</StoreConsumer>
</Container>
);
}

View File

@@ -1 +0,0 @@
export { Timers } from './timers';

View File

@@ -1 +0,0 @@
export { Notice } from './notice';

View File

@@ -1,10 +0,0 @@
.notice {
padding: 16px;
margin-top: 16px;
font-size: var(--font-sm);
line-height: 1.65;
color: var(--color-foreground-subtle);
text-align: center;
border: 1px dashed var(--color-neutral-200);
border-radius: 8px;
}

View File

@@ -1,10 +0,0 @@
import styles from './notice.module.css';
export function Notice() {
return (
<p className={styles.notice}>
Please do not close this tab while timers are running, otherwise all
timers will be stopped.
</p>
);
}

View File

@@ -1 +0,0 @@
export { Timer } from './timer';

View File

@@ -1,126 +0,0 @@
.timer {
position: relative;
padding: 8px;
overflow: hidden;
background-color: var(--color-neutral-100);
border: 1px solid var(--color-neutral-200);
border-radius: 8px;
&:not(:last-of-type) {
margin-bottom: 24px;
}
& .header {
position: relative;
top: -8px;
width: 100%;
& .bar {
height: 2px;
margin: 0 -8px;
background-color: var(--color-neutral-200);
& .completed {
height: 100%;
background-color: var(--color-neutral-500);
transition: 0.2s;
}
}
}
& .footer {
display: flex;
column-gap: 4px;
align-items: center;
& .control {
display: flex;
flex-grow: 1;
column-gap: 4px;
align-items: center;
height: 40px;
padding: 4px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
& .input {
flex-grow: 1;
width: 100%;
min-width: 0;
height: 100%;
padding: 0 8px;
color: var(--color-foreground-subtle);
background-color: transparent;
border: none;
border-radius: 4px;
outline: none;
&.finished {
text-decoration: line-through;
}
}
& .button {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
aspect-ratio: 1 / 1;
color: var(--color-foreground);
cursor: pointer;
background-color: var(--color-neutral-200);
border: 1px solid var(--color-neutral-300);
border-radius: 2px;
outline: none;
transition: 0.2s;
&.reset {
background-color: var(--color-neutral-100);
border: none;
}
&:disabled,
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
& .delete {
display: flex;
align-items: center;
justify-content: center;
width: 38px;
height: 38px;
color: #f43f5e;
cursor: pointer;
background-color: rgb(244 63 94 / 10%);
border: none;
border-radius: 4px;
outline: none;
transition: 0.2s;
&.disabled {
cursor: not-allowed;
opacity: 0.6;
}
}
}
& .left {
display: flex;
align-items: center;
justify-content: center;
height: 120px;
font-family: var(--font-mono);
font-size: var(--font-2xlg);
font-weight: 700;
cursor: pointer;
& span {
color: var(--color-foreground-subtle);
}
}
}

View File

@@ -1,212 +0,0 @@
import { useRef, useMemo, useState, useEffect } from 'react';
import { IoPlay, IoPause, IoRefresh, IoTrashOutline } from 'react-icons/io5';
import { Toolbar } from './toolbar';
import { useTimers } from '@/stores/timers';
import { useAlarm } from '@/hooks/use-alarm';
import { useSnackbar } from '@/contexts/snackbar';
import { padNumber } from '@/helpers/number';
import { cn } from '@/helpers/styles';
import styles from './timer.module.css';
interface TimerProps {
id: string;
}
export function Timer({ id }: TimerProps) {
const intervalRef = useRef<ReturnType<typeof setInterval> | null>(null);
const lastActiveTimeRef = useRef<number | null>(null);
const lastStateRef = useRef<{ spent: number; total: number } | null>(null);
const [isRunning, setIsRunning] = useState(false);
const { first, last, name, spent, total } = useTimers(state =>
state.getTimer(id),
);
const tick = useTimers(state => state.tick);
const rename = useTimers(state => state.rename);
const reset = useTimers(state => state.reset);
const deleteTimer = useTimers(state => state.delete);
const left = useMemo(() => total - spent, [total, spent]);
const hours = useMemo(() => Math.floor(left / 3600), [left]);
const minutes = useMemo(() => Math.floor((left % 3600) / 60), [left]);
const seconds = useMemo(() => left % 60, [left]);
const [isReversed, setIsReversed] = useState(false);
const spentHours = useMemo(() => Math.floor(spent / 3600), [spent]);
const spentMinutes = useMemo(() => Math.floor((spent % 3600) / 60), [spent]);
const spentSeconds = useMemo(() => spent % 60, [spent]);
const playAlarm = useAlarm();
const showSnackbar = useSnackbar();
const handleStart = () => {
if (left > 0) setIsRunning(true);
};
const handlePause = () => setIsRunning(false);
const handleToggle = () => {
if (isRunning) handlePause();
else handleStart();
};
const handleReset = () => {
if (spent === 0) return;
if (isRunning) return showSnackbar('Please first stop the timer.');
setIsRunning(false);
reset(id);
};
const handleDelete = () => {
if (isRunning) return showSnackbar('Please first stop the timer.');
deleteTimer(id);
};
useEffect(() => {
if (isRunning) {
if (intervalRef.current) clearInterval(intervalRef.current);
intervalRef.current = setInterval(() => tick(id), 1000);
}
return () => {
if (intervalRef.current) clearInterval(intervalRef.current);
};
}, [isRunning, tick, id]);
useEffect(() => {
if (left === 0 && isRunning) {
setIsRunning(false);
playAlarm();
if (intervalRef.current) clearInterval(intervalRef.current);
}
}, [left, isRunning, playAlarm]);
useEffect(() => {
const handleBlur = () => {
if (isRunning) {
lastActiveTimeRef.current = Date.now();
lastStateRef.current = { spent, total };
}
};
const handleFocus = () => {
if (isRunning && lastActiveTimeRef.current && lastStateRef.current) {
const elapsed = Math.floor(
(Date.now() - lastActiveTimeRef.current) / 1000,
);
const previousLeft =
lastStateRef.current.total - lastStateRef.current.spent;
const currentLeft = left;
const correctedLeft = previousLeft - elapsed;
if (correctedLeft < currentLeft) {
tick(id, currentLeft - correctedLeft);
}
lastActiveTimeRef.current = null;
lastStateRef.current = null;
}
};
window.addEventListener('blur', handleBlur);
window.addEventListener('focus', handleFocus);
return () => {
window.removeEventListener('blur', handleBlur);
window.removeEventListener('focus', handleFocus);
};
}, [isRunning, tick, id, spent, total, left]);
return (
<div className={styles.timer}>
<header className={styles.header}>
<div className={styles.bar}>
<div
className={styles.completed}
style={{ width: `${(left / total) * 100}%` }}
/>
</div>
</header>
<Toolbar first={first} id={id} last={last} />
<div
className={styles.left}
tabIndex={0}
onClick={() => setIsReversed(prev => !prev)}
onKeyDown={() => setIsReversed(prev => !prev)}
>
{!isReversed ? (
<>
{padNumber(hours)}
<span>:</span>
{padNumber(minutes)}
<span>:</span>
{padNumber(seconds)}
</>
) : (
<>
<span>-</span>
{padNumber(spentHours)}
<span>:</span>
{padNumber(spentMinutes)}
<span>:</span>
{padNumber(spentSeconds)}
</>
)}
</div>
<footer className={styles.footer}>
<div className={styles.control}>
<input
className={cn(styles.input, left === 0 && styles.finished)}
placeholder="Untitled"
type="text"
value={name}
onChange={e => rename(id, e.target.value)}
/>
<button
aria-disabled={isRunning || spent === 0}
className={cn(
styles.button,
styles.reset,
(isRunning || spent === 0) && styles.disabled,
)}
onClick={handleReset}
>
<IoRefresh />
</button>
<button
className={styles.button}
disabled={!isRunning && left === 0}
onClick={handleToggle}
>
{isRunning ? <IoPause /> : <IoPlay />}
</button>
</div>
<button
aria-disabled={isRunning}
className={cn(styles.delete, isRunning && styles.disabled)}
onClick={handleDelete}
>
<IoTrashOutline />
</button>
</footer>
</div>
);
}

View File

@@ -1 +0,0 @@
export { Toolbar } from './toolbar';

View File

@@ -1,37 +0,0 @@
.toolbar {
position: absolute;
top: 12px;
right: 12px;
display: flex;
column-gap: 4px;
align-items: center;
height: 30px;
padding: 4px;
background-color: var(--color-neutral-50);
border: 1px solid var(--color-neutral-200);
border-radius: 4px;
& button {
display: flex;
align-items: center;
justify-content: center;
height: 100%;
aspect-ratio: 1 / 1;
font-size: var(--font-xsm);
color: var(--color-foreground-subtle);
cursor: pointer;
background-color: transparent;
border: none;
transition: 0.2s;
&:disabled {
cursor: not-allowed;
opacity: 0.2;
}
&:not(:disabled):hover {
color: var(--color-foreground);
background-color: var(--color-neutral-100);
}
}
}

View File

@@ -1,39 +0,0 @@
import { IoIosArrowDown, IoIosArrowUp } from 'react-icons/io';
import { useTimers } from '@/stores/timers';
import styles from './toolbar.module.css';
interface ToolbarProps {
first: boolean;
id: string;
last: boolean;
}
export function Toolbar({ first, id, last }: ToolbarProps) {
const moveUp = useTimers(state => state.moveUp);
const moveDown = useTimers(state => state.moveDown);
return (
<div className={styles.toolbar}>
<button
disabled={first}
onClick={e => {
e.stopPropagation();
moveUp(id);
}}
>
<IoIosArrowUp />
</button>
<button
disabled={last}
onClick={e => {
e.stopPropagation();
moveDown(id);
}}
>
<IoIosArrowDown />
</button>
</div>
);
}

View File

@@ -1,27 +0,0 @@
.timers {
margin-top: 48px;
& > header {
display: flex;
column-gap: 12px;
align-items: center;
margin-bottom: 16px;
& .title {
font-family: var(--font-display);
font-size: var(--font-lg);
line-height: 1;
}
& .line {
flex-grow: 1;
height: 0;
border-top: 1px dashed var(--color-neutral-200);
}
& .spent {
font-size: var(--font-sm);
color: var(--color-foreground-subtle);
}
}
}

View File

@@ -1,46 +0,0 @@
import { useMemo } from 'react';
import { useAutoAnimate } from '@formkit/auto-animate/react';
import { Timer } from './timer';
import { Notice } from './notice';
import { useTimers } from '@/stores/timers';
import styles from './timers.module.css';
export function Timers() {
const [animationParent] = useAutoAnimate();
const [animationList] = useAutoAnimate();
const timers = useTimers(state => state.timers);
const spent = useTimers(state => state.spent());
const total = useTimers(state => state.total());
const spentMinutes = useMemo(() => Math.floor(spent / 60), [spent]);
const totalMinutes = useMemo(() => Math.floor(total / 60), [total]);
return (
<div ref={animationParent}>
{timers.length > 0 ? (
<div className={styles.timers} ref={animationList}>
<header>
<h2 className={styles.title}>Timers</h2>
<div className={styles.line} />
{totalMinutes > 0 && (
<p className={styles.spent}>
{spentMinutes} / {totalMinutes} Minute
{totalMinutes !== 1 && 's'}
</p>
)}
</header>
{timers.map(timer => (
<Timer id={timer.id} key={timer.id} />
))}
<Notice />
</div>
) : null}
</div>
);
}