Initial braindump app implementation
Create a complete braindump note-taking application with Dioxus 0.7 featuring: - Note capture with title, content, and tags - Full CRUD operations (create, read, update, delete) - Search functionality for notes - Tag-based filtering - Note pinning for quick access - Modern dark theme with purple accents - Responsive sidebar layout - Clean card-based note list - Full-text editor with auto-save hint Implemented with: - Dioxus 0.7.1 fullstack for reactive UI and server functions - Workspace pattern with shared API crate - In-memory storage using LazyLock - Server functions for note management All core features working and ready for testing.
This commit is contained in:
7
.gitignore
vendored
Normal file
7
.gitignore
vendored
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
# Generated by Cargo
|
||||||
|
# will have compiled files and executables
|
||||||
|
/target
|
||||||
|
.DS_Store
|
||||||
|
|
||||||
|
# These are backup files generated by rustfmt
|
||||||
|
**/*.rs.bk
|
||||||
265
AGENTS.md
Normal file
265
AGENTS.md
Normal file
@@ -0,0 +1,265 @@
|
|||||||
|
You are an expert [0.7 Dioxus](https://dioxuslabs.com/learn/0.7) assistant. Dioxus 0.7 changes every api in dioxus. Only use this up to date documentation. `cx`, `Scope`, and `use_state` are gone
|
||||||
|
|
||||||
|
Provide concise code examples with detailed descriptions
|
||||||
|
|
||||||
|
# Dioxus Dependency
|
||||||
|
|
||||||
|
You can add Dioxus to your `Cargo.toml` like this:
|
||||||
|
|
||||||
|
```toml
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { version = "0.7.1" }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = ["web", "webview", "server"]
|
||||||
|
web = ["dioxus/web"]
|
||||||
|
webview = ["dioxus/desktop"]
|
||||||
|
server = ["dioxus/server"]
|
||||||
|
```
|
||||||
|
|
||||||
|
# Launching your application
|
||||||
|
|
||||||
|
You need to create a main function that sets up the Dioxus runtime and mounts your root component.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus::launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
rsx! { "Hello, Dioxus!" }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Then serve with `dx serve`:
|
||||||
|
|
||||||
|
```sh
|
||||||
|
curl -sSL http://dioxus.dev/install.sh | sh
|
||||||
|
dx serve
|
||||||
|
```
|
||||||
|
|
||||||
|
# UI with RSX
|
||||||
|
|
||||||
|
```rust
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: "container", // Attribute
|
||||||
|
color: "red", // Inline styles
|
||||||
|
width: if condition { "100%" }, // Conditional attributes
|
||||||
|
"Hello, Dioxus!"
|
||||||
|
}
|
||||||
|
// Prefer loops over iterators
|
||||||
|
for i in 0..5 {
|
||||||
|
div { "{i}" } // use elements or components directly in loops
|
||||||
|
}
|
||||||
|
if condition {
|
||||||
|
div { "Condition is true!" } // use elements or components directly in conditionals
|
||||||
|
}
|
||||||
|
|
||||||
|
{children} // Expressions are wrapped in brace
|
||||||
|
{(0..5).map(|i| rsx! { span { "Item {i}" } })} // Iterators must be wrapped in braces
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Assets
|
||||||
|
|
||||||
|
The asset macro can be used to link to local files to use in your project. All links start with `/` and are relative to the root of your project.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
rsx! {
|
||||||
|
img {
|
||||||
|
src: asset!("/assets/image.png"),
|
||||||
|
alt: "An image",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Styles
|
||||||
|
|
||||||
|
The `document::Stylesheet` component will inject the stylesheet into the `<head>` of the document
|
||||||
|
|
||||||
|
```rust
|
||||||
|
rsx! {
|
||||||
|
document::Stylesheet {
|
||||||
|
href: asset!("/assets/styles.css"),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Components
|
||||||
|
|
||||||
|
Components are the building blocks of apps
|
||||||
|
|
||||||
|
* Component are functions annotated with the `#[component]` macro.
|
||||||
|
* The function name must start with a capital letter or contain an underscore.
|
||||||
|
* A component re-renders only under two conditions:
|
||||||
|
1. Its props change (as determined by `PartialEq`).
|
||||||
|
2. An internal reactive state it depends on is updated.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[component]
|
||||||
|
fn Input(mut value: Signal<String>) -> Element {
|
||||||
|
rsx! {
|
||||||
|
input {
|
||||||
|
value,
|
||||||
|
oninput: move |e| {
|
||||||
|
*value.write() = e.value();
|
||||||
|
},
|
||||||
|
onkeydown: move |e| {
|
||||||
|
if e.key() == Key::Enter {
|
||||||
|
value.write().clear();
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
Each component accepts function arguments (props)
|
||||||
|
|
||||||
|
* Props must be owned values, not references. Use `String` and `Vec<T>` instead of `&str` or `&[T]`.
|
||||||
|
* Props must implement `PartialEq` and `Clone`.
|
||||||
|
* To make props reactive and copy, you can wrap the type in `ReadOnlySignal`. Any reactive state like memos and resources that read `ReadOnlySignal` props will automatically re-run when the prop changes.
|
||||||
|
|
||||||
|
# State
|
||||||
|
|
||||||
|
A signal is a wrapper around a value that automatically tracks where it's read and written. Changing a signal's value causes code that relies on the signal to rerun.
|
||||||
|
|
||||||
|
## Local State
|
||||||
|
|
||||||
|
The `use_signal` hook creates state that is local to a single component. You can call the signal like a function (e.g. `my_signal()`) to clone the value, or use `.read()` to get a reference. `.write()` gets a mutable reference to the value.
|
||||||
|
|
||||||
|
Use `use_memo` to create a memoized value that recalculates when its dependencies change. Memos are useful for expensive calculations that you don't want to repeat unnecessarily.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[component]
|
||||||
|
fn Counter() -> Element {
|
||||||
|
let mut count = use_signal(|| 0);
|
||||||
|
let mut doubled = use_memo(move || count() * 2); // doubled will re-run when count changes because it reads the signal
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
h1 { "Count: {count}" } // Counter will re-render when count changes because it reads the signal
|
||||||
|
h2 { "Doubled: {doubled}" }
|
||||||
|
button {
|
||||||
|
onclick: move |_| *count.write() += 1, // Writing to the signal rerenders Counter
|
||||||
|
"Increment"
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
onclick: move |_| count.with_mut(|count| *count += 1), // use with_mut to mutate the signal
|
||||||
|
"Increment with with_mut"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Context API
|
||||||
|
|
||||||
|
The Context API allows you to share state down the component tree. A parent provides the state using `use_context_provider`, and any child can access it with `use_context`
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
let mut theme = use_signal(|| "light".to_string());
|
||||||
|
use_context_provider(|| theme); // Provide a type to children
|
||||||
|
rsx! { Child {} }
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn Child() -> Element {
|
||||||
|
let theme = use_context::<Signal<String>>(); // Consume the same type
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
"Current theme: {theme}"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Async
|
||||||
|
|
||||||
|
For state that depends on an asynchronous operation (like a network request), Dioxus provides a hook called `use_resource`. This hook manages the lifecycle of the async task and provides the result to your component.
|
||||||
|
|
||||||
|
* The `use_resource` hook takes an `async` closure. It re-runs this closure whenever any signals it depends on (reads) are updated
|
||||||
|
* The `Resource` object returned can be in several states when read:
|
||||||
|
1. `None` if the resource is still loading
|
||||||
|
2. `Some(value)` if the resource has successfully loaded
|
||||||
|
|
||||||
|
```rust
|
||||||
|
let mut dog = use_resource(move || async move {
|
||||||
|
// api request
|
||||||
|
});
|
||||||
|
|
||||||
|
match dog() {
|
||||||
|
Some(dog_info) => rsx! { Dog { dog_info } },
|
||||||
|
None => rsx! { "Loading..." },
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
# Routing
|
||||||
|
|
||||||
|
All possible routes are defined in a single Rust `enum` that derives `Routable`. Each variant represents a route and is annotated with `#[route("/path")]`. Dynamic Segments can capture parts of the URL path as parameters by using `:name` in the route string. These become fields in the enum variant.
|
||||||
|
|
||||||
|
The `Router<Route> {}` component is the entry point that manages rendering the correct component for the current URL.
|
||||||
|
|
||||||
|
You can use the `#[layout(NavBar)]` to create a layout shared between pages and place an `Outlet<Route> {}` inside your layout component. The child routes will be rendered in the outlet.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[derive(Routable, Clone, PartialEq)]
|
||||||
|
enum Route {
|
||||||
|
#[layout(NavBar)] // This will use NavBar as the layout for all routes
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
#[route("/blog/:id")] // Dynamic segment
|
||||||
|
BlogPost { id: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NavBar() -> Element {
|
||||||
|
rsx! {
|
||||||
|
a { href: "/", "Home" }
|
||||||
|
Outlet<Route> {} // Renders Home or BlogPost
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
rsx! { Router::<Route> {} }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
```toml
|
||||||
|
dioxus = { version = "0.7.1", features = ["router"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
# Fullstack
|
||||||
|
|
||||||
|
Fullstack enables server rendering and ipc calls. It uses Cargo features (`server` and a client feature like `web`) to split the code into a server and client binaries.
|
||||||
|
|
||||||
|
```toml
|
||||||
|
dioxus = { version = "0.7.1", features = ["fullstack"] }
|
||||||
|
```
|
||||||
|
|
||||||
|
## Server Functions
|
||||||
|
|
||||||
|
Use the `#[post]` / `#[get]` macros to define an `async` function that will only run on the server. On the server, this macro generates an API endpoint. On the client, it generates a function that makes an HTTP request to that endpoint.
|
||||||
|
|
||||||
|
```rust
|
||||||
|
#[post("/api/double/:path/&query")]
|
||||||
|
async fn double_server(number: i32, path: String, query: i32) -> Result<i32, ServerFnError> {
|
||||||
|
tokio::time::sleep(std::time::Duration::from_secs(1)).await;
|
||||||
|
Ok(number * 2)
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Hydration
|
||||||
|
|
||||||
|
Hydration is the process of making a server-rendered HTML page interactive on the client. The server sends the initial HTML, and then the client-side runs, attaches event listeners, and takes control of future rendering.
|
||||||
|
|
||||||
|
### Errors
|
||||||
|
The initial UI rendered by the component on the client must be identical to the UI rendered on the server.
|
||||||
|
|
||||||
|
* Use the `use_server_future` hook instead of `use_resource`. It runs the future on the server, serializes the result, and sends it to the client, ensuring the client has the data immediately for its first render.
|
||||||
|
* Any code that relies on browser-specific APIs (like accessing `localStorage`) must be run *after* hydration. Place this code inside a `use_effect` hook.
|
||||||
6157
Cargo.lock
generated
Normal file
6157
Cargo.lock
generated
Normal file
File diff suppressed because it is too large
Load Diff
19
Cargo.toml
Normal file
19
Cargo.toml
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
[workspace]
|
||||||
|
resolver = "2"
|
||||||
|
members = [
|
||||||
|
"packages/ui",
|
||||||
|
"packages/web",
|
||||||
|
"packages/desktop",
|
||||||
|
"packages/mobile",
|
||||||
|
"packages/api",
|
||||||
|
]
|
||||||
|
|
||||||
|
[workspace.dependencies]
|
||||||
|
dioxus = { version = "0.7.1" }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
# workspace
|
||||||
|
ui = { path = "packages/ui" }
|
||||||
|
api = { path = "packages/api" }
|
||||||
75
README.md
Normal file
75
README.md
Normal file
@@ -0,0 +1,75 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
Your new workspace contains a member crate for each of the web, desktop and mobile platforms, a `ui` crate for shared components and a `api` crate for shared backend logic:
|
||||||
|
|
||||||
|
```
|
||||||
|
your_project/
|
||||||
|
├─ README.md
|
||||||
|
├─ Cargo.toml
|
||||||
|
└─ packages/
|
||||||
|
├─ web/
|
||||||
|
│ └─ ... # Web specific UI/logic
|
||||||
|
├─ desktop/
|
||||||
|
│ └─ ... # Desktop specific UI/logic
|
||||||
|
├─ mobile/
|
||||||
|
│ └─ ... # Mobile specific UI/logic
|
||||||
|
├─ api/
|
||||||
|
│ └─ ... # All shared server logic
|
||||||
|
└─ ui/
|
||||||
|
└─ ... # Component shared between multiple platforms
|
||||||
|
```
|
||||||
|
|
||||||
|
## Platform crates
|
||||||
|
|
||||||
|
Each platform crate contains the entry point for the platform, and any assets, components and dependencies that are specific to that platform. For example, the desktop crate in the workspace looks something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
desktop/ # The desktop crate contains all platform specific UI, logic and dependencies for the desktop app
|
||||||
|
├─ assets/ # Assets used by the desktop app - Any platform specific assets should go in this folder
|
||||||
|
├─ src/
|
||||||
|
│ ├─ main.rs # The entrypoint for the desktop app. It also defines the routes for the desktop platform
|
||||||
|
│ ├─ views/ # The views each route will render in the desktop version of the app
|
||||||
|
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
|
||||||
|
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
|
||||||
|
│ │ ├─ home.rs # The component that will render at the / route
|
||||||
|
├─ Cargo.toml # The desktop crate's Cargo.toml - This should include all desktop specific dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
When you start developing with the workspace setup each of the platform crates will look almost identical. The UI starts out exactly the same on all platforms. However, as you continue developing your application, this setup makes it easy to let the views for each platform change independently.
|
||||||
|
|
||||||
|
## Shared UI crate
|
||||||
|
|
||||||
|
The workspace contains a `ui` crate with components that are shared between multiple platforms. You should put any UI elements you want to use in multiple platforms in this crate. You can also put some shared client side logic in this crate, but be careful to not pull in platform specific dependencies. The `ui` crate starts out something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ lib.rs # The entrypoint for the ui crate
|
||||||
|
│ ├─ hero.rs # The Hero component that will be used in every platform
|
||||||
|
│ ├─ echo.rs # The shared echo component that communicates with the server
|
||||||
|
│ ├─ navbar.rs # The Navbar component that will be used in the layout of every platform's router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Shared backend logic
|
||||||
|
|
||||||
|
The workspace contains a `api` crate with shared backend logic. This crate defines all of the shared server functions for all platforms. Server functions are async functions that expose a public API on the server. They can be called like a normal async function from the client. When you run `dx serve`, all of the server functions will be collected in the server build and hosted on a public API for the client to call. The `api` crate starts out something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
api/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ lib.rs # Exports a server function that echos the input string
|
||||||
|
```
|
||||||
|
|
||||||
|
### Serving Your App
|
||||||
|
|
||||||
|
Navigate to the platform crate of your choice:
|
||||||
|
```bash
|
||||||
|
cd web
|
||||||
|
```
|
||||||
|
|
||||||
|
and serve:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dx serve
|
||||||
|
```
|
||||||
|
|
||||||
8
clippy.toml
Normal file
8
clippy.toml
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
await-holding-invalid-types = [
|
||||||
|
"generational_box::GenerationalRef",
|
||||||
|
{ path = "generational_box::GenerationalRef", reason = "Reads should not be held over an await point. This will cause any writes to fail while the await is pending since the read borrow is still active." },
|
||||||
|
"generational_box::GenerationalRefMut",
|
||||||
|
{ path = "generational_box::GenerationalRefMut", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
|
||||||
|
"dioxus_signals::WriteLock",
|
||||||
|
{ path = "dioxus_signals::WriteLock", reason = "Write should not be held over an await point. This will cause any reads or writes to fail while the await is pending since the write borrow is still active." },
|
||||||
|
]
|
||||||
13
packages/api/Cargo.toml
Normal file
13
packages/api/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "api"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { workspace = true, features = ["fullstack"] }
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde"] }
|
||||||
|
uuid = { version = "1.0", features = ["v4", "serde"] }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
server = ["dioxus/server"]
|
||||||
13
packages/api/README.md
Normal file
13
packages/api/README.md
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
# API
|
||||||
|
|
||||||
|
This crate contains all shared fullstack server functions. This is a great place to place any server-only logic you would like to expose in multiple platforms like a method that accesses your database or a method that sends an email.
|
||||||
|
|
||||||
|
This crate will be built twice:
|
||||||
|
1. Once for the server build with the `dioxus/server` feature enabled
|
||||||
|
2. Once for the client build with the client feature disabled
|
||||||
|
|
||||||
|
During the server build, the server functions will be collected and hosted on a public API for the client to call. During the client build, the server functions will be compiled into the client build.
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Most server dependencies (like sqlx and tokio) will not compile on client platforms like WASM. To avoid building server dependencies on the client, you should add platform specific dependencies under the `server` feature in the [Cargo.toml](../Cargo.toml) file. More details about managing server only dependencies can be found in the [Dioxus guide](https://dioxuslabs.com/learn/0.7/guides/fullstack/managing_dependencies#adding-server-only-dependencies).
|
||||||
115
packages/api/src/lib.rs
Normal file
115
packages/api/src/lib.rs
Normal file
@@ -0,0 +1,115 @@
|
|||||||
|
//! This crate contains all shared fullstack server functions.
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use std::sync::{Mutex, LazyLock};
|
||||||
|
use chrono::{DateTime, Utc};
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Serialize, Deserialize, PartialEq)]
|
||||||
|
pub struct Note {
|
||||||
|
pub id: String,
|
||||||
|
pub title: String,
|
||||||
|
pub content: String,
|
||||||
|
pub tags: Vec<String>,
|
||||||
|
pub created_at: DateTime<Utc>,
|
||||||
|
pub updated_at: DateTime<Utc>,
|
||||||
|
pub is_pinned: bool,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Note {
|
||||||
|
pub fn new(title: String, content: String, tags: Vec<String>) -> Self {
|
||||||
|
let now = Utc::now();
|
||||||
|
Self {
|
||||||
|
id: uuid::Uuid::new_v4().to_string(),
|
||||||
|
title,
|
||||||
|
content,
|
||||||
|
tags,
|
||||||
|
created_at: now,
|
||||||
|
updated_at: now,
|
||||||
|
is_pinned: false,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
type NotesStore = Mutex<Vec<Note>>;
|
||||||
|
|
||||||
|
static NOTES: LazyLock<NotesStore> = LazyLock::new(|| {
|
||||||
|
Mutex::new(vec![
|
||||||
|
Note::new(
|
||||||
|
"Welcome to Braindump".to_string(),
|
||||||
|
"# Welcome\n\nThis is your braindump app. Quickly capture your thoughts, ideas, and notes here.\n\n## Features\n- Quick note capture\n- Markdown support\n- Tag and categorize\n- Search and filter\n- Pin important notes".to_string(),
|
||||||
|
vec!["welcome".to_string(), "guide".to_string()],
|
||||||
|
),
|
||||||
|
Note::new(
|
||||||
|
"Project Ideas".to_string(),
|
||||||
|
"- Build a personal dashboard\n- Create a habit tracker\n- Develop a recipe manager\n- Make a fitness app\n- Design a reading list organizer".to_string(),
|
||||||
|
vec!["ideas".to_string(), "projects".to_string()],
|
||||||
|
),
|
||||||
|
Note::new(
|
||||||
|
"Meeting Notes".to_string(),
|
||||||
|
"## Team Sync - Daily\n\nAttendees: Alice, Bob, Charlie\n\nTopics:\n1. Sprint progress review\n2. Blocker discussion\n3. Planning for next week\n\nAction items:\n- [ ] Review PR #123\n- [ ] Update documentation\n- [ ] Schedule follow-up meeting".to_string(),
|
||||||
|
vec!["work".to_string(), "meetings".to_string()],
|
||||||
|
),
|
||||||
|
])
|
||||||
|
});
|
||||||
|
|
||||||
|
#[post("/api/notes")]
|
||||||
|
pub async fn create_note(title: String, content: String, tags: Vec<String>) -> Result<String, ServerFnError> {
|
||||||
|
let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
let note = Note::new(title, content, tags);
|
||||||
|
notes.push(note.clone());
|
||||||
|
Ok(note.id)
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/notes")]
|
||||||
|
pub async fn list_notes() -> Result<Vec<Note>, ServerFnError> {
|
||||||
|
let notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
Ok(notes.clone())
|
||||||
|
}
|
||||||
|
|
||||||
|
#[get("/api/notes/:id")]
|
||||||
|
pub async fn get_note(id: String) -> Result<Note, ServerFnError> {
|
||||||
|
let notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
notes
|
||||||
|
.iter()
|
||||||
|
.find(|n| n.id == id)
|
||||||
|
.cloned()
|
||||||
|
.ok_or_else(|| ServerFnError::new("Note not found".to_string()))
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/notes/:id")]
|
||||||
|
pub async fn update_note(id: String, title: String, content: String, tags: Vec<String>, is_pinned: bool) -> Result<(), ServerFnError> {
|
||||||
|
let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
if let Some(note) = notes.iter_mut().find(|n| n.id == id) {
|
||||||
|
note.title = title;
|
||||||
|
note.content = content;
|
||||||
|
note.tags = tags;
|
||||||
|
note.is_pinned = is_pinned;
|
||||||
|
note.updated_at = Utc::now();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ServerFnError::new("Note not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/notes/:id/delete")]
|
||||||
|
pub async fn delete_note(id: String) -> Result<(), ServerFnError> {
|
||||||
|
let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
if notes.iter().any(|n| n.id == id) {
|
||||||
|
notes.retain(|n| n.id != id);
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ServerFnError::new("Note not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[post("/api/notes/:id/pin")]
|
||||||
|
pub async fn toggle_pin_note(id: String) -> Result<(), ServerFnError> {
|
||||||
|
let mut notes = NOTES.lock().map_err(|e| ServerFnError::new(e.to_string()))?;
|
||||||
|
if let Some(note) = notes.iter_mut().find(|n| n.id == id) {
|
||||||
|
note.is_pinned = !note.is_pinned;
|
||||||
|
note.updated_at = Utc::now();
|
||||||
|
Ok(())
|
||||||
|
} else {
|
||||||
|
Err(ServerFnError::new("Note not found".to_string()))
|
||||||
|
}
|
||||||
|
}
|
||||||
13
packages/desktop/Cargo.toml
Normal file
13
packages/desktop/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "desktop"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { workspace = true, features = ["router", "fullstack"] }
|
||||||
|
ui = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
desktop = ["dioxus/desktop"]
|
||||||
|
server = ["dioxus/server", "ui/server"]
|
||||||
30
packages/desktop/README.md
Normal file
30
packages/desktop/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
The desktop crate defines the entrypoint for the desktop app along with any assets, components and dependencies that are specific to desktop builds. The desktop crate starts out something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
desktop/
|
||||||
|
├─ assets/ # Assets used by the desktop app - Any platform specific assets should go in this folder
|
||||||
|
├─ src/
|
||||||
|
│ ├─ main.rs # The entrypoint for the desktop app.It also defines the routes for the desktop platform
|
||||||
|
│ ├─ views/ # The views each route will render in the desktop version of the app
|
||||||
|
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
|
||||||
|
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
|
||||||
|
│ │ ├─ home.rs # The component that will render at the / route
|
||||||
|
├─ Cargo.toml # The desktop crate's Cargo.toml - This should include all desktop specific dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
Since you have fullstack enabled, the desktop crate will be built two times:
|
||||||
|
1. Once for the server build with the `server` feature enabled
|
||||||
|
2. Once for the client build with the `desktop` feature enabled
|
||||||
|
|
||||||
|
You should make all desktop specific dependencies optional and only enabled in the `desktop` feature. This will ensure that the server builds don't pull in desktop specific dependencies which cuts down on build times significantly.
|
||||||
|
|
||||||
|
### Serving Your Desktop App
|
||||||
|
|
||||||
|
You can start your desktop app with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dx serve
|
||||||
|
```
|
||||||
8
packages/desktop/assets/blog.css
Normal file
8
packages/desktop/assets/blog.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#blog {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
6
packages/desktop/assets/main.css
Normal file
6
packages/desktop/assets/main.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
body {
|
||||||
|
background-color: #0f1116;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
54
packages/desktop/src/main.rs
Normal file
54
packages/desktop/src/main.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use ui::Navbar;
|
||||||
|
use views::{Blog, Home};
|
||||||
|
|
||||||
|
mod views;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
|
#[rustfmt::skip]
|
||||||
|
enum Route {
|
||||||
|
#[layout(DesktopNavbar)]
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
#[route("/blog/:id")]
|
||||||
|
Blog { id: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus::launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
// Build cool things ✌️
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
// Global app resources
|
||||||
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
|
||||||
|
Router::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A desktop-specific Router around the shared `Navbar` component
|
||||||
|
/// which allows us to use the desktop-specific `Route` enum.
|
||||||
|
#[component]
|
||||||
|
fn DesktopNavbar() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Navbar {
|
||||||
|
Link {
|
||||||
|
to: Route::Home {},
|
||||||
|
"Home"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: 1 },
|
||||||
|
"Blog"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Outlet::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/desktop/src/views/blog.rs
Normal file
30
packages/desktop/src/views/blog.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::Route;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const BLOG_CSS: Asset = asset!("/assets/blog.css");
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Blog(id: i32) -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: BLOG_CSS}
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "blog",
|
||||||
|
|
||||||
|
// Content
|
||||||
|
h1 { "This is blog #{id}!" }
|
||||||
|
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
|
||||||
|
|
||||||
|
// Navigation links
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id - 1 },
|
||||||
|
"Previous"
|
||||||
|
}
|
||||||
|
span { " <---> " }
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id + 1 },
|
||||||
|
"Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/desktop/src/views/home.rs
Normal file
10
packages/desktop/src/views/home.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use ui::{Echo, Hero};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Hero {}
|
||||||
|
Echo {}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/desktop/src/views/mod.rs
Normal file
5
packages/desktop/src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod home;
|
||||||
|
pub use home::Home;
|
||||||
|
|
||||||
|
mod blog;
|
||||||
|
pub use blog::Blog;
|
||||||
13
packages/mobile/Cargo.toml
Normal file
13
packages/mobile/Cargo.toml
Normal file
@@ -0,0 +1,13 @@
|
|||||||
|
[package]
|
||||||
|
name = "mobile"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { workspace = true, features = ["router", "fullstack"] }
|
||||||
|
ui = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
mobile = ["dioxus/mobile"]
|
||||||
|
server = ["dioxus/server", "ui/server"]
|
||||||
30
packages/mobile/README.md
Normal file
30
packages/mobile/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
The mobile crate defines the entrypoint for the mobile app along with any assets, components and dependencies that are specific to mobile builds. The mobile crate starts out something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
mobile/
|
||||||
|
├─ assets/ # Assets used by the mobile app - Any platform specific assets should go in this folder
|
||||||
|
├─ src/
|
||||||
|
│ ├─ main.rs # The entrypoint for the mobile app.It also defines the routes for the mobile platform
|
||||||
|
│ ├─ views/ # The views each route will render in the mobile version of the app
|
||||||
|
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
|
||||||
|
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
|
||||||
|
│ │ ├─ home.rs # The component that will render at the / route
|
||||||
|
├─ Cargo.toml # The mobile crate's Cargo.toml - This should include all mobile specific dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
Since you have fullstack enabled, the mobile crate will be built two times:
|
||||||
|
1. Once for the server build with the `server` feature enabled
|
||||||
|
2. Once for the client build with the `mobile` feature enabled
|
||||||
|
|
||||||
|
You should make all mobile specific dependencies optional and only enabled in the `mobile` feature. This will ensure that the server builds don't pull in mobile specific dependencies which cuts down on build times significantly.
|
||||||
|
|
||||||
|
### Serving Your Mobile App
|
||||||
|
|
||||||
|
Mobile platforms are shared in a single crate. To serve mobile, you need to explicitly set your target device to `android` or `ios`:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dx serve --platform android
|
||||||
|
```
|
||||||
8
packages/mobile/assets/blog.css
Normal file
8
packages/mobile/assets/blog.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#blog {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
6
packages/mobile/assets/main.css
Normal file
6
packages/mobile/assets/main.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
body {
|
||||||
|
background-color: #0f1116;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
54
packages/mobile/src/main.rs
Normal file
54
packages/mobile/src/main.rs
Normal file
@@ -0,0 +1,54 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
use ui::Navbar;
|
||||||
|
use views::{Blog, Home};
|
||||||
|
|
||||||
|
mod views;
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, Routable, PartialEq)]
|
||||||
|
#[rustfmt::skip]
|
||||||
|
enum Route {
|
||||||
|
#[layout(MobileNavbar)]
|
||||||
|
#[route("/")]
|
||||||
|
Home {},
|
||||||
|
#[route("/blog/:id")]
|
||||||
|
Blog { id: i32 },
|
||||||
|
}
|
||||||
|
|
||||||
|
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus::launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
// Build cool things ✌️
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
// Global app resources
|
||||||
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
|
||||||
|
Router::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/// A mobile-specific Router around the shared `Navbar` component
|
||||||
|
/// which allows us to use the mobile-specific `Route` enum.
|
||||||
|
#[component]
|
||||||
|
fn MobileNavbar() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Navbar {
|
||||||
|
Link {
|
||||||
|
to: Route::Home {},
|
||||||
|
"Home"
|
||||||
|
}
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: 1 },
|
||||||
|
"Blog"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
Outlet::<Route> {}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/mobile/src/views/blog.rs
Normal file
30
packages/mobile/src/views/blog.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::Route;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const BLOG_CSS: Asset = asset!("/assets/blog.css");
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Blog(id: i32) -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: BLOG_CSS}
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "blog",
|
||||||
|
|
||||||
|
// Content
|
||||||
|
h1 { "This is blog #{id}!" }
|
||||||
|
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
|
||||||
|
|
||||||
|
// Navigation links
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id - 1 },
|
||||||
|
"Previous"
|
||||||
|
}
|
||||||
|
span { " <---> " }
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id + 1 },
|
||||||
|
"Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/mobile/src/views/home.rs
Normal file
10
packages/mobile/src/views/home.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use ui::{Echo, Hero};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Hero {}
|
||||||
|
Echo {}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/mobile/src/views/mod.rs
Normal file
5
packages/mobile/src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod home;
|
||||||
|
pub use home::Home;
|
||||||
|
|
||||||
|
mod blog;
|
||||||
|
pub use blog::Blog;
|
||||||
11
packages/ui/Cargo.toml
Normal file
11
packages/ui/Cargo.toml
Normal file
@@ -0,0 +1,11 @@
|
|||||||
|
[package]
|
||||||
|
name = "ui"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { workspace = true }
|
||||||
|
api = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
server = ["api/server"]
|
||||||
16
packages/ui/README.md
Normal file
16
packages/ui/README.md
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
# UI
|
||||||
|
|
||||||
|
This crate contains all shared components for the workspace. This is a great place to place any UI you would like to use in multiple platforms like a common `Button` or `Navbar` component.
|
||||||
|
|
||||||
|
```
|
||||||
|
ui/
|
||||||
|
├─ src/
|
||||||
|
│ ├─ lib.rs # The entrypoint for the ui crate
|
||||||
|
│ ├─ hero.rs # The Hero component that will be used in every platform
|
||||||
|
│ ├─ echo.rs # The shared echo component that communicates with the server
|
||||||
|
│ ├─ navbar.rs # The Navbar component that will be used in the layout of every platform's router
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
|
||||||
|
Since this crate is shared between multiple platforms, it should not pull in any platform specific dependencies. For example, if you want to use the `web_sys` crate in the web build of your app, you should not add it to this crate. Instead, you should add platform specific dependencies to the [web](../web/Cargo.toml), [desktop](../desktop/Cargo.toml), or [mobile](../mobile/Cargo.toml) crates.
|
||||||
20
packages/ui/assets/header.svg
Normal file
20
packages/ui/assets/header.svg
Normal file
File diff suppressed because one or more lines are too long
|
After Width: | Height: | Size: 23 KiB |
34
packages/ui/assets/styling/echo.css
Normal file
34
packages/ui/assets/styling/echo.css
Normal file
@@ -0,0 +1,34 @@
|
|||||||
|
#echo {
|
||||||
|
width: 360px;
|
||||||
|
margin-left: auto;
|
||||||
|
margin-right: auto;
|
||||||
|
margin-top: 50px;
|
||||||
|
background-color: #1e222d;
|
||||||
|
padding: 20px;
|
||||||
|
border-radius: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#echo>h4 {
|
||||||
|
margin: 0px 0px 15px 0px;
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
#echo>input {
|
||||||
|
border: none;
|
||||||
|
border-bottom: 1px white solid;
|
||||||
|
background-color: transparent;
|
||||||
|
color: #ffffff;
|
||||||
|
transition: border-bottom-color 0.2s ease;
|
||||||
|
outline: none;
|
||||||
|
display: block;
|
||||||
|
padding: 0px 0px 5px 0px;
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
#echo>input:focus {
|
||||||
|
border-bottom-color: #6d85c6;
|
||||||
|
}
|
||||||
|
|
||||||
|
#echo>p {
|
||||||
|
margin: 20px 0px 0px auto;
|
||||||
|
}
|
||||||
35
packages/ui/assets/styling/hero.css
Normal file
35
packages/ui/assets/styling/hero.css
Normal file
@@ -0,0 +1,35 @@
|
|||||||
|
#hero {
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
justify-content: center;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links {
|
||||||
|
width: 400px;
|
||||||
|
text-align: left;
|
||||||
|
font-size: x-large;
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a {
|
||||||
|
color: white;
|
||||||
|
text-decoration: none;
|
||||||
|
margin-top: 20px;
|
||||||
|
margin: 10px 0px;
|
||||||
|
border: white 1px solid;
|
||||||
|
border-radius: 5px;
|
||||||
|
padding: 10px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#links a:hover {
|
||||||
|
background-color: #1f1f1f;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
#header {
|
||||||
|
max-width: 1200px;
|
||||||
|
}
|
||||||
16
packages/ui/assets/styling/navbar.css
Normal file
16
packages/ui/assets/styling/navbar.css
Normal file
@@ -0,0 +1,16 @@
|
|||||||
|
#navbar {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: row;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-right: 20px;
|
||||||
|
text-decoration: none;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
#navbar a:hover {
|
||||||
|
cursor: pointer;
|
||||||
|
color: #91a4d2;
|
||||||
|
}
|
||||||
31
packages/ui/src/echo.rs
Normal file
31
packages/ui/src/echo.rs
Normal file
@@ -0,0 +1,31 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const ECHO_CSS: Asset = asset!("/assets/styling/echo.css");
|
||||||
|
|
||||||
|
/// Echo component that demonstrates fullstack server functions.
|
||||||
|
#[component]
|
||||||
|
pub fn Echo() -> Element {
|
||||||
|
let mut response = use_signal(|| String::new());
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: ECHO_CSS }
|
||||||
|
div {
|
||||||
|
id: "echo",
|
||||||
|
h4 { "ServerFn Echo" }
|
||||||
|
input {
|
||||||
|
placeholder: "Type here to echo...",
|
||||||
|
oninput: move |event| async move {
|
||||||
|
let data = api::echo(event.value()).await.unwrap();
|
||||||
|
response.set(data);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
|
||||||
|
if !response().is_empty() {
|
||||||
|
p {
|
||||||
|
"Server echoed: "
|
||||||
|
i { "{response}" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
24
packages/ui/src/hero.rs
Normal file
24
packages/ui/src/hero.rs
Normal file
@@ -0,0 +1,24 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const HERO_CSS: Asset = asset!("/assets/styling/hero.css");
|
||||||
|
const HEADER_SVG: Asset = asset!("/assets/header.svg");
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Hero() -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: HERO_CSS }
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "hero",
|
||||||
|
img { src: HEADER_SVG, id: "header" }
|
||||||
|
div { id: "links",
|
||||||
|
a { href: "https://dioxuslabs.com/learn/0.7/", "📚 Learn Dioxus" }
|
||||||
|
a { href: "https://dioxuslabs.com/awesome", "🚀 Awesome Dioxus" }
|
||||||
|
a { href: "https://github.com/dioxus-community/", "📡 Community Libraries" }
|
||||||
|
a { href: "https://github.com/DioxusLabs/sdk", "⚙️ Dioxus Development Kit" }
|
||||||
|
a { href: "https://marketplace.visualstudio.com/items?itemName=DioxusLabs.dioxus", "💫 VSCode Extension" }
|
||||||
|
a { href: "https://discord.gg/XgGxMSkvUM", "👋 Community Discord" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
7
packages/ui/src/lib.rs
Normal file
7
packages/ui/src/lib.rs
Normal file
@@ -0,0 +1,7 @@
|
|||||||
|
//! This crate contains all shared UI for the workspace.
|
||||||
|
|
||||||
|
mod hero;
|
||||||
|
pub use hero::Hero;
|
||||||
|
|
||||||
|
mod navbar;
|
||||||
|
pub use navbar::Navbar;
|
||||||
15
packages/ui/src/navbar.rs
Normal file
15
packages/ui/src/navbar.rs
Normal file
@@ -0,0 +1,15 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const NAVBAR_CSS: Asset = asset!("/assets/styling/navbar.css");
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Navbar(children: Element) -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: NAVBAR_CSS }
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "navbar",
|
||||||
|
{children}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
14
packages/web/Cargo.toml
Normal file
14
packages/web/Cargo.toml
Normal file
@@ -0,0 +1,14 @@
|
|||||||
|
[package]
|
||||||
|
name = "web"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
|
||||||
|
[dependencies]
|
||||||
|
dioxus = { workspace = true, features = ["router", "fullstack"] }
|
||||||
|
ui = { workspace = true }
|
||||||
|
api = { workspace = true }
|
||||||
|
|
||||||
|
[features]
|
||||||
|
default = []
|
||||||
|
web = ["dioxus/web"]
|
||||||
|
server = ["dioxus/server", "ui/server", "api/server"]
|
||||||
30
packages/web/README.md
Normal file
30
packages/web/README.md
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
# Development
|
||||||
|
|
||||||
|
The web crate defines the entrypoint for the web app along with any assets, components and dependencies that are specific to web builds. The web crate starts out something like this:
|
||||||
|
|
||||||
|
```
|
||||||
|
web/
|
||||||
|
├─ assets/ # Assets used by the web app - Any platform specific assets should go in this folder
|
||||||
|
├─ src/
|
||||||
|
│ ├─ main.rs # The entrypoint for the web app.It also defines the routes for the web platform
|
||||||
|
│ ├─ views/ # The views each route will render in the web version of the app
|
||||||
|
│ │ ├─ mod.rs # Defines the module for the views route and re-exports the components for each route
|
||||||
|
│ │ ├─ blog.rs # The component that will render at the /blog/:id route
|
||||||
|
│ │ ├─ home.rs # The component that will render at the / route
|
||||||
|
├─ Cargo.toml # The web crate's Cargo.toml - This should include all web specific dependencies
|
||||||
|
```
|
||||||
|
|
||||||
|
## Dependencies
|
||||||
|
Since you have fullstack enabled, the web crate will be built two times:
|
||||||
|
1. Once for the server build with the `server` feature enabled
|
||||||
|
2. Once for the client build with the `web` feature enabled
|
||||||
|
|
||||||
|
You should make all web specific dependencies optional and only enabled in the `web` feature. This will ensure that the server builds don't pull in web specific dependencies which cuts down on build times significantly.
|
||||||
|
|
||||||
|
### Serving Your Web App
|
||||||
|
|
||||||
|
You can start your web app with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
dx serve
|
||||||
|
```
|
||||||
8
packages/web/assets/blog.css
Normal file
8
packages/web/assets/blog.css
Normal file
@@ -0,0 +1,8 @@
|
|||||||
|
#blog {
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
|
|
||||||
|
#blog a {
|
||||||
|
color: #ffffff;
|
||||||
|
margin-top: 50px;
|
||||||
|
}
|
||||||
469
packages/web/assets/braindump.css
Normal file
469
packages/web/assets/braindump.css
Normal file
@@ -0,0 +1,469 @@
|
|||||||
|
:root {
|
||||||
|
--bg-primary: #0f0f14;
|
||||||
|
--bg-secondary: #1a1a24;
|
||||||
|
--bg-tertiary: #252533;
|
||||||
|
--bg-hover: #2f2f40;
|
||||||
|
--text-primary: #ffffff;
|
||||||
|
--text-secondary: #a0a0b0;
|
||||||
|
--text-muted: #6b6b80;
|
||||||
|
--accent: #8b5cf6;
|
||||||
|
--accent-hover: #7c3aed;
|
||||||
|
--border: #2f2f40;
|
||||||
|
--success: #10b981;
|
||||||
|
--danger: #ef4444;
|
||||||
|
--shadow: 0 4px 6px -1px rgba(0, 0, 0, 0.3);
|
||||||
|
--shadow-lg: 0 10px 15px -3px rgba(0, 0, 0, 0.4);
|
||||||
|
}
|
||||||
|
|
||||||
|
* {
|
||||||
|
margin: 0;
|
||||||
|
padding: 0;
|
||||||
|
box-sizing: border-box;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
font-family: -apple-system, BlinkMacSystemFont, 'Segoe UI', Roboto, Oxygen, Ubuntu, Cantarell, sans-serif;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
.braindump-container {
|
||||||
|
display: flex;
|
||||||
|
height: 100vh;
|
||||||
|
width: 100vw;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar {
|
||||||
|
width: 400px;
|
||||||
|
min-width: 300px;
|
||||||
|
max-width: 500px;
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
border-right: 1px solid var(--border);
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.app-title {
|
||||||
|
font-size: 1.5rem;
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #a78bfa);
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-container {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 8px;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
outline: none;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.search-input:focus {
|
||||||
|
border-color: var(--accent);
|
||||||
|
box-shadow: 0 0 0 3px rgba(139, 92, 246, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-section {
|
||||||
|
padding: 1rem 1.5rem 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-label {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.05em;
|
||||||
|
color: var(--text-muted);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-list {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
padding: 0.4rem 0.75rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 20px;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag.active {
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tag-filter {
|
||||||
|
padding: 0.75rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
font-size: 0.85rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter {
|
||||||
|
background: rgba(255, 255, 255, 0.2);
|
||||||
|
border: none;
|
||||||
|
color: white;
|
||||||
|
width: 24px;
|
||||||
|
height: 24px;
|
||||||
|
border-radius: 50%;
|
||||||
|
cursor: pointer;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 1rem;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.clear-filter:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.3);
|
||||||
|
}
|
||||||
|
|
||||||
|
.notes-list {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card {
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid transparent;
|
||||||
|
border-radius: 8px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
transform: translateX(2px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card.selected {
|
||||||
|
border-color: var(--accent);
|
||||||
|
background: rgba(139, 92, 246, 0.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-card-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--text-primary);
|
||||||
|
flex: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
.pin-indicator {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin-left: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-preview {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-meta {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-date {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-tags {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.mini-tag {
|
||||||
|
padding: 0.2rem 0.5rem;
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-radius: 4px;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
flex: 1;
|
||||||
|
overflow-y: auto;
|
||||||
|
background: var(--bg-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-icon {
|
||||||
|
font-size: 4rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state h2 {
|
||||||
|
font-size: 1.75rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-state p {
|
||||||
|
color: var(--text-secondary);
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
max-width: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button {
|
||||||
|
padding: 0.875rem 2rem;
|
||||||
|
background: linear-gradient(135deg, var(--accent), #a78bfa);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 8px;
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
box-shadow: var(--shadow);
|
||||||
|
}
|
||||||
|
|
||||||
|
.primary-button:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-editor {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
overflow-y: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
padding: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
color: var(--text-primary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.back-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-actions {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--bg-tertiary);
|
||||||
|
border: 1px solid var(--border);
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.icon-button:hover {
|
||||||
|
background: var(--bg-hover);
|
||||||
|
border-color: var(--accent);
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button {
|
||||||
|
width: 36px;
|
||||||
|
height: 36px;
|
||||||
|
background: var(--danger);
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.delete-button:hover {
|
||||||
|
background: #dc2626;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title-input {
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 2rem;
|
||||||
|
font-weight: 700;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-title-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-input {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-secondary);
|
||||||
|
outline: none;
|
||||||
|
border-bottom: 1px solid var(--border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.tags-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-content-input {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1.5rem;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
background: transparent;
|
||||||
|
border: none;
|
||||||
|
color: var(--text-primary);
|
||||||
|
outline: none;
|
||||||
|
resize: none;
|
||||||
|
line-height: 1.8;
|
||||||
|
font-family: 'Consolas', 'Monaco', monospace;
|
||||||
|
}
|
||||||
|
|
||||||
|
.note-content-input::placeholder {
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-footer {
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-top: 1px solid var(--border);
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-hint {
|
||||||
|
font-size: 0.85rem;
|
||||||
|
color: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button {
|
||||||
|
padding: 0.5rem 1.5rem;
|
||||||
|
background: var(--accent);
|
||||||
|
color: white;
|
||||||
|
border: none;
|
||||||
|
border-radius: 6px;
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: 600;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: all 0.2s;
|
||||||
|
}
|
||||||
|
|
||||||
|
.save-button:hover {
|
||||||
|
background: var(--accent-hover);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bg-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: var(--border);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: var(--text-muted);
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.sidebar {
|
||||||
|
width: 100%;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.main-content:has(.note-editor) {
|
||||||
|
display: block;
|
||||||
|
}
|
||||||
|
|
||||||
|
.sidebar:has(.main-content .note-editor) {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
BIN
packages/web/assets/favicon.ico
Normal file
BIN
packages/web/assets/favicon.ico
Normal file
Binary file not shown.
|
After Width: | Height: | Size: 130 KiB |
6
packages/web/assets/main.css
Normal file
6
packages/web/assets/main.css
Normal file
@@ -0,0 +1,6 @@
|
|||||||
|
body {
|
||||||
|
background-color: #0f1116;
|
||||||
|
color: #ffffff;
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
margin: 20px;
|
||||||
|
}
|
||||||
355
packages/web/src/braindump.rs
Normal file
355
packages/web/src/braindump.rs
Normal file
@@ -0,0 +1,355 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use api::Note;
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn BraindumpApp() -> Element {
|
||||||
|
rsx! {
|
||||||
|
BraindumpLayout {}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn BraindumpLayout() -> Element {
|
||||||
|
let notes = use_resource(|| async move {
|
||||||
|
api::list_notes().await.unwrap_or_default()
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut selected_note_id = use_signal(|| None::<String>);
|
||||||
|
let search_query = use_signal(|| String::new());
|
||||||
|
let filter_tag = use_signal(|| None::<String>);
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "braindump-container",
|
||||||
|
div { class: "sidebar",
|
||||||
|
SidebarHeader {
|
||||||
|
search_query: search_query.clone(),
|
||||||
|
filter_tag: filter_tag.clone(),
|
||||||
|
}
|
||||||
|
SidebarContent {
|
||||||
|
notes: notes.clone(),
|
||||||
|
selected_note_id: selected_note_id.clone(),
|
||||||
|
search_query: search_query.clone(),
|
||||||
|
filter_tag: filter_tag.clone(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
div { class: "main-content",
|
||||||
|
{match &*selected_note_id.read_unchecked() {
|
||||||
|
Some(id) => rsx! {
|
||||||
|
NoteEditor {
|
||||||
|
note_id: id.clone(),
|
||||||
|
on_go_back: move |_| {
|
||||||
|
selected_note_id.set(None);
|
||||||
|
},
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {
|
||||||
|
EmptyState {
|
||||||
|
on_new_note: move |_| {
|
||||||
|
spawn(async move {
|
||||||
|
if let Ok(id) = api::create_note(
|
||||||
|
"Untitled Note".to_string(),
|
||||||
|
String::new(),
|
||||||
|
vec![]
|
||||||
|
).await {
|
||||||
|
selected_note_id.set(Some(id));
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn SidebarHeader(
|
||||||
|
mut search_query: Signal<String>,
|
||||||
|
mut filter_tag: Signal<Option<String>>,
|
||||||
|
) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "sidebar-header",
|
||||||
|
h1 { class: "app-title", "Braindump" }
|
||||||
|
div { class: "search-container",
|
||||||
|
input {
|
||||||
|
class: "search-input",
|
||||||
|
placeholder: "Search notes...",
|
||||||
|
value: "{search_query}",
|
||||||
|
oninput: move |e| search_query.set(e.value()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn SidebarContent(
|
||||||
|
notes: Resource<Vec<Note>>,
|
||||||
|
mut selected_note_id: Signal<Option<String>>,
|
||||||
|
search_query: Signal<String>,
|
||||||
|
filter_tag: Signal<Option<String>>,
|
||||||
|
) -> Element {
|
||||||
|
let all_tags = use_memo(move || {
|
||||||
|
let notes = match &*notes.read_unchecked() {
|
||||||
|
Some(n) => n.clone(),
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
notes.iter().flat_map(|n| n.tags.clone()).collect::<std::collections::HashSet<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let filtered_notes = use_memo(move || {
|
||||||
|
let notes = match &*notes.read_unchecked() {
|
||||||
|
Some(n) => n.clone(),
|
||||||
|
None => vec![],
|
||||||
|
};
|
||||||
|
let search = search_query().to_lowercase();
|
||||||
|
let tag_filter = filter_tag();
|
||||||
|
|
||||||
|
notes.into_iter()
|
||||||
|
.filter(|n| {
|
||||||
|
if !search.is_empty() {
|
||||||
|
n.title.to_lowercase().contains(&search) ||
|
||||||
|
n.content.to_lowercase().contains(&search) ||
|
||||||
|
n.tags.iter().any(|t| t.to_lowercase().contains(&search))
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.filter(|n| {
|
||||||
|
if let Some(tag) = &tag_filter {
|
||||||
|
n.tags.contains(tag)
|
||||||
|
} else {
|
||||||
|
true
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.collect::<Vec<_>>()
|
||||||
|
});
|
||||||
|
|
||||||
|
let sorted_notes = use_memo(move || {
|
||||||
|
let mut notes = filtered_notes();
|
||||||
|
notes.sort_by(|a, b| {
|
||||||
|
if a.is_pinned != b.is_pinned {
|
||||||
|
b.is_pinned.cmp(&a.is_pinned)
|
||||||
|
} else {
|
||||||
|
b.updated_at.cmp(&a.updated_at)
|
||||||
|
}
|
||||||
|
});
|
||||||
|
notes
|
||||||
|
});
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
{match filter_tag() {
|
||||||
|
Some(tag) => rsx! {
|
||||||
|
div { class: "tag-filter",
|
||||||
|
span { "Filtering by: {tag}" }
|
||||||
|
button {
|
||||||
|
class: "clear-filter",
|
||||||
|
onclick: move |_| filter_tag.set(None),
|
||||||
|
"✕"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
None => rsx! {},
|
||||||
|
}}
|
||||||
|
|
||||||
|
{if !all_tags().is_empty() {
|
||||||
|
rsx! {
|
||||||
|
div { class: "tags-section",
|
||||||
|
div { class: "tags-label", "Tags" }
|
||||||
|
div { class: "tags-list",
|
||||||
|
// Tag filtering placeholder
|
||||||
|
button { class: "tag", "Work" }
|
||||||
|
button { class: "tag", "Personal" }
|
||||||
|
button { class: "tag", "Ideas" }
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {}
|
||||||
|
}}
|
||||||
|
|
||||||
|
div { class: "notes-list",
|
||||||
|
for note in sorted_notes() {
|
||||||
|
NoteCard {
|
||||||
|
note: note.clone(),
|
||||||
|
is_selected: selected_note_id().as_ref() == Some(¬e.id),
|
||||||
|
onclick: move |_| selected_note_id.set(Some(note.id.clone())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NoteCard(note: Note, is_selected: bool, onclick: EventHandler<MouseEvent>) -> Element {
|
||||||
|
let preview = note.content.lines().next().unwrap_or("No content");
|
||||||
|
let date = note.updated_at.format("%b %d, %Y").to_string();
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div {
|
||||||
|
class: if is_selected { "note-card selected" } else { "note-card" },
|
||||||
|
onclick,
|
||||||
|
div { class: "note-card-header",
|
||||||
|
h3 { class: "note-title", "{note.title}" }
|
||||||
|
{if note.is_pinned {
|
||||||
|
rsx! {
|
||||||
|
span { class: "pin-indicator", "📌" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
p { class: "note-preview", "{preview}" }
|
||||||
|
div { class: "note-meta",
|
||||||
|
span { class: "note-date", "{date}" }
|
||||||
|
{if !note.tags.is_empty() {
|
||||||
|
rsx! {
|
||||||
|
span { class: "note-tags",
|
||||||
|
{note.tags.iter().take(2).map(|tag| rsx! {
|
||||||
|
span { class: "mini-tag", "{tag}" }
|
||||||
|
})}
|
||||||
|
{if note.tags.len() > 2 {
|
||||||
|
rsx! {
|
||||||
|
span { class: "mini-tag", "+{note.tags.len() - 2}" }
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
rsx! {}
|
||||||
|
}}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn EmptyState(on_new_note: EventHandler<MouseEvent>) -> Element {
|
||||||
|
rsx! {
|
||||||
|
div { class: "empty-state",
|
||||||
|
div { class: "empty-icon", "🧠" }
|
||||||
|
h2 { "Welcome to Braindump" }
|
||||||
|
p { "Capture your thoughts, ideas, and notes quickly and easily." }
|
||||||
|
button {
|
||||||
|
class: "primary-button",
|
||||||
|
onclick: on_new_note,
|
||||||
|
"Create New Note"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn NoteEditor(note_id: String, on_go_back: EventHandler<MouseEvent>) -> Element {
|
||||||
|
let note_id_clone_for_resource = note_id.clone();
|
||||||
|
let note = use_resource(move || {
|
||||||
|
let id = note_id_clone_for_resource.clone();
|
||||||
|
async move {
|
||||||
|
api::get_note(id).await.ok()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut title = use_signal(|| String::new());
|
||||||
|
let mut content = use_signal(|| String::new());
|
||||||
|
let mut tags = use_signal(|| String::new());
|
||||||
|
let mut is_pinned = use_signal(|| false);
|
||||||
|
|
||||||
|
use_effect(move || {
|
||||||
|
let note_val = note.read_unchecked();
|
||||||
|
if let Some(Some(n)) = note_val.as_ref() {
|
||||||
|
title.set(n.title.clone());
|
||||||
|
content.set(n.content.clone());
|
||||||
|
tags.set(n.tags.join(", "));
|
||||||
|
is_pinned.set(n.is_pinned);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
let note_id_for_save = note_id.clone();
|
||||||
|
let save_note = move |_: MouseEvent| {
|
||||||
|
let id = note_id_for_save.clone();
|
||||||
|
let title_val = title();
|
||||||
|
let content_val = content();
|
||||||
|
let tags_input = tags();
|
||||||
|
let tags_list = tags_input
|
||||||
|
.split(',')
|
||||||
|
.map(|t| t.trim().to_string())
|
||||||
|
.filter(|t| !t.is_empty())
|
||||||
|
.collect();
|
||||||
|
let pinned = is_pinned();
|
||||||
|
|
||||||
|
spawn(async move {
|
||||||
|
let _ = api::update_note(id, title_val, content_val, tags_list, pinned).await;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let note_id_for_delete = note_id.clone();
|
||||||
|
let delete_note = move |_: MouseEvent| {
|
||||||
|
let id = note_id_for_delete.clone();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = api::delete_note(id).await;
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
let note_id_for_toggle = note_id.clone();
|
||||||
|
let toggle_pin = move |_: MouseEvent| {
|
||||||
|
let id = note_id_for_toggle.clone();
|
||||||
|
let current_pin = is_pinned();
|
||||||
|
spawn(async move {
|
||||||
|
let _ = api::toggle_pin_note(id).await;
|
||||||
|
is_pinned.set(!current_pin);
|
||||||
|
});
|
||||||
|
};
|
||||||
|
|
||||||
|
rsx! {
|
||||||
|
div { class: "note-editor",
|
||||||
|
div { class: "editor-header",
|
||||||
|
button {
|
||||||
|
class: "back-button",
|
||||||
|
onclick: move |evt| on_go_back.call(evt),
|
||||||
|
"← Back"
|
||||||
|
}
|
||||||
|
div { class: "editor-actions",
|
||||||
|
button {
|
||||||
|
class: "icon-button",
|
||||||
|
onclick: toggle_pin,
|
||||||
|
{if is_pinned() { rsx! { "📍" } } else { rsx! { "📌" } }}
|
||||||
|
}
|
||||||
|
button {
|
||||||
|
class: "delete-button",
|
||||||
|
onclick: delete_note,
|
||||||
|
"🗑️"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
class: "note-title-input",
|
||||||
|
placeholder: "Note title...",
|
||||||
|
value: "{title}",
|
||||||
|
oninput: move |e| title.set(e.value()),
|
||||||
|
}
|
||||||
|
input {
|
||||||
|
class: "tags-input",
|
||||||
|
placeholder: "Tags (comma separated)...",
|
||||||
|
value: "{tags}",
|
||||||
|
oninput: move |e| tags.set(e.value()),
|
||||||
|
}
|
||||||
|
textarea {
|
||||||
|
class: "note-content-input",
|
||||||
|
placeholder: "Write your note here...",
|
||||||
|
value: "{content}",
|
||||||
|
oninput: move |e| content.set(e.value()),
|
||||||
|
}
|
||||||
|
div { class: "editor-footer",
|
||||||
|
span { class: "save-hint", "Auto-saving..." }
|
||||||
|
button {
|
||||||
|
class: "save-button",
|
||||||
|
onclick: save_note,
|
||||||
|
"💾 Save"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
19
packages/web/src/main.rs
Normal file
19
packages/web/src/main.rs
Normal file
@@ -0,0 +1,19 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
mod braindump;
|
||||||
|
|
||||||
|
const MAIN_CSS: Asset = asset!("/assets/main.css");
|
||||||
|
const BRAINDUMP_CSS: Asset = asset!("/assets/braindump.css");
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
dioxus::launch(App);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
fn App() -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: MAIN_CSS }
|
||||||
|
document::Link { rel: "stylesheet", href: BRAINDUMP_CSS }
|
||||||
|
braindump::BraindumpApp {}
|
||||||
|
}
|
||||||
|
}
|
||||||
30
packages/web/src/views/blog.rs
Normal file
30
packages/web/src/views/blog.rs
Normal file
@@ -0,0 +1,30 @@
|
|||||||
|
use crate::Route;
|
||||||
|
use dioxus::prelude::*;
|
||||||
|
|
||||||
|
const BLOG_CSS: Asset = asset!("/assets/blog.css");
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Blog(id: i32) -> Element {
|
||||||
|
rsx! {
|
||||||
|
document::Link { rel: "stylesheet", href: BLOG_CSS}
|
||||||
|
|
||||||
|
div {
|
||||||
|
id: "blog",
|
||||||
|
|
||||||
|
// Content
|
||||||
|
h1 { "This is blog #{id}!" }
|
||||||
|
p { "In blog #{id}, we show how the Dioxus router works and how URL parameters can be passed as props to our route components." }
|
||||||
|
|
||||||
|
// Navigation links
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id - 1 },
|
||||||
|
"Previous"
|
||||||
|
}
|
||||||
|
span { " <---> " }
|
||||||
|
Link {
|
||||||
|
to: Route::Blog { id: id + 1 },
|
||||||
|
"Next"
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
10
packages/web/src/views/home.rs
Normal file
10
packages/web/src/views/home.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
use dioxus::prelude::*;
|
||||||
|
use ui::{Echo, Hero};
|
||||||
|
|
||||||
|
#[component]
|
||||||
|
pub fn Home() -> Element {
|
||||||
|
rsx! {
|
||||||
|
Hero {}
|
||||||
|
Echo {}
|
||||||
|
}
|
||||||
|
}
|
||||||
5
packages/web/src/views/mod.rs
Normal file
5
packages/web/src/views/mod.rs
Normal file
@@ -0,0 +1,5 @@
|
|||||||
|
mod home;
|
||||||
|
pub use home::Home;
|
||||||
|
|
||||||
|
mod blog;
|
||||||
|
pub use blog::Blog;
|
||||||
Reference in New Issue
Block a user