codify "platform" into resolution logic, server-fn/fullstack overhaul (#4596)
overhaul dioxus fullstack using a new implementation of server_fn This PR is a complete rewrite of dioxus fullstack designed to improve compatibility with axum and to fix a number of compilation errors based on feature flag unification plumbing through macros.
This commit is contained in:
1 parent
8421c3aef0
commit
f2b1c6d97f
424 files changed
+17284
-6624
No files matched your search
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,14 @@
|
||||
[package]
|
||||
name = "bluetooth-scanner"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
tokio = { workspace = true, features = ["full"] }
|
||||
dioxus = { workspace = true, features = ["desktop"] }
|
||||
futures-channel = { workspace = true }
|
||||
futures = { workspace = true }
|
||||
btleplug = "0.11.8"
|
||||
@@ -0,0 +1,5 @@
|
||||
# Bluetooth scanner app
|
||||
|
||||
This desktop app showcases the use of background threads.
|
||||
|
||||

|
||||
@@ -0,0 +1,418 @@
|
||||
/*! tailwindcss v4.1.5 | MIT License | https://tailwindcss.com */
|
||||
@layer properties;
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol',
|
||||
'Noto Color Emoji';
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New',
|
||||
monospace;
|
||||
--color-green-500: oklch(72.3% 0.219 149.579);
|
||||
--color-indigo-500: oklch(58.5% 0.233 277.117);
|
||||
--color-indigo-600: oklch(51.1% 0.262 276.966);
|
||||
--color-purple-50: oklch(97.7% 0.014 308.299);
|
||||
--color-purple-500: oklch(62.7% 0.265 303.9);
|
||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||
--color-gray-500: oklch(55.1% 0.027 264.364);
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--font-weight-medium: 500;
|
||||
--font-weight-bold: 700;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, 'Apple Color Emoji', 'Segoe UI Emoji', 'Segoe UI Symbol', 'Noto Color Emoji');
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, 'Liberation Mono', 'Courier New', monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: currentcolor;
|
||||
@supports (color: color-mix(in lab, red, red)) {
|
||||
color: color-mix(in oklab, currentcolor 50%, transparent);
|
||||
}
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type='button'], [type='reset'], [type='submit']), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden='until-found'])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.table {
|
||||
display: table;
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.table-auto {
|
||||
table-layout: auto;
|
||||
}
|
||||
.overflow-x-auto {
|
||||
overflow-x: auto;
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
.bg-gray-50 {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
.bg-green-500 {
|
||||
background-color: var(--color-green-500);
|
||||
}
|
||||
.bg-indigo-500 {
|
||||
background-color: var(--color-indigo-500);
|
||||
}
|
||||
.bg-purple-50 {
|
||||
background-color: var(--color-purple-50);
|
||||
}
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.p-4 {
|
||||
padding: calc(var(--spacing) * 4);
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-1 {
|
||||
padding-block: calc(var(--spacing) * 1);
|
||||
}
|
||||
.py-3 {
|
||||
padding-block: calc(var(--spacing) * 3);
|
||||
}
|
||||
.py-5 {
|
||||
padding-block: calc(var(--spacing) * 5);
|
||||
}
|
||||
.py-8 {
|
||||
padding-block: calc(var(--spacing) * 8);
|
||||
}
|
||||
.pb-3 {
|
||||
padding-bottom: calc(var(--spacing) * 3);
|
||||
}
|
||||
.pl-6 {
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
}
|
||||
.text-xs {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
}
|
||||
.font-bold {
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.font-medium {
|
||||
--tw-font-weight: var(--font-weight-medium);
|
||||
font-weight: var(--font-weight-medium);
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.text-purple-500 {
|
||||
color: var(--color-purple-500);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.shadow {
|
||||
--tw-shadow: 0 1px 3px 0 var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 1px 2px -1px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter, display, visibility, content-visibility, overlay, pointer-events;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.duration-200 {
|
||||
--tw-duration: 200ms;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.hover\:bg-indigo-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-indigo-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.md\:w-auto {
|
||||
@media (width >= 48rem) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
}
|
||||
@property --tw-font-weight {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-inset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-ring-inset {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-offset-width {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --tw-ring-offset-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: #fff;
|
||||
}
|
||||
@property --tw-ring-offset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-duration {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@layer properties {
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-font-weight: initial;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-duration: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 123 KiB |
@@ -0,0 +1,90 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app)
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut scan = use_action(|| async {
|
||||
use btleplug::api::{Central, Manager as _, Peripheral, ScanFilter};
|
||||
|
||||
let manager = btleplug::platform::Manager::new().await?;
|
||||
|
||||
// get the first bluetooth adapter
|
||||
let adapters = manager.adapters().await?;
|
||||
let central = adapters
|
||||
.into_iter()
|
||||
.next()
|
||||
.context("No Bluetooth adapter found")?;
|
||||
|
||||
// start scanning for devices
|
||||
central.start_scan(ScanFilter::default()).await?;
|
||||
tokio::time::sleep(std::time::Duration::from_secs(2)).await;
|
||||
|
||||
// Return the list of peripherals after scanning
|
||||
let mut devices = vec![];
|
||||
for p in central.peripherals().await? {
|
||||
if let Some(p) = p.properties().await? {
|
||||
devices.push(p);
|
||||
}
|
||||
}
|
||||
|
||||
// Sort them by RSSI (signal strength)
|
||||
devices.sort_by_key(|p| p.rssi.unwrap_or(-100));
|
||||
|
||||
dioxus::Ok(devices)
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/assets/tailwind.css") }
|
||||
div {
|
||||
div { class: "py-8 px-6",
|
||||
div { class: "container px-4 mx-auto",
|
||||
h2 { class: "text-2xl font-bold", "Scan for Bluetooth Devices" }
|
||||
button {
|
||||
class: "inline-block w-full md:w-auto px-6 py-3 font-medium text-white bg-indigo-500 hover:bg-indigo-600 rounded transition duration-200",
|
||||
disabled: scan.is_pending(),
|
||||
onclick: move |_| {
|
||||
scan.call();
|
||||
},
|
||||
if scan.is_pending() { "Scanning" } else { "Scan" }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
section { class: "py-8",
|
||||
div { class: "container px-4 mx-auto",
|
||||
div { class: "p-4 mb-6 bg-white shadow rounded overflow-x-auto",
|
||||
table { class: "table-auto w-full",
|
||||
thead {
|
||||
tr { class: "text-xs text-gray-500 text-left",
|
||||
th { class: "pl-6 pb-3 font-medium", "Strength" }
|
||||
th { class: "pb-3 font-medium", "Network" }
|
||||
th { class: "pb-3 font-medium", "Channel" }
|
||||
th { class: "pb-3 px-2 font-medium", "Security" }
|
||||
}
|
||||
}
|
||||
match scan.result() {
|
||||
None if scan.is_pending() => rsx! { "Scanning..." },
|
||||
None => rsx! { "Press Scan to start scanning" },
|
||||
Some(Err(_err)) => rsx! { "Failed to scan" },
|
||||
Some(Ok(peripherals)) => rsx! {
|
||||
tbody {
|
||||
for peripheral in peripherals.read().iter().rev() {
|
||||
tr { class: "text-xs bg-gray-50",
|
||||
td { class: "py-5 px-6 font-medium", "{peripheral.rssi.unwrap_or(-100)}" }
|
||||
td { class: "flex py-3 font-medium", "{peripheral.local_name.clone().unwrap_or_default()}" }
|
||||
td { span { class: "inline-block py-1 px-2 text-white bg-green-500 rounded-full", "{peripheral.address}" } }
|
||||
td { span { class: "inline-block py-1 px-2 text-purple-500 bg-purple-50 rounded-full", "{peripheral.tx_power_level.unwrap_or_default()}" } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "./src/**/*.{rs,html,css}";
|
||||
@@ -0,0 +1,200 @@
|
||||
//! Calculator
|
||||
//!
|
||||
//! This example is a simple iOS-style calculator. Instead of wrapping the state in a single struct like the
|
||||
//! `calculate_mutable` example, this example uses several closures to manage actions with the state. Most
|
||||
//! components will start like this since it's the quickest way to start adding state to your app. The `Signal` type
|
||||
//! in Dioxus is `Copy` - meaning you don't need to clone it to use it in a closure.
|
||||
//!
|
||||
//! Notice how our logic is consolidated into just a few callbacks instead of a single struct. This is a rather organic
|
||||
//! way to start building state management in Dioxus, and it's a great way to start.
|
||||
|
||||
use dioxus::events::*;
|
||||
use dioxus::html::input_data::keyboard_types::Key;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const STYLE: Asset = asset!("/examples/assets/calculator.css");
|
||||
|
||||
fn main() {
|
||||
dioxus::LaunchBuilder::desktop()
|
||||
.with_cfg(desktop!({
|
||||
use dioxus::desktop::{Config, LogicalSize, WindowBuilder};
|
||||
Config::new().with_window(
|
||||
WindowBuilder::default()
|
||||
.with_title("Calculator")
|
||||
.with_inner_size(LogicalSize::new(300.0, 525.0)),
|
||||
)
|
||||
}))
|
||||
.launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut val = use_signal(|| String::from("0"));
|
||||
|
||||
let mut input_digit = move |num: String| {
|
||||
if val() == "0" {
|
||||
val.set(String::new());
|
||||
}
|
||||
val.write().push_str(num.as_str());
|
||||
};
|
||||
|
||||
let mut input_operator = move |key: &str| val.write().push_str(key);
|
||||
|
||||
let handle_key_down_event = move |evt: KeyboardEvent| match evt.key() {
|
||||
Key::Backspace => {
|
||||
if !val().is_empty() {
|
||||
val.write().pop();
|
||||
}
|
||||
}
|
||||
Key::Character(character) => match character.as_str() {
|
||||
"+" | "-" | "/" | "*" => input_operator(&character),
|
||||
"0" | "1" | "2" | "3" | "4" | "5" | "6" | "7" | "8" | "9" => input_digit(character),
|
||||
_ => {}
|
||||
},
|
||||
_ => {}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: STYLE }
|
||||
div { id: "wrapper",
|
||||
div { class: "app",
|
||||
div { class: "calculator", tabindex: "0", onkeydown: handle_key_down_event,
|
||||
div { class: "calculator-display",
|
||||
if val().is_empty() {
|
||||
"0"
|
||||
} else {
|
||||
"{val}"
|
||||
}
|
||||
}
|
||||
div { class: "calculator-keypad",
|
||||
div { class: "input-keys",
|
||||
div { class: "function-keys",
|
||||
button {
|
||||
class: "calculator-key key-clear",
|
||||
onclick: move |_| {
|
||||
val.set(String::new());
|
||||
if !val.cloned().is_empty() {
|
||||
val.set("0".into());
|
||||
}
|
||||
},
|
||||
if val.cloned().is_empty() { "C" } else { "AC" }
|
||||
}
|
||||
button {
|
||||
class: "calculator-key key-sign",
|
||||
onclick: move |_| {
|
||||
let new_val = calc_val(val.cloned().as_str());
|
||||
if new_val > 0.0 {
|
||||
val.set(format!("-{new_val}"));
|
||||
} else {
|
||||
val.set(format!("{}", new_val.abs()));
|
||||
}
|
||||
},
|
||||
"±"
|
||||
}
|
||||
button {
|
||||
class: "calculator-key key-percent",
|
||||
onclick: move |_| val.set(format!("{}", calc_val(val.cloned().as_str()) / 100.0)),
|
||||
"%"
|
||||
}
|
||||
}
|
||||
div { class: "digit-keys",
|
||||
button {
|
||||
class: "calculator-key key-0",
|
||||
onclick: move |_| input_digit(0.to_string()),
|
||||
"0"
|
||||
}
|
||||
button {
|
||||
class: "calculator-key key-dot",
|
||||
onclick: move |_| val.write().push('.'),
|
||||
"●"
|
||||
}
|
||||
for k in 1..10 {
|
||||
button {
|
||||
class: "calculator-key {k}",
|
||||
name: "key-{k}",
|
||||
onclick: move |_| input_digit(k.to_string()),
|
||||
"{k}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "operator-keys",
|
||||
for (key, class) in [("/", "key-divide"), ("*", "key-multiply"), ("-", "key-subtract"), ("+", "key-add")] {
|
||||
button {
|
||||
class: "calculator-key {class}",
|
||||
onclick: move |_| input_operator(key),
|
||||
"{key}"
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "calculator-key key-equals",
|
||||
onclick: move |_| val.set(format!("{}", calc_val(val.cloned().as_str()))),
|
||||
"="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn calc_val(val: &str) -> f64 {
|
||||
let mut temp = String::new();
|
||||
let mut operation = "+".to_string();
|
||||
|
||||
let mut start_index = 0;
|
||||
let mut temp_value;
|
||||
let mut fin_index = 0;
|
||||
|
||||
if &val[0..1] == "-" {
|
||||
temp_value = String::from("-");
|
||||
fin_index = 1;
|
||||
start_index += 1;
|
||||
} else {
|
||||
temp_value = String::from("");
|
||||
}
|
||||
|
||||
for c in val[fin_index..].chars() {
|
||||
if c == '+' || c == '-' || c == '*' || c == '/' {
|
||||
break;
|
||||
}
|
||||
temp_value.push(c);
|
||||
start_index += 1;
|
||||
}
|
||||
|
||||
let mut result = temp_value.parse::<f64>().unwrap();
|
||||
|
||||
if start_index + 1 >= val.len() {
|
||||
return result;
|
||||
}
|
||||
|
||||
for c in val[start_index..].chars() {
|
||||
if c == '+' || c == '-' || c == '*' || c == '/' {
|
||||
if !temp.is_empty() {
|
||||
match &operation as &str {
|
||||
"+" => result += temp.parse::<f64>().unwrap(),
|
||||
"-" => result -= temp.parse::<f64>().unwrap(),
|
||||
"*" => result *= temp.parse::<f64>().unwrap(),
|
||||
"/" => result /= temp.parse::<f64>().unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
}
|
||||
operation = c.to_string();
|
||||
temp = String::new();
|
||||
} else {
|
||||
temp.push(c);
|
||||
}
|
||||
}
|
||||
|
||||
if !temp.is_empty() {
|
||||
match &operation as &str {
|
||||
"+" => result += temp.parse::<f64>().unwrap(),
|
||||
"-" => result -= temp.parse::<f64>().unwrap(),
|
||||
"*" => result *= temp.parse::<f64>().unwrap(),
|
||||
"/" => result /= temp.parse::<f64>().unwrap(),
|
||||
_ => unreachable!(),
|
||||
};
|
||||
}
|
||||
|
||||
result
|
||||
}
|
||||
@@ -0,0 +1,230 @@
|
||||
//! This example showcases a simple calculator using an approach to state management where the state is composed of only
|
||||
//! a single signal. Since Dioxus implements traditional React diffing, state can be consolidated into a typical Rust struct
|
||||
//! with methods that take `&mut self`. For many use cases, this is a simple way to manage complex state without wrapping
|
||||
//! everything in a signal.
|
||||
//!
|
||||
//! Generally, you'll want to split your state into several signals if you have a large application, but for small
|
||||
//! applications, or focused components, this is a great way to manage state.
|
||||
|
||||
use dioxus::desktop::tao::dpi::LogicalSize;
|
||||
use dioxus::desktop::{Config, WindowBuilder};
|
||||
use dioxus::html::MouseEvent;
|
||||
use dioxus::html::input_data::keyboard_types::Key;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::LaunchBuilder::desktop()
|
||||
.with_cfg(
|
||||
Config::new().with_window(
|
||||
WindowBuilder::new()
|
||||
.with_title("Calculator Demo")
|
||||
.with_resizable(false)
|
||||
.with_inner_size(LogicalSize::new(320.0, 530.0)),
|
||||
),
|
||||
)
|
||||
.launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut state = use_signal(Calculator::new);
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/examples/assets/calculator.css") }
|
||||
div { id: "wrapper",
|
||||
div { class: "app",
|
||||
div {
|
||||
class: "calculator",
|
||||
onkeypress: move |evt| state.write().handle_keydown(evt),
|
||||
div { class: "calculator-display", {state.read().formatted_display()} }
|
||||
div { class: "calculator-keypad",
|
||||
div { class: "input-keys",
|
||||
div { class: "function-keys",
|
||||
CalculatorKey {
|
||||
name: "key-clear",
|
||||
onclick: move |_| state.write().clear_display(),
|
||||
if state.read().display_value == "0" {
|
||||
"C"
|
||||
} else {
|
||||
"AC"
|
||||
}
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-sign",
|
||||
onclick: move |_| state.write().toggle_sign(),
|
||||
"±"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-percent",
|
||||
onclick: move |_| state.write().toggle_percent(),
|
||||
"%"
|
||||
}
|
||||
}
|
||||
div { class: "digit-keys",
|
||||
CalculatorKey {
|
||||
name: "key-0",
|
||||
onclick: move |_| state.write().input_digit(0),
|
||||
"0"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-dot",
|
||||
onclick: move |_| state.write().input_dot(),
|
||||
"●"
|
||||
}
|
||||
for k in 1..10 {
|
||||
CalculatorKey {
|
||||
key: "{k}",
|
||||
name: "key-{k}",
|
||||
onclick: move |_| state.write().input_digit(k),
|
||||
"{k}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "operator-keys",
|
||||
CalculatorKey {
|
||||
name: "key-divide",
|
||||
onclick: move |_| state.write().set_operator(Operator::Div),
|
||||
"÷"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-multiply",
|
||||
onclick: move |_| state.write().set_operator(Operator::Mul),
|
||||
"×"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-subtract",
|
||||
onclick: move |_| state.write().set_operator(Operator::Sub),
|
||||
"−"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-add",
|
||||
onclick: move |_| state.write().set_operator(Operator::Add),
|
||||
"+"
|
||||
}
|
||||
CalculatorKey {
|
||||
name: "key-equals",
|
||||
onclick: move |_| state.write().perform_operation(),
|
||||
"="
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn CalculatorKey(name: String, onclick: EventHandler<MouseEvent>, children: Element) -> Element {
|
||||
rsx! {
|
||||
button { class: "calculator-key {name}", onclick, {children} }
|
||||
}
|
||||
}
|
||||
|
||||
struct Calculator {
|
||||
display_value: String,
|
||||
operator: Option<Operator>,
|
||||
waiting_for_operand: bool,
|
||||
cur_val: f64,
|
||||
}
|
||||
|
||||
#[derive(Clone)]
|
||||
enum Operator {
|
||||
Add,
|
||||
Sub,
|
||||
Mul,
|
||||
Div,
|
||||
}
|
||||
|
||||
impl Calculator {
|
||||
fn new() -> Self {
|
||||
Calculator {
|
||||
display_value: "0".to_string(),
|
||||
operator: None,
|
||||
waiting_for_operand: false,
|
||||
cur_val: 0.0,
|
||||
}
|
||||
}
|
||||
fn formatted_display(&self) -> String {
|
||||
use separator::Separatable;
|
||||
self.display_value
|
||||
.parse::<f64>()
|
||||
.unwrap()
|
||||
.separated_string()
|
||||
}
|
||||
fn clear_display(&mut self) {
|
||||
self.display_value = "0".to_string();
|
||||
}
|
||||
fn input_digit(&mut self, digit: u8) {
|
||||
let content = digit.to_string();
|
||||
if self.waiting_for_operand || self.display_value == "0" {
|
||||
self.waiting_for_operand = false;
|
||||
self.display_value = content;
|
||||
} else {
|
||||
self.display_value.push_str(content.as_str());
|
||||
}
|
||||
}
|
||||
fn input_dot(&mut self) {
|
||||
if !self.display_value.contains('.') {
|
||||
self.display_value.push('.');
|
||||
}
|
||||
}
|
||||
fn perform_operation(&mut self) {
|
||||
if let Some(op) = &self.operator {
|
||||
let rhs = self.display_value.parse::<f64>().unwrap();
|
||||
let new_val = match op {
|
||||
Operator::Add => self.cur_val + rhs,
|
||||
Operator::Sub => self.cur_val - rhs,
|
||||
Operator::Mul => self.cur_val * rhs,
|
||||
Operator::Div => self.cur_val / rhs,
|
||||
};
|
||||
self.cur_val = new_val;
|
||||
self.display_value = new_val.to_string();
|
||||
self.operator = None;
|
||||
}
|
||||
}
|
||||
fn toggle_sign(&mut self) {
|
||||
if self.display_value.starts_with('-') {
|
||||
self.display_value = self.display_value.trim_start_matches('-').to_string();
|
||||
} else {
|
||||
self.display_value = format!("-{}", self.display_value);
|
||||
}
|
||||
}
|
||||
fn toggle_percent(&mut self) {
|
||||
self.display_value = (self.display_value.parse::<f64>().unwrap() / 100.0).to_string();
|
||||
}
|
||||
fn backspace(&mut self) {
|
||||
if !self.display_value.as_str().eq("0") {
|
||||
self.display_value.pop();
|
||||
}
|
||||
}
|
||||
fn set_operator(&mut self, operator: Operator) {
|
||||
self.operator = Some(operator);
|
||||
self.cur_val = self.display_value.parse::<f64>().unwrap();
|
||||
self.waiting_for_operand = true;
|
||||
}
|
||||
fn handle_keydown(&mut self, evt: KeyboardEvent) {
|
||||
match evt.key() {
|
||||
Key::Backspace => self.backspace(),
|
||||
Key::Character(c) => match c.as_str() {
|
||||
"0" => self.input_digit(0),
|
||||
"1" => self.input_digit(1),
|
||||
"2" => self.input_digit(2),
|
||||
"3" => self.input_digit(3),
|
||||
"4" => self.input_digit(4),
|
||||
"5" => self.input_digit(5),
|
||||
"6" => self.input_digit(6),
|
||||
"7" => self.input_digit(7),
|
||||
"8" => self.input_digit(8),
|
||||
"9" => self.input_digit(9),
|
||||
"+" => self.operator = Some(Operator::Add),
|
||||
"-" => self.operator = Some(Operator::Sub),
|
||||
"/" => self.operator = Some(Operator::Div),
|
||||
"*" => self.operator = Some(Operator::Mul),
|
||||
_ => {}
|
||||
},
|
||||
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
//! A simple counters example that stores a list of items in a vec and then iterates over them.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
const STYLE: Asset = asset!("/examples/assets/counter.css");
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
// Store the counters in a signal
|
||||
let mut counters = use_signal(|| vec![0, 0, 0]);
|
||||
|
||||
// Whenever the counters change, sum them up
|
||||
let sum = use_memo(move || counters.read().iter().copied().sum::<i32>());
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: STYLE }
|
||||
|
||||
div { id: "controls",
|
||||
button { onclick: move |_| counters.write().push(0), "Add counter" }
|
||||
button { onclick: move |_| { counters.write().pop(); }, "Remove counter" }
|
||||
}
|
||||
|
||||
h3 { "Total: {sum}" }
|
||||
|
||||
// Calling `iter` on a Signal<Vec<>> gives you a GenerationalRef to each entry in the vec
|
||||
// We enumerate to get the idx of each counter, which we use later to modify the vec
|
||||
for (i, counter) in counters.iter().enumerate() {
|
||||
// We need a key to uniquely identify each counter. You really shouldn't be using the index, so we're using
|
||||
// the counter value itself.
|
||||
//
|
||||
// If we used the index, and a counter is removed, dioxus would need to re-write the contents of all following
|
||||
// counters instead of simply removing the one that was removed
|
||||
//
|
||||
// You should use a stable identifier for the key, like a unique id or the value of the counter itself
|
||||
li { key: "{i}",
|
||||
button { onclick: move |_| counters.write()[i] -= 1, "-1" }
|
||||
input {
|
||||
r#type: "number",
|
||||
value: "{counter}",
|
||||
oninput: move |e| {
|
||||
if let Ok(value) = e.parsed() {
|
||||
counters.write()[i] = value;
|
||||
}
|
||||
}
|
||||
}
|
||||
button { onclick: move |_| counters.write()[i] += 1, "+1" }
|
||||
button { onclick: move |_| { counters.write().remove(i); }, "x" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,164 @@
|
||||
//! Tiny CRM - A simple CRM app using the Router component and global signals
|
||||
//!
|
||||
//! This shows how to use the `Router` component to manage different views in your app. It also shows how to use global
|
||||
//! signals to manage state across the entire app.
|
||||
//!
|
||||
//! We could simply pass the state as a prop to each component, but this is a good example of how to use global state
|
||||
//! in a way that works across pages.
|
||||
//!
|
||||
//! We implement a number of important details here too, like focusing inputs, handling form submits, navigating the router,
|
||||
//! platform-specific configuration, and importing 3rd party CSS libraries.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::LaunchBuilder::new()
|
||||
.with_cfg(desktop!({
|
||||
use dioxus::desktop::{LogicalSize, WindowBuilder};
|
||||
dioxus::desktop::Config::default()
|
||||
.with_window(WindowBuilder::new().with_inner_size(LogicalSize::new(800, 600)))
|
||||
}))
|
||||
.launch(|| {
|
||||
rsx! {
|
||||
Stylesheet {
|
||||
href: "https://unpkg.com/purecss@2.0.6/build/pure-min.css",
|
||||
integrity: "sha384-Uu6IeWbM+gzNVXJcM9XV3SohHtmWE+3VGi496jvgX1jyvDTXfdK+rfZc8C1Aehk5",
|
||||
crossorigin: "anonymous",
|
||||
}
|
||||
Stylesheet { href: asset!("/examples/assets/crm.css") }
|
||||
h1 { "Dioxus CRM Example" }
|
||||
Router::<Route> {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
/// We only have one list of clients for the whole app, so we can use a global signal.
|
||||
static CLIENTS: GlobalSignal<Vec<Client>> = Signal::global(Vec::new);
|
||||
|
||||
struct Client {
|
||||
first_name: String,
|
||||
last_name: String,
|
||||
description: String,
|
||||
}
|
||||
|
||||
/// The pages of the app, each with a route
|
||||
#[derive(Routable, Clone)]
|
||||
enum Route {
|
||||
#[route("/")]
|
||||
List,
|
||||
|
||||
#[route("/new")]
|
||||
New,
|
||||
|
||||
#[route("/settings")]
|
||||
Settings,
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn List() -> Element {
|
||||
rsx! {
|
||||
h2 { "List of Clients" }
|
||||
Link { to: Route::New, class: "pure-button pure-button-primary", "Add Client" }
|
||||
Link { to: Route::Settings, class: "pure-button", "Settings" }
|
||||
for client in CLIENTS.read().iter() {
|
||||
div { class: "client", style: "margin-bottom: 50px",
|
||||
p { "Name: {client.first_name} {client.last_name}" }
|
||||
p { "Description: {client.description}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn New() -> Element {
|
||||
let mut first_name = use_signal(String::new);
|
||||
let mut last_name = use_signal(String::new);
|
||||
let mut description = use_signal(String::new);
|
||||
|
||||
let submit_client = move |_| {
|
||||
// Write the client
|
||||
CLIENTS.write().push(Client {
|
||||
first_name: first_name(),
|
||||
last_name: last_name(),
|
||||
description: description(),
|
||||
});
|
||||
|
||||
// And then navigate back to the client list
|
||||
router().push(Route::List);
|
||||
};
|
||||
|
||||
rsx! {
|
||||
h2 { "Add new Client" }
|
||||
form { class: "pure-form pure-form-aligned", onsubmit: submit_client,
|
||||
fieldset {
|
||||
div { class: "pure-control-group",
|
||||
label { r#for: "first_name", "First Name" }
|
||||
input {
|
||||
id: "first_name",
|
||||
r#type: "text",
|
||||
placeholder: "First Name…",
|
||||
required: true,
|
||||
value: "{first_name}",
|
||||
oninput: move |e| first_name.set(e.value()),
|
||||
|
||||
// when the form mounts, focus the first name input
|
||||
onmounted: move |e| async move {
|
||||
_ = e.set_focus(true).await;
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "pure-control-group",
|
||||
label { r#for: "last_name", "Last Name" }
|
||||
input {
|
||||
id: "last_name",
|
||||
r#type: "text",
|
||||
placeholder: "Last Name…",
|
||||
required: true,
|
||||
value: "{last_name}",
|
||||
oninput: move |e| last_name.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "pure-control-group",
|
||||
label { r#for: "description", "Description" }
|
||||
textarea {
|
||||
id: "description",
|
||||
placeholder: "Description…",
|
||||
value: "{description}",
|
||||
oninput: move |e| description.set(e.value()),
|
||||
}
|
||||
}
|
||||
|
||||
div { class: "pure-controls",
|
||||
button {
|
||||
r#type: "submit",
|
||||
class: "pure-button pure-button-primary",
|
||||
"Save"
|
||||
}
|
||||
Link {
|
||||
to: Route::List,
|
||||
class: "pure-button pure-button-primary red",
|
||||
"Cancel"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Settings() -> Element {
|
||||
rsx! {
|
||||
h2 { "Settings" }
|
||||
button {
|
||||
class: "pure-button pure-button-primary red",
|
||||
onclick: move |_| {
|
||||
CLIENTS.write().clear();
|
||||
dioxus::router::router().push(Route::List);
|
||||
},
|
||||
"Remove all Clients"
|
||||
}
|
||||
Link { to: Route::List, class: "pure-button", "Go back" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
//! This example demonstrates a simple app that fetches a list of dog breeds and displays a random dog.
|
||||
//!
|
||||
//! This app combines `use_loader` and `use_action` to fetch data from the Dog API.
|
||||
//! - `use_loader` automatically fetches the list of dog breeds when the component mounts.
|
||||
//! - `use_action` fetches a random dog image whenever the `.dispatch` method is called.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::collections::HashMap;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
// Fetch the list of breeds from the Dog API, using the `?` syntax to suspend or throw errors
|
||||
let breed_list = use_loader(move || async move {
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq, Clone)]
|
||||
struct ListBreeds {
|
||||
message: HashMap<String, Vec<String>>,
|
||||
}
|
||||
|
||||
reqwest::get("https://dog.ceo/api/breeds/list/all")
|
||||
.await?
|
||||
.json::<ListBreeds>()
|
||||
.await
|
||||
})?;
|
||||
|
||||
// Whenever this action is called, it will re-run the future and return the result.
|
||||
let mut breed = use_action(move |breed| async move {
|
||||
#[derive(Deserialize, Serialize, Debug, PartialEq)]
|
||||
struct DogApi {
|
||||
message: String,
|
||||
}
|
||||
|
||||
reqwest::get(format!("https://dog.ceo/api/breed/{breed}/images/random"))
|
||||
.await
|
||||
.unwrap()
|
||||
.json::<DogApi>()
|
||||
.await
|
||||
});
|
||||
|
||||
rsx! {
|
||||
h1 { "Doggo selector" }
|
||||
div { width: "400px",
|
||||
for cur_breed in breed_list.read().message.keys().take(20).cloned() {
|
||||
button {
|
||||
onclick: move |_| {
|
||||
breed.call(cur_breed.clone());
|
||||
},
|
||||
"{cur_breed}"
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
match breed.result() {
|
||||
None => rsx! { div { "Click the button to fetch a dog!" } },
|
||||
Some(Err(_e)) => rsx! { div { "Failed to fetch a dog, please try again." } },
|
||||
Some(Ok(res)) => rsx! {
|
||||
img {
|
||||
max_width: "500px",
|
||||
max_height: "500px",
|
||||
src: "{res.read().message}"
|
||||
}
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1 @@
|
||||
/target
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "ecommerce-site"
|
||||
version = "0.1.1"
|
||||
edition = "2021"
|
||||
publish = false
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = ["fullstack", "router"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde = { workspace = true }
|
||||
|
||||
[target.'cfg(target_family = "wasm")'.dependencies]
|
||||
chrono = { workspace = true, features = ["serde", "wasmbind"] }
|
||||
|
||||
[target.'cfg(not(target_family = "wasm"))'.dependencies]
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
|
||||
[features]
|
||||
web = ["dioxus/web"]
|
||||
server = ["dioxus/server"]
|
||||
@@ -0,0 +1,25 @@
|
||||
# Dioxus Example: An e-commerce site using the FakeStoreAPI
|
||||
|
||||
This example app is a fullstack web application leveraging the [FakeStoreAPI](https://fakestoreapi.com) and [Tailwind CSS](https://tailwindcss.com/).
|
||||
|
||||

|
||||
|
||||
# Development
|
||||
|
||||
1. Run the following commands to serve the application:
|
||||
|
||||
```bash
|
||||
dx serve
|
||||
```
|
||||
|
||||
Note that in Dioxus 0.7, the Tailwind watcher is initialized automatically if a `tailwind.css` file is find in your app's root.
|
||||
|
||||
# Status
|
||||
|
||||
This is a work in progress. The following features are currently implemented:
|
||||
|
||||
- [x] A homepage with a list of products dynamically fetched from the FakeStoreAPI (rendered using SSR)
|
||||
- [x] A product detail page with details about a product (rendered using LiveView)
|
||||
- [ ] A cart page
|
||||
- [ ] A checkout page
|
||||
- [ ] A login page
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 279 KiB |
@@ -0,0 +1,17 @@
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,898 @@
|
||||
/*! tailwindcss v4.1.0 | MIT License | https://tailwindcss.com */
|
||||
@supports ((-webkit-hyphens: none) and (not (margin-trim: inline))) or ((-moz-orient: inline) and (not (color:rgb(from red r g b)))) {
|
||||
@layer base {
|
||||
*, ::before, ::after, ::backdrop {
|
||||
--tw-translate-x: 0;
|
||||
--tw-translate-y: 0;
|
||||
--tw-translate-z: 0;
|
||||
--tw-rotate-x: rotateX(0);
|
||||
--tw-rotate-y: rotateY(0);
|
||||
--tw-rotate-z: rotateZ(0);
|
||||
--tw-skew-x: skewX(0);
|
||||
--tw-skew-y: skewY(0);
|
||||
--tw-border-style: solid;
|
||||
--tw-font-weight: initial;
|
||||
--tw-shadow: 0 0 #0000;
|
||||
--tw-shadow-color: initial;
|
||||
--tw-shadow-alpha: 100%;
|
||||
--tw-inset-shadow: 0 0 #0000;
|
||||
--tw-inset-shadow-color: initial;
|
||||
--tw-inset-shadow-alpha: 100%;
|
||||
--tw-ring-color: initial;
|
||||
--tw-ring-shadow: 0 0 #0000;
|
||||
--tw-inset-ring-color: initial;
|
||||
--tw-inset-ring-shadow: 0 0 #0000;
|
||||
--tw-ring-inset: initial;
|
||||
--tw-ring-offset-width: 0px;
|
||||
--tw-ring-offset-color: #fff;
|
||||
--tw-ring-offset-shadow: 0 0 #0000;
|
||||
--tw-duration: initial;
|
||||
}
|
||||
}
|
||||
}
|
||||
@layer theme, base, components, utilities;
|
||||
@layer theme {
|
||||
:root, :host {
|
||||
--font-sans: ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji",
|
||||
"Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji";
|
||||
--font-mono: ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono",
|
||||
"Courier New", monospace;
|
||||
--color-orange-300: oklch(83.7% 0.128 66.29);
|
||||
--color-orange-400: oklch(75% 0.183 55.934);
|
||||
--color-blue-300: oklch(80.9% 0.105 251.813);
|
||||
--color-gray-50: oklch(98.5% 0.002 247.839);
|
||||
--color-gray-100: oklch(96.7% 0.003 264.542);
|
||||
--color-gray-200: oklch(92.8% 0.006 264.531);
|
||||
--color-gray-400: oklch(70.7% 0.022 261.325);
|
||||
--color-gray-500: oklch(55.1% 0.027 264.364);
|
||||
--color-gray-600: oklch(44.6% 0.03 256.802);
|
||||
--color-gray-700: oklch(37.3% 0.034 259.733);
|
||||
--color-gray-800: oklch(27.8% 0.033 256.848);
|
||||
--color-white: #fff;
|
||||
--spacing: 0.25rem;
|
||||
--container-sm: 24rem;
|
||||
--container-md: 28rem;
|
||||
--container-xl: 36rem;
|
||||
--container-2xl: 42rem;
|
||||
--text-xs: 0.75rem;
|
||||
--text-xs--line-height: calc(1 / 0.75);
|
||||
--text-2xl: 1.5rem;
|
||||
--text-2xl--line-height: calc(2 / 1.5);
|
||||
--text-3xl: 1.875rem;
|
||||
--text-3xl--line-height: calc(2.25 / 1.875);
|
||||
--text-5xl: 3rem;
|
||||
--text-5xl--line-height: 1;
|
||||
--text-6xl: 3.75rem;
|
||||
--text-6xl--line-height: 1;
|
||||
--font-weight-semibold: 600;
|
||||
--font-weight-bold: 700;
|
||||
--radius-md: 0.375rem;
|
||||
--radius-lg: 0.5rem;
|
||||
--default-transition-duration: 150ms;
|
||||
--default-transition-timing-function: cubic-bezier(0.4, 0, 0.2, 1);
|
||||
--default-font-family: var(--font-sans);
|
||||
--default-mono-font-family: var(--font-mono);
|
||||
}
|
||||
}
|
||||
@layer base {
|
||||
*, ::after, ::before, ::backdrop, ::file-selector-button {
|
||||
box-sizing: border-box;
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
border: 0 solid;
|
||||
}
|
||||
html, :host {
|
||||
line-height: 1.5;
|
||||
-webkit-text-size-adjust: 100%;
|
||||
tab-size: 4;
|
||||
font-family: var(--default-font-family, ui-sans-serif, system-ui, sans-serif, "Apple Color Emoji", "Segoe UI Emoji", "Segoe UI Symbol", "Noto Color Emoji");
|
||||
font-feature-settings: var(--default-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-font-variation-settings, normal);
|
||||
-webkit-tap-highlight-color: transparent;
|
||||
}
|
||||
hr {
|
||||
height: 0;
|
||||
color: inherit;
|
||||
border-top-width: 1px;
|
||||
}
|
||||
abbr:where([title]) {
|
||||
-webkit-text-decoration: underline dotted;
|
||||
text-decoration: underline dotted;
|
||||
}
|
||||
h1, h2, h3, h4, h5, h6 {
|
||||
font-size: inherit;
|
||||
font-weight: inherit;
|
||||
}
|
||||
a {
|
||||
color: inherit;
|
||||
-webkit-text-decoration: inherit;
|
||||
text-decoration: inherit;
|
||||
}
|
||||
b, strong {
|
||||
font-weight: bolder;
|
||||
}
|
||||
code, kbd, samp, pre {
|
||||
font-family: var(--default-mono-font-family, ui-monospace, SFMono-Regular, Menlo, Monaco, Consolas, "Liberation Mono", "Courier New", monospace);
|
||||
font-feature-settings: var(--default-mono-font-feature-settings, normal);
|
||||
font-variation-settings: var(--default-mono-font-variation-settings, normal);
|
||||
font-size: 1em;
|
||||
}
|
||||
small {
|
||||
font-size: 80%;
|
||||
}
|
||||
sub, sup {
|
||||
font-size: 75%;
|
||||
line-height: 0;
|
||||
position: relative;
|
||||
vertical-align: baseline;
|
||||
}
|
||||
sub {
|
||||
bottom: -0.25em;
|
||||
}
|
||||
sup {
|
||||
top: -0.5em;
|
||||
}
|
||||
table {
|
||||
text-indent: 0;
|
||||
border-color: inherit;
|
||||
border-collapse: collapse;
|
||||
}
|
||||
:-moz-focusring {
|
||||
outline: auto;
|
||||
}
|
||||
progress {
|
||||
vertical-align: baseline;
|
||||
}
|
||||
summary {
|
||||
display: list-item;
|
||||
}
|
||||
ol, ul, menu {
|
||||
list-style: none;
|
||||
}
|
||||
img, svg, video, canvas, audio, iframe, embed, object {
|
||||
display: block;
|
||||
vertical-align: middle;
|
||||
}
|
||||
img, video {
|
||||
max-width: 100%;
|
||||
height: auto;
|
||||
}
|
||||
button, input, select, optgroup, textarea, ::file-selector-button {
|
||||
font: inherit;
|
||||
font-feature-settings: inherit;
|
||||
font-variation-settings: inherit;
|
||||
letter-spacing: inherit;
|
||||
color: inherit;
|
||||
border-radius: 0;
|
||||
background-color: transparent;
|
||||
opacity: 1;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup {
|
||||
font-weight: bolder;
|
||||
}
|
||||
:where(select:is([multiple], [size])) optgroup option {
|
||||
padding-inline-start: 20px;
|
||||
}
|
||||
::file-selector-button {
|
||||
margin-inline-end: 4px;
|
||||
}
|
||||
::placeholder {
|
||||
opacity: 1;
|
||||
}
|
||||
@supports (not (-webkit-appearance: -apple-pay-button)) or (contain-intrinsic-size: 1px) {
|
||||
::placeholder {
|
||||
color: color-mix(in oklab, currentColor 50%, transparent);
|
||||
}
|
||||
}
|
||||
textarea {
|
||||
resize: vertical;
|
||||
}
|
||||
::-webkit-search-decoration {
|
||||
-webkit-appearance: none;
|
||||
}
|
||||
::-webkit-date-and-time-value {
|
||||
min-height: 1lh;
|
||||
text-align: inherit;
|
||||
}
|
||||
::-webkit-datetime-edit {
|
||||
display: inline-flex;
|
||||
}
|
||||
::-webkit-datetime-edit-fields-wrapper {
|
||||
padding: 0;
|
||||
}
|
||||
::-webkit-datetime-edit, ::-webkit-datetime-edit-year-field, ::-webkit-datetime-edit-month-field, ::-webkit-datetime-edit-day-field, ::-webkit-datetime-edit-hour-field, ::-webkit-datetime-edit-minute-field, ::-webkit-datetime-edit-second-field, ::-webkit-datetime-edit-millisecond-field, ::-webkit-datetime-edit-meridiem-field {
|
||||
padding-block: 0;
|
||||
}
|
||||
:-moz-ui-invalid {
|
||||
box-shadow: none;
|
||||
}
|
||||
button, input:where([type="button"], [type="reset"], [type="submit"]), ::file-selector-button {
|
||||
appearance: button;
|
||||
}
|
||||
::-webkit-inner-spin-button, ::-webkit-outer-spin-button {
|
||||
height: auto;
|
||||
}
|
||||
[hidden]:where(:not([hidden="until-found"])) {
|
||||
display: none !important;
|
||||
}
|
||||
}
|
||||
@layer utilities {
|
||||
.absolute {
|
||||
position: absolute;
|
||||
}
|
||||
.fixed {
|
||||
position: fixed;
|
||||
}
|
||||
.relative {
|
||||
position: relative;
|
||||
}
|
||||
.inset-0 {
|
||||
inset: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-0 {
|
||||
top: calc(var(--spacing) * 0);
|
||||
}
|
||||
.top-1\/2 {
|
||||
top: calc(1/2 * 100%);
|
||||
}
|
||||
.right-0 {
|
||||
right: calc(var(--spacing) * 0);
|
||||
}
|
||||
.bottom-0 {
|
||||
bottom: calc(var(--spacing) * 0);
|
||||
}
|
||||
.left-0 {
|
||||
left: calc(var(--spacing) * 0);
|
||||
}
|
||||
.z-50 {
|
||||
z-index: 50;
|
||||
}
|
||||
.container {
|
||||
width: 100%;
|
||||
@media (width >= 40rem) {
|
||||
max-width: 40rem;
|
||||
}
|
||||
@media (width >= 48rem) {
|
||||
max-width: 48rem;
|
||||
}
|
||||
@media (width >= 64rem) {
|
||||
max-width: 64rem;
|
||||
}
|
||||
@media (width >= 80rem) {
|
||||
max-width: 80rem;
|
||||
}
|
||||
@media (width >= 96rem) {
|
||||
max-width: 96rem;
|
||||
}
|
||||
}
|
||||
.m-0 {
|
||||
margin: calc(var(--spacing) * 0);
|
||||
}
|
||||
.m-2 {
|
||||
margin: calc(var(--spacing) * 2);
|
||||
}
|
||||
.-mx-4 {
|
||||
margin-inline: calc(var(--spacing) * -4);
|
||||
}
|
||||
.mx-auto {
|
||||
margin-inline: auto;
|
||||
}
|
||||
.mt-2 {
|
||||
margin-top: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mr-1 {
|
||||
margin-right: calc(var(--spacing) * 1);
|
||||
}
|
||||
.mr-2 {
|
||||
margin-right: calc(var(--spacing) * 2);
|
||||
}
|
||||
.mr-3 {
|
||||
margin-right: calc(var(--spacing) * 3);
|
||||
}
|
||||
.mr-6 {
|
||||
margin-right: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mr-8 {
|
||||
margin-right: calc(var(--spacing) * 8);
|
||||
}
|
||||
.mr-10 {
|
||||
margin-right: calc(var(--spacing) * 10);
|
||||
}
|
||||
.mr-12 {
|
||||
margin-right: calc(var(--spacing) * 12);
|
||||
}
|
||||
.mr-14 {
|
||||
margin-right: calc(var(--spacing) * 14);
|
||||
}
|
||||
.mr-16 {
|
||||
margin-right: calc(var(--spacing) * 16);
|
||||
}
|
||||
.mr-auto {
|
||||
margin-right: auto;
|
||||
}
|
||||
.mb-4 {
|
||||
margin-bottom: calc(var(--spacing) * 4);
|
||||
}
|
||||
.mb-6 {
|
||||
margin-bottom: calc(var(--spacing) * 6);
|
||||
}
|
||||
.mb-8 {
|
||||
margin-bottom: calc(var(--spacing) * 8);
|
||||
}
|
||||
.mb-10 {
|
||||
margin-bottom: calc(var(--spacing) * 10);
|
||||
}
|
||||
.mb-12 {
|
||||
margin-bottom: calc(var(--spacing) * 12);
|
||||
}
|
||||
.mb-14 {
|
||||
margin-bottom: calc(var(--spacing) * 14);
|
||||
}
|
||||
.mb-16 {
|
||||
margin-bottom: calc(var(--spacing) * 16);
|
||||
}
|
||||
.mb-24 {
|
||||
margin-bottom: calc(var(--spacing) * 24);
|
||||
}
|
||||
.ml-8 {
|
||||
margin-left: calc(var(--spacing) * 8);
|
||||
}
|
||||
.block {
|
||||
display: block;
|
||||
}
|
||||
.flex {
|
||||
display: flex;
|
||||
}
|
||||
.hidden {
|
||||
display: none;
|
||||
}
|
||||
.inline-block {
|
||||
display: inline-block;
|
||||
}
|
||||
.inline-flex {
|
||||
display: inline-flex;
|
||||
}
|
||||
.h-2 {
|
||||
height: calc(var(--spacing) * 2);
|
||||
}
|
||||
.h-6 {
|
||||
height: calc(var(--spacing) * 6);
|
||||
}
|
||||
.h-8 {
|
||||
height: calc(var(--spacing) * 8);
|
||||
}
|
||||
.h-9 {
|
||||
height: calc(var(--spacing) * 9);
|
||||
}
|
||||
.h-40 {
|
||||
height: calc(var(--spacing) * 40);
|
||||
}
|
||||
.h-full {
|
||||
height: 100%;
|
||||
}
|
||||
.w-1\/2 {
|
||||
width: calc(1/2 * 100%);
|
||||
}
|
||||
.w-1\/4 {
|
||||
width: calc(1/4 * 100%);
|
||||
}
|
||||
.w-1\/6 {
|
||||
width: calc(1/6 * 100%);
|
||||
}
|
||||
.w-2 {
|
||||
width: calc(var(--spacing) * 2);
|
||||
}
|
||||
.w-5\/6 {
|
||||
width: calc(5/6 * 100%);
|
||||
}
|
||||
.w-6 {
|
||||
width: calc(var(--spacing) * 6);
|
||||
}
|
||||
.w-8 {
|
||||
width: calc(var(--spacing) * 8);
|
||||
}
|
||||
.w-12 {
|
||||
width: calc(var(--spacing) * 12);
|
||||
}
|
||||
.w-full {
|
||||
width: 100%;
|
||||
}
|
||||
.max-w-2xl {
|
||||
max-width: var(--container-2xl);
|
||||
}
|
||||
.max-w-md {
|
||||
max-width: var(--container-md);
|
||||
}
|
||||
.max-w-sm {
|
||||
max-width: var(--container-sm);
|
||||
}
|
||||
.max-w-xl {
|
||||
max-width: var(--container-xl);
|
||||
}
|
||||
.shrink-0 {
|
||||
flex-shrink: 0;
|
||||
}
|
||||
.translate-1\/2 {
|
||||
--tw-translate-x: calc(1/2 * 100%);
|
||||
--tw-translate-y: calc(1/2 * 100%);
|
||||
translate: var(--tw-translate-x) var(--tw-translate-y);
|
||||
}
|
||||
.transform {
|
||||
transform: var(--tw-rotate-x) var(--tw-rotate-y) var(--tw-rotate-z) var(--tw-skew-x) var(--tw-skew-y);
|
||||
}
|
||||
.cursor-pointer {
|
||||
cursor: pointer;
|
||||
}
|
||||
.flex-col {
|
||||
flex-direction: column;
|
||||
}
|
||||
.flex-row {
|
||||
flex-direction: row;
|
||||
}
|
||||
.flex-wrap {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.place-items-center {
|
||||
place-items: center;
|
||||
}
|
||||
.items-center {
|
||||
align-items: center;
|
||||
}
|
||||
.justify-between {
|
||||
justify-content: space-between;
|
||||
}
|
||||
.self-center {
|
||||
align-self: center;
|
||||
}
|
||||
.overflow-y-auto {
|
||||
overflow-y: auto;
|
||||
}
|
||||
.rounded {
|
||||
border-radius: 0.25rem;
|
||||
}
|
||||
.rounded-full {
|
||||
border-radius: calc(infinity * 1px);
|
||||
}
|
||||
.rounded-lg {
|
||||
border-radius: var(--radius-lg);
|
||||
}
|
||||
.rounded-md {
|
||||
border-radius: var(--radius-md);
|
||||
}
|
||||
.border {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 1px;
|
||||
}
|
||||
.border-0 {
|
||||
border-style: var(--tw-border-style);
|
||||
border-width: 0px;
|
||||
}
|
||||
.border-r {
|
||||
border-right-style: var(--tw-border-style);
|
||||
border-right-width: 1px;
|
||||
}
|
||||
.border-b {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 1px;
|
||||
}
|
||||
.border-b-2 {
|
||||
border-bottom-style: var(--tw-border-style);
|
||||
border-bottom-width: 2px;
|
||||
}
|
||||
.border-l {
|
||||
border-left-style: var(--tw-border-style);
|
||||
border-left-width: 1px;
|
||||
}
|
||||
.border-gray-200 {
|
||||
border-color: var(--color-gray-200);
|
||||
}
|
||||
.border-transparent {
|
||||
border-color: transparent;
|
||||
}
|
||||
.bg-gray-50 {
|
||||
background-color: var(--color-gray-50);
|
||||
}
|
||||
.bg-gray-100 {
|
||||
background-color: var(--color-gray-100);
|
||||
}
|
||||
.bg-gray-800 {
|
||||
background-color: var(--color-gray-800);
|
||||
}
|
||||
.bg-orange-300 {
|
||||
background-color: var(--color-orange-300);
|
||||
}
|
||||
.bg-white {
|
||||
background-color: var(--color-white);
|
||||
}
|
||||
.object-cover {
|
||||
object-fit: cover;
|
||||
}
|
||||
.object-scale-down {
|
||||
object-fit: scale-down;
|
||||
}
|
||||
.p-2 {
|
||||
padding: calc(var(--spacing) * 2);
|
||||
}
|
||||
.p-10 {
|
||||
padding: calc(var(--spacing) * 10);
|
||||
}
|
||||
.px-2 {
|
||||
padding-inline: calc(var(--spacing) * 2);
|
||||
}
|
||||
.px-4 {
|
||||
padding-inline: calc(var(--spacing) * 4);
|
||||
}
|
||||
.px-6 {
|
||||
padding-inline: calc(var(--spacing) * 6);
|
||||
}
|
||||
.px-8 {
|
||||
padding-inline: calc(var(--spacing) * 8);
|
||||
}
|
||||
.px-10 {
|
||||
padding-inline: calc(var(--spacing) * 10);
|
||||
}
|
||||
.px-12 {
|
||||
padding-inline: calc(var(--spacing) * 12);
|
||||
}
|
||||
.py-2 {
|
||||
padding-block: calc(var(--spacing) * 2);
|
||||
}
|
||||
.py-4 {
|
||||
padding-block: calc(var(--spacing) * 4);
|
||||
}
|
||||
.py-5 {
|
||||
padding-block: calc(var(--spacing) * 5);
|
||||
}
|
||||
.py-6 {
|
||||
padding-block: calc(var(--spacing) * 6);
|
||||
}
|
||||
.py-8 {
|
||||
padding-block: calc(var(--spacing) * 8);
|
||||
}
|
||||
.py-20 {
|
||||
padding-block: calc(var(--spacing) * 20);
|
||||
}
|
||||
.pr-10 {
|
||||
padding-right: calc(var(--spacing) * 10);
|
||||
}
|
||||
.pb-10 {
|
||||
padding-bottom: calc(var(--spacing) * 10);
|
||||
}
|
||||
.pl-4 {
|
||||
padding-left: calc(var(--spacing) * 4);
|
||||
}
|
||||
.pl-6 {
|
||||
padding-left: calc(var(--spacing) * 6);
|
||||
}
|
||||
.text-center {
|
||||
text-align: center;
|
||||
}
|
||||
.text-left {
|
||||
text-align: left;
|
||||
}
|
||||
.text-2xl {
|
||||
font-size: var(--text-2xl);
|
||||
line-height: var(--tw-leading, var(--text-2xl--line-height));
|
||||
}
|
||||
.text-3xl {
|
||||
font-size: var(--text-3xl);
|
||||
line-height: var(--tw-leading, var(--text-3xl--line-height));
|
||||
}
|
||||
.text-5xl {
|
||||
font-size: var(--text-5xl);
|
||||
line-height: var(--tw-leading, var(--text-5xl--line-height));
|
||||
}
|
||||
.text-xs {
|
||||
font-size: var(--text-xs);
|
||||
line-height: var(--tw-leading, var(--text-xs--line-height));
|
||||
}
|
||||
.font-bold {
|
||||
--tw-font-weight: var(--font-weight-bold);
|
||||
font-weight: var(--font-weight-bold);
|
||||
}
|
||||
.font-semibold {
|
||||
--tw-font-weight: var(--font-weight-semibold);
|
||||
font-weight: var(--font-weight-semibold);
|
||||
}
|
||||
.text-ellipsis {
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
.text-blue-300 {
|
||||
color: var(--color-blue-300);
|
||||
}
|
||||
.text-gray-400 {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
.text-gray-500 {
|
||||
color: var(--color-gray-500);
|
||||
}
|
||||
.text-gray-600 {
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
.text-white {
|
||||
color: var(--color-white);
|
||||
}
|
||||
.uppercase {
|
||||
text-transform: uppercase;
|
||||
}
|
||||
.placeholder-gray-400 {
|
||||
&::placeholder {
|
||||
color: var(--color-gray-400);
|
||||
}
|
||||
}
|
||||
.opacity-25 {
|
||||
opacity: 25%;
|
||||
}
|
||||
.shadow-2xl {
|
||||
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.shadow-lg {
|
||||
--tw-shadow: 0 10px 15px -3px var(--tw-shadow-color, rgb(0 0 0 / 0.1)), 0 4px 6px -4px var(--tw-shadow-color, rgb(0 0 0 / 0.1));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.ring-1 {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(1px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
.transition {
|
||||
transition-property: color, background-color, border-color, outline-color, text-decoration-color, fill, stroke, --tw-gradient-from, --tw-gradient-via, --tw-gradient-to, opacity, box-shadow, transform, translate, scale, rotate, filter, -webkit-backdrop-filter, backdrop-filter;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.transition-all {
|
||||
transition-property: all;
|
||||
transition-timing-function: var(--tw-ease, var(--default-transition-timing-function));
|
||||
transition-duration: var(--tw-duration, var(--default-transition-duration));
|
||||
}
|
||||
.duration-200 {
|
||||
--tw-duration: 200ms;
|
||||
transition-duration: 200ms;
|
||||
}
|
||||
.hover\:bg-orange-400 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
background-color: var(--color-orange-400);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-gray-600 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-gray-600);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:text-gray-700 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
color: var(--color-gray-700);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:shadow-2xl {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
--tw-shadow: 0 25px 50px -12px var(--tw-shadow-color, rgb(0 0 0 / 0.25));
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
.hover\:ring-4 {
|
||||
&:hover {
|
||||
@media (hover: hover) {
|
||||
--tw-ring-shadow: var(--tw-ring-inset,) 0 0 0 calc(4px + var(--tw-ring-offset-width)) var(--tw-ring-color, currentColor);
|
||||
box-shadow: var(--tw-inset-shadow), var(--tw-inset-ring-shadow), var(--tw-ring-offset-shadow), var(--tw-ring-shadow), var(--tw-shadow);
|
||||
}
|
||||
}
|
||||
}
|
||||
.focus\:border-blue-300 {
|
||||
&:focus {
|
||||
border-color: var(--color-blue-300);
|
||||
}
|
||||
}
|
||||
.focus\:ring-blue-300 {
|
||||
&:focus {
|
||||
--tw-ring-color: var(--color-blue-300);
|
||||
}
|
||||
}
|
||||
.focus\:ring-transparent {
|
||||
&:focus {
|
||||
--tw-ring-color: transparent;
|
||||
}
|
||||
}
|
||||
.focus\:outline-hidden {
|
||||
&:focus {
|
||||
--tw-outline-style: none;
|
||||
outline-style: none;
|
||||
@media (forced-colors: active) {
|
||||
outline: 2px solid transparent;
|
||||
outline-offset: 2px;
|
||||
}
|
||||
}
|
||||
}
|
||||
.md\:mb-0 {
|
||||
@media (width >= 48rem) {
|
||||
margin-bottom: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.md\:w-1\/2 {
|
||||
@media (width >= 48rem) {
|
||||
width: calc(1/2 * 100%);
|
||||
}
|
||||
}
|
||||
.md\:w-auto {
|
||||
@media (width >= 48rem) {
|
||||
width: auto;
|
||||
}
|
||||
}
|
||||
.md\:text-right {
|
||||
@media (width >= 48rem) {
|
||||
text-align: right;
|
||||
}
|
||||
}
|
||||
.md\:text-6xl {
|
||||
@media (width >= 48rem) {
|
||||
font-size: var(--text-6xl);
|
||||
line-height: var(--tw-leading, var(--text-6xl--line-height));
|
||||
}
|
||||
}
|
||||
.lg\:pl-20 {
|
||||
@media (width >= 64rem) {
|
||||
padding-left: calc(var(--spacing) * 20);
|
||||
}
|
||||
}
|
||||
.xl\:mx-auto {
|
||||
@media (width >= 80rem) {
|
||||
margin-inline: auto;
|
||||
}
|
||||
}
|
||||
.xl\:mb-0 {
|
||||
@media (width >= 80rem) {
|
||||
margin-bottom: calc(var(--spacing) * 0);
|
||||
}
|
||||
}
|
||||
.xl\:block {
|
||||
@media (width >= 80rem) {
|
||||
display: block;
|
||||
}
|
||||
}
|
||||
.xl\:flex {
|
||||
@media (width >= 80rem) {
|
||||
display: flex;
|
||||
}
|
||||
}
|
||||
.xl\:hidden {
|
||||
@media (width >= 80rem) {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
.xl\:inline-block {
|
||||
@media (width >= 80rem) {
|
||||
display: inline-block;
|
||||
}
|
||||
}
|
||||
.xl\:w-2\/3 {
|
||||
@media (width >= 80rem) {
|
||||
width: calc(2/3 * 100%);
|
||||
}
|
||||
}
|
||||
}
|
||||
@property --tw-translate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-translate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0;
|
||||
}
|
||||
@property --tw-rotate-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateX(0);
|
||||
}
|
||||
@property --tw-rotate-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateY(0);
|
||||
}
|
||||
@property --tw-rotate-z {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: rotateZ(0);
|
||||
}
|
||||
@property --tw-skew-x {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: skewX(0);
|
||||
}
|
||||
@property --tw-skew-y {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: skewY(0);
|
||||
}
|
||||
@property --tw-border-style {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: solid;
|
||||
}
|
||||
@property --tw-font-weight {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-inset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-shadow-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-shadow-alpha {
|
||||
syntax: "<percentage>";
|
||||
inherits: false;
|
||||
initial-value: 100%;
|
||||
}
|
||||
@property --tw-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-inset-ring-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-inset-ring-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-ring-inset {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@property --tw-ring-offset-width {
|
||||
syntax: "<length>";
|
||||
inherits: false;
|
||||
initial-value: 0px;
|
||||
}
|
||||
@property --tw-ring-offset-color {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: #fff;
|
||||
}
|
||||
@property --tw-ring-offset-shadow {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
initial-value: 0 0 #0000;
|
||||
}
|
||||
@property --tw-duration {
|
||||
syntax: "*";
|
||||
inherits: false;
|
||||
}
|
||||
@@ -0,0 +1,106 @@
|
||||
use chrono::{DateTime, Utc};
|
||||
use dioxus::prelude::Result;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
|
||||
// Cache up to 100 requests, invalidating them after 60 seconds
|
||||
pub(crate) async fn fetch_product(product_id: usize) -> Result<Product> {
|
||||
Ok(
|
||||
reqwest::get(format!("https://fakestoreapi.com/products/{product_id}"))
|
||||
.await?
|
||||
.json()
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
// Cache up to 100 requests, invalidating them after 60 seconds
|
||||
pub(crate) async fn fetch_products(count: usize, sort: Sort) -> Result<Vec<Product>> {
|
||||
Ok(reqwest::get(format!(
|
||||
"https://fakestoreapi.com/products/?sort={sort}&limit={count}"
|
||||
))
|
||||
.await?
|
||||
.json()
|
||||
.await?)
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Clone, Debug, Default)]
|
||||
pub(crate) struct Product {
|
||||
pub(crate) id: u32,
|
||||
pub(crate) title: String,
|
||||
pub(crate) price: f32,
|
||||
pub(crate) description: String,
|
||||
pub(crate) category: String,
|
||||
pub(crate) image: String,
|
||||
pub(crate) rating: Rating,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, PartialEq, Clone, Debug, Default)]
|
||||
pub(crate) struct Rating {
|
||||
pub(crate) rate: f32,
|
||||
pub(crate) count: u32,
|
||||
}
|
||||
|
||||
impl Display for Rating {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
let rounded = self.rate.round() as usize;
|
||||
for _ in 0..rounded {
|
||||
"★".fmt(f)?;
|
||||
}
|
||||
for _ in 0..(5 - rounded) {
|
||||
"☆".fmt(f)?;
|
||||
}
|
||||
|
||||
write!(f, " ({:01}) ({} ratings)", self.rate, self.count)?;
|
||||
|
||||
Ok(())
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(unused)]
|
||||
#[derive(Clone, Copy, Hash, PartialEq, Eq, PartialOrd)]
|
||||
pub(crate) enum Sort {
|
||||
Descending,
|
||||
Ascending,
|
||||
}
|
||||
|
||||
impl Display for Sort {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Sort::Descending => write!(f, "desc"),
|
||||
Sort::Ascending => write!(f, "asc"),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct User {
|
||||
id: usize,
|
||||
email: String,
|
||||
username: String,
|
||||
password: String,
|
||||
name: FullName,
|
||||
phone: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize)]
|
||||
struct FullName {
|
||||
firstname: String,
|
||||
lastname: String,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct Cart {
|
||||
id: usize,
|
||||
#[serde(rename = "userId")]
|
||||
user_id: usize,
|
||||
data: String,
|
||||
products: Vec<ProductInCart>,
|
||||
date: DateTime<Utc>,
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Clone)]
|
||||
struct ProductInCart {
|
||||
#[serde(rename = "productId")]
|
||||
product_id: usize,
|
||||
quantity: usize,
|
||||
}
|
||||
@@ -0,0 +1,14 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn error_page() -> Element {
|
||||
rsx! {
|
||||
section { class: "py-20",
|
||||
div { class: "container mx-auto px-4",
|
||||
div { class: "flex flex-wrap -mx-4 mb-24 text-center",
|
||||
"An internal error has occurred"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,23 @@
|
||||
// The homepage is statically rendered, so we don't need to a persistent websocket connection.
|
||||
|
||||
use crate::{
|
||||
api::{fetch_products, Sort},
|
||||
components::nav::Nav,
|
||||
components::product_item::ProductItem,
|
||||
};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
pub(crate) fn Home() -> Element {
|
||||
let products = use_loader(|| fetch_products(10, Sort::Ascending))?;
|
||||
|
||||
rsx! {
|
||||
Nav {}
|
||||
section { class: "p-10",
|
||||
for product in products.iter() {
|
||||
ProductItem {
|
||||
product: product.clone()
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,12 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub(crate) fn ChildrenOrLoading(children: Element) -> Element {
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/public/loading.css") }
|
||||
SuspenseBoundary {
|
||||
fallback: |_| rsx! { div { class: "spinner", } },
|
||||
{children}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,390 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn Nav() -> Element {
|
||||
rsx! {
|
||||
section { class: "relative",
|
||||
nav { class: "flex justify-between border-b",
|
||||
div { class: "px-12 py-8 flex w-full items-center",
|
||||
a { class: "hidden xl:block mr-16",
|
||||
href: "/",
|
||||
icons::cart_icon {}
|
||||
}
|
||||
ul { class: "hidden xl:flex font-semibold font-heading",
|
||||
li { class: "mr-12",
|
||||
a { class: "hover:text-gray-600",
|
||||
href: "/",
|
||||
"Category"
|
||||
}
|
||||
}
|
||||
li { class: "mr-12",
|
||||
a { class: "hover:text-gray-600",
|
||||
href: "/",
|
||||
"Collection"
|
||||
}
|
||||
}
|
||||
li { class: "mr-12",
|
||||
a { class: "hover:text-gray-600",
|
||||
href: "/",
|
||||
"Story"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a { class: "hover:text-gray-600",
|
||||
href: "/",
|
||||
"Brand"
|
||||
}
|
||||
}
|
||||
}
|
||||
a { class: "shrink-0 xl:mx-auto text-3xl font-bold font-heading",
|
||||
href: "/",
|
||||
img { class: "h-9",
|
||||
width: "auto",
|
||||
alt: "",
|
||||
src: "https://shuffle.dev/yofte-assets/logos/yofte-logo.svg",
|
||||
}
|
||||
}
|
||||
div { class: "hidden xl:inline-block mr-14",
|
||||
input { class: "py-5 px-8 w-full placeholder-gray-400 text-xs uppercase font-semibold font-heading bg-gray-50 border border-gray-200 focus:ring-blue-300 focus:border-blue-300 rounded-md",
|
||||
placeholder: "Search",
|
||||
r#type: "text",
|
||||
}
|
||||
}
|
||||
div { class: "hidden xl:flex items-center",
|
||||
a { class: "mr-10 hover:text-gray-600",
|
||||
href: "",
|
||||
icons::icon_1 {}
|
||||
}
|
||||
a { class: "flex items-center hover:text-gray-600",
|
||||
href: "/",
|
||||
icons::icon_2 {}
|
||||
span { class: "inline-block w-6 h-6 text-center bg-gray-50 rounded-full font-semibold font-heading",
|
||||
"3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
a { class: "hidden xl:flex items-center px-12 border-l font-semibold font-heading hover:text-gray-600",
|
||||
href: "/",
|
||||
icons::icon_3 {}
|
||||
span {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
a { class: "xl:hidden flex mr-6 items-center text-gray-600",
|
||||
href: "/",
|
||||
icons::icon_4 {}
|
||||
span { class: "inline-block w-6 h-6 text-center bg-gray-50 rounded-full font-semibold font-heading",
|
||||
"3"
|
||||
}
|
||||
}
|
||||
a { class: "navbar-burger self-center mr-12 xl:hidden",
|
||||
href: "/",
|
||||
icons::icon_5 {}
|
||||
}
|
||||
}
|
||||
div { class: "hidden navbar-menu fixed top-0 left-0 bottom-0 w-5/6 max-w-sm z-50",
|
||||
div { class: "navbar-backdrop fixed inset-0 bg-gray-800 opacity-25",
|
||||
}
|
||||
nav { class: "relative flex flex-col py-6 px-6 w-full h-full bg-white border-r overflow-y-auto",
|
||||
div { class: "flex items-center mb-8",
|
||||
a { class: "mr-auto text-3xl font-bold font-heading",
|
||||
href: "/",
|
||||
img { class: "h-9",
|
||||
src: "https://shuffle.dev/yofte-assets/logos/yofte-logo.svg",
|
||||
width: "auto",
|
||||
alt: "",
|
||||
}
|
||||
}
|
||||
button { class: "navbar-close",
|
||||
icons::icon_6 {}
|
||||
}
|
||||
}
|
||||
div { class: "flex mb-8 justify-between",
|
||||
a { class: "inline-flex items-center font-semibold font-heading",
|
||||
href: "/",
|
||||
icons::icon_7 {}
|
||||
span {
|
||||
"Sign In"
|
||||
}
|
||||
}
|
||||
div { class: "flex items-center",
|
||||
a { class: "mr-10",
|
||||
href: "/",
|
||||
icons::icon_8 {}
|
||||
}
|
||||
a { class: "flex items-center",
|
||||
href: "/",
|
||||
icons::icon_9 {}
|
||||
span { class: "inline-block w-6 h-6 text-center bg-gray-100 rounded-full font-semibold font-heading",
|
||||
"3"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
input { class: "block mb-10 py-5 px-8 bg-gray-100 rounded-md border-transparent focus:ring-blue-300 focus:border-blue-300 focus:outline-hidden",
|
||||
r#type: "search",
|
||||
placeholder: "Search",
|
||||
}
|
||||
ul { class: "text-3xl font-bold font-heading",
|
||||
li { class: "mb-8",
|
||||
a {
|
||||
href: "/",
|
||||
"Category"
|
||||
}
|
||||
}
|
||||
li { class: "mb-8",
|
||||
a {
|
||||
href: "/",
|
||||
"Collection"
|
||||
}
|
||||
}
|
||||
li { class: "mb-8",
|
||||
a {
|
||||
href: "/",
|
||||
"Story"
|
||||
}
|
||||
}
|
||||
li {
|
||||
a {
|
||||
href: "/",
|
||||
"Brand"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod icons {
|
||||
use super::*;
|
||||
|
||||
pub(super) fn cart_icon() -> Element {
|
||||
rsx! {
|
||||
svg { class: "mr-3",
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
view_box: "0 0 23 23",
|
||||
width: "23",
|
||||
height: "23",
|
||||
path {
|
||||
stroke_linejoin: "round",
|
||||
d: "M18.1159 8.72461H2.50427C1.99709 8.72461 1.58594 9.12704 1.58594 9.62346V21.3085C1.58594 21.8049 1.99709 22.2074 2.50427 22.2074H18.1159C18.6231 22.2074 19.0342 21.8049 19.0342 21.3085V9.62346C19.0342 9.12704 18.6231 8.72461 18.1159 8.72461Z",
|
||||
stroke: "currentColor",
|
||||
stroke_linecap: "round",
|
||||
stroke_width: "1.5",
|
||||
}
|
||||
path {
|
||||
stroke: "currentColor",
|
||||
stroke_linecap: "round",
|
||||
d: "M6.34473 6.34469V4.95676C6.34473 3.85246 6.76252 2.79338 7.5062 2.01252C8.24988 1.23165 9.25852 0.792969 10.3102 0.792969C11.362 0.792969 12.3706 1.23165 13.1143 2.01252C13.858 2.79338 14.2758 3.85246 14.2758 4.95676V6.34469",
|
||||
stroke_width: "1.5",
|
||||
stroke_linejoin: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_1() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
height: "20",
|
||||
view_box: "0 0 23 20",
|
||||
width: "23",
|
||||
fill: "none",
|
||||
path {
|
||||
d: "M11.4998 19.2061L2.70115 9.92527C1.92859 9.14433 1.41864 8.1374 1.24355 7.04712C1.06847 5.95684 1.23713 4.8385 1.72563 3.85053V3.85053C2.09464 3.10462 2.63366 2.45803 3.29828 1.96406C3.9629 1.47008 4.73408 1.14284 5.5483 1.00931C6.36252 0.875782 7.19647 0.939779 7.98144 1.19603C8.7664 1.45228 9.47991 1.89345 10.0632 2.48319L11.4998 3.93577L12.9364 2.48319C13.5197 1.89345 14.2332 1.45228 15.0182 1.19603C15.8031 0.939779 16.6371 0.875782 17.4513 1.00931C18.2655 1.14284 19.0367 1.47008 19.7013 1.96406C20.3659 2.45803 20.905 3.10462 21.274 3.85053V3.85053C21.7625 4.8385 21.9311 5.95684 21.756 7.04712C21.581 8.1374 21.071 9.14433 20.2984 9.92527L11.4998 19.2061Z",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "1.5",
|
||||
stroke_linejoin: "round",
|
||||
stroke_linecap: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_2() -> Element {
|
||||
rsx! {
|
||||
svg { class: "mr-3",
|
||||
fill: "none",
|
||||
height: "31",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
width: "32",
|
||||
view_box: "0 0 32 31",
|
||||
path {
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "1.5",
|
||||
d: "M16.0006 16.3154C19.1303 16.3154 21.6673 13.799 21.6673 10.6948C21.6673 7.59064 19.1303 5.07422 16.0006 5.07422C12.871 5.07422 10.334 7.59064 10.334 10.6948C10.334 13.799 12.871 16.3154 16.0006 16.3154Z",
|
||||
stroke_linecap: "round",
|
||||
stroke: "currentColor",
|
||||
}
|
||||
path {
|
||||
stroke_width: "1.5",
|
||||
d: "M24.4225 23.8963C23.6678 22.3507 22.4756 21.0445 20.9845 20.1298C19.4934 19.2151 17.7647 18.7295 15.9998 18.7295C14.2349 18.7295 12.5063 19.2151 11.0152 20.1298C9.52406 21.0445 8.33179 22.3507 7.57715 23.8963",
|
||||
stroke: "currentColor",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_3() -> Element {
|
||||
rsx! {
|
||||
svg { class: "h-2 w-2 text-gray-500 cursor-pointer",
|
||||
height: "10",
|
||||
width: "10",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
fill: "none",
|
||||
view_box: "0 0 10 10",
|
||||
path {
|
||||
stroke_width: "1.5",
|
||||
stroke_linejoin: "round",
|
||||
d: "M9.00002 1L1 9.00002M1.00003 1L9.00005 9.00002",
|
||||
stroke: "black",
|
||||
stroke_linecap: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_4() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
view_box: "0 0 20 12",
|
||||
fill: "none",
|
||||
width: "20",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
height: "12",
|
||||
path {
|
||||
d: "M1 2H19C19.2652 2 19.5196 1.89464 19.7071 1.70711C19.8946 1.51957 20 1.26522 20 1C20 0.734784 19.8946 0.48043 19.7071 0.292893C19.5196 0.105357 19.2652 0 19 0H1C0.734784 0 0.48043 0.105357 0.292893 0.292893C0.105357 0.48043 0 0.734784 0 1C0 1.26522 0.105357 1.51957 0.292893 1.70711C0.48043 1.89464 0.734784 2 1 2ZM19 10H1C0.734784 10 0.48043 10.1054 0.292893 10.2929C0.105357 10.4804 0 10.7348 0 11C0 11.2652 0.105357 11.5196 0.292893 11.7071C0.48043 11.8946 0.734784 12 1 12H19C19.2652 12 19.5196 11.8946 19.7071 11.7071C19.8946 11.5196 20 11.2652 20 11C20 10.7348 19.8946 10.4804 19.7071 10.2929C19.5196 10.1054 19.2652 10 19 10ZM19 5H1C0.734784 5 0.48043 5.10536 0.292893 5.29289C0.105357 5.48043 0 5.73478 0 6C0 6.26522 0.105357 6.51957 0.292893 6.70711C0.48043 6.89464 0.734784 7 1 7H19C19.2652 7 19.5196 6.89464 19.7071 6.70711C19.8946 6.51957 20 6.26522 20 6C20 5.73478 19.8946 5.48043 19.7071 5.29289C19.5196 5.10536 19.2652 5 19 5Z",
|
||||
fill: "#8594A5",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_5() -> Element {
|
||||
rsx! {
|
||||
svg { class: "mr-2",
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
width: "23",
|
||||
height: "23",
|
||||
view_box: "0 0 23 23",
|
||||
path {
|
||||
stroke_width: "1.5",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
d: "M18.1159 8.72461H2.50427C1.99709 8.72461 1.58594 9.12704 1.58594 9.62346V21.3085C1.58594 21.8049 1.99709 22.2074 2.50427 22.2074H18.1159C18.6231 22.2074 19.0342 21.8049 19.0342 21.3085V9.62346C19.0342 9.12704 18.6231 8.72461 18.1159 8.72461Z",
|
||||
stroke: "currentColor",
|
||||
}
|
||||
path {
|
||||
d: "M6.34473 6.34469V4.95676C6.34473 3.85246 6.76252 2.79338 7.5062 2.01252C8.24988 1.23165 9.25852 0.792969 10.3102 0.792969C11.362 0.792969 12.3706 1.23165 13.1143 2.01252C13.858 2.79338 14.2758 3.85246 14.2758 4.95676V6.34469",
|
||||
stroke_linejoin: "round",
|
||||
stroke_width: "1.5",
|
||||
stroke_linecap: "round",
|
||||
stroke: "currentColor",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_6() -> Element {
|
||||
rsx! {
|
||||
svg { class: "mr-3",
|
||||
height: "31",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
view_box: "0 0 32 31",
|
||||
width: "32",
|
||||
fill: "none",
|
||||
path {
|
||||
stroke: "currentColor",
|
||||
stroke_width: "1.5",
|
||||
d: "M16.0006 16.3154C19.1303 16.3154 21.6673 13.799 21.6673 10.6948C21.6673 7.59064 19.1303 5.07422 16.0006 5.07422C12.871 5.07422 10.334 7.59064 10.334 10.6948C10.334 13.799 12.871 16.3154 16.0006 16.3154Z",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
}
|
||||
path {
|
||||
stroke_linecap: "round",
|
||||
stroke_width: "1.5",
|
||||
stroke: "currentColor",
|
||||
stroke_linejoin: "round",
|
||||
d: "M24.4225 23.8963C23.6678 22.3507 22.4756 21.0445 20.9845 20.1298C19.4934 19.2151 17.7647 18.7295 15.9998 18.7295C14.2349 18.7295 12.5063 19.2151 11.0152 20.1298C9.52406 21.0445 8.33179 22.3507 7.57715 23.8963",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_7() -> Element {
|
||||
rsx! {
|
||||
svg { class: "mr-3",
|
||||
view_box: "0 0 23 23",
|
||||
fill: "none",
|
||||
height: "23",
|
||||
width: "23",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
stroke_linecap: "round",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "1.5",
|
||||
stroke_linejoin: "round",
|
||||
d: "M18.1159 8.72461H2.50427C1.99709 8.72461 1.58594 9.12704 1.58594 9.62346V21.3085C1.58594 21.8049 1.99709 22.2074 2.50427 22.2074H18.1159C18.6231 22.2074 19.0342 21.8049 19.0342 21.3085V9.62346C19.0342 9.12704 18.6231 8.72461 18.1159 8.72461Z",
|
||||
}
|
||||
path {
|
||||
d: "M6.34473 6.34469V4.95676C6.34473 3.85246 6.76252 2.79338 7.5062 2.01252C8.24988 1.23165 9.25852 0.792969 10.3102 0.792969C11.362 0.792969 12.3706 1.23165 13.1143 2.01252C13.858 2.79338 14.2758 3.85246 14.2758 4.95676V6.34469",
|
||||
stroke_width: "1.5",
|
||||
stroke_linecap: "round",
|
||||
stroke: "currentColor",
|
||||
stroke_linejoin: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_8() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
height: "20",
|
||||
width: "23",
|
||||
fill: "none",
|
||||
view_box: "0 0 23 20",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
d: "M11.4998 19.2061L2.70115 9.92527C1.92859 9.14433 1.41864 8.1374 1.24355 7.04712C1.06847 5.95684 1.23713 4.8385 1.72563 3.85053V3.85053C2.09464 3.10462 2.63366 2.45803 3.29828 1.96406C3.9629 1.47008 4.73408 1.14284 5.5483 1.00931C6.36252 0.875782 7.19647 0.939779 7.98144 1.19603C8.7664 1.45228 9.47991 1.89345 10.0632 2.48319L11.4998 3.93577L12.9364 2.48319C13.5197 1.89345 14.2332 1.45228 15.0182 1.19603C15.8031 0.939779 16.6371 0.875782 17.4513 1.00931C18.2655 1.14284 19.0367 1.47008 19.7013 1.96406C20.3659 2.45803 20.905 3.10462 21.274 3.85053V3.85053C21.7625 4.8385 21.9311 5.95684 21.756 7.04712C21.581 8.1374 21.071 9.14433 20.2984 9.92527L11.4998 19.2061Z",
|
||||
stroke_linejoin: "round",
|
||||
stroke: "currentColor",
|
||||
stroke_width: "1.5",
|
||||
stroke_linecap: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_9() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
view_box: "0 0 18 18",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
width: "18",
|
||||
height: "18",
|
||||
fill: "none",
|
||||
path {
|
||||
fill: "black",
|
||||
d: "M18 15.4688H0V17.7207H18V15.4688Z",
|
||||
}
|
||||
path {
|
||||
fill: "black",
|
||||
d: "M11.0226 7.87402H0V10.126H11.0226V7.87402Z",
|
||||
}
|
||||
path {
|
||||
fill: "black",
|
||||
d: "M18 0.279297H0V2.53127H18V0.279297Z",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::api::Product;
|
||||
|
||||
#[component]
|
||||
pub(crate) fn ProductItem(product: Product) -> Element {
|
||||
let Product {
|
||||
id,
|
||||
title,
|
||||
price,
|
||||
category,
|
||||
image,
|
||||
rating,
|
||||
..
|
||||
} = product;
|
||||
|
||||
rsx! {
|
||||
section { class: "h-40 p-2 m-2 shadow-lg ring-1 rounded-lg flex flex-row place-items-center hover:ring-4 hover:shadow-2xl transition-all duration-200",
|
||||
img {
|
||||
class: "object-scale-down w-1/6 h-full",
|
||||
src: "{image}",
|
||||
}
|
||||
div { class: "pl-4 text-left text-ellipsis",
|
||||
a {
|
||||
href: "/details/{id}",
|
||||
class: "w-full text-center",
|
||||
"{title}"
|
||||
}
|
||||
p {
|
||||
class: "w-full",
|
||||
"{rating}"
|
||||
}
|
||||
p {
|
||||
class: "w-full",
|
||||
"{category}"
|
||||
}
|
||||
p {
|
||||
class: "w-1/4",
|
||||
"${price}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,308 @@
|
||||
use std::{fmt::Display, str::FromStr};
|
||||
|
||||
use crate::api::{fetch_product, Product};
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[component]
|
||||
pub fn ProductPage(product_id: ReadSignal<usize>) -> Element {
|
||||
let mut quantity = use_signal(|| 1);
|
||||
let mut size = use_signal(Size::default);
|
||||
let product = use_loader(move || fetch_product(product_id()))?;
|
||||
|
||||
let Product {
|
||||
title,
|
||||
price,
|
||||
description,
|
||||
category,
|
||||
image,
|
||||
rating,
|
||||
..
|
||||
} = product();
|
||||
|
||||
rsx! {
|
||||
section { class: "py-20",
|
||||
div { class: "container mx-auto px-4",
|
||||
div { class: "flex flex-wrap -mx-4 mb-24",
|
||||
div { class: "w-full md:w-1/2 px-4 mb-8 md:mb-0",
|
||||
div { class: "relative mb-10",
|
||||
style: "height: 564px;",
|
||||
a { class: "absolute top-1/2 left-0 ml-8 transform translate-1/2",
|
||||
href: "#",
|
||||
icons::icon_0 {}
|
||||
}
|
||||
img { class: "object-cover w-full h-full",
|
||||
alt: "",
|
||||
src: "{image}",
|
||||
}
|
||||
a { class: "absolute top-1/2 right-0 mr-8 transform translate-1/2",
|
||||
href: "#",
|
||||
icons::icon_1 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "w-full md:w-1/2 px-4",
|
||||
div { class: "lg:pl-20",
|
||||
div { class: "mb-10 pb-10 border-b",
|
||||
h2 { class: "mt-2 mb-6 max-w-xl text-5xl md:text-6xl font-bold font-heading",
|
||||
"{title}"
|
||||
}
|
||||
div { class: "mb-8",
|
||||
"{rating}"
|
||||
}
|
||||
p { class: "inline-block mb-8 text-2xl font-bold font-heading text-blue-300",
|
||||
span {
|
||||
"${price}"
|
||||
}
|
||||
}
|
||||
p { class: "max-w-md text-gray-500",
|
||||
"{description}"
|
||||
}
|
||||
}
|
||||
div { class: "flex mb-12",
|
||||
div { class: "mr-6",
|
||||
span { class: "block mb-4 font-bold font-heading text-gray-400 uppercase",
|
||||
"QTY"
|
||||
}
|
||||
div { class: "inline-flex items-center px-4 font-semibold font-heading text-gray-500 border border-gray-200 focus:ring-blue-300 focus:border-blue-300 rounded-md",
|
||||
button { class: "py-2 hover:text-gray-700",
|
||||
onclick: move |_| quantity += 1,
|
||||
icons::icon_2 {}
|
||||
}
|
||||
input { class: "w-12 m-0 px-2 py-4 text-center md:text-right border-0 focus:ring-transparent focus:outline-hidden rounded-md",
|
||||
placeholder: "1",
|
||||
r#type: "number",
|
||||
value: "{quantity}",
|
||||
oninput: move |evt| if let Ok(as_number) = evt.value().parse() { quantity.set(as_number) },
|
||||
}
|
||||
button { class: "py-2 hover:text-gray-700",
|
||||
onclick: move |_| quantity -= 1,
|
||||
icons::icon_3 {}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
span { class: "block mb-4 font-bold font-heading text-gray-400 uppercase",
|
||||
"Size"
|
||||
}
|
||||
select { class: "pl-6 pr-10 py-4 font-semibold font-heading text-gray-500 border border-gray-200 focus:ring-blue-300 focus:border-blue-300 rounded-md",
|
||||
id: "",
|
||||
name: "",
|
||||
onchange: move |evt| {
|
||||
if let Ok(new_size) = evt.value().parse() {
|
||||
size.set(new_size);
|
||||
}
|
||||
},
|
||||
option {
|
||||
value: "1",
|
||||
"Medium"
|
||||
}
|
||||
option {
|
||||
value: "2",
|
||||
"Small"
|
||||
}
|
||||
option {
|
||||
value: "3",
|
||||
"Large"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "flex flex-wrap -mx-4 mb-14 items-center",
|
||||
div { class: "w-full xl:w-2/3 px-4 mb-4 xl:mb-0",
|
||||
a { class: "block bg-orange-300 hover:bg-orange-400 text-center text-white font-bold font-heading py-5 px-8 rounded-md uppercase transition duration-200",
|
||||
href: "#",
|
||||
"Add to cart"
|
||||
}
|
||||
}
|
||||
}
|
||||
div { class: "flex items-center",
|
||||
span { class: "mr-8 text-gray-500 font-bold font-heading uppercase",
|
||||
"SHARE IT"
|
||||
}
|
||||
a { class: "mr-1 w-8 h-8",
|
||||
href: "#",
|
||||
img {
|
||||
alt: "",
|
||||
src: "https://shuffle.dev/yofte-assets/buttons/facebook-circle.svg",
|
||||
}
|
||||
}
|
||||
a { class: "mr-1 w-8 h-8",
|
||||
href: "#",
|
||||
img {
|
||||
alt: "",
|
||||
src: "https://shuffle.dev/yofte-assets/buttons/instagram-circle.svg",
|
||||
}
|
||||
}
|
||||
a { class: "w-8 h-8",
|
||||
href: "#",
|
||||
img {
|
||||
src: "https://shuffle.dev/yofte-assets/buttons/twitter-circle.svg",
|
||||
alt: "",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
div {
|
||||
ul { class: "flex flex-wrap mb-16 border-b-2",
|
||||
li { class: "w-1/2 md:w-auto",
|
||||
a { class: "inline-block py-6 px-10 bg-white text-gray-500 font-bold font-heading shadow-2xl",
|
||||
href: "#",
|
||||
"Description"
|
||||
}
|
||||
}
|
||||
li { class: "w-1/2 md:w-auto",
|
||||
a { class: "inline-block py-6 px-10 text-gray-500 font-bold font-heading",
|
||||
href: "#",
|
||||
"Customer reviews"
|
||||
}
|
||||
}
|
||||
li { class: "w-1/2 md:w-auto",
|
||||
a { class: "inline-block py-6 px-10 text-gray-500 font-bold font-heading",
|
||||
href: "#",
|
||||
"Shipping & returns"
|
||||
}
|
||||
}
|
||||
li { class: "w-1/2 md:w-auto",
|
||||
a { class: "inline-block py-6 px-10 text-gray-500 font-bold font-heading",
|
||||
href: "#",
|
||||
"Brand"
|
||||
}
|
||||
}
|
||||
}
|
||||
h3 { class: "mb-8 text-3xl font-bold font-heading text-blue-300",
|
||||
"{category}"
|
||||
}
|
||||
p { class: "max-w-2xl text-gray-500",
|
||||
"{description}"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Default)]
|
||||
enum Size {
|
||||
Small,
|
||||
#[default]
|
||||
Medium,
|
||||
Large,
|
||||
}
|
||||
|
||||
impl Display for Size {
|
||||
fn fmt(&self, f: &mut std::fmt::Formatter<'_>) -> std::fmt::Result {
|
||||
match self {
|
||||
Size::Small => "small".fmt(f),
|
||||
Size::Medium => "medium".fmt(f),
|
||||
Size::Large => "large".fmt(f),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
impl FromStr for Size {
|
||||
type Err = ();
|
||||
|
||||
fn from_str(s: &str) -> Result<Self, Self::Err> {
|
||||
use Size::*;
|
||||
match s.to_lowercase().as_str() {
|
||||
"small" => Ok(Small),
|
||||
"medium" => Ok(Medium),
|
||||
"large" => Ok(Large),
|
||||
_ => Err(()),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
mod icons {
|
||||
use super::*;
|
||||
|
||||
pub(super) fn icon_0() -> Element {
|
||||
rsx! {
|
||||
svg { class: "w-6 h-6",
|
||||
view_box: "0 0 24 23",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
height: "23",
|
||||
fill: "none",
|
||||
width: "24",
|
||||
path {
|
||||
stroke: "black",
|
||||
fill: "black",
|
||||
d: "M2.01328 18.9877C2.05682 16.7902 2.71436 12.9275 6.3326 9.87096L6.33277 9.87116L6.33979 9.86454L6.3398 9.86452C6.34682 9.85809 8.64847 7.74859 13.4997 7.74859C13.6702 7.74859 13.8443 7.75111 14.0206 7.757L14.0213 7.75702L14.453 7.76978L14.6331 7.77511V7.59486V3.49068L21.5728 10.5736L14.6331 17.6562V13.6558V13.5186L14.4998 13.4859L14.1812 13.4077C14.1807 13.4075 14.1801 13.4074 14.1792 13.4072M2.01328 18.9877L14.1792 13.4072M2.01328 18.9877C7.16281 11.8391 14.012 13.3662 14.1792 13.4072M2.01328 18.9877L14.1792 13.4072M23.125 10.6961L23.245 10.5736L23.125 10.4512L13.7449 0.877527L13.4449 0.571334V1V6.5473C8.22585 6.54663 5.70981 8.81683 5.54923 8.96832C-0.317573 13.927 0.931279 20.8573 0.946581 20.938L0.946636 20.9383L1.15618 22.0329L1.24364 22.4898L1.47901 22.0885L2.041 21.1305L2.04103 21.1305C4.18034 17.4815 6.71668 15.7763 8.8873 15.0074C10.9246 14.2858 12.6517 14.385 13.4449 14.4935V20.1473V20.576L13.7449 20.2698L23.125 10.6961Z",
|
||||
stroke_width: "0.35",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_1() -> Element {
|
||||
rsx! {
|
||||
svg { class: "w-6 h-6",
|
||||
height: "27",
|
||||
view_box: "0 0 27 27",
|
||||
fill: "none",
|
||||
width: "27",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
d: "M13.4993 26.2061L4.70067 16.9253C3.9281 16.1443 3.41815 15.1374 3.24307 14.0471C3.06798 12.9568 3.23664 11.8385 3.72514 10.8505V10.8505C4.09415 10.1046 4.63318 9.45803 5.29779 8.96406C5.96241 8.47008 6.73359 8.14284 7.54782 8.00931C8.36204 7.87578 9.19599 7.93978 9.98095 8.19603C10.7659 8.45228 11.4794 8.89345 12.0627 9.48319L13.4993 10.9358L14.9359 9.48319C15.5192 8.89345 16.2327 8.45228 17.0177 8.19603C17.8026 7.93978 18.6366 7.87578 19.4508 8.00931C20.265 8.14284 21.0362 8.47008 21.7008 8.96406C22.3654 9.45803 22.9045 10.1046 23.2735 10.8505V10.8505C23.762 11.8385 23.9306 12.9568 23.7556 14.0471C23.5805 15.1374 23.0705 16.1443 22.298 16.9253L13.4993 26.2061Z",
|
||||
stroke: "black",
|
||||
stroke_width: "1.5",
|
||||
stroke_linecap: "round",
|
||||
stroke_linejoin: "round",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_2() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
view_box: "0 0 12 12",
|
||||
height: "12",
|
||||
width: "12",
|
||||
fill: "none",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
g {
|
||||
opacity: "0.35",
|
||||
rect {
|
||||
height: "12",
|
||||
x: "5",
|
||||
fill: "currentColor",
|
||||
width: "2",
|
||||
}
|
||||
rect {
|
||||
fill: "currentColor",
|
||||
width: "2",
|
||||
height: "12",
|
||||
x: "12",
|
||||
y: "5",
|
||||
transform: "rotate(90 12 5)",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub(super) fn icon_3() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
width: "12",
|
||||
fill: "none",
|
||||
view_box: "0 0 12 2",
|
||||
height: "2",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
g {
|
||||
opacity: "0.35",
|
||||
rect {
|
||||
transform: "rotate(90 12 0)",
|
||||
height: "12",
|
||||
fill: "currentColor",
|
||||
x: "12",
|
||||
width: "2",
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use components::home::Home;
|
||||
use components::loading::ChildrenOrLoading;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
mod components {
|
||||
pub mod error;
|
||||
pub mod home;
|
||||
pub mod loading;
|
||||
pub mod nav;
|
||||
pub mod product_item;
|
||||
pub mod product_page;
|
||||
}
|
||||
mod api;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(|| {
|
||||
rsx! {
|
||||
document::Link {
|
||||
rel: "stylesheet",
|
||||
href: asset!("/public/tailwind.css")
|
||||
}
|
||||
|
||||
ChildrenOrLoading {
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Routable, Debug, PartialEq)]
|
||||
enum Route {
|
||||
#[route("/")]
|
||||
Home {},
|
||||
|
||||
#[route("/details/:product_id")]
|
||||
Details { product_id: usize },
|
||||
}
|
||||
|
||||
#[component]
|
||||
/// Render a more sophisticated page with ssr
|
||||
fn Details(product_id: usize) -> Element {
|
||||
rsx! {
|
||||
div {
|
||||
components::nav::Nav {}
|
||||
components::product_page::ProductPage {
|
||||
product_id
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
@import "tailwindcss";
|
||||
@source "./src/**/*.{rs,html,css}";
|
||||
@@ -0,0 +1,13 @@
|
||||
# Generated by Cargo
|
||||
# will have compiled files and executables
|
||||
/target/
|
||||
/dist/
|
||||
/static/
|
||||
/.dioxus/
|
||||
|
||||
# Remove Cargo.lock from gitignore if creating an executable, leave it for libraries
|
||||
# More information here https://doc.rust-lang.org/cargo/guide/cargo-toml-vs-cargo-lock.html
|
||||
Cargo.lock
|
||||
|
||||
# These are backup files generated by rustfmt
|
||||
**/*.rs.bk
|
||||
@@ -0,0 +1,11 @@
|
||||
[package]
|
||||
name = "file-explorer"
|
||||
edition = "2021"
|
||||
version = "0.1.0"
|
||||
publish = false
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = ["desktop"] }
|
||||
open = { workspace = true }
|
||||
@@ -0,0 +1,43 @@
|
||||
[application]
|
||||
|
||||
# App (Project) Name
|
||||
name = "file-explorer"
|
||||
|
||||
# Dioxus App Default Platform
|
||||
# desktop, web
|
||||
default_platform = "desktop"
|
||||
|
||||
# `build` & `serve` dist path
|
||||
out_dir = "dist"
|
||||
|
||||
# assets file folder
|
||||
asset_dir = "assets"
|
||||
|
||||
[web.app]
|
||||
|
||||
# HTML title tag content
|
||||
title = "file-explorer"
|
||||
|
||||
[web.watcher]
|
||||
|
||||
# when watcher trigger, regenerate the `index.html`
|
||||
reload_html = true
|
||||
|
||||
# which files or dirs will be watcher monitoring
|
||||
watch_path = ["src", "assets"]
|
||||
|
||||
# include `assets` in web platform
|
||||
[web.resource]
|
||||
|
||||
# CSS style file
|
||||
|
||||
style = []
|
||||
|
||||
# Javascript code file
|
||||
script = []
|
||||
|
||||
[web.resource.dev]
|
||||
|
||||
# Javascript code file
|
||||
# serve: [dev-server] only
|
||||
script = []
|
||||
@@ -0,0 +1,13 @@
|
||||
# File-explorer with Rust and Dioxus
|
||||
|
||||
This example shows how a Dioxus App can directly leverage system calls and libraries to bridge native functionality with the WebView renderer.
|
||||
|
||||

|
||||
|
||||
|
||||
## To run this example:
|
||||
|
||||
```
|
||||
dx serve
|
||||
```
|
||||
|
||||
@@ -0,0 +1,180 @@
|
||||
* {
|
||||
margin: 0;
|
||||
padding: 0;
|
||||
font-family: 'Roboto', sans-serif;
|
||||
user-select: none;
|
||||
transition: .2s all;
|
||||
}
|
||||
|
||||
body {
|
||||
padding-top: 77px;
|
||||
}
|
||||
|
||||
|
||||
/* header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
z-index: 10;
|
||||
padding: 20px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
}
|
||||
header h1 {
|
||||
float: left;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
header .material-icons {
|
||||
float: right;
|
||||
cursor: pointer;
|
||||
}
|
||||
header .icon-menu {
|
||||
float: left;
|
||||
margin-right: 20px;
|
||||
} */
|
||||
|
||||
main {
|
||||
padding: 20px 50px;
|
||||
}
|
||||
|
||||
.folder * {
|
||||
width: 100px;
|
||||
}
|
||||
|
||||
.folder {
|
||||
float: left;
|
||||
width: 100px;
|
||||
height: 152px;
|
||||
/* //padding: 20px; */
|
||||
margin-right: 50px;
|
||||
margin-bottom: 70px;
|
||||
border-radius: 2px;
|
||||
/* //overflow: hidden; */
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
.folder:hover h1 {
|
||||
display: none;
|
||||
}
|
||||
|
||||
.folder:hover p.cooltip {
|
||||
opacity: 1;
|
||||
top: 0;
|
||||
}
|
||||
|
||||
.folder * {
|
||||
text-align: center;
|
||||
}
|
||||
|
||||
.folder i {
|
||||
margin: 0;
|
||||
font-size: 100px;
|
||||
color: #607D8B;
|
||||
}
|
||||
|
||||
.folder h1 {
|
||||
position: relative;
|
||||
display: block;
|
||||
top: -37px;
|
||||
font-size: 20px;
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
.folder p.cooltip {
|
||||
position: relative;
|
||||
top: 5px;
|
||||
left: -50%;
|
||||
margin-left: 35px;
|
||||
background: #212121;
|
||||
font-size: 15px;
|
||||
color: white;
|
||||
border-radius: 4px;
|
||||
padding: 10px 20px;
|
||||
padding-right: 30px;
|
||||
width: 100px;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.folder p.cooltip:before {
|
||||
content: '';
|
||||
position: absolute;
|
||||
display: block;
|
||||
top: -4px;
|
||||
left: 50%;
|
||||
margin-left: -5px;
|
||||
height: 10px;
|
||||
width: 10px;
|
||||
border-radius: 2px;
|
||||
background-color: #212121;
|
||||
transform: rotate(45deg);
|
||||
}
|
||||
|
||||
div.properties {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
right: 0;
|
||||
bottom: 0;
|
||||
z-index: 10;
|
||||
width: 300px;
|
||||
background-color: white;
|
||||
}
|
||||
|
||||
div.properties:before {
|
||||
content: '';
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 300px;
|
||||
bottom: 0;
|
||||
background-color: #212121;
|
||||
opacity: .5;
|
||||
overflow: hidden;
|
||||
}
|
||||
|
||||
div.properties img {
|
||||
position: relative;
|
||||
top: -1px;
|
||||
left: -1px;
|
||||
width: 110%;
|
||||
height: 200px;
|
||||
filter: blur(2px);
|
||||
}
|
||||
|
||||
div.properties h1 {
|
||||
position: relative;
|
||||
width: 100%;
|
||||
text-align: left;
|
||||
margin-left: 20px;
|
||||
color: white;
|
||||
}
|
||||
|
||||
header {
|
||||
position: fixed;
|
||||
top: 0;
|
||||
left: 0;
|
||||
right: 0;
|
||||
padding: 20px;
|
||||
background-color: #2196F3;
|
||||
color: white;
|
||||
display: flex;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
header h1 {
|
||||
font-weight: 400;
|
||||
}
|
||||
|
||||
header span {
|
||||
flex: 1;
|
||||
}
|
||||
|
||||
header i {
|
||||
margin: 0 10px;
|
||||
cursor: pointer;
|
||||
}
|
||||
|
||||
header i:nth-child(1) {
|
||||
margin: 0 20px;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 44 KiB |
@@ -0,0 +1,129 @@
|
||||
//! Example: File Explorer
|
||||
//!
|
||||
//! This is a fun little desktop application that lets you explore the file system.
|
||||
//!
|
||||
//! This example is interesting because it's mixing filesystem operations and GUI, which is typically hard for UI to do.
|
||||
//! We store the state entirely in a single signal, making the explorer logic fairly easy to reason about.
|
||||
|
||||
use std::env::current_dir;
|
||||
use std::path::PathBuf;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut files = use_signal(Files::new);
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/assets/fileexplorer.css") }
|
||||
Stylesheet { href: "https://fonts.googleapis.com/icon?family=Material+Icons" }
|
||||
div {
|
||||
header {
|
||||
i { class: "material-icons icon-menu", "menu" }
|
||||
h1 { "Files: " {files.read().current()} }
|
||||
span { }
|
||||
i { class: "material-icons", onclick: move |_| files.write().go_up(), "logout" }
|
||||
}
|
||||
main {
|
||||
for (dir_id, path) in files.read().path_names.iter().enumerate() {
|
||||
{
|
||||
let path_end = path.components().next_back().map(|p|p.as_os_str()).unwrap_or(path.as_os_str()).to_string_lossy();
|
||||
let path = path.display();
|
||||
rsx! {
|
||||
div { class: "folder", key: "{path}",
|
||||
i { class: "material-icons",
|
||||
onclick: move |_| files.write().enter_dir(dir_id),
|
||||
if path_end.contains('.') {
|
||||
"description"
|
||||
} else {
|
||||
"folder"
|
||||
}
|
||||
p { class: "cooltip", "0 folders / 0 files" }
|
||||
}
|
||||
h1 { "{path_end}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if let Some(err) = files.read().err.as_ref() {
|
||||
div {
|
||||
code { "{err}" }
|
||||
button { onclick: move |_| files.write().clear_err(), "x" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A simple little struct to hold the file explorer state
|
||||
///
|
||||
/// We don't use any fancy signals or memoization here - Dioxus is so fast that even a file explorer can be done with a
|
||||
/// single signal.
|
||||
struct Files {
|
||||
current_path: PathBuf,
|
||||
path_names: Vec<PathBuf>,
|
||||
err: Option<String>,
|
||||
}
|
||||
|
||||
impl Files {
|
||||
fn new() -> Self {
|
||||
let mut files = Self {
|
||||
current_path: std::path::absolute(current_dir().unwrap()).unwrap(),
|
||||
path_names: vec![],
|
||||
err: None,
|
||||
};
|
||||
|
||||
files.reload_path_list();
|
||||
|
||||
files
|
||||
}
|
||||
|
||||
fn reload_path_list(&mut self) {
|
||||
let paths = match std::fs::read_dir(&self.current_path) {
|
||||
Ok(e) => e,
|
||||
Err(err) => {
|
||||
let err = format!("An error occurred: {err:?}");
|
||||
self.err = Some(err);
|
||||
return;
|
||||
}
|
||||
};
|
||||
let collected = paths.collect::<Vec<_>>();
|
||||
|
||||
// clear the current state
|
||||
self.clear_err();
|
||||
self.path_names.clear();
|
||||
|
||||
for path in collected {
|
||||
self.path_names.push(path.unwrap().path().to_path_buf());
|
||||
}
|
||||
}
|
||||
|
||||
fn go_up(&mut self) {
|
||||
self.current_path = match self.current_path.parent() {
|
||||
Some(path) => path.to_path_buf(),
|
||||
None => {
|
||||
self.err = Some("Cannot go up from the root directory".to_string());
|
||||
return;
|
||||
}
|
||||
};
|
||||
self.reload_path_list();
|
||||
}
|
||||
|
||||
fn enter_dir(&mut self, dir_id: usize) {
|
||||
let path = &self.path_names[dir_id];
|
||||
self.current_path.clone_from(path);
|
||||
self.reload_path_list();
|
||||
}
|
||||
|
||||
fn current(&self) -> String {
|
||||
self.current_path.display().to_string()
|
||||
}
|
||||
|
||||
fn clear_err(&mut self) {
|
||||
self.err = None;
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,2 @@
|
||||
/static
|
||||
/dist
|
||||
@@ -0,0 +1,19 @@
|
||||
[package]
|
||||
name = "fullstack-hackernews-example"
|
||||
version = "0.1.0"
|
||||
authors = ["Evan Almloff <evanalmloff@gmail.com>"]
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = ["fullstack", "router"] }
|
||||
chrono = { workspace = true, features = ["serde"] }
|
||||
reqwest = { workspace= true, features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
|
||||
[features]
|
||||
default = []
|
||||
server = ["dioxus/server"]
|
||||
web = ["dioxus/web"]
|
||||
@@ -0,0 +1,17 @@
|
||||
@keyframes spin {
|
||||
0% {
|
||||
transform: rotate(0deg);
|
||||
}
|
||||
|
||||
100% {
|
||||
transform: rotate(360deg);
|
||||
}
|
||||
}
|
||||
.spinner {
|
||||
width: 10px;
|
||||
height: 10px;
|
||||
border: 4px solid #f3f3f3;
|
||||
border-top: 4px solid #3498db;
|
||||
border-radius: 50%;
|
||||
animation: spin 2s linear infinite;
|
||||
}
|
||||
@@ -0,0 +1,249 @@
|
||||
#![allow(non_snake_case, unused)]
|
||||
use dioxus::prelude::*;
|
||||
// Define the Hackernews API and types
|
||||
use chrono::{DateTime, Utc};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::{
|
||||
fmt::{Display, Formatter},
|
||||
num::ParseIntError,
|
||||
str::FromStr,
|
||||
};
|
||||
use svg_attributes::to;
|
||||
|
||||
fn main() {
|
||||
LaunchBuilder::new()
|
||||
.with_cfg(server_only! {
|
||||
dioxus::server::ServeConfig::builder().enable_out_of_order_streaming()
|
||||
})
|
||||
.launch(|| {
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/assets/hackernews.css") }
|
||||
Router::<Route> {}
|
||||
}
|
||||
});
|
||||
}
|
||||
|
||||
#[derive(Clone, Routable)]
|
||||
enum Route {
|
||||
#[route("/story&:story")]
|
||||
StoryPreview { story: Option<i64> },
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn StoryPreview(story: ReadSignal<Option<i64>>) -> Element {
|
||||
rsx! {
|
||||
div { display: "flex", flex_direction: "row", width: "100%",
|
||||
div { width: "50%",
|
||||
SuspenseBoundary { fallback: |context| rsx! { "Loading..." },
|
||||
Stories {}
|
||||
}
|
||||
}
|
||||
div { width: "50%",
|
||||
SuspenseBoundary { fallback: |context| rsx! { "Loading preview..." },
|
||||
if let Some(story) = story() {
|
||||
Preview { story_id: story }
|
||||
} else {
|
||||
div { padding: "0.5rem", "Select a story to preview" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Stories() -> Element {
|
||||
let stories = use_loader(move || async move {
|
||||
let stories_ids = reqwest::get(&format!("{}topstories.json", BASE_API_URL))
|
||||
.await?
|
||||
.json::<Vec<i64>>()
|
||||
.await?
|
||||
.into_iter()
|
||||
.take(30)
|
||||
.collect::<Vec<i64>>();
|
||||
dioxus::Ok(stories_ids)
|
||||
})?;
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
for story in stories() {
|
||||
ChildrenOrLoading { key: "{story}",
|
||||
StoryListing { story }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn StoryListing(story: ReadSignal<i64>) -> Element {
|
||||
let story = use_loader(move || get_story(story()))?;
|
||||
let StoryItem {
|
||||
title,
|
||||
url,
|
||||
by,
|
||||
score,
|
||||
time,
|
||||
kids,
|
||||
id,
|
||||
..
|
||||
} = story().item;
|
||||
|
||||
let url = url.as_deref().unwrap_or_default();
|
||||
let hostname = url
|
||||
.trim_start_matches("https://")
|
||||
.trim_start_matches("http://")
|
||||
.trim_start_matches("www.");
|
||||
let score = format!("{score} {}", if score == 1 { " point" } else { " points" });
|
||||
let comments = format!(
|
||||
"{} {}",
|
||||
kids.len(),
|
||||
if kids.len() == 1 {
|
||||
" comment"
|
||||
} else {
|
||||
" comments"
|
||||
}
|
||||
);
|
||||
let time = time.format("%D %l:%M %p");
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
padding: "0.5rem",
|
||||
position: "relative",
|
||||
div { font_size: "1.5rem",
|
||||
Link {
|
||||
to: Route::StoryPreview { story: Some(id) },
|
||||
"{title}"
|
||||
}
|
||||
a {
|
||||
color: "gray",
|
||||
href: "https://news.ycombinator.com/from?site={hostname}",
|
||||
text_decoration: "none",
|
||||
" ({hostname})"
|
||||
}
|
||||
}
|
||||
div { display: "flex", flex_direction: "row", color: "gray",
|
||||
div { "{score}" }
|
||||
div { padding_left: "0.5rem", "by {by}" }
|
||||
div { padding_left: "0.5rem", "{time}" }
|
||||
div { padding_left: "0.5rem", "{comments}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Preview(story_id: ReadSignal<i64>) -> Element {
|
||||
let story = use_loader(move || get_story(story_id()))?.cloned();
|
||||
rsx! {
|
||||
div { padding: "0.5rem",
|
||||
div { font_size: "1.5rem", a { href: story.item.url, "{story.item.title}" } }
|
||||
if let Some(text) = &story.item.text { div { dangerous_inner_html: "{text}" } }
|
||||
for comment in story.item.kids.iter().copied() {
|
||||
ChildrenOrLoading {
|
||||
key: "{comment}",
|
||||
Comment { comment }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn Comment(comment: ReadSignal<i64>) -> Element {
|
||||
let comment = use_loader(move || async move {
|
||||
let mut comment = reqwest::get(&format!("{}{}{}.json", BASE_API_URL, ITEM_API, comment))
|
||||
.await?
|
||||
.json::<CommentData>()
|
||||
.await?;
|
||||
dioxus::Ok(comment)
|
||||
})?;
|
||||
|
||||
let CommentData {
|
||||
by,
|
||||
time,
|
||||
text,
|
||||
id,
|
||||
kids,
|
||||
..
|
||||
} = comment();
|
||||
|
||||
rsx! {
|
||||
div { padding: "0.5rem",
|
||||
div { color: "gray", "by {by}" }
|
||||
div { dangerous_inner_html: "{text}" }
|
||||
for comment in kids.iter().copied() {
|
||||
ChildrenOrLoading {
|
||||
key: "{comment}",
|
||||
Comment { comment }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
pub static BASE_API_URL: &str = "https://hacker-news.firebaseio.com/v0/";
|
||||
pub static ITEM_API: &str = "item/";
|
||||
pub static USER_API: &str = "user/";
|
||||
const COMMENT_DEPTH: i64 = 1;
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StoryPageData {
|
||||
#[serde(flatten)]
|
||||
pub item: StoryItem,
|
||||
#[serde(default)]
|
||||
pub comments: Vec<CommentData>,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct CommentData {
|
||||
pub id: i64,
|
||||
/// there will be no by field if the comment was deleted
|
||||
#[serde(default)]
|
||||
pub by: String,
|
||||
#[serde(default)]
|
||||
pub text: String,
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub time: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub kids: Vec<i64>,
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
#[derive(Clone, Debug, PartialEq, Serialize, Deserialize)]
|
||||
pub struct StoryItem {
|
||||
pub id: i64,
|
||||
pub title: String,
|
||||
pub url: Option<String>,
|
||||
pub text: Option<String>,
|
||||
#[serde(default)]
|
||||
pub by: String,
|
||||
#[serde(default)]
|
||||
pub score: i64,
|
||||
#[serde(default)]
|
||||
pub descendants: i64,
|
||||
#[serde(with = "chrono::serde::ts_seconds")]
|
||||
pub time: DateTime<Utc>,
|
||||
#[serde(default)]
|
||||
pub kids: Vec<i64>,
|
||||
pub r#type: String,
|
||||
}
|
||||
|
||||
pub async fn get_story(id: i64) -> Result<StoryPageData> {
|
||||
Ok(
|
||||
reqwest::get(&format!("{}{}{}.json", BASE_API_URL, ITEM_API, id))
|
||||
.await?
|
||||
.json::<StoryPageData>()
|
||||
.await?,
|
||||
)
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ChildrenOrLoading(children: Element) -> Element {
|
||||
rsx! {
|
||||
SuspenseBoundary {
|
||||
fallback: |_| rsx! { div { class: "spinner", } },
|
||||
children
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! The simplest example of a Dioxus app.
|
||||
//!
|
||||
//! In this example we:
|
||||
//! - import a number of important items from the prelude (launch, Element, rsx, div, etc.)
|
||||
//! - define a main function that calls the launch function with our app function
|
||||
//! - define an app function that returns a div element with the text "Hello, world!"
|
||||
//!
|
||||
//! The `launch` function is the entry point for all Dioxus apps. It takes a function that returns an Element. This function
|
||||
//! calls "launch" on the currently-configured renderer you have. So if the `web` feature is enabled, it will launch a web
|
||||
//! app, and if the `desktop` feature is enabled, it will launch a desktop app.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
rsx! {
|
||||
div { "Hello, world!" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,21 @@
|
||||
[package]
|
||||
name = "hotdog"
|
||||
version = "0.1.0"
|
||||
authors = ["Dioxus Labs"]
|
||||
edition = "2021"
|
||||
publish = false
|
||||
|
||||
[dependencies]
|
||||
dioxus = { workspace = true, features = ["fullstack", "router"] }
|
||||
reqwest = { workspace = true, features = ["json"] }
|
||||
serde = { workspace = true, features = ["derive"] }
|
||||
serde_json = { workspace = true }
|
||||
rusqlite = { version = "0.32.0", optional = true }
|
||||
anyhow = { workspace = true }
|
||||
|
||||
[features]
|
||||
default = ["web", "server"]
|
||||
web = ["dioxus/web"]
|
||||
desktop = ["dioxus/desktop"]
|
||||
mobile = ["dioxus/mobile"]
|
||||
server = ["dioxus/server", "dep:rusqlite"]
|
||||
@@ -0,0 +1,8 @@
|
||||
[application]
|
||||
|
||||
# App (Project) Name
|
||||
name = "hot_dog"
|
||||
|
||||
[bundle]
|
||||
identifier = "com.dioxuslabs"
|
||||
publisher = "Dioxus Labs"
|
||||
@@ -0,0 +1,24 @@
|
||||
FROM rust:1 AS chef
|
||||
RUN cargo install cargo-chef
|
||||
WORKDIR /app
|
||||
|
||||
FROM chef AS planner
|
||||
COPY . .
|
||||
RUN cargo chef prepare --recipe-path recipe.json
|
||||
|
||||
FROM chef AS builder
|
||||
COPY --from=planner /app/recipe.json recipe.json
|
||||
RUN cargo chef cook --release --recipe-path recipe.json
|
||||
COPY . .
|
||||
RUN curl -L --proto '=https' --tlsv1.2 -sSf https://raw.githubusercontent.com/DioxusLabs/dioxus/refs/heads/main/.github/install.sh | bash
|
||||
RUN /.cargo/bin/dx bundle --platform web
|
||||
|
||||
FROM chef AS runtime
|
||||
COPY --from=builder /app/target/dx/hotdog/release/web/ /usr/local/app
|
||||
|
||||
ENV PORT=8080
|
||||
ENV IP=0.0.0.0
|
||||
EXPOSE 8080
|
||||
|
||||
WORKDIR /usr/local/app
|
||||
ENTRYPOINT [ "/usr/local/app/server" ]
|
||||
@@ -0,0 +1,16 @@
|
||||
# Hot diggity dog!
|
||||
|
||||
A Dioxus demo app for the new tutorial!
|
||||
|
||||

|
||||
|
||||
## To run
|
||||
|
||||
Make sure you cd to this directory (dioxus/hotdog) and then `serve` any platform:
|
||||
|
||||
```rust
|
||||
dx serve --platform web
|
||||
dx serve --platform desktop
|
||||
dx serve --platform ios
|
||||
dx serve --platform android
|
||||
```
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
@@ -0,0 +1,20 @@
|
||||
<svg xmlns="http://www.w3.org/2000/svg" xmlns:xlink="http://www.w3.org/1999/xlink" viewBox="0 0 1007 197"><style>
|
||||
@keyframes a0_t { 0% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 35% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); } 50% { transform: translate(225px,339.9px) scale(1,1) translate(-89.5px,-24px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } 100% { transform: translate(225px,339.9px) scale(0,1) translate(-89.5px,-24px); } }
|
||||
@keyframes a1_t { 0% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 20% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 35% { transform: translate(225px,348.9px) scale(1,1) translate(-41.5px,-11px); } 45% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } 100% { transform: translate(225px,348.9px) scale(0,1) translate(-41.5px,-11px); } }
|
||||
@keyframes a3_t { 0% { transform: translate(225px,198.9px) rotate(-90deg); } 10% { transform: translate(225px,198.9px) rotate(-90deg); } 20% { transform: translate(225px,198.9px) rotate(-90deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) rotate(0deg); } 35% { transform: translate(225px,198.9px) rotate(0deg); } 45% { transform: translate(225px,198.9px) rotate(0deg); } 50% { transform: translate(225px,198.9px) rotate(0deg); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(225px,198.9px) rotate(-90deg); } 65% { transform: translate(225px,198.9px) rotate(-90deg); } 75% { transform: translate(225px,198.9px) rotate(-90deg); } 100% { transform: translate(225px,198.9px) rotate(-90deg); } }
|
||||
@keyframes a2_t { 0% { transform: translate(-200px,-167px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(-150px,-80px); } 20% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(-200px,-123px); } 35% { transform: translate(-200px,-123px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { transform: translate(-150px,-100px); } 50% { transform: translate(-150px,-100px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(-150px,-80px); } 65% { transform: translate(-150px,-80px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { transform: translate(-217px,-200px); } 100% { transform: translate(-217px,-200px); } }
|
||||
@keyframes a2_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a2_sw { 0% { stroke-width: 30px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { stroke-width: 8px; } 65% { stroke-width: 8px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { stroke-width: 30px; } 100% { stroke-width: 30px; } }
|
||||
@keyframes a2_w { 0% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 300px; } 20% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { width: 400px; } 35% { width: 400px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { width: 300px; } 50% { width: 300px; } 60% { width: 300px; } 65% { width: 300px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { width: 434px; } 100% { width: 434px; } }
|
||||
@keyframes a2_h { 0% { height: 334px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { height: 160px; } 20% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { height: 246px; } 35% { height: 246px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 45% { height: 200px; } 50% { height: 200px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { height: 160px; } 65% { height: 160px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { height: 400px; } 100% { height: 400px; } }
|
||||
@keyframes a4_t { 0% { transform: translate(143.5px,48.9px); } 10% { transform: translate(180.5px,98.9px); } 100% { transform: translate(180.5px,98.9px); } }
|
||||
@keyframes a4_w { 0% { width: 168.5px; } 10% { width: 94.5px; } 100% { width: 94.5px; } }
|
||||
@keyframes a4_h { 0% { height: 302px; } 10% { height: 202px; } 100% { height: 202px; } }
|
||||
@keyframes a5_t { 0% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(190px,324.9px); } 20% { transform: translate(190px,324.9px); } 30% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
|
||||
@keyframes a5_o { 0% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { opacity: 1; } 20% { opacity: 1; } 30% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a5_w { 0% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { width: 70px; } 20% { width: 70px; } 30% { width: 70px; } 100% { width: 70px; } }
|
||||
@keyframes a6_t { 0% { transform: translate(217px,324.9px); } 50% { transform: translate(217px,324.9px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { transform: translate(190px,324.9px); } 65% { transform: translate(190px,324.9px); } 75% { transform: translate(190px,324.9px); } 100% { transform: translate(190px,324.9px); } }
|
||||
@keyframes a6_o { 0% { opacity: 0; } 50% { opacity: 0; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { opacity: 1; } 65% { opacity: 1; animation-timing-function: cubic-bezier(.6,0,.4,1); } 75% { opacity: 0; } 100% { opacity: 0; } }
|
||||
@keyframes a6_w { 0% { width: 16px; } 50% { width: 16px; animation-timing-function: cubic-bezier(.6,0,.4,1); } 60% { width: 70px; } 65% { width: 70px; } 75% { width: 70px; } 100% { width: 70px; } }
|
||||
@keyframes a7_t { 0% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 10% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 20% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 25% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.6,0,.4,1); } 30% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 35% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 45% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); } 50% { transform: translate(225px,198.9px) scale(.8,.8) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 55% { transform: translate(225px,198.9px) scale(.7,.7) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 60% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); } 65% { transform: translate(225px,198.9px) scale(1,1) translate(-42.3px,-93px); animation-timing-function: cubic-bezier(.4,0,.6,1); } 75% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } 100% { transform: translate(225px,198.9px) scale(1.5,1.5) translate(-42.3px,-93px); } }
|
||||
</style><defs><symbol id="Symbol-2" preserveAspectRatio="none" width="111.5" height="186" viewBox="0 0 111.5 186" overflow="visible"><g transform="translate(8.5,4.5)"><rect x="4" width="85" height="8" fill="#d9d9d9"/><rect x="4" y="26" width="85" height="8" fill="#d9d9d9"/><rect x="25" y="56" width="43" height="8" fill="#d9d9d9"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,175) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="85" height="8" fill="#d9d9d9" transform="translate(46.5,149) rotate(180) scale(-1,1) translate(-42.5,-4)"/><rect width="43" height="8" fill="#d9d9d9" transform="translate(46.5,119) rotate(180) scale(-1,1) translate(-21.5,-4)"/><path d="M0 4c0 53 94.5 115 94.5 169" stroke="#3cc4dc" stroke-width="17" stroke-linecap="square" fill="none"/><path d="M94.5 4c0 53-94.5 115-94.5 169" stroke="#fb422d" stroke-width="17" stroke-linecap="square" fill="none"/></g></symbol><symbol id="Symbol-5" preserveAspectRatio="none" width="84.5" height="186" viewBox="0 0 84.5 186" overflow="visible"><g transform="translate(42.3,93) scale(5.352,6.9) translate(-16,60)"><g transform="translate(-34.9,-125.8)"><g transform="translate(24,5)"><path d="M33 46.5c0 3.1-0.8 5.5-2.2 7.4c-1.4 1.9-3.3 3.6-5.4 5.4c-2.2 1.7-4.5 3.6-6.3 6.2c-1.8 2.5-3.1 5.5-3.1 9.5h4.7c0-3.1 .9-5.4 2.2-7.3c1.4-1.9 3.3-3.5 5.5-5.3c2.1-1.8 4.4-3.7 6.2-6.2c1.8-2.6 3.1-5.6 3.1-9.7Z" fill="#e96020"/><path d="M20.4 70.7c-0.6 0-1 .5-1 1.1c0 .7 .4 1.2 1 1.2h12.9c.7 0 1.2-0.5 1.2-1.2c0-0.6-0.5-1.1-1.2-1.1Z" fill="#2d323b"/><path d="M21.8 66.6c-0.7 0-1 .6-1 1.2c0 .6 .3 1 .9 1h10.3c.6 0 1.1-0.5 1.1-1.1c0-0.6-0.5-1.1-1.1-1.1Z" fill="#2d323b"/><path d="M21.8 53c-0.7 0-1.2 .5-1.1 1.1c0 .6 .4 1.1 1.1 1.1h10.2c.6 0 .9-0.6 .9-1.2c0-0.6-0.3-1-0.9-1Z" fill="#2d323b"/><path d="M20.4 48.8c-0.6 0-1.2 .5-1.2 1.1c0 .7 .6 1.2 1.2 1.2h12.9c.7 0 1-0.5 1-1.1c0-0.7-0.3-1.2-1-1.2Z" fill="#2d323b"/><path d="M16 46.5c0 4.1 1.3 7.1 3.1 9.7c1.8 2.5 4.1 4.4 6.3 6.2c2.1 1.8 4 3.6 5.4 5.5c1.4 1.9 2.2 4 2.2 7.1h4.7c0-4-1.3-7.1-3.1-9.6c-1.8-2.5-4.1-4.4-6.2-6.2c-2.2-1.8-4.1-3.4-5.5-5.3c-1.3-1.9-2.2-4.3-2.2-7.4Z" fill="#00a8d6"/></g></g></g></symbol></defs><rect width="1007" height="197" stroke="#0f1116" fill="#0f1116" stroke-width="0"/><path fill="#fff" d="M83.3 106.1v-65.7h17c4.2 0 7.9 .8 11 2.4c3.1 1.6 5.5 3.9 7.2 6.9c1.7 2.9 2.6 6.4 2.6 10.4v26.2c0 4-0.9 7.5-2.6 10.5c-1.7 3-4.1 5.3-7.2 6.9c-3.1 1.6-6.8 2.4-11 2.4Zm8.1-7.2h8.9c4 0 7.1-1.1 9.3-3.3c2.3-2.3 3.4-5.3 3.4-9.3v-26.2c0-3.9-1.1-7-3.4-9.2c-2.2-2.2-5.3-3.3-9.3-3.3h-8.9Zm45.3 7.2v-7.4h17.5v-34.7h-15.7v-7.4h23.8v42.1h16.7v7.4Zm20.7-58.4c-2 0-3.6-0.5-4.7-1.5c-1.2-1.1-1.7-2.4-1.7-4.2c0-1.8 .5-3.2 1.7-4.3c1.1-1 2.7-1.5 4.7-1.5c1.9 0 3.5 .5 4.6 1.5c1.2 1.1 1.7 2.5 1.7 4.3c0 1.8-0.5 3.1-1.7 4.2c-1.1 1-2.7 1.5-4.6 1.5Zm52.6 59.1c-6 0-10.7-1.7-14.2-5.1c-3.4-3.4-5.1-8-5.1-13.8v-13.1c0-5.9 1.7-10.5 5.1-13.9c3.5-3.3 8.2-5 14.2-5c6 0 10.7 1.7 14.2 5c3.4 3.4 5.2 8 5.2 13.8v13.2c0 5.8-1.8 10.4-5.2 13.8c-3.5 3.4-8.2 5.1-14.2 5.1Zm0-7.2c3.5 0 6.3-1 8.3-3c2-1.9 3-4.8 3-8.7v-13.1c0-3.9-1-6.8-3-8.8c-2-1.9-4.8-2.9-8.3-2.9c-3.5 0-6.2 1-8.2 2.9c-2 2-3 4.9-3 8.8v13.1c0 3.9 1 6.8 3 8.7c2 2 4.7 3 8.2 3Zm31.5 6.5l17.6-25.5l-16.5-24h9.5l9.8 15.3c.5 .7 .9 1.4 1.2 2.2c.4 .7 .8 1.4 1 1.8c.2-0.4 .5-1.1 .8-1.8c.4-0.8 .8-1.5 1.3-2.2l9.9-15.3h9.4l-16.5 24.1l17.5 25.4h-9.5l-10.7-16.2c-0.4-0.7-0.8-1.4-1.2-2.3c-0.4-0.9-0.7-1.6-1-2.1c-0.2 .5-0.6 1.2-1 2.1c-0.5 .9-1 1.6-1.4 2.3l-10.7 16.2Zm76.4 .9c-5.7 0-10.2-1.7-13.7-5.1c-3.4-3.4-5.1-8-5.1-13.8v-31.5h8.1v31.5c0 3.7 1 6.6 2.9 8.7c1.9 2.1 4.5 3.1 7.8 3.1c3.4 0 6-1 8-3.1c1.9-2.1 2.9-5 2.9-8.7v-31.5h8.1v31.5c0 5.8-1.7 10.4-5.2 13.8c-3.5 3.4-8.1 5.1-13.8 5.1Zm52.6-0.2c-5.1 0-9.3-1.2-12.5-3.5c-3.3-2.3-4.9-5.5-4.9-9.6h8.3c0 1.8 .9 3.3 2.6 4.3c1.8 1.1 4 1.6 6.6 1.6h3.8c3.2 0 5.6-0.6 7.1-1.9c1.6-1.3 2.4-3 2.4-5.2c0-2.1-0.8-3.8-2.2-5c-1.5-1.2-3.6-2-6.4-2.5l-6.6-1c-5-0.9-8.6-2.4-10.9-4.7c-2.4-2.3-3.5-5.5-3.5-9.5c0-4.5 1.4-7.9 4.3-10.3c2.9-2.4 7.1-3.6 12.6-3.6h3.4c5 0 9.1 1.1 12.1 3.4c3 2.2 4.6 5.2 4.6 8.9h-8.3c0-1.6-0.8-2.8-2.3-3.7c-1.5-1-3.6-1.4-6.3-1.4h-3.4c-2.8 0-5 .6-6.5 1.7c-1.5 1.2-2.3 2.9-2.3 5c0 3.6 2.5 5.8 7.6 6.7l6.7 1.1c5.3 .8 9.2 2.4 11.7 4.6c2.4 2.3 3.6 5.6 3.6 9.9c0 4.6-1.5 8.2-4.4 10.8c-2.9 2.6-7.3 3.9-13.2 3.9Z"/><path fill="#fff" d="M91 164c-2 0-3.6-0.6-4.7-1.7c-1.1-1.1-1.6-2.7-1.6-4.6v-15.9h2.7v15.9c0 1.2 .3 2.2 .9 2.8c.6 .7 1.5 1.1 2.7 1.1c1.2 0 2.1-0.4 2.7-1.1c.6-0.7 .9-1.6 .9-2.8v-15.9h2.7v15.9c0 2-0.5 3.5-1.6 4.6c-1.1 1.1-2.6 1.7-4.7 1.7Zm17.5-0.1c-1.7 0-3.1-0.3-4.2-1.1c-1.1-0.8-1.6-1.9-1.6-3.2h2.8c0 .6 .3 1.1 .8 1.4c.6 .4 1.4 .5 2.2 .5h1.3c1.1 0 1.9-0.2 2.4-0.6c.5-0.4 .8-1 .8-1.7c0-0.7-0.3-1.3-0.8-1.7c-0.5-0.4-1.2-0.7-2.1-0.8l-2.2-0.4c-1.7-0.3-2.9-0.8-3.6-1.5c-0.8-0.8-1.2-1.9-1.2-3.2c0-1.5 .5-2.6 1.4-3.4c1-0.8 2.4-1.2 4.2-1.2h1.2c1.6 0 3 .3 4 1.1c1 .7 1.5 1.7 1.5 3h-2.7c0-0.6-0.3-1-0.8-1.3c-0.5-0.3-1.2-0.4-2.1-0.4h-1.1c-1 0-1.7 .2-2.2 .5c-0.5 .4-0.8 1-0.8 1.7c0 1.2 .9 1.9 2.6 2.2l2.2 .4c1.8 .3 3.1 .8 3.9 1.5c.8 .8 1.2 1.9 1.2 3.3c0 1.6-0.5 2.8-1.5 3.6c-0.9 .9-2.4 1.3-Line truncated
|
||||
|
After Width: | Height: | Size: 23 KiB |
@@ -0,0 +1,149 @@
|
||||
/* App-wide styling */
|
||||
html, body {
|
||||
background-color: #0e0e0e;
|
||||
color: white;
|
||||
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||
height: 100%;
|
||||
width: 100%;
|
||||
overflow: hidden;
|
||||
margin: 0;
|
||||
}
|
||||
|
||||
#main {
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
height: 100%;
|
||||
justify-content: space-between;
|
||||
}
|
||||
|
||||
#dogview {
|
||||
max-height: 80vh;
|
||||
flex-grow: 1;
|
||||
width: 100%;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
}
|
||||
|
||||
#dogimg {
|
||||
display: block;
|
||||
max-width: 50%;
|
||||
max-height: 50%;
|
||||
transform: scale(1.8);
|
||||
border-radius: 5px;
|
||||
border: 1px solid rgb(233, 233, 233);
|
||||
box-shadow: 0px 0px 5px 1px rgb(216, 216, 216, 0.5);
|
||||
}
|
||||
|
||||
#title {
|
||||
text-align: center;
|
||||
padding-top: 10px;
|
||||
border-bottom: 1px solid #a8a8a8;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
#title a {
|
||||
text-decoration: none;
|
||||
color: white;
|
||||
}
|
||||
|
||||
#heart {
|
||||
background-color: white;
|
||||
padding: 5px;
|
||||
border-radius: 5px;
|
||||
}
|
||||
|
||||
#title span {
|
||||
width: 20px;
|
||||
}
|
||||
|
||||
#title h1 {
|
||||
margin: 0.25em;
|
||||
font-style: italic;
|
||||
}
|
||||
|
||||
#buttons {
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: center;
|
||||
gap: 20px;
|
||||
/* padding-top: 20px; */
|
||||
padding-bottom: 20px;
|
||||
}
|
||||
|
||||
#skip { background-color: gray }
|
||||
#save { background-color: green; }
|
||||
|
||||
#skip, #save {
|
||||
padding: 5px 30px 5px 30px;
|
||||
border-radius: 3px;
|
||||
font-size: 2rem;
|
||||
font-weight: bold;
|
||||
color: rgb(230, 230, 230)
|
||||
}
|
||||
|
||||
#navbar {
|
||||
border: 1px solid rgb(233, 233, 233);
|
||||
border-width: 1px 0px 0px 0px;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
justify-content: space-evenly;
|
||||
padding: 20px;
|
||||
gap: 20px;
|
||||
}
|
||||
|
||||
#navbar a {
|
||||
background-color: #a8a8a8;
|
||||
border-radius: 5px;
|
||||
border: 1px solid black;
|
||||
text-decoration: none;
|
||||
color: black;
|
||||
padding: 10px 30px 10px 30px;
|
||||
}
|
||||
|
||||
#favorites {
|
||||
flex-grow: 1;
|
||||
overflow: hidden;
|
||||
display: flex;
|
||||
flex-direction: column;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
#favorites-container {
|
||||
overflow-y: auto;
|
||||
overflow-x: hidden;
|
||||
display: flex;
|
||||
flex-direction: row;
|
||||
flex-wrap: wrap;
|
||||
justify-content: center;
|
||||
gap: 10px;
|
||||
padding: 10px;
|
||||
}
|
||||
|
||||
.favorite-dog {
|
||||
max-height: 180px;
|
||||
max-width: 60%;
|
||||
position: relative;
|
||||
}
|
||||
|
||||
.favorite-dog img {
|
||||
max-height: 150px;
|
||||
border-radius: 5px;
|
||||
margin: 5px;
|
||||
}
|
||||
|
||||
.favorite-dog:hover button {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.favorite-dog button {
|
||||
display: none;
|
||||
position: absolute;
|
||||
bottom: 10px;
|
||||
left: 10px;
|
||||
z-index: 10;
|
||||
}
|
||||
Binary file not shown.
|
After Width: | Height: | Size: 1.4 MiB |
@@ -0,0 +1,26 @@
|
||||
# fly.toml app configuration file generated for hot-dog on 2024-12-19T18:23:45-08:00
|
||||
#
|
||||
# See https://fly.io/docs/reference/configuration/ for information about how to use this file.
|
||||
#
|
||||
|
||||
app = 'hot-dog'
|
||||
primary_region = 'sjc'
|
||||
|
||||
[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
|
||||
|
||||
[mounts]
|
||||
source = "hotdogdb"
|
||||
destination = "/usr/local/app/hotdogdb"
|
||||
@@ -0,0 +1,41 @@
|
||||
use anyhow::Result;
|
||||
use dioxus::prelude::*;
|
||||
|
||||
#[cfg(feature = "server")]
|
||||
thread_local! {
|
||||
static DB: std::sync::LazyLock<rusqlite::Connection> = std::sync::LazyLock::new(|| {
|
||||
let conn = rusqlite::Connection::open("hotdogdb/hotdog.db").expect("Failed to open database");
|
||||
|
||||
conn.execute_batch(
|
||||
"CREATE TABLE IF NOT EXISTS dogs (
|
||||
id INTEGER PRIMARY KEY,
|
||||
url TEXT NOT NULL
|
||||
);",
|
||||
)
|
||||
.unwrap();
|
||||
|
||||
conn
|
||||
});
|
||||
}
|
||||
|
||||
#[get("/api/dogs")]
|
||||
pub async fn list_dogs() -> Result<Vec<(usize, String)>> {
|
||||
DB.with(|db| {
|
||||
Ok(db
|
||||
.prepare("SELECT id, url FROM dogs ORDER BY id DESC LIMIT 10")?
|
||||
.query_map([], |row| Ok((row.get(0)?, row.get(1)?)))?
|
||||
.collect::<Result<Vec<(usize, String)>, rusqlite::Error>>()?)
|
||||
})
|
||||
}
|
||||
|
||||
#[delete("/api/dogs/{id}")]
|
||||
pub async fn remove_dog(id: usize) -> Result<()> {
|
||||
DB.with(|db| db.execute("DELETE FROM dogs WHERE id = ?1", [id]))?;
|
||||
Ok(())
|
||||
}
|
||||
|
||||
#[post("/api/dogs")]
|
||||
pub async fn save_dog(image: String) -> Result<()> {
|
||||
DB.with(|db| db.execute("INSERT INTO dogs (url) VALUES (?1)", [&image]))?;
|
||||
Ok(())
|
||||
}
|
||||
@@ -0,0 +1,71 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
use crate::{
|
||||
backend::{list_dogs, remove_dog, save_dog},
|
||||
Route,
|
||||
};
|
||||
|
||||
#[component]
|
||||
pub fn Favorites() -> Element {
|
||||
let mut favorites = use_loader(list_dogs)?;
|
||||
|
||||
rsx! {
|
||||
div { id: "favorites",
|
||||
for (id , url) in favorites.cloned() {
|
||||
div { class: "favorite-dog", key: "{id}",
|
||||
img { src: "{url}" }
|
||||
button {
|
||||
onclick: move |_| async move {
|
||||
_ = remove_dog(id).await;
|
||||
favorites.restart();
|
||||
},
|
||||
"❌"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn NavBar() -> Element {
|
||||
rsx! {
|
||||
div { id: "title",
|
||||
span {}
|
||||
Link { to: Route::DogView, h1 { "🌭 HotDog! " } }
|
||||
Link { to: Route::Favorites, id: "heart", "♥️" }
|
||||
}
|
||||
Outlet::<Route> {}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
pub fn DogView() -> Element {
|
||||
let mut img_src = use_loader(|| async move {
|
||||
anyhow::Ok(
|
||||
reqwest::get("https://dog.ceo/api/breeds/image/random")
|
||||
.await?
|
||||
.json::<serde_json::Value>()
|
||||
.await?["message"]
|
||||
.to_string(),
|
||||
)
|
||||
})?;
|
||||
|
||||
rsx! {
|
||||
div { id: "dogview",
|
||||
img { id: "dogimg", src: "{img_src}" }
|
||||
}
|
||||
div { id: "buttons",
|
||||
button {
|
||||
id: "skip",
|
||||
onclick: move |_| img_src.restart(),
|
||||
"skip"
|
||||
}
|
||||
button {
|
||||
id: "save",
|
||||
onclick: move |_| async move { _ = save_dog(img_src()).await },
|
||||
"save!"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,29 @@
|
||||
mod backend;
|
||||
mod frontend;
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use frontend::*;
|
||||
|
||||
#[derive(Routable, PartialEq, Clone)]
|
||||
enum Route {
|
||||
#[layout(NavBar)]
|
||||
#[route("/")]
|
||||
DogView,
|
||||
|
||||
#[route("/favorites")]
|
||||
Favorites,
|
||||
}
|
||||
|
||||
fn main() {
|
||||
#[cfg(not(feature = "server"))]
|
||||
dioxus::fullstack::set_server_url("https://hot-dog.fly.dev");
|
||||
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
rsx! {
|
||||
Stylesheet { href: asset!("/assets/main.css") }
|
||||
Router::<Route> {}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,97 @@
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app)
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut api_key = use_signal(|| "".to_string());
|
||||
let mut prompt = use_signal(|| "".to_string());
|
||||
let mut num_images = use_signal(|| 1.to_string());
|
||||
|
||||
let mut image = use_action(move || async move {
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Props, Clone, Default)]
|
||||
struct ImageResponse {
|
||||
created: i32,
|
||||
data: Vec<UrlImage>,
|
||||
}
|
||||
|
||||
#[derive(serde::Serialize, serde::Deserialize, Debug, PartialEq, Props, Clone)]
|
||||
struct UrlImage {
|
||||
url: String,
|
||||
}
|
||||
|
||||
if api_key.peek().is_empty() || prompt.peek().is_empty() || num_images.peek().is_empty() {
|
||||
return dioxus::Ok(ImageResponse::default());
|
||||
}
|
||||
|
||||
let res = reqwest::Client::new()
|
||||
.post("https://api.openai.com/v1/images/generations")
|
||||
.json(&serde_json::json!({
|
||||
"prompt": prompt.cloned(),
|
||||
"n": num_images.cloned().parse::<i32>().unwrap_or(1),
|
||||
"size":"1024x1024",
|
||||
}))
|
||||
.bearer_auth(api_key)
|
||||
.send()
|
||||
.await?
|
||||
.json::<ImageResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(res)
|
||||
});
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: "https://unpkg.com/bulma@0.9.0/css/bulma.min.css" }
|
||||
div { class: "container",
|
||||
div { class: "columns",
|
||||
div { class: "column",
|
||||
input { class: "input is-primary mt-4",
|
||||
value: "{api_key}",
|
||||
r#type: "text",
|
||||
placeholder: "Your OpenAI API Key",
|
||||
oninput: move |evt| api_key.set(evt.value()),
|
||||
}
|
||||
input { class: "input is-primary mt-4",
|
||||
placeholder: "MAX 1000 Dgts",
|
||||
r#type: "text",
|
||||
value:"{prompt}",
|
||||
oninput: move |evt| prompt.set(evt.value())
|
||||
}
|
||||
input { class: "input is-primary mt-4",
|
||||
r#type: "number",
|
||||
min:"1",
|
||||
max:"10",
|
||||
value:"{num_images}",
|
||||
oninput: move |evt| num_images.set(evt.value()),
|
||||
}
|
||||
}
|
||||
}
|
||||
button {
|
||||
class: "button is-primary",
|
||||
class: if image.is_pending() { "is-loading" },
|
||||
onclick: move |_| {
|
||||
image.call();
|
||||
},
|
||||
"Generate image"
|
||||
}
|
||||
if let Some(image) = image.value() {
|
||||
for image in image.read().data.as_slice() {
|
||||
section { class: "is-flex",
|
||||
div { class: "container is-fluid",
|
||||
div { class: "container has-text-centered",
|
||||
div { class: "is-justify-content-center",
|
||||
div { class: "level",
|
||||
div { class: "level-item",
|
||||
figure { class: "image", img { alt: "", src: "{image.url}", } }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,22 @@
|
||||
//! The example from the readme!
|
||||
//!
|
||||
//! This example demonstrates how to create a simple counter app with dioxus. The `Signal` type wraps inner values,
|
||||
//! making them `Copy`, allowing them to be freely used in closures and async functions. `Signal` also provides
|
||||
//! helper methods like AddAssign, SubAssign, toggle, etc, to make it easy to update the value without running
|
||||
//! into lock issues.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let mut count = use_signal(|| 0);
|
||||
|
||||
rsx! {
|
||||
h1 { "High-Five counter: {count}" }
|
||||
button { onclick: move |_| count += 1, "Up high!" }
|
||||
button { onclick: move |_| count -= 1, "Down low!" }
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,259 @@
|
||||
//! The typical TodoMVC app, implemented in Dioxus.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use std::collections::HashMap;
|
||||
|
||||
const STYLE: Asset = asset!("/examples/assets/todomvc.css");
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy)]
|
||||
enum FilterState {
|
||||
All,
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
|
||||
struct TodoItem {
|
||||
checked: bool,
|
||||
contents: String,
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
// We store the todos in a HashMap in a Signal.
|
||||
// Each key is the id of the todo, and the value is the todo itself.
|
||||
let mut todos = use_signal(HashMap::<u32, TodoItem>::new);
|
||||
|
||||
let filter = use_signal(|| FilterState::All);
|
||||
|
||||
// We use a simple memoized signal to calculate the number of active todos.
|
||||
// Whenever the todos change, the active_todo_count will be recalculated.
|
||||
let active_todo_count =
|
||||
use_memo(move || todos.read().values().filter(|item| !item.checked).count());
|
||||
|
||||
// We use a memoized signal to filter the todos based on the current filter state.
|
||||
// Whenever the todos or filter change, the filtered_todos will be recalculated.
|
||||
// Note that we're only storing the IDs of the todos, not the todos themselves.
|
||||
let filtered_todos = use_memo(move || {
|
||||
let mut filtered_todos = todos
|
||||
.read()
|
||||
.iter()
|
||||
.filter(|(_, item)| match filter() {
|
||||
FilterState::All => true,
|
||||
FilterState::Active => !item.checked,
|
||||
FilterState::Completed => item.checked,
|
||||
})
|
||||
.map(|f| *f.0)
|
||||
.collect::<Vec<_>>();
|
||||
|
||||
filtered_todos.sort_unstable();
|
||||
|
||||
filtered_todos
|
||||
});
|
||||
|
||||
// Toggle all the todos to the opposite of the current state.
|
||||
// If all todos are checked, uncheck them all. If any are unchecked, check them all.
|
||||
let toggle_all = move |_| {
|
||||
let check = active_todo_count() != 0;
|
||||
for (_, item) in todos.write().iter_mut() {
|
||||
item.checked = check;
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: STYLE }
|
||||
section { class: "todoapp",
|
||||
TodoHeader { todos }
|
||||
section { class: "main",
|
||||
if !todos.read().is_empty() {
|
||||
input {
|
||||
id: "toggle-all",
|
||||
class: "toggle-all",
|
||||
r#type: "checkbox",
|
||||
onchange: toggle_all,
|
||||
checked: active_todo_count() == 0
|
||||
}
|
||||
label { r#for: "toggle-all" }
|
||||
}
|
||||
|
||||
// Render the todos using the filtered_todos signal
|
||||
// We pass the ID into the TodoEntry component so it can access the todo from the todos signal.
|
||||
// Since we store the todos in a signal too, we also need to send down the todo list
|
||||
ul { class: "todo-list",
|
||||
for id in filtered_todos() {
|
||||
TodoEntry { key: "{id}", id, todos }
|
||||
}
|
||||
}
|
||||
|
||||
// We only show the footer if there are todos.
|
||||
if !todos.read().is_empty() {
|
||||
ListFooter { active_todo_count, todos, filter }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A simple info footer
|
||||
footer { class: "info",
|
||||
p { "Double-click to edit a todo" }
|
||||
p {
|
||||
"Created by "
|
||||
a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }
|
||||
}
|
||||
p {
|
||||
"Part of "
|
||||
a { href: "http://todomvc.com", "TodoMVC" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TodoHeader(mut todos: WriteSignal<HashMap<u32, TodoItem>>) -> Element {
|
||||
let mut draft = use_signal(|| "".to_string());
|
||||
let mut todo_id = use_signal(|| 0);
|
||||
|
||||
let onkeydown = move |evt: KeyboardEvent| {
|
||||
if evt.key() == Key::Enter && !draft.read().is_empty() {
|
||||
let id = todo_id();
|
||||
let todo = TodoItem {
|
||||
checked: false,
|
||||
contents: draft.to_string(),
|
||||
};
|
||||
todos.write().insert(id, todo);
|
||||
todo_id += 1;
|
||||
draft.set("".to_string());
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
header { class: "header",
|
||||
h1 { "todos" }
|
||||
input {
|
||||
class: "new-todo",
|
||||
placeholder: "What needs to be done?",
|
||||
value: "{draft}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| draft.set(evt.value()),
|
||||
onkeydown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single todo entry
|
||||
/// This takes the ID of the todo and the todos signal as props
|
||||
/// We can use these together to memoize the todo contents and checked state
|
||||
#[component]
|
||||
fn TodoEntry(mut todos: WriteSignal<HashMap<u32, TodoItem>>, id: u32) -> Element {
|
||||
let mut is_editing = use_signal(|| false);
|
||||
|
||||
// To avoid re-rendering this component when the todo list changes, we isolate our reads to memos
|
||||
// This way, the component will only re-render when the contents of the todo change, or when the editing state changes.
|
||||
// This does involve taking a local clone of the todo contents, but it allows us to prevent this component from re-rendering
|
||||
let checked = use_memo(move || todos.read().get(&id).unwrap().checked);
|
||||
let contents = use_memo(move || todos.read().get(&id).unwrap().contents.clone());
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
// Dioxus lets you use if statements in rsx to conditionally render attributes
|
||||
// These will get merged into a single class attribute
|
||||
class: if checked() { "completed" },
|
||||
class: if is_editing() { "editing" },
|
||||
|
||||
// Some basic controls for the todo
|
||||
div { class: "view",
|
||||
input {
|
||||
class: "toggle",
|
||||
r#type: "checkbox",
|
||||
id: "cbg-{id}",
|
||||
checked: "{checked}",
|
||||
oninput: move |evt| todos.write().get_mut(&id).unwrap().checked = evt.checked()
|
||||
}
|
||||
label {
|
||||
r#for: "cbg-{id}",
|
||||
ondoubleclick: move |_| is_editing.set(true),
|
||||
onclick: |evt| evt.prevent_default(),
|
||||
"{contents}"
|
||||
}
|
||||
button {
|
||||
class: "destroy",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
todos.write().remove(&id);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Only render the actual input if we're editing
|
||||
if is_editing() {
|
||||
input {
|
||||
class: "edit",
|
||||
value: "{contents}",
|
||||
oninput: move |evt| todos.write().get_mut(&id).unwrap().contents = evt.value(),
|
||||
autofocus: "true",
|
||||
onfocusout: move |_| is_editing.set(false),
|
||||
onkeydown: move |evt| {
|
||||
match evt.key() {
|
||||
Key::Enter | Key::Escape | Key::Tab => is_editing.set(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListFooter(
|
||||
mut todos: WriteSignal<HashMap<u32, TodoItem>>,
|
||||
active_todo_count: ReadSignal<usize>,
|
||||
mut filter: WriteSignal<FilterState>,
|
||||
) -> Element {
|
||||
// We use a memoized signal to calculate whether we should show the "Clear completed" button.
|
||||
// This will recompute whenever the todos change, and if the value is true, the button will be shown.
|
||||
let show_clear_completed = use_memo(move || todos.read().values().any(|todo| todo.checked));
|
||||
|
||||
rsx! {
|
||||
footer { class: "footer",
|
||||
span { class: "todo-count",
|
||||
strong { "{active_todo_count} " }
|
||||
span {
|
||||
match active_todo_count() {
|
||||
1 => "item",
|
||||
_ => "items",
|
||||
}
|
||||
" left"
|
||||
}
|
||||
}
|
||||
ul { class: "filters",
|
||||
for (state , state_text , url) in [
|
||||
(FilterState::All, "All", "#/"),
|
||||
(FilterState::Active, "Active", "#/active"),
|
||||
(FilterState::Completed, "Completed", "#/completed"),
|
||||
] {
|
||||
li {
|
||||
a {
|
||||
href: url,
|
||||
class: if filter() == state { "selected" },
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
filter.set(state)
|
||||
},
|
||||
{state_text}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_clear_completed() {
|
||||
button {
|
||||
class: "clear-completed",
|
||||
onclick: move |_| todos.write().retain(|_, todo| !todo.checked),
|
||||
"Clear completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,313 @@
|
||||
//! The typical TodoMVC app, implemented in Dioxus with stores. Stores let us
|
||||
//! share nested reactive state between components. They let us keep our todomvc
|
||||
//! state in a single struct without wrapping every type in a signal while still
|
||||
//! maintaining fine grained reactivity.
|
||||
|
||||
use dioxus::prelude::*;
|
||||
use std::{collections::HashMap, vec};
|
||||
|
||||
const STYLE: Asset = asset!("/examples/assets/todomvc.css");
|
||||
|
||||
/// Deriving the store macro on a struct will automatically generate an extension trait
|
||||
/// for Store<TodoState> with method to zoom into the fields of the struct.
|
||||
///
|
||||
/// For this struct, the macro derives the following methods for Store<TodoState>:
|
||||
/// - `todos(self) -> Store<HashMap<u32, TodoItem>, _>`
|
||||
/// - `filter(self) -> Store<FilterState, _>`
|
||||
#[derive(Store, PartialEq, Clone, Debug)]
|
||||
struct TodoState {
|
||||
todos: HashMap<u32, TodoItem>,
|
||||
filter: FilterState,
|
||||
}
|
||||
|
||||
// We can also add custom methods to the store by using the `store` attribute on an impl block.
|
||||
// The store attribute turns the impl block into an extension trait for Store<TodoState>.
|
||||
// Methods that take &self will automatically get a bound that Lens: Readable<Target = TodoState>
|
||||
// Methods that take &mut self will automatically get a bound that Lens: Writable<Target = TodoState>
|
||||
#[store]
|
||||
impl<Lens> Store<TodoState, Lens> {
|
||||
fn active_items(&self) -> Vec<u32> {
|
||||
let filter = self.filter().cloned();
|
||||
let mut active_ids: Vec<u32> = self
|
||||
.todos()
|
||||
.iter()
|
||||
.filter_map(|(id, item)| item.active(filter).then_some(id))
|
||||
.collect();
|
||||
active_ids.sort_unstable();
|
||||
active_ids
|
||||
}
|
||||
|
||||
fn incomplete_count(&self) -> usize {
|
||||
self.todos()
|
||||
.values()
|
||||
.filter(|item| item.incomplete())
|
||||
.count()
|
||||
}
|
||||
|
||||
fn toggle_all(&mut self) {
|
||||
let check = self.incomplete_count() != 0;
|
||||
for item in self.todos().values() {
|
||||
item.checked().set(check);
|
||||
}
|
||||
}
|
||||
|
||||
fn has_todos(&self) -> bool {
|
||||
!self.todos().is_empty()
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(PartialEq, Eq, Clone, Copy, Debug)]
|
||||
enum FilterState {
|
||||
All,
|
||||
Active,
|
||||
Completed,
|
||||
}
|
||||
|
||||
#[derive(Store, PartialEq, Clone, Debug)]
|
||||
struct TodoItem {
|
||||
checked: bool,
|
||||
contents: String,
|
||||
}
|
||||
|
||||
impl TodoItem {
|
||||
fn new(contents: impl ToString) -> Self {
|
||||
Self {
|
||||
checked: false,
|
||||
contents: contents.to_string(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[store]
|
||||
impl<Lens> Store<TodoItem, Lens> {
|
||||
fn complete(&self) -> bool {
|
||||
self.checked().cloned()
|
||||
}
|
||||
|
||||
fn incomplete(&self) -> bool {
|
||||
!self.complete()
|
||||
}
|
||||
|
||||
fn active(&self, filter: FilterState) -> bool {
|
||||
match filter {
|
||||
FilterState::All => true,
|
||||
FilterState::Active => self.incomplete(),
|
||||
FilterState::Completed => self.complete(),
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
// We store the state of our todo list in a store to use throughout the app.
|
||||
let mut todos = use_store(|| TodoState {
|
||||
todos: HashMap::new(),
|
||||
filter: FilterState::All,
|
||||
});
|
||||
|
||||
// We use a simple memo to calculate the number of active todos.
|
||||
// Whenever the todos change, the active_todo_count will be recalculated.
|
||||
let active_todo_count = use_memo(move || todos.incomplete_count());
|
||||
|
||||
// We use a memo to filter the todos based on the current filter state.
|
||||
// Whenever the todos or filter change, the filtered_todos will be recalculated.
|
||||
// Note that we're only storing the IDs of the todos, not the todos themselves.
|
||||
let filtered_todos = use_memo(move || todos.active_items());
|
||||
|
||||
// Toggle all the todos to the opposite of the current state.
|
||||
// If all todos are checked, uncheck them all. If any are unchecked, check them all.
|
||||
let toggle_all = move |_| {
|
||||
todos.toggle_all();
|
||||
};
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: STYLE }
|
||||
section { class: "todoapp",
|
||||
TodoHeader { todos }
|
||||
section { class: "main",
|
||||
if todos.has_todos() {
|
||||
input {
|
||||
id: "toggle-all",
|
||||
class: "toggle-all",
|
||||
r#type: "checkbox",
|
||||
onchange: toggle_all,
|
||||
checked: active_todo_count() == 0
|
||||
}
|
||||
label { r#for: "toggle-all" }
|
||||
}
|
||||
|
||||
// Render the todos using the filtered_todos memo
|
||||
// We pass the ID along with the hashmap into the TodoEntry component so it can access the todo from the todos store.
|
||||
ul { class: "todo-list",
|
||||
for id in filtered_todos() {
|
||||
TodoEntry { key: "{id}", id, todos }
|
||||
}
|
||||
}
|
||||
|
||||
// We only show the footer if there are todos.
|
||||
if todos.has_todos() {
|
||||
ListFooter { active_todo_count, todos }
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
// A simple info footer
|
||||
footer { class: "info",
|
||||
p { "Double-click to edit a todo" }
|
||||
p {
|
||||
"Created by "
|
||||
a { href: "http://github.com/jkelleyrtp/", "jkelleyrtp" }
|
||||
}
|
||||
p {
|
||||
"Part of "
|
||||
a { href: "http://todomvc.com", "TodoMVC" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn TodoHeader(mut todos: Store<TodoState>) -> Element {
|
||||
let mut draft = use_signal(|| "".to_string());
|
||||
let mut todo_id = use_signal(|| 0);
|
||||
|
||||
let onkeydown = move |evt: KeyboardEvent| {
|
||||
if evt.key() == Key::Enter && !draft.read().is_empty() {
|
||||
let id = todo_id();
|
||||
let todo = TodoItem::new(draft.take());
|
||||
todos.todos().insert(id, todo);
|
||||
todo_id += 1;
|
||||
}
|
||||
};
|
||||
|
||||
rsx! {
|
||||
header { class: "header",
|
||||
h1 { "todos" }
|
||||
input {
|
||||
class: "new-todo",
|
||||
placeholder: "What needs to be done?",
|
||||
value: "{draft}",
|
||||
autofocus: "true",
|
||||
oninput: move |evt| draft.set(evt.value()),
|
||||
onkeydown
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// A single todo entry
|
||||
/// This takes the ID of the todo and the todos store as props
|
||||
/// We can use these together to memoize the todo contents and checked state
|
||||
#[component]
|
||||
fn TodoEntry(mut todos: Store<TodoState>, id: u32) -> Element {
|
||||
let mut is_editing = use_signal(|| false);
|
||||
|
||||
// When we get an item out of the store, it will only subscribe to that specific item.
|
||||
// Since we only get the single todo item, the component will only rerender when that item changes.
|
||||
let entry = todos.todos().get(id).unwrap();
|
||||
let checked = entry.checked();
|
||||
let contents = entry.contents();
|
||||
|
||||
rsx! {
|
||||
li {
|
||||
// Dioxus lets you use if statements in rsx to conditionally render attributes
|
||||
// These will get merged into a single class attribute
|
||||
class: if checked() { "completed" },
|
||||
class: if is_editing() { "editing" },
|
||||
|
||||
// Some basic controls for the todo
|
||||
div { class: "view",
|
||||
input {
|
||||
class: "toggle",
|
||||
r#type: "checkbox",
|
||||
id: "cbg-{id}",
|
||||
checked: "{checked}",
|
||||
oninput: move |evt| entry.checked().set(evt.checked())
|
||||
}
|
||||
label {
|
||||
r#for: "cbg-{id}",
|
||||
ondoubleclick: move |_| is_editing.set(true),
|
||||
onclick: |evt| evt.prevent_default(),
|
||||
"{contents}"
|
||||
}
|
||||
button {
|
||||
class: "destroy",
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
todos.todos().remove(&id);
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
// Only render the actual input if we're editing
|
||||
if is_editing() {
|
||||
input {
|
||||
class: "edit",
|
||||
value: "{contents}",
|
||||
oninput: move |evt| entry.contents().set(evt.value()),
|
||||
autofocus: "true",
|
||||
onfocusout: move |_| is_editing.set(false),
|
||||
onkeydown: move |evt| {
|
||||
match evt.key() {
|
||||
Key::Enter | Key::Escape | Key::Tab => is_editing.set(false),
|
||||
_ => {}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn ListFooter(mut todos: Store<TodoState>, active_todo_count: ReadSignal<usize>) -> Element {
|
||||
// We use a memo to calculate whether we should show the "Clear completed" button.
|
||||
// This will recompute whenever the number of todos change or the checked state of an existing
|
||||
// todo changes
|
||||
let show_clear_completed = use_memo(move || todos.todos().values().any(|todo| todo.complete()));
|
||||
let mut filter = todos.filter();
|
||||
|
||||
rsx! {
|
||||
footer { class: "footer",
|
||||
span { class: "todo-count",
|
||||
strong { "{active_todo_count} " }
|
||||
span {
|
||||
match active_todo_count() {
|
||||
1 => "item",
|
||||
_ => "items",
|
||||
}
|
||||
" left"
|
||||
}
|
||||
}
|
||||
ul { class: "filters",
|
||||
for (state , state_text , url) in [
|
||||
(FilterState::All, "All", "#/"),
|
||||
(FilterState::Active, "Active", "#/active"),
|
||||
(FilterState::Completed, "Completed", "#/completed"),
|
||||
] {
|
||||
li {
|
||||
a {
|
||||
href: url,
|
||||
class: if filter() == state { "selected" },
|
||||
onclick: move |evt| {
|
||||
evt.prevent_default();
|
||||
filter.set(state)
|
||||
},
|
||||
{state_text}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
if show_clear_completed() {
|
||||
button {
|
||||
class: "clear-completed",
|
||||
onclick: move |_| todos.todos().retain(|_, todo| !todo.checked),
|
||||
"Clear completed"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
#![allow(non_snake_case)]
|
||||
|
||||
use dioxus::{fullstack::Loading, prelude::*};
|
||||
use serde::{Deserialize, Serialize};
|
||||
use std::fmt::Display;
|
||||
|
||||
fn main() {
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
let country = use_signal(|| WeatherLocation {
|
||||
name: "Berlin".to_string(),
|
||||
country: "Germany".to_string(),
|
||||
latitude: 52.5244,
|
||||
longitude: 13.4105,
|
||||
id: 2950159,
|
||||
});
|
||||
|
||||
let current_weather = use_loader(move || get_weather(country()));
|
||||
|
||||
rsx! {
|
||||
Stylesheet { href: "https://unpkg.com/tailwindcss@^2.0/dist/tailwind.min.css" }
|
||||
div { class: "mx-auto p-4 bg-gray-100 h-screen flex justify-center",
|
||||
div { class: "flex items-center justify-center flex-row",
|
||||
div { class: "flex items-start justify-center flex-row",
|
||||
SearchBox { country }
|
||||
div { class: "flex flex-wrap w-full px-2",
|
||||
div { class: "bg-gray-900 text-white relative min-w-0 break-words rounded-lg overflow-hidden shadow-sm mb-4 w-full dark:bg-gray-600",
|
||||
div { class: "px-6 py-6 relative",
|
||||
match current_weather {
|
||||
Ok(weather) => rsx! {
|
||||
CountryData {
|
||||
country: country.read().clone(),
|
||||
weather: weather.cloned(),
|
||||
}
|
||||
Forecast { weather: weather.cloned() }
|
||||
},
|
||||
Err(Loading::Pending(_)) => rsx! {
|
||||
div { "Loading weather data..." }
|
||||
},
|
||||
Err(Loading::Failed(_)) => rsx! {
|
||||
div { "Failed to load weather data." }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[component]
|
||||
fn CountryData(weather: WeatherResponse, country: WeatherLocation) -> Element {
|
||||
let today = "Today";
|
||||
let max_temp = weather.daily.temperature_2m_max.first().unwrap();
|
||||
let min_temp = weather.daily.temperature_2m_min.first().unwrap();
|
||||
|
||||
rsx! {
|
||||
div { class: "flex mb-4 justify-between items-center",
|
||||
div {
|
||||
h5 { class: "mb-0 font-medium text-xl", "{country.name} 🏞️" }
|
||||
h6 { class: "mb-0", "{today}" }
|
||||
}
|
||||
div {
|
||||
div { class: "flex items-center",
|
||||
span { "Temp min" }
|
||||
span { class: "px-2 inline-block", "👉 {min_temp}°" }
|
||||
}
|
||||
div { class: "flex items-center",
|
||||
span { "Temp max" }
|
||||
span { class: "px-2 inline-block ", "👉 {max_temp}º" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[allow(non_snake_case)]
|
||||
#[component]
|
||||
fn Forecast(weather: WeatherResponse) -> Element {
|
||||
let today = (weather.daily.temperature_2m_max.first().unwrap()
|
||||
+ weather.daily.temperature_2m_max.first().unwrap())
|
||||
/ 2.0;
|
||||
let tomorrow = (weather.daily.temperature_2m_max.get(1).unwrap()
|
||||
+ weather.daily.temperature_2m_max.get(1).unwrap())
|
||||
/ 2.0;
|
||||
let past_tomorrow = (weather.daily.temperature_2m_max.get(2).unwrap()
|
||||
+ weather.daily.temperature_2m_max.get(2).unwrap())
|
||||
/ 2.0;
|
||||
|
||||
rsx! {
|
||||
div { class: "px-6 pt-4 relative",
|
||||
div { class: "w-full h-px bg-gray-100 mb-4" }
|
||||
div { p { class: "text-center w-full mb-4", "👇 Forecast 📆" } }
|
||||
div { class: "text-center justify-between items-center flex",
|
||||
div { class: "text-center mb-0 flex items-center justify-center flex-col mx-4 w-16",
|
||||
span { class: "block my-1", "Today" }
|
||||
span { class: "block my-1", "{today}°" }
|
||||
}
|
||||
div { class: "text-center mb-0 flex items-center justify-center flex-col mx-8 w-16",
|
||||
span { class: "block my-1", "Tomorrow" }
|
||||
span { class: "block my-1", "{tomorrow}°" }
|
||||
}
|
||||
div { class: "text-center mb-0 flex items-center justify-center flex-col mx-2 w-30",
|
||||
span { class: "block my-1", "Past Tomorrow" }
|
||||
span { class: "block my-1", "{past_tomorrow}°" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[component]
|
||||
fn SearchBox(mut country: WriteSignal<WeatherLocation>) -> Element {
|
||||
let mut input = use_signal(|| "".to_string());
|
||||
let locations = use_resource(move || get_locations(input()));
|
||||
|
||||
rsx! {
|
||||
div {
|
||||
div { class: "inline-flex flex-col justify-center relative text-gray-500",
|
||||
div { class: "relative",
|
||||
input {
|
||||
class: "p-2 pl-8 rounded-lg border border-gray-200 bg-gray-200 focus:bg-white focus:outline-none focus:ring-2 focus:ring-yellow-600 focus:border-transparent",
|
||||
placeholder: "Country name",
|
||||
"type": "text",
|
||||
autofocus: true,
|
||||
oninput: move |e: FormEvent| input.set(e.value())
|
||||
}
|
||||
svg {
|
||||
class: "w-4 h-4 absolute left-2.5 top-3.5",
|
||||
"viewBox": "0 0 24 24",
|
||||
fill: "none",
|
||||
stroke: "currentColor",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
path {
|
||||
d: "M21 21l-6-6m2-5a7 7 0 11-14 0 7 7 0 0114 0z",
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-linecap": "round",
|
||||
"stroke-width": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
ul { class: "bg-white border border-gray-100 w-full mt-2 max-h-72 overflow-auto",
|
||||
if let Some(Ok(locs)) = locations.read().as_ref() {
|
||||
for wl in locs.iter().take(5).cloned() {
|
||||
li { class: "pl-8 pr-2 py-1 border-b-2 border-gray-100 relative cursor-pointer hover:bg-yellow-50 hover:text-gray-900",
|
||||
onclick: move |_| country.set(wl.clone()),
|
||||
MapIcon {}
|
||||
b { "{wl.name}" }
|
||||
" · {wl.country}"
|
||||
}
|
||||
}
|
||||
} else {
|
||||
"loading locations..."
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
fn MapIcon() -> Element {
|
||||
rsx! {
|
||||
svg {
|
||||
class: "stroke-current absolute w-4 h-4 left-2 top-2",
|
||||
stroke: "currentColor",
|
||||
xmlns: "http://www.w3.org/2000/svg",
|
||||
"viewBox": "0 0 24 24",
|
||||
fill: "none",
|
||||
path {
|
||||
"stroke-linejoin": "round",
|
||||
"stroke-width": "2",
|
||||
"stroke-linecap": "round",
|
||||
d: "M17.657 16.657L13.414 20.9a1.998 1.998 0 01-2.827 0l-4.244-4.243a8 8 0 1111.314 0z"
|
||||
}
|
||||
path {
|
||||
"stroke-linecap": "round",
|
||||
"stroke-linejoin": "round",
|
||||
d: "M15 11a3 3 0 11-6 0 3 3 0 016 0z",
|
||||
"stroke-width": "2"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||
struct WeatherLocation {
|
||||
id: usize,
|
||||
name: String,
|
||||
latitude: f32,
|
||||
longitude: f32,
|
||||
country: String,
|
||||
}
|
||||
|
||||
type WeatherLocations = Vec<WeatherLocation>;
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize)]
|
||||
struct SearchResponse {
|
||||
results: WeatherLocations,
|
||||
}
|
||||
|
||||
async fn get_locations(input: impl Display) -> reqwest::Result<WeatherLocations> {
|
||||
let res = reqwest::get(&format!(
|
||||
"https://geocoding-api.open-meteo.com/v1/search?name={input}"
|
||||
))
|
||||
.await?
|
||||
.json::<SearchResponse>()
|
||||
.await?;
|
||||
|
||||
Ok(res.results)
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||
struct WeatherResponse {
|
||||
daily: DailyWeather,
|
||||
hourly: HourlyWeather,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||
struct HourlyWeather {
|
||||
time: Vec<String>,
|
||||
temperature_2m: Vec<f32>,
|
||||
}
|
||||
|
||||
#[derive(Debug, Default, Serialize, Deserialize, PartialEq, Clone)]
|
||||
struct DailyWeather {
|
||||
temperature_2m_min: Vec<f32>,
|
||||
temperature_2m_max: Vec<f32>,
|
||||
}
|
||||
|
||||
async fn get_weather(location: WeatherLocation) -> reqwest::Result<WeatherResponse> {
|
||||
reqwest::get(&format!("https://api.open-meteo.com/v1/forecast?latitude={}&longitude={}&hourly=temperature_2m&daily=temperature_2m_max,temperature_2m_min,apparent_temperature_max,apparent_temperature_min&timezone=GMT", location.latitude, location.longitude))
|
||||
.await?
|
||||
.json::<WeatherResponse>()
|
||||
.await
|
||||
}
|
||||
@@ -0,0 +1,162 @@
|
||||
//! A websocket chat demo using Dioxus' built-in websocket support.
|
||||
//!
|
||||
//! We setup an endpoint at `/api/chat` that accepts a `name` and `user_id` query parameter.
|
||||
//! Each client connects to that endpoint, and we use a `tokio::broadcast` channel
|
||||
//! to send messages to all connected clients.
|
||||
//!
|
||||
//! In practice, you'd use a distributed messaging system (Redis PubSub / Kafka / etc) to coordinate
|
||||
//! between multiple server instances and an additional database to persist chat history.
|
||||
|
||||
use dioxus::fullstack::{WebSocketOptions, Websocket, use_websocket};
|
||||
use dioxus::prelude::*;
|
||||
use serde::{Deserialize, Serialize};
|
||||
use uuid::Uuid;
|
||||
|
||||
fn main() {
|
||||
println!("IP and PORT: {:#?}", std::env::var("IP"));
|
||||
println!("IP and PORT: {:#?}", std::env::var("PORT"));
|
||||
dioxus::launch(app);
|
||||
}
|
||||
|
||||
fn app() -> Element {
|
||||
// store the user's current input
|
||||
let mut input = use_signal(|| "".to_string());
|
||||
|
||||
// Select a unique id for the user, and then use that entropy to pick a random name
|
||||
let user_id = use_signal(uuid::Uuid::new_v4);
|
||||
let user_name = use_signal(|| {
|
||||
match user_id.read().as_bytes()[0] % 7 {
|
||||
0 => "Alice",
|
||||
1 => "Bob",
|
||||
2 => "Eve",
|
||||
3 => "Mallory",
|
||||
4 => "Trent",
|
||||
5 => "Peggy",
|
||||
6 => "Victor",
|
||||
_ => "Charlie",
|
||||
}
|
||||
.to_string()
|
||||
});
|
||||
|
||||
// Store the messages we've received from the server
|
||||
let mut message_list = use_signal(Vec::<ChatMessage>::new);
|
||||
|
||||
// Connect to the websocket endpoint
|
||||
let mut socket =
|
||||
use_websocket(move || uppercase_ws(user_name(), user_id(), Default::default()));
|
||||
|
||||
use_future(move || async move {
|
||||
while let Ok(msg) = socket.recv().await {
|
||||
match msg {
|
||||
ServerEvent::ReceiveMessage(message) => message_list.write().push(message),
|
||||
ServerEvent::Connected { messages } => message_list.set(messages),
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
rsx! {
|
||||
h1 { "WebSocket Chat" }
|
||||
p { "Connection status: {socket.status():?} as {user_name}" }
|
||||
input {
|
||||
placeholder: "Type a message",
|
||||
value: "{input}",
|
||||
oninput: move |e| async move { input.set(e.value()) },
|
||||
onkeydown: move |e| async move {
|
||||
if e.key() == Key::Enter {
|
||||
_ = socket.send(ClientEvent::SendMessage(input.read().clone())).await;
|
||||
input.set("".to_string());
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
div {
|
||||
for message in message_list.read().iter().rev() {
|
||||
pre { "{message.name}: {message.message}" }
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
/// The events that the client can send to the server
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum ClientEvent {
|
||||
SendMessage(String),
|
||||
}
|
||||
|
||||
/// The events that the server can send to the client
|
||||
#[derive(Serialize, Deserialize, Debug)]
|
||||
enum ServerEvent {
|
||||
Connected { messages: Vec<ChatMessage> },
|
||||
ReceiveMessage(ChatMessage),
|
||||
}
|
||||
|
||||
#[derive(Serialize, Deserialize, Debug, Clone, PartialEq)]
|
||||
struct ChatMessage {
|
||||
user_id: Uuid,
|
||||
name: String,
|
||||
message: String,
|
||||
}
|
||||
|
||||
#[get("/api/chat?name&user_id")]
|
||||
async fn uppercase_ws(
|
||||
name: String,
|
||||
user_id: Uuid,
|
||||
options: WebSocketOptions,
|
||||
) -> Result<Websocket<ClientEvent, ServerEvent>> {
|
||||
use std::sync::LazyLock;
|
||||
use tokio::sync::{
|
||||
Mutex,
|
||||
broadcast::{self, Sender},
|
||||
};
|
||||
|
||||
// Every chat app needs a chat room! For this demo, we just use a tokio broadcast channel and a mutex-protected
|
||||
// list of messages to store chat history.
|
||||
//
|
||||
// We place these types in the body of this serverfn since they're not used on the client, only the server.
|
||||
static MESSAGES: LazyLock<Mutex<Vec<ChatMessage>>> = LazyLock::new(|| Mutex::new(Vec::new()));
|
||||
static BROADCAST: LazyLock<Sender<ChatMessage>> = LazyLock::new(|| broadcast::channel(100).0);
|
||||
|
||||
Ok(options.on_upgrade(move |mut socket| async move {
|
||||
// Send back all the messages from the room to the new client
|
||||
let messages = MESSAGES.lock().await.clone();
|
||||
_ = socket.send(ServerEvent::Connected { messages }).await;
|
||||
|
||||
// Subscriber to the broadcast channel
|
||||
let sender = BROADCAST.clone();
|
||||
let mut broadcast = sender.subscribe();
|
||||
|
||||
// Announce that we've joined
|
||||
let _ = sender.send(ChatMessage {
|
||||
message: format!("{name} has connected."),
|
||||
user_id,
|
||||
name: "[CONSOLE]".to_string(),
|
||||
});
|
||||
|
||||
// Loop poll the broadcast receiver and the websocket for new messages
|
||||
// If we receive a message from the broadcast channel, send it to the client
|
||||
// If we receive a message from the client, broadcast it to all other clients and save it to the message list
|
||||
loop {
|
||||
tokio::select! {
|
||||
Ok(msg) = broadcast.recv() => {
|
||||
let _ = socket.send(ServerEvent::ReceiveMessage(msg)).await;
|
||||
}
|
||||
Ok(ClientEvent::SendMessage(message)) = socket.recv() => {
|
||||
let chat_message = ChatMessage {
|
||||
user_id,
|
||||
name: name.clone(),
|
||||
message,
|
||||
};
|
||||
let _ = sender.send(chat_message.clone());
|
||||
MESSAGES.lock().await.push(chat_message.clone());
|
||||
},
|
||||
else => break,
|
||||
}
|
||||
}
|
||||
|
||||
_ = sender.send(ChatMessage {
|
||||
name: "[CONSOLE]".to_string(),
|
||||
message: format!("{name} has disconnected."),
|
||||
user_id,
|
||||
});
|
||||
}))
|
||||
}
|
||||
Reference in new issue
Block a user