diff options
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)]), + ]); |