☀️

M5Stackで蓄積したデータベースをグラフ表示するWebアプリを作る。5(shadcn/uiコンポーネント導入編)

2024/11/10に公開

https://zenn.dev/akihiro_ya/articles/59b61503853b85
https://zenn.dev/akihiro_ya/articles/b52893e6842042
https://zenn.dev/akihiro_ya/articles/4643683db221b5
https://zenn.dev/akihiro_ya/articles/e21aa749ce02ca
の続き。

ダッシュボードのデザインにshadcn/uiのUIコンポーネントを導入してみる。

GitHubリポジトリ

GitHubにプロジェクト一式を置いておくので、試してください。
https://github.com/ak1211/vite-react-purs

shadcn/uiをインストールする

Viteプロジェクトなので「Installation」の
1 Create projectはすでにしているので("vanilla javascript"なんだけど。)、続きから。

Add Tailwind and its configuration

npm install -D tailwindcss postcss autoprefixer
 
npx tailwindcss init -p

Add this import header in your main css file, src/index.css in our case:

メインのcssファイル名はmain.cssにする派閥なので。

/main.css
@tailwind base;
@tailwind components;
@tailwind utilities;

/* ... */
/index.html
<!doctype html>
<html lang="en">

<head>
  <meta charset="UTF-8" />
  <link rel="icon" type="image/svg+xml" href="/vite.svg" />
  <link rel="stylesheet" href="/main.css" />
  <meta name="viewport" content="width=device-width, initial-scale=1.0" />
  <title>Vite + React</title>
</head>

<body>
  <div id="root"></div>
  <script type="module" src="/src/index.js"></script>
  <script src="https://cdn.jsdelivr.net/npm/apexcharts"></script>
</body>

</html>
/tailwind.config.js
/** @type {import('tailwindcss').Config} */
export default {
  content: [
    "./index.html",
    "./src/**/*.{ts,tsx,js,jsx}",
    "./output/**/*.js"
  ],
  theme: {
    extend: {},
  },
  plugins: [],
}

Edit tsconfig.json file

/tsconfig.json
{
  "files": [],
  "references": [
    {
      "path": "./tsconfig.app.json"
    },
    {
      "path": "./tsconfig.node.json"
    }
  ],
  "compilerOptions": {
    "baseUrl": ".",
    "paths": {
      "@/*": ["./src/*"]
    }
  }
}

Edit tsconfig.app.json file

