summaryrefslogtreecommitdiff
diff options
context:
space:
mode:
authorpolwex <polwex@sortug.com>2025-06-15 01:44:45 +0700
committerpolwex <polwex@sortug.com>2025-06-15 01:44:45 +0700
commit69312f5133734237edaea6ca29e2de9bf3203050 (patch)
tree7fee98735e5701cacb4aba87cfd307f0e1fb505a
parentf13574dc6661dba88a64580942f0c62cd42f63d7 (diff)
checkpoint here...
-rw-r--r--bs5/universal/native/DB.ml120
-rw-r--r--bs5/universal/native/dune27
-rw-r--r--bs5/universal/native/shared/Align.re30
-rw-r--r--bs5/universal/native/shared/App.re102
-rw-r--r--bs5/universal/native/shared/Arrow.re27
-rw-r--r--bs5/universal/native/shared/Button.re30
-rw-r--r--bs5/universal/native/shared/ClientRouter.re12
-rw-r--r--bs5/universal/native/shared/Context.re5
-rw-r--r--bs5/universal/native/shared/Counter.re50
-rw-r--r--bs5/universal/native/shared/Cx.re21
-rw-r--r--bs5/universal/native/shared/Debug_props.re89
-rw-r--r--bs5/universal/native/shared/DeleteNoteButton.re33
-rw-r--r--bs5/universal/native/shared/DemoLayout.re39
-rw-r--r--bs5/universal/native/shared/Document.re54
-rw-r--r--bs5/universal/native/shared/Expander.re45
-rw-r--r--bs5/universal/native/shared/Hr.re12
-rw-r--r--bs5/universal/native/shared/InputText.re15
-rw-r--r--bs5/universal/native/shared/JsMap.re76
-rw-r--r--bs5/universal/native/shared/Link.re58
-rw-r--r--bs5/universal/native/shared/Note.re16
-rw-r--r--bs5/universal/native/shared/NoteEditor.re70
-rw-r--r--bs5/universal/native/shared/NoteListSkeleton.re31
-rw-r--r--bs5/universal/native/shared/NotePreview.re12
-rw-r--r--bs5/universal/native/shared/NoteSkeleton.re6
-rw-r--r--bs5/universal/native/shared/Promise_renderer.re28
-rw-r--r--bs5/universal/native/shared/RR.re21
-rw-r--r--bs5/universal/native/shared/Router.re117
-rw-r--r--bs5/universal/native/shared/Row.re33
-rw-r--r--bs5/universal/native/shared/SearchField.re40
-rw-r--r--bs5/universal/native/shared/ServerActionFromPropsClient.re30
-rw-r--r--bs5/universal/native/shared/ServerActionWithError.re14
-rw-r--r--bs5/universal/native/shared/ServerActionWithFormData.re22
-rw-r--r--bs5/universal/native/shared/ServerActionWithFormDataFormAction.re21
-rw-r--r--bs5/universal/native/shared/ServerActionWithFormDataServer.re23
-rw-r--r--bs5/universal/native/shared/ServerActionWithFormDataWithArg.re27
-rw-r--r--bs5/universal/native/shared/ServerActionWithSimpleResponse.re23
-rw-r--r--bs5/universal/native/shared/ServerFunctions.re80
-rw-r--r--bs5/universal/native/shared/SidebarNoteContent.re69
-rw-r--r--bs5/universal/native/shared/Spinner.re11
-rw-r--r--bs5/universal/native/shared/Stack.re25
-rw-r--r--bs5/universal/native/shared/Static_small.re6
-rw-r--r--bs5/universal/native/shared/Text.re68
-rw-r--r--bs5/universal/native/shared/Textarea.re15
-rw-r--r--bs5/universal/native/shared/Theme.re141
44 files changed, 1794 insertions, 0 deletions
diff --git a/bs5/universal/native/DB.ml b/bs5/universal/native/DB.ml
new file mode 100644
index 0000000..53ffa1b
--- /dev/null
+++ b/bs5/universal/native/DB.ml
@@ -0,0 +1,120 @@
+open Lwt.Syntax
+
+let read_file file =
+ let ( / ) = Filename.concat in
+ let path = Sys.getcwd () / "server" / "db" / file in
+ try%lwt
+ let%lwt v = Lwt_io.with_file ~mode:Lwt_io.Input path Lwt_io.read in
+ Lwt_result.return v
+ with e ->
+ Dream.log "Error reading file %s: %s" path (Printexc.to_string e);
+ Lwt.return_error (Printexc.to_string e)
+
+let parse_note (note : Yojson.Safe.t) : Note.t option =
+ match note with
+ | `Assoc fields ->
+ let id =
+ fields |> List.assoc "id" |> Yojson.Safe.to_string |> int_of_string
+ in
+ let title = fields |> List.assoc "title" |> Yojson.Safe.Util.to_string in
+ let content =
+ fields |> List.assoc "content" |> Yojson.Safe.Util.to_string
+ in
+ let updated_at =
+ fields |> List.assoc "updated_at" |> Yojson.Safe.to_string
+ |> float_of_string
+ in
+ Some { Note.id; title; content; updated_at }
+ | _ -> None
+
+let parse_notes json =
+ try
+ match Yojson.Safe.from_string json with
+ | `List notes -> notes |> List.filter_map parse_note |> Result.ok
+ | _ -> Result.error "Invalid notes file format"
+ with _ -> Result.error "Invalid JSON format format"
+
+module Cache = struct
+ let db_cache = ref None
+ let set value = db_cache := Some value
+ let read () = !db_cache
+ let delete () = db_cache := None
+end
+
+let read_notes () =
+ match Cache.read () with
+ | Some (Ok notes) -> Lwt_result.return notes
+ | Some (Error e) -> Lwt_result.fail e
+ | None -> (
+ try%lwt
+ match%lwt read_file "./notes.json" with
+ | Ok json ->
+ Cache.set (parse_notes json);
+ Lwt_result.lift (parse_notes json)
+ | Error _ -> Lwt.return_error "Error reading notes file"
+ with _error ->
+ (* When something fails, treat it as an empty note db *)
+ Lwt.return_ok [])
+
+let find_one notes id =
+ match notes |> List.find_opt (fun (note : Note.t) -> note.id = id) with
+ | Some note -> Lwt_result.return note
+ | None -> Lwt_result.fail ("Note with id " ^ Int.to_string id ^ " not found")
+
+let add_note ~title ~content =
+ let%lwt notes = read_notes () in
+ let notes =
+ Result.map
+ (fun notes ->
+ let length = List.length notes in
+ let note : Note.t =
+ { id = length; title; content; updated_at = Unix.time () }
+ in
+ note :: notes)
+ notes
+ in
+ Cache.set notes;
+ Lwt_result.lift (notes |> Result.map (fun notes -> notes |> List.hd))
+
+let edit_note ~id ~title ~content =
+ let%lwt notes = read_notes () in
+ let notes =
+ Result.map
+ (fun notes ->
+ let notes =
+ notes
+ |> List.map (fun (current_note : Note.t) ->
+ if current_note.id = id then
+ {
+ current_note with
+ title;
+ content;
+ updated_at = Unix.time ();
+ }
+ else current_note)
+ in
+ notes)
+ notes
+ in
+ Cache.set notes;
+ Lwt_result.lift (notes |> Result.map (fun notes -> notes |> List.hd))
+
+let delete_note id =
+ let%lwt notes = read_notes () in
+ let notes =
+ Result.map
+ (fun notes -> notes |> List.filter (fun (note : Note.t) -> note.id <> id))
+ notes
+ in
+ Cache.set notes;
+ Lwt_result.lift notes
+
+let fetch_note id =
+ match Cache.read () with
+ | Some (Ok notes) -> find_one notes id
+ | Some (Error e) -> Lwt_result.fail e
+ | None -> (
+ let* notes = read_notes () in
+ match notes with
+ | Ok notes -> find_one notes id
+ | Error e -> Lwt_result.fail e)
diff --git a/bs5/universal/native/dune b/bs5/universal/native/dune
new file mode 100644
index 0000000..942f192
--- /dev/null
+++ b/bs5/universal/native/dune
@@ -0,0 +1,27 @@
+(include_subdirs unqualified)
+
+(library
+ (name demo_shared_native)
+ (flags :standard -w -26-27) ; browser_only removes code form the server, making this warning necessary
+ (libraries
+ server-reason-react.react
+ server-reason-react.reactDom
+ server-reason-react.js
+ server-reason-react.belt
+ server-reason-react.dom
+ server-reason-react.webapi
+ server-reason-react.url_native
+ melange-fetch
+ yojson
+ unix
+ dream
+ lwt
+ lwt.unix)
+ (wrapped false)
+ (preprocess
+ (pps
+ lwt_ppx
+ server-reason-react.melange.ppx
+ server-reason-react.ppx
+ server-reason-react.browser_ppx
+ melange-json-native.ppx)))
diff --git a/bs5/universal/native/shared/Align.re b/bs5/universal/native/shared/Align.re
new file mode 100644
index 0000000..6397067
--- /dev/null
+++ b/bs5/universal/native/shared/Align.re
@@ -0,0 +1,30 @@
+type verticalAlign = [
+ | `top
+ | `center
+ | `bottom
+];
+type horizontalAlign = [
+ | `left
+ | `center
+ | `right
+];
+
+[@react.component]
+let make = (~h: horizontalAlign=`center, ~v: verticalAlign=`center, ~children) => {
+ let className =
+ Cx.make([
+ "flex flex-col h-full w-full",
+ switch (h) {
+ | `left => "items-start"
+ | `center => "items-center"
+ | `right => "items-end"
+ },
+ switch (v) {
+ | `top => "justify-start"
+ | `center => "justify-center"
+ | `bottom => "justify-end"
+ },
+ ]);
+
+ <div className> children </div>;
+};
diff --git a/bs5/universal/native/shared/App.re b/bs5/universal/native/shared/App.re
new file mode 100644
index 0000000..25c54e4
--- /dev/null
+++ b/bs5/universal/native/shared/App.re
@@ -0,0 +1,102 @@
+module Hr = {
+ [@react.component]
+ let make = () => {
+ <span
+ className={Cx.make([
+ "block",
+ "w-full",
+ "h-px",
+ Theme.background(Theme.Color.Gray4),
+ ])}
+ />;
+ };
+};
+
+module Title = {
+ type item = {
+ label: string,
+ link: string,
+ };
+
+ module Menu = {
+ [@react.component]
+ let make = () => {
+ let data = [|
+ {
+ label: "Documentation",
+ link: "https://github.com/ml-in-barcelona/server-reason-react",
+ },
+ {
+ label: "Issues",
+ link: "https://github.com/ml-in-barcelona/server-reason-react/issues",
+ },
+ {
+ label: "About",
+ link: "https://twitter.com/davesnx",
+ },
+ |];
+
+ <div
+ className={Cx.make([
+ "flex",
+ "items-center",
+ "justify-items-end",
+ "gap-4",
+ ])}>
+ {React.array(
+ Belt.Array.mapWithIndex(data, (key, item) =>
+ <div className={Cx.make(["block"])} key={Int.to_string(key)}>
+ <Link.Text href={item.link} target="_blank">
+ {item.label}
+ </Link.Text>
+ </div>
+ ),
+ )}
+ </div>;
+ };
+ };
+
+ [@react.component]
+ let make = () => {
+ <section>
+ <div className="mb-4">
+ <h1
+ className={Cx.make([
+ "m-0",
+ "text-5xl",
+ "font-bold",
+ Theme.text(Theme.Color.Gray13),
+ ])}>
+ {React.string("Server Reason React")}
+ </h1>
+ </div>
+ <Menu />
+ </section>;
+ };
+};
+
+[@warning "-26-27-32"];
+
+[@react.component]
+let make = () => {
+ React.useEffect(() => {
+ Js.log("Client mounted");
+ None;
+ });
+
+ let (title, setTitle) = RR.useStateValue("Server Reason React");
+
+ let%browser_only onChangeTitle = e => {
+ let value = React.Event.Form.target(e)##value;
+ setTitle(value);
+ };
+
+ <DemoLayout background=Theme.Color.Gray2>
+ <Stack gap=8 justify=`start>
+ {React.array([|
+ <Title />,
+ <InputText value=title onChange=onChangeTitle />
+ |])}
+ </Stack>
+ </DemoLayout>;
+};
diff --git a/bs5/universal/native/shared/Arrow.re b/bs5/universal/native/shared/Arrow.re
new file mode 100644
index 0000000..5c49a58
--- /dev/null
+++ b/bs5/universal/native/shared/Arrow.re
@@ -0,0 +1,27 @@
+type direction =
+ | Left
+ | Right;
+
+[@react.component]
+let make = (~direction: direction=Right) => {
+ <svg
+ className={Cx.make([
+ "w-3 h-3 ms-2",
+ switch (direction) {
+ | Left => "transform -rotate-180"
+ | Right => ""
+ },
+ ])}
+ ariaHidden=true
+ xmlns="http://www.w3.org/2000/svg"
+ fill="none"
+ viewBox="0 0 14 10">
+ <path
+ stroke="currentColor"
+ strokeLinecap="round"
+ strokeLinejoin="round"
+ strokeWidth="2"
+ d="M1 5h12m0 0L9 1m4 4L9 9"
+ />
+ </svg>;
+};
diff --git a/bs5/universal/native/shared/Button.re b/bs5/universal/native/shared/Button.re
new file mode 100644
index 0000000..653b7d7
--- /dev/null
+++ b/bs5/universal/native/shared/Button.re
@@ -0,0 +1,30 @@
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make = (~noteId: option(int), ~children: React.element) => {
+ let (isPending, startTransition) = React.useTransition();
+ let {navigate, _}: ClientRouter.t = ClientRouter.useRouter();
+ let isDraft = Belt.Option.isNone(noteId);
+
+ let className =
+ Cx.make([
+ Theme.button,
+ isDraft ? "edit-button--solid" : "edit-button--outline",
+ ]);
+
+ <button
+ className
+ disabled=isPending
+ onClick={_ => {
+ startTransition(() => {
+ navigate({
+ selectedId: noteId,
+ isEditing: true,
+ searchText: None,
+ })
+ })
+ }}
+ role="menuitem">
+ children
+ </button>;
+};
diff --git a/bs5/universal/native/shared/ClientRouter.re b/bs5/universal/native/shared/ClientRouter.re
new file mode 100644
index 0000000..1d5365c
--- /dev/null
+++ b/bs5/universal/native/shared/ClientRouter.re
@@ -0,0 +1,12 @@
+/* ClientRouter does nothing in native */
+type t = Router.t(unit);
+
+let useRouter: unit => t =
+ () => {
+ {
+ location: Router.initialLocation,
+ refresh: _ => (),
+ navigate: _str => (),
+ useAction: (_, _) => ((_, _, _) => (), false),
+ };
+ };
diff --git a/bs5/universal/native/shared/Context.re b/bs5/universal/native/shared/Context.re
new file mode 100644
index 0000000..ce16c8d
--- /dev/null
+++ b/bs5/universal/native/shared/Context.re
@@ -0,0 +1,5 @@
+module Provider = {
+ include React.Context;
+ let context = React.createContext(23);
+ let make = React.Context.provider(context);
+};
diff --git a/bs5/universal/native/shared/Counter.re b/bs5/universal/native/shared/Counter.re
new file mode 100644
index 0000000..4f8835d
--- /dev/null
+++ b/bs5/universal/native/shared/Counter.re
@@ -0,0 +1,50 @@
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make = (~initial: int) => {
+ let (state, [@browser_only] setCount) = RR.useStateValue(initial);
+
+ let onClick = _ => {
+ switch%platform () {
+ | Client => setCount(state + 1)
+ | Server => ()
+ };
+ };
+
+ <Row align=`center gap=2>
+ {React.array([|
+ <Text color=Theme.Color.Gray11> "A classic counter" </Text>,
+ <button
+ onClick={e => onClick(e)}
+ className="cursor-pointer font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800">
+ {React.string(Int.to_string(state))}
+ </button>
+ |])}
+ </Row>;
+};
+
+module Double = {
+ /* This component tests that client components can be nested in modules */
+ [@react.client.component]
+ let make = (~initial: int) => {
+ let (state, [@browser_only] setCount) = RR.useStateValue(initial);
+
+ let onClick = _ => {
+ switch%platform () {
+ | Client => setCount(state + 2)
+ | Server => ()
+ };
+ };
+
+ <Row align=`center gap=2>
+ {React.array([|
+ <Text color=Theme.Color.Gray11> "A classic counter" </Text>,
+ <button
+ onClick={e => onClick(e)}
+ className="cursor-pointer font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800">
+ {React.string(Int.to_string(state))}
+ </button>
+ |])}
+ </Row>;
+ };
+};
diff --git a/bs5/universal/native/shared/Cx.re b/bs5/universal/native/shared/Cx.re
new file mode 100644
index 0000000..caafd0a
--- /dev/null
+++ b/bs5/universal/native/shared/Cx.re
@@ -0,0 +1,21 @@
+let make = cns =>
+ cns->Belt.List.keep(x => x !== "") |> String.concat(" ") |> String.trim;
+
+let ifTrue = (cn, x) => x ? cn : "";
+
+let ifSome = (cn, x) =>
+ switch (x) {
+ | Some(_) => cn
+ | None => ""
+ };
+
+let mapSome = (x, fn) =>
+ switch (x) {
+ | Some(x) => fn(x)
+ | None => ""
+ };
+
+let unpack =
+ fun
+ | Some(x) => x
+ | None => "";
diff --git a/bs5/universal/native/shared/Debug_props.re b/bs5/universal/native/shared/Debug_props.re
new file mode 100644
index 0000000..49b4567
--- /dev/null
+++ b/bs5/universal/native/shared/Debug_props.re
@@ -0,0 +1,89 @@
+[@warning "-33"];
+
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make =
+ (
+ ~string: string,
+ ~int: int=999999,
+ ~float: float,
+ ~bool_true: bool,
+ ~bool_false: bool,
+ ~string_list: list(string),
+ ~header: option(React.element),
+ ~children: React.element,
+ ~promise: Js.Promise.t(string),
+ ) => {
+ <code
+ className="inline-flex text-left items-center space-x-4 bg-stone-800 text-slate-300 rounded-lg p-4 pl-6">
+ <Stack gap=3>
+ {React.array([|
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("string")} </span>,
+ <span> {React.string(string)} </span>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("int")} </span>,
+ <span> {React.int(int)} </span>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("float")} </span>,
+ <span> {React.float(float)} </span>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("bool_true")} </span>,
+ <span> {React.string(bool_true ? "true" : "false")} </span>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("bool_false")} </span>,
+ <span> {React.string(bool_false ? "true" : "false")} </span>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("string_list")} </span>,
+ <Row gap=2>
+ {string_list
+ |> Array.of_list
+ |> Array.map(item => <span key=item> {React.string(item)} </span>)
+ |> React.array}
+ </Row>
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("React.element")} </span>,
+ children
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold">
+ {React.string("option(React.element)")}
+ </span>,
+ {switch (header) {
+ | Some(header) => <header> header </header>
+ | None => React.null
+ }}
+ |])}
+ </Row>,
+ <Row gap=2>
+ {React.array([|
+ <span className="font-bold"> {React.string("Promise")} </span>,
+ <Promise_renderer promise />
+ |])}
+ </Row>
+ |])}
+ </Stack>
+ </code>;
+};
diff --git a/bs5/universal/native/shared/DeleteNoteButton.re b/bs5/universal/native/shared/DeleteNoteButton.re
new file mode 100644
index 0000000..995b489
--- /dev/null
+++ b/bs5/universal/native/shared/DeleteNoteButton.re
@@ -0,0 +1,33 @@
+open Melange_json.Primitives;
+
+[@warning "-26-27-32"];
+[@react.client.component]
+let make = (~noteId: int) => {
+ let (isNavigating, startNavigating) = React.useTransition();
+ let (isDeleting, setIsDeleting) = RR.useStateValue(false);
+ let {navigate, _}: ClientRouter.t = ClientRouter.useRouter();
+
+ let className = Theme.button;
+
+ <button
+ className
+ disabled={isNavigating || isDeleting}
+ onClick={_ => {
+ ServerFunctions.Notes.delete_.call(~id=noteId)
+ |> Js.Promise.then_(_ => {
+ setIsDeleting(false);
+ startNavigating(() => {
+ navigate({
+ selectedId: None,
+ isEditing: false,
+ searchText: None,
+ })
+ });
+ Js.Promise.resolve();
+ })
+ |> ignore
+ }}
+ role="menuitem">
+ {React.string("Delete")}
+ </button>;
+};
diff --git a/bs5/universal/native/shared/DemoLayout.re b/bs5/universal/native/shared/DemoLayout.re
new file mode 100644
index 0000000..376162f
--- /dev/null
+++ b/bs5/universal/native/shared/DemoLayout.re
@@ -0,0 +1,39 @@
+type mode =
+ | FullScreen
+ | Fit800px;
+
+[@react.component]
+let make = (~children, ~background=Theme.Color.Gray2, ~mode=Fit800px) => {
+ <div
+ className={Cx.make([
+ "m-0",
+ "p-8",
+ "min-w-[100vw]",
+ "min-h-[100vh]",
+ switch (mode) {
+ | FullScreen => "h-100vh w-100vw"
+ | Fit800px => "h-full w-[800px]"
+ },
+ "flex",
+ "flex-col",
+ "items-center",
+ "justify-start",
+ Theme.background(background),
+ ])}>
+ <nav className="w-full mt-10">
+ <a
+ className={Cx.make([
+ "text-s font-bold inline-flex items-center justify-between gap-2",
+ Theme.text(Theme.Color.Gray12),
+ Theme.hover([Theme.text(Theme.Color.Gray10)]),
+ ])}
+ href=Router.home>
+ <Arrow direction=Left />
+ {React.string("Home")}
+ </a>
+ </nav>
+ <div spellCheck=false className="w-full pt-6 max-w-[1200px]">
+ children
+ </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/Document.re b/bs5/universal/native/shared/Document.re
new file mode 100644
index 0000000..9883fc8
--- /dev/null
+++ b/bs5/universal/native/shared/Document.re
@@ -0,0 +1,54 @@
+let globalStyles =
+ Printf.sprintf(
+ {js|
+ html, body, #root {
+ margin: 0;
+ padding: 0;
+ width: 100vw;
+ height: 100vh;
+ background-color: %s;
+ }
+
+ * {
+ font-family: -apple-system, BlinkMacSystemFont, Roboto, Helvetica, Arial, sans-serif;
+ -webkit-font-smoothing: antialiased;
+ -moz-osx-font-smoothing: grayscale;
+ box-sizing: border-box;
+ }
+
+ @keyframes spin {
+ from {
+ transform: rotate(0deg);
+ }
+ to {
+ transform: rotate(360deg);
+ }
+ }
+|js},
+ Theme.Color.gray2,
+ );
+
+[@react.component]
+let make = (~children, ~script=?) => {
+ <html>
+ <head>
+ <meta charSet="UTF-8" />
+ <meta name="viewport" content="width=device-width, initial-scale=1.0" />
+ <title> {React.string("Server Reason React demo")} </title>
+ <link
+ rel="shortcut icon"
+ href="https://reasonml.github.io/img/icon_50.png"
+ />
+ <style
+ type_="text/css"
+ dangerouslySetInnerHTML={"__html": globalStyles}
+ />
+ <link rel="stylesheet" href="/output.css" />
+ {switch (script) {
+ | None => React.null
+ | Some(src) => <script type_="module" src />
+ }}
+ </head>
+ <body> <div id="root"> children </div> </body>
+ </html>;
+};
diff --git a/bs5/universal/native/shared/Expander.re b/bs5/universal/native/shared/Expander.re
new file mode 100644
index 0000000..a64ce51
--- /dev/null
+++ b/bs5/universal/native/shared/Expander.re
@@ -0,0 +1,45 @@
+[@warning "-26-27-32"];
+
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make =
+ (
+ ~id: int,
+ ~title: string,
+ ~children: React.element,
+ ~expandedChildren: React.element,
+ ) => {
+ let (isExpanded, setIsExpanded) = RR.useStateValue(false);
+ let (isPending, startTransition) = React.useTransition();
+
+ <div
+ className={Cx.make([
+ "mb-3 flex flex-col rounded-md",
+ Theme.background(Theme.Color.Gray4),
+ Theme.border(Theme.Color.None),
+ ])}>
+ <div
+ className={Cx.make([
+ "relative p-4 w-full justify-between items-start flex-wrap transition-[max-height] duration-250 ease-out scale-100 flex flex-col gap-1 cursor-pointer",
+ ])}>
+ children
+ {isExpanded ? expandedChildren : React.null}
+ </div>
+ <div
+ className="px-4 mt-1 mb-4 cursor-pointer self-center w-full"
+ onClick={_ => setIsExpanded(!isExpanded)}>
+ <div
+ className={Cx.make([
+ isExpanded ? "" : "rotate-180",
+ "w-full rounded-md flex items-center justify-center pt-1 text-sm select-none",
+ "transition-[background-color] duration-250 ease-out",
+ Theme.text(Theme.Color.Gray11),
+ Theme.background(Theme.Color.Gray5),
+ Theme.hover([Theme.background(Theme.Color.Gray7)]),
+ ])}>
+ {React.string("^")}
+ </div>
+ </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/Hr.re b/bs5/universal/native/shared/Hr.re
new file mode 100644
index 0000000..bee64e3
--- /dev/null
+++ b/bs5/universal/native/shared/Hr.re
@@ -0,0 +1,12 @@
+[@react.component]
+let make = () => {
+ <hr
+ className={Cx.make([
+ "block",
+ "w-full",
+ "h-[1px]",
+ "border-0 border-b-2",
+ Theme.border(Theme.Color.Gray7),
+ ])}
+ />;
+};
diff --git a/bs5/universal/native/shared/InputText.re b/bs5/universal/native/shared/InputText.re
new file mode 100644
index 0000000..011ba38
--- /dev/null
+++ b/bs5/universal/native/shared/InputText.re
@@ -0,0 +1,15 @@
+[@react.component]
+let make = (~value, ~onChange, ~id="", ~placeholder="") =>
+ <input
+ className={Cx.make([
+ "m-0 py-2 px-4",
+ "rounded-md",
+ Theme.background(Theme.Color.Gray1),
+ Theme.text(Theme.Color.Gray12),
+ ])}
+ id
+ placeholder
+ type_="text"
+ value
+ onChange
+ />;
diff --git a/bs5/universal/native/shared/JsMap.re b/bs5/universal/native/shared/JsMap.re
new file mode 100644
index 0000000..abf8cfc
--- /dev/null
+++ b/bs5/universal/native/shared/JsMap.re
@@ -0,0 +1,76 @@
+[@platform js]
+include {
+ type t('k, 'v);
+
+ [@mel.new] external make: unit => t('k, 'v) = "Map";
+
+ [@mel.send] [@mel.return nullable]
+ external get: (t('k, 'v), 'k) => option('v) = "get";
+
+ [@mel.send] external set: (t('k, 'v), 'k, 'v) => unit = "set";
+
+ [@mel.send] external delete: (t('k, 'v), 'k) => unit = "delete";
+
+ [@mel.send] external clear: t('k, 'v) => unit = "clear";
+
+ [@mel.send] external size: t('k, 'v) => int = "size";
+
+ [@mel.send] external has: (t('k, 'v), 'k) => bool = "has";
+
+ [@mel.send] external values: t('k, 'v) => array('v) = "values";
+
+ [@mel.send] external keys: t('k, 'v) => array('k) = "keys";
+
+ [@mel.send]
+ external entries: t('k, 'v) => array(('k, 'v)) = "entries";
+
+ [@mel.send]
+ external forEach: (t('k, 'v), ('k, 'v) => unit) => unit = "forEach";
+ };
+
+[@platform native]
+include {
+ type t('k, 'v) = {mutable entries: list(('k, 'v))};
+
+ let make = () => {entries: []};
+
+ let set = (map, k, v) => {
+ let rec update = entries =>
+ switch (entries) {
+ | [] => [(k, v)]
+ | [(k', _), ...rest] when k == k' => [(k, v), ...rest]
+ | [pair, ...rest] => [pair, ...update(rest)]
+ };
+ map.entries = update(map.entries);
+ map;
+ };
+
+ let get = (map, k) =>
+ map.entries
+ |> List.find_opt(((k', _)) => k == k')
+ |> Option.map(((_, v)) => v);
+
+ let delete = (map, k) => {
+ map.entries = List.filter(((k', _)) => k != k', map.entries);
+ map;
+ };
+
+ let clear = map => {
+ map.entries = [];
+ map;
+ };
+
+ let size = map => List.length(map.entries);
+
+ let has = (map, k) =>
+ List.exists(((k', _)) => k == k', map.entries);
+
+ let values = map => List.map(((_, v)) => v, map.entries);
+
+ let keys = map => List.map(((k, _)) => k, map.entries);
+
+ let entries = map => map.entries;
+
+ let forEach = (map, f) =>
+ List.iter(((k, v)) => f(v, k, map), map.entries);
+ };
diff --git a/bs5/universal/native/shared/Link.re b/bs5/universal/native/shared/Link.re
new file mode 100644
index 0000000..c4965a5
--- /dev/null
+++ b/bs5/universal/native/shared/Link.re
@@ -0,0 +1,58 @@
+let defaultSize = Text.Medium;
+
+module Base = {
+ [@react.component]
+ let make = (~size, ~color, ~href, ~children, ~underline, ~target=?) => {
+ <a
+ href
+ ?target
+ className={Cx.make([
+ Text.size_to_string(size),
+ "inline-flex items-center",
+ underline ? "underline" : "",
+ "transition-colors duration-250 ease-out",
+ Theme.text(color),
+ Theme.hover([
+ Theme.text(Theme.Color.oneScaleUp(color)),
+ underline ? "underline" : "",
+ ]),
+ ])}>
+ children
+ </a>;
+ };
+};
+
+module Text = {
+ [@react.component]
+ let make =
+ (
+ ~color=Theme.Color.Gray12,
+ ~size=defaultSize,
+ ~href,
+ ~children,
+ ~target=?,
+ ) => {
+ <Base size href color ?target underline=true>
+ {React.string(children)}
+ </Base>;
+ };
+};
+
+module WithArrow = {
+ [@react.component]
+ let make =
+ (
+ ~color=Theme.Color.Gray13,
+ ~size=defaultSize,
+ ~href,
+ ~children,
+ ~target=?,
+ ) => {
+ <Base size href color ?target underline=false>
+ {React.array([|
+ React.string(children),
+ <Arrow />
+ |])}
+ </Base>;
+ };
+};
diff --git a/bs5/universal/native/shared/Note.re b/bs5/universal/native/shared/Note.re
new file mode 100644
index 0000000..53b598c
--- /dev/null
+++ b/bs5/universal/native/shared/Note.re
@@ -0,0 +1,16 @@
+open Melange_json.Primitives;
+
+[@deriving json]
+type t = {
+ id: int,
+ title: string,
+ content: string,
+ updated_at: float,
+};
+
+let pp = note => {
+ Dream.log("%s", "Note");
+ Dream.log(" title: %s", note.title);
+ Dream.log(" content: %s", note.content);
+ Dream.log(" updated_at: %f", note.updated_at);
+};
diff --git a/bs5/universal/native/shared/NoteEditor.re b/bs5/universal/native/shared/NoteEditor.re
new file mode 100644
index 0000000..265a5ae
--- /dev/null
+++ b/bs5/universal/native/shared/NoteEditor.re
@@ -0,0 +1,70 @@
+[@warning "-26-27-32"];
+
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make =
+ (~noteId: option(int), ~initialTitle: string, ~initialBody: string) => {
+ let router: ClientRouter.t = ClientRouter.useRouter();
+ let (title, setTitle) = RR.useStateValue(initialTitle);
+ let (body, setBody) = RR.useStateValue(initialBody);
+ let (isNavigating, startNavigating) = React.useTransition();
+
+ let%browser_only onChangeTitle = e => {
+ let newValue = React.Event.Form.target(e)##value;
+ setTitle(newValue);
+ };
+
+ let%browser_only onChangeBody = e => {
+ let newValue = React.Event.Form.target(e)##value;
+ setBody(newValue);
+ };
+
+ <div className="flex flex-col gap-4">
+ <form
+ className="flex flex-col gap-2"
+ autoComplete="off"
+ onSubmit={e => React.Event.Form.preventDefault(e)}>
+ <InputText value=title onChange=onChangeTitle />
+ <Textarea rows=10 value=body onChange=onChangeBody />
+ </form>
+ <div className="flex flex-col gap-4">
+ <div className="flex flex-row gap-2" role="menubar">
+ <button
+ className=Theme.button
+ disabled=isNavigating
+ onClick=[%browser_only
+ _ => {
+ let action =
+ switch (noteId) {
+ | Some(id) =>
+ ServerFunctions.Notes.edit.call(~id, ~title, ~content=body)
+ | None =>
+ ServerFunctions.Notes.create.call(~title, ~content=body)
+ };
+
+ action
+ |> Js.Promise.then_((result: Note.t) => {
+ let id = result.id;
+ router.navigate({
+ selectedId: Some(id),
+ isEditing: false,
+ searchText: None,
+ });
+ Js.Promise.resolve();
+ })
+ |> ignore;
+ }
+ ]
+ role="menuitem">
+ {React.string("Done")}
+ </button>
+ {switch (noteId) {
+ | Some(id) => <DeleteNoteButton noteId=id />
+ | None => React.null
+ }}
+ </div>
+ <NotePreview key="note-preview" body />
+ </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/NoteListSkeleton.re b/bs5/universal/native/shared/NoteListSkeleton.re
new file mode 100644
index 0000000..a72ee2e
--- /dev/null
+++ b/bs5/universal/native/shared/NoteListSkeleton.re
@@ -0,0 +1,31 @@
+[@react.component]
+let make = () => {
+ <div className="mt-8">
+ <ul className="flex flex-col">
+ <li className="v-stack">
+ <div
+ className={Cx.make([
+ Theme.background(Theme.Color.Gray4),
+ "animate-pulse relative mb-3 p-4 w-full flex justify-between items-start flex-wrap h-[150px] transition-[max-height] ease-out rounded-md",
+ ])}
+ />
+ </li>
+ <li className="v-stack">
+ <div
+ className={Cx.make([
+ Theme.background(Theme.Color.Gray4),
+ "animate-pulse relative mb-3 p-4 w-full flex justify-between items-start flex-wrap h-[150px] transition-[max-height] ease-out rounded-md",
+ ])}
+ />
+ </li>
+ <li className="v-stack">
+ <div
+ className={Cx.make([
+ Theme.background(Theme.Color.Gray4),
+ "animate-pulse relative mb-3 p-4 w-full flex justify-between items-start flex-wrap h-[150px] transition-[max-height] ease-out rounded-md",
+ ])}
+ />
+ </li>
+ </ul>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/NotePreview.re b/bs5/universal/native/shared/NotePreview.re
new file mode 100644
index 0000000..f6cdf10
--- /dev/null
+++ b/bs5/universal/native/shared/NotePreview.re
@@ -0,0 +1,12 @@
+[@react.component]
+let make = (~body: string) => {
+ <span
+ className={Cx.make([
+ "markdown",
+ "block w-full p-8 rounded-md",
+ Theme.background(Theme.Color.Gray4),
+ Theme.text(Theme.Color.Gray12),
+ ])}
+ dangerouslySetInnerHTML={"__html": body}
+ />;
+};
diff --git a/bs5/universal/native/shared/NoteSkeleton.re b/bs5/universal/native/shared/NoteSkeleton.re
new file mode 100644
index 0000000..594b212
--- /dev/null
+++ b/bs5/universal/native/shared/NoteSkeleton.re
@@ -0,0 +1,6 @@
+[@react.component]
+let make = (~isEditing as _) => {
+ <div className="flex items-center justify-center h-full">
+ <Text> "Loading..." </Text>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/Promise_renderer.re b/bs5/universal/native/shared/Promise_renderer.re
new file mode 100644
index 0000000..94ec072
--- /dev/null
+++ b/bs5/universal/native/shared/Promise_renderer.re
@@ -0,0 +1,28 @@
+[@warning "-33"];
+
+open Melange_json.Primitives;
+
+module Reader = {
+ [@react.component]
+ let make = (~promise: Js.Promise.t(string)) => {
+ let value = React.Experimental.use(promise);
+ let%browser_only onMouseOver = _ev => {
+ Js.log("Over the promise!");
+ };
+ <div className="cursor-pointer" onMouseOver> <Text> value </Text> </div>;
+ };
+};
+
+[@react.client.component]
+let make = (~promise: Js.Promise.t(string)) => {
+ <div className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <React.Suspense
+ fallback={
+ <div className={Cx.make([Theme.text(Theme.Color.Gray14)])}>
+ {React.string("Loading...")}
+ </div>
+ }>
+ <Reader promise />
+ </React.Suspense>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/RR.re b/bs5/universal/native/shared/RR.re
new file mode 100644
index 0000000..46399a2
--- /dev/null
+++ b/bs5/universal/native/shared/RR.re
@@ -0,0 +1,21 @@
+[@platform native]
+include {
+ let useStateValue = initialState => {
+ let setValueStatic = _newState => ();
+ (initialState, setValueStatic);
+ };
+ };
+
+[@platform js]
+include {
+ [@mel.module "react"]
+ external useState:
+ (unit => 'state) => ('state, (. ('state => 'state)) => unit) =
+ "useState";
+
+ let useStateValue = initialState => {
+ let (state, setState) = useState(_ => initialState);
+ let setValueStatic = newState => setState(. _ => newState);
+ (state, setValueStatic);
+ };
+ };
diff --git a/bs5/universal/native/shared/Router.re b/bs5/universal/native/shared/Router.re
new file mode 100644
index 0000000..7b814a6
--- /dev/null
+++ b/bs5/universal/native/shared/Router.re
@@ -0,0 +1,117 @@
+let home = "/";
+let demoRenderToStaticMarkup = "/demo/render-to-static-markup";
+let demoRenderToString = "/demo/render-to-string";
+let demoRenderToStream = "/demo/render-to-stream";
+let demoServerOnlyRSC = "/demo/server-only-rsc";
+let demoSinglePageRSC = "/demo/single-page-rsc";
+let demoRouterRSC = "/demo/router-rsc";
+
+let links = [|
+ ("Server side render to string (renderToString)", demoRenderToString),
+ (
+ "Server side render to static markup (renderToStaticMarkup)",
+ demoRenderToStaticMarkup,
+ ),
+ ("Server side render to stream (renderToStream)", demoRenderToStream),
+ (
+ "React Server components without client (createFromFetch)",
+ demoServerOnlyRSC,
+ ),
+ (
+ "React Server components with createFromReadableStream (RSC + SSR)",
+ demoSinglePageRSC,
+ ),
+ (
+ "React Server components with single page router (createFromFetch + createFromReadableStream)",
+ demoRouterRSC,
+ ),
+|];
+
+module Menu = {
+ [@react.component]
+ let make = () => {
+ <ul className="flex flex-col gap-4">
+ {links
+ |> Array.map(((title, href)) =>
+ <li> <Link.WithArrow href> title </Link.WithArrow> </li>
+ )
+ |> React.array}
+ </ul>;
+ };
+};
+type location = {
+ selectedId: option(int),
+ isEditing: bool,
+ searchText: option(string),
+};
+
+let locationToString = location =>
+ [
+ switch (location.selectedId) {
+ | Some(id) => "selectedId=" ++ Int.to_string(id)
+ | None => ""
+ },
+ "isEditing=" ++ (location.isEditing ? "true" : "false"),
+ switch (location.searchText) {
+ | Some(text) => "searchText=" ++ text
+ | None => ""
+ },
+ ]
+ |> List.filter(s => s != "")
+ |> String.concat("&");
+
+let initialLocation = {
+ selectedId: None,
+ isEditing: false,
+ searchText: None,
+};
+
+let locationFromString = str => {
+ switch (URL.make(str)) {
+ | Some(url) =>
+ let searchParams = URL.searchParams(url);
+ let selectedId =
+ URL.SearchParams.get(searchParams, "selectedId")
+ |> Option.map(id => int_of_string(id));
+ let searchText = URL.SearchParams.get(searchParams, "searchText");
+
+ let isEditing =
+ URL.SearchParams.get(searchParams, "isEditing")
+ |> Option.map(v =>
+ switch (v) {
+ | "true" => true
+ | "false" => false
+ | _ => false
+ }
+ )
+ |> Option.value(~default=false);
+
+ {
+ selectedId,
+ isEditing,
+ searchText,
+ };
+
+ | None => initialLocation
+ };
+};
+
+type payload = {
+ body: string,
+ title: string,
+};
+
+/* 'a is melange-fetch's response in melange */
+type t('a) = {
+ location,
+ navigate: location => unit,
+ useAction: (string, string) => ((payload, location, unit) => unit, bool),
+ refresh: option('a) => unit,
+};
+
+let useRouter = () => {
+ location: initialLocation,
+ navigate: _ => (),
+ useAction: (_, _) => ((_, _, _) => (), false),
+ refresh: _ => (),
+};
diff --git a/bs5/universal/native/shared/Row.re b/bs5/universal/native/shared/Row.re
new file mode 100644
index 0000000..451fa73
--- /dev/null
+++ b/bs5/universal/native/shared/Row.re
@@ -0,0 +1,33 @@
+[@react.component]
+let make =
+ (
+ ~gap=0,
+ ~align: Theme.align=`start,
+ ~justify: Theme.justify=`around,
+ ~fullHeight=false,
+ ~fullWidth=false,
+ ~children,
+ ) => {
+ let className =
+ Cx.make([
+ "flex row",
+ fullHeight ? "h-full" : "h-auto",
+ fullWidth ? "w-full" : "w-auto",
+ "gap-" ++ Int.to_string(gap),
+ switch (align) {
+ | `start => "items-start"
+ | `center => "items-center"
+ | `end_ => "items-end"
+ },
+ switch (justify) {
+ | `around => "justify-around"
+ | `between => "justify-between"
+ | `evenly => "justify-evenly"
+ | `start => "justify-start"
+ | `center => "justify-center"
+ | `end_ => "justify-end"
+ },
+ ]);
+
+ <div className> children </div>;
+};
diff --git a/bs5/universal/native/shared/SearchField.re b/bs5/universal/native/shared/SearchField.re
new file mode 100644
index 0000000..5ab888d
--- /dev/null
+++ b/bs5/universal/native/shared/SearchField.re
@@ -0,0 +1,40 @@
+[@warning "-26-27"];
+
+open Melange_json.Primitives;
+
+[@react.client.component]
+let make = (~searchText: string, ~selectedId: option(int), ~isEditing: bool) => {
+ let {navigate, _}: ClientRouter.t = ClientRouter.useRouter();
+ let (text, setText) = RR.useStateValue(searchText);
+ let (isSearching, startSearching) = React.useTransition();
+
+ let onSubmit = event => {
+ React.Event.Form.preventDefault(event);
+ };
+
+ let%browser_only onChange = event => {
+ let target = React.Event.Form.target(event);
+ let nextText = target##value;
+ setText(nextText);
+ startSearching(() =>
+ navigate({
+ searchText: Some(nextText),
+ selectedId,
+ isEditing,
+ })
+ );
+ };
+
+ <form className="search" role="search" onSubmit>
+ <label className="offscreen mr-4" htmlFor="sidebar-search-input">
+ <Text> "Search for a note by title" </Text>
+ </label>
+ <InputText
+ id="sidebar-search-input"
+ placeholder="Search"
+ value=text
+ onChange
+ />
+ <Spinner active=isSearching />
+ </form>;
+};
diff --git a/bs5/universal/native/shared/ServerActionFromPropsClient.re b/bs5/universal/native/shared/ServerActionFromPropsClient.re
new file mode 100644
index 0000000..a374870
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionFromPropsClient.re
@@ -0,0 +1,30 @@
+[@react.client.component]
+let make =
+ (
+ ~actionOnClick:
+ Runtime.server_function(
+ (~name: string, ~age: int) => Js.Promise.t(string),
+ ),
+ ) => {
+ let (isLoading, setIsLoading) = RR.useStateValue(false);
+ let (message, setMessage) = RR.useStateValue("");
+ <div>
+ <button
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ onClick={_ => {
+ setIsLoading(true);
+ actionOnClick.call(~name="Lola", ~age=20)
+ |> Js.Promise.then_(response => {
+ setIsLoading(false);
+ Js.log(response);
+ setMessage(response);
+ Js.Promise.resolve();
+ })
+ |> ignore;
+ }}>
+ {React.string("Click me to get a message from the server")}
+ </button>
+ <div className="mb-4" />
+ <div> <Text> {isLoading ? "Loading..." : message} </Text> </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithError.re b/bs5/universal/native/shared/ServerActionWithError.re
new file mode 100644
index 0000000..94e8e67
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithError.re
@@ -0,0 +1,14 @@
+[@react.client.component]
+let make = () => {
+ <div className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <button
+ className="cursor-pointer font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ onClick=[%browser_only
+ _ => {
+ ServerFunctions.error.call() |> ignore;
+ }
+ ]>
+ {React.string("Click to trigger error, see it on the console")}
+ </button>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithFormData.re b/bs5/universal/native/shared/ServerActionWithFormData.re
new file mode 100644
index 0000000..415d694
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithFormData.re
@@ -0,0 +1,22 @@
+[@react.client.component]
+let make = () => {
+ <form
+ action={
+ switch%platform () {
+ | Server => `String("")
+ | Client => Obj.magic(ServerFunctions.formDataFunction.call)
+ }
+ }
+ className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <input
+ name="name"
+ className="w-full mb-2 font-sans border border-gray-300 py-2 px-4 rounded-md bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
+ placeholder="Name"
+ />
+ <button
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ type_="submit">
+ {React.string("Send Form Data")}
+ </button>
+ </form>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithFormDataFormAction.re b/bs5/universal/native/shared/ServerActionWithFormDataFormAction.re
new file mode 100644
index 0000000..fe80f98
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithFormDataFormAction.re
@@ -0,0 +1,21 @@
+[@react.component]
+let make = () => {
+ <form className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <input
+ name="name"
+ className="w-full mb-2 font-sans border border-gray-300 py-2 px-4 rounded-md bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
+ placeholder="Name"
+ />
+ <button
+ formAction={
+ switch%platform () {
+ | Server => `Function(ServerFunctions.formDataFunction)
+ | Client => ""
+ }
+ }
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ type_="submit">
+ {React.string("Send Form Data")}
+ </button>
+ </form>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithFormDataServer.re b/bs5/universal/native/shared/ServerActionWithFormDataServer.re
new file mode 100644
index 0000000..f539f65
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithFormDataServer.re
@@ -0,0 +1,23 @@
+[@react.component]
+let make = () => {
+ <form
+ action={
+ switch%platform () {
+ | Server => `Function(ServerFunctions.formDataFunction)
+ // doesn't matter the client value, it will never reach the browser
+ | Client => ""
+ }
+ }
+ className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <input
+ name="name"
+ className="w-full mb-2 font-sans border border-gray-300 py-2 px-4 rounded-md bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
+ placeholder="Name"
+ />
+ <button
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ type_="submit">
+ {React.string("Send Form Data")}
+ </button>
+ </form>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithFormDataWithArg.re b/bs5/universal/native/shared/ServerActionWithFormDataWithArg.re
new file mode 100644
index 0000000..2196f80
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithFormDataWithArg.re
@@ -0,0 +1,27 @@
+[@react.client.component]
+let make = () => {
+ <form
+ action={
+ switch%platform () {
+ | Server => `String("")
+ | Client =>
+ Obj.magic(
+ ServerFunctions.formDataWithArg.call(
+ Js.Date.now() |> string_of_float,
+ ),
+ )
+ }
+ }
+ className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <input
+ name="country"
+ className="w-full mb-2 font-sans border border-gray-300 py-2 px-4 rounded-md bg-white text-gray-900 placeholder-gray-500 focus:outline-none focus:ring-2 focus:ring-blue-500 focus:border-transparent transition duration-200"
+ placeholder="Name"
+ />
+ <button
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ type_="submit">
+ {React.string("Send Form Data")}
+ </button>
+ </form>;
+};
diff --git a/bs5/universal/native/shared/ServerActionWithSimpleResponse.re b/bs5/universal/native/shared/ServerActionWithSimpleResponse.re
new file mode 100644
index 0000000..7df593e
--- /dev/null
+++ b/bs5/universal/native/shared/ServerActionWithSimpleResponse.re
@@ -0,0 +1,23 @@
+[@react.client.component]
+let make = () => {
+ let (message, setMessage) = RR.useStateValue("");
+ let (isLoading, setIsLoading) = RR.useStateValue(false);
+
+ <div className={Cx.make([Theme.text(Theme.Color.Gray4)])}>
+ <button
+ className="font-mono border-2 py-1 px-2 rounded-lg bg-yellow-950 border-yellow-700 text-yellow-200 hover:bg-yellow-800"
+ onClick={_ => {
+ setIsLoading(true);
+ ServerFunctions.simpleResponse.call(~name="Lola", ~age=20)
+ |> Js.Promise.then_(response => {
+ setIsLoading(false);
+ setMessage(response);
+ Js.Promise.resolve();
+ })
+ |> ignore;
+ }}>
+ {React.string("Click to get the server response")}
+ </button>
+ <div> <Text> {isLoading ? "Loading..." : message} </Text> </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/ServerFunctions.re b/bs5/universal/native/shared/ServerFunctions.re
new file mode 100644
index 0000000..24d94a5
--- /dev/null
+++ b/bs5/universal/native/shared/ServerFunctions.re
@@ -0,0 +1,80 @@
+open Melange_json.Primitives;
+
+module Notes = {
+ [@react.server.function]
+ let create = (~title: string, ~content: string): Js.Promise.t(Note.t) => {
+ let note = DB.addNote(~title, ~content);
+ let%lwt response =
+ switch%lwt (note) {
+ | Ok(note) => Lwt.return(note)
+ | Error(e) => failwith(e)
+ };
+ Lwt.return(response);
+ };
+
+ [@react.server.function]
+ let edit =
+ (~id: int, ~title: string, ~content: string): Js.Promise.t(Note.t) => {
+ let note = DB.editNote(~id, ~title, ~content);
+ let%lwt response =
+ switch%lwt (note) {
+ | Ok(note) => Lwt.return(note)
+ | Error(e) => failwith(e)
+ };
+
+ Lwt.return(response);
+ };
+
+ [@react.server.function]
+ let delete_ = (~id: int): Js.Promise.t(string) => {
+ let _ = DB.deleteNote(id);
+ Lwt.return("Note deleted");
+ };
+};
+
+[@react.server.function]
+let simpleResponse = (~name: string, ~age: int): Js.Promise.t(string) => {
+ Lwt.return(Printf.sprintf("Hello %s, you are %d years old", name, age));
+};
+
+[@react.server.function]
+let error = (): Js.Promise.t(string) => {
+ // Uncomment to see that it also works with Lwt.fail
+ Lwt.fail(
+ failwith("Error from server"),
+ // failwith(
+ // "Error from server",
+ // );
+ );
+};
+
+[@react.server.function]
+let formDataFunction = (formData: Js.FormData.t): Js.Promise.t(string) => {
+ let name =
+ switch (formData->Js.FormData.get("name")) {
+ | `String(name) => name
+ | exception _ => failwith("Invalid formData.")
+ };
+
+ let response = Printf.sprintf("Form data received: %s", name);
+
+ Lwt.return(response);
+};
+
+[@react.server.function]
+let formDataWithArg =
+ (timestamp: string, formData: Js.FormData.t): Js.Promise.t(string) => {
+ let country =
+ switch (formData->Js.FormData.get("country")) {
+ | `String(country) => country
+ };
+
+ let response =
+ Printf.sprintf(
+ "Form data received: %s, timestamp: %s",
+ country,
+ timestamp,
+ );
+
+ Lwt.return(response);
+};
diff --git a/bs5/universal/native/shared/SidebarNoteContent.re b/bs5/universal/native/shared/SidebarNoteContent.re
new file mode 100644
index 0000000..36872dc
--- /dev/null
+++ b/bs5/universal/native/shared/SidebarNoteContent.re
@@ -0,0 +1,69 @@
+[@warning "-26-27-32"];
+
+open Melange_json.Primitives;
+
+module Square = {
+ [@react.component]
+ let make = (~isExpanded) => {
+ <div
+ className={Cx.make([
+ isExpanded ? "" : "rotate-180",
+ "w-full rounded-md flex items-center justify-center pt-1 text-sm select-none",
+ "transition-[background-color] duration-250 ease-out",
+ Theme.text(Theme.Color.Gray11),
+ Theme.background(Theme.Color.Gray5),
+ Theme.hover([Theme.background(Theme.Color.Gray7)]),
+ ])}>
+ {React.string("^")}
+ </div>;
+ };
+};
+
+[@react.client.component]
+let make =
+ (
+ ~id: int,
+ ~title: string,
+ ~children: React.element,
+ ~expandedChildren: React.element,
+ ) => {
+ let router = ClientRouter.useRouter();
+ let (isExpanded, setIsExpanded) = RR.useStateValue(false);
+ let (isPending, startTransition) = React.useTransition();
+
+ let isActive =
+ switch (router.location.selectedId) {
+ | Some(selectedId) => selectedId == id
+ | None => false
+ };
+
+ <div
+ className={Cx.make([
+ "mb-3 flex flex-col rounded-md",
+ Theme.background(Theme.Color.Gray4),
+ isActive
+ ? Theme.border(Theme.Color.Gray8) : Theme.border(Theme.Color.None),
+ ])}>
+ <div
+ className={Cx.make([
+ "relative p-4 w-full justify-between items-start flex-wrap transition-[max-height] duration-250 ease-out scale-100 flex flex-col gap-1 cursor-pointer",
+ ])}
+ onClick={_ => {
+ startTransition(() => {
+ router.navigate({
+ selectedId: Some(id),
+ isEditing: false,
+ searchText: router.location.searchText,
+ })
+ })
+ }}>
+ children
+ {isExpanded ? expandedChildren : React.null}
+ </div>
+ <div
+ className="px-4 mt-1 mb-4 cursor-pointer self-center w-full"
+ onClick={_ => setIsExpanded(!isExpanded)}>
+ <Square isExpanded />
+ </div>
+ </div>;
+};
diff --git a/bs5/universal/native/shared/Spinner.re b/bs5/universal/native/shared/Spinner.re
new file mode 100644
index 0000000..691cc07
--- /dev/null
+++ b/bs5/universal/native/shared/Spinner.re
@@ -0,0 +1,11 @@
+[@react.component]
+let make = (~active) => {
+ <div
+ role="progressbar"
+ ariaBusy=true
+ className={
+ "inline-block w-5 h-5 rounded-full border-3 border-gray-500/50 border-t-white transition-opacity duration-100 linear "
+ ++ (active ? "opacity-100 animate-spin" : "opacity-0")
+ }
+ />;
+};
diff --git a/bs5/universal/native/shared/Stack.re b/bs5/universal/native/shared/Stack.re
new file mode 100644
index 0000000..afb8e98
--- /dev/null
+++ b/bs5/universal/native/shared/Stack.re
@@ -0,0 +1,25 @@
+[@react.component]
+let make =
+ (~gap=0, ~align=`start, ~justify=`around, ~fullHeight=false, ~children) => {
+ let className =
+ Cx.make([
+ "flex flex-col",
+ fullHeight ? "h-full" : "h-auto",
+ "gap-" ++ Int.to_string(gap),
+ switch (align) {
+ | `start => "items-start"
+ | `center => "items-center"
+ | `end_ => "items-end"
+ },
+ switch (justify) {
+ | `around => "justify-around"
+ | `between => "justify-between"
+ | `evenly => "justify-evenly"
+ | `start => "justify-start"
+ | `center => "justify-center"
+ | `end_ => "justify-end"
+ },
+ ]);
+
+ <div className> children </div>;
+};
diff --git a/bs5/universal/native/shared/Static_small.re b/bs5/universal/native/shared/Static_small.re
new file mode 100644
index 0000000..1a9be27
--- /dev/null
+++ b/bs5/universal/native/shared/Static_small.re
@@ -0,0 +1,6 @@
+[@react.component]
+let make = () =>
+ <div>
+ <div> {React.string("This is Light Server Component")} </div>
+ <div> {React.string("Heavy Server Component")} </div>
+ </div>;
diff --git a/bs5/universal/native/shared/Text.re b/bs5/universal/native/shared/Text.re
new file mode 100644
index 0000000..fd8f1c5
--- /dev/null
+++ b/bs5/universal/native/shared/Text.re
@@ -0,0 +1,68 @@
+type size =
+ | XSmall
+ | Small
+ | Medium
+ | Large
+ | XLarge
+ | XXLarge
+ | XXXLarge;
+
+let size_to_string = size =>
+ switch (size) {
+ | XSmall => "text-xs"
+ | Small => "text-sm"
+ | Medium => "text-base"
+ | Large => "text-lg"
+ | XLarge => "text-xl"
+ | XXLarge => "text-2xl"
+ | XXXLarge => "text-3xl"
+ };
+
+type weight =
+ | Thin
+ | Light
+ | Regular
+ | Semibold
+ | Bold
+ | Extrabold
+ | Black;
+
+type align =
+ | Left
+ | Center
+ | Right
+ | Justify;
+
+[@react.component]
+let make =
+ (
+ ~color=Theme.Color.Gray12,
+ ~size: size=Small,
+ ~weight: weight=Regular,
+ ~align=Left,
+ ~children,
+ ~role=?,
+ ) => {
+ let className =
+ Cx.make([
+ Theme.text(color),
+ size_to_string(size),
+ switch (weight) {
+ | Thin => "font-thin"
+ | Light => "font-light"
+ | Regular => "font-normal"
+ | Semibold => "font-semibold"
+ | Bold => "font-bold"
+ | Extrabold => "font-extrabold"
+ | Black => "font-black"
+ },
+ switch (align) {
+ | Left => "text-left"
+ | Right => "text-right"
+ | Justify => "text-justify"
+ | Center => "text-center"
+ },
+ ]);
+
+ <span className ?role> {React.string(children)} </span>;
+};
diff --git a/bs5/universal/native/shared/Textarea.re b/bs5/universal/native/shared/Textarea.re
new file mode 100644
index 0000000..8147bb2
--- /dev/null
+++ b/bs5/universal/native/shared/Textarea.re
@@ -0,0 +1,15 @@
+[@react.component]
+let make = (~rows=10, ~value, ~onChange, ~id="", ~placeholder="") =>
+ <textarea
+ className={Cx.make([
+ "m-0 py-2 px-4",
+ "rounded-md",
+ Theme.background(Theme.Color.Gray1),
+ Theme.text(Theme.Color.Gray12),
+ ])}
+ id
+ placeholder
+ rows
+ value
+ onChange
+ />;
diff --git a/bs5/universal/native/shared/Theme.re b/bs5/universal/native/shared/Theme.re
new file mode 100644
index 0000000..31fd01d
--- /dev/null
+++ b/bs5/universal/native/shared/Theme.re
@@ -0,0 +1,141 @@
+type align = [
+ | `start
+ | `center
+ | `end_
+];
+
+type justify = [
+ | `around
+ | `between
+ | `evenly
+ | `start
+ | `center
+ | `end_
+];
+
+module Media = {
+ let onDesktop = rules => {
+ String.concat(" md:", rules);
+ };
+};
+
+module Color = {
+ type t =
+ | None
+ | Transparent
+ | Gray0
+ | Gray1
+ | Gray2
+ | Gray3
+ | Gray4
+ | Gray5
+ | Gray6
+ | Gray7
+ | Gray8
+ | Gray9
+ | Gray10
+ | Gray11
+ | Gray12
+ | Gray13
+ | Gray14
+ | Primary;
+
+ let oneScaleUp = color => {
+ switch (color) {
+ | Gray0 => Gray1
+ | Gray1 => Gray2
+ | Gray2 => Gray3
+ | Gray3 => Gray4
+ | Gray4 => Gray5
+ | Gray5 => Gray6
+ | Gray6 => Gray7
+ | Gray7 => Gray8
+ | Gray8 => Gray9
+ | Gray9 => Gray10
+ | Gray10 => Gray11
+ | Gray11 => Gray12
+ | Gray12 => Gray13
+ | Gray13 => Gray14
+ | Gray14 => Gray14
+ | _ => color
+ };
+ };
+
+ let primary = "#FFC53D";
+ let gray0 = "#080808";
+ let gray1 = "#0F0F0F";
+ let gray2 = "#151515";
+ let gray3 = "#191919";
+ let gray4 = "#1E1E1E";
+ let gray5 = "#252525";
+ let gray6 = "#2A2A2A";
+ let gray7 = "#313131";
+ let gray8 = "#3A3A3A";
+ let gray9 = "#484848";
+ let gray10 = "#6E6E6E";
+ let gray11 = "#B4B4B4";
+ let gray12 = "#EEEEEE";
+ let gray13 = "#F5F5F5";
+ let gray14 = "#FFFFFF";
+
+ let brokenWhite = gray10;
+ let white = gray12;
+ let black = gray1;
+ let fadedBlack = gray3;
+};
+
+let none = "none";
+
+type kind =
+ | Text
+ | Background
+ | Border;
+
+let to_string = kind =>
+ switch (kind) {
+ | Text => "text"
+ | Background => "bg"
+ | Border => "border"
+ };
+
+let color = (~kind, value) =>
+ switch ((value: Color.t)) {
+ | None => to_string(kind) ++ "-none"
+ | Transparent => to_string(kind) ++ "-transparent"
+ | Gray0 => to_string(kind) ++ "-[" ++ Color.gray0 ++ "]"
+ | Gray1 => to_string(kind) ++ "-[" ++ Color.gray1 ++ "]"
+ | Gray2 => to_string(kind) ++ "-[" ++ Color.gray2 ++ "]"
+ | Gray3 => to_string(kind) ++ "-[" ++ Color.gray3 ++ "]"
+ | Gray4 => to_string(kind) ++ "-[" ++ Color.gray4 ++ "]"
+ | Gray5 => to_string(kind) ++ "-[" ++ Color.gray5 ++ "]"
+ | Gray6 => to_string(kind) ++ "-[" ++ Color.gray6 ++ "]"
+ | Gray7 => to_string(kind) ++ "-[" ++ Color.gray7 ++ "]"
+ | Gray8 => to_string(kind) ++ "-[" ++ Color.gray8 ++ "]"
+ | Gray9 => to_string(kind) ++ "-[" ++ Color.gray9 ++ "]"
+ | Gray10 => to_string(kind) ++ "-[" ++ Color.gray10 ++ "]"
+ | Gray11 => to_string(kind) ++ "-[" ++ Color.gray11 ++ "]"
+ | Gray12 => to_string(kind) ++ "-[" ++ Color.gray12 ++ "]"
+ | Gray13 => to_string(kind) ++ "-[" ++ Color.gray13 ++ "]"
+ | Gray14 => to_string(kind) ++ "-[" ++ Color.gray14 ++ "]"
+ | Primary => to_string(kind) ++ "-[" ++ Color.primary ++ "]"
+ };
+
+let text = value => color(~kind=Text, value);
+let background = value => color(~kind=Background, value);
+let border = value => color(~kind=Border, value);
+
+let hover = value =>
+ switch (value) {
+ | [] => ""
+ | [value] => " hover:" ++ value
+ | values => " hover:" ++ String.concat(" hover:", values)
+ };
+
+let button =
+ Cx.make([
+ "px-4 py-1 border-2 rounded-md",
+ "transition-[background-color] duration-250 ease-out",
+ border(Color.Gray5),
+ text(Color.Gray12),
+ hover([background(Color.Gray6), border(Color.Gray7)]),
+ ]);