Compare commits

...

3 Commits

Author SHA1 Message Date
Greg Johnston
71a2534fad miscellaneous updates 2025-05-09 16:38:59 -04:00
Greg Johnston
b5e86886e6 add completed checkbox 2025-04-30 15:58:19 -04:00
Greg Johnston
d7fbe615cd example: websocket shopping list 2025-04-30 15:40:52 -04:00
12 changed files with 671 additions and 0 deletions

View File

@@ -0,0 +1,4 @@
# flyctl launch added from .gitignore
**/Dockerfile
**/target
fly.toml

View File

@@ -0,0 +1,2 @@
Dockerfile
target

View File

@@ -0,0 +1,83 @@
[package]
name = "shopping_list"
version = "0.1.0"
edition = "2021"
[lib]
crate-type = ["cdylib", "rlib"]
[dependencies]
console_log = "1.0"
console_error_panic_hook = "0.1.7"
futures = "0.3.30"
#leptos = { path = "../../leptos", features = ["tracing"] }
#leptos_axum = { path = "../../integrations/axum", optional = true }
#reactive_stores = { path = "../../reactive_stores" }
leptos = { version = "0.8", features = ["tracing"] }
leptos_axum = { version = "0.8", optional = true }
leptos_meta = { version = "0.8" }
reactive_stores = { version = "0.2" }
log = "0.4.22"
simple_logger = "5.0"
serde = { version = "1.0", features = ["derive"] }
axum = { version = "0.8.1", optional = true }
tokio = { version = "1.39", features = ["full"], optional = true }
thiserror = "2.0"
wasm-bindgen = "0.2.100"
uuid = { version = "1.16.0", features = ["v4", "serde"] }
async-broadcast = "0.7.2"
[features]
hydrate = ["leptos/hydrate", "uuid/js"]
ssr = ["dep:axum", "dep:tokio", "leptos/ssr", "dep:leptos_axum"]
[package.metadata.cargo-all-features]
denylist = ["axum", "tokio", "leptos_axum"]
skip_feature_sets = [["csr", "ssr"], ["csr", "hydrate"], ["ssr", "hydrate"], []]
[package.metadata.leptos]
# The name used by wasm-bindgen/cargo-leptos for the JS/WASM bundle. Defaults to the crate name
output-name = "shopping_list"
# The site root folder is where cargo-leptos generate all output. WARNING: all content of this folder will be erased on a rebuild. Use it in your server setup.
site-root = "target/site"
# The site-root relative folder where all compiled output (JS, WASM and CSS) is written
# Defaults to pkg
site-pkg-dir = "pkg"
# [Optional] The source CSS file. If it ends with .sass or .scss then it will be compiled by dart-sass into CSS. The CSS is optimized by Lightning CSS before being written to <site-root>/<site-pkg>/app.css
style-file = "./style.css"
# [Optional] Files in the asset-dir will be copied to the site-root directory
assets-dir = "public"
# The IP and port (ex: 127.0.0.1:3000) where the server serves the content. Use it in your server setup.
site-addr = "127.0.0.1:3000"
# The port to use for automatic reload monitoring
reload-port = 3001
# [Optional] Command to use when running end2end tests. It will run in the end2end dir.
end2end-cmd = "cargo make test-ui"
end2end-dir = "e2e"
# The browserlist query used for optimizing the CSS.
browserquery = "defaults"
# Set by cargo-leptos watch when building with that tool. Controls whether autoreload JS will be included in the head
watch = false
# The environment Leptos will run in, usually either "DEV" or "PROD"
env = "DEV"
# The features to use when compiling the bin target
#
# Optional. Can be over-ridden with the command line parameter --bin-features
bin-features = ["ssr"]
# If the --no-default-features flag should be used when compiling the bin target
#
# Optional. Defaults to false.
bin-default-features = false
# The features to use when compiling the lib target
#
# Optional. Can be over-ridden with the command line parameter --lib-features
lib-features = ["hydrate"]
# If the --no-default-features flag should be used when compiling the lib target
#
# Optional. Defaults to false.
lib-default-features = false
hash-files = true

View File

@@ -0,0 +1,21 @@
MIT License
Copyright (c) 2022 Greg Johnston
Permission is hereby granted, free of charge, to any person obtaining a copy
of this software and associated documentation files (the "Software"), to deal
in the Software without restriction, including without limitation the rights
to use, copy, modify, merge, publish, distribute, sublicense, and/or sell
copies of the Software, and to permit persons to whom the Software is
furnished to do so, subject to the following conditions:
The above copyright notice and this permission notice shall be included in all
copies or substantial portions of the Software.
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF ANY KIND, EXPRESS OR
IMPLIED, INCLUDING BUT NOT LIMITED TO THE WARRANTIES OF MERCHANTABILITY,
FITNESS FOR A PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT SHALL THE
AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY CLAIM, DAMAGES OR OTHER
LIABILITY, WHETHER IN AN ACTION OF CONTRACT, TORT OR OTHERWISE, ARISING FROM,
OUT OF OR IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER DEALINGS IN THE
SOFTWARE.