/tsconfig.app.json
{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["src"]
}{
  "compilerOptions": {
    "target": "ES2020",
    "useDefineForClassFields": true,
    "lib": ["ES2020", "DOM", "DOM.Iterable"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,
    "jsx": "react-jsx",

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true,

    //
    "baseUrl": ".",
    "paths": {
      "@/*": [
        "./src/*"
      ]
    }
  },
  "include": ["src"]
}

Update vite.config.ts

$ npm i -D @types/node
/vite.config.js
import path from "path"
import { defineConfig } from 'vite';
import react from '@vitejs/plugin-react';

// https://vitejs.dev/config/
export default defineConfig({
  plugins: [react()],
  resolve: {
    alias: {
      "@": path.resolve(__dirname, "./src"),
    }
  },
  server: {
    headers: {
      'Cross-Origin-Opener-Policy': 'same-origin',
      'Cross-Origin-Embedder-Policy': 'require-corp',
    },
  },
  optimizeDeps: {
    exclude: ['@sqlite.org/sqlite-wasm'],
  },
});

Run the CLI

$ npx shadcn@latest init
✔ Preflight checks.
✔ Verifying framework. Found Vite.
✔ Validating Tailwind CSS.
✔ Validating import alias.
✔ Which style would you like to use? › New York
✔ Which color would you like to use as the base color? › Zinc
✔ Would you like to use CSS variables for theming? … no / yes
✔ Writing components.json.
✔ Checking registry.
✔ Updating tailwind.config.js
✔ Updating main.css
✔ Installing dependencies.
ℹ Updated 1 file:
  - src/lib/utils.ts

Success! Project initialization completed.
You may now add components.

That's it

$ npx shadcn@latest add button
✔ Checking registry.
✔ Installing dependencies.
✔ Created 1 file:
  - src/components/ui/button.tsx

TypeScriptのインストール

Vanilla JavaScriptのプロジェクトなので、TypeScriptを入れなければ。
最初からTypeScriptにしていればよかった。

$ npm install --save-dev typescript typescript-eslint
$ mv vite.config.js vite.config.ts

tsconfig.node.json

/tsconfig.node.json
{
  "compilerOptions": {
    "target": "ES2022",
    "lib": ["ES2023"],
    "module": "ESNext",
    "skipLibCheck": true,

    /* Bundler mode */
    "moduleResolution": "bundler",
    "allowImportingTsExtensions": true,
    "isolatedModules": true,
    "moduleDetection": "force",
    "noEmit": true,

    /* Linting */
    "strict": true,
    "noUnusedLocals": true,
    "noUnusedParameters": true,
    "noFallthroughCasesInSwitch": true
  },
  "include": ["vite.config.ts"]
}

shadcn/uiのボタンコンポーネントを適用する

とりあえずボタンを置いてみるだけなので、JavaScriptをPureScriptFFIで動かしてみる。

/src/App/Shadcn/Button.js
import { Button } from "@/components/ui/button"
export const buttonImpl = Button
/src/App/Shadcn/Button.purs
module App.Shadcn.Button
  ( ButtonProps
  , button
  ) where

import React.Basic.Events (EventHandler)
import React.Basic.Hooks (JSX, ReactComponent)
import React.Basic.Hooks as React

type ButtonProps
  = { onClick :: EventHandler
    , children :: Array JSX
    , className :: String
    }

foreign import buttonImpl :: forall a. ReactComponent { | a }

button :: ButtonProps -> JSX
button props = React.element buttonImpl props
/src/App.purs
module App where

import Prelude hiding ((/))
import App.Pages.About as About
import App.Pages.Charts as Charts
import App.Pages.Home as Home
import App.Router as AppRouter
import App.Routes (Page(..), Route(..))
import App.Shadcn.Button as ShadcnButton
import Effect (Effect)
import React.Basic.DOM as R
import React.Basic.Events (handler_)
import React.Basic.Hooks (JSX)
import React.Basic.Hooks as React

mkApp :: Effect (Unit -> JSX)
mkApp = do
  home <- Home.mkHome
  about <- About.mkAbout
  charts <- Charts.mkCharts
  React.component "App" \_ -> React.do
    router <- AppRouter.useRouter
    pure
      $ React.fragment
          [ R.div_
              [ R.text "Menu: "
              , R.ul_
                  [ ShadcnButton.button
                      { onClick: handler_ $ router.navigate Home
                      , className: "m-1"
                      , children: [ R.text "Go to Home page" ]
                      }
                  , ShadcnButton.button
                      { onClick: handler_ $ router.navigate About
                      , className: "m-1"
                      , children: [ R.text "Go to About page" ]
                      }
                  , ShadcnButton.button
                      { onClick: handler_ $ router.navigate Charts
                      , className: "m-1"
                      , children: [ R.text "Go to Charts page" ]
                      }
                  ]
              ]
          , case router.route of
              Page page -> case page of
                Home -> home unit
                About -> about unit
                Charts -> charts unit
              NotFound -> React.fragment []
          ]

実行

$ npm run dev

ボタンがShadcn/uiのボタンコンポーネントになりました。

variantを設定してみる

https://ui.shadcn.com/docs/components/button
"Examples"の例のように"variant"を設定できるようにする。(variantに文字列をそのまま入れる、かなり雑な型付け。面倒ですもん。)

これはこの関数の型のまね。

src/App/Shadcn/Button.purs
module App.Shadcn.Button
  ( ButtonProps
  , button
  ) where

import Prim.Row (class Union)
import React.Basic.DOM (Props_button)
import React.Basic.Hooks (JSX, ReactComponent)
import React.Basic.Hooks as React

type ButtonProps
  = ( variant :: String
    , size :: String
    , asChild :: Boolean
    | Props_button
    )

foreign import buttonImpl :: forall a. ReactComponent { | a }

button :: forall attrs attrs_. Union attrs attrs_ ButtonProps => Record attrs -> JSX
button props = React.element buttonImpl props
"src/App/Pages/Charts.purs"これは長いのでたたんでおく。
src/App/Pages/Charts.purs
module App.Pages.Charts
  ( ChartsProps
  , mkCharts
  ) where

import Prelude
import App.DataAcquisition.Types (EnvSensorId, PressureChartSeries, RelativeHumidityChartSeries, TemperatureChartSeries)
import App.PressureChart as PressureChart
import App.RelativeHumidityChart as RelativeHumidityChart
import App.Shadcn.Button as ShadcnButton
import App.TemperatureChart as TemperatureChart
import Data.Argonaut.Core (Json)
import Data.Argonaut.Decode (JsonDecodeError, decodeJson, printJsonDecodeError)
import Data.Either (Either, either)
import Data.Int (toNumber)
import Data.Maybe (Maybe(..), maybe)
import Data.Newtype (unwrap)
import Data.Traversable (traverse, traverse_)
import Data.Tuple (uncurry)
import Data.Tuple.Nested (type (/\), (/\), uncurry3, tuple3)
import Effect (Effect)
import Effect.Aff (Aff)
import Effect.Aff as Aff
import Effect.Class.Console (log, logShow, error, errorShow)
import Effect.Exception (Error)
import JS.BigInt as BigInt
import React.Basic.DOM as DOM
import React.Basic.DOM.Events (capture_, targetFiles)
import React.Basic.Events (handler)
import React.Basic.Hooks (Component, component, useEffect, useState)
import React.Basic.Hooks as React
import Sqlite3Wasm.Sqlite3Wasm (ConfigGetResult, DbId, OpenResult, OpfsDatabaseFilePath(..), SqliteWorker1Promiser)
import Sqlite3Wasm.Sqlite3Wasm as Sq3
import Web.File.File (File)
import Web.File.File as File
import Web.File.FileList (FileList)
import Web.File.FileList as FileList
import Web.File.FileReader.Aff (readAsArrayBuffer)

type ChartsProps
  = Unit

type State
  = { counter :: Int
    , promiser :: Maybe SqliteWorker1Promiser
    , dbId :: Maybe DbId
    , temperatureSeries :: Array TemperatureChartSeries
    , relativeHumiditySeries :: Array RelativeHumidityChartSeries
    , pressureSeries :: Array PressureChartSeries
    }

initialState :: State
initialState =
  { counter: 0
  , promiser: Nothing
  , dbId: Nothing
  , temperatureSeries: []
  , relativeHumiditySeries: []
  , pressureSeries: []
  }

mkCharts :: Component ChartsProps
mkCharts = do
  temperatureChart <- TemperatureChart.mkComponent
  relativeHumidityChart <- RelativeHumidityChart.mkComponent
  pressureChart <- PressureChart.mkComponent
  component "Charts" \_props -> React.do
    -- ステートフックを使って、stateとsetStateを得る
    stateHook@(state /\ setState) <- useState initialState
    -- 副作用フック(useEffect)
    useEffect unit do
      let
        fail :: Error -> Effect Unit
        fail = log <<< Aff.message

        update :: SqliteWorker1Promiser -> Effect Unit
        update newPromiser = setState _ { promiser = Just newPromiser }
      -- SQLite3 WASM Worker1 promiser を得る
      Aff.runAff_ (either fail update) Sq3.createWorker1Promiser
      -- 副作用フックのクリーンアップ関数を返却する
      pure $ void $ closeDatabaseHandler stateHook
    --
    pure
      $ DOM.div
          { children:
              [ DOM.h1_ [ DOM.text "Charts" ]
              , DOM.p_ [ DOM.text "Try clicking the button!" ]
              , ShadcnButton.button
                  { onClick:
                      capture_ do
                        setState _ { counter = state.counter + 1 }
                  , className: "m-1"
                  , variant: "destructive"
                  , children:
                      [ DOM.text "Clicks: "
                      , DOM.text (show state.counter)
                      ]
                  }
              , ShadcnButton.button
                  { onClick: capture_ versionButtonOnClickHandler
                  , className: "m-1"
                  , variant: "secondary"
                  , children:
                      [ DOM.text "SQLite version"
                      ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ sensorIdButtonOnClickHandler stateHook "temperature"
                  , className: "m-1"
                  , variant: "secondary"
                  , children:
                      [ DOM.text "temperatureテーブルに入ってるsensor_id" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ getTemperatureButtonOnClickHandler stateHook
                  , className: "m-1"
                  , variant: ""
                  , children:
                      [ DOM.text "temperature" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ sensorIdButtonOnClickHandler stateHook "relative_humidity"
                  , className: "m-1"
                  , variant: "secondary"
                  , children:
                      [ DOM.text "relative_humidityテーブルに入ってるsensor_id" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ getRelativeHumidityButtonOnClickHandler stateHook
                  , className: "m-1"
                  , variant: ""
                  , children:
                      [ DOM.text "relative_humidity" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ sensorIdButtonOnClickHandler stateHook "pressure"
                  , className: "m-1"
                  , variant: "secondary"
                  , children:
                      [ DOM.text "pressureテーブルに入ってるsensor_id" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ getPressureButtonOnClickHandler stateHook
                  , className: "m-1"
                  , variant: ""
                  , children:
                      [ DOM.text "pressure" ]
                  }
              , ShadcnButton.button
                  { onClick:
                      capture_ $ openFileHandler stateHook opfsDatabaseFileToUse
                  , className: "m-1"
                  , variant: ""
                  , children:
                      [ DOM.text "Open database file on OPFS"
                      ]
                  }
              , DOM.p_
                  [ DOM.text "データーベースファイルをアップロードする"
                  , DOM.input
                      { type: "file"
                      , onChange:
                          handler targetFiles loadFileHandler
                      }
                  ]
              , temperatureChart state.temperatureSeries
              , relativeHumidityChart state.relativeHumiditySeries
              , pressureChart state.pressureSeries
              ]
          }

-- OPFS上のデータベースファイルパス
opfsDatabaseFileToUse :: OpfsDatabaseFilePath
opfsDatabaseFileToUse = OpfsDatabaseFilePath "env_database.sqlite3"

versionButtonOnClickHandler :: Effect Unit
versionButtonOnClickHandler = Aff.runAff_ (either fail success) $ configGet
  where
  configGet :: Aff ConfigGetResult
  configGet = Sq3.configGet =<< Sq3.createWorker1Promiser

  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: ConfigGetResult -> Effect Unit
  success r = logShow r

sensorIdButtonOnClickHandler :: StateHook -> String -> Effect Unit
sensorIdButtonOnClickHandler (state /\ _) table =
  Aff.runAff_ (either fail success)
    $ maybe (Aff.throwError $ Aff.error "database file not opened") (uncurry3 getSensorIdStoredInTable) do
        promiser <- state.promiser
        dbId <- state.dbId
        pure $ tuple3 promiser dbId table
  where
  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: Array EnvSensorId -> Effect Unit
  success = traverse_ logShow

getTemperatureButtonOnClickHandler :: StateHook -> Effect Unit
getTemperatureButtonOnClickHandler (state /\ setState) =
  Aff.runAff_ (either fail success)
    $ maybe (Aff.throwError $ Aff.error "database file not opened") (uncurry3 go) do
        promiser <- state.promiser
        dbId <- state.dbId
        pure $ tuple3 promiser dbId "temperature"
  where
  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  -- 取得したデーターでstate変数を上書きする
  success :: Array TemperatureChartSeries -> Effect Unit
  success xs = setState _ { temperatureSeries = xs }

  mkRecord :: EnvSensorId -> Array DbRowTemperature -> TemperatureChartSeries
  mkRecord sensorId rowTemperature =
    let
      record x = { at: x.at, degc: (toNumber x.milli_degc) / 1000.0 }
    in
      sensorId /\ map record rowTemperature

  go :: SqliteWorker1Promiser -> DbId -> String -> Aff (Array TemperatureChartSeries)
  go promiser dbId table = do
    sensors <- getSensorIdStoredInTable promiser dbId table
    traverse (\s -> mkRecord s <$> getTemperature promiser dbId s) sensors

getRelativeHumidityButtonOnClickHandler :: StateHook -> Effect Unit
getRelativeHumidityButtonOnClickHandler (state /\ setState) =
  Aff.runAff_ (either fail success)
    $ maybe (Aff.throwError $ Aff.error "database file not opened") (uncurry3 go) do
        promiser <- state.promiser
        dbId <- state.dbId
        pure $ tuple3 promiser dbId "relative_humidity"
  where
  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: Array RelativeHumidityChartSeries -> Effect Unit
  success xs = setState _ { relativeHumiditySeries = xs }

  mkRecord :: EnvSensorId -> Array DbRowRelativeHumidity -> RelativeHumidityChartSeries
  mkRecord sensorId rowRelativeHumidity =
    let
      record x = { at: x.at, percent: (toNumber x.ppm_rh) / 10000.0 }
    in
      sensorId /\ map record rowRelativeHumidity

  go :: SqliteWorker1Promiser -> DbId -> String -> Aff (Array RelativeHumidityChartSeries)
  go promiser dbId table = do
    sensors <- getSensorIdStoredInTable promiser dbId table
    traverse (\s -> mkRecord s <$> getRelativeHumidity promiser dbId s) sensors

getPressureButtonOnClickHandler :: StateHook -> Effect Unit
getPressureButtonOnClickHandler (state /\ setState) =
  Aff.runAff_ (either fail success)
    $ maybe (Aff.throwError $ Aff.error "database file not opened") (uncurry3 go) do
        promiser <- state.promiser
        dbId <- state.dbId
        pure $ tuple3 promiser dbId "pressure"
  where
  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: Array PressureChartSeries -> Effect Unit
  success xs = setState _ { pressureSeries = xs }

  mkRecord :: EnvSensorId -> Array DbRowPressure -> PressureChartSeries
  mkRecord sensorId rowPressure =
    let
      record x = { at: x.at, hpa: (toNumber x.pascal) / 100.0 }
    in
      sensorId /\ map record rowPressure

  go :: SqliteWorker1Promiser -> DbId -> String -> Aff (Array PressureChartSeries)
  go promiser dbId table = do
    sensors <- getSensorIdStoredInTable promiser dbId table
    traverse (\s -> mkRecord s <$> getPressure promiser dbId s) sensors

type StateHook
  = (State /\ ((State -> State) -> Effect Unit))

{-}
 データーベースにクエリを発行する
 -}
type DbRowTemperature
  = { at :: Int, milli_degc :: Int }

type DbRowRelativeHumidity
  = { at :: Int, ppm_rh :: Int }

type DbRowPressure
  = { at :: Int, pascal :: Int }

-- データーベースを閉じる
closeDatabaseHandler :: StateHook -> Effect Unit
closeDatabaseHandler (state /\ setState) =
  maybe mempty (uncurry close) do
    promiser <- state.promiser
    dbId <- state.dbId
    pure (promiser /\ dbId)
  where
  close :: SqliteWorker1Promiser -> DbId -> Effect Unit
  close promiser dbId = Aff.runAff_ (either fail $ const success) $ Sq3.close promiser dbId

  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: Effect Unit
  success = do
    logShow "database closed"
    setState _ { dbId = Nothing }

-- OPFS上のデーターベースファイルを開く
openFileHandler :: StateHook -> OpfsDatabaseFilePath -> Effect Unit
openFileHandler (state /\ setState) filepath = maybe mempty go state.promiser
  where
  go promiser = Aff.runAff_ (either fail success) $ Sq3.open promiser filepath

  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: OpenResult -> Effect Unit
  success result = do
    logShow $ "database '" <> result.filename <> "' opened"
    setState _ { dbId = Just result.dbId }

-- テーブルに格納されているセンサーIDを取得する
getSensorIdStoredInTable :: SqliteWorker1Promiser -> DbId -> String -> Aff (Array EnvSensorId)
getSensorIdStoredInTable promiser dbId targetTable = do
  result <- Sq3.exec promiser dbId $ "SELECT DISTINCT `sensor_id` FROM `" <> targetTable <> "` ORDER BY `sensor_id`;"
  let
    decoded = map decodeJson_ result.resultRows
  traverse (either fail success) decoded
  where
  fail = Aff.throwError <<< Aff.error <<< printJsonDecodeError

  decodeJson_ :: Json -> Either JsonDecodeError { sensor_id :: EnvSensorId }
  decodeJson_ = decodeJson

  success r = pure r.sensor_id

-- 温度テーブルから温度を取得する
getTemperature :: SqliteWorker1Promiser -> DbId -> EnvSensorId -> Aff (Array DbRowTemperature)
getTemperature promiser dbId sensorId = do
  let
    query =
      "SELECT `at`,`milli_degc` FROM `temperature` WHERE `sensor_id`="
        <> BigInt.toString (unwrap sensorId)
        <> " ORDER BY `at` ASC LIMIT 10000;"
  void $ log query
  result <- Sq3.exec promiser dbId query
  let
    decoded = map decodeJson_ result.resultRows
  traverse (either fail pure) decoded
  where
  fail = Aff.throwError <<< Aff.error <<< printJsonDecodeError

  decodeJson_ :: Json -> Either JsonDecodeError DbRowTemperature
  decodeJson_ = decodeJson

-- 湿度テーブルから温度を取得する
getRelativeHumidity :: SqliteWorker1Promiser -> DbId -> EnvSensorId -> Aff (Array DbRowRelativeHumidity)
getRelativeHumidity promiser dbId sensorId = do
  let
    query =
      "SELECT `at`,`ppm_rh` FROM `relative_humidity` WHERE `sensor_id`="
        <> BigInt.toString (unwrap sensorId)
        <> " ORDER BY `at` ASC LIMIT 10000;"
  void $ log query
  result <- Sq3.exec promiser dbId query
  let
    decoded = map decodeJson_ result.resultRows
  traverse (either fail pure) decoded
  where
  fail = Aff.throwError <<< Aff.error <<< printJsonDecodeError

  decodeJson_ :: Json -> Either JsonDecodeError DbRowRelativeHumidity
  decodeJson_ = decodeJson

-- 気圧テーブルから温度を取得する
getPressure :: SqliteWorker1Promiser -> DbId -> EnvSensorId -> Aff (Array DbRowPressure)
getPressure promiser dbId sensorId = do
  let
    query =
      "SELECT `at`,`pascal` FROM `pressure` WHERE `sensor_id`="
        <> BigInt.toString (unwrap sensorId)
        <> " ORDER BY `at` ASC LIMIT 10000;"
  void $ log query
  result <- Sq3.exec promiser dbId query
  let
    decoded = map decodeJson_ result.resultRows
  traverse (either fail pure) decoded
  where
  fail = Aff.throwError <<< Aff.error <<< printJsonDecodeError

  decodeJson_ :: Json -> Either JsonDecodeError DbRowPressure
  decodeJson_ = decodeJson

-- OPFS上のデーターベースファイルにローカルファイルを上書きする
loadFileHandler :: Maybe FileList -> Effect Unit
loadFileHandler Nothing = mempty

loadFileHandler (Just filelist) = maybe (errorShow "FileList is empty") go $ FileList.item 0 filelist
  where
  go :: File -> Effect Unit
  go file = Aff.runAff_ (either fail $ const success) $ overwriteOpfsFile file

  overwriteOpfsFile :: File -> Aff Unit
  overwriteOpfsFile file = do
    ab <- readAsArrayBuffer (File.toBlob file)
    Sq3.overwriteOpfsFileWithSpecifiedArrayBuffer opfsDatabaseFileToUse ab

  fail :: Error -> Effect Unit
  fail = error <<< Aff.message

  success :: Effect Unit
  success = do
    log "OPFS file overwrited"

確認


variantが設定できました。

Discussion