diff --git a/Cargo.toml b/Cargo.toml index fda9f70..51ab2cb 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -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" diff --git a/index.scss b/index.scss index 41c234d..f5d8966 100644 --- a/index.scss +++ b/index.scss @@ -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; + } + } } \ No newline at end of file diff --git a/src/kanban.rs b/src/kanban.rs index 83ed2f0..84b70ce 100644 --- a/src/kanban.rs +++ b/src/kanban.rs @@ -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::); + let show_save_popup = use_state(|| false); + let dragging_card = use_state(|| None::); + + 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).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! {
@@ -172,10 +260,42 @@ pub fn kanban_board() -> Html {
{for data.columns.iter().map(|column| { html! { - + } })}
+ + // Card editing modal + {if let Some(card) = (*editing_card).clone() { + html! { + + } + } else { + html! {} + }} + + // Save confirmation popup + {if *show_save_popup { + html! { +
+
+ + {"Card saved successfully!"} +
+
+ } + } else { + html! {} + }}
} @@ -184,14 +304,50 @@ pub fn kanban_board() -> Html { #[derive(Properties, PartialEq)] pub struct KanbanColumnProps { pub column: KanbanColumn, + pub on_edit_card: Callback, + pub on_card_drag_start: Callback, + 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! { -
+
{&column.title} @@ -200,9 +356,14 @@ pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {

{&column.description}

- {for column.cards.iter().map(|card| { + {for column.cards.iter().enumerate().map(|(index, card)| { html! { - + } })}
@@ -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, + pub on_drag_start: Callback, + pub index: usize, } #[function_component(KanbanCardComponent)] @@ -238,8 +402,24 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html { .collect::() .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! { -
+

{&card.title}

{&card.description}

@@ -318,4 +498,235 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
} +} + +// Card Edit Modal Component +#[derive(Properties, PartialEq)] +pub struct CardEditModalProps { + pub card: KanbanCard, + pub on_save: Callback, + 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! { + + } } \ No newline at end of file