0.1.0 (#1)
Some checks failed
Publish / publish (push) Failing after 13s
Lint / lint (push) Successful in 11s

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:
Julien Valverdé
2025-01-18 00:54:42 +01:00
parent 2146489fc4
commit 5430d8daa4
42 changed files with 1271 additions and 13 deletions

View 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)
)

View File

@@ -0,0 +1 @@
export * as Todo from "./Todo"

View File

View 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>
)

View 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)

View 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 */

View 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>
)
}

View 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>
)
}

View 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>
)
}

View 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>
)
}

View File

@@ -0,0 +1 @@
export {}

View 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)

View 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,
}
}))

View File

@@ -0,0 +1 @@
export * as TodosState from "./TodosState"

View 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>
)
}

View 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>
)
}

View 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
View File

@@ -0,0 +1 @@
/// <reference types="vite/client" />