Add Remote type constructor. Setup basic State/Msg/effects/Reducer. Format
This commit is contained in:
parent
ae6451e052
commit
473897fdfc
5 changed files with 238 additions and 67 deletions
|
|
@ -1,6 +1,6 @@
|
|||
{
|
||||
"semi": true,
|
||||
"singleQuote": true,
|
||||
"semi": false,
|
||||
"singleQuote": false,
|
||||
"trailingComma": "all",
|
||||
"printWidth": 100
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,16 +1,16 @@
|
|||
import js from '@eslint/js';
|
||||
import prettierConfig from 'eslint-config-prettier';
|
||||
import reactHooks from 'eslint-plugin-react-hooks';
|
||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
||||
import globals from 'globals';
|
||||
import tseslint from 'typescript-eslint';
|
||||
import js from "@eslint/js"
|
||||
import prettierConfig from "eslint-config-prettier"
|
||||
import reactHooks from "eslint-plugin-react-hooks"
|
||||
import reactRefresh from "eslint-plugin-react-refresh"
|
||||
import globals from "globals"
|
||||
import tseslint from "typescript-eslint"
|
||||
|
||||
export default tseslint.config(
|
||||
{
|
||||
ignores: ['dist', 'node_modules'],
|
||||
ignores: ["dist", "node_modules"],
|
||||
},
|
||||
{
|
||||
files: ['src/**/*.{ts,tsx}'],
|
||||
files: ["src/**/*.{ts,tsx}"],
|
||||
extends: [
|
||||
js.configs.recommended,
|
||||
...tseslint.configs.strictTypeChecked,
|
||||
|
|
@ -25,32 +25,32 @@ export default tseslint.config(
|
|||
},
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
"react-hooks": reactHooks,
|
||||
"react-refresh": reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'@typescript-eslint/consistent-type-imports': [
|
||||
'error',
|
||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
||||
"@typescript-eslint/consistent-type-imports": [
|
||||
"error",
|
||||
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||
],
|
||||
'@typescript-eslint/no-unused-vars': [
|
||||
'error',
|
||||
"@typescript-eslint/no-unused-vars": [
|
||||
"error",
|
||||
{
|
||||
argsIgnorePattern: '^_',
|
||||
varsIgnorePattern: '^_',
|
||||
caughtErrorsIgnorePattern: '^_',
|
||||
argsIgnorePattern: "^_",
|
||||
varsIgnorePattern: "^_",
|
||||
caughtErrorsIgnorePattern: "^_",
|
||||
},
|
||||
],
|
||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
||||
'@typescript-eslint/no-floating-promises': 'error',
|
||||
'@typescript-eslint/no-misused-promises': 'error',
|
||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
||||
'default-case': 'off',
|
||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
||||
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||
"@typescript-eslint/no-floating-promises": "error",
|
||||
"@typescript-eslint/no-misused-promises": "error",
|
||||
"@typescript-eslint/no-unnecessary-condition": "error",
|
||||
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||
"@typescript-eslint/prefer-optional-chain": "error",
|
||||
"default-case": "off",
|
||||
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||
},
|
||||
},
|
||||
prettierConfig,
|
||||
);
|
||||
)
|
||||
|
|
|
|||
225
src/App.tsx
225
src/App.tsx
|
|
@ -1,31 +1,61 @@
|
|||
import { z } from 'zod'
|
||||
import { z } from "zod"
|
||||
import { useReducer, useEffect, createContext, useContext } from "react"
|
||||
|
||||
// TODO: Use Material UI later.
|
||||
// TODO: Improve error-handling. Introduce some proper server response.
|
||||
|
||||
// For better type-error messages when you have inexhaustive switch cases.
|
||||
function assertNever(value: never): never {
|
||||
throw new Error(`Unexpected value: ${value}`)
|
||||
}
|
||||
|
||||
// === Image ===
|
||||
type ImageId = string
|
||||
type ImageRef = string
|
||||
|
||||
type Image = {
|
||||
id: ImageId,
|
||||
dimension: Dimension,
|
||||
source: ImageRef,
|
||||
id: ImageId
|
||||
dimension: Dimension
|
||||
source: ImageRef
|
||||
}
|
||||
|
||||
// === Dimension ===
|
||||
|
||||
type Dimension = {
|
||||
width: number,
|
||||
height: number,
|
||||
width: number
|
||||
height: number
|
||||
}
|
||||
const Dimension = {
|
||||
medium: { width: 200, height: 200 } as Dimension,
|
||||
big: { width: 300, height: 500 } as Dimension,
|
||||
}
|
||||
|
||||
// WARNING: page starts at 1
|
||||
type Pagination = {
|
||||
page: number,
|
||||
limit: number, // page size
|
||||
// === Page ===
|
||||
type Page = {
|
||||
page: number
|
||||
limit: number // page size
|
||||
}
|
||||
|
||||
const Pagination = {
|
||||
init(): Pagination { return { page: 1, limit: 10 } },
|
||||
}
|
||||
const LIMIT = 10
|
||||
const FIRST_PAGE = 1
|
||||
|
||||
const Page = {
|
||||
init(): Page {
|
||||
return { page: FIRST_PAGE, limit: LIMIT }
|
||||
},
|
||||
next(page: Page): Page {
|
||||
return { ...page, page: page.page + 1 }
|
||||
},
|
||||
previous(page: Page): Page {
|
||||
// Could do
|
||||
// if (page.page == FIRST_PAGE) { return page }
|
||||
// this preserves identity of object, so is nicer for `useEffect`, but it's waaaay to subtle. So I'm not relying on that.
|
||||
return { ...page, page: Math.max(FIRST_PAGE, page.page - 1) }
|
||||
},
|
||||
eq(page0: Page, page1: Page): boolean {
|
||||
return page0.page === page1.page && page0.limit === page1.limit
|
||||
},
|
||||
}
|
||||
|
||||
// === api ===
|
||||
const picsumApiImageSchema = z.object({
|
||||
|
|
@ -38,7 +68,7 @@ const picsumApiImageSchema = z.object({
|
|||
})
|
||||
type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
|
||||
|
||||
async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiImage[]> {
|
||||
async function getPicsumImages({ page, limit }: Page): Promise<PicsumApiImage[]> {
|
||||
const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`)
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch images: ${response.status}`)
|
||||
|
|
@ -50,29 +80,170 @@ async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiIm
|
|||
return data
|
||||
}
|
||||
|
||||
async function getImages(pagination: Pagination): Promise<Image[]> {
|
||||
const data = await getPicsumImages(pagination)
|
||||
return data.map(({ id, download_url, width, height }) => ({ id: id as ImageId, dimension: { width, height }, source: download_url as ImageRef }))
|
||||
// TODO: We don't really need this.
|
||||
async function getImages(page: Page): Promise<Image[]> {
|
||||
const data = await getPicsumImages(page)
|
||||
return data.map(({ id, download_url, width, height }) => ({
|
||||
id: id as ImageId,
|
||||
dimension: { width, height },
|
||||
source: download_url as ImageRef,
|
||||
}))
|
||||
}
|
||||
|
||||
async function getImageIds(pagination: Pagination): Promise<ImageId[]> {
|
||||
const data = await getPicsumImages(pagination)
|
||||
async function getImageIds(page: Page): Promise<ImageId[]> {
|
||||
const data = await getPicsumImages(page)
|
||||
return data.map(({ id }) => id)
|
||||
}
|
||||
|
||||
// Use this for `<img src=... />`
|
||||
function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
|
||||
return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}`;
|
||||
return `https://picsum.photos/id/${id}/${dimension.width}/${dimension.height}`
|
||||
}
|
||||
|
||||
// test
|
||||
// (async () => {
|
||||
// const imgs = await getImages({ page: 2, limit: 10 })
|
||||
// console.log(imgs)
|
||||
// })()
|
||||
// === Generic Remote Data ===
|
||||
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
|
||||
|
||||
export default function App() {
|
||||
const Remote = {
|
||||
loading(): Remote<never, never> {
|
||||
return { tag: "loading" }
|
||||
},
|
||||
error<E>(error: E): Remote<never, E> {
|
||||
return { tag: "error", error }
|
||||
},
|
||||
loaded<A>(value: A): Remote<A, never> {
|
||||
return { tag: "loaded", value }
|
||||
},
|
||||
}
|
||||
|
||||
// === App ===
|
||||
// TODO: introduce proper error type
|
||||
type AppError = string
|
||||
|
||||
type State = {
|
||||
imageIds: Remote<ImageId[], AppError>
|
||||
page: Page
|
||||
selectedImage: ImageId | undefined
|
||||
}
|
||||
|
||||
const State = {
|
||||
init(): State {
|
||||
return {
|
||||
imageIds: Remote.loading(),
|
||||
page: Page.init(),
|
||||
selectedImage: undefined,
|
||||
}
|
||||
},
|
||||
}
|
||||
|
||||
type Msg =
|
||||
| { tag: "imagesReceived"; imageIds: ImageId[]; forPage: Page }
|
||||
| { tag: "previousButtonClicked" }
|
||||
| { tag: "nextButtonClicked" }
|
||||
| { tag: "imageClicked"; imageId: ImageId }
|
||||
| { tag: "modalCloseButtonClicked" }
|
||||
|
||||
function useApp(): [State, Dispatch] {
|
||||
const [state, dispatch] = useReducer(update, State.init())
|
||||
|
||||
// === initialization & reloading ===
|
||||
useEffect(() => {
|
||||
// TODO: error-handling
|
||||
getImageIds(state.page).then((imageIds) => {
|
||||
dispatch({ tag: "imagesReceived", forPage: state.page, imageIds })
|
||||
})
|
||||
}, [state.page])
|
||||
|
||||
function update(state: State, msg: Msg): State {
|
||||
switch (msg.tag) {
|
||||
case "imagesReceived":
|
||||
if (Page.eq(state.page, msg.forPage)) {
|
||||
return { ...state, imageIds: Remote.loaded(msg.imageIds) }
|
||||
} else {
|
||||
return state
|
||||
}
|
||||
case "previousButtonClicked": {
|
||||
console.log("prev")
|
||||
const newPage = Page.previous(state.page)
|
||||
if (Page.eq(newPage, state.page)) {
|
||||
return state // preserves identity
|
||||
} else {
|
||||
return { ...state, page: newPage, imageIds: Remote.loading() }
|
||||
}
|
||||
}
|
||||
case "nextButtonClicked": {
|
||||
console.log("next")
|
||||
return { ...state, page: Page.next(state.page), imageIds: Remote.loading() }
|
||||
}
|
||||
case "imageClicked": {
|
||||
console.log(msg.imageId)
|
||||
return { ...state, selectedImage: msg.imageId }
|
||||
}
|
||||
case "modalCloseButtonClicked": {
|
||||
console.log("modal closed")
|
||||
return { ...state, selectedImage: undefined }
|
||||
}
|
||||
default:
|
||||
return assertNever(msg)
|
||||
}
|
||||
}
|
||||
|
||||
return [state, dispatch]
|
||||
}
|
||||
|
||||
type Dispatch = (msg: Msg) => void
|
||||
const DispatchContext = createContext<Dispatch | null>(null)
|
||||
|
||||
function useDispatch() {
|
||||
const dispatch = useContext(DispatchContext)
|
||||
|
||||
if (dispatch === null) {
|
||||
throw new Error("useDispatch must be used inside DispatchContext.Provider")
|
||||
}
|
||||
|
||||
return dispatch
|
||||
}
|
||||
|
||||
// === Views ===
|
||||
function RemoteImages({ images }: { images: Remote<ImageId[], AppError> }) {
|
||||
switch (images.tag) {
|
||||
case "loading":
|
||||
return <div>Loading...</div>
|
||||
case "error":
|
||||
return <div>Error: TODO</div>
|
||||
case "loaded":
|
||||
return <Images images={images.value} />
|
||||
}
|
||||
}
|
||||
|
||||
function Images({ images }: { images: ImageId[] }) {
|
||||
const dispatch = useDispatch()
|
||||
// TODO: Is there some basic `Col/Row` component?
|
||||
return (
|
||||
<h1>hello world</h1>
|
||||
<div style={{ display: "flex", flexDirection: "column" }}>
|
||||
{images.map((imageId) => (
|
||||
<img
|
||||
style={{ width: Dimension.medium.width, height: Dimension.medium.height }}
|
||||
key={imageId}
|
||||
src={getImageSource(imageId, Dimension.medium)}
|
||||
onClick={() => dispatch({ tag: "imageClicked", imageId })}
|
||||
alt=""
|
||||
/>
|
||||
))}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export default function App() {
|
||||
const [state, dispatch] = useApp()
|
||||
|
||||
return (
|
||||
<DispatchContext.Provider value={dispatch}>
|
||||
<main>
|
||||
<h1>Picturarium</h1>
|
||||
<RemoteImages images={state.imageIds} />
|
||||
<button onClick={() => dispatch({ tag: "previousButtonClicked" })}>prev</button>
|
||||
<button onClick={() => dispatch({ tag: "nextButtonClicked" })}>next</button>
|
||||
</main>
|
||||
</DispatchContext.Provider>
|
||||
)
|
||||
}
|
||||
|
|
|
|||
14
src/main.tsx
14
src/main.tsx
|
|
@ -1,16 +1,16 @@
|
|||
import { StrictMode } from 'react';
|
||||
import { createRoot } from 'react-dom/client';
|
||||
import App from './App';
|
||||
import './index.css';
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import App from "./App"
|
||||
import "./index.css"
|
||||
|
||||
const rootElement = document.getElementById('root');
|
||||
const rootElement = document.getElementById("root")
|
||||
|
||||
if (rootElement === null) {
|
||||
throw new Error('Root element #root was not found.');
|
||||
throw new Error("Root element #root was not found.")
|
||||
}
|
||||
|
||||
createRoot(rootElement).render(
|
||||
<StrictMode>
|
||||
<App />
|
||||
</StrictMode>,
|
||||
);
|
||||
)
|
||||
|
|
|
|||
|
|
@ -1,6 +1,6 @@
|
|||
import react from '@vitejs/plugin-react';
|
||||
import { defineConfig } from 'vite';
|
||||
import react from "@vitejs/plugin-react"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
export default defineConfig({
|
||||
plugins: [react()],
|
||||
});
|
||||
})
|
||||
|
|
|
|||
Loading…
Add table
Add a link
Reference in a new issue