From 69312f5133734237edaea6ca29e2de9bf3203050 Mon Sep 17 00:00:00 2001 From: polwex Date: Sun, 15 Jun 2025 01:44:45 +0700 Subject: checkpoint here... --- bs5/universal/native/DB.ml | 120 ++++++++++++++++++ bs5/universal/native/dune | 27 ++++ bs5/universal/native/shared/Align.re | 30 +++++ bs5/universal/native/shared/App.re | 102 +++++++++++++++ bs5/universal/native/shared/Arrow.re | 27 ++++ bs5/universal/native/shared/Button.re | 30 +++++ bs5/universal/native/shared/ClientRouter.re | 12 ++ bs5/universal/native/shared/Context.re | 5 + bs5/universal/native/shared/Counter.re | 50 ++++++++ bs5/universal/native/shared/Cx.re | 21 +++ bs5/universal/native/shared/Debug_props.re | 89 +++++++++++++ bs5/universal/native/shared/DeleteNoteButton.re | 33 +++++ bs5/universal/native/shared/DemoLayout.re | 39 ++++++ bs5/universal/native/shared/Document.re | 54 ++++++++ bs5/universal/native/shared/Expander.re | 45 +++++++ bs5/universal/native/shared/Hr.re | 12 ++ bs5/universal/native/shared/InputText.re | 15 +++ bs5/universal/native/shared/JsMap.re | 76 +++++++++++ bs5/universal/native/shared/Link.re | 58 +++++++++ bs5/universal/native/shared/Note.re | 16 +++ bs5/universal/native/shared/NoteEditor.re | 70 ++++++++++ bs5/universal/native/shared/NoteListSkeleton.re | 31 +++++ bs5/universal/native/shared/NotePreview.re | 12 ++ bs5/universal/native/shared/NoteSkeleton.re | 6 + bs5/universal/native/shared/Promise_renderer.re | 28 ++++ bs5/universal/native/shared/RR.re | 21 +++ bs5/universal/native/shared/Router.re | 117 +++++++++++++++++ bs5/universal/native/shared/Row.re | 33 +++++ bs5/universal/native/shared/SearchField.re | 40 ++++++ .../native/shared/ServerActionFromPropsClient.re | 30 +++++ .../native/shared/ServerActionWithError.re | 14 ++ .../native/shared/ServerActionWithFormData.re | 22 ++++ .../shared/ServerActionWithFormDataFormAction.re | 21 +++ .../shared/ServerActionWithFormDataServer.re | 23 ++++ .../shared/ServerActionWithFormDataWithArg.re | 27 ++++ .../shared/ServerActionWithSimpleResponse.re | 23 ++++ bs5/universal/native/shared/ServerFunctions.re | 80 ++++++++++++ bs5/universal/native/shared/SidebarNoteContent.re | 69 ++++++++++ bs5/universal/native/shared/Spinner.re | 11 ++ bs5/universal/native/shared/Stack.re | 25 ++++ bs5/universal/native/shared/Static_small.re | 6 + bs5/universal/native/shared/Text.re | 68 ++++++++++ bs5/universal/native/shared/Textarea.re | 15 +++ bs5/universal/native/shared/Theme.re | 141 +++++++++++++++++++++ 44 files changed, 1794 insertions(+) create mode 100644 bs5/universal/native/DB.ml create mode 100644 bs5/universal/native/dune create mode 100644 bs5/universal/native/shared/Align.re create mode 100644 bs5/universal/native/shared/App.re create mode 100644 bs5/universal/native/shared/Arrow.re create mode 100644 bs5/universal/native/shared/Button.re create mode 100644 bs5/universal/native/shared/ClientRouter.re create mode 100644 bs5/universal/native/shared/Context.re create mode 100644 bs5/universal/native/shared/Counter.re create mode 100644 bs5/universal/native/shared/Cx.re create mode 100644 bs5/universal/native/shared/Debug_props.re create mode 100644 bs5/universal/native/shared/DeleteNoteButton.re create mode 100644 bs5/universal/native/shared/DemoLayout.re create mode 100644 bs5/universal/native/shared/Document.re create mode 100644 bs5/universal/native/shared/Expander.re create mode 100644 bs5/universal/native/shared/Hr.re create mode 100644 bs5/universal/native/shared/InputText.re create mode 100644 bs5/universal/native/shared/JsMap.re create mode 100644 bs5/universal/native/shared/Link.re create mode 100644 bs5/universal/native/shared/Note.re create mode 100644 bs5/universal/native/shared/NoteEditor.re create mode 100644 bs5/universal/native/shared/NoteListSkeleton.re create mode 100644 bs5/universal/native/shared/NotePreview.re create mode 100644 bs5/universal/native/shared/NoteSkeleton.re create mode 100644 bs5/universal/native/shared/Promise_renderer.re create mode 100644 bs5/universal/native/shared/RR.re create mode 100644 bs5/universal/native/shared/Router.re create mode 100644 bs5/universal/native/shared/Row.re create mode 100644 bs5/universal/native/shared/SearchField.re create mode 100644 bs5/universal/native/shared/ServerActionFromPropsClient.re create mode 100644 bs5/universal/native/shared/ServerActionWithError.re create mode 100644 bs5/universal/native/shared/ServerActionWithFormData.re create mode 100644 bs5/universal/native/shared/ServerActionWithFormDataFormAction.re create mode 100644 bs5/universal/native/shared/ServerActionWithFormDataServer.re create mode 100644 bs5/universal/native/shared/ServerActionWithFormDataWithArg.re create mode 100644 bs5/universal/native/shared/ServerActionWithSimpleResponse.re create mode 100644 bs5/universal/native/shared/ServerFunctions.re create mode 100644 bs5/universal/native/shared/SidebarNoteContent.re create mode 100644 bs5/universal/native/shared/Spinner.re create mode 100644 bs5/universal/native/shared/Stack.re create mode 100644 bs5/universal/native/shared/Static_small.re create mode 100644 bs5/universal/native/shared/Text.re create mode 100644 bs5/universal/native/shared/Textarea.re create mode 100644 bs5/universal/native/shared/Theme.re 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" + }, + ]); + +
children
; +}; 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 = () => { + ; + }; +}; + +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", + }, + |]; + +
+ {React.array( + Belt.Array.mapWithIndex(data, (key, item) => +
+ + {item.label} + +
+ ), + )} +
; + }; + }; + + [@react.component] + let make = () => { +
+
+

+ {React.string("Server Reason React")} +

+
+ +
; + }; +}; + +[@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); + }; + + + + {React.array([| + , + <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")} + +