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:
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