Refactor row into component
This commit is contained in:
parent
83cd4b2d78
commit
1b083cfb6f
19 changed files with 408 additions and 87 deletions
9
.prettierrc
Normal file
9
.prettierrc
Normal file
|
@ -0,0 +1,9 @@
|
|||
{
|
||||
"tabWidth": 2,
|
||||
"printWidth": 100,
|
||||
"singleQuote": true,
|
||||
"trailingComma": "none",
|
||||
"bracketSpacing": true,
|
||||
"semi": true,
|
||||
"arrowParens": "avoid"
|
||||
}
|
BIN
public/sounds/darn.mp3
Normal file
BIN
public/sounds/darn.mp3
Normal file
Binary file not shown.
BIN
public/sounds/flush.wav
Normal file
BIN
public/sounds/flush.wav
Normal file
Binary file not shown.
BIN
public/sounds/starman.mp3
Normal file
BIN
public/sounds/starman.mp3
Normal file
Binary file not shown.
BIN
public/sounds/toot.wav
Normal file
BIN
public/sounds/toot.wav
Normal file
Binary file not shown.
BIN
public/sounds/woohoo.ogg
Normal file
BIN
public/sounds/woohoo.ogg
Normal file
Binary file not shown.
BIN
public/sounds/yay.mp3
Normal file
BIN
public/sounds/yay.mp3
Normal file
Binary file not shown.
BIN
public/sounds/yipee.mp3
Normal file
BIN
public/sounds/yipee.mp3
Normal file
Binary file not shown.
96
src/App.tsx
96
src/App.tsx
|
@ -1,8 +1,8 @@
|
|||
import React from 'react';
|
||||
import {useSpring, animated} from '@react-spring/web';
|
||||
|
||||
import LoginForm from './LoginForm';
|
||||
import ScoreCounter from './ScoreCounter';
|
||||
import Row from './Row';
|
||||
import Icon from './Icon';
|
||||
|
||||
import { useJSONLocalStorage, useLocalStorage } from './hooks/useLocalStorage';
|
||||
|
@ -11,15 +11,12 @@ import {toot, starman, flush, getCheer} from './sound';
|
|||
import './App.css';
|
||||
|
||||
|
||||
const COLUMNS = [1,2,3,4,5,6,7,8,9,10];
|
||||
|
||||
interface Stats {
|
||||
attempts: number;
|
||||
scheduledPoops: number;
|
||||
selfPoops: number;
|
||||
cleanUndies: number;
|
||||
streaks: number[];
|
||||
currentStreakSince: number;
|
||||
};
|
||||
|
||||
const initialStats:Stats = {
|
||||
|
@ -27,16 +24,18 @@ const initialStats:Stats = {
|
|||
scheduledPoops: 1,
|
||||
selfPoops: 1,
|
||||
cleanUndies: 1,
|
||||
};
|
||||
|
||||
const initialStreakTracker:StreakTracker = {
|
||||
streaks: [0,0,0,0],
|
||||
currentStreakSince: Date.now()
|
||||
};
|
||||
}
|
||||
|
||||
function App() {
|
||||
|
||||
const [stats, setStats] = useJSONLocalStorage<Stats>("stats", initialStats);
|
||||
const [name] = useLocalStorage<string>("name", {});
|
||||
const [password] = useLocalStorage<string>("password", {});
|
||||
const { attempts, scheduledPoops, selfPoops, cleanUndies, streaks, currentStreakSince } = stats;
|
||||
|
||||
return !name || !password ? <LoginForm /> :(
|
||||
<div className="App">
|
||||
|
@ -46,87 +45,10 @@ function App() {
|
|||
Points: <ScoreCounter stats={stats}/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="row attempt" onClick={() => {
|
||||
setStats({...stats, attempts: attempts + 1 });
|
||||
flush.play();
|
||||
setTimeout(() => getCheer().play(), 1800)
|
||||
}}>
|
||||
{COLUMNS.map(i =>
|
||||
i <= attempts ?
|
||||
<Icon key={i} emoji="🚽" className="attempt" />
|
||||
: <div key={i} className="attempt cell"/>
|
||||
)}
|
||||
<div
|
||||
className="delete cell checked"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setStats({...stats, attempts: Math.max(0, attempts - 1)});
|
||||
console.log("BLARG");
|
||||
}}
|
||||
>
|
||||
🚫
|
||||
</div>
|
||||
</div>
|
||||
<div className="row scheduled-poops" onClick={() => {
|
||||
setStats({...stats, scheduledPoops: scheduledPoops + 1 });
|
||||
toot.play();
|
||||
setTimeout(() => getCheer().play(), 1800)
|
||||
}}>
|
||||
{COLUMNS.map(i =>
|
||||
i <= scheduledPoops ?
|
||||
<Icon key={i} emoji="💩" className="scheduled" />
|
||||
: <div key={i} className="scheduled cell"/>
|
||||
)}
|
||||
<div
|
||||
className="delete cell checked"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setStats({...stats, scheduledPoops: Math.max(0, scheduledPoops - 1)});
|
||||
}}
|
||||
>
|
||||
🚫
|
||||
</div>
|
||||
</div>
|
||||
<div className="row self-poops" onClick={() => {
|
||||
setStats({...stats, selfPoops: selfPoops + 1 });
|
||||
toot.play();
|
||||
setTimeout(() => getCheer().play(), 1800)
|
||||
}}>
|
||||
{COLUMNS.map(i =>
|
||||
i <= selfPoops ?
|
||||
<Icon emoji="💩" className="self" />
|
||||
: <div key={i} className="self cell"/>
|
||||
)}
|
||||
<div
|
||||
className="delete cell checked"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setStats({...stats, selfPoops: Math.max(0, selfPoops - 1)});
|
||||
}}
|
||||
>
|
||||
🚫
|
||||
</div>
|
||||
</div>
|
||||
<div className="row clean-undies" onClick={() => {
|
||||
setStats({...stats, cleanUndies: cleanUndies + 1 })
|
||||
starman.play();
|
||||
setTimeout(() => getCheer().play(), 1800)
|
||||
}}>
|
||||
{COLUMNS.map(i =>
|
||||
i <= cleanUndies ?
|
||||
<Icon emoji="🩲" className="clean-undies" />
|
||||
: <div key={i} className="clean-undies cell"/>
|
||||
)}
|
||||
<div
|
||||
className="delete cell checked"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setStats({...stats, cleanUndies: Math.max(0, cleanUndies - 1)});
|
||||
}}
|
||||
>
|
||||
🚫
|
||||
</div>
|
||||
</div>
|
||||
<Row stats={stats} setStats={setStats} name="attempts" emoji="🚽" sound={flush} />
|
||||
<Row stats={stats} setStats={setStats} name="scheduledPoops" emoji="💩" sound={toot} />
|
||||
<Row stats={stats} setStats={setStats} name="selfPoops" emoji="💩" sound={toot} />
|
||||
<Row stats={stats} setStats={setStats} name="cleanUndies" emoji="🩲" sound={starman} />
|
||||
<div className="streak-counter"></div>
|
||||
</div>
|
||||
);
|
||||
|
|
15
src/Icon.tsx
Normal file
15
src/Icon.tsx
Normal file
|
@ -0,0 +1,15 @@
|
|||
import React from 'react';
|
||||
import {useSpring, animated} from '@react-spring/web';
|
||||
|
||||
const Icon = ({emoji, className}:{emoji: string, className: string}) => {
|
||||
const springs = useSpring({
|
||||
from: {scale: 0.5, rotate: -45},
|
||||
to: [
|
||||
{scale: 1.3, rotate: 45},
|
||||
{scale: 1, rotate: 0}
|
||||
]});
|
||||
return <animated.div style={springs} className={`${className} cell checked`}>
|
||||
{emoji}
|
||||
</animated.div>
|
||||
}
|
||||
export default Icon;
|
21
src/LoginForm.css
Normal file
21
src/LoginForm.css
Normal file
|
@ -0,0 +1,21 @@
|
|||
.form-login {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
height: 750px;
|
||||
}
|
||||
|
||||
.form-login > label {
|
||||
margin: 15px;
|
||||
width: 400px;
|
||||
text-align: right;
|
||||
}
|
||||
.form-login input {
|
||||
margin-left: 10px;
|
||||
}
|
||||
.form-login > .button-row {
|
||||
width: 400px;
|
||||
text-align: right;
|
||||
}
|
||||
|
35
src/LoginForm.tsx
Normal file
35
src/LoginForm.tsx
Normal file
|
@ -0,0 +1,35 @@
|
|||
import React from 'react';
|
||||
import './LoginForm.css';
|
||||
|
||||
import { useLocalStorage } from './hooks/useLocalStorage';
|
||||
|
||||
const LoginForm = () => {
|
||||
const [, setName] = useLocalStorage<string>('name', {});
|
||||
const [, setPassword] = useLocalStorage<string>('password', {});
|
||||
|
||||
return (
|
||||
<form
|
||||
className="form-login"
|
||||
onSubmit={e => {
|
||||
e.preventDefault();
|
||||
console.log('BLARG');
|
||||
const inputs = new FormData(e.target as HTMLFormElement);
|
||||
setName(inputs.get('name') as string);
|
||||
setPassword(inputs.get('password') as string);
|
||||
}}
|
||||
>
|
||||
<label>
|
||||
Name:
|
||||
<input name="name" minLength={2} />
|
||||
</label>
|
||||
<label>
|
||||
Password: + <input name="password" type="password" minLength={3} />
|
||||
</label>
|
||||
<div className="button-row">
|
||||
<button type="submit">Let's Go!</button>
|
||||
</div>
|
||||
</form>
|
||||
);
|
||||
};
|
||||
|
||||
export default LoginForm;
|
40
src/Row.tsx
Normal file
40
src/Row.tsx
Normal file
|
@ -0,0 +1,40 @@
|
|||
import React from 'react';
|
||||
|
||||
import { getCheer } from './sound';
|
||||
|
||||
import Icon from './Icon';
|
||||
|
||||
const COLUMNS = [1,2,3,4,5,6,7,8,9,10];
|
||||
interface RowProps {
|
||||
stats: Stats;
|
||||
setStats: (s: Stats) => void;
|
||||
name: keyof Stats;
|
||||
emoji: string;
|
||||
sound: HTMLAudioElement;
|
||||
}
|
||||
|
||||
const Row = ({stats, setStats, name, emoji, sound}: RowProps) => {
|
||||
const stat = stats[name];
|
||||
return <div className={`row ${name}`} onClick={() => {
|
||||
setStats({...stats, [name]: stat + 1 });
|
||||
sound.play();
|
||||
setTimeout(() => getCheer().play(), 1800)
|
||||
}}>
|
||||
{COLUMNS.map(i =>
|
||||
i <= stat ?
|
||||
<Icon key={i} emoji={emoji} className={name} />
|
||||
: <div key={i} className={`${name} cell`} />
|
||||
)}
|
||||
<div
|
||||
className="delete cell checked"
|
||||
onClick={e => {
|
||||
e.stopPropagation();
|
||||
setStats({...stats, [name]: Math.max(0, stat - 1)});
|
||||
}}
|
||||
>
|
||||
🚫
|
||||
</div>
|
||||
</div>
|
||||
};
|
||||
|
||||
export default Row;
|
0
src/ScoreCounter.css
Normal file
0
src/ScoreCounter.css
Normal file
72
src/ScoreCounter.tsx
Normal file
72
src/ScoreCounter.tsx
Normal file
|
@ -0,0 +1,72 @@
|
|||
import React, {useState, useEffect} from 'react';
|
||||
import {useSpring, animated} from '@react-spring/web';
|
||||
|
||||
import './ScoreCounter.css';
|
||||
|
||||
let scrollInterval: ReturnType<typeof setInterval>;
|
||||
|
||||
const points = {
|
||||
attempt: 1,
|
||||
scheduledPoop: 5,
|
||||
selfPoop: 10,
|
||||
cleanUndies: 50,
|
||||
streakBonus: [50, 60, 80, 100, 120, 140, 150]
|
||||
};
|
||||
|
||||
const calcPoints = (stats: Stats) => {
|
||||
return stats.attempts * points.attempt +
|
||||
stats.scheduledPoops * points.scheduledPoop +
|
||||
stats.selfPoops * points.selfPoop +
|
||||
stats.cleanUndies * points.cleanUndies;
|
||||
//+ stats.streaks.reduce((p,s,i) => p + s * points.streakBonus[i]);
|
||||
};
|
||||
|
||||
const getShakeStyle = () => {
|
||||
const xr = Math.random();
|
||||
const yr = Math.random();
|
||||
const rr = Math.random();
|
||||
const sr = Math.random();
|
||||
return {
|
||||
x: Math.floor(xr * 30) - 15,
|
||||
y: Math.floor(yr * 30) - 15,
|
||||
rotate: Math.floor((rr * 40)) - 20,
|
||||
scale: sr * 0.3 + .80
|
||||
};
|
||||
};
|
||||
|
||||
const ScoreCounter = ({stats}: {stats: Stats}) => {
|
||||
const score = calcPoints(stats);
|
||||
const [showing, setShowing] = useState<number>(score);
|
||||
|
||||
const restState = { x: 0, y: 0, rotate: 0, scale: 1};
|
||||
|
||||
const [springs, api] = useSpring(() => (restState));
|
||||
|
||||
useEffect(() => {
|
||||
scrollInterval = setInterval(() => {
|
||||
setShowing(n => {
|
||||
const delta = Math.ceil(Math.abs(n - score)/5);
|
||||
if(n < score) {
|
||||
api.start(getShakeStyle());
|
||||
return n + delta;
|
||||
} else if( n > score) {
|
||||
api.start(getShakeStyle());
|
||||
return n - delta;
|
||||
} else {
|
||||
clearInterval(scrollInterval);
|
||||
Promise.all(api.start(
|
||||
{x:0, y: -10, rotate: 0, scale: 1.25})).then(() => {
|
||||
api.start(restState);
|
||||
});;
|
||||
return n;
|
||||
}
|
||||
});
|
||||
}, 80);
|
||||
|
||||
return () => clearInterval(scrollInterval);
|
||||
}, [score]);
|
||||
|
||||
return <animated.span style={{display: "inline-block", ...springs}} className="score-counter">{showing}</animated.span>;
|
||||
};
|
||||
|
||||
export default ScoreCounter;
|
106
src/hooks/localStorage.ts
Normal file
106
src/hooks/localStorage.ts
Normal file
|
@ -0,0 +1,106 @@
|
|||
let isLocalStorageEnabled = true;
|
||||
|
||||
interface MemoryStore {
|
||||
[key: string]: string;
|
||||
}
|
||||
|
||||
const memoryStorage: MemoryStore = {};
|
||||
|
||||
const subscriptions = new EventTarget();
|
||||
|
||||
export interface LocalStorageEvent extends CustomEvent {
|
||||
key: string;
|
||||
newValue: string;
|
||||
}
|
||||
|
||||
export const getLocalStorage = (key: string): string | null => {
|
||||
if (!isLocalStorageEnabled) {
|
||||
return Object.prototype.hasOwnProperty.call(memoryStorage, key) ? memoryStorage[key] : null;
|
||||
}
|
||||
|
||||
try {
|
||||
const value = window.localStorage.getItem(key);
|
||||
if (value && memoryStorage[key] !== value) {
|
||||
memoryStorage[key] = value;
|
||||
}
|
||||
return value;
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('localStorage is diasabled using memory storage instead.', e);
|
||||
isLocalStorageEnabled = false;
|
||||
return getLocalStorage(key);
|
||||
}
|
||||
};
|
||||
|
||||
export const setLocalStorage = (key: string, newValue: string): void => {
|
||||
const event = new CustomEvent('local-storage-change', { detail: { key, newValue } });
|
||||
|
||||
if (!isLocalStorageEnabled) {
|
||||
memoryStorage[key] = newValue;
|
||||
subscriptions.dispatchEvent(event);
|
||||
return;
|
||||
}
|
||||
try {
|
||||
window.localStorage.setItem(key, newValue);
|
||||
memoryStorage[key] = newValue;
|
||||
subscriptions.dispatchEvent(event);
|
||||
} catch (e) {
|
||||
// eslint-disable-next-line no-console
|
||||
console.warn('localStorage is diasabled using memory storage instead.', e);
|
||||
isLocalStorageEnabled = false;
|
||||
return setLocalStorage(key, newValue);
|
||||
}
|
||||
};
|
||||
|
||||
export const isKeySet = (key: string): boolean => {
|
||||
if (isLocalStorageEnabled) {
|
||||
try {
|
||||
for (let i = 0; i < window.localStorage.length; i++) {
|
||||
if (window.localStorage.key(i) === key) {
|
||||
return true;
|
||||
}
|
||||
}
|
||||
} catch {
|
||||
// ignore the error
|
||||
}
|
||||
}
|
||||
return Object.keys(memoryStorage).includes(key);
|
||||
};
|
||||
|
||||
// This function allows you to subscribe to localStorage changes. By default
|
||||
// this includes changes that are made in other tabs/windows of the same
|
||||
// browser. The 'callback' will be called whenever a change is made to
|
||||
// localStorage at the given 'key'. An unsubscibe function is returned, which
|
||||
// you can call when you are ready to unsubscribe.
|
||||
export const subscribeToLocalStorage = (
|
||||
key: string,
|
||||
callback: (value: string) => void,
|
||||
options?: { excludeExternal?: boolean }
|
||||
): (() => void) => {
|
||||
const { excludeExternal } = options || {};
|
||||
const updateHandler = (e: LocalStorageEvent | { detail: { key: string; newValue: string } }) => {
|
||||
const { key: eventKey, newValue } = 'detail' in e ? e.detail : e;
|
||||
if (eventKey === key) {
|
||||
callback(newValue);
|
||||
}
|
||||
};
|
||||
// subscribe to events for this instance of the page only
|
||||
subscriptions.addEventListener(
|
||||
'local-storage-change',
|
||||
updateHandler as EventListenerOrEventListenerObject
|
||||
);
|
||||
|
||||
// if not excluded, subscribe to events for all instances of the page
|
||||
!excludeExternal &&
|
||||
window.addEventListener('storage', updateHandler as EventListenerOrEventListenerObject);
|
||||
|
||||
// return a function that will be used to unsubscribe to these events.
|
||||
return () => {
|
||||
subscriptions.removeEventListener(
|
||||
'local-storage-change',
|
||||
updateHandler as EventListenerOrEventListenerObject
|
||||
);
|
||||
!excludeExternal &&
|
||||
window.removeEventListener('storage', updateHandler as EventListenerOrEventListenerObject);
|
||||
};
|
||||
};
|
75
src/hooks/useLocalStorage.ts
Normal file
75
src/hooks/useLocalStorage.ts
Normal file
|
@ -0,0 +1,75 @@
|
|||
import { useState, useEffect, useCallback, useRef, useMemo } from 'react';
|
||||
|
||||
import { getLocalStorage, subscribeToLocalStorage, setLocalStorage } from './localStorage';
|
||||
|
||||
export const AVAILABLE_KEYS = [
|
||||
"stats",
|
||||
"name",
|
||||
"password"
|
||||
] as const;
|
||||
export type LocalStorageKey = typeof AVAILABLE_KEYS[number];
|
||||
interface LocalStorageOptions<T> {
|
||||
excludeExternal?: boolean;
|
||||
defaultValue?: T;
|
||||
}
|
||||
|
||||
export const useLocalStorage = <T extends string = string>(
|
||||
key: LocalStorageKey,
|
||||
options: LocalStorageOptions<T> = {}
|
||||
): [T | null, (value: T) => void] => {
|
||||
// We track the localStorage value as state so that we can re-render when it
|
||||
// is updated (see the subscription useEffect for how that works)
|
||||
const [valueState, setValueState] = useState<T | null>(
|
||||
(getLocalStorage(key) as T) || options.defaultValue || null
|
||||
);
|
||||
|
||||
// This ref is needed because we don't want to ever redefine the setValue
|
||||
// function but we want to be able to use whatever the latest key is when we
|
||||
// do setValue
|
||||
const keyRef = useRef<string>(key);
|
||||
useEffect(() => {
|
||||
keyRef.current = key;
|
||||
}, [key]);
|
||||
|
||||
// We want this set function to never be redefined, so we use useCallback with
|
||||
// an empty array and only reference the key by ref
|
||||
const setValue = useCallback((value: T) => {
|
||||
setLocalStorage(keyRef.current, value);
|
||||
}, []);
|
||||
|
||||
// subscribe to localstorage changes and set the `currentValue` state on
|
||||
// change.
|
||||
useEffect(() => {
|
||||
// make sure to return. The subscribe function returns an unsubscribe
|
||||
// function
|
||||
return subscribeToLocalStorage(key, setValueState as (val: string) => void, options);
|
||||
}, [key, options]);
|
||||
|
||||
return [valueState, setValue];
|
||||
};
|
||||
|
||||
export const useJSONLocalStorage = <T>(
|
||||
key: LocalStorageKey,
|
||||
defaultValue: T,
|
||||
options?: LocalStorageOptions<T>
|
||||
): [T, (newValue: T) => void] => {
|
||||
const [stringValue, setStringValue] = useLocalStorage(key, {
|
||||
...options,
|
||||
defaultValue: defaultValue && JSON.stringify(defaultValue)
|
||||
});
|
||||
|
||||
const value = useMemo(() => {
|
||||
try {
|
||||
return JSON.parse(stringValue as string);
|
||||
} catch {
|
||||
|
||||
return defaultValue;
|
||||
}
|
||||
}, [stringValue, defaultValue]);
|
||||
|
||||
const setValue = (newValue: T) => setStringValue(JSON.stringify(newValue));
|
||||
|
||||
return [value, setValue];
|
||||
};
|
||||
|
||||
export default useLocalStorage;
|
15
src/sound.ts
Normal file
15
src/sound.ts
Normal file
|
@ -0,0 +1,15 @@
|
|||
export const toot = new Audio('/sounds/toot.wav');
|
||||
export const flush = new Audio('/sounds/flush.wav');
|
||||
export const darn = new Audio('/sounds/darn.mp3');
|
||||
export const woohoo = new Audio('/sounds/woohoo.ogg');
|
||||
export const yay = new Audio('/sounds/yay.mp3');
|
||||
export const yipee = new Audio('/sounds/yipee.mp3');
|
||||
export const starman = new Audio('/sounds/starman.mp3');
|
||||
|
||||
let count = 0;
|
||||
const cheers = [woohoo, yay, yipee];
|
||||
|
||||
export const getCheer = () => {
|
||||
count = (count + 1) % 3;
|
||||
return cheers[count];
|
||||
};
|
11
src/types.d.ts
vendored
Normal file
11
src/types.d.ts
vendored
Normal file
|
@ -0,0 +1,11 @@
|
|||
interface Stats {
|
||||
attempts: number;
|
||||
scheduledPoops: number;
|
||||
selfPoops: number;
|
||||
cleanUndies: number;
|
||||
}
|
||||
|
||||
interface StreakTracker {
|
||||
streaks: number[];
|
||||
currentStreakSince: number;
|
||||
}
|
Loading…
Reference in a new issue