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>
);
}

View file

@ -1,24 +0,0 @@
---
import Layout from '@/layouts/layout.astro';
import Donate from '@/components/donate.astro';
import Hero from '@/components/tools/hero.astro';
import { BreathingExercises as Breathing } from '@/components/tools/breathing';
import Footer from '@/components/footer.astro';
import About from '@/components/tools/about.astro';
import Source from '@/components/source.astro';
---
<Layout title="Breathing Exercises Tool — Moodist">
<Donate />
<Hero
desc="Simple breathing exercises to relax."
title="Breathing Exercises"
/>
<Breathing client:load />
<About
text="Experience calm and focus with our simple breathing exercise tool. Designed to guide your breath, it helps reduce stress, enhance relaxation, and improve mindfulness in just a few minutes a day."
/>
<Source />
<Footer />
</Layout>

View file

@ -1,21 +0,0 @@
---
import Layout from '@/layouts/layout.astro';
import Donate from '@/components/donate.astro';
import Hero from '@/components/tools/hero.astro';
import { Timer } from '@/components/tools/timer';
import Footer from '@/components/footer.astro';
import About from '@/components/tools/about.astro';
import Source from '@/components/source.astro';
---
<Layout title="Online Countdown Timer — Moodist">
<Donate />
<Hero desc="Distraction-free countdown timer." title="Countdown Timer" />
<Timer client:load />
<About
text="Stay on track with our simple countdown timer. Whether you're timing a task, workout, or event, it helps you manage your time effectively with a clear, easy-to-use interface."
/>
<Source />
<Footer />
</Layout>

View file

@ -1,21 +0,0 @@
---
import Layout from '@/layouts/layout.astro';
import Donate from '@/components/donate.astro';
import Hero from '@/components/tools/hero.astro';
import { Notepad as NotepadComponent } from '@/components/tools/notepad';
import Footer from '@/components/footer.astro';
import About from '@/components/tools/about.astro';
import Source from '@/components/source.astro';
---
<Layout title="Online Notepad — Moodist">
<Donate />
<Hero desc="Distraction-free online notepad." title="Online Notepad" />
<NotepadComponent client:load />
<About
text="Capture your thoughts instantly with our simple online notepad. Lightweight and easy to use, it lets you jot down notes, ideas, and to-do lists anytime, anywhere, without distractions."
/>
<Source />
<Footer />
</Layout>

View file

@ -1,21 +0,0 @@
---
import Layout from '@/layouts/layout.astro';
import Donate from '@/components/donate.astro';
import Hero from '@/components/tools/hero.astro';
import { Pomodoro as PomodoroTimer } from '@/components/tools/pomodoro';
import Footer from '@/components/footer.astro';
import About from '@/components/tools/about.astro';
import Source from '@/components/source.astro';
---
<Layout title="Pomodoro Timer — Moodist">
<Donate />
<Hero desc="Super simple Pomodoro timer." title="Pomodoro Timer" />
<PomodoroTimer client:load />
<About
text="Boost your productivity with our simple Pomodoro timer. Designed to help you stay focused, it breaks your work into manageable intervals with regular breaks, making it easier to stay on track and avoid burnout."
/>
<Source />
<Footer />
</Layout>

View file

@ -1,19 +0,0 @@
import { create } from 'zustand';
interface AlarmStore {
isPlaying: boolean;
play: () => void;
stop: () => void;
}
export const useAlarmStore = create<AlarmStore>()(set => ({
isPlaying: false,
play() {
set({ isPlaying: true });
},
stop() {
set({ isPlaying: false });
},
}));

View file

@ -1,155 +0,0 @@
import { v4 as uuid } from 'uuid';
import { create } from 'zustand';
import { createJSONStorage, persist } from 'zustand/middleware';
interface Timer {
id: string;
name: string;
spent: number;
total: number;
}
interface State {
spent: () => number;
timers: Array<Timer>;
total: () => number;
}
interface Actions {
add: (timer: { name: string; total: number }) => void;
delete: (id: string) => void;
getTimer: (id: string) => Timer & { first: boolean; last: boolean };
moveDown: (id: string) => void;
moveUp: (id: string) => void;
rename: (id: string, newName: string) => void;
reset: (id: string) => void;
tick: (id: string, amount?: number) => void;
}
export const useTimers = create<State & Actions>()(
persist(
(set, get) => ({
add({ name, total }) {
set(state => ({
timers: [
{
id: uuid(),
name,
spent: 0,
total,
},
...state.timers,
],
}));
},
delete(id) {
set(state => ({
timers: state.timers.filter(timer => timer.id !== id),
}));
},
getTimer(id) {
const timers = get().timers;
const timer = timers.filter(timer => timer.id === id)[0];
const index = timers.indexOf(timer);
return {
...timer,
first: index === 0,
last: index === timers.length - 1,
};
},
moveDown(id) {
set(state => {
const index = state.timers.findIndex(timer => timer.id === id);
if (index < state.timers.length - 1) {
const newTimers = [...state.timers];
[newTimers[index + 1], newTimers[index]] = [
newTimers[index],
newTimers[index + 1],
];
return { timers: newTimers };
}
return state;
});
},
moveUp(id) {
set(state => {
const index = state.timers.findIndex(timer => timer.id === id);
if (index > 0) {
const newTimers = [...state.timers];
[newTimers[index - 1], newTimers[index]] = [
newTimers[index],
newTimers[index - 1],
];
return { timers: newTimers };
}
return state;
});
},
rename(id, newName) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, name: newName };
}),
}));
},
reset(id) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
return { ...timer, spent: 0 };
}),
}));
},
spent() {
return get().timers.reduce((prev, curr) => prev + curr.spent, 0);
},
tick(id, amount = 1) {
set(state => ({
timers: state.timers.map(timer => {
if (timer.id !== id) return timer;
const updatedSpent =
timer.spent + amount > timer.total
? timer.total
: timer.spent + amount;
return { ...timer, spent: updatedSpent };
}),
}));
},
timers: [],
total() {
return get().timers.reduce((prev, curr) => prev + curr.total, 0);
},
}),
{
name: 'moodist-countdown-timers',
partialize: state => ({ timers: state.timers }),
skipHydration: true,
storage: createJSONStorage(() => localStorage),
version: 0,
},
),
);