Skip to content

Commit c042d2d

Browse files
authored
feat(website): add effect-atom (#1174)
1 parent fa6ce45 commit c042d2d

File tree

13 files changed

+647
-20
lines changed

13 files changed

+647
-20
lines changed

.github/instructions/nx.instructions.md

Lines changed: 3 additions & 3 deletions
Original file line numberDiff line numberDiff line change
@@ -8,14 +8,14 @@ You are in an nx workspace using Nx 21.4.0 and pnpm as the package manager.
88

99
You have access to the Nx MCP server and the tools it provides. Use them. Follow these guidelines in order to best help the user:
1010

11-
## General Guidelines
11+
# General Guidelines
1212

1313
- When answering questions, use the nx_workspace tool first to gain an understanding of the workspace architecture
1414
- For questions around nx configuration, best practices or if you're unsure, use the nx_docs tool to get relevant, up-to-date docs!! Always use this instead of assuming things about nx configuration
1515
- If the user needs help with an Nx configuration or project graph error, use the 'nx_workspace' tool to get any errors
1616
- To help answer questions about the workspace structure or simply help with demonstrating how tasks depend on each other, use the 'nx_visualize_graph' tool
1717

18-
## Generation Guidelines
18+
# Generation Guidelines
1919

2020
If the user wants to generate something, use the following flow:
2121

@@ -30,7 +30,7 @@ If the user wants to generate something, use the following flow:
3030
- read the generator log file using the 'nx_read_generator_log' tool
3131
- use the information provided in the log file to answer the user's question or continue with what they were doing
3232

33-
## Running Tasks Guidelines
33+
# Running Tasks Guidelines
3434

3535
If the user wants help with tasks or commands (which include keywords like "test", "build", "lint", or other similar actions), use the following flow:
3636

.pkgs/configs/eslint.ts

Lines changed: 1 addition & 1 deletion
Original file line numberDiff line numberDiff line change
@@ -107,7 +107,7 @@ export const typescript: ConfigArray = tseslint.config(
107107
{
108108
extends: [
109109
pluginDeMorgan.configs.recommended,
110-
// eslint-disable-next-line @typescript-eslint/no-unsafe-member-access
110+
111111
pluginJsdoc.configs["flat/recommended-typescript-error"],
112112
pluginRegexp.configs["flat/recommended"],
113113
pluginPerfectionist.configs["recommended-natural"],

apps/website/app/layout.tsx

Lines changed: 6 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -1,14 +1,14 @@
11
import type { Metadata } from "next";
22
import type { ReactNode } from "react";
3-
3+
import { Toaster } from "#/components/ui/Toaster";
4+
import { baseUrl } from "#/lib/metadata";
45
import { RootProvider } from "fumadocs-ui/provider";
5-
import { ViewTransitions } from "next-view-transitions";
66

7+
import { ViewTransitions } from "next-view-transitions";
78
import { IBM_Plex_Mono } from "next/font/google";
8-
import { baseUrl } from "../lib/metadata";
99

10-
import "./app.css";
11-
import "./app.override.css";
10+
import "#/app/app.css";
11+
import "#/app/app.override.css";
1212

1313
const ibm_plex_mono = IBM_Plex_Mono({
1414
subsets: ["latin"],
@@ -56,6 +56,7 @@ export default function Layout({ children }: { children: ReactNode }) {
5656
<RootProvider theme={themeOptions}>
5757
{children}
5858
</RootProvider>
59+
<Toaster />
5960
</body>
6061
</html>
6162
</ViewTransitions>

apps/website/atoms/location.ts

Lines changed: 21 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,21 @@
1+
import { Atom } from "@effect-atom/atom-react";
2+
import * as Option from "effect/Option";
3+
4+
function getHash() {
5+
const hash = location.hash.slice(1);
6+
if (hash.length > 0) {
7+
return Option.some(hash);
8+
}
9+
return Option.none<string>();
10+
}
11+
12+
export const hashAtom = Atom.make<Option.Option<string>>((get) => {
13+
function onHashChange() {
14+
get.setSelf(getHash());
15+
}
16+
window.addEventListener("hashchange", onHashChange);
17+
get.addFinalizer(() => {
18+
window.removeEventListener("hashchange", onHashChange);
19+
});
20+
return getHash();
21+
});

apps/website/atoms/theme.ts

Lines changed: 22 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,22 @@
1+
import { Atom } from "@effect-atom/atom-react";
2+
3+
function getTheme(): "light" | "dark" {
4+
const selected = localStorage?.getItem("starlight-theme") ?? "system";
5+
if (selected === "light" || selected === "dark") {
6+
return selected;
7+
}
8+
return window.matchMedia("(prefers-color-scheme: dark)").matches ? "dark" : "light";
9+
}
10+
11+
export const themeAtom = Atom.make<"light" | "dark">((get) => {
12+
const observer = new MutationObserver(function() {
13+
get.setSelf(getTheme());
14+
});
15+
get.addFinalizer(() => {
16+
observer.disconnect();
17+
});
18+
observer.observe(document.documentElement, {
19+
attributeFilter: ["data-theme"],
20+
});
21+
return getTheme();
22+
});
Lines changed: 113 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,113 @@
1+
import { cn } from "#/lib/cn";
2+
import { Cross2Icon } from "@radix-ui/react-icons";
3+
import * as ToastPrimitives from "@radix-ui/react-toast";
4+
import { cva, type VariantProps } from "class-variance-authority";
5+
import * as React from "react";
6+
7+
const ToastProvider = ToastPrimitives.Provider;
8+
9+
const ToastViewport = (
10+
{ className, ref, ...props }:
11+
& React.ComponentPropsWithoutRef<typeof ToastPrimitives.Viewport>
12+
& { ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Viewport> | null> },
13+
) => (
14+
<ToastPrimitives.Viewport
15+
className={cn(
16+
"fixed top-0 z-[100] flex max-h-screen w-full flex-col-reverse p-4 sm:bottom-0 sm:right-0 sm:top-auto sm:flex-col md:max-w-[420px]",
17+
className,
18+
)}
19+
ref={ref}
20+
{...props}
21+
/>
22+
);
23+
ToastViewport.displayName = ToastPrimitives.Viewport.displayName;
24+
25+
const toastVariants = cva(
26+
"group pointer-events-auto relative flex w-full items-center justify-between space-x-2 overflow-hidden rounded-md border p-4 pr-6 shadow-lg transition-all data-[swipe=cancel]:translate-x-0 data-[swipe=end]:translate-x-[var(--radix-toast-swipe-end-x)] data-[swipe=move]:translate-x-[var(--radix-toast-swipe-move-x)] data-[swipe=move]:transition-none data-[state=open]:animate-in data-[state=closed]:animate-out data-[swipe=end]:animate-out data-[state=closed]:fade-out-80 data-[state=closed]:slide-out-to-right-full data-[state=open]:slide-in-from-top-full data-[state=open]:sm:slide-in-from-bottom-full",
27+
{
28+
defaultVariants: {
29+
variant: "default",
30+
},
31+
variants: {
32+
variant: {
33+
default: "border bg-background text-foreground",
34+
destructive: "destructive group border-destructive bg-destructive text-destructive-foreground",
35+
},
36+
},
37+
},
38+
);
39+
40+
const Toast = (
41+
{ className, ref, variant, ...props }:
42+
& React.ComponentPropsWithoutRef<typeof ToastPrimitives.Root>
43+
& VariantProps<typeof toastVariants>
44+
& { ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Root> | null> },
45+
) => {
46+
return <ToastPrimitives.Root className={cn(toastVariants({ variant }), className)} ref={ref} {...props} />;
47+
};
48+
Toast.displayName = ToastPrimitives.Root.displayName;
49+
50+
const ToastAction = (
51+
{ className, ref, ...props }: React.ComponentPropsWithoutRef<typeof ToastPrimitives.Action> & {
52+
ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Action> | null>;
53+
},
54+
) => (
55+
<ToastPrimitives.Action
56+
className={cn(
57+
"inline-flex h-8 shrink-0 items-center justify-center rounded-md border bg-transparent px-3 text-sm font-medium transition-colors hover:bg-secondary focus:outline-none focus:ring-1 focus:ring-ring disabled:pointer-events-none disabled:opacity-50 group-[.destructive]:border-muted/40 group-[.destructive]:hover:border-destructive/30 group-[.destructive]:hover:bg-destructive group-[.destructive]:hover:text-destructive-foreground group-[.destructive]:focus:ring-destructive",
58+
className,
59+
)}
60+
ref={ref}
61+
{...props}
62+
/>
63+
);
64+
ToastAction.displayName = ToastPrimitives.Action.displayName;
65+
66+
const ToastClose = (
67+
{ className, ref, ...props }: React.ComponentPropsWithoutRef<typeof ToastPrimitives.Close> & {
68+
ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Close> | null>;
69+
},
70+
) => (
71+
<ToastPrimitives.Close
72+
className={cn(
73+
"absolute right-1 top-1 rounded-md p-1 text-foreground/50 opacity-0 transition-opacity hover:text-foreground focus:opacity-100 focus:outline-none focus:ring-1 group-hover:opacity-100 group-[.destructive]:text-red-300 group-[.destructive]:hover:text-red-50 group-[.destructive]:focus:ring-red-400 group-[.destructive]:focus:ring-offset-red-600",
74+
className,
75+
)}
76+
ref={ref}
77+
toast-close=""
78+
{...props}
79+
>
80+
<Cross2Icon className="h-4 w-4" />
81+
</ToastPrimitives.Close>
82+
);
83+
ToastClose.displayName = ToastPrimitives.Close.displayName;
84+
85+
const ToastTitle = (
86+
{ className, ref, ...props }: React.ComponentPropsWithoutRef<typeof ToastPrimitives.Title> & {
87+
ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Title> | null>;
88+
},
89+
) => <ToastPrimitives.Title className={cn("text-sm font-semibold [&+div]:text-xs", className)} ref={ref} {...props} />;
90+
ToastTitle.displayName = ToastPrimitives.Title.displayName;
91+
92+
const ToastDescription = (
93+
{ className, ref, ...props }: React.ComponentPropsWithoutRef<typeof ToastPrimitives.Description> & {
94+
ref?: React.RefObject<React.ElementRef<typeof ToastPrimitives.Description> | null>;
95+
},
96+
) => <ToastPrimitives.Description className={cn("text-sm opacity-90", className)} ref={ref} {...props} />;
97+
ToastDescription.displayName = ToastPrimitives.Description.displayName;
98+
99+
type ToastProps = React.ComponentPropsWithoutRef<typeof Toast>;
100+
101+
type ToastActionElement = React.ReactElement<typeof ToastAction>;
102+
103+
export {
104+
Toast,
105+
ToastAction,
106+
type ToastActionElement,
107+
ToastClose,
108+
ToastDescription,
109+
type ToastProps,
110+
ToastProvider,
111+
ToastTitle,
112+
ToastViewport,
113+
};
Lines changed: 25 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,25 @@
1+
/* eslint-disable @typescript-eslint/strict-boolean-expressions */
2+
"use client";
3+
import { Toast, ToastClose, ToastDescription, ToastProvider, ToastTitle, ToastViewport } from "#/components/ui/Toast";
4+
import { toastsAtom } from "#/services/toaster";
5+
import { useAtomValue } from "@effect-atom/atom-react";
6+
7+
export function Toaster() {
8+
const toasts = useAtomValue(toastsAtom);
9+
10+
return (
11+
<ToastProvider>
12+
{toasts.map(({ id, description, title, action, ...props }) => (
13+
<Toast className="bg-[--sl-color-bg]" key={id} {...props}>
14+
<div className="grid gap-1">
15+
{title ? <ToastTitle className="text-[--sl-color-white]">{title}</ToastTitle> : null}
16+
{description ? <ToastDescription className="text-[--sl-color-text]">{description}</ToastDescription> : null}
17+
</div>
18+
{action}
19+
<ToastClose className="bg-transparent text-[--sl-color-white] cursor-pointer" />
20+
</Toast>
21+
))}
22+
<ToastViewport />
23+
</ToastProvider>
24+
);
25+
}

apps/website/package.json

Lines changed: 5 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -11,9 +11,13 @@
1111
},
1212
"dependencies": {
1313
"@chevrotain/regexp-to-ast": "^11.0.3",
14+
"@effect-atom/atom-react": "^0.1.14",
1415
"@eslint-react/eff": "workspace:*",
16+
"@radix-ui/react-icons": "^1.3.2",
1517
"bsky-react-post": "^0.1.7",
18+
"class-variance-authority": "^0.7.1",
1619
"clsx": "^2.1.1",
20+
"effect": "^3.17.7",
1721
"fumadocs-core": "15.6.4",
1822
"fumadocs-docgen": "2.1.0",
1923
"fumadocs-mdx": "11.7.5",
@@ -30,6 +34,7 @@
3034
"twoslash": "^0.3.4"
3135
},
3236
"devDependencies": {
37+
"@effect/language-service": "^0.35.2",
3338
"@eslint-react/eslint-plugin": "workspace:*",
3439
"@eslint-react/kit": "workspace:*",
3540
"@eslint-react/shared": "workspace:*",

apps/website/services/toaster.ts

Lines changed: 68 additions & 0 deletions
Original file line numberDiff line numberDiff line change
@@ -0,0 +1,68 @@
1+
import type { ToastActionElement, ToastProps } from "#/components/ui/Toast";
2+
import { Atom, Registry } from "@effect-atom/atom-react";
3+
import * as Array from "effect/Array";
4+
import * as Effect from "effect/Effect";
5+
import * as Queue from "effect/Queue";
6+
import * as Ref from "effect/Ref";
7+
8+
export interface Toast extends ToastProps {
9+
readonly id: string;
10+
readonly description?: React.ReactNode;
11+
readonly title?: string;
12+
readonly action?: ToastActionElement;
13+
}
14+
15+
export const toastsAtom = Atom.make(Array.empty<Toast>());
16+
17+
export class Toaster extends Effect.Service<Toaster>()("app/Toaster", {
18+
scoped: Effect.gen(function*() {
19+
const counter = yield* Ref.make(0);
20+
const removeQueue = yield* Queue.unbounded<string>();
21+
const registry = yield* Registry.AtomRegistry;
22+
23+
const nextId = Ref.getAndUpdate(counter, (n) => n + 1).pipe(
24+
Effect.map((n) => (n % Number.MAX_SAFE_INTEGER).toString()),
25+
);
26+
27+
function createToast(id: string, toast: Omit<Toast, "id">): Toast {
28+
return {
29+
...toast,
30+
id,
31+
onOpenChange: (open) => !open && dismissToast(id),
32+
open: true,
33+
};
34+
}
35+
36+
function addToast(toast: Omit<Toast, "id">) {
37+
return nextId.pipe(Effect.andThen((id) => registry.update(toastsAtom, Array.prepend(createToast(id, toast)))));
38+
}
39+
40+
function removeToast(id: string) {
41+
return Effect.sync(() =>
42+
registry.update(
43+
toastsAtom,
44+
Array.filter((toast) => toast.id !== id),
45+
)
46+
);
47+
}
48+
49+
function dismissToast(id: string) {
50+
Queue.unsafeOffer(removeQueue, id);
51+
registry.update(
52+
toastsAtom,
53+
Array.map((toast) => (toast.id === id ? { ...toast, open: false } : toast)),
54+
);
55+
}
56+
57+
yield* Queue.take(removeQueue).pipe(
58+
Effect.flatMap((id) => removeToast(id).pipe(Effect.delay("5 seconds"), Effect.fork)),
59+
Effect.forever,
60+
Effect.forkScoped,
61+
Effect.interruptible,
62+
);
63+
64+
return {
65+
toast: addToast,
66+
} as const;
67+
}),
68+
}) {}

apps/website/tsconfig.json

Lines changed: 21 additions & 5 deletions
Original file line numberDiff line numberDiff line change
@@ -14,16 +14,32 @@
1414
],
1515
"erasableSyntaxOnly": true,
1616
"paths": {
17-
"#": ["."],
18-
"#/*": ["./*"],
19-
"#/.source": ["./.source/index.ts"]
17+
"#": [
18+
"."
19+
],
20+
"#/*": [
21+
"./*"
22+
],
23+
"#/.source": [
24+
"./.source/index.ts"
25+
]
2026
},
2127
"plugins": [
28+
{
29+
"name": "@effect/language-service"
30+
},
2231
{
2332
"name": "next"
2433
}
2534
]
2635
},
27-
"include": ["next-env.d.ts", "**/*.ts", "**/*.tsx", ".next/types/**/*.ts"],
28-
"exclude": ["node_modules"]
36+
"include": [
37+
"next-env.d.ts",
38+
"**/*.ts",
39+
"**/*.tsx",
40+
".next/types/**/*.ts"
41+
],
42+
"exclude": [
43+
"node_modules"
44+
]
2945
}

0 commit comments

Comments
 (0)