diff --git a/.prettierrc.json b/.prettierrc.json index 47174e4..f313bb2 100644 --- a/.prettierrc.json +++ b/.prettierrc.json @@ -1,6 +1,6 @@ { - "semi": true, - "singleQuote": true, + "semi": false, + "singleQuote": false, "trailingComma": "all", "printWidth": 100 } diff --git a/eslint.config.js b/eslint.config.js index 0162f79..e50a55f 100644 --- a/eslint.config.js +++ b/eslint.config.js @@ -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, -); +) diff --git a/src/App.tsx b/src/App.tsx index 0e818e0..a030ba7 100644 --- a/src/App.tsx +++ b/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 -async function getPicsumImages({ page, limit }: Pagination): Promise { +async function getPicsumImages({ page, limit }: Page): Promise { 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 { - 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 { + 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 { - const data = await getPicsumImages(pagination) +async function getImageIds(page: Page): Promise { + const data = await getPicsumImages(page) return data.map(({ id }) => id) } // Use this for `` 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 = { tag: "loading" } | { tag: "error"; error: E } | { tag: "loaded"; value: A } -export default function App() { +const Remote = { + loading(): Remote { + return { tag: "loading" } + }, + error(error: E): Remote { + return { tag: "error", error } + }, + loaded(value: A): Remote { + return { tag: "loaded", value } + }, +} + +// === App === +// TODO: introduce proper error type +type AppError = string + +type State = { + imageIds: Remote + 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(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 }) { + switch (images.tag) { + case "loading": + return
Loading...
+ case "error": + return
Error: TODO
+ case "loaded": + return + } +} + +function Images({ images }: { images: ImageId[] }) { + const dispatch = useDispatch() + // TODO: Is there some basic `Col/Row` component? return ( -

hello world

+
+ {images.map((imageId) => ( + dispatch({ tag: "imageClicked", imageId })} + alt="" + /> + ))} +
+ ) +} + +export default function App() { + const [state, dispatch] = useApp() + + return ( + +
+

Picturarium

+ + + +
+
) } diff --git a/src/main.tsx b/src/main.tsx index 5784f84..57d77e4 100644 --- a/src/main.tsx +++ b/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( , -); +) diff --git a/vite.config.ts b/vite.config.ts index fabde1a..322f82f 100644 --- a/vite.config.ts +++ b/vite.config.ts @@ -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()], -}); +})