0.1.0 (#1)
Co-authored-by: Julien Valverdé <julien.valverde@mailo.com> Reviewed-on: https://gitea:3000/Thilawyn/reffuse/pulls/1
This commit was merged in pull request #1.
This commit is contained in:
24
packages/example/.gitignore
vendored
Normal file
24
packages/example/.gitignore
vendored
Normal file
@@ -0,0 +1,24 @@
|
||||
# Logs
|
||||
logs
|
||||
*.log
|
||||
npm-debug.log*
|
||||
yarn-debug.log*
|
||||
yarn-error.log*
|
||||
pnpm-debug.log*
|
||||
lerna-debug.log*
|
||||
|
||||
node_modules
|
||||
dist
|
||||
dist-ssr
|
||||
*.local
|
||||
|
||||
# Editor directories and files
|
||||
.vscode/*
|
||||
!.vscode/extensions.json
|
||||
.idea
|
||||
.DS_Store
|
||||
*.suo
|
||||
*.ntvs*
|
||||
*.njsproj
|
||||
*.sln
|
||||
*.sw?
|
||||
50
packages/example/README.md
Normal file
50
packages/example/README.md
Normal file
@@ -0,0 +1,50 @@
|
||||
# React + TypeScript + Vite
|
||||
|
||||
This template provides a minimal setup to get React working in Vite with HMR and some ESLint rules.
|
||||
|
||||
Currently, two official plugins are available:
|
||||
|
||||
- [@vitejs/plugin-react](https://github.com/vitejs/vite-plugin-react/blob/main/packages/plugin-react/README.md) uses [Babel](https://babeljs.io/) for Fast Refresh
|
||||
- [@vitejs/plugin-react-swc](https://github.com/vitejs/vite-plugin-react-swc) uses [SWC](https://swc.rs/) for Fast Refresh
|
||||
|
||||
## Expanding the ESLint configuration
|
||||
|
||||
If you are developing a production application, we recommend updating the configuration to enable type aware lint rules:
|
||||
|
||||
- Configure the top-level `parserOptions` property like this:
|
||||
|
||||
```js
|
||||
export default tseslint.config({
|
||||
languageOptions: {
|
||||
// other options...
|
||||
parserOptions: {
|
||||
project: ['./tsconfig.node.json', './tsconfig.app.json'],
|
||||
tsconfigRootDir: import.meta.dirname,
|
||||
},
|
||||
},
|
||||
})
|
||||
```
|
||||
|
||||
- Replace `tseslint.configs.recommended` to `tseslint.configs.recommendedTypeChecked` or `tseslint.configs.strictTypeChecked`
|
||||
- Optionally add `...tseslint.configs.stylisticTypeChecked`
|
||||
- Install [eslint-plugin-react](https://github.com/jsx-eslint/eslint-plugin-react) and update the config:
|
||||
|
||||
```js
|
||||
// eslint.config.js
|
||||
import react from 'eslint-plugin-react'
|
||||
|
||||
export default tseslint.config({
|
||||
// Set the react version
|
||||
settings: { react: { version: '18.3' } },
|
||||
plugins: {
|
||||
// Add the react plugin
|
||||
react,
|
||||
},
|
||||
rules: {
|
||||
// other rules...
|
||||
// Enable its recommended rules
|
||||
...react.configs.recommended.rules,
|
||||
...react.configs['jsx-runtime'].rules,
|
||||
},
|
||||
})
|
||||
```
|
||||
28
packages/example/eslint.config.js
Normal file
28
packages/example/eslint.config.js
Normal file
@@ -0,0 +1,28 @@
|
||||
import js from '@eslint/js'
|
||||
import globals from 'globals'
|
||||
import reactHooks from 'eslint-plugin-react-hooks'
|
||||
import reactRefresh from 'eslint-plugin-react-refresh'
|
||||
import tseslint from 'typescript-eslint'
|
||||
|
||||
export default tseslint.config(
|
||||
{ ignores: ['dist'] },
|
||||
{
|
||||
extends: [js.configs.recommended, ...tseslint.configs.recommended],
|
||||
files: ['**/*.{ts,tsx}'],
|
||||
languageOptions: {
|
||||
ecmaVersion: 2020,
|
||||
globals: globals.browser,
|
||||
},
|
||||
plugins: {
|
||||
'react-hooks': reactHooks,
|
||||
'react-refresh': reactRefresh,
|
||||
},
|
||||
rules: {
|
||||
...reactHooks.configs.recommended.rules,
|
||||
'react-refresh/only-export-components': [
|
||||
'warn',
|
||||
{ allowConstantExport: true },
|
||||
],
|
||||
},
|
||||
},
|
||||
)
|
||||
13
packages/example/index.html
Normal file
13
packages/example/index.html
Normal file
@@ -0,0 +1,13 @@
|
||||
<!doctype html>
|
||||
<html lang="en">
|
||||
<head>
|
||||
<meta charset="UTF-8" />
|
||||
<link rel="icon" type="image/svg+xml" href="/vite.svg" />
|
||||
<meta name="viewport" content="width=device-width, initial-scale=1.0" />
|
||||
<title>Vite + React + TS</title>
|
||||
</head>
|
||||
<body>
|
||||
<div id="root"></div>
|
||||
<script type="module" src="/src/main.tsx"></script>
|
||||
</body>
|
||||
</html>
|
||||
41
packages/example/package.json
Normal file
41
packages/example/package.json
Normal file
@@ -0,0 +1,41 @@
|
||||
{
|
||||
"name": "@reffuse/example",
|
||||
"version": "0.0.0",
|
||||
"type": "module",
|
||||
"private": true,
|
||||
"scripts": {
|
||||
"dev": "vite",
|
||||
"build": "tsc -b && vite build",
|
||||
"lint:tsc": "tsc --noEmit",
|
||||
"lint:eslint": "eslint .",
|
||||
"preview": "vite preview"
|
||||
},
|
||||
"devDependencies": {
|
||||
"@eslint/js": "^9.17.0",
|
||||
"@tanstack/react-router": "^1.95.3",
|
||||
"@tanstack/router-devtools": "^1.95.3",
|
||||
"@tanstack/router-plugin": "^1.95.3",
|
||||
"@thilawyn/thilaschema": "^0.1.4",
|
||||
"@types/react": "^19.0.4",
|
||||
"@types/react-dom": "^19.0.2",
|
||||
"@vitejs/plugin-react": "^4.3.4",
|
||||
"effect": "^3.12.1",
|
||||
"eslint": "^9.17.0",
|
||||
"eslint-plugin-react-hooks": "^5.0.0",
|
||||
"eslint-plugin-react-refresh": "^0.4.16",
|
||||
"globals": "^15.14.0",
|
||||
"react": "^19.0.0",
|
||||
"react-dom": "^19.0.0",
|
||||
"reffuse": "workspace:*",
|
||||
"typescript-eslint": "^8.18.2",
|
||||
"vite": "^6.0.5"
|
||||
},
|
||||
"dependencies": {
|
||||
"@effect/platform": "^0.73.1",
|
||||
"@effect/platform-browser": "^0.52.1",
|
||||
"@radix-ui/themes": "^3.1.6",
|
||||
"@typed/id": "^0.17.1",
|
||||
"lucide-react": "^0.471.1",
|
||||
"mobx": "^6.13.5"
|
||||
}
|
||||
}
|
||||
1
packages/example/public/vite.svg
Normal file
1
packages/example/public/vite.svg
Normal file
@@ -0,0 +1 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" aria-hidden="true" role="img" class="iconify iconify--logos" width="31.88" height="32" preserveAspectRatio="xMidYMid meet" viewBox="0 0 256 257"><defs><linearGradient id="IconifyId1813088fe1fbc01fb466" x1="-.828%" x2="57.636%" y1="7.652%" y2="78.411%"><stop offset="0%" stop-color="#41D1FF"></stop><stop offset="100%" stop-color="#BD34FE"></stop></linearGradient><linearGradient id="IconifyId1813088fe1fbc01fb467" x1="43.376%" x2="50.316%" y1="2.242%" y2="89.03%"><stop offset="0%" stop-color="#FFEA83"></stop><stop offset="8.333%" stop-color="#FFDD35"></stop><stop offset="100%" stop-color="#FFA800"></stop></linearGradient></defs><path fill="url(#IconifyId1813088fe1fbc01fb466)" d="M255.153 37.938L134.897 252.976c-2.483 4.44-8.862 4.466-11.382.048L.875 37.958c-2.746-4.814 1.371-10.646 6.827-9.67l120.385 21.517a6.537 6.537 0 0 0 2.322-.004l117.867-21.483c5.438-.991 9.574 4.796 6.877 9.62Z"></path><path fill="url(#IconifyId1813088fe1fbc01fb467)" d="M185.432.063L96.44 17.501a3.268 3.268 0 0 0-2.634 3.014l-5.474 92.456a3.268 3.268 0 0 0 3.997 3.378l24.777-5.718c2.318-.535 4.413 1.507 3.936 3.838l-7.361 36.047c-.495 2.426 1.782 4.5 4.151 3.78l15.304-4.649c2.372-.72 4.652 1.36 4.15 3.788l-11.698 56.621c-.732 3.542 3.979 5.473 5.943 2.437l1.313-2.028l72.516-144.72c1.215-2.423-.88-5.186-3.54-4.672l-25.505 4.922c-2.396.462-4.435-1.77-3.759-4.114l16.646-57.705c.677-2.35-1.37-4.583-3.769-4.113Z"></path></svg>
|
||||
|
After Width: | Height: | Size: 1.5 KiB |
26
packages/example/src/domain/Todo.ts
Normal file
26
packages/example/src/domain/Todo.ts
Normal file
@@ -0,0 +1,26 @@
|
||||
import { ThSchema } from "@thilawyn/thilaschema"
|
||||
import { GetRandomValues, makeUuid4 } from "@typed/id"
|
||||
import { Effect, Schema } from "effect"
|
||||
|
||||
|
||||
export class Todo extends Schema.Class<Todo>("Todo")({
|
||||
_tag: Schema.tag("Todo"),
|
||||
id: Schema.String,
|
||||
content: Schema.String,
|
||||
completedAt: Schema.OptionFromSelf(Schema.DateTimeUtcFromSelf),
|
||||
}) {}
|
||||
|
||||
|
||||
export const TodoFromJsonStruct = Schema.Struct({
|
||||
...Todo.fields,
|
||||
completedAt: Schema.Option(Schema.DateTimeUtc),
|
||||
}).pipe(
|
||||
ThSchema.assertEncodedJsonifiable
|
||||
)
|
||||
|
||||
export const TodoFromJson = TodoFromJsonStruct.pipe(Schema.compose(Todo))
|
||||
|
||||
|
||||
export const generateUniqueID = makeUuid4.pipe(
|
||||
Effect.provide(GetRandomValues.CryptoRandom)
|
||||
)
|
||||
1
packages/example/src/domain/index.ts
Normal file
1
packages/example/src/domain/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as Todo from "./Todo"
|
||||
0
packages/example/src/index.css
Normal file
0
packages/example/src/index.css
Normal file
36
packages/example/src/main.tsx
Normal file
36
packages/example/src/main.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { FetchHttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { createRouter, RouterProvider } from "@tanstack/react-router"
|
||||
import { ReffuseRuntime } from "@thilawyn/reffuse"
|
||||
import { Layer } from "effect"
|
||||
import { StrictMode } from "react"
|
||||
import { createRoot } from "react-dom/client"
|
||||
import { GlobalContext } from "./reffuse"
|
||||
import { routeTree } from "./routeTree.gen"
|
||||
|
||||
|
||||
const layer = Layer.empty.pipe(
|
||||
Layer.provideMerge(Clipboard.layer),
|
||||
Layer.provideMerge(Geolocation.layer),
|
||||
Layer.provideMerge(Permissions.layer),
|
||||
Layer.provideMerge(FetchHttpClient.layer),
|
||||
)
|
||||
|
||||
const router = createRouter({ routeTree })
|
||||
|
||||
declare module "@tanstack/react-router" {
|
||||
interface Register {
|
||||
router: typeof router
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
createRoot(document.getElementById("root")!).render(
|
||||
<StrictMode>
|
||||
<ReffuseRuntime.Provider>
|
||||
<GlobalContext.Provider layer={layer}>
|
||||
<RouterProvider router={router} />
|
||||
</GlobalContext.Provider>
|
||||
</ReffuseRuntime.Provider>
|
||||
</StrictMode>
|
||||
)
|
||||
13
packages/example/src/reffuse.ts
Normal file
13
packages/example/src/reffuse.ts
Normal file
@@ -0,0 +1,13 @@
|
||||
import { HttpClient } from "@effect/platform"
|
||||
import { Clipboard, Geolocation, Permissions } from "@effect/platform-browser"
|
||||
import { Reffuse, ReffuseContext } from "reffuse"
|
||||
|
||||
|
||||
export const GlobalContext = ReffuseContext.make<
|
||||
| Clipboard.Clipboard
|
||||
| Geolocation.Geolocation
|
||||
| Permissions.Permissions
|
||||
| HttpClient.HttpClient
|
||||
>()
|
||||
|
||||
export const R = Reffuse.make(GlobalContext)
|
||||
134
packages/example/src/routeTree.gen.ts
Normal file
134
packages/example/src/routeTree.gen.ts
Normal file
@@ -0,0 +1,134 @@
|
||||
/* eslint-disable */
|
||||
|
||||
// @ts-nocheck
|
||||
|
||||
// noinspection JSUnusedGlobalSymbols
|
||||
|
||||
// This file was automatically generated by TanStack Router.
|
||||
// You should NOT make any changes in this file as it will be overwritten.
|
||||
// Additionally, you should also exclude this file from your linter and/or formatter to prevent it from being checked or modified.
|
||||
|
||||
// Import Routes
|
||||
|
||||
import { Route as rootRoute } from './routes/__root'
|
||||
import { Route as TimeImport } from './routes/time'
|
||||
import { Route as CountImport } from './routes/count'
|
||||
import { Route as IndexImport } from './routes/index'
|
||||
|
||||
// Create/Update Routes
|
||||
|
||||
const TimeRoute = TimeImport.update({
|
||||
id: '/time',
|
||||
path: '/time',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const CountRoute = CountImport.update({
|
||||
id: '/count',
|
||||
path: '/count',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
const IndexRoute = IndexImport.update({
|
||||
id: '/',
|
||||
path: '/',
|
||||
getParentRoute: () => rootRoute,
|
||||
} as any)
|
||||
|
||||
// Populate the FileRoutesByPath interface
|
||||
|
||||
declare module '@tanstack/react-router' {
|
||||
interface FileRoutesByPath {
|
||||
'/': {
|
||||
id: '/'
|
||||
path: '/'
|
||||
fullPath: '/'
|
||||
preLoaderRoute: typeof IndexImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/count': {
|
||||
id: '/count'
|
||||
path: '/count'
|
||||
fullPath: '/count'
|
||||
preLoaderRoute: typeof CountImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
'/time': {
|
||||
id: '/time'
|
||||
path: '/time'
|
||||
fullPath: '/time'
|
||||
preLoaderRoute: typeof TimeImport
|
||||
parentRoute: typeof rootRoute
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// Create and export the route tree
|
||||
|
||||
export interface FileRoutesByFullPath {
|
||||
'/': typeof IndexRoute
|
||||
'/count': typeof CountRoute
|
||||
'/time': typeof TimeRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesByTo {
|
||||
'/': typeof IndexRoute
|
||||
'/count': typeof CountRoute
|
||||
'/time': typeof TimeRoute
|
||||
}
|
||||
|
||||
export interface FileRoutesById {
|
||||
__root__: typeof rootRoute
|
||||
'/': typeof IndexRoute
|
||||
'/count': typeof CountRoute
|
||||
'/time': typeof TimeRoute
|
||||
}
|
||||
|
||||
export interface FileRouteTypes {
|
||||
fileRoutesByFullPath: FileRoutesByFullPath
|
||||
fullPaths: '/' | '/count' | '/time'
|
||||
fileRoutesByTo: FileRoutesByTo
|
||||
to: '/' | '/count' | '/time'
|
||||
id: '__root__' | '/' | '/count' | '/time'
|
||||
fileRoutesById: FileRoutesById
|
||||
}
|
||||
|
||||
export interface RootRouteChildren {
|
||||
IndexRoute: typeof IndexRoute
|
||||
CountRoute: typeof CountRoute
|
||||
TimeRoute: typeof TimeRoute
|
||||
}
|
||||
|
||||
const rootRouteChildren: RootRouteChildren = {
|
||||
IndexRoute: IndexRoute,
|
||||
CountRoute: CountRoute,
|
||||
TimeRoute: TimeRoute,
|
||||
}
|
||||
|
||||
export const routeTree = rootRoute
|
||||
._addFileChildren(rootRouteChildren)
|
||||
._addFileTypes<FileRouteTypes>()
|
||||
|
||||
/* ROUTE_MANIFEST_START
|
||||
{
|
||||
"routes": {
|
||||
"__root__": {
|
||||
"filePath": "__root.tsx",
|
||||
"children": [
|
||||
"/",
|
||||
"/count",
|
||||
"/time"
|
||||
]
|
||||
},
|
||||
"/": {
|
||||
"filePath": "index.tsx"
|
||||
},
|
||||
"/count": {
|
||||
"filePath": "count.tsx"
|
||||
},
|
||||
"/time": {
|
||||
"filePath": "time.tsx"
|
||||
}
|
||||
}
|
||||
}
|
||||
ROUTE_MANIFEST_END */
|
||||
27
packages/example/src/routes/__root.tsx
Normal file
27
packages/example/src/routes/__root.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { Container, Flex, Theme } from "@radix-ui/themes"
|
||||
import "@radix-ui/themes/styles.css"
|
||||
import { createRootRoute, Link, Outlet } from "@tanstack/react-router"
|
||||
import { TanStackRouterDevtools } from "@tanstack/router-devtools"
|
||||
import "../index.css"
|
||||
|
||||
|
||||
export const Route = createRootRoute({
|
||||
component: Root
|
||||
})
|
||||
|
||||
function Root() {
|
||||
return (
|
||||
<Theme>
|
||||
<Container>
|
||||
<Flex direction="row" justify="center" align="center" gap="2">
|
||||
<Link to="/">Index</Link>
|
||||
<Link to="/time">Time</Link>
|
||||
<Link to="/count">Count</Link>
|
||||
</Flex>
|
||||
</Container>
|
||||
|
||||
<Outlet />
|
||||
<TanStackRouterDevtools />
|
||||
</Theme>
|
||||
)
|
||||
}
|
||||
27
packages/example/src/routes/count.tsx
Normal file
27
packages/example/src/routes/count.tsx
Normal file
@@ -0,0 +1,27 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Ref } from "effect"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/count")({
|
||||
component: Count
|
||||
})
|
||||
|
||||
function Count() {
|
||||
|
||||
const runSync = R.useRunSync()
|
||||
|
||||
const countRef = R.useRef(0)
|
||||
const [count] = R.useRefState(countRef)
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
{/* <button onClick={() => setCount((count) => count + 1)}> */}
|
||||
<button onClick={() => Ref.update(countRef, count => count + 1).pipe(runSync)}>
|
||||
count is {count}
|
||||
</button>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
34
packages/example/src/routes/index.tsx
Normal file
34
packages/example/src/routes/index.tsx
Normal file
@@ -0,0 +1,34 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { TodosContext } from "@/todos/reffuse"
|
||||
import { TodosState } from "@/todos/services"
|
||||
import { VTodos } from "@/todos/views/VTodos"
|
||||
import { Container } from "@radix-ui/themes"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, Effect, Layer } from "effect"
|
||||
import { useMemo } from "react"
|
||||
|
||||
|
||||
export const Route = createFileRoute("/")({
|
||||
component: Index
|
||||
})
|
||||
|
||||
function Index() {
|
||||
|
||||
const todosLayer = useMemo(() => Layer.empty.pipe(
|
||||
Layer.provideMerge(TodosState.make("todos"))
|
||||
), [])
|
||||
|
||||
R.useEffect(Effect.addFinalizer(() => Console.log("Effect cleanup")).pipe(
|
||||
Effect.flatMap(() => Console.log("Effect recalculated"))
|
||||
))
|
||||
|
||||
|
||||
return (
|
||||
<Container>
|
||||
<TodosContext.Provider layer={todosLayer}>
|
||||
<VTodos />
|
||||
</TodosContext.Provider>
|
||||
</Container>
|
||||
)
|
||||
|
||||
}
|
||||
49
packages/example/src/routes/time.tsx
Normal file
49
packages/example/src/routes/time.tsx
Normal file
@@ -0,0 +1,49 @@
|
||||
import { R } from "@/reffuse"
|
||||
import { createFileRoute } from "@tanstack/react-router"
|
||||
import { Console, DateTime, Effect, Ref, Schedule, Stream } from "effect"
|
||||
|
||||
|
||||
const timeEverySecond = Stream.repeatEffectWithSchedule(
|
||||
DateTime.now,
|
||||
Schedule.intersect(Schedule.forever, Schedule.spaced("1 second")),
|
||||
)
|
||||
|
||||
|
||||
export const Route = createFileRoute("/time")({
|
||||
component: Time
|
||||
})
|
||||
|
||||
function Time() {
|
||||
|
||||
const timeRef = R.useRefFromEffect(DateTime.now)
|
||||
|
||||
R.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
|
||||
Effect.flatMap(() =>
|
||||
Stream.runForEach(timeEverySecond, v => Ref.set(timeRef, v))
|
||||
)
|
||||
), [timeRef])
|
||||
// Reffuse.useFork(Effect.addFinalizer(() => Console.log("Cleanup")).pipe(
|
||||
// Effect.flatMap(() => DateTime.now),
|
||||
// Effect.flatMap(v => Ref.set(timeRef, v)),
|
||||
// Effect.repeat(Schedule.intersect(
|
||||
// Schedule.forever,
|
||||
// Schedule.spaced("1 second"),
|
||||
// )),
|
||||
// ), [timeRef])
|
||||
|
||||
const [time] = R.useRefState(timeRef)
|
||||
|
||||
|
||||
return (
|
||||
<div className="container mx-auto">
|
||||
<p className="text-center">
|
||||
{DateTime.format(time, {
|
||||
hour: "numeric",
|
||||
minute: "numeric",
|
||||
second: "numeric",
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
)
|
||||
|
||||
}
|
||||
1
packages/example/src/services/index.ts
Normal file
1
packages/example/src/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export {}
|
||||
7
packages/example/src/todos/reffuse.ts
Normal file
7
packages/example/src/todos/reffuse.ts
Normal file
@@ -0,0 +1,7 @@
|
||||
import { GlobalContext } from "@/reffuse"
|
||||
import { Reffuse, ReffuseContext } from "reffuse"
|
||||
import { TodosState } from "./services"
|
||||
|
||||
|
||||
export const TodosContext = ReffuseContext.make<TodosState.TodosState>()
|
||||
export const R = Reffuse.make(GlobalContext, TodosContext)
|
||||
69
packages/example/src/todos/services/TodosState.ts
Normal file
69
packages/example/src/todos/services/TodosState.ts
Normal file
@@ -0,0 +1,69 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { KeyValueStore } from "@effect/platform"
|
||||
import { BrowserKeyValueStore } from "@effect/platform-browser"
|
||||
import { PlatformError } from "@effect/platform/Error"
|
||||
import { Chunk, Context, Effect, identity, Layer, ParseResult, Ref, Schema, SubscriptionRef } from "effect"
|
||||
|
||||
|
||||
export class TodosState extends Context.Tag("TodosState")<TodosState, {
|
||||
readonly todos: SubscriptionRef.SubscriptionRef<Chunk.Chunk<Todo.Todo>>
|
||||
|
||||
readonly readFromLocalStorage: Effect.Effect<void, PlatformError | ParseResult.ParseError>
|
||||
readonly saveToLocalStorage: Effect.Effect<void, PlatformError | ParseResult.ParseError>
|
||||
|
||||
readonly prepend: (todo: Todo.Todo) => Effect.Effect<void>
|
||||
readonly replace: (index: number, todo: Todo.Todo) => Effect.Effect<void>
|
||||
readonly remove: (index: number) => Effect.Effect<void>
|
||||
// readonly moveUp: (index: number) => Effect.Effect<void, Cause.NoSuchElementException>
|
||||
// readonly moveDown: (index: number) => Effect.Effect<void, Cause.NoSuchElementException>
|
||||
}>() {}
|
||||
|
||||
|
||||
export const make = (key: string) => Layer.effect(TodosState, Effect.gen(function*() {
|
||||
const todos = yield* SubscriptionRef.make(Chunk.empty<Todo.Todo>())
|
||||
|
||||
const readFromLocalStorage = KeyValueStore.KeyValueStore.pipe(
|
||||
Effect.flatMap(kv => kv.get(key)),
|
||||
Effect.flatMap(identity),
|
||||
Effect.flatMap(Schema.parseJson().pipe(
|
||||
Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
|
||||
Schema.decode,
|
||||
)),
|
||||
Effect.flatMap(v => Ref.set(todos, v)),
|
||||
|
||||
Effect.catchTag("NoSuchElementException", () => Ref.set(todos, Chunk.empty())),
|
||||
|
||||
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
|
||||
)
|
||||
|
||||
const saveToLocalStorage = Effect.all([KeyValueStore.KeyValueStore, todos]).pipe(
|
||||
Effect.flatMap(([kv, values]) => values.pipe(
|
||||
Schema.parseJson().pipe(
|
||||
Schema.compose(Schema.Chunk(Todo.TodoFromJson)),
|
||||
Schema.encode,
|
||||
),
|
||||
Effect.flatMap(v => kv.set(key, v)),
|
||||
)),
|
||||
|
||||
Effect.provide(BrowserKeyValueStore.layerLocalStorage),
|
||||
)
|
||||
|
||||
const prepend = (todo: Todo.Todo) => Ref.update(todos, Chunk.prepend(todo))
|
||||
const replace = (index: number, todo: Todo.Todo) => Ref.update(todos, Chunk.replace(index, todo))
|
||||
const remove = (index: number) => Ref.update(todos, Chunk.remove(index))
|
||||
|
||||
// const moveUp = (index: number) => Effect.gen(function*() {
|
||||
|
||||
// })
|
||||
|
||||
yield* readFromLocalStorage
|
||||
|
||||
return {
|
||||
todos,
|
||||
readFromLocalStorage,
|
||||
saveToLocalStorage,
|
||||
prepend,
|
||||
replace,
|
||||
remove,
|
||||
}
|
||||
}))
|
||||
1
packages/example/src/todos/services/index.ts
Normal file
1
packages/example/src/todos/services/index.ts
Normal file
@@ -0,0 +1 @@
|
||||
export * as TodosState from "./TodosState"
|
||||
52
packages/example/src/todos/views/VNewTodo.tsx
Normal file
52
packages/example/src/todos/views/VNewTodo.tsx
Normal file
@@ -0,0 +1,52 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { Box, Button, Card, Flex, TextArea } from "@radix-ui/themes"
|
||||
import { Effect, Option } from "effect"
|
||||
import { R } from "../reffuse"
|
||||
import { TodosState } from "../services"
|
||||
|
||||
|
||||
export function VNewTodo() {
|
||||
|
||||
const runSync = R.useRunSync()
|
||||
|
||||
const createEmptyTodo = Todo.generateUniqueID.pipe(
|
||||
Effect.map(id => Todo.Todo.make({
|
||||
id,
|
||||
content: "",
|
||||
completedAt: Option.none(),
|
||||
}, true))
|
||||
)
|
||||
|
||||
const todoRef = R.useRefFromEffect(createEmptyTodo)
|
||||
const [todo, setTodo] = R.useRefState(todoRef)
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Card>
|
||||
<Flex direction="column" align="stretch" gap="2">
|
||||
<TextArea
|
||||
value={todo.content}
|
||||
onChange={e => setTodo(prev =>
|
||||
Todo.Todo.make({ ...prev, content: e.target.value }, true)
|
||||
)}
|
||||
/>
|
||||
|
||||
<Flex direction="row" justify="center" align="center">
|
||||
<Button
|
||||
onClick={() => TodosState.TodosState.pipe(
|
||||
Effect.flatMap(state => state.prepend(todo)),
|
||||
Effect.flatMap(() => createEmptyTodo),
|
||||
Effect.map(setTodo),
|
||||
runSync,
|
||||
)}
|
||||
>
|
||||
Add
|
||||
</Button>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
|
||||
}
|
||||
56
packages/example/src/todos/views/VTodo.tsx
Normal file
56
packages/example/src/todos/views/VTodo.tsx
Normal file
@@ -0,0 +1,56 @@
|
||||
import { Todo } from "@/domain"
|
||||
import { Box, Card, Flex, IconButton, TextArea } from "@radix-ui/themes"
|
||||
import { Effect } from "effect"
|
||||
import { Delete } from "lucide-react"
|
||||
import { useState } from "react"
|
||||
import { R } from "../reffuse"
|
||||
import { TodosState } from "../services"
|
||||
|
||||
|
||||
export interface VTodoProps {
|
||||
readonly index: number
|
||||
readonly todo: Todo.Todo
|
||||
}
|
||||
|
||||
export function VTodo({ index, todo }: VTodoProps) {
|
||||
|
||||
const runSync = R.useRunSync()
|
||||
const editorMode = useState(false)
|
||||
|
||||
|
||||
return (
|
||||
<Box>
|
||||
<Card>
|
||||
<Flex direction="column" align="stretch" gap="1">
|
||||
<TextArea
|
||||
value={todo.content}
|
||||
onChange={e => TodosState.TodosState.pipe(
|
||||
Effect.flatMap(state => state.replace(
|
||||
index,
|
||||
Todo.Todo.make({ ...todo, content: e.target.value }, true),
|
||||
)),
|
||||
runSync,
|
||||
)}
|
||||
disabled={!editorMode}
|
||||
/>
|
||||
|
||||
<Flex direction="row" justify="between" align="center">
|
||||
<Box></Box>
|
||||
|
||||
<Flex direction="row" align="center" gap="1">
|
||||
<IconButton
|
||||
onClick={() => TodosState.TodosState.pipe(
|
||||
Effect.flatMap(state => state.remove(index)),
|
||||
runSync,
|
||||
)}
|
||||
>
|
||||
<Delete />
|
||||
</IconButton>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Flex>
|
||||
</Card>
|
||||
</Box>
|
||||
)
|
||||
|
||||
}
|
||||
36
packages/example/src/todos/views/VTodos.tsx
Normal file
36
packages/example/src/todos/views/VTodos.tsx
Normal file
@@ -0,0 +1,36 @@
|
||||
import { Box, Flex } from "@radix-ui/themes"
|
||||
import { Chunk, Effect, Stream } from "effect"
|
||||
import { R } from "../reffuse"
|
||||
import { TodosState } from "../services"
|
||||
import { VNewTodo } from "./VNewTodo"
|
||||
import { VTodo } from "./VTodo"
|
||||
|
||||
|
||||
export function VTodos() {
|
||||
|
||||
// Sync changes to the todos with the local storage
|
||||
R.useFork(TodosState.TodosState.pipe(
|
||||
Effect.flatMap(state =>
|
||||
Stream.runForEach(state.todos.changes, () => state.saveToLocalStorage)
|
||||
)
|
||||
))
|
||||
|
||||
const todosRef = R.useMemo(TodosState.TodosState.pipe(Effect.map(state => state.todos)))
|
||||
const [todos] = R.useRefState(todosRef)
|
||||
|
||||
|
||||
return (
|
||||
<Flex direction="column" align="center" gap="3">
|
||||
<Box width="500px">
|
||||
<VNewTodo />
|
||||
</Box>
|
||||
|
||||
{Chunk.map(todos, (todo, index) => (
|
||||
<Box key={todo.id} width="500px">
|
||||
<VTodo index={index} todo={todo} />
|
||||
</Box>
|
||||
))}
|
||||
</Flex>
|
||||
)
|
||||
|
||||
}
|
||||
1
packages/example/src/vite-env.d.ts
vendored
Normal file
1
packages/example/src/vite-env.d.ts
vendored
Normal file
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
30
packages/example/tsconfig.app.json
Normal file
30
packages/example/tsconfig.app.json
Normal file
@@ -0,0 +1,30 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.app.tsbuildinfo",
|
||||
"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,
|
||||
"noUncheckedSideEffectImports": true,
|
||||
|
||||
"paths": {
|
||||
"@/*": ["./src/*"]
|
||||
}
|
||||
},
|
||||
"include": ["src"]
|
||||
}
|
||||
7
packages/example/tsconfig.json
Normal file
7
packages/example/tsconfig.json
Normal file
@@ -0,0 +1,7 @@
|
||||
{
|
||||
"files": [],
|
||||
"references": [
|
||||
{ "path": "./tsconfig.app.json" },
|
||||
{ "path": "./tsconfig.node.json" }
|
||||
]
|
||||
}
|
||||
24
packages/example/tsconfig.node.json
Normal file
24
packages/example/tsconfig.node.json
Normal file
@@ -0,0 +1,24 @@
|
||||
{
|
||||
"compilerOptions": {
|
||||
"tsBuildInfoFile": "./node_modules/.tmp/tsconfig.node.tsbuildinfo",
|
||||
"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,
|
||||
"noUncheckedSideEffectImports": true
|
||||
},
|
||||
"include": ["vite.config.ts"]
|
||||
}
|
||||
19
packages/example/vite.config.ts
Normal file
19
packages/example/vite.config.ts
Normal file
@@ -0,0 +1,19 @@
|
||||
import { TanStackRouterVite } from "@tanstack/router-plugin/vite"
|
||||
import react from "@vitejs/plugin-react"
|
||||
import path from "node:path"
|
||||
import { defineConfig } from "vite"
|
||||
|
||||
|
||||
// https://vite.dev/config/
|
||||
export default defineConfig({
|
||||
plugins: [
|
||||
TanStackRouterVite(),
|
||||
react(),
|
||||
],
|
||||
|
||||
resolve: {
|
||||
alias: {
|
||||
"@": path.resolve(__dirname, "./src"),
|
||||
},
|
||||
},
|
||||
})
|
||||
Reference in New Issue
Block a user