mirror of
https://github.com/leptos-rs/leptos.git
synced 2025-12-28 06:42:35 -05:00
Compare commits
3 Commits
fix-4481
...
websocket-
| Author | SHA1 | Date | |
|---|---|---|---|
|
|
71a2534fad | ||
|
|
b5e86886e6 | ||
|
|
d7fbe615cd |
4
examples/websocket_shopping_list/.dockerignore
Normal file
4
examples/websocket_shopping_list/.dockerignore
Normal file
@@ -0,0 +1,4 @@
|
||||
# flyctl launch added from .gitignore
|
||||
**/Dockerfile
|
||||
**/target
|
||||
fly.toml
|
||||
2
examples/websocket_shopping_list/.gitignore
vendored
Normal file
2
examples/websocket_shopping_list/.gitignore
vendored
Normal file
@@ -0,0 +1,2 @@
|
||||
Dockerfile
|
||||
target
|
||||
83
examples/websocket_shopping_list/Cargo.toml
Normal file
83
examples/websocket_shopping_list/Cargo.toml
Normal 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
|
||||
21
examples/websocket_shopping_list/LICENSE
Normal file
21
examples/websocket_shopping_list/LICENSE
Normal 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.
|
||||
12
examples/websocket_shopping_list/Makefile.toml
Normal file
12
examples/websocket_shopping_list/Makefile.toml
Normal 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", "${@}"]
|
||||
19
examples/websocket_shopping_list/README.md
Normal file
19
examples/websocket_shopping_list/README.md
Normal 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.
|
||||
22
examples/websocket_shopping_list/fly.toml
Normal file
22
examples/websocket_shopping_list/fly.toml
Normal 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
|
||||
BIN
examples/websocket_shopping_list/public/favicon.ico
Normal file
BIN
examples/websocket_shopping_list/public/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 15 KiB |
375
examples/websocket_shopping_list/src/app.rs
Normal file
375
examples/websocket_shopping_list/src/app.rs
Normal 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>
|
||||
}
|
||||
}
|
||||
9
examples/websocket_shopping_list/src/lib.rs
Normal file
9
examples/websocket_shopping_list/src/lib.rs
Normal 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);
|
||||
}
|
||||
34
examples/websocket_shopping_list/src/main.rs
Normal file
34
examples/websocket_shopping_list/src/main.rs
Normal 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();
|
||||
}
|
||||
90
examples/websocket_shopping_list/style.css
Normal file
90
examples/websocket_shopping_list/style.css
Normal 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;
|
||||
}
|
||||
Reference in New Issue
Block a user