This commit is contained in:
despiegk 2025-08-08 08:39:55 +02:00
parent 085ce51b0a
commit e34d527089
3 changed files with 712 additions and 7 deletions

View File

@ -13,7 +13,19 @@ categories = ["gui", "wasm", "web-programming"]
[dependencies]
yew = { version="0.21", features=["csr"] }
yew-router = "0.18"
web-sys = { version = "0.3", features = ["Document", "HtmlElement", "Window"] }
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"

View File

@ -541,4 +541,286 @@ body {
}
}
}
}
// 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: 600px;
width: 90%;
max-height: 90vh;
overflow-y: auto;
border: 1px solid var(--bs-card-border);
}
.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);
}
.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;
}
}
.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;
}
}
// 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;
}
}
// 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;
&.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);
}
}
}
// Responsive modal adjustments
@media (max-width: 768px) {
.modal-content {
width: 95%;
margin: 1rem;
}
.modal-header,
.modal-body,
.modal-footer {
padding: 1rem;
}
.save-popup {
top: 1rem;
right: 1rem;
left: 1rem;
.save-popup-content {
padding: 0.75rem 1rem;
font-size: 0.875rem;
}
}
}

View File

@ -1,5 +1,7 @@
use yew::prelude::*;
use serde::{Deserialize, Serialize};
use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent};
use wasm_bindgen::JsCast;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KanbanData {
@ -160,7 +162,93 @@ fn get_sample_data() -> KanbanData {
#[function_component(KanbanBoard)]
pub fn kanban_board() -> Html {
let data = get_sample_data();
let data = use_state(|| get_sample_data());
let editing_card = use_state(|| None::<KanbanCard>);
let show_save_popup = use_state(|| false);
let dragging_card = use_state(|| None::<String>);
let on_edit_card = {
let editing_card = editing_card.clone();
Callback::from(move |card: KanbanCard| {
editing_card.set(Some(card));
})
};
let on_close_edit = {
let editing_card = editing_card.clone();
Callback::from(move |_| {
editing_card.set(None);
})
};
let on_save_card = {
let data = data.clone();
let editing_card = editing_card.clone();
let show_save_popup = show_save_popup.clone();
Callback::from(move |updated_card: KanbanCard| {
let mut new_data = (*data).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;
}
}
data.set(new_data);
editing_card.set(None);
show_save_popup.set(true);
// Hide popup after 2 seconds
let show_save_popup_clone = show_save_popup.clone();
wasm_bindgen_futures::spawn_local(async move {
gloo_utils::window().set_timeout_with_callback_and_timeout_and_arguments_0(
&wasm_bindgen::closure::Closure::wrap(Box::new(move || {
show_save_popup_clone.set(false);
}) as Box<dyn FnMut()>).into_js_value().unchecked_into(),
2000
).unwrap();
});
})
};
let on_card_drag_start = {
let dragging_card = dragging_card.clone();
Callback::from(move |card_id: String| {
dragging_card.set(Some(card_id));
})
};
let on_card_drop = {
let data = data.clone();
let dragging_card = dragging_card.clone();
Callback::from(move |(target_column_id, target_position): (String, usize)| {
if let Some(card_id) = (*dragging_card).clone() {
let mut new_data = (*data).clone();
let mut moved_card = None;
// Remove card from source column
for column in &mut new_data.columns {
if let Some(card_index) = column.cards.iter().position(|c| c.id == card_id) {
moved_card = Some(column.cards.remove(card_index));
break;
}
}
// Add card to target column
if let Some(card) = moved_card {
if let Some(target_column) = new_data.columns.iter_mut().find(|c| c.id == target_column_id) {
let insert_position = target_position.min(target_column.cards.len());
target_column.cards.insert(insert_position, card);
}
}
data.set(new_data);
dragging_card.set(None);
}
})
};
html! {
<div class="kanban-board">
@ -172,10 +260,42 @@ pub fn kanban_board() -> Html {
<div class="kanban-columns">
{for data.columns.iter().map(|column| {
html! {
<KanbanColumnComponent column={column.clone()} />
<KanbanColumnComponent
column={column.clone()}
on_edit_card={on_edit_card.clone()}
on_card_drag_start={on_card_drag_start.clone()}
on_card_drop={on_card_drop.clone()}
/>
}
})}
</div>
// Card editing modal
{if let Some(card) = (*editing_card).clone() {
html! {
<CardEditModal
card={card}
on_save={on_save_card.clone()}
on_close={on_close_edit.clone()}
/>
}
} else {
html! {}
}}
// Save confirmation popup
{if *show_save_popup {
html! {
<div class="save-popup">
<div class="save-popup-content">
<i class="bi bi-check-circle-fill text-success me-2"></i>
{"Card saved successfully!"}
</div>
</div>
}
} else {
html! {}
}}
</div>
</div>
}
@ -184,14 +304,50 @@ pub fn kanban_board() -> Html {
#[derive(Properties, PartialEq)]
pub struct KanbanColumnProps {
pub column: KanbanColumn,
pub on_edit_card: Callback<KanbanCard>,
pub on_card_drag_start: Callback<String>,
pub on_card_drop: Callback<(String, usize)>,
}
#[function_component(KanbanColumnComponent)]
pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
let column = &props.column;
let drag_over = use_state(|| false);
let ondragover = {
let drag_over = drag_over.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
drag_over.set(true);
})
};
let ondragleave = {
let drag_over = drag_over.clone();
Callback::from(move |_: DragEvent| {
drag_over.set(false);
})
};
let ondrop = {
let on_card_drop = props.on_card_drop.clone();
let column_id = column.id.clone();
let drag_over = drag_over.clone();
Callback::from(move |e: DragEvent| {
e.prevent_default();
drag_over.set(false);
on_card_drop.emit((column_id.clone(), 0));
})
};
let column_class = if *drag_over {
"kanban-column drag-over"
} else {
"kanban-column"
};
html! {
<div class="kanban-column">
<div class={column_class} {ondragover} {ondragleave} {ondrop}>
<div class="column-header">
<div class="column-title">
{&column.title}
@ -200,9 +356,14 @@ pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
<p class="column-description">{&column.description}</p>
</div>
<div class="column-cards">
{for column.cards.iter().map(|card| {
{for column.cards.iter().enumerate().map(|(index, card)| {
html! {
<KanbanCardComponent card={card.clone()} />
<KanbanCardComponent
card={card.clone()}
on_edit={props.on_edit_card.clone()}
on_drag_start={props.on_card_drag_start.clone()}
index={index}
/>
}
})}
</div>
@ -213,6 +374,9 @@ pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
#[derive(Properties, PartialEq)]
pub struct KanbanCardProps {
pub card: KanbanCard,
pub on_edit: Callback<KanbanCard>,
pub on_drag_start: Callback<String>,
pub index: usize,
}
#[function_component(KanbanCardComponent)]
@ -238,8 +402,24 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
.collect::<String>()
.to_uppercase();
let onclick = {
let on_edit = props.on_edit.clone();
let card = card.clone();
Callback::from(move |_| {
on_edit.emit(card.clone());
})
};
let ondragstart = {
let on_drag_start = props.on_drag_start.clone();
let card_id = card.id.clone();
Callback::from(move |_: DragEvent| {
on_drag_start.emit(card_id.clone());
})
};
html! {
<div class="kanban-card">
<div class="kanban-card" draggable="true" {onclick} {ondragstart}>
<div class="card-header">
<h3 class="card-title">{&card.title}</h3>
<p class="card-description">{&card.description}</p>
@ -318,4 +498,235 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
</div>
</div>
}
}
// Card Edit Modal Component
#[derive(Properties, PartialEq)]
pub struct CardEditModalProps {
pub card: KanbanCard,
pub on_save: Callback<KanbanCard>,
pub on_close: Callback<()>,
}
#[function_component(CardEditModal)]
pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
let card = use_state(|| props.card.clone());
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 |e: Event| {
let textarea: HtmlTextAreaElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.description = textarea.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 |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
let mut updated_card = (*card).clone();
updated_card.due_date = input.value();
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 on_save = props.on_save.clone();
let card = card.clone();
Callback::from(move |_| {
on_save.emit((*card).clone());
})
};
let on_close_click = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let on_close_click_modal = {
let on_close = props.on_close.clone();
Callback::from(move |_| {
on_close.emit(());
})
};
let tags_string = card.tags.join(", ");
html! {
<div class="modal-overlay" onclick={on_close_click_modal.clone()}>
<div class="modal-content" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
<div class="modal-header">
<h2>{"Edit Card"}</h2>
<button type="button" class="btn-close" onclick={on_close_click}>
<i class="bi bi-x-lg"></i>
</button>
</div>
<div class="modal-body">
<div class="form-group">
<label for="card-title">{"Title"}</label>
<input
type="text"
id="card-title"
class="form-control"
value={card.title.clone()}
onchange={on_title_change}
/>
</div>
<div class="form-group">
<label for="card-description">{"Description"}</label>
<textarea
id="card-description"
class="form-control"
rows="4"
value={card.description.clone()}
onchange={on_description_change}
></textarea>
</div>
<div class="form-row">
<div class="form-group">
<label for="card-priority">{"Priority"}</label>
<select
id="card-priority"
class="form-control"
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 class="form-group">
<label for="card-due-date">{"Due Date"}</label>
<input
type="date"
id="card-due-date"
class="form-control"
value={card.due_date.clone()}
onchange={on_due_date_change}
/>
</div>
</div>
<div class="form-group">
<label for="card-assignee">{"Assignee"}</label>
<input
type="text"
id="card-assignee"
class="form-control"
value={card.assignee.clone()}
onchange={on_assignee_change}
/>
</div>
<div class="form-group">
<label for="card-tags">{"Tags (comma separated)"}</label>
<input
type="text"
id="card-tags"
class="form-control"
value={tags_string}
onchange={on_tags_change}
/>
</div>
<div class="form-row">
<div class="form-group">
<label>{"Attachments"}</label>
<div class="stat-display">
<i class="bi bi-paperclip me-2"></i>
{card.attachments}
</div>
</div>
<div class="form-group">
<label>{"Comments"}</label>
<div class="stat-display">
<i class="bi bi-chat-dots me-2"></i>
{card.comments}
</div>
</div>
</div>
<div class="form-group">
<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>
</div>
<div class="modal-footer">
<button type="button" class="btn btn-secondary" onclick={on_close_click_modal.clone()}>
{"Cancel"}
</button>
<button type="button" class="btn btn-primary" onclick={on_save_click}>
{"Save Changes"}
</button>
</div>
</div>
</div>
}
}