View File

@@ -0,0 +1,12 @@
extend = [
{ path = "../cargo-make/main.toml" },
{ path = "../cargo-make/cargo-leptos-webdriver-test.toml" },
]
[env]
CLIENT_PROCESS_NAME = "websocket"
[tasks.test-ui]
cwd = "./e2e"
command = "cargo"
args = ["make", "test-ui", "${@}"]

View File

@@ -0,0 +1,19 @@
# Leptos WebSocket
This example creates a basic WebSocket echo app.
## Getting Started
See the [Examples README](../README.md) for setup and run instructions.
## E2E Testing
See the [E2E README](./e2e/README.md) for more information about the testing strategy.
## Rendering
See the [SSR Notes](../SSR_NOTES.md) for more information about Server Side Rendering.
## Quick Start
Run `cargo leptos watch` to run this example.

View File

@@ -0,0 +1,22 @@
# fly.toml app configuration file generated for leptos-shopping on 2025-05-09T11:29:06-04:00
#
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
#
app = 'leptos-shopping'
primary_region = 'bos'
[build]
[http_service]
internal_port = 8080
force_https = true
auto_stop_machines = 'stop'
auto_start_machines = true
min_machines_running = 0
processes = ['app']
[[vm]]
memory = '1gb'
cpu_kind = 'shared'
cpus = 1

Binary file not shown.

After

Width:  |  Height:  |  Size: 15 KiB

View File

