mirror of
https://github.com/remvze/moodist.git
synced 2025-12-18 01:14:17 +00:00
feat: remove all tools
This commit is contained in:
parent
47a63a774e
commit
2bbdc7e09e
53 changed files with 0 additions and 2064 deletions
|
|
@ -3,7 +3,6 @@ import { useEffect } from 'react';
|
||||||
import { useSoundStore } from '@/stores/sound';
|
import { useSoundStore } from '@/stores/sound';
|
||||||
import { useNoteStore } from '@/stores/note';
|
import { useNoteStore } from '@/stores/note';
|
||||||
import { usePresetStore } from '@/stores/preset';
|
import { usePresetStore } from '@/stores/preset';
|
||||||
import { useTimers } from '@/stores/timers';
|
|
||||||
|
|
||||||
interface StoreConsumerProps {
|
interface StoreConsumerProps {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
|
|
@ -14,7 +13,6 @@ export function StoreConsumer({ children }: StoreConsumerProps) {
|
||||||
useSoundStore.persist.rehydrate();
|
useSoundStore.persist.rehydrate();
|
||||||
useNoteStore.persist.rehydrate();
|
useNoteStore.persist.rehydrate();
|
||||||
usePresetStore.persist.rehydrate();
|
usePresetStore.persist.rehydrate();
|
||||||
useTimers.persist.rehydrate();
|
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
return <>{children}</>;
|
return <>{children}</>;
|
||||||
|
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
/* WIP */
|
|
||||||
|
|
@ -1,10 +0,0 @@
|
||||||
import { Container } from '@/components/container';
|
|
||||||
import { Exercise } from './exercise';
|
|
||||||
|
|
||||||
export function BreathingExercises() {
|
|
||||||
return (
|
|
||||||
<Container tight>
|
|
||||||
<Exercise />
|
|
||||||
</Container>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Exercise } from './exercise';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { BreathingExercises } from './breathing';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Button } from './button';
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Button } from './button';
|
|
||||||
|
|
@ -1,11 +0,0 @@
|
||||||
import { StoreConsumer } from '@/components/store-consumer';
|
|
||||||
|
|
||||||
import { Notepad as NotepadComponent } from './notepad';
|
|
||||||
|
|
||||||
export function Notepad() {
|
|
||||||
return (
|
|
||||||
<StoreConsumer>
|
|
||||||
<NotepadComponent />
|
|
||||||
</StoreConsumer>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Pomodoro } from './pomodoro';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Setting } from './setting';
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Tabs } from './tabs';
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Field } from './field';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Form } from './form';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Timer } from './timer';
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Timers } from './timers';
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Notice } from './notice';
|
|
||||||
|
|
@ -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;
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Timer } from './timer';
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1 +0,0 @@
|
||||||
export { Toolbar } from './toolbar';
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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>
|
|
||||||
|
|
@ -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 });
|
|
||||||
},
|
|
||||||
}));
|
|
||||||
|
|
@ -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,
|
|
||||||
},
|
|
||||||
),
|
|
||||||
);
|
|
||||||
Loading…
Add table
Reference in a new issue