Compare commits
7 Commits
3e7485e082
...
main
Author | SHA1 | Date | |
---|---|---|---|
7470200fc2 | |||
ef3a0e82b8 | |||
e34d527089 | |||
085ce51b0a | |||
7d3ddc12ed | |||
2baa5a930a | |||
bb529b9973 |
2
.gitignore
vendored
2
.gitignore
vendored
@@ -14,3 +14,5 @@ Cargo.lock
|
|||||||
# MSVC Windows builds of rustc generate these, which store debugging information
|
# MSVC Windows builds of rustc generate these, which store debugging information
|
||||||
*.pdb
|
*.pdb
|
||||||
|
|
||||||
|
|
||||||
|
dist/
|
||||||
|
37
Cargo.toml
Normal file
37
Cargo.toml
Normal file
@@ -0,0 +1,37 @@
|
|||||||
|
[package]
|
||||||
|
name = "web1"
|
||||||
|
version = "0.1.0"
|
||||||
|
edition = "2021"
|
||||||
|
description = "Template for starting a Yew project using Trunk"
|
||||||
|
readme = "README.md"
|
||||||
|
repository = "https://github.com/yewstack/yew-trunk-minimal-template"
|
||||||
|
license = "MIT OR Apache-2.0"
|
||||||
|
keywords = ["yew", "trunk"]
|
||||||
|
categories = ["gui", "wasm", "web-programming"]
|
||||||
|
|
||||||
|
# See more keys and their definitions at https://doc.rust-lang.org/cargo/reference/manifest.html
|
||||||
|
[dependencies]
|
||||||
|
yew = { version="0.21", features=["csr"] }
|
||||||
|
yew-router = "0.18"
|
||||||
|
web-sys = { version = "0.3", features = [
|
||||||
|
"Document",
|
||||||
|
"HtmlElement",
|
||||||
|
"Window",
|
||||||
|
"DragEvent",
|
||||||
|
"Element",
|
||||||
|
"HtmlInputElement",
|
||||||
|
"HtmlTextAreaElement",
|
||||||
|
"HtmlSelectElement",
|
||||||
|
"MouseEvent",
|
||||||
|
"Event"
|
||||||
|
] }
|
||||||
|
wasm-bindgen = "0.2"
|
||||||
|
gloo-utils = "0.1"
|
||||||
|
gloo-storage = "0.2"
|
||||||
|
gloo-net = "0.4"
|
||||||
|
wasm-bindgen-futures = "0.4"
|
||||||
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
serde_json = "1.0" # Added for JSON serialization/deserialization
|
||||||
|
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||||
|
pulldown-cmark = "0.9"
|
||||||
|
html-escape = "0.2"
|
177
LICENSE-APACHE
Normal file
177
LICENSE-APACHE
Normal file
@@ -0,0 +1,177 @@
|
|||||||
|
|
||||||
|
Apache License
|
||||||
|
Version 2.0, January 2004
|
||||||
|
http://www.apache.org/licenses/
|
||||||
|
|
||||||
|
TERMS AND CONDITIONS FOR USE, REPRODUCTION, AND DISTRIBUTION
|
||||||
|
|
||||||
|
1. Definitions.
|
||||||
|
|
||||||
|
"License" shall mean the terms and conditions for use, reproduction,
|
||||||
|
and distribution as defined by Sections 1 through 9 of this document.
|
||||||
|
|
||||||
|
"Licensor" shall mean the copyright owner or entity authorized by
|
||||||
|
the copyright owner that is granting the License.
|
||||||
|
|
||||||
|
"Legal Entity" shall mean the union of the acting entity and all
|
||||||
|
other entities that control, are controlled by, or are under common
|
||||||
|
control with that entity. For the purposes of this definition,
|
||||||
|
"control" means (i) the power, direct or indirect, to cause the
|
||||||
|
direction or management of such entity, whether by contract or
|
||||||
|
otherwise, or (ii) ownership of fifty percent (50%) or more of the
|
||||||
|
outstanding shares, or (iii) beneficial ownership of such entity.
|
||||||
|
|
||||||
|
"You" (or "Your") shall mean an individual or Legal Entity
|
||||||
|
exercising permissions granted by this License.
|
||||||
|
|
||||||
|
"Source" form shall mean the preferred form for making modifications,
|
||||||
|
including but not limited to software source code, documentation
|
||||||
|
source, and configuration files.
|
||||||
|
|
||||||
|
"Object" form shall mean any form resulting from mechanical
|
||||||
|
transformation or translation of a Source form, including but
|
||||||
|
not limited to compiled object code, generated documentation,
|
||||||
|
and conversions to other media types.
|
||||||
|
|
||||||
|
"Work" shall mean the work of authorship, whether in Source or
|
||||||
|
Object form, made available under the License, as indicated by a
|
||||||
|
copyright notice that is included in or attached to the work
|
||||||
|
(an example is provided in the Appendix below).
|
||||||
|
|
||||||
|
"Derivative Works" shall mean any work, whether in Source or Object
|
||||||
|
form, that is based on (or derived from) the Work and for which the
|
||||||
|
editorial revisions, annotations, elaborations, or other modifications
|
||||||
|
represent, as a whole, an original work of authorship. For the purposes
|
||||||
|
of this License, Derivative Works shall not include works that remain
|
||||||
|
separable from, or merely link (or bind by name) to the interfaces of,
|
||||||
|
the Work and Derivative Works thereof.
|
||||||
|
|
||||||
|
"Contribution" shall mean any work of authorship, including
|
||||||
|
the original version of the Work and any modifications or additions
|
||||||
|
to that Work or Derivative Works thereof, that is intentionally
|
||||||
|
submitted to Licensor for inclusion in the Work by the copyright owner
|
||||||
|
or by an individual or Legal Entity authorized to submit on behalf of
|
||||||
|
the copyright owner. For the purposes of this definition, "submitted"
|
||||||
|
means any form of electronic, verbal, or written communication sent
|
||||||
|
to the Licensor or its representatives, including but not limited to
|
||||||
|
communication on electronic mailing lists, source code control systems,
|
||||||
|
and issue tracking systems that are managed by, or on behalf of, the
|
||||||
|
Licensor for the purpose of discussing and improving the Work, but
|
||||||
|
excluding communication that is conspicuously marked or otherwise
|
||||||
|
designated in writing by the copyright owner as "Not a Contribution."
|
||||||
|
|
||||||
|
"Contributor" shall mean Licensor and any individual or Legal Entity
|
||||||
|
on behalf of whom a Contribution has been received by Licensor and
|
||||||
|
subsequently incorporated within the Work.
|
||||||
|
|
||||||
|
2. Grant of Copyright License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
copyright license to reproduce, prepare Derivative Works of,
|
||||||
|
publicly display, publicly perform, sublicense, and distribute the
|
||||||
|
Work and such Derivative Works in Source or Object form.
|
||||||
|
|
||||||
|
3. Grant of Patent License. Subject to the terms and conditions of
|
||||||
|
this License, each Contributor hereby grants to You a perpetual,
|
||||||
|
worldwide, non-exclusive, no-charge, royalty-free, irrevocable
|
||||||
|
(except as stated in this section) patent license to make, have made,
|
||||||
|
use, offer to sell, sell, import, and otherwise transfer the Work,
|
||||||
|
where such license applies only to those patent claims licensable
|
||||||
|
by such Contributor that are necessarily infringed by their
|
||||||
|
Contribution(s) alone or by combination of their Contribution(s)
|
||||||
|
with the Work to which such Contribution(s) was submitted. If You
|
||||||
|
institute patent litigation against any entity (including a
|
||||||
|
cross-claim or counterclaim in a lawsuit) alleging that the Work
|
||||||
|
or a Contribution incorporated within the Work constitutes direct
|
||||||
|
or contributory patent infringement, then any patent licenses
|
||||||
|
granted to You under this License for that Work shall terminate
|
||||||
|
as of the date such litigation is filed.
|
||||||
|
|
||||||
|
4. Redistribution. You may reproduce and distribute copies of the
|
||||||
|
Work or Derivative Works thereof in any medium, with or without
|
||||||
|
modifications, and in Source or Object form, provided that You
|
||||||
|
meet the following conditions:
|
||||||
|
|
||||||
|
(a) You must give any other recipients of the Work or
|
||||||
|
Derivative Works a copy of this License; and
|
||||||
|
|
||||||
|
(b) You must cause any modified files to carry prominent notices
|
||||||
|
stating that You changed the files; and
|
||||||
|
|
||||||
|
(c) You must retain, in the Source form of any Derivative Works
|
||||||
|
that You distribute, all copyright, patent, trademark, and
|
||||||
|
attribution notices from the Source form of the Work,
|
||||||
|
excluding those notices that do not pertain to any part of
|
||||||
|
the Derivative Works; and
|
||||||
|
|
||||||
|
(d) If the Work includes a "NOTICE" text file as part of its
|
||||||
|
distribution, then any Derivative Works that You distribute must
|
||||||
|
include a readable copy of the attribution notices contained
|
||||||
|
within such NOTICE file, excluding those notices that do not
|
||||||
|
pertain to any part of the Derivative Works, in at least one
|
||||||
|
of the following places: within a NOTICE text file distributed
|
||||||
|
as part of the Derivative Works; within the Source form or
|
||||||
|
documentation, if provided along with the Derivative Works; or,
|
||||||
|
within a display generated by the Derivative Works, if and
|
||||||
|
wherever such third-party notices normally appear. The contents
|
||||||
|
of the NOTICE file are for informational purposes only and
|
||||||
|
do not modify the License. You may add Your own attribution
|
||||||
|
notices within Derivative Works that You distribute, alongside
|
||||||
|
or as an addendum to the NOTICE text from the Work, provided
|
||||||
|
that such additional attribution notices cannot be construed
|
||||||
|
as modifying the License.
|
||||||
|
|
||||||
|
You may add Your own copyright statement to Your modifications and
|
||||||
|
may provide additional or different license terms and conditions
|
||||||
|
for use, reproduction, or distribution of Your modifications, or
|
||||||
|
for any such Derivative Works as a whole, provided Your use,
|
||||||
|
reproduction, and distribution of the Work otherwise complies with
|
||||||
|
the conditions stated in this License.
|
||||||
|
|
||||||
|
5. Submission of Contributions. Unless You explicitly state otherwise,
|
||||||
|
any Contribution intentionally submitted for inclusion in the Work
|
||||||
|
by You to the Licensor shall be under the terms and conditions of
|
||||||
|
this License, without any additional terms or conditions.
|
||||||
|
Notwithstanding the above, nothing herein shall supersede or modify
|
||||||
|
the terms of any separate license agreement you may have executed
|
||||||
|
with Licensor regarding such Contributions.
|
||||||
|
|
||||||
|
6. Trademarks. This License does not grant permission to use the trade
|
||||||
|
names, trademarks, service marks, or product names of the Licensor,
|
||||||
|
except as required for reasonable and customary use in describing the
|
||||||
|
origin of the Work and reproducing the content of the NOTICE file.
|
||||||
|
|
||||||
|
7. Disclaimer of Warranty. Unless required by applicable law or
|
||||||
|
agreed to in writing, Licensor provides the Work (and each
|
||||||
|
Contributor provides its Contributions) on an "AS IS" BASIS,
|
||||||
|
WITHOUT WARRANTIES OR CONDITIONS OF ANY KIND, either express or
|
||||||
|
implied, including, without limitation, any warranties or conditions
|
||||||
|
of TITLE, NON-INFRINGEMENT, MERCHANTABILITY, or FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE. You are solely responsible for determining the
|
||||||
|
appropriateness of using or redistributing the Work and assume any
|
||||||
|
risks associated with Your exercise of permissions under this License.
|
||||||
|
|
||||||
|
8. Limitation of Liability. In no event and under no legal theory,
|
||||||
|
whether in tort (including negligence), contract, or otherwise,
|
||||||
|
unless required by applicable law (such as deliberate and grossly
|
||||||
|
negligent acts) or agreed to in writing, shall any Contributor be
|
||||||
|
liable to You for damages, including any direct, indirect, special,
|
||||||
|
incidental, or consequential damages of any character arising as a
|
||||||
|
result of this License or out of the use or inability to use the
|
||||||
|
Work (including but not limited to damages for loss of goodwill,
|
||||||
|
work stoppage, computer failure or malfunction, or any and all
|
||||||
|
other commercial damages or losses), even if such Contributor
|
||||||
|
has been advised of the possibility of such damages.
|
||||||
|
|
||||||
|
9. Accepting Warranty or Additional Liability. While redistributing
|
||||||
|
the Work or Derivative Works thereof, You may choose to offer,
|
||||||
|
and charge a fee for, acceptance of support, warranty, indemnity,
|
||||||
|
or other liability obligations and/or rights consistent with this
|
||||||
|
License. However, in accepting such obligations, You may act only
|
||||||
|
on Your own behalf and on Your sole responsibility, not on behalf
|
||||||
|
of any other Contributor, and only if You agree to indemnify,
|
||||||
|
defend, and hold each Contributor harmless for any liability
|
||||||
|
incurred by, or claims asserted against, such Contributor by reason
|
||||||
|
of your accepting any such warranty or additional liability.
|
||||||
|
|
||||||
|
END OF TERMS AND CONDITIONS
|
25
LICENSE-MIT
Normal file
25
LICENSE-MIT
Normal file
@@ -0,0 +1,25 @@
|
|||||||
|
Copyright (c) despiegk <kristof@incubaid.com>
|
||||||
|
|
||||||
|
Permission is hereby granted, free of charge, to any
|
||||||
|
person obtaining a copy of this software and associated
|
||||||
|
documentation files (the "Software"), to deal in the
|
||||||
|
Software without restriction, including without
|
||||||
|
limitation the rights to use, copy, modify, merge,
|
||||||
|
publish, distribute, sublicense, and/or sell copies of
|
||||||
|
the Software, and to permit persons to whom the Software
|
||||||
|
is furnished to do so, subject to the following
|
||||||
|
conditions:
|
||||||
|
|
||||||
|
The above copyright notice and this permission notice
|
||||||
|
shall be included in all copies or substantial portions
|
||||||
|
of the Software.
|
||||||
|
|
||||||
|
THE SOFTWARE IS PROVIDED "AS IS", WITHOUT WARRANTY OF
|
||||||
|
ANY KIND, EXPRESS OR IMPLIED, INCLUDING BUT NOT LIMITED
|
||||||
|
TO THE WARRANTIES OF MERCHANTABILITY, FITNESS FOR A
|
||||||
|
PARTICULAR PURPOSE AND NONINFRINGEMENT. IN NO EVENT
|
||||||
|
SHALL THE AUTHORS OR COPYRIGHT HOLDERS BE LIABLE FOR ANY
|
||||||
|
CLAIM, DAMAGES OR OTHER LIABILITY, WHETHER IN AN ACTION
|
||||||
|
OF CONTRACT, TORT OR OTHERWISE, ARISING FROM, OUT OF OR
|
||||||
|
IN CONNECTION WITH THE SOFTWARE OR THE USE OR OTHER
|
||||||
|
DEALINGS IN THE SOFTWARE.
|
75
README.md
75
README.md
@@ -1,2 +1,75 @@
|
|||||||
# webtest
|
# Yew Trunk Template
|
||||||
|
|
||||||
|
This is a fairly minimal template for a Yew app that's built with [Trunk].
|
||||||
|
|
||||||
|
## Usage
|
||||||
|
|
||||||
|
For a more thorough explanation of Trunk and its features, please head over to the [repository][trunk].
|
||||||
|
|
||||||
|
### Installation
|
||||||
|
|
||||||
|
If you don't already have it installed, it's time to install Rust: <https://www.rust-lang.org/tools/install>.
|
||||||
|
The rest of this guide assumes a typical Rust installation which contains both `rustup` and Cargo.
|
||||||
|
|
||||||
|
To compile Rust to WASM, we need to have the `wasm32-unknown-unknown` target installed.
|
||||||
|
If you don't already have it, install it with the following command:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
rustup target add wasm32-unknown-unknown
|
||||||
|
```
|
||||||
|
|
||||||
|
Now that we have our basics covered, it's time to install the star of the show: [Trunk].
|
||||||
|
Simply run the following command to install it:
|
||||||
|
|
||||||
|
```bash
|
||||||
|
cargo install trunk wasm-bindgen-cli
|
||||||
|
```
|
||||||
|
|
||||||
|
That's it, we're done!
|
||||||
|
|
||||||
|
### Running
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trunk serve
|
||||||
|
```
|
||||||
|
|
||||||
|
Rebuilds the app whenever a change is detected and runs a local server to host it.
|
||||||
|
|
||||||
|
There's also the `trunk watch` command which does the same thing but without hosting it.
|
||||||
|
|
||||||
|
### Release
|
||||||
|
|
||||||
|
```bash
|
||||||
|
trunk build --release
|
||||||
|
```
|
||||||
|
|
||||||
|
This builds the app in release mode similar to `cargo build --release`.
|
||||||
|
You can also pass the `--release` flag to `trunk serve` if you need to get every last drop of performance.
|
||||||
|
|
||||||
|
Unless overwritten, the output will be located in the `dist` directory.
|
||||||
|
|
||||||
|
## Using this template
|
||||||
|
|
||||||
|
There are a few things you have to adjust when adopting this template.
|
||||||
|
|
||||||
|
### Remove example code
|
||||||
|
|
||||||
|
The code in [src/main.rs](src/main.rs) specific to the example is limited to only the `view` method.
|
||||||
|
There is, however, a fair bit of Sass in [index.scss](index.scss) you can remove.
|
||||||
|
|
||||||
|
### Update metadata
|
||||||
|
|
||||||
|
Update the `version`, `description` and `repository` fields in the [Cargo.toml](Cargo.toml) file.
|
||||||
|
The [index.html](index.html) file also contains a `<title>` tag that needs updating.
|
||||||
|
|
||||||
|
|
||||||
|
Finally, you should update this very `README` file to be about your app.
|
||||||
|
|
||||||
|
### License
|
||||||
|
|
||||||
|
The template ships with both the Apache and MIT license.
|
||||||
|
If you don't want to have your app dual licensed, just remove one (or both) of the files and update the `license` field in `Cargo.toml`.
|
||||||
|
|
||||||
|
There are two empty spaces in the MIT license you need to fill out: `` and `despiegk <kristof@incubaid.com>`.
|
||||||
|
|
||||||
|
[trunk]: https://github.com/thedodd/trunk
|
452
ai_instructions.md
Normal file
452
ai_instructions.md
Normal file
@@ -0,0 +1,452 @@
|
|||||||
|
|
||||||
|
# Yew Framework Documentation for AI Coders: Core Concepts & Coding
|
||||||
|
|
||||||
|
This document provides a comprehensive overview of the Yew framework's core coding concepts, designed to equip AI robot coders with the knowledge necessary to build web applications using Rust and WebAssembly.
|
||||||
|
|
||||||
|
Yew is a modern Rust framework for building client-side web applications using WebAssembly (Wasm). It enables the development of highly performant web UIs by leveraging Rust's strong type system and rich ecosystem. Yew promotes a component-based architecture for building reusable and maintainable UI elements.
|
||||||
|
|
||||||
|
## Fundamental Building Blocks
|
||||||
|
|
||||||
|
### The `html!` Macro for UI Composition
|
||||||
|
|
||||||
|
Yew employs the `html!` procedural macro for declarative UI construction, drawing inspiration from JSX. This macro is the primary way to define the structure of your component's output.
|
||||||
|
|
||||||
|
**Syntax and Features:**
|
||||||
|
|
||||||
|
* **Single Root Node:** The `html!` macro always expects a single root HTML node. To render multiple top-level elements without a wrapping container, use the fragment syntax: `<> ... </>`.
|
||||||
|
* **Embedding Rust Expressions:** Any valid Rust expression can be embedded within the markup using curly braces (`{ expression }`). The expression **must evaluate to a type that implements `Into<Html>`**.
|
||||||
|
```rust
|
||||||
|
let header_text = "Welcome to Yew".to_string();
|
||||||
|
let count = 5;
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<h1>{ header_text }</h1>
|
||||||
|
<p>{"Current count: "}{ count }</p>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Literals:** String literals are typically enclosed in quotes and then a `{}`, e.g., `{"Hello"}`. They are treated as `Text` nodes, inherently mitigating common HTML injection (XSS) risks.
|
||||||
|
* **Element / Component Definition:**
|
||||||
|
* **HTML Elements:** Standard HTML elements are written as `<tagname property="value">child</tagname>` or self-closing `<tagname property="value" />`.
|
||||||
|
* **Dynamic Tag Names:** For situations where the HTML tag name is determined at runtime, use the `@{expression}` syntax. The expression must be a string.
|
||||||
|
```rust
|
||||||
|
let level = 3;
|
||||||
|
html! { <@{format!("h{}", level)} class="subtitle">{"Dynamic Heading"}</@> }
|
||||||
|
```
|
||||||
|
* **Yew Components:** Yew components are instantiated like custom HTML tags, using PascalCase for their names: `<MyComponent property={value} />`.
|
||||||
|
* **Attributes and Properties:**
|
||||||
|
* **HTML Attributes:** Set on elements directly: `<div attribute={rust_value} />`.
|
||||||
|
* **Boolean Attributes:** Set with `true` or `false`. `false` is equivalent to omitting the attribute entirely.
|
||||||
|
```rust
|
||||||
|
html! { <input type="checkbox" checked={some_boolean_var} /> }
|
||||||
|
```
|
||||||
|
* **String-like Attributes:** Can accept `&str`, `String`, or Yew's optimized `AttrValue` (a cheaply cloneable `Rc<str>` or `&'static str`). `AttrValue` is generally recommended for performance-sensitive scenarios, especially when passing values as properties to other components.
|
||||||
|
* **Optional Attributes:** Use `Option<T>` for attribute values. If the `Option` is `None`, the attribute will not be rendered in the DOM.
|
||||||
|
```rust
|
||||||
|
let maybe_id: Option<&str> = Some("unique-element");
|
||||||
|
html! { <div id={maybe_id}></div> } // Renders with id or not
|
||||||
|
```
|
||||||
|
* **Yew-Specific Properties (Special Props):** These are not directly reflected in the DOM but serve as instructions to Yew's Virtual DOM.
|
||||||
|
* `ref={node_ref_handle}`: Connects a `NodeRef` to a DOM element, allowing direct programmatic access to the underlying DOM node (e.g., for canvas manipulation, scrolling, form input values).
|
||||||
|
* `key={unique_key}`: Provides a unique identifier for elements within a list. Crucial for performance optimization, as Yew uses keys to efficiently reconcile list items during updates, preventing unnecessary re-renders or DOM manipulations. Keys must be unique *within their immediate siblings* (the list itself) and should be stable/deterministic, not based on item position.
|
||||||
|
* **Conditional Rendering:** Use standard Rust `if` and `if let` control flow structures directly within the `html!` macro to conditionally render content.
|
||||||
|
```rust
|
||||||
|
let show_message = true;
|
||||||
|
html! {
|
||||||
|
if show_message {
|
||||||
|
<p>{"This message is visible."}</p>
|
||||||
|
} else {
|
||||||
|
<p>{"Message hidden."}</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **List Rendering / Iteration:**
|
||||||
|
* Use `for` syntax directly in `html!`: `{ for collection.iter() }`. This expects the iterator items to be renderable `Html`.
|
||||||
|
* Alternatively, map the collection to `Html` elements and use `.collect::<Html>()`.
|
||||||
|
* **Always use `key` for list items** where the order or presence of items can change, as this drastically improves reconciliation performance.
|
||||||
|
```rust
|
||||||
|
let items = vec!["Apple", "Banana", "Cherry"];
|
||||||
|
html! {
|
||||||
|
<ul>
|
||||||
|
{ for items.iter().map(|item| html! { <li key={item.to_string()}>{item}</li> }) }
|
||||||
|
</ul>
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Components: Function Components
|
||||||
|
|
||||||
|
Yew applications are built from components, which encapsulate UI logic and presentation. Function components are the recommended and most common way to define components in modern Yew.
|
||||||
|
|
||||||
|
* **Definition:** Declare a function component using the `#[function_component]` attribute on a `fn` that returns `Html`. By convention, component names are PascalCase.
|
||||||
|
```rust
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn MySimpleComponent() -> Html {
|
||||||
|
html! { <p>{"Hello from a component!"}</p> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Properties (Props):** Data is passed from parent to child components using "props". Props are defined by a struct that must implement `Properties` (usually via `#[derive(Properties)]`) and `PartialEq`. The function component accepts an `&Props` reference as its single argument.
|
||||||
|
```rust
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct GreetProps {
|
||||||
|
pub name: String,
|
||||||
|
#[prop_or_default] // Field attribute for optional props with default value
|
||||||
|
pub greeting_text: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn Greeter(props: &GreetProps) -> Html {
|
||||||
|
let greeting = if props.greeting_text.is_empty() {
|
||||||
|
"Hello".to_string()
|
||||||
|
} else {
|
||||||
|
props.greeting_text.clone()
|
||||||
|
};
|
||||||
|
html! { <p>{ format!("{}, {}!", greeting, props.name) }</p> }
|
||||||
|
}
|
||||||
|
// Usage: html! { <Greeter name="Alice" /> } or html! { <Greeter name="Bob" greeting_text="Hi" /> }
|
||||||
|
```
|
||||||
|
* **Reactive Nature of Props:** Yew automatically re-renders a component when its props change (detected via `PartialEq`).
|
||||||
|
* **`props!` Macro:** Allows building `Properties` structs programmatically.
|
||||||
|
```rust
|
||||||
|
use yew::props;
|
||||||
|
let my_props = props! { GreetProps { name: "Charlie".to_string() } };
|
||||||
|
html! { <Greeter ..my_props /> }
|
||||||
|
```
|
||||||
|
* **Children (Special Prop):** If a component's `Props` struct includes a `pub children: Html` field, it can accept nested `html!` content.
|
||||||
|
```rust
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CardProps {
|
||||||
|
pub title: String,
|
||||||
|
pub children: Html, // This field name is special
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn Card(props: &CardProps) -> Html {
|
||||||
|
html! {
|
||||||
|
<div class="card">
|
||||||
|
<h2>{ &props.title }</h2>
|
||||||
|
<div class="card-content">
|
||||||
|
{ props.children.clone() }
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Usage: html! { <Card title="My Card"> <p>Some content here.</p> </Card> }
|
||||||
|
```
|
||||||
|
* **Generic Components:** Function components can be generic over types, provided the generic type parameters meet the necessary trait bounds (e.g., `PartialEq`).
|
||||||
|
```rust
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct ItemDisplayProps<T: PartialEq + ToHtml> {
|
||||||
|
pub item: T,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
pub fn ItemDisplay<T>(props: &ItemDisplayProps<T>) -> Html
|
||||||
|
where
|
||||||
|
T: PartialEq + ToHtml + 'static, // 'static for use in VDOM
|
||||||
|
{
|
||||||
|
html! { <p>{ &props.item }</p> }
|
||||||
|
}
|
||||||
|
// Usage: html! { <ItemDisplay<i32> item=123 /> }
|
||||||
|
```
|
||||||
|
* **Pure Components:** A function component is "pure" if its output `Html` is solely determined by its props, and it has no side effects or internal mutable state. Yew's reconciliation benefits from pure components.
|
||||||
|
* Simple pure components with no hooks can sometimes be implemented as regular functions returning `Html` to reduce overhead.
|
||||||
|
* **Communication Patterns:**
|
||||||
|
* **Parent to Child:** Via [Props](#properties-props).
|
||||||
|
* **Child to Parent:** Via [Callbacks](#callbacks). The parent passes a `Callback` to the child via props, and the child calls `emit()` on it to send data back up.
|
||||||
|
|
||||||
|
## State Management and Interactivity
|
||||||
|
|
||||||
|
Yew provides "hooks" to manage mutable state and side effects within function components.
|
||||||
|
|
||||||
|
### Hooks
|
||||||
|
|
||||||
|
Hooks are functions that allow "hooking into" the lifecycle and state management capabilities of function components.
|
||||||
|
|
||||||
|
**Rules of Hooks:**
|
||||||
|
|
||||||
|
1. **Naming Convention:** Hook function names **must start with `use_`**.
|
||||||
|
2. **Top-Level Calls Only:** Hooks can **only be called at the top level of a function component or another hook**, and not inside loops, conditionals without `if let`, or nested functions *unless* within the scrutinee of a top-level `if` or `match` expression.
|
||||||
|
3. **Consistent Call Order:** Hooks must be called in the exact same order on every render. This enables Yew to correctly associate state with hook calls.
|
||||||
|
4. **No Early Return:** A component using hooks cannot `return` early before all hooks are called unless using [Suspense](#suspense).
|
||||||
|
|
||||||
|
**Common Pre-defined Hooks:**
|
||||||
|
|
||||||
|
* **`use_state<T>() -> UseStateHandle<T>`:** Manages local component state. Returns a handle that can be dereferenced to get the current value and has a `.set(new_value)` method to update the state. Updating state triggers a re-render.
|
||||||
|
```rust
|
||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn ClickCounter() -> Html {
|
||||||
|
let count = use_state(|| 0); // Initializes with 0
|
||||||
|
let onclick = {
|
||||||
|
let count = count.clone(); // Clone the handle for the closure
|
||||||
|
move |_| {
|
||||||
|
count.set(*count + 1); // Update the state
|
||||||
|
}
|
||||||
|
};
|
||||||
|
html! { <button {onclick}>{"Clicked "}{*count}{" times"}</button> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **`use_state_eq<T: PartialEq>() -> UseStateHandle<T>`:** Similar to `use_state`, but only triggers a re-render if the new value is *not* equal to the current value (using `PartialEq`).
|
||||||
|
* **`use_memo<T, D>(f: impl FnOnce(D) -> T, deps: D) -> Rc<T>`:** Memoizes an expensive computation. The `f` closure is only re-executed if `deps` (dependencies) change.
|
||||||
|
* **`use_callback<IN, OUT, F>(f: F) -> Callback<IN, OUT>`:** Memoizes a `Callback` instance. The `Callback` is only recreated if its dependencies (captured variables) change.
|
||||||
|
* **`use_mut_ref<T>() -> Rc<RefCell<T>>`:** Provides a mutable reference (`RefCell`) that persists across re-renders without triggering them. Useful for mutable data that doesn't directly affect rendering or for interop with mutable JS APIs.
|
||||||
|
* **`use_node_ref() -> NodeRef`:** Creates a `NodeRef` handle, used to get a direct reference to a rendered DOM element.
|
||||||
|
```rust
|
||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlInputElement;
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn MyInput() -> Html {
|
||||||
|
let input_ref = use_node_ref();
|
||||||
|
let current_value = use_state(|| String::new());
|
||||||
|
|
||||||
|
let on_input_change = {
|
||||||
|
let input_ref = input_ref.clone();
|
||||||
|
let current_value = current_value.clone();
|
||||||
|
move |_| {
|
||||||
|
if let Some(input) = input_ref.cast::<HtmlInputElement>() {
|
||||||
|
current_value.set(input.value());
|
||||||
|
}
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div>
|
||||||
|
<input ref={input_ref} type="text" oninput={on_input_change} />
|
||||||
|
<p>{"Input Value: "}{&*current_value}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **`use_reducer<R>() -> UseReducerHandle<R>`:** Manages more complex state using a reducer pattern (similar to Redux), allowing state updates based on dispatched "actions". The state must implement the `Reducible` trait.
|
||||||
|
* **`use_reducer_eq<R: PartialEq>() -> UseReducerHandle<R>`:** Similar to `use_reducer`, but dispatches only if the new state differs from the old.
|
||||||
|
* **`use_effect(f: impl FnOnce() -> impl FnOnce())`:** Runs a side effect after every render. Can return a cleanup closure that runs before the next effect or when the component is unmounted.
|
||||||
|
* **`use_effect_with<D>(deps: D, f: impl FnOnce(D) -> impl FnOnce())`:** Runs a side effect only when `deps` (dependencies) change.
|
||||||
|
* **`use_context<T: Clone + PartialEq>() -> Option<Rc<T>>`:** Accesses a context value provided by an ancestor `ContextProvider` component.
|
||||||
|
* **`use_force_update()`:** Returns a callback that, when emitted, forces a re-render of the component. Use sparingly.
|
||||||
|
|
||||||
|
**Custom Hooks:** Components can extract reusable stateful logic into custom hooks by defining functions starting with `use_` and marking them with `#[hook]`. Custom hooks compose existing hooks.
|
||||||
|
|
||||||
|
### Callbacks
|
||||||
|
|
||||||
|
`Callback` is a crucial type for event handling and child-to-parent communication. It wraps an `Fn` closure in an `Rc`, making it cheaply clonable.
|
||||||
|
|
||||||
|
* **`Callback::from(closure)`:** Creates a `Callback` from a closure.
|
||||||
|
* **`callback.emit(value)`:** Invokes the wrapped closure with the given value.
|
||||||
|
* **DOM Events:** Event handlers in `html!` (e.g., `onclick`, `oninput`) expect a `Callback` that takes the corresponding `web_sys` event type as an argument.
|
||||||
|
```rust
|
||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::MouseEvent;
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn MyButton() -> Html {
|
||||||
|
let onclick_handler = Callback::from(move |e: MouseEvent| {
|
||||||
|
// Access event properties:
|
||||||
|
log::info!("Click event at: ({}, {})", e.client_x(), e.client_y());
|
||||||
|
// More complex logic...
|
||||||
|
});
|
||||||
|
html! { <button onclick={onclick_handler}>{"Click Me"}</button> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **`TargetCast` Trait:** Provided by Yew (within `yew::prelude::*`), this trait extends `web_sys::Event` to safely cast the event target to a specific HTML element type (e.g., `HtmlInputElement`).
|
||||||
|
* `event.target_dyn_into::<HtmlElementType>() -> Option<HtmlElementType>` (safe, checked)
|
||||||
|
* `event.target_unchecked_into::<HtmlElementType>() -> HtmlElementType` (unchecked, use with caution when type is guaranteed)
|
||||||
|
|
||||||
|
### Contexts
|
||||||
|
|
||||||
|
Contexts provide a way to pass data deeply through the component tree without manually "prop drilling" at every level.
|
||||||
|
|
||||||
|
* **Provider (`ContextProvider`):** An ancestor component wraps its children with `ContextProvider<T>`, providing a value of type `T`. `T` must implement `Clone` and `PartialEq`.
|
||||||
|
```rust
|
||||||
|
// Define your context data
|
||||||
|
#[derive(Clone, Debug, PartialEq)]
|
||||||
|
struct Theme {
|
||||||
|
foreground: String,
|
||||||
|
background: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn ThemeProvider(props: &ChildrenProps) -> Html { // ChildrenProps from yew::html
|
||||||
|
let theme = use_state(|| Theme {
|
||||||
|
foreground: "#000".to_string(),
|
||||||
|
background: "#eee".to_string(),
|
||||||
|
});
|
||||||
|
html! {
|
||||||
|
<yew::ContextProvider<Theme> context={(*theme).clone()}>
|
||||||
|
{ props.children.clone() }
|
||||||
|
</yew::ContextProvider<Theme>>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
// Usage: html! { <ThemeProvider> <ArbitraryDeepComponent /> </ThemeProvider> }
|
||||||
|
```
|
||||||
|
* **Consumer (`use_context` hook):** Descendant function components use `use_context::<T>()` to retrieve the provided value.
|
||||||
|
```rust
|
||||||
|
#[function_component]
|
||||||
|
fn ThemedText() -> Html {
|
||||||
|
let theme = use_context::<Theme>() // Retrieve the Theme context
|
||||||
|
.expect("Theme context not provided!");
|
||||||
|
html! {
|
||||||
|
<p style={format!("color: {}; background: {};", theme.foreground, theme.background)}>
|
||||||
|
{"This text is themed."}
|
||||||
|
</p>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
* **Mutable Contexts:** To allow children to modify a context value, combine `ContextProvider` with `use_reducer` for a predictable state update mechanism.
|
||||||
|
|
||||||
|
### Event Handling and Delegation
|
||||||
|
|
||||||
|
Yew integrates with `web-sys` for DOM events. Yew's event system employs event delegation: listeners are not directly attached to individual elements but are handled by a single delegate at the application's root. Events then "bubble up" through Yew's Virtual DOM hierarchy.
|
||||||
|
|
||||||
|
* **Event Listener Names:** In `html!`, event listeners start with `on` followed by the event name (e.g., `onclick`, `oninput`).
|
||||||
|
* **Manual Event Listeners:** For events not directly supported by `html!` or for fine-grained control, use `use_effect_with` and `gloo-events` (`EventListener`) to manually attach/detach event listeners to `NodeRef`-obtained DOM elements.
|
||||||
|
```rust
|
||||||
|
use gloo::events::EventListener;
|
||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::HtmlElement;
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn CustomEventHandler() -> Html {
|
||||||
|
let div_ref = use_node_ref();
|
||||||
|
use_effect_with(div_ref.clone(), {
|
||||||
|
let div_ref_clone = div_ref.clone();
|
||||||
|
move |_| {
|
||||||
|
let mut listener_obj: Option<EventListener> = None;
|
||||||
|
if let Some(div_element) = div_ref_clone.cast::<HtmlElement>() {
|
||||||
|
let listener = EventListener::new(&div_element, "custom-event", move |event| {
|
||||||
|
log::info!("Custom event received!");
|
||||||
|
// event.dyn_into::<web_sys::CustomEvent>() for richer data
|
||||||
|
});
|
||||||
|
listener_obj = Some(listener);
|
||||||
|
}
|
||||||
|
move || drop(listener_obj) // Cleanup when effect re-runs or component unmounts
|
||||||
|
}
|
||||||
|
});
|
||||||
|
html! { <div ref={div_ref}>{"Div with custom event listener"}</div> }
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
## Advanced Topics
|
||||||
|
|
||||||
|
### Interacting with JavaScript (JS) and Web APIs
|
||||||
|
|
||||||
|
Yew compiles to Wasm, but direct interaction with browser APIs often involves `wasm-bindgen` and related crates.
|
||||||
|
|
||||||
|
* **`wasm-bindgen`:** Bridges calls between Rust and JavaScript. Used for defining FFI (Foreign Function Interface) to import/export functions.
|
||||||
|
* **`web-sys`:** Provides Rust bindings for all Web APIs (DOM, Fetch, etc.). This is the primary way to interact with the browser from Rust.
|
||||||
|
* **Features:** `web-sys` is heavily feature-gated; enable only the necessary features in `Cargo.toml` to avoid bloat (e.g., `features = ["Document", "HtmlElement", "Window"]`).
|
||||||
|
* **Inheritance:** `web-sys` types simulate JavaScript inheritance using Rust's `Deref` and `AsRef` traits. For instance, an `HtmlElement` can deref to `Element`, then `Node`, then `EventTarget`, finally `JsValue`. This allows calling methods from ancestor types.
|
||||||
|
* **`JsCast` Trait:** Crucial for downcasting `JsValue` or generic `EventTarget` (from `web_sys`) to specific, more concrete types (e.g., `HtmlInputElement`). Provides `dyn_into` (checked, returns `Result`) and `unchecked_into` (unchecked, faster).
|
||||||
|
* **`js-sys`:** Provides Rust bindings for JavaScript's standard, built-in objects (e.g., `Date`, `Object`).
|
||||||
|
* **`wasm-bindgen-futures`:** Bridges Rust `Future`s with JavaScript `Promise`s.
|
||||||
|
* **`spawn_local(future)`:** Spawns a Rust `Future` to run on the current thread's event loop, enabling asynchronous operations (e.g., `async fetch()` calls).
|
||||||
|
|
||||||
|
### Asynchronous Operations and Suspense
|
||||||
|
|
||||||
|
Yew's `Suspense` component allows suspending component rendering while waiting for an asynchronous task (e.g., data fetching) to complete, showing a fallback UI in the interim. This enables a "Render-as-You-Fetch" pattern.
|
||||||
|
|
||||||
|
* **`Suspense` Component:** Wraps children components that might "suspend". Requires a `fallback` prop, which is the `Html` to render during suspension.
|
||||||
|
* **Suspending Hooks:** A hook can signal suspension by returning `Err(Suspension)`.
|
||||||
|
* **`Suspension::new()`:** Creates a `Suspension` and a `SuspensionHandle`. When `handle.resume()` is called (or `handle` is dropped), the suspended component re-renders.
|
||||||
|
```rust
|
||||||
|
use yew::prelude::*;
|
||||||
|
use yew::suspense::{Suspension, SuspensionResult};
|
||||||
|
|
||||||
|
// A hypothetical async data loading function
|
||||||
|
async fn fetch_user_data() -> String { "AI Robot Coder".to_string() }
|
||||||
|
|
||||||
|
// This hook will suspend rendering until user data is fetched
|
||||||
|
#[hook]
|
||||||
|
fn use_current_user() -> SuspensionResult<String> {
|
||||||
|
let (suspension, handle) = Suspension::new(); // Create suspension
|
||||||
|
let user_data_state = use_state(|| None ); // Local state for fetched data
|
||||||
|
|
||||||
|
// If data is already there, return Ok.
|
||||||
|
if let Some(data) = &*user_data_state {
|
||||||
|
return Ok(data.clone());
|
||||||
|
}
|
||||||
|
|
||||||
|
// If not, spawn an async task and signal suspension.
|
||||||
|
let user_data_state_clone = user_data_state.clone();
|
||||||
|
wasm_bindgen_futures::spawn_local(async move {
|
||||||
|
let data = fetch_user_data().await;
|
||||||
|
user_data_state_clone.set(Some(data)); // Update state
|
||||||
|
handle.resume(); // Signal the suspension to resume
|
||||||
|
});
|
||||||
|
|
||||||
|
Err(suspension) // Signal that the component should suspend
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn UserDisplay() -> HtmlResult { // HtmlResult instead of Html for suspending components
|
||||||
|
let user_name = use_current_user()?; // The '?' operator propagates the suspension
|
||||||
|
Ok(html! { <p>{ format!("Hello, {}!", user_name) }</p> })
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component]
|
||||||
|
fn App() -> Html {
|
||||||
|
html! {
|
||||||
|
<Suspense fallback={html!{<p>{"Loading user..."}</p>}}>
|
||||||
|
<UserDisplay />
|
||||||
|
</Suspense>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
```
|
||||||
|
|
||||||
|
### Routing (`yew-router` crate)
|
||||||
|
|
||||||
|
`yew-router` provides client-side routing for Single Page Applications (SPAs).
|
||||||
|
|
||||||
|
* **`Routable` Enum:** Define routes as an enum deriving `Routable`. Each variant maps to a URL path using `#[at("/path")]`. `#[not_found]` designates the fallback route.
|
||||||
|
* **Path Segments:** Define dynamic segments with `:segment_name` (for single segments) or `*wildcard` (for multi-segment wildcards). These become fields in the enum variant.
|
||||||
|
* **`BrowserRouter`:** The main router component that manages browser history. All `<Switch />` and `<Link />` components must be descendants of `BrowserRouter`.
|
||||||
|
* **`Switch` Component:** Takes an `Routable` enum and a `render` function. The `render` function receives the matched route variant and returns the `Html` to display.
|
||||||
|
* **`Link` Component:** A component that renders an `<a>` tag but performs client-side navigation (`pushState`) instead of a full page reload.
|
||||||
|
* **Navigation API (`use_navigator()`):** Provides programmatic navigation via `navigator.push(&Route)` or `navigator.replace(&Route)`.
|
||||||
|
* **Query Parameters:** Can be included in navigation by passing a serializable struct/map to `push_with_query()` or retrieved from `location.query()`.
|
||||||
|
* **Nested Routers:** Allows for modular routing within sub-sections of the application, often using a `*` wildcard in the parent router to delegate to a child router.
|
||||||
|
* **Basename:** Configure a common prefix for all routes, useful when the application is served from a sub-path.
|
||||||
|
|
||||||
|
### Web Workers and Agents
|
||||||
|
|
||||||
|
Yew `Agents` are a mechanism for offloading tasks to Web Workers, enabling concurrent processing and preventing UI unresponsiveness.
|
||||||
|
|
||||||
|
* **Types of Agents:**
|
||||||
|
* `Public`: A single instance shared across all bridges in a web worker.
|
||||||
|
* `Private`: A new instance spawned for each bridge connection.
|
||||||
|
* **Communication:**
|
||||||
|
* `Bridges`: Bi-directional communication between a component and an agent, or between agents.
|
||||||
|
* `Dispatchers`: Uni-directional communication from a component to an agent.
|
||||||
|
* **Overhead:** Agents introduce serialization/deserialization overhead for messages between threads (using `bincode`), making them suitable for chunky computations rather than frequent, small messages.
|
||||||
|
|
||||||
|
## Deployment Considerations
|
||||||
|
|
||||||
|
* **Release Build:** Use `trunk build --release` for optimized, production-ready builds.
|
||||||
|
* **Server Configuration:**
|
||||||
|
* **SPA Fallback:** Configure the HTTP server to serve `index.html` as a fallback for any unmatched URL paths, allowing the `yew-router` to handle client-side routing.
|
||||||
|
* **MIME-type:** Ensure `.wasm` files are served with the `application/wasm` MIME-type.
|
||||||
|
* **Relative Paths:** Use `<base data-trunk-public-url />` in `index.html` and `trunk build --public-url /your/path/` to deploy the app under a sub-path.
|
||||||
|
* **Environment Variables:** Use `std::env!("VAR_NAME")` to embed environment variables at **compile time** (runtime access in browser is not direct).
|
||||||
|
|
||||||
|
## Debugging and Testing
|
||||||
|
|
||||||
|
### Debugging
|
||||||
|
|
||||||
|
* **Panics:** Yew automatically logs Rust panics to the browser's developer console.
|
||||||
|
* **Console Logging:**
|
||||||
|
* `wasm-logger`: Integrates Rust's `log` crate with the browser console.
|
||||||
|
* `gloo-console`: Provides `log!` macro for direct `JsValue` logging to console.
|
||||||
|
* `tracing-web`: Integrates `tracing` framework with browser console and performance API.
|
||||||
|
* **Component Lifecycles:** Use `tracing` to gain insights into component re-renders and hook execution.
|
||||||
|
* **Source Maps:** Limited support available; requires specific configuration.
|
||||||
|
|
||||||
|
### Testing
|
||||||
|
|
||||||
|
* **`wasm_bindgen_test`:** Enables running Rust tests directly in a browser environment.
|
||||||
|
* **Snapshot Testing:** Yew provides utilities (`yew::tests::layout_tests`) for snapshot testing component output.
|
||||||
|
* **Shallow Rendering:** Work in progress for testing components in isolation without rendering their full subtree.
|
||||||
|
|
||||||
|
---
|
22
index.html
Normal file
22
index.html
Normal file
@@ -0,0 +1,22 @@
|
|||||||
|
<!DOCTYPE html>
|
||||||
|
<html lang="en">
|
||||||
|
<head>
|
||||||
|
<meta charset="utf-8" />
|
||||||
|
<meta name="viewport" content="width=device-width, initial-scale=1" />
|
||||||
|
<title>Yew Bootstrap App - Modern Rust WebAssembly</title>
|
||||||
|
<meta name="description" content="A modern web application built with Yew, Rust, and Bootstrap 5" />
|
||||||
|
|
||||||
|
<!-- Bootstrap CSS -->
|
||||||
|
<link href="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/css/bootstrap.min.css" rel="stylesheet" integrity="sha384-T3c6CoIi6uLrA9TneNEoa7RxnatzjcDSCmG1MXxSR1GAsXEV/Dwwykc2MPK8M2HN" crossorigin="anonymous">
|
||||||
|
|
||||||
|
<!-- Bootstrap Icons -->
|
||||||
|
<link rel="stylesheet" href="https://cdn.jsdelivr.net/npm/bootstrap-icons@1.11.2/font/bootstrap-icons.css">
|
||||||
|
|
||||||
|
<!-- Custom SCSS -->
|
||||||
|
<link data-trunk rel="sass" href="index.scss" />
|
||||||
|
</head>
|
||||||
|
<body>
|
||||||
|
<!-- Bootstrap JS -->
|
||||||
|
<script src="https://cdn.jsdelivr.net/npm/bootstrap@5.3.2/dist/js/bootstrap.bundle.min.js" integrity="sha384-C6RzsynM9kWDrMNeT87bh95OGNyZPhcTNXj1NW7RuBCsyN/o0jlpcV8Qyq46cDfL" crossorigin="anonymous"></script>
|
||||||
|
</body>
|
||||||
|
</html>
|
627
index.scss
Normal file
627
index.scss
Normal file
@@ -0,0 +1,627 @@
|
|||||||
|
// Import component styles
|
||||||
|
@import 'styles/modal.scss';
|
||||||
|
@import 'styles/comments.scss';
|
||||||
|
@import 'styles/forms.scss';
|
||||||
|
@import 'styles/markdown.scss';
|
||||||
|
|
||||||
|
// CSS Custom Properties for theming
|
||||||
|
:root {
|
||||||
|
--bs-body-bg: #ffffff;
|
||||||
|
--bs-body-color: #212529;
|
||||||
|
--bs-navbar-bg: #f8f9fa;
|
||||||
|
--bs-navbar-color: rgba(0, 0, 0, 0.65);
|
||||||
|
--bs-navbar-active-color: rgba(0, 0, 0, 0.9);
|
||||||
|
--bs-navbar-brand-color: #0d6efd;
|
||||||
|
--bs-hero-bg: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
--bs-hero-color: #ffffff;
|
||||||
|
--bs-card-bg: #ffffff;
|
||||||
|
--bs-card-border: rgba(0, 0, 0, 0.125);
|
||||||
|
--bs-feature-bg: #f8f9fa;
|
||||||
|
--bs-footer-bg: #e9ecef;
|
||||||
|
--bs-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
--bs-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.175);
|
||||||
|
--bs-primary: #0d6efd;
|
||||||
|
--bs-primary-rgb: 13, 110, 253;
|
||||||
|
--bs-secondary: #6c757d;
|
||||||
|
--bs-danger: #dc3545;
|
||||||
|
--bs-danger-rgb: 220, 53, 69;
|
||||||
|
--bs-success: #198754;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dark theme variables
|
||||||
|
.dark-theme {
|
||||||
|
--bs-body-bg: #0d1117;
|
||||||
|
--bs-body-color: #f0f6fc;
|
||||||
|
--bs-navbar-bg: #161b22;
|
||||||
|
--bs-navbar-color: rgba(240, 246, 252, 0.65);
|
||||||
|
--bs-navbar-active-color: rgba(240, 246, 252, 0.9);
|
||||||
|
--bs-navbar-brand-color: #58a6ff;
|
||||||
|
--bs-hero-bg: linear-gradient(135deg, #1f2937 0%, #374151 100%);
|
||||||
|
--bs-hero-color: #f0f6fc;
|
||||||
|
--bs-card-bg: #21262d;
|
||||||
|
--bs-card-border: rgba(240, 246, 252, 0.125);
|
||||||
|
--bs-feature-bg: #161b22;
|
||||||
|
--bs-footer-bg: #0d1117;
|
||||||
|
--bs-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.3);
|
||||||
|
--bs-shadow-lg: 0 1rem 3rem rgba(0, 0, 0, 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Global styles
|
||||||
|
* {
|
||||||
|
transition: background-color 0.3s ease, color 0.3s ease, border-color 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
body {
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-family: 'Segoe UI', Tahoma, Geneva, Verdana, sans-serif;
|
||||||
|
line-height: 1.6;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Navbar styling
|
||||||
|
.navbar {
|
||||||
|
background-color: var(--bs-navbar-bg) !important;
|
||||||
|
backdrop-filter: blur(10px);
|
||||||
|
border-bottom: 1px solid var(--bs-card-border);
|
||||||
|
box-shadow: var(--bs-shadow);
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
color: var(--bs-navbar-brand-color) !important;
|
||||||
|
font-weight: 700;
|
||||||
|
font-size: 1.5rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: scale(1.05);
|
||||||
|
transition: transform 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-nav .nav-link {
|
||||||
|
color: var(--bs-navbar-color) !important;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.5rem 1rem !important;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
margin: 0 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
color: var(--bs-navbar-active-color) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
&.active {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.15);
|
||||||
|
color: var(--bs-navbar-active-color) !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Hero section
|
||||||
|
.hero-section {
|
||||||
|
background: var(--bs-hero-bg);
|
||||||
|
color: var(--bs-hero-color);
|
||||||
|
min-height: 60vh;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
position: relative;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: '';
|
||||||
|
position: absolute;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background: url('data:image/svg+xml,<svg xmlns="http://www.w3.org/2000/svg" viewBox="0 0 100 100"><defs><pattern id="grain" width="100" height="100" patternUnits="userSpaceOnUse"><circle cx="50" cy="50" r="1" fill="white" opacity="0.1"/></pattern></defs><rect width="100" height="100" fill="url(%23grain)"/></svg>');
|
||||||
|
opacity: 0.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.container {
|
||||||
|
position: relative;
|
||||||
|
z-index: 1;
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
text-shadow: 0 2px 4px rgba(0, 0, 0, 0.3);
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
text-shadow: 0 1px 2px rgba(0, 0, 0, 0.2);
|
||||||
|
opacity: 0.95;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
border: none;
|
||||||
|
font-weight: 600;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: 0 1.5rem 4rem rgba(0, 0, 0, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Card styling
|
||||||
|
.card {
|
||||||
|
background-color: var(--bs-card-bg);
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
box-shadow: var(--bs-shadow);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-5px);
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
transition: transform 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover .feature-icon {
|
||||||
|
transform: scale(1.1) rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Theme toggle styling
|
||||||
|
.form-check-input {
|
||||||
|
&:checked {
|
||||||
|
background-color: #0d6efd;
|
||||||
|
border-color: #0d6efd;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Background sections
|
||||||
|
.bg-body-secondary {
|
||||||
|
background-color: var(--bs-feature-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.bg-body-tertiary {
|
||||||
|
background-color: var(--bs-footer-bg) !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Animations
|
||||||
|
@keyframes fadeInUp {
|
||||||
|
from {
|
||||||
|
opacity: 0;
|
||||||
|
transform: translateY(30px);
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
opacity: 1;
|
||||||
|
transform: translateY(0);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card {
|
||||||
|
animation: fadeInUp 0.6s ease forwards;
|
||||||
|
|
||||||
|
&:nth-child(1) { animation-delay: 0.1s; }
|
||||||
|
&:nth-child(2) { animation-delay: 0.2s; }
|
||||||
|
&:nth-child(3) { animation-delay: 0.3s; }
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive improvements
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.hero-section {
|
||||||
|
min-height: 50vh;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 2.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.lead {
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.navbar-brand {
|
||||||
|
font-size: 1.25rem !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.feature-icon {
|
||||||
|
width: 3rem !important;
|
||||||
|
height: 3rem !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Custom scrollbar for webkit browsers
|
||||||
|
::-webkit-scrollbar {
|
||||||
|
width: 8px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-track {
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.3);
|
||||||
|
border-radius: 4px;
|
||||||
|
}
|
||||||
|
|
||||||
|
::-webkit-scrollbar-thumb:hover {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.5);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus styles for accessibility
|
||||||
|
.btn:focus,
|
||||||
|
.form-check-input:focus,
|
||||||
|
.nav-link:focus {
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Print styles
|
||||||
|
@media print {
|
||||||
|
.navbar,
|
||||||
|
.form-check,
|
||||||
|
footer {
|
||||||
|
display: none !important;
|
||||||
|
}
|
||||||
|
|
||||||
|
.hero-section {
|
||||||
|
background: none !important;
|
||||||
|
color: black !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Kanban Board Styles
|
||||||
|
.kanban-board {
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
min-height: 100vh;
|
||||||
|
padding: 2rem 0;
|
||||||
|
|
||||||
|
.kanban-header {
|
||||||
|
margin-bottom: 2rem;
|
||||||
|
text-align: center;
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-weight: 700;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-description {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-columns {
|
||||||
|
display: flex;
|
||||||
|
gap: 1.5rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
padding: 1rem 0;
|
||||||
|
min-height: 70vh;
|
||||||
|
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
flex: 1;
|
||||||
|
min-width: 300px;
|
||||||
|
background-color: var(--bs-feature-bg);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
box-shadow: var(--bs-shadow);
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
.column-header {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 2px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
.column-title {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.card-count {
|
||||||
|
background-color: var(--bs-navbar-color);
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
border-radius: 50%;
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-description {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-cards {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
gap: 1rem;
|
||||||
|
min-height: 200px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
background-color: var(--bs-card-bg);
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
box-shadow: var(--bs-shadow);
|
||||||
|
transition: all 0.3s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-header {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
font-size: 1rem;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-description {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
.priority-badge {
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
|
||||||
|
&.priority-high {
|
||||||
|
background-color: rgba(220, 53, 69, 0.1);
|
||||||
|
color: #dc3545;
|
||||||
|
border: 1px solid rgba(220, 53, 69, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.priority-medium {
|
||||||
|
background-color: rgba(255, 193, 7, 0.1);
|
||||||
|
color: #ffc107;
|
||||||
|
border: 1px solid rgba(255, 193, 7, 0.2);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.priority-low {
|
||||||
|
background-color: rgba(25, 135, 84, 0.1);
|
||||||
|
color: #198754;
|
||||||
|
border: 1px solid rgba(25, 135, 84, 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.due-date {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
color: var(--bs-primary);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 500;
|
||||||
|
border: 1px solid rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-tags {
|
||||||
|
display: flex;
|
||||||
|
flex-wrap: wrap;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
|
||||||
|
.tag {
|
||||||
|
background-color: var(--bs-navbar-color);
|
||||||
|
color: var(--bs-body-bg);
|
||||||
|
padding: 0.125rem 0.5rem;
|
||||||
|
border-radius: 1rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-assignee {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
.assignee-avatar {
|
||||||
|
width: 1.5rem;
|
||||||
|
height: 1.5rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
.stat-item {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.25rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-progress {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.progress-bar {
|
||||||
|
width: 40px;
|
||||||
|
height: 4px;
|
||||||
|
background-color: var(--bs-card-border);
|
||||||
|
border-radius: 2px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #198754;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text {
|
||||||
|
font-size: 0.7rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive kanban adjustments
|
||||||
|
@media (max-width: 992px) {
|
||||||
|
.kanban-board {
|
||||||
|
.kanban-columns {
|
||||||
|
gap: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
min-width: 280px;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.kanban-board {
|
||||||
|
padding: 1rem 0;
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
min-width: 100%;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-card {
|
||||||
|
padding: 0.75rem;
|
||||||
|
|
||||||
|
.card-meta {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-stats {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
// Save popup styles
|
||||||
|
.save-popup {
|
||||||
|
position: fixed;
|
||||||
|
top: 2rem;
|
||||||
|
right: 2rem;
|
||||||
|
z-index: 1100;
|
||||||
|
animation: slideInRight 0.3s ease-out;
|
||||||
|
|
||||||
|
.save-popup-content {
|
||||||
|
background-color: var(--bs-success);
|
||||||
|
color: white;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
font-weight: 500;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
@keyframes slideInRight {
|
||||||
|
from {
|
||||||
|
transform: translateX(100%);
|
||||||
|
opacity: 0;
|
||||||
|
}
|
||||||
|
to {
|
||||||
|
transform: translateX(0);
|
||||||
|
opacity: 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Drag and drop styles
|
||||||
|
.kanban-card {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-2px);
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
}
|
||||||
|
|
||||||
|
&.dragging {
|
||||||
|
opacity: 0.5;
|
||||||
|
transform: rotate(5deg);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.kanban-column {
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.drag-over {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
transform: scale(1.02);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.column-cards {
|
||||||
|
min-height: 200px;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&.drag-over {
|
||||||
|
background-color: rgba(var(--bs-primary-rgb), 0.05);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
139
kanban_data.json
Normal file
139
kanban_data.json
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"title": "Project Management Board",
|
||||||
|
"description": "Track project progress with this kanban board",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"id": "todo",
|
||||||
|
"title": "To Do",
|
||||||
|
"description": "Tasks that need to be started",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-1",
|
||||||
|
"title": "Design User Interface",
|
||||||
|
"description": "Create wireframes and mockups for the new feature",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Alice Johnson",
|
||||||
|
"tags": ["design", "ui/ux"],
|
||||||
|
"dueDate": "2024-01-15",
|
||||||
|
"attachments": 2,
|
||||||
|
"comments": 3,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 1,
|
||||||
|
"total": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-2",
|
||||||
|
"title": "Research Market Trends",
|
||||||
|
"description": "Analyze current market trends and competitor analysis",
|
||||||
|
"priority": "medium",
|
||||||
|
"assignee": "Bob Smith",
|
||||||
|
"tags": ["research", "analysis"],
|
||||||
|
"dueDate": "2024-01-20",
|
||||||
|
"attachments": 0,
|
||||||
|
"comments": 1,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 0,
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "in-progress",
|
||||||
|
"title": "In Progress",
|
||||||
|
"description": "Tasks currently being worked on",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-3",
|
||||||
|
"title": "Implement Authentication",
|
||||||
|
"description": "Set up user authentication system with JWT tokens and secure password handling",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Charlie Brown",
|
||||||
|
"tags": ["backend", "security"],
|
||||||
|
"dueDate": "2024-01-12",
|
||||||
|
"attachments": 1,
|
||||||
|
"comments": 5,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 2,
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-4",
|
||||||
|
"title": "Database Migration",
|
||||||
|
"description": "Migrate existing data to new database schema",
|
||||||
|
"priority": "medium",
|
||||||
|
"assignee": "Diana Prince",
|
||||||
|
"tags": ["database", "migration"],
|
||||||
|
"dueDate": "2024-01-18",
|
||||||
|
"attachments": 3,
|
||||||
|
"comments": 2,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 3,
|
||||||
|
"total": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"title": "Review",
|
||||||
|
"description": "Tasks pending review and approval",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-5",
|
||||||
|
"title": "API Documentation",
|
||||||
|
"description": "Complete API documentation with examples and usage guidelines",
|
||||||
|
"priority": "low",
|
||||||
|
"assignee": "Eve Wilson",
|
||||||
|
"tags": ["documentation", "api"],
|
||||||
|
"dueDate": "2024-01-10",
|
||||||
|
"attachments": 2,
|
||||||
|
"comments": 4,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 4,
|
||||||
|
"total": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "done",
|
||||||
|
"title": "Done",
|
||||||
|
"description": "Completed tasks",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-6",
|
||||||
|
"title": "Setup Development Environment",
|
||||||
|
"description": "Configure development tools and environment for the team",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Frank Miller",
|
||||||
|
"tags": ["setup", "devops"],
|
||||||
|
"dueDate": "2024-01-05",
|
||||||
|
"attachments": 1,
|
||||||
|
"comments": 2,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 3,
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-7",
|
||||||
|
"title": "Initial Project Planning",
|
||||||
|
"description": "Define project scope, timeline, and resource allocation",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Grace Lee",
|
||||||
|
"tags": ["planning", "management"],
|
||||||
|
"dueDate": "2024-01-03",
|
||||||
|
"attachments": 4,
|
||||||
|
"comments": 8,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 5,
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
139
public/kanban_data.json
Normal file
139
public/kanban_data.json
Normal file
@@ -0,0 +1,139 @@
|
|||||||
|
{
|
||||||
|
"title": "Project Management Board",
|
||||||
|
"description": "Track project progress with this kanban board",
|
||||||
|
"columns": [
|
||||||
|
{
|
||||||
|
"id": "todo",
|
||||||
|
"title": "To Do",
|
||||||
|
"description": "Tasks that need to be started",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-1",
|
||||||
|
"title": "Design User Interface",
|
||||||
|
"description": "Create wireframes and mockups for the new feature",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Alice Johnson",
|
||||||
|
"tags": ["design", "ui/ux"],
|
||||||
|
"dueDate": "2024-01-15",
|
||||||
|
"attachments": 2,
|
||||||
|
"comments": 3,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 1,
|
||||||
|
"total": 4
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-2",
|
||||||
|
"title": "Research Market Trends",
|
||||||
|
"description": "Analyze current market trends and competitor analysis",
|
||||||
|
"priority": "medium",
|
||||||
|
"assignee": "Bob Smith",
|
||||||
|
"tags": ["research", "analysis"],
|
||||||
|
"dueDate": "2024-01-20",
|
||||||
|
"attachments": 0,
|
||||||
|
"comments": 1,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 0,
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "in-progress",
|
||||||
|
"title": "In Progress",
|
||||||
|
"description": "Tasks currently being worked on",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-3",
|
||||||
|
"title": "Implement Authentication",
|
||||||
|
"description": "Set up user authentication system with JWT tokens and secure password handling",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Charlie Brown",
|
||||||
|
"tags": ["backend", "security"],
|
||||||
|
"dueDate": "2024-01-12",
|
||||||
|
"attachments": 1,
|
||||||
|
"comments": 5,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 2,
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-4",
|
||||||
|
"title": "Database Migration",
|
||||||
|
"description": "Migrate existing data to new database schema",
|
||||||
|
"priority": "medium",
|
||||||
|
"assignee": "Diana Prince",
|
||||||
|
"tags": ["database", "migration"],
|
||||||
|
"dueDate": "2024-01-18",
|
||||||
|
"attachments": 3,
|
||||||
|
"comments": 2,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 3,
|
||||||
|
"total": 6
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "review",
|
||||||
|
"title": "Review",
|
||||||
|
"description": "Tasks pending review and approval",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-5",
|
||||||
|
"title": "API Documentation",
|
||||||
|
"description": "Complete API documentation with examples and usage guidelines",
|
||||||
|
"priority": "low",
|
||||||
|
"assignee": "Eve Wilson",
|
||||||
|
"tags": ["documentation", "api"],
|
||||||
|
"dueDate": "2024-01-10",
|
||||||
|
"attachments": 2,
|
||||||
|
"comments": 4,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 4,
|
||||||
|
"total": 4
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "done",
|
||||||
|
"title": "Done",
|
||||||
|
"description": "Completed tasks",
|
||||||
|
"cards": [
|
||||||
|
{
|
||||||
|
"id": "card-6",
|
||||||
|
"title": "Setup Development Environment",
|
||||||
|
"description": "Configure development tools and environment for the team",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Frank Miller",
|
||||||
|
"tags": ["setup", "devops"],
|
||||||
|
"dueDate": "2024-01-05",
|
||||||
|
"attachments": 1,
|
||||||
|
"comments": 2,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 3,
|
||||||
|
"total": 3
|
||||||
|
}
|
||||||
|
},
|
||||||
|
{
|
||||||
|
"id": "card-7",
|
||||||
|
"title": "Initial Project Planning",
|
||||||
|
"description": "Define project scope, timeline, and resource allocation",
|
||||||
|
"priority": "high",
|
||||||
|
"assignee": "Grace Lee",
|
||||||
|
"tags": ["planning", "management"],
|
||||||
|
"dueDate": "2024-01-03",
|
||||||
|
"attachments": 4,
|
||||||
|
"comments": 8,
|
||||||
|
"checklist": {
|
||||||
|
"completed": 5,
|
||||||
|
"total": 5
|
||||||
|
}
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
||||||
|
]
|
||||||
|
}
|
129
src/app.rs
Normal file
129
src/app.rs
Normal file
@@ -0,0 +1,129 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
use gloo_utils::document;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use serde::{Deserialize, Serialize};
|
||||||
|
use crate::home::HomePage;
|
||||||
|
use crate::kanban::KanbanBoard;
|
||||||
|
use crate::edit_card_page::EditCardPage;
|
||||||
|
|
||||||
|
#[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)]
|
||||||
|
enum Theme {
|
||||||
|
Light,
|
||||||
|
Dark,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Theme {
|
||||||
|
fn as_str(&self) -> &'static str {
|
||||||
|
match self {
|
||||||
|
Theme::Light => "light-theme",
|
||||||
|
Theme::Dark => "dark-theme",
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Clone, Routable, PartialEq)]
|
||||||
|
pub enum Route {
|
||||||
|
#[at("/")]
|
||||||
|
Home,
|
||||||
|
#[at("/kanban")]
|
||||||
|
Kanban,
|
||||||
|
#[at("/edit/:card_id")]
|
||||||
|
EditCard { card_id: String },
|
||||||
|
#[at("/edit?documentid=:document_id")]
|
||||||
|
EditByDocumentId { document_id: String },
|
||||||
|
#[at("/edit?content=:content")]
|
||||||
|
EditByContent { content: String },
|
||||||
|
#[not_found]
|
||||||
|
#[at("/404")]
|
||||||
|
NotFound,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn switch(routes: Route) -> Html {
|
||||||
|
match routes {
|
||||||
|
Route::Home => html! { <HomePage /> },
|
||||||
|
Route::Kanban => html! { <KanbanBoard /> },
|
||||||
|
Route::EditCard { card_id } => html! { <EditCardPage card_id={card_id} /> },
|
||||||
|
Route::EditByDocumentId { document_id } => html! { <EditCardPage card_id={document_id} /> },
|
||||||
|
Route::EditByContent { content } => {
|
||||||
|
// For content-based editing, we could create a new component or handle it differently
|
||||||
|
// For now, redirect to kanban board
|
||||||
|
html! { <KanbanBoard /> }
|
||||||
|
},
|
||||||
|
Route::NotFound => html! { <h1>{ "404 - Page not found" }</h1> },
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(App)]
|
||||||
|
pub fn app() -> Html {
|
||||||
|
let theme = use_state(|| {
|
||||||
|
LocalStorage::get("theme").unwrap_or(Theme::Light)
|
||||||
|
});
|
||||||
|
|
||||||
|
let onclick_theme_toggle = {
|
||||||
|
let theme = theme.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let new_theme = if *theme == Theme::Light { Theme::Dark } else { Theme::Light };
|
||||||
|
theme.set(new_theme);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
use_effect_with(theme.clone(), move |theme| {
|
||||||
|
let body = document().body().expect("body element not found");
|
||||||
|
body.set_class_name(theme.as_str());
|
||||||
|
LocalStorage::set("theme", **theme).unwrap();
|
||||||
|
});
|
||||||
|
|
||||||
|
let navbar_class = if *theme == Theme::Dark { "navbar navbar-expand-lg navbar-dark" } else { "navbar navbar-expand-lg navbar-light" };
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<BrowserRouter>
|
||||||
|
<nav class={navbar_class}>
|
||||||
|
<div class="container-fluid">
|
||||||
|
<Link<Route> to={Route::Home} classes="navbar-brand fw-bold">
|
||||||
|
{"🦀 Yew Bootstrap App"}
|
||||||
|
</Link<Route>>
|
||||||
|
<button class="navbar-toggler" type="button" data-bs-toggle="collapse" data-bs-target="#navbarNav" aria-controls="navbarNav" aria-expanded="false" aria-label="Toggle navigation">
|
||||||
|
<span class="navbar-toggler-icon"></span>
|
||||||
|
</button>
|
||||||
|
<div class="collapse navbar-collapse" id="navbarNav">
|
||||||
|
<ul class="navbar-nav me-auto mb-2 mb-lg-0">
|
||||||
|
<li class="nav-item">
|
||||||
|
<Link<Route> to={Route::Home} classes="nav-link">
|
||||||
|
<i class="bi bi-house-fill me-1"></i>{"Home"}
|
||||||
|
</Link<Route>>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<Link<Route> to={Route::Kanban} classes="nav-link">
|
||||||
|
<i class="bi bi-kanban me-1"></i>{"Kanban"}
|
||||||
|
</Link<Route>>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">
|
||||||
|
<i class="bi bi-star-fill me-1"></i>{"Features"}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">
|
||||||
|
<i class="bi bi-currency-dollar me-1"></i>{"Pricing"}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
<li class="nav-item">
|
||||||
|
<a class="nav-link" href="#">
|
||||||
|
<i class="bi bi-envelope-fill me-1"></i>{"Contact"}
|
||||||
|
</a>
|
||||||
|
</li>
|
||||||
|
</ul>
|
||||||
|
<div class="form-check form-switch d-flex align-items-center">
|
||||||
|
<i class="bi bi-sun-fill me-2 text-warning"></i>
|
||||||
|
<input class="form-check-input me-2" type="checkbox" id="flexSwitchCheckDefault" onclick={onclick_theme_toggle} checked={*theme == Theme::Dark} />
|
||||||
|
<i class="bi bi-moon-stars-fill text-info"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</nav>
|
||||||
|
|
||||||
|
<Switch<Route> render={switch} />
|
||||||
|
</BrowserRouter>
|
||||||
|
}
|
||||||
|
}
|
311
src/edit_card_page.rs
Normal file
311
src/edit_card_page.rs
Normal file
@@ -0,0 +1,311 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use yew_router::prelude::*;
|
||||||
|
use web_sys::{HtmlInputElement, HtmlSelectElement};
|
||||||
|
use wasm_bindgen::JsCast;
|
||||||
|
use chrono::Utc;
|
||||||
|
use gloo_storage::{LocalStorage, Storage};
|
||||||
|
use serde_json;
|
||||||
|
|
||||||
|
use crate::kanban::{KanbanCard, Comment, get_sample_data};
|
||||||
|
use crate::markdown_editor::MarkdownDescriptionView;
|
||||||
|
use crate::app::Route;
|
||||||
|
use crate::kanban::DateSelector; // Assuming DateSelector is public and needed
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct EditCardPageProps {
|
||||||
|
pub card_id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(EditCardPage)]
|
||||||
|
pub fn edit_card_page(props: &EditCardPageProps) -> Html {
|
||||||
|
let navigator = use_navigator().unwrap();
|
||||||
|
|
||||||
|
// Load kanban data from local storage or use sample data
|
||||||
|
let kanban_data = use_state(|| {
|
||||||
|
match LocalStorage::get::<String>("kanban_data") {
|
||||||
|
Ok(data_str) => {
|
||||||
|
serde_json::from_str(&data_str).unwrap_or_else(|_| get_sample_data())
|
||||||
|
}
|
||||||
|
Err(_) => get_sample_data()
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Find the card to edit
|
||||||
|
let card_to_edit = use_state(|| {
|
||||||
|
let data = (*kanban_data).clone();
|
||||||
|
data.columns.iter()
|
||||||
|
.flat_map(|col| &col.cards)
|
||||||
|
.find(|card| card.id == props.card_id)
|
||||||
|
.cloned()
|
||||||
|
});
|
||||||
|
|
||||||
|
let card = use_state(|| card_to_edit.as_ref().cloned().unwrap_or_else(|| KanbanCard {
|
||||||
|
id: "".to_string(),
|
||||||
|
title: "New Card".to_string(),
|
||||||
|
description: "".to_string(),
|
||||||
|
priority: "medium".to_string(),
|
||||||
|
assignee: "".to_string(),
|
||||||
|
tags: vec![],
|
||||||
|
due_date: "".to_string(),
|
||||||
|
attachments: 0,
|
||||||
|
comments: 0,
|
||||||
|
checklist: crate::kanban::ChecklistInfo { completed: 0, total: 0 },
|
||||||
|
comments_list: vec![],
|
||||||
|
}));
|
||||||
|
|
||||||
|
// If card_to_edit is None, navigate back to kanban board
|
||||||
|
if card_to_edit.is_none() {
|
||||||
|
navigator.push(&Route::Kanban);
|
||||||
|
return html! { <p>{"Card not found, redirecting..."}</p> };
|
||||||
|
}
|
||||||
|
|
||||||
|
let on_title_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.title = input.value();
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_description_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |value: String| {
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.description = value;
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_priority_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let select: HtmlSelectElement = e.target_unchecked_into();
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.priority = select.value();
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_assignee_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.assignee = input.value();
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_due_date_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |date: String| {
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.due_date = date;
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_add_comment = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |content: String| {
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
let new_comment = Comment {
|
||||||
|
id: format!("comment-{}", Utc::now().timestamp_millis()),
|
||||||
|
author: "Current User".to_string(), // In a real app, this would be the logged-in user
|
||||||
|
content,
|
||||||
|
timestamp: Utc::now().to_rfc3339(),
|
||||||
|
};
|
||||||
|
updated_card.comments_list.push(new_comment);
|
||||||
|
updated_card.comments = updated_card.comments_list.len() as u32;
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_delete_comment = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |comment_id: String| {
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.comments_list.retain(|c| c.id != comment_id);
|
||||||
|
updated_card.comments = updated_card.comments_list.len() as u32;
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_tags_change = {
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let mut updated_card = (*card).clone();
|
||||||
|
updated_card.tags = input.value()
|
||||||
|
.split(',')
|
||||||
|
.map(|s| s.trim().to_string())
|
||||||
|
.filter(|s| !s.is_empty())
|
||||||
|
.collect();
|
||||||
|
card.set(updated_card);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_save_click = {
|
||||||
|
let card = card.clone();
|
||||||
|
let kanban_data = kanban_data.clone();
|
||||||
|
let navigator = navigator.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
let mut new_data = (*kanban_data).clone();
|
||||||
|
let updated_card = (*card).clone();
|
||||||
|
|
||||||
|
// Find and update the card in the data
|
||||||
|
for column in &mut new_data.columns {
|
||||||
|
if let Some(card_index) = column.cards.iter().position(|c| c.id == updated_card.id) {
|
||||||
|
column.cards[card_index] = updated_card.clone();
|
||||||
|
break;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Save updated data to local storage
|
||||||
|
LocalStorage::set("kanban_data", serde_json::to_string(&new_data).unwrap()).unwrap();
|
||||||
|
kanban_data.set(new_data); // Update state
|
||||||
|
navigator.push(&Route::Kanban); // Navigate back to kanban board
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_cancel_click = {
|
||||||
|
let navigator = navigator.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
navigator.push(&Route::Kanban); // Navigate back to kanban board without saving
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let tags_string = card.tags.join(", ");
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="container mt-4">
|
||||||
|
<div class="card">
|
||||||
|
<div class="card-header">
|
||||||
|
<h2>{"Edit Card"}</h2>
|
||||||
|
</div>
|
||||||
|
<div class="card-body">
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="card-title" class="form-label">{"Title"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="card-title"
|
||||||
|
class="form-control"
|
||||||
|
value={card.title.clone()}
|
||||||
|
onchange={on_title_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3 description-form-group">
|
||||||
|
<MarkdownDescriptionView
|
||||||
|
content={card.description.clone()}
|
||||||
|
on_change={on_description_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label for="card-priority" class="form-label">{"Priority"}</label>
|
||||||
|
<select
|
||||||
|
id="card-priority"
|
||||||
|
class="form-select"
|
||||||
|
value={card.priority.clone()}
|
||||||
|
onchange={on_priority_change}
|
||||||
|
>
|
||||||
|
<option value="low">{"Low"}</option>
|
||||||
|
<option value="medium">{"Medium"}</option>
|
||||||
|
<option value="high">{"High"}</option>
|
||||||
|
</select>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<DateSelector
|
||||||
|
value={card.due_date.clone()}
|
||||||
|
on_change={on_due_date_change}
|
||||||
|
label={"Due Date".to_string()}
|
||||||
|
id={"card-due-date".to_string()}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="card-assignee" class="form-label">{"Assignee"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="card-assignee"
|
||||||
|
class="form-control"
|
||||||
|
value={card.assignee.clone()}
|
||||||
|
onchange={on_assignee_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label for="card-tags" class="form-label">{"Tags (comma separated)"}</label>
|
||||||
|
<input
|
||||||
|
type="text"
|
||||||
|
id="card-tags"
|
||||||
|
class="form-control"
|
||||||
|
value={tags_string}
|
||||||
|
onchange={on_tags_change}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="row mb-3">
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{"Attachments"}</label>
|
||||||
|
<div class="stat-display">
|
||||||
|
<i class="bi bi-paperclip me-2"></i>
|
||||||
|
{card.attachments}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-6">
|
||||||
|
<div class="form-group">
|
||||||
|
<label class="form-label">{"Comments"}</label>
|
||||||
|
<div class="stat-display">
|
||||||
|
<i class="bi bi-chat-dots me-2"></i>
|
||||||
|
{card.comments}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="form-group mb-3">
|
||||||
|
<label class="form-label">{"Checklist Progress"}</label>
|
||||||
|
<div class="checklist-display">
|
||||||
|
<div class="progress-bar-large">
|
||||||
|
<div
|
||||||
|
class="progress-fill"
|
||||||
|
style={format!("width: {}%", if card.checklist.total > 0 {
|
||||||
|
(card.checklist.completed as f32 / card.checklist.total as f32 * 100.0) as u32
|
||||||
|
} else { 0 })}
|
||||||
|
></div>
|
||||||
|
</div>
|
||||||
|
<span class="progress-text-large">
|
||||||
|
{format!("{}/{} completed", card.checklist.completed, card.checklist.total)}
|
||||||
|
</span>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<crate::kanban::CommentsCard
|
||||||
|
comments={card.comments_list.clone()}
|
||||||
|
on_add_comment={on_add_comment}
|
||||||
|
on_delete_comment={on_delete_comment}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="card-footer text-end">
|
||||||
|
<button type="button" class="btn btn-secondary me-2" onclick={on_cancel_click}>
|
||||||
|
{"Cancel"}
|
||||||
|
</button>
|
||||||
|
<button type="button" class="btn btn-primary" onclick={on_save_click}>
|
||||||
|
{"Save Changes"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
94
src/home.rs
Normal file
94
src/home.rs
Normal file
@@ -0,0 +1,94 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
|
||||||
|
#[function_component(HomePage)]
|
||||||
|
pub fn home_page() -> Html {
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="hero-section jumbotron">
|
||||||
|
<div class="container py-5 text-center">
|
||||||
|
<div class="row justify-content-center">
|
||||||
|
<div class="col-lg-8">
|
||||||
|
<h1 class="display-4 fw-bold mb-4">{"🚀 Welcome to Yew Bootstrap!"}</h1>
|
||||||
|
<p class="lead fs-5 mb-4">{"Experience the power of Rust and WebAssembly with this modern Yew application featuring Bootstrap 5 integration, responsive design, and seamless dark mode switching."}</p>
|
||||||
|
<div class="d-grid gap-2 d-md-flex justify-content-md-center">
|
||||||
|
<button class="btn btn-primary btn-lg px-4 me-md-2" type="button">
|
||||||
|
<i class="bi bi-rocket-takeoff me-2"></i>{"Get Started"}
|
||||||
|
</button>
|
||||||
|
<button class="btn btn-outline-secondary btn-lg px-4" type="button">
|
||||||
|
<i class="bi bi-github me-2"></i>{"View Source"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="container my-5">
|
||||||
|
<div class="row g-4">
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-primary bg-gradient text-white rounded-3 mb-3 mx-auto" style="width: 4rem; height: 4rem; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<i class="bi bi-lightning-charge fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">{"⚡ Fast Performance"}</h3>
|
||||||
|
<p class="card-text">{"Built with Rust and WebAssembly for blazing fast performance. Experience near-native speed in your web applications."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-success bg-gradient text-white rounded-3 mb-3 mx-auto" style="width: 4rem; height: 4rem; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<i class="bi bi-shield-check fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">{"🛡️ Type Safety"}</h3>
|
||||||
|
<p class="card-text">{"Rust's powerful type system ensures memory safety and prevents common programming errors at compile time."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="col-md-4">
|
||||||
|
<div class="card h-100 shadow-sm">
|
||||||
|
<div class="card-body text-center">
|
||||||
|
<div class="feature-icon bg-info bg-gradient text-white rounded-3 mb-3 mx-auto" style="width: 4rem; height: 4rem; display: flex; align-items: center; justify-content: center;">
|
||||||
|
<i class="bi bi-phone fs-2"></i>
|
||||||
|
</div>
|
||||||
|
<h3 class="card-title">{"📱 Responsive Design"}</h3>
|
||||||
|
<p class="card-text">{"Fully responsive design that works perfectly on desktop, tablet, and mobile devices with Bootstrap 5."}</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="bg-body-secondary py-5">
|
||||||
|
<div class="container">
|
||||||
|
<div class="row align-items-center">
|
||||||
|
<div class="col-lg-6">
|
||||||
|
<h2 class="display-6 fw-bold mb-3">{"🎨 Modern UI Components"}</h2>
|
||||||
|
<p class="lead">{"This application showcases modern UI patterns with smooth theme transitions, interactive components, and beautiful typography."}</p>
|
||||||
|
<ul class="list-unstyled">
|
||||||
|
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i>{"Dark/Light theme switching"}</li>
|
||||||
|
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i>{"Responsive navigation"}</li>
|
||||||
|
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i>{"Bootstrap 5 integration"}</li>
|
||||||
|
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i>{"Custom CSS properties"}</li>
|
||||||
|
<li class="mb-2"><i class="bi bi-check-circle-fill text-success me-2"></i>{"Kanban board with rich content"}</li>
|
||||||
|
</ul>
|
||||||
|
</div>
|
||||||
|
<div class="col-lg-6 text-center">
|
||||||
|
<div class="p-4">
|
||||||
|
<i class="bi bi-palette2 display-1 text-primary"></i>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<footer class="bg-body-tertiary py-4 mt-5">
|
||||||
|
<div class="container text-center">
|
||||||
|
<p class="mb-0">{"Built with ❤️ using Yew, Rust, and Bootstrap 5"}</p>
|
||||||
|
</div>
|
||||||
|
</footer>
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
1250
src/kanban.rs
Normal file
1250
src/kanban.rs
Normal file
File diff suppressed because it is too large
Load Diff
10
src/main.rs
Normal file
10
src/main.rs
Normal file
@@ -0,0 +1,10 @@
|
|||||||
|
mod app;
|
||||||
|
mod home;
|
||||||
|
mod kanban;
|
||||||
|
mod markdown_editor;
|
||||||
|
mod edit_card_page;
|
||||||
|
use app::App;
|
||||||
|
|
||||||
|
fn main() {
|
||||||
|
yew::Renderer::<App>::new().render();
|
||||||
|
}
|
246
src/markdown_editor.rs
Normal file
246
src/markdown_editor.rs
Normal file
@@ -0,0 +1,246 @@
|
|||||||
|
use yew::prelude::*;
|
||||||
|
use web_sys::{HtmlTextAreaElement, MouseEvent};
|
||||||
|
use pulldown_cmark::{Parser, Options, html};
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MarkdownEditorProps {
|
||||||
|
pub value: String,
|
||||||
|
pub on_change: Callback<String>,
|
||||||
|
pub placeholder: Option<String>,
|
||||||
|
pub rows: Option<u32>,
|
||||||
|
pub widescreen: Option<bool>,
|
||||||
|
}
|
||||||
|
|
||||||
|
fn markdown_to_html(markdown: &str) -> String {
|
||||||
|
let mut options = Options::empty();
|
||||||
|
options.insert(Options::ENABLE_STRIKETHROUGH);
|
||||||
|
options.insert(Options::ENABLE_TABLES);
|
||||||
|
options.insert(Options::ENABLE_FOOTNOTES);
|
||||||
|
options.insert(Options::ENABLE_TASKLISTS);
|
||||||
|
|
||||||
|
let parser = Parser::new_ext(markdown, options);
|
||||||
|
let mut html_output = String::new();
|
||||||
|
html::push_html(&mut html_output, parser);
|
||||||
|
html_output
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MarkdownEditor)]
|
||||||
|
pub fn markdown_editor(props: &MarkdownEditorProps) -> Html {
|
||||||
|
let widescreen = use_state(|| props.widescreen.unwrap_or(false));
|
||||||
|
|
||||||
|
let toggle_widescreen = {
|
||||||
|
let widescreen = widescreen.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
widescreen.set(!*widescreen);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_input = {
|
||||||
|
let on_change = props.on_change.clone();
|
||||||
|
Callback::from(move |e: InputEvent| {
|
||||||
|
let textarea: HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
on_change.emit(textarea.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let container_class = if *widescreen {
|
||||||
|
"markdown-editor-container widescreen"
|
||||||
|
} else {
|
||||||
|
"markdown-editor-container"
|
||||||
|
};
|
||||||
|
|
||||||
|
// Split view with editor on left and preview on right
|
||||||
|
let editor_class = "markdown-editor split-view";
|
||||||
|
|
||||||
|
// Convert markdown to HTML for preview
|
||||||
|
let rendered_html = use_memo(
|
||||||
|
props.value.clone(),
|
||||||
|
|content| markdown_to_html(content),
|
||||||
|
);
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class={container_class}>
|
||||||
|
<div class="markdown-toolbar">
|
||||||
|
<div class="toolbar-left">
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class={if *widescreen { "btn btn-success btn-sm" } else { "btn btn-outline-success btn-sm" }}
|
||||||
|
onclick={toggle_widescreen.clone()}
|
||||||
|
title="Toggle Widescreen"
|
||||||
|
>
|
||||||
|
<i class="bi bi-arrows-angle-expand me-1"></i>
|
||||||
|
{if *widescreen { "Normal View" } else { "Widescreen" }}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="toolbar-right">
|
||||||
|
<small class="text-muted">{"Markdown Editor with Live Preview"}</small>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class={editor_class}>
|
||||||
|
<div class="editor-pane">
|
||||||
|
<textarea
|
||||||
|
class="form-control markdown-textarea"
|
||||||
|
value={props.value.clone()}
|
||||||
|
placeholder={props.placeholder.clone().unwrap_or_else(|| "Enter markdown text...".to_string())}
|
||||||
|
rows={props.rows.unwrap_or(10).to_string()}
|
||||||
|
oninput={on_input}
|
||||||
|
></textarea>
|
||||||
|
</div>
|
||||||
|
<div class="preview-pane">
|
||||||
|
<div class="preview-header">
|
||||||
|
<h6>{"Preview"}</h6>
|
||||||
|
</div>
|
||||||
|
<div class="markdown-preview">
|
||||||
|
<MarkdownContent html={(*rendered_html).clone()} />
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MarkdownContentProps {
|
||||||
|
pub html: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MarkdownContent)]
|
||||||
|
pub fn markdown_content(props: &MarkdownContentProps) -> Html {
|
||||||
|
let div_ref = use_node_ref();
|
||||||
|
|
||||||
|
use_effect_with(props.html.clone(), {
|
||||||
|
let div_ref = div_ref.clone();
|
||||||
|
move |html| {
|
||||||
|
if let Some(div) = div_ref.cast::<web_sys::HtmlElement>() {
|
||||||
|
div.set_inner_html(html);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div ref={div_ref}></div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MarkdownViewerProps {
|
||||||
|
pub content: String,
|
||||||
|
pub class: Option<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MarkdownViewer)]
|
||||||
|
pub fn markdown_viewer(props: &MarkdownViewerProps) -> Html {
|
||||||
|
// Convert markdown to HTML using use_memo with correct syntax
|
||||||
|
let rendered_html = use_memo(
|
||||||
|
props.content.clone(),
|
||||||
|
|content| markdown_to_html(content),
|
||||||
|
);
|
||||||
|
|
||||||
|
let class = props.class.clone().unwrap_or_else(|| "markdown-content".to_string());
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class={class}>
|
||||||
|
<MarkdownContent html={(*rendered_html).clone()} />
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct MarkdownDescriptionViewProps {
|
||||||
|
pub content: String,
|
||||||
|
pub on_change: Callback<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(MarkdownDescriptionView)]
|
||||||
|
pub fn markdown_description_view(props: &MarkdownDescriptionViewProps) -> Html {
|
||||||
|
let show_editor_modal = use_state(|| false);
|
||||||
|
|
||||||
|
let on_edit_click = {
|
||||||
|
let show_editor_modal = show_editor_modal.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_editor_modal.set(true);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_modal_close = {
|
||||||
|
let show_editor_modal = show_editor_modal.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
show_editor_modal.set(false);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_content_change = {
|
||||||
|
let on_change = props.on_change.clone();
|
||||||
|
Callback::from(move |new_content: String| {
|
||||||
|
on_change.emit(new_content);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<>
|
||||||
|
<div class="markdown-description-view">
|
||||||
|
<div class="description-header">
|
||||||
|
<label class="form-label">{"Description"}</label>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-edit-description"
|
||||||
|
onclick={on_edit_click}
|
||||||
|
title="Edit Description"
|
||||||
|
>
|
||||||
|
<i class="bi bi-pencil"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="description-content">
|
||||||
|
{if props.content.trim().is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="empty-description">
|
||||||
|
<i class="bi bi-file-text text-muted"></i>
|
||||||
|
<span class="text-muted">{"No description provided. Click the edit icon to add one."}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {
|
||||||
|
<MarkdownViewer
|
||||||
|
content={props.content.clone()}
|
||||||
|
class={Some("card-description-content".to_string())}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if *show_editor_modal {
|
||||||
|
html! {
|
||||||
|
<div class="modal-overlay" onclick={on_modal_close.clone()}>
|
||||||
|
<div class="modal-content markdown-editor-modal" onclick={|e: MouseEvent| e.stop_propagation()}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{"Edit Description"}</h2>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-close"
|
||||||
|
onclick={on_modal_close}
|
||||||
|
title="Close"
|
||||||
|
>
|
||||||
|
{"×"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
<div class="modal-body">
|
||||||
|
<MarkdownEditor
|
||||||
|
value={props.content.clone()}
|
||||||
|
on_change={on_content_change}
|
||||||
|
placeholder={Some("Enter description in Markdown format...".to_string())}
|
||||||
|
rows={Some(15)}
|
||||||
|
widescreen={Some(false)}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
|
</>
|
||||||
|
}
|
||||||
|
}
|
6
start.sh
Executable file
6
start.sh
Executable file
@@ -0,0 +1,6 @@
|
|||||||
|
#!/bin/bash
|
||||||
|
set -ex
|
||||||
|
|
||||||
|
cd "$(dirname "$0")"
|
||||||
|
|
||||||
|
trunk serve --open -p 9999
|
250
styles/comments.scss
Normal file
250
styles/comments.scss
Normal file
@@ -0,0 +1,250 @@
|
|||||||
|
// Comments Card styles
|
||||||
|
.comments-card {
|
||||||
|
background-color: var(--bs-card-bg);
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
padding: 1rem;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
border-bottom: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-weight: 600;
|
||||||
|
font-size: 1.1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-count {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.9rem;
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
max-height: 300px;
|
||||||
|
overflow-y: auto;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
padding: 0.75rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-feature-bg);
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 1.75rem;
|
||||||
|
height: 1.75rem;
|
||||||
|
border-radius: 50%;
|
||||||
|
background: linear-gradient(135deg, #667eea 0%, #764ba2 100%);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
color: white;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-delete-comment {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--bs-danger-rgb), 0.1);
|
||||||
|
color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.4;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-comment {
|
||||||
|
border-top: 1px solid var(--bs-card-border);
|
||||||
|
padding-top: 1rem;
|
||||||
|
|
||||||
|
textarea {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 80px;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dense comments display for cards
|
||||||
|
.card-comments-dense {
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
padding-top: 0.5rem;
|
||||||
|
border-top: 1px solid var(--bs-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-preview {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-size: 0.75rem;
|
||||||
|
cursor: pointer;
|
||||||
|
transition: color 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.recent-comments {
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
|
||||||
|
.recent-comment {
|
||||||
|
background-color: var(--bs-feature-bg);
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.7rem;
|
||||||
|
line-height: 1.3;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-author-small {
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text-small {
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
display: -webkit-box;
|
||||||
|
-webkit-line-clamp: 2;
|
||||||
|
-webkit-box-orient: vertical;
|
||||||
|
overflow: hidden;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments Modal specific styles
|
||||||
|
.comments-modal {
|
||||||
|
.modal-content {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list {
|
||||||
|
max-height: 400px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-item {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
font-size: 0.9rem;
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive adjustments
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.comment-header {
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-meta {
|
||||||
|
align-self: flex-end;
|
||||||
|
}
|
||||||
|
|
||||||
|
.add-comment textarea {
|
||||||
|
min-height: 60px;
|
||||||
|
}
|
||||||
|
}
|
210
styles/forms.scss
Normal file
210
styles/forms.scss
Normal file
@@ -0,0 +1,210 @@
|
|||||||
|
// Form styles
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
|
||||||
|
label {
|
||||||
|
display: block;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
font-weight: 500;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
width: 100%;
|
||||||
|
padding: 0.75rem;
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
background-color: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 0.875rem;
|
||||||
|
transition: border-color 0.15s ease-in-out, box-shadow 0.15s ease-in-out;
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
box-shadow: 0 0 0 0.2rem rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
textarea.form-control {
|
||||||
|
resize: vertical;
|
||||||
|
min-height: 100px;
|
||||||
|
}
|
||||||
|
|
||||||
|
select.form-control {
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
input[type="date"].form-control,
|
||||||
|
.date-picker {
|
||||||
|
cursor: pointer;
|
||||||
|
position: relative;
|
||||||
|
background-image: url("data:image/svg+xml,%3csvg xmlns='http://www.w3.org/2000/svg' fill='none' viewBox='0 0 24 24' stroke='%236b7280'%3e%3cpath stroke-linecap='round' stroke-linejoin='round' stroke-width='2' d='M8 7V3m8 4V3m-9 8h10M5 21h14a2 2 0 002-2V7a2 2 0 00-2-2H5a2 2 0 00-2 2v12a2 2 0 002 2z'/%3e%3c/svg%3e");
|
||||||
|
background-position: right 0.75rem center;
|
||||||
|
background-repeat: no-repeat;
|
||||||
|
background-size: 1.25rem 1.25rem;
|
||||||
|
padding-right: 2.5rem;
|
||||||
|
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
opacity: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
width: 2.5rem;
|
||||||
|
height: 100%;
|
||||||
|
cursor: pointer;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
border-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-row {
|
||||||
|
display: grid;
|
||||||
|
grid-template-columns: 1fr 1fr;
|
||||||
|
gap: 1rem;
|
||||||
|
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
grid-template-columns: 1fr;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.stat-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bs-feature-bg);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
.checklist-display {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 1rem;
|
||||||
|
padding: 0.75rem;
|
||||||
|
background-color: var(--bs-feature-bg);
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
|
||||||
|
.progress-bar-large {
|
||||||
|
flex: 1;
|
||||||
|
height: 8px;
|
||||||
|
background-color: var(--bs-card-border);
|
||||||
|
border-radius: 4px;
|
||||||
|
overflow: hidden;
|
||||||
|
|
||||||
|
.progress-fill {
|
||||||
|
height: 100%;
|
||||||
|
background-color: #198754;
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.progress-text-large {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
font-weight: 500;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Button styles
|
||||||
|
.btn {
|
||||||
|
padding: 0.5rem 1rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
text-decoration: none;
|
||||||
|
display: inline-flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
cursor: pointer;
|
||||||
|
border: 1px solid transparent;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
|
||||||
|
&.btn-primary {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
filter: brightness(0.9);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-secondary {
|
||||||
|
background-color: var(--bs-secondary);
|
||||||
|
color: white;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: var(--bs-secondary);
|
||||||
|
filter: brightness(0.9);
|
||||||
|
transform: translateY(-1px);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.btn-sm {
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:disabled {
|
||||||
|
opacity: 0.6;
|
||||||
|
cursor: not-allowed;
|
||||||
|
transform: none !important;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Date selector specific styles
|
||||||
|
.date-selector {
|
||||||
|
.form-control {
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::-webkit-calendar-picker-indicator {
|
||||||
|
background: transparent;
|
||||||
|
bottom: 0;
|
||||||
|
color: transparent;
|
||||||
|
cursor: pointer;
|
||||||
|
height: auto;
|
||||||
|
left: 0;
|
||||||
|
position: absolute;
|
||||||
|
right: 0;
|
||||||
|
top: 0;
|
||||||
|
width: auto;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-inner-spin-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-clear-button {
|
||||||
|
display: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Focus styles for accessibility
|
||||||
|
.btn:focus,
|
||||||
|
.form-control:focus {
|
||||||
|
box-shadow: 0 0 0 0.25rem rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive form adjustments
|
||||||
|
@media (max-width: 576px) {
|
||||||
|
.form-group {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.form-control {
|
||||||
|
padding: 0.625rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
padding: 0.625rem 1rem;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
721
styles/markdown.scss
Normal file
721
styles/markdown.scss
Normal file
@@ -0,0 +1,721 @@
|
|||||||
|
/* Markdown Editor Styles */
|
||||||
|
.markdown-editor-container {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
||||||
|
height: 500px; /* Increased height for better modal experience */
|
||||||
|
min-height: 400px; /* Minimum height to ensure usability */
|
||||||
|
|
||||||
|
/* In modal context, take more space */
|
||||||
|
.modal-body & {
|
||||||
|
height: 60vh; /* Use viewport height for better responsiveness */
|
||||||
|
min-height: 450px;
|
||||||
|
max-height: 70vh; /* Prevent it from being too tall */
|
||||||
|
}
|
||||||
|
|
||||||
|
&.widescreen {
|
||||||
|
position: fixed;
|
||||||
|
top: 30px;
|
||||||
|
left: 30px;
|
||||||
|
right: 30px;
|
||||||
|
bottom: 30px;
|
||||||
|
z-index: 1050;
|
||||||
|
border: 2px solid var(--bs-primary);
|
||||||
|
box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
height: auto;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-toolbar {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-primary-rgb), 0.05) 100%);
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
|
||||||
|
.toolbar-left {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.75rem;
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-weight: 500;
|
||||||
|
padding: 0.375rem 0.75rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
transform: translateY(-1px);
|
||||||
|
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.toolbar-right {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
|
||||||
|
small {
|
||||||
|
font-weight: 500;
|
||||||
|
opacity: 0.8;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor {
|
||||||
|
display: flex;
|
||||||
|
height: calc(100% - 60px); /* Subtract toolbar height */
|
||||||
|
|
||||||
|
&.full-view {
|
||||||
|
.editor-pane {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
&.split-view {
|
||||||
|
.editor-pane {
|
||||||
|
width: 50%;
|
||||||
|
border-right: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
width: 50%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.markdown-textarea {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0;
|
||||||
|
resize: none;
|
||||||
|
flex: 1;
|
||||||
|
height: 100%;
|
||||||
|
font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
padding: 1rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
box-shadow: none;
|
||||||
|
border-color: transparent;
|
||||||
|
outline: none;
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.02);
|
||||||
|
}
|
||||||
|
|
||||||
|
&::placeholder {
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
opacity: 0.6;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.01);
|
||||||
|
height: 100%;
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-success-rgb, 25, 135, 84), 0.05) 100%);
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
font-weight: 600;
|
||||||
|
margin: 0;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "👁";
|
||||||
|
font-size: 1rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-preview {
|
||||||
|
flex: 1;
|
||||||
|
padding: 1rem;
|
||||||
|
overflow-y: auto;
|
||||||
|
height: calc(100% - 50px);
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Content Styles */
|
||||||
|
.markdown-content, .card-description-content {
|
||||||
|
line-height: 1.7;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-size: 0.9rem; /* Slightly smaller base font */
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 1.75rem;
|
||||||
|
margin-bottom: 0.875rem;
|
||||||
|
font-weight: 600;
|
||||||
|
line-height: 1.3;
|
||||||
|
color: var(--bs-emphasis-color);
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 {
|
||||||
|
font-size: 1.4rem; /* Even smaller H1 */
|
||||||
|
border-bottom: 3px solid var(--bs-primary);
|
||||||
|
padding-bottom: 0.75rem;
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
background: linear-gradient(135deg, var(--bs-primary), var(--bs-info));
|
||||||
|
-webkit-background-clip: text;
|
||||||
|
-webkit-text-fill-color: transparent;
|
||||||
|
background-clip: text;
|
||||||
|
}
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
font-size: 1.1rem; /* Even smaller H2 */
|
||||||
|
border-bottom: 2px solid rgba(var(--bs-primary-rgb), 0.3);
|
||||||
|
padding-bottom: 0.5rem;
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
h3 {
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "▶";
|
||||||
|
color: var(--bs-primary);
|
||||||
|
margin-right: 0.5rem;
|
||||||
|
font-size: 0.8em;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
font-size: 1.125rem;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
h5, h6 {
|
||||||
|
font-size: 1rem;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-weight: 700;
|
||||||
|
}
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
text-align: justify;
|
||||||
|
|
||||||
|
&:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-bottom: 1.25rem;
|
||||||
|
padding-left: 1.75rem;
|
||||||
|
|
||||||
|
li {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
line-height: 1.6;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::marker {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
font-weight: bold;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-bottom: 0.75rem;
|
||||||
|
margin-top: 0.5rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
padding: 1rem 1.5rem;
|
||||||
|
border-left: 5px solid var(--bs-primary);
|
||||||
|
background: linear-gradient(135deg, rgba(var(--bs-primary-rgb), 0.08) 0%, rgba(var(--bs-primary-rgb), 0.03) 100%);
|
||||||
|
border-radius: 0 0.5rem 0.5rem 0;
|
||||||
|
font-style: italic;
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "\201C";
|
||||||
|
font-size: 3rem;
|
||||||
|
color: rgba(var(--bs-primary-rgb), 0.3);
|
||||||
|
position: absolute;
|
||||||
|
top: -0.5rem;
|
||||||
|
left: 0.5rem;
|
||||||
|
font-family: Georgia, serif;
|
||||||
|
}
|
||||||
|
|
||||||
|
p:last-child {
|
||||||
|
margin-bottom: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-info-rgb), 0.1) 100%);
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
border-radius: 0.375rem;
|
||||||
|
font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace;
|
||||||
|
font-size: 0.85em;
|
||||||
|
border: 1px solid rgba(var(--bs-info-rgb), 0.2);
|
||||||
|
color: var(--bs-info);
|
||||||
|
font-weight: 500;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%);
|
||||||
|
color: #f8f9fa;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
overflow-x: auto;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border: 1px solid rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15);
|
||||||
|
position: relative;
|
||||||
|
|
||||||
|
&::before {
|
||||||
|
content: "Code";
|
||||||
|
position: absolute;
|
||||||
|
top: 0.5rem;
|
||||||
|
right: 1rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: rgba(255, 255, 255, 0.5);
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 1px;
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: transparent;
|
||||||
|
padding: 0;
|
||||||
|
color: inherit;
|
||||||
|
border: none;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
width: 100%;
|
||||||
|
border-collapse: collapse;
|
||||||
|
margin: 1.5rem 0;
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
overflow: hidden;
|
||||||
|
box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1);
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
text-align: left;
|
||||||
|
}
|
||||||
|
|
||||||
|
th {
|
||||||
|
background: linear-gradient(135deg, var(--bs-primary) 0%, rgba(var(--bs-primary-rgb), 0.8) 100%);
|
||||||
|
color: white;
|
||||||
|
font-weight: 600;
|
||||||
|
text-transform: uppercase;
|
||||||
|
letter-spacing: 0.5px;
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.03);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:hover {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.08);
|
||||||
|
transition: background-color 0.2s ease;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
hr {
|
||||||
|
margin: 3rem 0;
|
||||||
|
border: none;
|
||||||
|
height: 3px;
|
||||||
|
background: linear-gradient(90deg, transparent 0%, var(--bs-primary) 50%, transparent 100%);
|
||||||
|
border-radius: 1.5px;
|
||||||
|
}
|
||||||
|
|
||||||
|
a {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
text-decoration: none;
|
||||||
|
font-weight: 500;
|
||||||
|
position: relative;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
content: "";
|
||||||
|
position: absolute;
|
||||||
|
bottom: -2px;
|
||||||
|
left: 0;
|
||||||
|
width: 0;
|
||||||
|
height: 2px;
|
||||||
|
background: var(--bs-primary);
|
||||||
|
transition: width 0.3s ease;
|
||||||
|
}
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
|
||||||
|
&::after {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
strong, b {
|
||||||
|
font-weight: 700;
|
||||||
|
color: var(--bs-emphasis-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
em, i {
|
||||||
|
font-style: italic;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
}
|
||||||
|
|
||||||
|
del, s {
|
||||||
|
text-decoration: line-through;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Task lists
|
||||||
|
input[type="checkbox"] {
|
||||||
|
margin-right: 0.75rem;
|
||||||
|
transform: scale(1.2);
|
||||||
|
accent-color: var(--bs-primary);
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Inline elements */
|
||||||
|
mark {
|
||||||
|
background: linear-gradient(135deg, rgba(var(--bs-warning-rgb), 0.3) 0%, rgba(var(--bs-warning-rgb), 0.1) 100%);
|
||||||
|
padding: 0.125rem 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Card Description Specific Styles */
|
||||||
|
.card-description {
|
||||||
|
.card-description-content {
|
||||||
|
font-size: 0.8rem; /* Slightly smaller base font for card description */
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
|
||||||
|
h1, h2, h3, h4, h5, h6 {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
|
||||||
|
&:first-child {
|
||||||
|
margin-top: 0;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
h1 { font-size: 0.9rem; } /* Smaller H1 for card description */
|
||||||
|
h2 { font-size: 0.85rem; } /* Smaller H2 for card description */
|
||||||
|
h3 { font-size: 0.9rem; }
|
||||||
|
h4 { font-size: 0.875rem; }
|
||||||
|
h5 { font-size: 0.85rem; }
|
||||||
|
h6 { font-size: 0.8rem; }
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
ul, ol {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
padding-left: 1rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
blockquote {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.25rem 0.5rem;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
padding: 0.5rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
margin: 0.5rem 0;
|
||||||
|
font-size: 0.8rem;
|
||||||
|
|
||||||
|
th, td {
|
||||||
|
padding: 0.25rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Dark theme adjustments */
|
||||||
|
.dark-theme {
|
||||||
|
.markdown-toolbar {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
border-bottom-color: var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-header {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
border-bottom-color: var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-content, .card-description-content {
|
||||||
|
blockquote {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
code {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
pre {
|
||||||
|
background: var(--bs-light);
|
||||||
|
color: var(--bs-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
table {
|
||||||
|
th {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
}
|
||||||
|
|
||||||
|
tr:nth-child(even) {
|
||||||
|
background: var(--bs-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Responsive adjustments */
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.markdown-editor-container.widescreen {
|
||||||
|
top: 20px;
|
||||||
|
left: 20px;
|
||||||
|
right: 20px;
|
||||||
|
bottom: 20px;
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor.split-view {
|
||||||
|
flex-direction: column;
|
||||||
|
|
||||||
|
.editor-pane {
|
||||||
|
width: 100%;
|
||||||
|
border-right: none;
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.preview-pane {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Kanban Card Edit Button Styles */
|
||||||
|
.card-title-row {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: flex-start;
|
||||||
|
gap: 0.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit-card {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
padding: 0.25rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0.6;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.card-title {
|
||||||
|
flex: 1;
|
||||||
|
margin: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Markdown Description View Styles */
|
||||||
|
.markdown-description-view {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
|
||||||
|
.description-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-primary-rgb), 0.05) 100%);
|
||||||
|
border-bottom: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
|
||||||
|
.form-label {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-edit-description {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
color: var(--bs-secondary);
|
||||||
|
padding: 0.375rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
opacity: 0.7;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bs-primary);
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.1);
|
||||||
|
opacity: 1;
|
||||||
|
transform: scale(1.1);
|
||||||
|
}
|
||||||
|
|
||||||
|
&:focus {
|
||||||
|
outline: none;
|
||||||
|
box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25);
|
||||||
|
}
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.description-content {
|
||||||
|
height: 200px; /* 1/3 of typical form height */
|
||||||
|
overflow-y: auto;
|
||||||
|
padding: 1rem;
|
||||||
|
|
||||||
|
/* Custom scrollbar */
|
||||||
|
&::-webkit-scrollbar {
|
||||||
|
width: 6px;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-track {
|
||||||
|
background: transparent;
|
||||||
|
}
|
||||||
|
|
||||||
|
&::-webkit-scrollbar-thumb {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.2);
|
||||||
|
border-radius: 3px;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(var(--bs-primary-rgb), 0.3);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.empty-description {
|
||||||
|
display: flex;
|
||||||
|
flex-direction: column;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
height: 100%;
|
||||||
|
text-align: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
i {
|
||||||
|
font-size: 2rem;
|
||||||
|
opacity: 0.5;
|
||||||
|
}
|
||||||
|
|
||||||
|
span {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
opacity: 0.7;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-description-editor {
|
||||||
|
border: 1px solid var(--bs-border-color);
|
||||||
|
border-radius: 0.5rem;
|
||||||
|
background: var(--bs-body-bg);
|
||||||
|
|
||||||
|
.editor-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 0.75rem 1rem;
|
||||||
|
background: linear-gradient(135deg, var(--bs-primary) 0%, rgba(var(--bs-primary-rgb), 0.8) 100%);
|
||||||
|
color: white;
|
||||||
|
border-radius: 0.5rem 0.5rem 0 0;
|
||||||
|
|
||||||
|
h6 {
|
||||||
|
margin: 0;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn {
|
||||||
|
color: white;
|
||||||
|
border-color: rgba(255, 255, 255, 0.3);
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background: rgba(255, 255, 255, 0.1);
|
||||||
|
border-color: rgba(255, 255, 255, 0.5);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.markdown-editor-container {
|
||||||
|
border: none;
|
||||||
|
border-radius: 0 0 0.5rem 0.5rem;
|
||||||
|
height: 400px; /* Larger height for editing */
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
/* Form group specific styles for description */
|
||||||
|
.description-form-group {
|
||||||
|
.markdown-description-view,
|
||||||
|
.markdown-description-editor {
|
||||||
|
width: 100%;
|
||||||
|
}
|
||||||
|
}
|
215
styles/modal.scss
Normal file
215
styles/modal.scss
Normal file
@@ -0,0 +1,215 @@
|
|||||||
|
// Modal styles
|
||||||
|
.modal-overlay {
|
||||||
|
position: fixed;
|
||||||
|
top: 0;
|
||||||
|
left: 0;
|
||||||
|
right: 0;
|
||||||
|
bottom: 0;
|
||||||
|
background-color: rgba(0, 0, 0, 0.5);
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
z-index: 1000;
|
||||||
|
backdrop-filter: blur(4px);
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-content {
|
||||||
|
background-color: var(--bs-card-bg);
|
||||||
|
border-radius: 0.75rem;
|
||||||
|
box-shadow: var(--bs-shadow-lg);
|
||||||
|
max-width: 800px; // Increased width
|
||||||
|
width: 95%;
|
||||||
|
max-height: 90vh;
|
||||||
|
overflow-y: auto;
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
&.comments-modal {
|
||||||
|
max-width: 600px;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-bottom: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
h2 {
|
||||||
|
margin: 0;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.btn-close {
|
||||||
|
background: none;
|
||||||
|
border: none;
|
||||||
|
font-size: 1.25rem;
|
||||||
|
color: var(--bs-navbar-color);
|
||||||
|
cursor: pointer;
|
||||||
|
padding: 0.5rem;
|
||||||
|
border-radius: 0.25rem;
|
||||||
|
transition: all 0.2s ease;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
background-color: rgba(var(--bs-danger-rgb), 0.1);
|
||||||
|
color: var(--bs-danger);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-body {
|
||||||
|
padding: 1.5rem;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-footer {
|
||||||
|
display: flex;
|
||||||
|
justify-content: flex-end;
|
||||||
|
gap: 0.75rem;
|
||||||
|
padding: 1.5rem;
|
||||||
|
border-top: 1px solid var(--bs-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Dense comments styles for kanban cards
|
||||||
|
.card-comments-dense {
|
||||||
|
margin-top: 0.75rem;
|
||||||
|
padding-top: 0.75rem;
|
||||||
|
border-top: 1px solid var(--bs-card-border);
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-preview {
|
||||||
|
.comment-preview {
|
||||||
|
display: flex;
|
||||||
|
gap: 0.25rem;
|
||||||
|
margin-bottom: 0.25rem;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
line-height: 1.2;
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
flex-shrink: 0;
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-text {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
overflow: hidden;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.more-comments {
|
||||||
|
font-size: 0.75rem;
|
||||||
|
color: var(--bs-primary);
|
||||||
|
cursor: pointer;
|
||||||
|
text-decoration: underline;
|
||||||
|
margin-top: 0.25rem;
|
||||||
|
|
||||||
|
&:hover {
|
||||||
|
color: var(--bs-primary-dark);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments modal specific styles
|
||||||
|
.comments-modal {
|
||||||
|
.card-info {
|
||||||
|
margin-bottom: 1.5rem;
|
||||||
|
padding-bottom: 1rem;
|
||||||
|
border-bottom: 1px solid var(--bs-card-border);
|
||||||
|
|
||||||
|
h4 {
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-section {
|
||||||
|
h5 {
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
|
||||||
|
.comment-count {
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
font-weight: normal;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comments-list-modal {
|
||||||
|
.comment-item-modal {
|
||||||
|
padding: 1rem;
|
||||||
|
border: 1px solid var(--bs-card-border);
|
||||||
|
border-radius: 6px;
|
||||||
|
margin-bottom: 1rem;
|
||||||
|
background-color: var(--bs-card-bg);
|
||||||
|
|
||||||
|
.comment-header {
|
||||||
|
display: flex;
|
||||||
|
justify-content: space-between;
|
||||||
|
align-items: center;
|
||||||
|
margin-bottom: 0.5rem;
|
||||||
|
|
||||||
|
.comment-author {
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
gap: 0.5rem;
|
||||||
|
|
||||||
|
.author-avatar {
|
||||||
|
width: 32px;
|
||||||
|
height: 32px;
|
||||||
|
border-radius: 50%;
|
||||||
|
background-color: var(--bs-primary);
|
||||||
|
color: white;
|
||||||
|
display: flex;
|
||||||
|
align-items: center;
|
||||||
|
justify-content: center;
|
||||||
|
font-size: 0.75rem;
|
||||||
|
font-weight: 600;
|
||||||
|
}
|
||||||
|
|
||||||
|
.author-name {
|
||||||
|
font-weight: 600;
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-date {
|
||||||
|
font-size: 0.875rem;
|
||||||
|
color: var(--bs-secondary-color);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.comment-content {
|
||||||
|
color: var(--bs-body-color);
|
||||||
|
line-height: 1.5;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
.no-comments {
|
||||||
|
text-align: center;
|
||||||
|
padding: 2rem;
|
||||||
|
|
||||||
|
p {
|
||||||
|
margin: 0;
|
||||||
|
font-style: italic;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Responsive modal adjustments
|
||||||
|
@media (max-width: 768px) {
|
||||||
|
.modal-content {
|
||||||
|
width: 98%;
|
||||||
|
margin: 0.5rem;
|
||||||
|
max-width: none;
|
||||||
|
}
|
||||||
|
|
||||||
|
.modal-header,
|
||||||
|
.modal-body,
|
||||||
|
.modal-footer {
|
||||||
|
padding: 1rem;
|
||||||
|
}
|
||||||
|
}
|
Reference in New Issue
Block a user