@@ -0,0 +1,375 @@
use futures::channel::mpsc::UnboundedSender;
use leptos::{html::Input, prelude::*, task::spawn_local};
use leptos_meta::HashedStylesheet;
use reactive_stores::{ArcStore, Field, Store, StoreFieldIterator};
use serde::{Deserialize, Serialize};
use server_fn::{codec::JsonEncoding, BoxedStream, ServerFnError, Websocket};
use uuid::Uuid;
pub fn shell(options: LeptosOptions) -> impl IntoView {
view! {
<!DOCTYPE html>
<html lang="en">
<head>
<meta charset="utf-8" />
<meta name="viewport" content="width=device-width, initial-scale=1" />
<AutoReload options=options.clone() />
<HashedStylesheet options=options.clone()/>
<HydrationScripts options />
<link rel="shortcut icon" type="image/ico" href="/favicon.ico" />
</head>
<body>
<App />
</body>
</html>
}
}
// Business Logic
#[derive(Debug, Default, Clone, Store, PartialEq, Eq)]
pub struct ShoppingList {
#[store(key: Uuid = |item| item.id)]
pub items: Vec<Item>,
}
#[derive(Debug, Clone, Store, PartialEq, Eq, Serialize, Deserialize)]
pub struct Item {
pub id: Uuid,
pub label: String,
pub completed: bool,
}
#[derive(Debug, Clone, PartialEq, Eq, Serialize, Deserialize)]
pub enum Message {
Connect,
Disconnect,
Welcome { list: Vec<Item> },
Add { id: Uuid, label: String },
Remove { id: Uuid },
MarkComplete { id: Uuid, completed: bool },
Edit { id: Uuid, new_label: String },
}
#[derive(Debug, Clone, Copy)]
pub struct Client {
store: State,
connection:
StoredValue<UnboundedSender<Result<MessageWithUser, ServerFnError>>>,
user: Uuid,
}
#[derive(Debug, Clone, Copy)]
pub struct State(Store<ShoppingList>);
impl From<ArcStore<ShoppingList>> for State {
fn from(value: ArcStore<ShoppingList>) -> Self {
State(value.into())
}
}
impl State {
/// Applies an update to the local store.
pub fn apply_local_update(&self, message: Message) {
match message {
Message::Connect => {}
Message::Disconnect => {}
Message::Welcome { list } => *self.0.items().write() = list,
Message::Add { id, label } => self.0.items().write().push(Item {
id,
label,
completed: false,
}),
Message::Remove { id } => {
self.0.items().write().retain(|item| item.id != id);
}
Message::MarkComplete { id, completed } => {
if let Some(item) = self.find(&id) {
*item.completed().write() = completed;
}
}
Message::Edit { id, new_label } => {
if let Some(item) = self.find(&id) {
*item.label().write() = new_label;
}
}
}
}
fn find(&self, id: &Uuid) -> Option<Field<Item>> {
let store = self.0.items().read_untracked();
store
.iter()
.position(|item| &item.id == id)
.map(|idx| self.0.items().at_unkeyed(idx).into())
}
}
impl Client {
pub fn new(
connection: UnboundedSender<Result<MessageWithUser, ServerFnError>>,
) -> Self {
let user = Uuid::new_v4();
connection
.unbounded_send(Ok((user, Message::Connect)))
.unwrap();
Self {
user,
store: State(Store::new(ShoppingList::default())),
connection: StoredValue::new(connection),
}
}
pub fn goodbye(&self) {
_ = self
.connection
.read_value()
.unbounded_send(Ok((self.user, Message::Disconnect)));
}
/// Updates the shopping list from this local device. This will both
/// update the state of the UI here, and send the update over the websocket.
pub fn update(&self, message: Message) {
self.store.apply_local_update(message.clone());
self.send_update(message);
}
/// Applies an update that was received from the server.
pub fn received_update(&self, user: Uuid, message: Message) {
match message {
Message::Welcome { list } => {
*self.store.0.items().write() = list;
}
_ => {
if user != self.user {
self.store.apply_local_update(message);
}
}
}
}
/// Sends an update to the server.
pub fn send_update(&self, message: Message) {
self.connection
.read_value()
.unbounded_send(Ok((self.user, message)))
.unwrap();
}
}
type MessageWithUser = (Uuid, Message);
#[server(protocol = Websocket<JsonEncoding, JsonEncoding>)]
async fn messages(
input: BoxedStream<MessageWithUser, ServerFnError>,
) -> Result<BoxedStream<MessageWithUser, ServerFnError>, ServerFnError> {
let mut input = input; // FIXME :-)
#[derive(Debug, Clone)]
pub struct ServerState(ArcStore<ShoppingList>);
impl ServerState {
fn initial_items(&self) -> Vec<Item> {
self.0.clone().items().get_untracked()
}
fn apply_local_update(&self, message: Message) {
Owner::new().with(|| {
State::from(self.0.clone()).apply_local_update(message)
})
}
}
use futures::{
channel::mpsc::{channel, Sender},
StreamExt,
};
use std::{
collections::HashMap,
sync::{LazyLock, Mutex},
};
static SHOPPING_LIST: LazyLock<ServerState> =
LazyLock::new(|| ServerState(ArcStore::new(ShoppingList::default())));
static USER_SENDERS: LazyLock<
Mutex<HashMap<Uuid, Sender<Result<MessageWithUser, ServerFnError>>>>,
> = LazyLock::new(|| Mutex::new(HashMap::new()));
let (tx, rx) = channel(32);
let mut tx = Some(tx);
// spawn a task to listen to the input stream of messages coming in over the websocket
tokio::spawn(async move {
while let Some(msg) = input.next().await {
match msg {
Err(e) => eprintln!("{e}"),
Ok((user, msg)) => match msg {
Message::Connect => {
println!("\nuser connecting: {user:?}");
if let Some(mut tx) = tx.take() {
tx.try_send(Ok((
user,
Message::Welcome {
list: SHOPPING_LIST.initial_items(),
},
)))
.unwrap();
USER_SENDERS.lock().unwrap().insert(user, tx);
}
}
Message::Disconnect => {
println!("\nuser disconnecting: {user:?}");
USER_SENDERS.lock().unwrap().remove(&user);
}
_ => {
println!("\nmsg from {user:?} {msg:?}");
SHOPPING_LIST.apply_local_update(msg.clone());
let mut senders = USER_SENDERS.lock().unwrap();
senders.retain(|tx_user, tx| {
if tx_user != &user {
let res = tx.try_send(Ok((user, msg.clone())));
if res.is_err() {
println!("user disconnected: {tx_user:?}");
return false;
}
}
true
});
println!("\n{:#?}", &*SHOPPING_LIST.0.read_untracked());
}
},
}
}
});
Ok(rx.into())
}
#[component]
pub fn App() -> impl IntoView {
use futures::{channel::mpsc, StreamExt};
let (tx, rx) = mpsc::unbounded();
let client = Client::new(tx);
// we'll only listen for websocket messages on the client
if cfg!(feature = "hydrate") {
on_cleanup(move || {
client.goodbye();
});
spawn_local(async move {
match messages(rx.into()).await {
Ok(mut messages) => {
while let Some(msg) = messages.next().await {
leptos::logging::log!("{:?}", msg);
match msg {
Ok((user, msg)) => {
// when we get a message from the server, only apply it locally
client.received_update(user, msg);
}
Err(e) => {
leptos::logging::error!("{e:?}");
}
}
}
}
Err(e) => leptos::logging::warn!("{e}"),
}
});
}
let add_item = NodeRef::<Input>::new();
view! {
<h1>"My Shopping List"</h1>
<form
class="add"
on:submit:target=move |ev| {
ev.prevent_default();
let label = add_item.get().unwrap().value();
client.update(Message::Add { id: Uuid::new_v4(), label });
ev.target().reset();
}
>
<input type="text" node_ref=add_item autofocus/>
<input
type="submit"
value="Add"
/>
</form>
<ul>
<For
each=move || client.store.0.items()
key=|item| item.id().get()
let:item
>
<ItemEditor client item/>
</For>
</ul>
}
}
#[component]
pub fn ItemEditor(
client: Client,
#[prop(into)] item: Field<Item>,
) -> impl IntoView {
let editing = RwSignal::new(false);
view! {
<li class:completed=item.completed()>
<input
class="item"
type="checkbox"
prop:checked=item.completed()
id=move || item.id().read().to_string()
on:change:target=move |ev| {
client.update(Message::MarkComplete {
id: item.id().get(),
completed: ev.target().checked()
});
}
/>
<label
class="item"
class:hidden=move || editing.get()
for=move || item.id().read().to_string()
>
{item.label()}
</label>
<input
class="item"
type="text"
prop:value=item.label()
on:change:target=move |ev| {
client.update(Message::Edit {
id: item.id().get(),
new_label: ev.target().value()
});
editing.set(false);
}
class:hidden=move || !editing.get()
/>
<button
class:hidden=move || editing.get()
on:click=move |_| editing.set(true)
>
"Edit"
</button>
<button
class:hidden=move || !editing.get()
on:click=move |_| editing.set(false)
>
"Cancel"
</button>
<button on:click=move |_| client.update(Message::Remove { id: item.id().get() })>
""
</button>
</li>
}
}

