Refactor row into component

This commit is contained in:
John Shaver 2023-01-18 13:10:26 -08:00
parent 83cd4b2d78
commit 1b083cfb6f
19 changed files with 408 additions and 87 deletions

9
.prettierrc Normal file
View 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

Binary file not shown.

BIN
public/sounds/flush.wav Normal file

Binary file not shown.

BIN
public/sounds/starman.mp3 Normal file

Binary file not shown.

BIN
public/sounds/toot.wav Normal file

Binary file not shown.

BIN
public/sounds/woohoo.ogg Normal file

Binary file not shown.

BIN
public/sounds/yay.mp3 Normal file

Binary file not shown.

BIN
public/sounds/yipee.mp3 Normal file

Binary file not shown.

View file

@ -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
View 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
View 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
View 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
View 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
View file

72
src/ScoreCounter.tsx Normal file
View 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
View 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);
};
};

View 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
View 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
View file

@ -0,0 +1,11 @@
interface Stats {
attempts: number;
scheduledPoops: number;
selfPoops: number;
cleanUndies: number;
}
interface StreakTracker {
streaks: number[];
currentStreakSince: number;
}