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,
|
"semi": false,
|
||||||
"singleQuote": true,
|
"singleQuote": false,
|
||||||
"trailingComma": "all",
|
"trailingComma": "all",
|
||||||
"printWidth": 100
|
"printWidth": 100
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,16 +1,16 @@
|
||||||
import js from '@eslint/js';
|
import js from "@eslint/js"
|
||||||
import prettierConfig from 'eslint-config-prettier';
|
import prettierConfig from "eslint-config-prettier"
|
||||||
import reactHooks from 'eslint-plugin-react-hooks';
|
import reactHooks from "eslint-plugin-react-hooks"
|
||||||
import reactRefresh from 'eslint-plugin-react-refresh';
|
import reactRefresh from "eslint-plugin-react-refresh"
|
||||||
import globals from 'globals';
|
import globals from "globals"
|
||||||
import tseslint from 'typescript-eslint';
|
import tseslint from "typescript-eslint"
|
||||||
|
|
||||||
export default tseslint.config(
|
export default tseslint.config(
|
||||||
{
|
{
|
||||||
ignores: ['dist', 'node_modules'],
|
ignores: ["dist", "node_modules"],
|
||||||
},
|
},
|
||||||
{
|
{
|
||||||
files: ['src/**/*.{ts,tsx}'],
|
files: ["src/**/*.{ts,tsx}"],
|
||||||
extends: [
|
extends: [
|
||||||
js.configs.recommended,
|
js.configs.recommended,
|
||||||
...tseslint.configs.strictTypeChecked,
|
...tseslint.configs.strictTypeChecked,
|
||||||
|
|
@ -25,32 +25,32 @@ export default tseslint.config(
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
plugins: {
|
plugins: {
|
||||||
'react-hooks': reactHooks,
|
"react-hooks": reactHooks,
|
||||||
'react-refresh': reactRefresh,
|
"react-refresh": reactRefresh,
|
||||||
},
|
},
|
||||||
rules: {
|
rules: {
|
||||||
...reactHooks.configs.recommended.rules,
|
...reactHooks.configs.recommended.rules,
|
||||||
'@typescript-eslint/consistent-type-imports': [
|
"@typescript-eslint/consistent-type-imports": [
|
||||||
'error',
|
"error",
|
||||||
{ prefer: 'type-imports', fixStyle: 'inline-type-imports' },
|
{ prefer: "type-imports", fixStyle: "inline-type-imports" },
|
||||||
],
|
],
|
||||||
'@typescript-eslint/no-unused-vars': [
|
"@typescript-eslint/no-unused-vars": [
|
||||||
'error',
|
"error",
|
||||||
{
|
{
|
||||||
argsIgnorePattern: '^_',
|
argsIgnorePattern: "^_",
|
||||||
varsIgnorePattern: '^_',
|
varsIgnorePattern: "^_",
|
||||||
caughtErrorsIgnorePattern: '^_',
|
caughtErrorsIgnorePattern: "^_",
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
'@typescript-eslint/switch-exhaustiveness-check': 'error',
|
"@typescript-eslint/switch-exhaustiveness-check": "error",
|
||||||
'@typescript-eslint/no-floating-promises': 'error',
|
"@typescript-eslint/no-floating-promises": "error",
|
||||||
'@typescript-eslint/no-misused-promises': 'error',
|
"@typescript-eslint/no-misused-promises": "error",
|
||||||
'@typescript-eslint/no-unnecessary-condition': 'error',
|
"@typescript-eslint/no-unnecessary-condition": "error",
|
||||||
'@typescript-eslint/prefer-nullish-coalescing': 'error',
|
"@typescript-eslint/prefer-nullish-coalescing": "error",
|
||||||
'@typescript-eslint/prefer-optional-chain': 'error',
|
"@typescript-eslint/prefer-optional-chain": "error",
|
||||||
'default-case': 'off',
|
"default-case": "off",
|
||||||
'react-refresh/only-export-components': ['warn', { allowConstantExport: true }],
|
"react-refresh/only-export-components": ["warn", { allowConstantExport: true }],
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
prettierConfig,
|
prettierConfig,
|
||||||
);
|
)
|
||||||
|
|
|
||||||
227
src/App.tsx
227
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: Use Material UI later.
|
||||||
// TODO: Improve error-handling. Introduce some proper server response.
|
// 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 ImageId = string
|
||||||
type ImageRef = string
|
type ImageRef = string
|
||||||
|
|
||||||
type Image = {
|
type Image = {
|
||||||
id: ImageId,
|
id: ImageId
|
||||||
dimension: Dimension,
|
dimension: Dimension
|
||||||
source: ImageRef,
|
source: ImageRef
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// === Dimension ===
|
||||||
|
|
||||||
type Dimension = {
|
type Dimension = {
|
||||||
width: number,
|
width: number
|
||||||
height: number,
|
height: number
|
||||||
|
}
|
||||||
|
const Dimension = {
|
||||||
|
medium: { width: 200, height: 200 } as Dimension,
|
||||||
|
big: { width: 300, height: 500 } as Dimension,
|
||||||
}
|
}
|
||||||
|
|
||||||
// WARNING: page starts at 1
|
// === Page ===
|
||||||
type Pagination = {
|
type Page = {
|
||||||
page: number,
|
page: number
|
||||||
limit: number, // page size
|
limit: number // page size
|
||||||
}
|
}
|
||||||
|
|
||||||
const Pagination = {
|
const LIMIT = 10
|
||||||
init(): Pagination { return { page: 1, 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 ===
|
// === api ===
|
||||||
const picsumApiImageSchema = z.object({
|
const picsumApiImageSchema = z.object({
|
||||||
|
|
@ -38,7 +68,7 @@ const picsumApiImageSchema = z.object({
|
||||||
})
|
})
|
||||||
type PicsumApiImage = z.infer<typeof picsumApiImageSchema>
|
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}`)
|
const response = await fetch(`https://picsum.photos/v2/list?limit=${limit}&page=${page}`)
|
||||||
if (!response.ok) {
|
if (!response.ok) {
|
||||||
throw new Error(`Failed to fetch images: ${response.status}`)
|
throw new Error(`Failed to fetch images: ${response.status}`)
|
||||||
|
|
@ -50,29 +80,170 @@ async function getPicsumImages({ page, limit }: Pagination): Promise<PicsumApiIm
|
||||||
return data
|
return data
|
||||||
}
|
}
|
||||||
|
|
||||||
async function getImages(pagination: Pagination): Promise<Image[]> {
|
// TODO: We don't really need this.
|
||||||
const data = await getPicsumImages(pagination)
|
async function getImages(page: Page): Promise<Image[]> {
|
||||||
return data.map(({ id, download_url, width, height }) => ({ id: id as ImageId, dimension: { width, height }, source: download_url as ImageRef }))
|
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[]> {
|
async function getImageIds(page: Page): Promise<ImageId[]> {
|
||||||
const data = await getPicsumImages(pagination)
|
const data = await getPicsumImages(page)
|
||||||
return data.map(({ id }) => id)
|
return data.map(({ id }) => id)
|
||||||
}
|
}
|
||||||
|
|
||||||
// Use this for `<img src=... />`
|
// Use this for `<img src=... />`
|
||||||
function getImageSource(id: ImageId, dimension: Dimension): ImageRef {
|
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
|
// === Generic Remote Data ===
|
||||||
// (async () => {
|
type Remote<A, E> = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A }
|
||||||
// const imgs = await getImages({ page: 2, limit: 10 })
|
|
||||||
// console.log(imgs)
|
|
||||||
// })()
|
|
||||||
|
|
||||||
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 (
|
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 { StrictMode } from "react"
|
||||||
import { createRoot } from 'react-dom/client';
|
import { createRoot } from "react-dom/client"
|
||||||
import App from './App';
|
import App from "./App"
|
||||||
import './index.css';
|
import "./index.css"
|
||||||
|
|
||||||
const rootElement = document.getElementById('root');
|
const rootElement = document.getElementById("root")
|
||||||
|
|
||||||
if (rootElement === null) {
|
if (rootElement === null) {
|
||||||
throw new Error('Root element #root was not found.');
|
throw new Error("Root element #root was not found.")
|
||||||
}
|
}
|
||||||
|
|
||||||
createRoot(rootElement).render(
|
createRoot(rootElement).render(
|
||||||
<StrictMode>
|
<StrictMode>
|
||||||
<App />
|
<App />
|
||||||
</StrictMode>,
|
</StrictMode>,
|
||||||
);
|
)
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,6 @@
|
||||||
import react from '@vitejs/plugin-react';
|
import react from "@vitejs/plugin-react"
|
||||||
import { defineConfig } from 'vite';
|
import { defineConfig } from "vite"
|
||||||
|
|
||||||
export default defineConfig({
|
export default defineConfig({
|
||||||
plugins: [react()],
|
plugins: [react()],
|
||||||
});
|
})
|
||||||
|
|
|
||||||
Loading…
Add table
Add a link
Reference in a new issue