Merge pull request 'convert_to_actix' (#2) from convert_to_actix into main

Reviewed-on: Yehowshua/example-spa-elm-app#2
This commit is contained in:
Yehowshua 2025-01-06 06:25:18 +00:00
commit 1634ea7056
28 changed files with 2874 additions and 122 deletions

16
.gitignore vendored
View file

@ -1,6 +1,10 @@
elm-stuff/
elm.js
elm.min.js
node_modules/
package-lock.json
package.json
frontend/elm-stuff/
frontend/elm.js
frontend/elm.min.js
frontend/node_modules/
frontend/package-lock.json
frontend/package.json
backend/target
public/*
elm-stuff/*
result

View file

@ -1,10 +1,50 @@
SRC_FILES := $(shell find src -name "*.elm")
# Directories
FRONTEND_DIR := ./frontend
BACKEND_DIR := ./backend
PUBLIC_DIR := ./public
ASSET_DIR := ./assets
all: elm.min.js
# Commands
ELM_MAKE := elm make
CARGO_BUILD := cargo build
CARGO_RUN := cargo run
serve: all
python3 -m http.server
ifeq ($(DEBUG)$(RELEASE),) # Both are empty
$(error You must set exactly one of DEBUG=1 or RELEASE=1)
endif
elm.min.js: $(SRC_FILES)
rm -f elm.min.js elm.js
./optimize.sh src/Main.elm
ifeq ($(DEBUG)$(RELEASE),11) # Both are set
$(error Both DEBUG and RELEASE cannot be set at the same time)
endif
# Targets
.PHONY: all frontend backend clean serve
$(PUBLIC_DIR):
mkdir -p $(PUBLIC_DIR)
$(PUBLIC_DIR)/index.html: $(FRONTEND_DIR)/index.html $(PUBLIC_DIR)
cp $< $@
.PHONY: frontend backend
frontend: $(PUBLIC_DIR)/index.html $(PUBLIC_DIR)
make -C $(FRONTEND_DIR)
cp $(FRONTEND_DIR)/elm.min.js $(PUBLIC_DIR)/
cp $(FRONTEND_DIR)/src/ports.websocket.js $(PUBLIC_DIR)/
mkdir -p $(PUBLIC_DIR)/assets
backend:
$(CARGO_BUILD) --manifest-path=$(BACKEND_DIR)/Cargo.toml
serve: frontend backend
ifeq ($(DEBUG),1)
RUST_LOG=info,actix_web=debug $(CARGO_RUN) --manifest-path=$(BACKEND_DIR)/Cargo.toml
else ifeq ($(RELEASE),1)
$(CARGO_RUN) --release --manifest-path=$(BACKEND_DIR)/Cargo.toml
endif
clean:
rm -rf $(PUBLIC_DIR)/*
rm -rf $(BACKEND_DIR)/target
make -C $(FRONTEND_DIR) clean

View file

@ -2,25 +2,25 @@
Example demonstrating how one might architect a single page application
Elm app.
# Dependencies MacOS
```bash
brew install node elm
npm install -g uglify-js@2.4.11
```
# Building
```
make serve
```bash
nix-shell -p elmPackages.elm cargo uglify-js
make serve RELEASE=1 # can also do DEBUG=1 instead
```
Now open `http://localhost:8000` in your browser.
Now open `http://127.0.0.1:8080` in your browser.
# TODO
- [x] Add Makefile
- [ ] Determine if `src/Body.elm` or pages in `sr/Page` should have subscription functions
- [ ] use actix backend that maps most root requests to serve `actix_file::Files`
- [ ] Submit to slack for feedback...
- [ ] Refactor into router page
- [ ] Handle back-navigation
- [ ] `EventHandlers.onMessage` should inject successfully decoded message into
Msg type directly...
- [ ] Run `uglify` twice as per [this link](https://github.com/rtfeldman/elm-spa-example/tree/master?tab=readme-ov-file#production-build)
- [ ] JSONify backend code for all send/receive
- [ ] Backend should only communicate over websocket
- [ ] Close websocket after 15s of no response(from frontend) to websocket pings
- [ ] Implement dark mode
- [ ] Add GPLV3 License
- [ ] Add `make release` target that is nix ready...
- [ ] Add `default.nix`

1846
backend/Cargo.lock generated Normal file

File diff suppressed because it is too large Load diff

15
backend/Cargo.toml Normal file
View file

@ -0,0 +1,15 @@
[package]
name = "backend"
version = "0.1.0"
edition = "2021"
[dependencies]
actix-web = "4.0"
actix-files = "0.6"
actix-web-actors = "4.0"
actix = "0.13"
chrono = "0.4" # For timestamp generation
env_logger = "0.10" # For logging
serde = { version = "1.0", features = ["derive"] }
serde_json = "1.0"
log = "0.4"

14
backend/src/landing.rs Normal file
View file

@ -0,0 +1,14 @@
use serde::{Deserialize, Serialize};
#[derive(Serialize)]
pub enum DownMsg {
Greeting(String),
TimeUpdate(String),
}
#[derive(Deserialize)]
pub enum UpMsg {
RequestGreet(String)
}

104
backend/src/main.rs Normal file
View file

@ -0,0 +1,104 @@
use actix::prelude::*;
use actix_files::Files;
use actix_web::{web, App, Error, HttpRequest, HttpResponse, HttpServer, Responder};
use actix_web_actors::ws;
use log::info;
use serde::{Deserialize, Serialize};
use std::{fs, time::Duration};
use chrono::Local;
mod landing;
#[derive(Serialize)]
enum DownMsg {
Landing(landing::DownMsg),
}
#[derive(Deserialize)]
enum UpMsg {
Landing(landing::UpMsg),
}
/// WebSocket actor
struct MyWebSocket;
impl Actor for MyWebSocket {
type Context = ws::WebsocketContext<Self>;
fn started(&mut self, ctx: &mut Self::Context) {
info!("WebSocket actor started");
ctx.run_interval(Duration::from_secs(1), |_, ctx| {
let current_time = Local::now().format("%H:%M:%S").to_string();
let message = DownMsg::Landing(landing::DownMsg::TimeUpdate(current_time));
if let Ok(json_message) = serde_json::to_string(&message) {
ctx.text(json_message);
}
});
}
}
impl StreamHandler<Result<ws::Message, ws::ProtocolError>> for MyWebSocket {
fn handle(
&mut self,
msg: Result<ws::Message, ws::ProtocolError>,
ctx: &mut ws::WebsocketContext<Self>,
) {
match msg {
Ok(ws::Message::Text(text)) => {
// Attempt to decode the incoming message
if let Ok(up_msg) = serde_json::from_str::<UpMsg>(&text) {
match up_msg {
UpMsg::Landing(landing::UpMsg::RequestGreet(name)) => {
let greeting = format!("Hello, {}!", name);
let response = DownMsg::Landing(landing::DownMsg::Greeting(greeting));
if let Ok(json_response) = serde_json::to_string(&response) {
ctx.text(json_response);
}
}
}
}
}
Ok(ws::Message::Ping(msg)) => {
ctx.pong(&msg);
}
_ => {}
}
}
}
async fn websocket_handler(req: HttpRequest, stream: web::Payload) -> Result<HttpResponse, Error> {
ws::start(MyWebSocket {}, &req, stream)
}
#[actix_web::main]
async fn main() -> std::io::Result<()> {
env_logger::init();
let address = "127.0.0.1";
let port = std::env::var("EXAMPLE_ELM_APP_PORT")
.unwrap_or("8080".to_string())
.parse()
.unwrap();
info!("Starting server at http://{}:{}", address, port);
HttpServer::new(|| {
App::new()
.route("/ws/", web::get().to(websocket_handler)) // WebSocket endpoint
.service(Files::new("/assets", "./public/assets"))
.service(Files::new("/", "./public").index_file("index.html")) // Serve frontend
.default_service(web::route().to(|| async {
let index_html = fs::read_to_string("./public/index.html")
.unwrap_or_else(|_| "404 Not Found".to_string());
HttpResponse::Ok()
.content_type("text/html; charset=utf-8")
.body(index_html)
}))
})
.bind((address, port))?
.run()
.await
}

25
default.nix Normal file
View file

@ -0,0 +1,25 @@
{
system ? builtins.currentSystem,
}:
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
root = lock.nodes.${lock.root};
inherit (lock.nodes.${root.inputs.flake-compat}.locked)
owner
repo
rev
narHash
;
flake-compat = fetchTarball {
url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz";
sha256 = narHash;
};
flake = import flake-compat {
inherit system;
src = ./.;
};
in
flake.defaultNix

197
flake.lock Normal file
View file

@ -0,0 +1,197 @@
{
"nodes": {
"elm-spa": {
"inputs": {
"nixpkgs": [
"mkElmDerivation",
"nixpkgs"
]
},
"locked": {
"lastModified": 1706301604,
"narHash": "sha256-n6LDjnPCTLbKTrRgeZhlLTfY6V45xNYcb4NYEMuO4jg=",
"owner": "jeslie0",
"repo": "elm-spa",
"rev": "4c82e18d5fcf9d4c027f0ef0e89204dd87584f7f",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "elm-spa",
"type": "github"
}
},
"elm-watch": {
"inputs": {
"nixpkgs": [
"mkElmDerivation",
"nixpkgs"
],
"npm-fix": "npm-fix",
"npmlock2nix": "npmlock2nix"
},
"locked": {
"lastModified": 1706304401,
"narHash": "sha256-992cypnhoRbsGkDc5/X241rafBML4EP0EuT6cBcaY/8=",
"owner": "jeslie0",
"repo": "elm-watch",
"rev": "2f1c6c0e69b163c15e2ce66f543c38021b2a0ea3",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "elm-watch",
"type": "github"
}
},
"flake-compat": {
"flake": false,
"locked": {
"lastModified": 1733328505,
"narHash": "sha256-NeCCThCEP3eCl2l/+27kNNK7QrwZB1IJCrXfrbv5oqU=",
"owner": "edolstra",
"repo": "flake-compat",
"rev": "ff81ac966bb2cae68946d5ed5fc4994f96d0ffec",
"type": "github"
},
"original": {
"owner": "edolstra",
"repo": "flake-compat",
"type": "github"
}
},
"mkElmDerivation": {
"inputs": {
"elm-spa": "elm-spa",
"elm-watch": "elm-watch",
"nixpkgs": "nixpkgs"
},
"locked": {
"lastModified": 1735436157,
"narHash": "sha256-G7gF5z0HEQVaLJuygUoCSJs27wQdautyMsciFYdpNCo=",
"owner": "jeslie0",
"repo": "mkElmDerivation",
"rev": "f369fd6ff78205821c3f409814e840691be645e7",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "mkElmDerivation",
"type": "github"
}
},
"nixpkgs": {
"locked": {
"lastModified": 1696757521,
"narHash": "sha256-cfgtLNCBLFx2qOzRLI6DHfqTdfWI+UbvsKYa3b3fvaA=",
"owner": "nixos",
"repo": "nixpkgs",
"rev": "2646b294a146df2781b1ca49092450e8a32814e1",
"type": "github"
},
"original": {
"owner": "nixos",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"nixpkgs_2": {
"locked": {
"lastModified": 1735821806,
"narHash": "sha256-cuNapx/uQeCgeuhUhdck3JKbgpsml259sjUQnWM7zW8=",
"owner": "NixOS",
"repo": "nixpkgs",
"rev": "d6973081434f88088e5321f83ebafe9a1167c367",
"type": "github"
},
"original": {
"owner": "NixOS",
"ref": "nixpkgs-unstable",
"repo": "nixpkgs",
"type": "github"
}
},
"npm-fix": {
"inputs": {
"nixpkgs": [
"mkElmDerivation",
"elm-watch",
"nixpkgs"
]
},
"locked": {
"lastModified": 1706304213,
"narHash": "sha256-XN9ESRSOANR0iFbEMMY1C1jvgZlYJsXQYVAHxxRmn+c=",
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"rev": "e9851274afa12b04d98e694ed089aa9cde8d7349",
"type": "github"
},
"original": {
"owner": "jeslie0",
"repo": "npm-lockfile-fix",
"type": "github"
}
},
"npmlock2nix": {
"flake": false,
"locked": {
"lastModified": 1673447413,
"narHash": "sha256-sJM82Sj8yfQYs9axEmGZ9Evzdv/kDcI9sddqJ45frrU=",
"owner": "nix-community",
"repo": "npmlock2nix",
"rev": "9197bbf397d76059a76310523d45df10d2e4ca81",
"type": "github"
},
"original": {
"owner": "nix-community",
"repo": "npmlock2nix",
"type": "github"
}
},
"root": {
"inputs": {
"flake-compat": "flake-compat",
"mkElmDerivation": "mkElmDerivation",
"nixpkgs": "nixpkgs_2",
"utils": "utils"
}
},
"systems": {
"locked": {
"lastModified": 1681028828,
"narHash": "sha256-Vy1rq5AaRuLzOxct8nz4T6wlgyUR7zLU309k9mBC768=",
"owner": "nix-systems",
"repo": "default",
"rev": "da67096a3b9bf56a91d16901293e51ba5b49a27e",
"type": "github"
},
"original": {
"owner": "nix-systems",
"repo": "default",
"type": "github"
}
},
"utils": {
"inputs": {
"systems": "systems"
},
"locked": {
"lastModified": 1731533236,
"narHash": "sha256-l0KFg5HjrsfsO/JpG+r7fRrqm12kzFHyUHqHCVpMMbI=",
"owner": "numtide",
"repo": "flake-utils",
"rev": "11707dc2f618dd54ca8739b309ec4fc024de578b",
"type": "github"
},
"original": {
"owner": "numtide",
"repo": "flake-utils",
"type": "github"
}
}
},
"root": "root",
"version": 7
}

119
flake.nix Normal file
View file

@ -0,0 +1,119 @@
{
inputs = {
nixpkgs = {
url = "github:NixOS/nixpkgs/nixpkgs-unstable";
};
utils.url = "github:numtide/flake-utils";
mkElmDerivation.url = "github:jeslie0/mkElmDerivation";
flake-compat = {
url = "github:edolstra/flake-compat";
flake = false;
};
};
outputs =
inputs:
inputs.utils.lib.eachDefaultSystem (
system:
let
pkgs = import inputs.nixpkgs {
localSystem = system;
overlays = [
inputs.mkElmDerivation.overlays.default
(final: prev: {
example-spa-elm-app = prev.callPackage (
{
stdenv,
lib,
elmPackages,
uglify-js,
rustPlatform,
cargo,
rustc,
}:
stdenv.mkDerivation (
let
allPackagesJsonPath = "${inputs.mkElmDerivation}/mkElmDerivation/all-packages.json";
elmHashesJsonPath = "${inputs.mkElmDerivation}/mkElmDerivation/elm-hashes.json";
snapshot = import "${inputs.mkElmDerivation}/src/snapshot/default.nix" (
prev.haskellPackages // { inherit lib; }
);
elmJson = ./frontend/elm.json;
in
{
pname = "example-spa-elm-app";
version = "0.1.0";
src = inputs.self;
nativeBuildInputs = [
elmPackages.elm
uglify-js
rustPlatform.cargoSetupHook
cargo
rustc
];
cargoDeps = pkgs.rustPlatform.importCargoLock {
lockFile = ./backend/Cargo.lock;
allowBuiltinFetchGit = true;
};
cargoRoot = "backend";
makeFlags = [
"RELEASE=1"
"frontend"
"backend"
];
postPatch = ''
patchShebangs ./frontend/build.sh
'';
preBuild = ''
(
cd frontend
${
(import "${inputs.mkElmDerivation}/nix/lib.nix" {
inherit
stdenv
lib
snapshot
allPackagesJsonPath
;
}).mkDotElmCommand
elmHashesJsonPath
elmJson
}
)
export ELM_HOME=$PWD/frontend/.elm
'';
installPhase = ''
runHook preInstall
mkdir -p $out
cp -r ./public $out/
cp ./backend/target/debug/backend $out/
runHook postInstall
'';
}
)
) { };
})
];
};
in
{
packages = {
default = inputs.self.packages."${system}".example-spa-elm-app;
example-spa-elm-app = pkgs.example-spa-elm-app;
};
devShells.default =
with pkgs;
mkShell {
inputsFrom = [ example-spa-elm-app ];
};
}
);
}

24
frontend/Makefile Normal file
View file

@ -0,0 +1,24 @@
SRC_FILES := $(shell find src -name "*.elm")
all: elm.min.js
.PHONY: elm.min.js
ifeq ($(DEBUG)$(RELEASE),) # Both are empty
$(error You must set exactly one of DEBUG=1 or RELEASE=1)
endif
ifeq ($(DEBUG)$(RELEASE),11) # Both are set
$(error Both DEBUG and RELEASE cannot be set at the same time)
endif
elm.min.js:
ifeq ($(DEBUG),1)
./build.sh --debug src/Main.elm
else ifeq ($(RELEASE),1)
./build.sh --optimize src/Main.elm
endif
clean:
rm elm.js elm.min.js

22
frontend/build.sh Executable file
View file

@ -0,0 +1,22 @@
#!/usr/bin/env bash
set -ex
js="elm.js"
min="elm.min.js"
if [ "$1" = "--debug" ]; then
elm make --debug --output=$js "${@:2}"
elif [ "$1" = "--optimize" ]; then
elm make --optimize --output=$js "${@:2}"
else
echo "Error: You must specify either --debug or --optimize."
exit 1
fi
uglifyjs elm.js --compress 'pure_funcs="F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9",pure_getters=true,keep_fargs=false,unsafe_comps=true,unsafe=true,passes=2' --output elm.js
uglifyjs elm.js --mangle --output $min
echo "Compiled size: $(wc -c < $js) bytes ($js)"
echo "Minified size: $(wc -c < $min) bytes ($min)"
echo "Gzipped size: $(gzip -c $min | wc -c) bytes"

View file

@ -9,11 +9,12 @@
"elm/browser": "1.0.2",
"elm/core": "1.0.5",
"elm/html": "1.0.0",
"elm/json": "1.1.3",
"elm/url": "1.0.0",
"kageurufu/elm-websockets": "1.0.1",
"mdgriffith/elm-ui": "1.1.8"
},
"indirect": {
"elm/json": "1.1.3",
"elm/time": "1.0.0",
"elm/virtual-dom": "1.0.3"
}

View file

@ -7,10 +7,12 @@
<body>
<div id="elm"></div>
<script src="elm.min.js"></script>
<script src="ports.websocket.js"></script>
<script>
var app = Elm.Main.init({
node: document.getElementById('elm')
});
initSockets(app);
</script>
</body>
</html>

94
frontend/src/Body.elm Normal file
View file

@ -0,0 +1,94 @@
module Body exposing (Msg(..), Model(..), init, update, view, handleRoute, subscriptions)
import Element
import Page.About
import Page.Contact
import Page.Landing
import Page.Products
import Page.Resources
import Page.NotFound
type Msg
= MsgLanding Page.Landing.Msg
| MsgProducts Page.Products.Msg
| MsgResources Page.Resources.Msg
| MsgAbout Page.About.Msg
| MsgContact Page.Contact.Msg
| MsgNotFound Page.NotFound.Msg
type Model
= ModelLanding Page.Landing.Model
| ModelProducts Page.Products.Model
| ModelResources Page.Resources.Model
| ModelAbout Page.About.Model
| ModelContact Page.Contact.Model
| ModelNotFound Page.NotFound.Model
init : () -> Model
init flags = ModelLanding (Page.Landing.init flags)
subscriptions : Model -> Sub Msg
subscriptions model =
case model of
ModelLanding m -> Page.Landing.subscriptions m |> Sub.map MsgLanding
ModelProducts m -> Page.Products.subscriptions m |> Sub.map MsgProducts
ModelResources m -> Page.Resources.subscriptions m |> Sub.map MsgResources
ModelAbout m -> Page.About.subscriptions m |> Sub.map MsgAbout
ModelContact m -> Page.Contact.subscriptions m |> Sub.map MsgContact
ModelNotFound m -> Page.NotFound.subscriptions m |> Sub.map MsgNotFound
handleRoute : String -> Model
handleRoute path =
let
page =
case path of
"/" -> ModelLanding <| Page.Landing.init ()
"/Products" -> ModelProducts <| Page.Products.init ()
"/Resources" -> ModelResources <| Page.Resources.init ()
"/About" -> ModelAbout <| Page.About.init ()
"/Contact" -> ModelContact <| Page.Contact.init ()
_ -> ModelNotFound <| Page.NotFound.init ()
in
page
update : Msg -> Model -> (Model, Cmd Msg)
update bodyMsg bodyModel =
let
updatePage msg model updateFn wrapMsg toBodyModel =
let
(newModel, cmd) = updateFn msg model
in
(toBodyModel newModel, Cmd.map wrapMsg cmd)
in
case (bodyMsg, bodyModel) of
(MsgLanding msg, ModelLanding m) ->
updatePage msg m Page.Landing.update MsgLanding ModelLanding
(MsgProducts msg, ModelProducts m) ->
updatePage msg m Page.Products.update MsgProducts ModelProducts
(MsgResources msg, ModelResources m) ->
updatePage msg m Page.Resources.update MsgResources ModelResources
(MsgAbout msg, ModelAbout m) ->
updatePage msg m Page.About.update MsgAbout ModelAbout
(MsgContact msg, ModelContact m) ->
updatePage msg m Page.Contact.update MsgContact ModelContact
_ ->
(bodyModel, Cmd.none)
view : Model -> Element.Element Msg
view model =
let
content = case model of
ModelLanding m -> Page.Landing.view m |> Element.map MsgLanding
ModelProducts m -> Page.Products.view m |> Element.map MsgProducts
ModelResources m -> Page.Resources.view m |> Element.map MsgResources
ModelAbout m -> Page.About.view m |> Element.map MsgAbout
ModelContact m -> Page.Contact.view m |> Element.map MsgContact
ModelNotFound m -> Page.NotFound.view m |> Element.map MsgNotFound
in
Element.el [Element.centerY ,Element.centerX] content

View file

@ -1,4 +1,11 @@
module Header exposing (Model, Msg, view, init, update)
module Header exposing
( Model
, Msg
, view
, init
, subscriptions
, update
)
import Element exposing (Element)
import Element.Events
import Element.Border
@ -9,6 +16,9 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)
@ -27,6 +37,11 @@ view model =
{ url = "/" ++ string
, label = Element.text string
}
title = Element.link
[Element.alignLeft]
{ url = "/"
, label = Element.text "Elm Example App"
}
products = headerButton "Products"
resources = headerButton "Resources"
@ -37,7 +52,7 @@ view model =
Element.spacing 15,
Element.paddingXY 30 25,
dropShadow]
[ Element.el [Element.alignLeft] (Element.text "Elm Example App")
[ title
, products
, resources
, about

View file

@ -9,6 +9,7 @@ import String exposing (right)
import Browser.Navigation
import Browser exposing (UrlRequest)
import Html exposing (header)
import Ports
-- internal imports
import Body
@ -34,11 +35,12 @@ init flags url key =
model =
{ key = key
, url = url
, page = page
, page = Body.handleRoute url.path
, header = header
}
in
(model, Cmd.none)
(model, Ports.socketOpen)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
@ -49,15 +51,19 @@ update msg model =
in
( {model | page = newPage}, cmd |> Cmd.map Body )
UrlChanged url ->
( {model | url = url}, Cmd.none )
let
newModel = {model | page = Body.handleRoute url.path, url = url}
in
( newModel, Cmd.none )
UrlRequest (Browser.Internal url) ->
( model, Browser.Navigation.pushUrl model.key (Url.toString url) )
_ -> (model, Cmd.none)
subscriptions : Model -> Sub Msg
subscriptions _ =
Sub.none
subscriptions model = Sub.batch
[ model.header |> Header.subscriptions |> Sub.map Header
, model.page |> Body.subscriptions |> Sub.map Body
]
view : Model -> Browser.Document Msg
view model =

View file

@ -1,4 +1,11 @@
module Page.About exposing (Model, Msg, view, init, update)
module Page.About exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
type alias Model = {}
@ -7,6 +14,9 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)

View file

@ -1,4 +1,11 @@
module Page.Contact exposing (Model, Msg, view, init, update)
module Page.Contact exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
type alias Model = {}
@ -7,6 +14,10 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)

View file

@ -0,0 +1,138 @@
module Page.Landing exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
import Websockets
import Ports
import Json.Decode as Decode
import Json.Encode as Encode exposing (Value)
import Html.Attributes exposing (placeholder)
import Element.Input
import Element.Background
type alias Model = {
time : String,
greetWidgetText : String,
greeting : String
}
type DownMsgLanding
= Greeting String
| TimeUpdate String
decodeDownMsgLanding : Decode.Decoder DownMsgLanding
decodeDownMsgLanding =
let
t = Decode.map Greeting (Decode.field "Greeting" Decode.string)
in
Decode.oneOf
[ Decode.map Greeting (Decode.field "Greeting" Decode.string)
, Decode.map TimeUpdate (Decode.field "TimeUpdate" Decode.string)
]
decodeDownMsg : Decode.Decoder Msg
decodeDownMsg =
let
decoder = Decode.field "Landing" decodeDownMsgLanding
in
Decode.map DownMsg decoder
type UpMsgLanding = RequestGreet String
encodeUpMsgLanding : UpMsgLanding -> Value
encodeUpMsgLanding msg =
case msg of
RequestGreet name ->
Encode.object
[ ( "Landing", Encode.object
[ ( "RequestGreet", Encode.string name ) ]
)
]
type Msg
= DownMsg DownMsgLanding
| UpMsg UpMsgLanding
| DecodeError Decode.Error
| GreetWidgetText String
| NoOp
init : () -> Model
init flags = {
time = "time not yet set",
greetWidgetText = "",
greeting = ""
}
subscriptions : Model -> Sub Msg
subscriptions model =
Ports.socketOnEvent
(Websockets.EventHandlers
(\_ -> NoOp)
(\_ -> NoOp)
(\_ -> NoOp)
(\message ->
case Decode.decodeString decodeDownMsg message.data of
Ok msg -> msg
Err err -> DecodeError err
)
(\_ -> NoOp)
)
update : Msg -> Model -> (Model, Cmd Msg)
update msg model =
case msg of
DownMsg (TimeUpdate time) -> ( {model | time = time}, Cmd.none )
DownMsg (Greeting greeting) -> ( {model | greeting = greeting}, Cmd.none)
UpMsg upMsgLanding -> (
model,
upMsgLanding |> encodeUpMsgLanding |> Ports.socketSend
)
GreetWidgetText text -> ( {model | greetWidgetText = text}, Cmd.none )
_ -> (model, Cmd.none)
greetWidget : Model -> Element Msg
greetWidget model =
let
idleBlue = Element.rgb255 18 147 217
focusBlue = Element.rgb255 18 125 184
myButton = Element.Input.button
[ Element.Background.color idleBlue
, Element.mouseOver [Element.Background.color focusBlue]
, Element.width (Element.fill |> Element.maximum 100)
, Element.height (Element.fill |> Element.maximum 50)
]
{ onPress = Just <| UpMsg <| RequestGreet model.greetWidgetText
, label = Element.text "Greet"
}
placeholder = case model.greetWidgetText of
"" -> Just <| Element.Input.placeholder [] <| Element.text "Type Your Name"
_ -> Just <| Element.Input.placeholder [] <| Element.text ""
textInput =
Element.Input.text
[ Element.width (Element.fill |> Element.maximum 400)
, Element.height Element.fill
]
{ onChange = GreetWidgetText
, text = model.greetWidgetText
, placeholder = placeholder
, label = Element.Input.labelHidden "Enter your name"
}
nameInput = Element.row [] [ textInput , myButton]
in
Element.column []
[ nameInput
, Element.text model.greeting
]
view : Model -> Element Msg
view model =
Element.column []
[ Element.text "Landing"
, Element.text <| "Current time is : " ++ model.time
, greetWidget model
]

View file

@ -1,4 +1,11 @@
module Page.Landing exposing (Model, Msg, view, init, update)
module Page.NotFound exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
type alias Model = {}
@ -7,10 +14,13 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)
view : Model -> Element Msg
view model =
Element.el []
<| Element.text "Landing"
<| Element.text "I'm sorry, it seems we don't have that page!"

View file

@ -1,4 +1,11 @@
module Page.Products exposing (Model, Msg, view, init, update)
module Page.Products exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
type alias Model = {}
@ -7,6 +14,9 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)

View file

@ -1,4 +1,11 @@
module Page.Resources exposing (Model, Msg, view, init, update)
module Page.Resources exposing
( Model
, Msg
, view
, init
, update
, subscriptions
)
import Element exposing (Element)
type alias Model = {}
@ -7,6 +14,9 @@ type alias Msg = {}
init : () -> Model
init flags = {}
subscriptions : Model -> Sub Msg
subscriptions _ = Sub.none
update : Msg -> Model -> (Model, Cmd Msg)
update msg model = (model, Cmd.none)

31
frontend/src/Ports.elm Normal file
View file

@ -0,0 +1,31 @@
port module Ports exposing
( socketOnEvent
, socketSend
, socketOpen
)
import Websockets
import Json.Encode as Encode
port webSocketCommand : Websockets.CommandPort msg
port webSocketEvent : Websockets.EventPort msg
socketName : String
socketName = "app"
socketOnEvent : Websockets.EventHandlers msg -> Sub msg
socketOnEvent eventHandlers =
socket.onEvent eventHandlers
socketSend : Encode.Value -> Cmd msg
socketSend data = socket.send socketName data
socketOpen : Cmd msg
socketOpen = socket.open socketName "/ws/" []
socket : Websockets.Methods msg
socket =
Websockets.withPorts
{ command = webSocketCommand
, event = webSocketEvent
}

View file

@ -0,0 +1,60 @@
function initSockets(app) {
var sockets = new Map();
app.ports.webSocketCommand.subscribe(function (command) {
switch (command.type) {
case "open": {
var name_1 = command.name, url = command.url, meta_1 = command.meta;
var oldSocket = sockets.get(name_1);
if (oldSocket) {
oldSocket.ws.close(-1, "New socket opened with the same name");
sockets.delete(name_1);
}
var socket = {
ws: new WebSocket(url),
name: name_1,
meta: meta_1,
};
sockets.set(name_1, socket);
socket.ws.addEventListener("open", function (ev) {
app.ports.webSocketEvent.send({ type: "opened", name: name_1, meta: meta_1 });
});
socket.ws.addEventListener("message", function (_a) {
var data = _a.data;
app.ports.webSocketEvent.send({ type: "message", name: name_1, meta: meta_1, data: data });
});
socket.ws.addEventListener("close", function (_a) {
var reason = _a.reason;
app.ports.webSocketEvent.send({ type: "closed", name: name_1, meta: meta_1, reason: reason });
sockets.delete(name_1);
});
socket.ws.addEventListener("error", function (ev) {
app.ports.webSocketEvent.send({
type: "error",
name: name_1,
meta: meta_1,
error: null,
});
sockets.delete(name_1);
});
break;
}
case "send": {
var name_2 = command.name, data = command.data;
var socket = sockets.get(name_2);
if (socket) {
socket.ws.send(typeof data === "object" ? JSON.stringify(data) : data);
}
break;
}
case "close": {
var name_3 = command.name;
var socket = sockets.get(name_3);
if (socket) {
socket.ws.close();
sockets.delete(name_3);
}
break;
}
}
});
}

View file

@ -1,15 +0,0 @@
#!/bin/sh
set -e
js="elm.js"
min="elm.min.js"
elm make --debug --output=$js "$@"
# elm make --optimize --debug --output=$js "$@"
uglifyjs $js --compress 'pure_funcs=[F2,F3,F4,F5,F6,F7,F8,F9,A2,A3,A4,A5,A6,A7,A8,A9],pure_getters,keep_fargs=false,unsafe_comps,unsafe' | uglifyjs --mangle --output $min
echo "Compiled size:$(wc $js -c) bytes ($js)"
echo "Minified size:$(wc $min -c) bytes ($min)"
echo "Gzipped size: $(gzip $min -c | wc -c) bytes"

25
shell.nix Normal file
View file

@ -0,0 +1,25 @@
{
system ? builtins.currentSystem,
}:
let
lock = builtins.fromJSON (builtins.readFile ./flake.lock);
root = lock.nodes.${lock.root};
inherit (lock.nodes.${root.inputs.flake-compat}.locked)
owner
repo
rev
narHash
;
flake-compat = fetchTarball {
url = "https://github.com/${owner}/${repo}/archive/${rev}.tar.gz";
sha256 = narHash;
};
flake = import flake-compat {
inherit system;
src = ./.;
};
in
flake.shellNix

View file

@ -1,66 +0,0 @@
module Body exposing (Msg(..), Model(..), init, update, view)
import Element
import Page.About
import Page.Contact
import Page.Landing
import Page.Products
import Page.Resources
type Msg
= MsgLanding Page.Landing.Msg
| MsgProducts Page.Products.Msg
| MsgResources Page.Resources.Msg
| MsgAbout Page.About.Msg
| MsgContact Page.Contact.Msg
type Model
= ModelLanding Page.Landing.Model
| ModelProducts Page.Products.Model
| ModelResources Page.Resources.Model
| ModelAbout Page.About.Model
| ModelContact Page.Contact.Model
init : () -> Model
init flags = ModelLanding (Page.Landing.init flags)
update : Msg -> Model -> (Model, Cmd Msg)
update bodyMsg bodyModel =
let
updatePage msg model updateFn wrapMsg toBodyModel =
let
(newModel, cmd) = updateFn msg model
in
(toBodyModel newModel, Cmd.map wrapMsg cmd)
in
case (bodyMsg, bodyModel) of
(MsgLanding msg, ModelLanding m) ->
updatePage msg m Page.Landing.update MsgLanding ModelLanding
(MsgProducts msg, ModelProducts m) ->
updatePage msg m Page.Products.update MsgProducts ModelProducts
(MsgResources msg, ModelResources m) ->
updatePage msg m Page.Resources.update MsgResources ModelResources
(MsgAbout msg, ModelAbout m) ->
updatePage msg m Page.About.update MsgAbout ModelAbout
(MsgContact msg, ModelContact m) ->
updatePage msg m Page.Contact.update MsgContact ModelContact
_ ->
(bodyModel, Cmd.none)
view : Model -> Element.Element Msg
view model =
let
content = case model of
ModelLanding m -> Page.Landing.view m |> Element.map MsgLanding
ModelProducts m -> Page.Products.view m |> Element.map MsgProducts
ModelResources m -> Page.Resources.view m |> Element.map MsgResources
ModelAbout m -> Page.About.view m |> Element.map MsgAbout
ModelContact m -> Page.Contact.view m |> Element.map MsgContact
in
Element.el [Element.centerY ,Element.centerX] content