├── .gitignore ├── .prettierrc ├── LICENSE ├── README.md ├── assets ├── objecthooks-screencap1.gif ├── objecthooks-screencap2.gif ├── objecthooks-screencap3.gif ├── objecthooks-screencap4.gif ├── screencap.gif └── screencap2.gif ├── package.json ├── packages ├── name-that-dog │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── BreedForm.tsx │ │ │ ├── BreedIndex.tsx │ │ │ ├── BreedLink.tsx │ │ │ ├── DogImage.tsx │ │ │ ├── Game.tsx │ │ │ ├── RandomForm.tsx │ │ │ ├── Spinner.scss │ │ │ ├── Spinner.tsx │ │ │ ├── ThemeProvider.tsx │ │ │ ├── UpdateSection.tsx │ │ │ └── tsconfig.json │ │ ├── foo.ts │ │ ├── hooks │ │ │ └── useGameLogic.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── logic │ │ │ ├── ApiFetch.ts │ │ │ ├── ArticleFetch.ts │ │ │ ├── Breeds.ts │ │ │ ├── DogApiFetch.ts │ │ │ ├── GameLogic.ts │ │ │ ├── ImageSearch.ts │ │ │ ├── SearchTerms.ts │ │ │ ├── Update.ts │ │ │ └── WikiApiFetch.ts │ │ ├── mdx.d.ts │ │ └── utils.ts │ ├── tsconfig.json │ └── yarn.lock ├── notebook │ ├── .gitignore │ ├── package.json │ ├── src │ │ ├── components │ │ │ ├── App.scss │ │ │ ├── App.tsx │ │ │ ├── NoteEditor.scss │ │ │ ├── NoteEditor.tsx │ │ │ ├── NoteSelect.scss │ │ │ ├── NoteSelect.tsx │ │ │ ├── StyleButton.scss │ │ │ └── StyleButton.tsx │ │ ├── hooks │ │ │ └── useNotebooks.ts │ │ ├── index.html │ │ ├── index.tsx │ │ ├── logic │ │ │ ├── Database.ts │ │ │ ├── EditorLogic.ts │ │ │ └── NotebookSelectorLogic.ts │ │ └── types.ts │ ├── tsconfig.json │ └── yarn.lock └── object-hooks │ ├── package.json │ ├── src │ ├── index.ts │ ├── types.ts │ ├── useArray.ts │ ├── useArrays.ts │ ├── useForceUpdate.ts │ ├── useInstance.ts │ ├── useInstances.ts │ ├── useObject.ts │ └── useObjects.ts │ └── tsconfig.json ├── tsconfig.json └── yarn.lock /.gitignore: -------------------------------------------------------------------------------- 1 | node_modules 2 | lib 3 | .DS_Store 4 | yarn-error.log -------------------------------------------------------------------------------- /.prettierrc: -------------------------------------------------------------------------------- 1 | { 2 | "singleQuote": true, 3 | "trailingComma": "es5" 4 | } 5 | -------------------------------------------------------------------------------- /LICENSE: -------------------------------------------------------------------------------- 1 | MIT License 2 | 3 | Copyright (c) 2020 Ryan Lynch 4 | 5 | Permission is hereby granted, free of charge, to any person obtaining a copy 6 | of this software and associated documentation files (the "Software"), to deal 7 | in the Software without restriction, including without limitation the rights 8 | to use, copy, modify, merge, publish, distribute, sublicense, and/or sell 9 | copies of the Software, and to permit persons to whom the Software is 10 | furnished to do so, subject to the following conditions: 11 | 12 | The above copyright notice and this permission notice shall be included in all 13 | copies or substantial portions of the Software. 14 | 15 | THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR 16 | IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY, 17 | FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE 18 | AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER 19 | LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, 20 | OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE 21 | SOFTWARE. 22 | -------------------------------------------------------------------------------- /README.md: -------------------------------------------------------------------------------- 1 | # Object Hooks 2 | 3 | The repository is called Object Hooks, which belies its true [purpose](#purpose), but the compiled applications from this repo are a matching game for dogs breeds, and an IndexDB powered notebook! 4 | 5 | ## [🐕🐩.to](https://🐕🐩.to) 6 | 7 | ![Screen capture of application](./assets/screencap.gif) 8 | 9 | ## [📔.to](https://📔.to) 10 | 11 | ![Screen capture of application](./assets/screencap2.gif) 12 | 13 | # Purpose 14 | 15 | The purpose of this repository is to develop and test a group of novel [React Hooks](https://reactjs.org/docs/hooks-intro.html), all of which revolve around mutable state. These hooks are: 16 | 17 | 1. [useObject](#useobject) 18 | 1. [useArray](#usearray) 19 | 1. [useInstance](#useinstance) 20 | 1. [useInstances](#useinstances) 21 | 22 | The examples in this readme are simplified, but look in the source (you can start with the dog matching app's [Game.tsx](./packages/name-that-dog/src/components/Game.tsx)!) for more realistic usages. Lets get started! 23 | 24 | # useObject 25 | 26 | Takes a plain object or array and returns a tuple of a mutable [Proxy](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Proxy) of the first passed object or array, and a function to trigger it to reset to the next passed value: 27 | 28 | ```tsx 29 | import React from 'react'; 30 | 31 | import { useObject } from '../hooks/useObject'; 32 | 33 | export const Counter: React.FC = () => { 34 | const [local, resetLocal] = useObject({ 35 | count: 1, 36 | }); 37 | 38 | return ( 39 | <> 40 | 47 | 54 | 55 | ); 56 | }; 57 | ``` 58 | 59 | ![screen capture of example](./assets/objecthooks-screencap1.gif) 60 | 61 | The Proxy object also implements the [AsyncIterable](https://developer.mozilla.org/en-US/docs/Web/JavaScript/Reference/Global_Objects/Symbol/asyncIterator) interface. This means you can use `await` in an async function to recieve the next state, _and_ you can use a `for await` loop in an async function to asynchronously iterate over future states. Here we add a trivial logger that logs the count. Pretty cool! 62 | 63 | ```tsx 64 | import React, { useEffect } from 'react'; 65 | 66 | import { useObject } from '../hooks/useObject'; 67 | 68 | export const Counter: React.FC = () => { 69 | const [local, resetLocal] = useObject({ 70 | count: 1, 71 | }); 72 | 73 | const logCount = (count: number) => console.log(`Count is: ${count}`); 74 | 75 | const watchCount = async () => { 76 | logCount(local.count); 77 | 78 | for await (const { count } of local) { 79 | logCount(count); 80 | } 81 | }; 82 | 83 | useEffect(() => { 84 | watchCount(); 85 | }, [local]); 86 | 87 | return ( 88 | <> 89 | 96 | 103 | 104 | ); 105 | }; 106 | ``` 107 | 108 | ![sceen capture of example](./assets/objecthooks-screencap2.gif) 109 | 110 | # useArray 111 | 112 | You may be thinking: "What about arrays?" Those work too, so you can mutate and observe them as well! See the source for usage! 113 | 114 | # useInstance 115 | 116 | Now that we have mutable objects, it would be nice if they could do more than hold values. What if they could hold logic, and and type themselves in a way that we can extend them? Fot that, we'll need to use a class, and the `useInstance` hook. This example should look familiar, because its just the first counter example implemented with a class! 117 | 118 | ```tsx 119 | import React from 'react'; 120 | 121 | import { useInstance } from '../hooks/useInstance'; 122 | 123 | class CounterLogic { 124 | count = 1; 125 | 126 | increment() { 127 | this.count++; 128 | } 129 | } 130 | 131 | export const Counter: React.FC = () => { 132 | const [counter, resetCounter] = useInstance(CounterLogic); 133 | 134 | return ( 135 | <> 136 | 143 | 150 | 151 | ); 152 | }; 153 | ``` 154 | 155 | ![screen capture of example](./assets/objecthooks-screencap3.gif) 156 | 157 | The instance of the class, like the object from `useObject`, is also an AsyncIterable of its updates. Here's the second logging example rewritten with the logging logic as its own class. 158 | 159 | ```tsx 160 | import React, { useEffect } from 'react'; 161 | 162 | import { useInstance } from '../hooks/useInstance'; 163 | 164 | class CounterLogic { 165 | count = 1; 166 | 167 | increment() { 168 | this.count++; 169 | } 170 | } 171 | 172 | class CounterLogger { 173 | constructor(private logic: CounterLogic & AsyncIterable) {} 174 | 175 | log(count: number) { 176 | console.log(`Count is: ${count}`); 177 | } 178 | 179 | async watch() { 180 | this.log(this.logic.count); 181 | 182 | for await (const { count } of this.logic) { 183 | this.log(count); 184 | } 185 | } 186 | } 187 | 188 | export const Counter: React.FC = () => { 189 | const [counter, resetCounter] = useInstance(CounterLogic); 190 | const [logger, resetLogger] = useInstance(CounterLogger, counter); 191 | 192 | useEffect(() => { 193 | logger.watch(); 194 | }, [logger]); 195 | 196 | return ( 197 | <> 198 | 205 | 213 | 214 | ); 215 | }; 216 | ``` 217 | 218 | ![screen capture of example](./assets/objecthooks-screencap4.gif) 219 | 220 | One additional thing to note about this example is that the constructors for these classes can take arguments. Just pass them as the remaining arguments to `useInstance`! 221 | 222 | # useInstances 223 | 224 | This one is a little experimental at the moment in terms of its return value, so I'll hold off on documenting it. The idea is "What if I wanted to make a collection of these instances"? For current useage, see the source! 225 | -------------------------------------------------------------------------------- /assets/objecthooks-screencap1.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/objecthooks-screencap1.gif -------------------------------------------------------------------------------- /assets/objecthooks-screencap2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/objecthooks-screencap2.gif -------------------------------------------------------------------------------- /assets/objecthooks-screencap3.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/objecthooks-screencap3.gif -------------------------------------------------------------------------------- /assets/objecthooks-screencap4.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/objecthooks-screencap4.gif -------------------------------------------------------------------------------- /assets/screencap.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/screencap.gif -------------------------------------------------------------------------------- /assets/screencap2.gif: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/assets/screencap2.gif -------------------------------------------------------------------------------- /package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "object-hooks-test", 3 | "version": "0.0.1", 4 | "main": "index.js", 5 | "repository": "git@github.com:shiftyp/useclass-test.git", 6 | "author": "Ryan Lynch ", 7 | "license": "MIT", 8 | "private": true, 9 | "workspaces": [ 10 | "./packages/*" 11 | ], 12 | "scripts": { 13 | "start:notebook": "yarn workspace notebook start", 14 | "start:dog": "yarn workspace name-that-dog start", 15 | "start": "yarn start:notebook" 16 | } 17 | } 18 | -------------------------------------------------------------------------------- /packages/name-that-dog/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | dist 3 | node_modules 4 | .cache 5 | -------------------------------------------------------------------------------- /packages/name-that-dog/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "name-that-dog", 3 | "version": "0.0.3", 4 | "description": "A dog matching game built with object hooks", 5 | "main": "src/index.tsx", 6 | "dependencies": { 7 | "@rebass/forms": "^4.0.6", 8 | "@rebass/preset": "^4.0.5", 9 | "@types/rebass": "^4.0.5", 10 | "emotion-theming": "^10.0.27", 11 | "object-hooks": "^0.0.2", 12 | "react": "^16.13.1", 13 | "react-dom": "16.13.1", 14 | "react-masonry-component": "6.2.1", 15 | "rebass": "^4.0.7" 16 | }, 17 | "devDependencies": { 18 | "@types/react": "16.9.19", 19 | "@types/react-dom": "16.9.5", 20 | "parcel-bundler": "1.12.4", 21 | "sass": "1.26.3", 22 | "serverless": "^1.68.0", 23 | "serverless-plugin-typescript": "^1.1.9", 24 | "typescript": "^3.8.3" 25 | }, 26 | "scripts": { 27 | "start": "parcel ./src/index.html", 28 | "build": "parcel build ./src/index.html" 29 | }, 30 | "browserslist": [ 31 | ">0.2%", 32 | "not dead", 33 | "not ie <= 11", 34 | "not op_mini all" 35 | ], 36 | "private": true 37 | } 38 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/BreedForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { useRef, FormEvent, ChangeEvent } from 'react'; 2 | import { Select } from '@rebass/forms'; 3 | import { Button, Box, Flex } from 'rebass'; 4 | 5 | import { SearchTerms } from '../logic/SearchTerms'; 6 | 7 | export const BreedForm: React.FC<{ 8 | terms: SearchTerms; 9 | addDog: () => void; 10 | }> = ({ terms, addDog }) => { 11 | const onListSelectChange = (e: ChangeEvent) => { 12 | terms.selected = e.target.value; 13 | }; 14 | 15 | const onSecondaryListSelectChange = (e: ChangeEvent) => { 16 | terms.secondarySelected = e.target.value; 17 | }; 18 | 19 | const onFormSubmit = (e: FormEvent) => { 20 | e.preventDefault(); 21 | addDog(); 22 | }; 23 | 24 | return ( 25 | 26 | 27 | {!!terms.breedNames.length && ( 28 | 38 | )} 39 | 40 | 41 | {!!terms.secondaryBreedNames.length && ( 42 | 52 | )} 53 | 54 | 55 | 56 | 57 | 58 | ); 59 | }; 60 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/BreedIndex.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Flex, Card } from 'rebass'; 4 | import { BreedLink } from './BreedLink'; 5 | 6 | export const BreedIndex: React.FC<{ 7 | counts: Record; 8 | selectMode: boolean; 9 | onSelect: (breed: string) => void; 10 | }> = ({ counts, selectMode, onSelect }) => { 11 | return ( 12 | 13 | {Object.keys(counts).map((breed) => ( 14 | onSelect(breed) : () => {}} 20 | /> 21 | ))} 22 | 23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/BreedLink.tsx: -------------------------------------------------------------------------------- 1 | import React, { useEffect } from 'react'; 2 | 3 | import { Text, Link, Button, Card } from 'rebass'; 4 | 5 | import { useInstance } from 'object-hooks'; 6 | 7 | import { ArticleFetch } from '../logic/ArticleFetch'; 8 | 9 | export const BreedLink: React.FC<{ 10 | breed: string; 11 | count: number; 12 | buttonMode?: boolean; 13 | onClick: () => void; 14 | }> = ({ breed, count, buttonMode = false, onClick }) => { 15 | const [article] = useInstance(ArticleFetch); 16 | 17 | useEffect(() => { 18 | article.load(breed); 19 | }, [article, breed]); 20 | 21 | const text = 22 | article.data?.href && !buttonMode ? ( 23 | 24 | 29 | {article.data?.title || breed} 30 | 31 | : {count} 32 | 33 | ) : ( 34 | 35 | {article.data?.title || breed}: {count} 36 | 37 | ); 38 | 39 | return buttonMode ? ( 40 | 43 | ) : ( 44 | {text} 45 | ); 46 | }; 47 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/DogImage.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { Image, Box } from 'rebass'; 4 | 5 | import { ImageSearch } from '../logic/ImageSearch'; 6 | 7 | export const DogImage: React.FC<{ 8 | imageSearch: ImageSearch; 9 | onClick: (e: React.MouseEvent) => void; 10 | fadeOut: boolean; 11 | }> = ({ imageSearch, onClick, fadeOut }) => { 12 | return ( 13 | 14 | {imageSearch.data ? ( 15 | {imageSearch.breed 25 | ) : null} 26 | 27 | ); 28 | }; 29 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/Game.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import Masonry from 'react-masonry-component'; 4 | import { Button, Flex, Box, Text } from 'rebass'; 5 | import { Checkbox, Label } from '@rebass/forms'; 6 | 7 | import { BreedForm } from './BreedForm'; 8 | import { RandomForm } from './RandomForm'; 9 | import { DogImage } from './DogImage'; 10 | import { BreedIndex } from './BreedIndex'; 11 | import { UpdateSection } from './UpdateSection'; 12 | import { ThemeProvider } from './ThemeProvider'; 13 | 14 | import { useGameLogic } from '../hooks/useGameLogic'; 15 | 16 | export const Game: React.FC = () => { 17 | const { 18 | searches, 19 | terms, 20 | counts, 21 | logic, 22 | breeds, 23 | onImageClick, 24 | reset, 25 | } = useGameLogic(); 26 | 27 | const images: React.ReactNode[] = []; 28 | 29 | for (let i = searches.length - 1; i >= 0; i--) { 30 | const search = searches[i]; 31 | 32 | images.push( 33 | 39 | ); 40 | } 41 | 42 | return ( 43 | 44 | 45 | {logic.randomMode ? ( 46 | 47 | ) : ( 48 | 49 | )} 50 | 51 | 52 | 53 | 54 | 63 | 64 | 65 | 70 | {images} 71 | 72 | ); 73 | }; 74 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/RandomForm.tsx: -------------------------------------------------------------------------------- 1 | import React, { FormEvent, ChangeEvent } from 'react'; 2 | import { Input } from '@rebass/forms'; 3 | import { Button, Box, Flex } from 'rebass'; 4 | 5 | import { useObject } from 'object-hooks'; 6 | 7 | import { SearchTerms } from '../logic/SearchTerms'; 8 | 9 | export const RandomForm: React.FC<{ 10 | terms: SearchTerms; 11 | addDog: () => void; 12 | }> = ({ terms, addDog }) => { 13 | const [local] = useObject({ 14 | numDogs: 5, 15 | }); 16 | 17 | const onChange = (e: ChangeEvent) => { 18 | local.numDogs = e.target.valueAsNumber; 19 | }; 20 | 21 | const onFormSubmit = async (e: FormEvent) => { 22 | e.preventDefault(); 23 | for (var i = 0; i < local.numDogs; i++) { 24 | terms.randomize(); 25 | await addDog(); 26 | } 27 | }; 28 | 29 | return ( 30 | 31 | 32 | 39 | 40 | 41 | 42 | 43 | 44 | ); 45 | }; 46 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/Spinner.scss: -------------------------------------------------------------------------------- 1 | .spinner { 2 | display: flex; 3 | position: fixed; 4 | top: 0; 5 | left: 0; 6 | right: 0; 7 | bottom: 0; 8 | background-color: rgba(0, 0, 0, 0.2); 9 | align-items: center; 10 | justify-content: center; 11 | } 12 | 13 | .lds-hourglass { 14 | display: inline-block; 15 | position: relative; 16 | width: 80px; 17 | height: 80px; 18 | } 19 | .lds-hourglass:after { 20 | content: ' '; 21 | display: block; 22 | border-radius: 50%; 23 | width: 0; 24 | height: 0; 25 | margin: 8px; 26 | box-sizing: border-box; 27 | border: 32px solid #fff; 28 | border-color: #fff transparent #fff transparent; 29 | animation: lds-hourglass 1.2s infinite; 30 | } 31 | @keyframes lds-hourglass { 32 | 0% { 33 | transform: rotate(0); 34 | animation-timing-function: cubic-bezier(0.55, 0.055, 0.675, 0.19); 35 | } 36 | 50% { 37 | transform: rotate(900deg); 38 | animation-timing-function: cubic-bezier(0.215, 0.61, 0.355, 1); 39 | } 40 | 100% { 41 | transform: rotate(1800deg); 42 | } 43 | } 44 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/Spinner.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import './Spinner.scss'; 4 | 5 | export const Spinner = () => { 6 | return ( 7 |
8 |
9 |
10 | ); 11 | }; 12 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/ThemeProvider.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { ThemeProvider as InnerThemeProvider } from 'emotion-theming'; 3 | import theme from '@rebass/preset'; 4 | 5 | export const ThemeProvider: React.FC = ({ children }) => ( 6 | {children} 7 | ); 8 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/UpdateSection.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Update } from '../logic/Update'; 3 | import { Spinner } from './Spinner'; 4 | 5 | export const UpdateSection: React.FC<{ updates: Readonly[] }> = ({ 6 | updates, 7 | children, 8 | }) => { 9 | const { errors, updating } = updates.reduce( 10 | (acc, update) => { 11 | return { 12 | errors: update.error ? [...acc.errors, update.error] : acc.errors, 13 | updating: acc.updating || update.updating, 14 | }; 15 | }, 16 | { 17 | errors: [] as Error[], 18 | updating: false, 19 | } 20 | ); 21 | 22 | return ( 23 | <> 24 |
    25 | {errors.map((err, i) => ( 26 |
  • {err.message}
  • 27 | ))} 28 |
29 | {children} 30 | {updating ? : null} 31 | 32 | ); 33 | }; 34 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/components/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "include": ["./src/*"], 3 | "compilerOptions": { 4 | "strict": true, 5 | "esModuleInterop": true, 6 | "lib": ["dom", "esnext"], 7 | "jsx": "react", 8 | "target": "es5", 9 | "allowJs": true, 10 | "skipLibCheck": true, 11 | "allowSyntheticDefaultImports": true, 12 | "forceConsistentCasingInFileNames": true, 13 | "module": "esnext", 14 | "moduleResolution": "node", 15 | "resolveJsonModule": true, 16 | "isolatedModules": true, 17 | "noEmit": true, 18 | "noImplicitAny": false 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/foo.ts: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | const Link = React.forwa(); 4 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/hooks/useGameLogic.ts: -------------------------------------------------------------------------------- 1 | import { useEffect } from 'react'; 2 | 3 | import { ImageSearch } from '../logic/ImageSearch'; 4 | import { Breeds } from '../logic/Breeds'; 5 | import { GameLogic } from '../logic/GameLogic'; 6 | import { SearchTerms } from '../logic/SearchTerms'; 7 | 8 | import { 9 | useObject, 10 | useArray, 11 | useInstances, 12 | useInstance, 13 | HooksProxy, 14 | } from 'object-hooks'; 15 | 16 | export const useGameLogic = () => { 17 | const [breeds] = useInstance(Breeds); 18 | const [terms, resetTerms] = useInstance(SearchTerms, breeds); 19 | const [counts, resetCounts] = useObject>({}); 20 | const [searches, resetSearches] = useArray>([]); 21 | const createSearch = useInstances(ImageSearch); 22 | 23 | const [logic, resetGame] = useInstance( 24 | GameLogic, 25 | terms, 26 | counts, 27 | searches, 28 | createSearch 29 | ); 30 | 31 | const onImageClick = (search: ImageSearch & AsyncIterable) => ( 32 | e: React.MouseEvent 33 | ) => { 34 | e.stopPropagation(); 35 | logic.startSelectMode(search); 36 | }; 37 | 38 | useEffect(() => { 39 | breeds.load(); 40 | }, [breeds]); 41 | 42 | return { 43 | logic, 44 | counts, 45 | searches, 46 | terms, 47 | reset: () => { 48 | resetCounts(); 49 | resetSearches(); 50 | resetTerms(); 51 | resetGame(); 52 | }, 53 | onImageClick, 54 | breeds, 55 | }; 56 | }; 57 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/index.html: -------------------------------------------------------------------------------- 1 | 2 | 3 | 4 | 5 | 6 | Object Hooks Test 7 | 8 | 9 |
10 | 11 | 12 | 13 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/index.tsx: -------------------------------------------------------------------------------- 1 | import * as React from 'react'; 2 | import { render } from 'react-dom'; 3 | 4 | import { Game } from './components/Game'; 5 | import { ThemeProvider } from './components/ThemeProvider'; 6 | 7 | const rootElement = document.getElementById('root'); 8 | render( 9 | 10 | 11 | , 12 | rootElement 13 | ); 14 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/ApiFetch.ts: -------------------------------------------------------------------------------- 1 | import { Update } from './Update'; 2 | 3 | interface ApiResponse { 4 | message: Message; 5 | } 6 | 7 | export abstract class ApiFetch extends Update { 8 | abstract readonly apiBase: string; 9 | abstract readonly apiSuffix?: string; 10 | 11 | abstract transform: (response: Response) => Data; 12 | 13 | protected fetch(path: string, info?: RequestInit) { 14 | return this.update( 15 | fetch(`${this.apiBase}${path}${this.apiSuffix || ''}`, info) 16 | .then((resp) => resp.json()) 17 | .then((resp) => this.transform(resp)) 18 | ); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/ArticleFetch.ts: -------------------------------------------------------------------------------- 1 | import { WikiApiFetch } from './WikiApiFetch'; 2 | 3 | interface ArticleData { 4 | href: string; 5 | title: string; 6 | } 7 | 8 | type ArticleResponse = [string, string[], string[], string[]]; 9 | export class ArticleFetch extends WikiApiFetch { 10 | private static cache: Record = {}; 11 | 12 | transform = ([search, terms, _, articles]) => ({ 13 | href: articles[0], 14 | title: terms[0], 15 | }); 16 | 17 | async load(breed: string) { 18 | if (ArticleFetch.cache[breed]) { 19 | this.success(ArticleFetch.cache[breed]); 20 | return; 21 | } 22 | 23 | const terms = breed.split(' '); 24 | 25 | const possibleTerms = [ 26 | `${breed} dog`, 27 | `${terms.reverse().join(' ')} dog`, 28 | `${terms.reverse().join(' ')}`, 29 | breed, 30 | ]; 31 | 32 | let termCounter = 0; 33 | 34 | do { 35 | await this.fetch( 36 | `action=opensearch&search=${encodeURIComponent( 37 | possibleTerms[termCounter++] 38 | )}&limit=1&namespace=0` 39 | ); 40 | } while ( 41 | !this.data?.href && 42 | !this.error && 43 | termCounter < possibleTerms.length 44 | ); 45 | 46 | if (!this.data?.href) { 47 | this.failure(new Error(`Couldnt find ${breed} on wikipedia`)); 48 | } 49 | 50 | if (!this.error && this.data) { 51 | ArticleFetch.cache[breed] = this.data; 52 | } 53 | } 54 | } 55 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/Breeds.ts: -------------------------------------------------------------------------------- 1 | import { DogApiFetch } from './DogApiFetch'; 2 | 3 | type BreedsRecord = Record; 4 | 5 | export class Breeds extends DogApiFetch { 6 | load() { 7 | return this.fetch('/breeds/list/all'); 8 | } 9 | } 10 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/DogApiFetch.ts: -------------------------------------------------------------------------------- 1 | import { ApiFetch } from './ApiFetch'; 2 | 3 | interface DogApiResponse { 4 | message: Data; 5 | } 6 | 7 | export abstract class DogApiFetch extends ApiFetch< 8 | Data, 9 | DogApiResponse 10 | > { 11 | apiBase = 'https://dog.ceo/api'; 12 | apiSuffix = undefined; 13 | 14 | transform = (response) => response.message; 15 | } 16 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/GameLogic.ts: -------------------------------------------------------------------------------- 1 | import { useRef } from 'react'; 2 | 3 | import { ImageSearch } from './ImageSearch'; 4 | import { SearchTerms } from './SearchTerms'; 5 | 6 | import { shuffle } from '../utils'; 7 | import { HooksProxy } from '../hooks/types'; 8 | 9 | export class GameLogic { 10 | searchId = 0; 11 | 12 | randomMode = true; 13 | selectMode = false; 14 | selectedImageSearch: (ImageSearch & AsyncIterable) | null = null; 15 | 16 | constructor( 17 | private terms: HooksProxy, 18 | private counts: HooksProxy>, 19 | private searches: HooksProxy[]>, 20 | private createSearch: ( 21 | ...args: ConstructorParameters 22 | ) => HooksProxy 23 | ) {} 24 | 25 | addDog = async () => { 26 | const imageSearch = this.createSearch(this.terms, this.searchId++); 27 | 28 | this.counts[imageSearch.breed] = (this.counts[imageSearch.breed] || 0) + 1; 29 | 30 | this.searches.push(imageSearch); 31 | shuffle(this.searches); 32 | 33 | await imageSearch.search(); 34 | }; 35 | 36 | toggleRandomMode = () => { 37 | this.randomMode = !this.randomMode; 38 | }; 39 | 40 | selectBreed = (breed: string) => { 41 | if (this.selectedImageSearch && this.selectedImageSearch.breed === breed) { 42 | const index = this.searches.indexOf(this.selectedImageSearch); 43 | 44 | if (index !== -1) { 45 | this.searches.splice(index, 1); 46 | this.counts[breed] = (this.counts[breed] || 1) - 1; 47 | 48 | if (this.counts[breed] === 0) { 49 | delete this.counts[breed]; 50 | } 51 | } 52 | 53 | this.endSelectMode(); 54 | } 55 | }; 56 | 57 | startSelectMode(search: ImageSearch & AsyncIterable) { 58 | if (this.selectMode) this.endSelectMode(); 59 | this.selectedImageSearch = search; 60 | this.selectMode = true; 61 | window.addEventListener('click', this.endSelectMode); 62 | } 63 | 64 | endSelectMode() { 65 | this.selectMode = false; 66 | window.removeEventListener('click', this.endSelectMode); 67 | } 68 | } 69 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/ImageSearch.ts: -------------------------------------------------------------------------------- 1 | import { DogApiFetch } from './DogApiFetch'; 2 | import { SearchTerms } from './SearchTerms'; 3 | 4 | export class ImageSearch extends DogApiFetch { 5 | constructor( 6 | searchTerms: SearchTerms, 7 | public id: number, 8 | private terms = searchTerms.toArray() 9 | ) { 10 | super(); 11 | } 12 | 13 | get breed() { 14 | return this.terms.join(' '); 15 | } 16 | 17 | async search() { 18 | return await this.fetch(`/breed/${this.terms.join('/')}/images/random`); 19 | } 20 | } 21 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/SearchTerms.ts: -------------------------------------------------------------------------------- 1 | import { Breeds } from './Breeds'; 2 | 3 | export class SearchTerms { 4 | private _selected: string | null = null; 5 | 6 | get selected() { 7 | return this._selected || ''; 8 | } 9 | 10 | set selected(selected: string) { 11 | this._selected = selected; 12 | this.setDefaultSecondarySelected(); 13 | } 14 | 15 | secondarySelected: string | null = null; 16 | 17 | constructor(private breeds: AsyncIterable & Breeds) { 18 | this.watchBreeds(); 19 | } 20 | 21 | get breedNames() { 22 | const { data } = this.breeds; 23 | 24 | return data ? Object.keys(data) : []; 25 | } 26 | 27 | get secondaryBreedNames(): string[] { 28 | const { data } = this.breeds; 29 | 30 | return data && this.selected ? data[this.selected] : []; 31 | } 32 | 33 | toArray(): string[] { 34 | const terms = [this.selected]; 35 | 36 | if (this.secondarySelected) { 37 | terms.push(this.secondarySelected); 38 | } 39 | 40 | return terms as string[]; 41 | } 42 | 43 | randomize() { 44 | const { breedNames } = this; 45 | 46 | this.selected = breedNames[Math.floor(Math.random() * breedNames.length)]; 47 | 48 | const { secondaryBreedNames } = this; 49 | 50 | this.secondarySelected = 51 | secondaryBreedNames[ 52 | Math.floor(Math.random() * secondaryBreedNames.length) 53 | ]; 54 | } 55 | 56 | private setDefaultSelected() { 57 | this.selected = this.breedNames[0]; 58 | } 59 | 60 | private setDefaultSecondarySelected() { 61 | this.secondarySelected = this.secondaryBreedNames[0]; 62 | } 63 | 64 | private async watchBreeds() { 65 | for await (const { data } of this.breeds) { 66 | if (data) { 67 | this.setDefaultSelected(); 68 | } 69 | } 70 | } 71 | } 72 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/Update.ts: -------------------------------------------------------------------------------- 1 | export class Update { 2 | updating: boolean = false; 3 | error: Error | null = null; 4 | data: Data | null = null; 5 | 6 | protected async update(promise: Promise) { 7 | this.begin(); 8 | 9 | try { 10 | this.success(await promise); 11 | } catch (error) { 12 | this.failure(error); 13 | } 14 | 15 | this.finish(); 16 | } 17 | 18 | protected begin() { 19 | this.updating = true; 20 | this.error = null; 21 | this.data = null; 22 | } 23 | 24 | protected finish() { 25 | this.updating = false; 26 | } 27 | 28 | protected failure(error: Error) { 29 | this.error = error; 30 | this.data = null; 31 | } 32 | 33 | protected success(data: Data) { 34 | this.data = data; 35 | this.error = null; 36 | } 37 | } 38 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/logic/WikiApiFetch.ts: -------------------------------------------------------------------------------- 1 | import { ApiFetch } from './ApiFetch'; 2 | 3 | export abstract class WikiApiFetch extends ApiFetch< 4 | Data, 5 | Response 6 | > { 7 | apiBase = 'https://en.wikipedia.org/w/api.php?'; 8 | apiSuffix = '&format=json&origin=*'; 9 | } 10 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/mdx.d.ts: -------------------------------------------------------------------------------- 1 | declare module '@mdx-js/react' { 2 | import * as React from 'react'; 3 | type ComponentType = 4 | | 'a' 5 | | 'blockquote' 6 | | 'code' 7 | | 'delete' 8 | | 'em' 9 | | 'h1' 10 | | 'h2' 11 | | 'h3' 12 | | 'h4' 13 | | 'h5' 14 | | 'h6' 15 | | 'hr' 16 | | 'img' 17 | | 'inlineCode' 18 | | 'li' 19 | | 'ol' 20 | | 'p' 21 | | 'pre' 22 | | 'strong' 23 | | 'sup' 24 | | 'table' 25 | | 'td' 26 | | 'thematicBreak' 27 | | 'tr' 28 | | 'ul'; 29 | export type Components = { 30 | [key in ComponentType]?: React.ComponentType<{ children: React.ReactNode }>; 31 | }; 32 | export interface MDXProviderProps { 33 | children: React.ReactNode; 34 | components: Components; 35 | } 36 | export class MDXProvider extends React.Component {} 37 | 38 | export const mdx: any; 39 | } 40 | 41 | declare module '*.mdx' { 42 | let MDXComponent: (props: any) => JSX.Element; 43 | export default MDXComponent; 44 | } 45 | -------------------------------------------------------------------------------- /packages/name-that-dog/src/utils.ts: -------------------------------------------------------------------------------- 1 | export const shuffle = (a: T[]) => { 2 | for (let i = a.length - 1; i > 0; i--) { 3 | const j = Math.floor(Math.random() * (i + 1)); 4 | [a[i], a[j]] = [a[j], a[i]]; 5 | } 6 | return a; 7 | }; 8 | -------------------------------------------------------------------------------- /packages/name-that-dog/tsconfig.json: -------------------------------------------------------------------------------- 1 | { 2 | "compilerOptions": { 3 | "esModuleInterop": true, 4 | "outDir": "lib", 5 | "jsx": "react" 6 | } 7 | } 8 | -------------------------------------------------------------------------------- /packages/name-that-dog/yarn.lock: -------------------------------------------------------------------------------- https://raw.githubusercontent.com/shiftyp/objects/51bfb23fabd76acd77146b3498d0ff7447ec7057/packages/name-that-dog/yarn.lock -------------------------------------------------------------------------------- /packages/notebook/.gitignore: -------------------------------------------------------------------------------- 1 | .DS_STORE 2 | dist 3 | node_modules 4 | .cache 5 | -------------------------------------------------------------------------------- /packages/notebook/package.json: -------------------------------------------------------------------------------- 1 | { 2 | "name": "notebook", 3 | "version": "0.0.1", 4 | "description": "Your private notebook", 5 | "main": "src/index.tsx", 6 | "scripts": { 7 | "start": "parcel ./src/index.html", 8 | "build": "parcel build ./src/index.html" 9 | }, 10 | "dependencies": { 11 | "@types/draft-js": "^0.10.40", 12 | "@types/react-select": "^3.0.11", 13 | "classnames": "^2.2.6", 14 | "draft-js": "^0.11.5", 15 | "object-hooks": "^0.0.4", 16 | "react": "^16.13.1", 17 | "react-dom": "16.13.1", 18 | "react-select": "^3.1.0" 19 | }, 20 | "devDependencies": { 21 | "@types/react": "16.9.19", 22 | "@types/react-dom": "16.9.5", 23 | "parcel-bundler": "1.12.4", 24 | "sass": "1.26.3", 25 | "typescript": "^3.8.3" 26 | }, 27 | "private": true 28 | } 29 | -------------------------------------------------------------------------------- /packages/notebook/src/components/App.scss: -------------------------------------------------------------------------------- 1 | body { 2 | margin: 0; 3 | } 4 | 5 | .app-body { 6 | max-width: 35em; 7 | min-height: 100vh; 8 | padding: 1em; 9 | border-left: 1px dotted #ccc; 10 | border-right: 1px dotted #ccc; 11 | margin: 0 auto; 12 | box-sizing: border-box; 13 | } 14 | 15 | .app-header { 16 | display: flex; 17 | flex-direction: row; 18 | border-bottom: 1px dotted #ccc; 19 | padding-bottom: 10px; 20 | box-sizing: border-box; 21 | 22 | & > button { 23 | margin-left: 10px; 24 | } 25 | 26 | & > :nth-child(2) { 27 | flex: 1; 28 | margin-left: 0; 29 | } 30 | font-family: sans-serif; 31 | } 32 | 33 | .app-title { 34 | margin: 0; 35 | padding: 0 10px 0 0; 36 | height: 1em; 37 | } 38 | -------------------------------------------------------------------------------- /packages/notebook/src/components/App.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | 3 | import { NoteEditor } from './NoteEditor'; 4 | import { useNotebooks } from '../hooks/useNotebooks'; 5 | import { NoteSelect } from './NoteSelect'; 6 | import { StyleButton } from './StyleButton'; 7 | 8 | import './App.scss'; 9 | 10 | export const App: React.FC = () => { 11 | const { editor, selector } = useNotebooks(); 12 | return ( 13 |
14 |
15 |

📔.to

16 | 17 | 18 | 19 | 20 | 21 | {editor.currentNote && } 22 |
23 | ); 24 | }; 25 | -------------------------------------------------------------------------------- /packages/notebook/src/components/NoteEditor.scss: -------------------------------------------------------------------------------- 1 | .note-editor-container { 2 | display: flex; 3 | flex-direction: column; 4 | justify-content: stretch; 5 | padding: 14px; 6 | margin: 14px 0 0 0; 7 | & * { 8 | z-index: 0; 9 | } 10 | font-size: 1.1em; 11 | border: 1px solid #eee; 12 | } 13 | -------------------------------------------------------------------------------- /packages/notebook/src/components/NoteEditor.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import { Editor, getDefaultKeyBinding } from 'draft-js'; 3 | import { EditorLogic } from '../logic/EditorLogic'; 4 | 5 | import 'draft-js/dist/Draft.css'; 6 | 7 | import './NoteEditor.scss'; 8 | 9 | export const NoteEditor: React.FC<{ editor: EditorLogic }> = ({ editor }) => { 10 | return ( 11 |
12 | getDefaultKeyBinding(e)} 16 | handleKeyCommand={(command, editorState) => { 17 | editor.updateFromCommand(command); 18 | 19 | if (editor.editorState !== editorState) { 20 | return 'handled'; 21 | } 22 | return 'not-handled'; 23 | }} 24 | /> 25 |
26 | ); 27 | }; 28 | -------------------------------------------------------------------------------- /packages/notebook/src/components/NoteSelect.scss: -------------------------------------------------------------------------------- 1 | .note-select-option { 2 | display: flex; 3 | justify-content: space-between; 4 | padding: 14px 10px; 5 | border-bottom: 1px dotted #ccc; 6 | 7 | &:last-child { 8 | border-bottom: none; 9 | } 10 | } 11 | -------------------------------------------------------------------------------- /packages/notebook/src/components/NoteSelect.tsx: -------------------------------------------------------------------------------- 1 | import React from 'react'; 2 | import Select from 'react-select/creatable'; 3 | 4 | import { EditorLogic } from '../logic/EditorLogic'; 5 | import { NotebookSelectorLogic } from '../logic/NotebookSelectorLogic'; 6 | 7 | import './NoteSelect.scss'; 8 | 9 | export const NoteSelect: React.FC<{ 10 | editor: EditorLogic; 11 | selector: NotebookSelectorLogic; 12 | }> = ({ editor, selector }) => { 13 | const options = selector.currentNotes 14 | ? selector.currentNotes.map(({ id, name, updated }) => ({ 15 | value: id, 16 | label: name, 17 | updated: updated, 18 | })) 19 | : []; 20 | const selectedOption = 21 | editor.currentNote && 22 | options.find((opt) => opt.value === editor.currentNote.id); 23 | return ( 24 |