View File

@@ -0,0 +1,9 @@
pub mod app;
#[cfg(feature = "hydrate")]
#[wasm_bindgen::prelude::wasm_bindgen]
pub fn hydrate() {
use crate::app::App;
console_error_panic_hook::set_once();
leptos::mount::hydrate_body(App);
}

View File

@@ -0,0 +1,34 @@
#[cfg(feature = "ssr")]
#[tokio::main]
async fn main() {
use axum::Router;
use leptos::prelude::*;
use leptos_axum::{generate_route_list, LeptosRoutes};
use shopping_list::app::{shell, App};
simple_logger::init_with_level(log::Level::Error)
.expect("couldn't initialize logging");
// Setting this to None means we'll be using cargo-leptos and its env vars
let conf = get_configuration(None).unwrap();
let leptos_options = conf.leptos_options;
let addr = leptos_options.site_addr;
let routes = generate_route_list(App);
// build our application with a route
let app = Router::new()
.leptos_routes(&leptos_options, routes, {
let leptos_options = leptos_options.clone();
move || shell(leptos_options.clone())
})
.fallback(leptos_axum::file_and_error_handler(shell))
.with_state(leptos_options);
// run our app with hyper
// `axum::Server` is a re-export of `hyper::Server`
let listener = tokio::net::TcpListener::bind(&addr).await.unwrap();
println!("listening on http://{}", &addr);
axum::serve(listener, app.into_make_service())
.await
.unwrap();
}

View File

@@ -0,0 +1,90 @@
.hidden {
display: none;
}
h1 {
text-align: center;
}
form.add {
display: flex;
justify-content: center;
}
form.add > * {
margin: 1rem;
}
.item {
cursor: pointer;
}
.completed label {
text-decoration: line-through;
}
main {
max-width: 500px;
width: 100%;
background: white;
padding: 1rem;
border-radius: 8px;
box-shadow: 0 2px 10px rgba(0, 0, 0, 0.1);
}
h1 {
text-align: center;
margin-bottom: 1rem;
font-size: 1.5rem;
}
form.add {
display: flex;
gap: 0.5rem;
margin-bottom: 1rem;
}
form.add input[type="text"] {
flex: 1;
padding: 0.5rem;
font-size: 1rem;
}
form.add input[type="submit"] {
padding: 0.5rem 1rem;
font-size: 1rem;
cursor: pointer;
}
ul {
list-style: none;
padding: 0;
margin: 0;
}
li {
display: flex;
align-items: center;
padding: 0.5rem 0;
border-bottom: 1px solid #ddd;
gap: 0.5rem;
flex-wrap: wrap;
}
li:last-child {
border-bottom: none;
}
input[type="checkbox"] {
transform: scale(1.2);
}
label.item {
flex: 1;
word-break: break-word;
font-size: 1rem;
}
input.item.hidden {
display: none;
}