☀️
M5Stackで蓄積したデータベースをグラフ表示するWebアプリを作る。5(shadcn/uiコンポーネント導入編)
の続き。
ダッシュボードのデザインにshadcn/uiのUIコンポーネントを導入してみる。
GitHubリポジトリ
GitHubにプロジェクト一式を置いておくので、試してください。
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を設定してみる
"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