diff --git a/client/.eslintignore b/client/.eslintignore index 7758a6a..722408a 100644 --- a/client/.eslintignore +++ b/client/.eslintignore @@ -6,4 +6,4 @@ bin dist lib env.d.ts -vite.config.ts \ No newline at end of file +vite.config.ts diff --git a/client/package.json b/client/package.json index f914d61..90e1def 100644 --- a/client/package.json +++ b/client/package.json @@ -18,29 +18,26 @@ "watch": "concurrently --kill-others 'npm run server' 'npm run check-types -- --watch --preserveWatchOutput' 'sleep 3; npm run dev'" }, "dependencies": { - "classnames": "^2.3.1", - "navigo": "^8.11.1", - "qs": "^6.11.0", - "react": "^18.2.0", - "react-dom": "^18.2.0", - "replicache-react": "5.0.1", + "@solidjs/router": "^0.12.4", "shared": "^0.1.0", + "solid-js": "^1.8.15", "todomvc-app-css": "^2.4.2" }, "devDependencies": { - "@rocicorp/eslint-config": "^0.1.2", - "@rocicorp/prettier-config": "^0.1.1", - "@types/react": "^18.0.17", - "@types/react-dom": "^18.0.6", - "@vitejs/plugin-react": "^2.0.1", - "concurrently": "^7.4.0", - "prettier": "^2.2.1", - "typescript": "^4.7.4", - "use-debounce": "^9.0.4", - "vite": "^3.0.7" + "@rocicorp/eslint-config": "^0.5.1", + "@rocicorp/prettier-config": "^0.2.0", + "concurrently": "^8.2.2", + "typescript": "^5.3.3", + "vite": "^5.1.4", + "vite-plugin-solid": "^2.10.1" }, "eslintConfig": { - "extends": "@rocicorp/eslint-config" + "extends": "@rocicorp/eslint-config", + "rules": { + "@typescript-eslint/naming-convention": [ + "off" + ] + } }, "prettier": "@rocicorp/prettier-config" } diff --git a/client/src/app.tsx b/client/src/app.tsx index c40cd1e..f611d5d 100644 --- a/client/src/app.tsx +++ b/client/src/app.tsx @@ -1,140 +1,171 @@ -import {Dialog} from '@headlessui/react'; +import {A, useNavigate, useParams} from '@solidjs/router'; import {nanoid} from 'nanoid'; -import Navigo from 'navigo'; -import {useEffect, useState} from 'react'; -import {ReadTransaction, Replicache} from 'replicache'; -import {useSubscribe} from 'replicache-react'; -import {TodoUpdate, todosByList} from 'shared'; -import {getList, listLists} from 'shared/src/list'; -import Header from './components/header'; -import MainSection from './components/main-section'; -import {Share} from './components/share'; +import { + MutatorDefs, + ReadTransaction, + ReadonlyJSONValue, + Replicache, +} from 'replicache'; +import {List, TodoUpdate, getList, listLists, todosByList} from 'shared'; +import { + Component, + For, + JSX, + createEffect, + createSignal, + on, + onCleanup, +} from 'solid-js'; +import {createStore, reconcile} from 'solid-js/store'; +import Header from './components/header.jsx'; +import MainSection from './components/main-section.jsx'; +import Share from './components/share.jsx'; +import {createEffectAccessor} from './create-effect-accessor.js'; import {M} from './mutators'; // This is the top-level component for our app. -const App = ({ - rep, - userID, - onUserIDChange, -}: { +const App = (props: { rep: Replicache; userID: string; onUserIDChange: (userID: string) => void; }) => { - const router = new Navigo('/'); - const [listID, setListID] = useState(''); - const [showingShare, setShowingShare] = useState(false); - - router.on('/list/:listID', match => { - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {data} = match!; - // eslint-disable-next-line @typescript-eslint/no-non-null-assertion - const {listID} = data!; - setListID(listID); - }); - - useEffect(() => { - router.resolve(); - }, []); + const [showingShare, setShowingShare] = createSignal(false); - // Listen for pokes related to just this list. - useEventSourcePoke(`/api/replicache/poke?channel=list/${listID}`, rep); - // Listen for pokes related to the docs this user has access to. - useEventSourcePoke(`/api/replicache/poke?channel=user/${userID}`, rep); + const params = useParams(); - const lists = useSubscribe(rep, listLists, {default: []}); - lists.sort((a, b) => a.name.localeCompare(b.name)); + createEffect(() => { + const {listID} = params; + if (!listID) { + return; + } + // Listen for pokes related to just this list. + useEventSourcePoke( + `/api/replicache/poke?channel=list/${listID}`, + props.rep, + ); + // Listen for pokes related to the docs this user has access to. + useEventSourcePoke( + `/api/replicache/poke?channel=user/${props.userID}`, + props.rep, + ); + }); - const selectedList = useSubscribe( - rep, - (tx: ReadTransaction) => getList(tx, listID), - {dependencies: [listID]}, + const lists = createEffectAccessor( + set => + onCleanup( + props.rep.subscribe(async tx => { + const arr = await listLists(tx); + return arr.sort((a, b) => a.name.localeCompare(b.name)); + }, set), + ), + [], ); + const selectedList = createEffectAccessor(async set => { + const {listID} = params; + set(await props.rep.query(tx => getList(tx, listID))); + }, undefined); + // Subscribe to all todos and sort them. - const todos = useSubscribe(rep, async tx => todosByList(tx, listID), { - default: [], - dependencies: [listID], - }); - todos.sort((a, b) => a.sort - b.sort); + const todos = subscribeStore( + () => [props.rep, params.listID], + async (tx, listID) => { + const todos = await todosByList(tx, listID); + return todos.sort((a, b) => a.sort - b.sort); + }, + [], + ); + + function subscribeStore< + T extends object, + MD extends MutatorDefs, + Deps extends [...args: ReadonlyJSONValue[]], + >( + deps: () => [Replicache, ...Deps], + query: (tx: ReadTransaction, ...args: Deps) => Promise, + def: T, + ) { + const [state, setState] = createStore(def); + createEffect( + on(deps, ([rep, ...deps]) => + onCleanup( + rep.subscribe( + tx => query(tx, ...deps), + data => setState(reconcile(data, {merge: true})), + ), + ), + ), + ); + return state; + } // Define event handlers and connect them to Replicache mutators. Each // of these mutators runs immediately (optimistically) locally, then runs // again on the server-side automatically. const handleNewItem = (text: string) => { - void rep.mutate.createTodo({ + void props.rep.mutate.createTodo({ id: nanoid(), - listID, + listID: params.listID, text, completed: false, }); }; const handleUpdateTodo = (update: TodoUpdate) => - rep.mutate.updateTodo(update); + props.rep.mutate.updateTodo(update); const handleDeleteTodos = async (ids: string[]) => { for (const id of ids) { - await rep.mutate.deleteTodo(id); + await props.rep.mutate.deleteTodo(id); } }; const handleCompleteTodos = async (completed: boolean, ids: string[]) => { for (const id of ids) { - await rep.mutate.updateTodo({ + await props.rep.mutate.updateTodo({ id, completed, }); } }; + const navigate = useNavigate(); + const handleNewList = async (name: string) => { + const {userID} = props; const id = nanoid(); - await rep.mutate.createList({ + await props.rep.mutate.createList({ id, ownerID: userID, name, }); - router.navigate(`/list/${id}`); + navigate(`/list/${id}`); }; const handleDeleteList = async () => { - await rep.mutate.deleteList(listID); + await props.rep.mutate.deleteList(params.listID); + navigate('/'); }; // Render app. - return (
}> + {list => {list.name}} +
-
+
setShowingShare(!showingShare)} + onUserIDChange={props.onUserIDChange} + onShare={() => setShowingShare(!showingShare())} /> - {selectedList ? ( + {selectedList() ? ( No list selected
)}
-
- setShowingShare(false)}> - +
+ setShowingShare(false)} + class="share-dialog" + > +
); }; function useEventSourcePoke(url: string, rep: Replicache) { - useEffect(() => { + createEffect(() => { const ev = new EventSource(url); ev.onmessage = () => { void rep.pull(); }; - return () => ev.close(); - }, [url, rep]); + onCleanup(() => ev.close()); + }); } export default App; + +const Dialog: Component<{ + open: boolean; + children: JSX.Element; + onClose: () => void; + class?: string; +}> = props => { + let el!: HTMLDialogElement; + const onClick = (e: MouseEvent) => { + const dialogDimensions = el.getBoundingClientRect(); + if ( + e.clientX < dialogDimensions.left || + e.clientX > dialogDimensions.right || + e.clientY < dialogDimensions.top || + e.clientY > dialogDimensions.bottom + ) { + el.close(); + } + }; + + const onClose = () => { + props.onClose(); + }; + + createEffect(() => { + if (props.open) { + el.showModal(); + } else { + el.close(); + } + }); + + return ( + + {props.children} + + ); +}; diff --git a/client/src/components/footer.tsx b/client/src/components/footer.tsx index 3406d5f..f64c3fb 100644 --- a/client/src/components/footer.tsx +++ b/client/src/components/footer.tsx @@ -1,41 +1,40 @@ -import React from 'react'; +import {For} from 'solid-js'; import FilterLink from './link'; const FILTER_TITLES = ['All', 'Active', 'Completed']; -const Footer = ({ - active, - completed, - currentFilter, - onFilter, - onDeleteCompleted, -}: { +const Footer = (props: { active: number; completed: number; currentFilter: string; onFilter: (filter: string) => void; onDeleteCompleted: () => void; }) => { - const itemWord = active === 1 ? 'item' : 'items'; + const itemWord = () => (props.active === 1 ? 'item' : 'items'); return ( -