...
This commit is contained in:
parent
e34d527089
commit
ef3a0e82b8
@ -31,3 +31,4 @@ gloo-storage = "0.2"
|
|||||||
gloo-net = "0.4"
|
gloo-net = "0.4"
|
||||||
wasm-bindgen-futures = "0.4"
|
wasm-bindgen-futures = "0.4"
|
||||||
serde = { version = "1.0", features = ["derive"] }
|
serde = { version = "1.0", features = ["derive"] }
|
||||||
|
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
222
index.scss
222
index.scss
@ -1,3 +1,8 @@
|
|||||||
|
// Import component styles
|
||||||
|
@import 'styles/modal.scss';
|
||||||
|
@import 'styles/comments.scss';
|
||||||
|
@import 'styles/forms.scss';
|
||||||
|
|
||||||
// CSS Custom Properties for theming
|
// CSS Custom Properties for theming
|
||||||
:root {
|
:root {
|
||||||
--bs-body-bg: #ffffff;
|
--bs-body-bg: #ffffff;
|
||||||
@ -14,6 +19,12 @@
|
|||||||
--bs-footer-bg: #e9ecef;
|
--bs-footer-bg: #e9ecef;
|
||||||
--bs-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075);
|
--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-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 variables
|
||||||
@ -543,156 +554,6 @@ 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 styles
|
||||||
.save-popup {
|
.save-popup {
|
||||||
@ -763,64 +624,3 @@ body {
|
|||||||
border-radius: 0.5rem;
|
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;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
494
src/kanban.rs
494
src/kanban.rs
@ -2,6 +2,7 @@ use yew::prelude::*;
|
|||||||
use serde::{Deserialize, Serialize};
|
use serde::{Deserialize, Serialize};
|
||||||
use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent};
|
use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent};
|
||||||
use wasm_bindgen::JsCast;
|
use wasm_bindgen::JsCast;
|
||||||
|
use chrono::Utc;
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
pub struct KanbanData {
|
pub struct KanbanData {
|
||||||
@ -31,6 +32,16 @@ pub struct KanbanCard {
|
|||||||
pub attachments: u32,
|
pub attachments: u32,
|
||||||
pub comments: u32,
|
pub comments: u32,
|
||||||
pub checklist: ChecklistInfo,
|
pub checklist: ChecklistInfo,
|
||||||
|
#[serde(rename = "commentsList")]
|
||||||
|
pub comments_list: Vec<Comment>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
|
pub struct Comment {
|
||||||
|
pub id: String,
|
||||||
|
pub author: String,
|
||||||
|
pub content: String,
|
||||||
|
pub timestamp: String,
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
|
||||||
@ -60,6 +71,20 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 2,
|
attachments: 2,
|
||||||
comments: 3,
|
comments: 3,
|
||||||
checklist: ChecklistInfo { completed: 1, total: 4 },
|
checklist: ChecklistInfo { completed: 1, total: 4 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-1".to_string(),
|
||||||
|
author: "Bob Smith".to_string(),
|
||||||
|
content: "Looking good! Can we add more color variations?".to_string(),
|
||||||
|
timestamp: "2024-01-10T10:30:00Z".to_string(),
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
id: "comment-2".to_string(),
|
||||||
|
author: "Alice Johnson".to_string(),
|
||||||
|
content: "Sure, I'll work on that today.".to_string(),
|
||||||
|
timestamp: "2024-01-10T11:15:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
KanbanCard {
|
KanbanCard {
|
||||||
id: "card-2".to_string(),
|
id: "card-2".to_string(),
|
||||||
@ -72,6 +97,14 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 0,
|
attachments: 0,
|
||||||
comments: 1,
|
comments: 1,
|
||||||
checklist: ChecklistInfo { completed: 0, total: 3 },
|
checklist: ChecklistInfo { completed: 0, total: 3 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-3".to_string(),
|
||||||
|
author: "Charlie Brown".to_string(),
|
||||||
|
content: "I found some interesting data on competitor pricing.".to_string(),
|
||||||
|
timestamp: "2024-01-09T14:20:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -91,6 +124,20 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 1,
|
attachments: 1,
|
||||||
comments: 5,
|
comments: 5,
|
||||||
checklist: ChecklistInfo { completed: 2, total: 5 },
|
checklist: ChecklistInfo { completed: 2, total: 5 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-4".to_string(),
|
||||||
|
author: "Diana Prince".to_string(),
|
||||||
|
content: "Make sure to use bcrypt for password hashing.".to_string(),
|
||||||
|
timestamp: "2024-01-08T09:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
Comment {
|
||||||
|
id: "comment-5".to_string(),
|
||||||
|
author: "Charlie Brown".to_string(),
|
||||||
|
content: "Already on it! Also implementing 2FA.".to_string(),
|
||||||
|
timestamp: "2024-01-08T09:30:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
KanbanCard {
|
KanbanCard {
|
||||||
id: "card-4".to_string(),
|
id: "card-4".to_string(),
|
||||||
@ -103,6 +150,14 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 3,
|
attachments: 3,
|
||||||
comments: 2,
|
comments: 2,
|
||||||
checklist: ChecklistInfo { completed: 3, total: 6 },
|
checklist: ChecklistInfo { completed: 3, total: 6 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-6".to_string(),
|
||||||
|
author: "Eve Wilson".to_string(),
|
||||||
|
content: "Don't forget to backup before migration!".to_string(),
|
||||||
|
timestamp: "2024-01-07T16:45:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -122,6 +177,14 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 2,
|
attachments: 2,
|
||||||
comments: 4,
|
comments: 4,
|
||||||
checklist: ChecklistInfo { completed: 4, total: 4 },
|
checklist: ChecklistInfo { completed: 4, total: 4 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-7".to_string(),
|
||||||
|
author: "Frank Miller".to_string(),
|
||||||
|
content: "Great work on the examples!".to_string(),
|
||||||
|
timestamp: "2024-01-06T13:20:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -141,6 +204,14 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 1,
|
attachments: 1,
|
||||||
comments: 2,
|
comments: 2,
|
||||||
checklist: ChecklistInfo { completed: 3, total: 3 },
|
checklist: ChecklistInfo { completed: 3, total: 3 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-8".to_string(),
|
||||||
|
author: "Grace Lee".to_string(),
|
||||||
|
content: "Environment is working perfectly!".to_string(),
|
||||||
|
timestamp: "2024-01-04T12:00:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
KanbanCard {
|
KanbanCard {
|
||||||
id: "card-7".to_string(),
|
id: "card-7".to_string(),
|
||||||
@ -153,6 +224,14 @@ fn get_sample_data() -> KanbanData {
|
|||||||
attachments: 4,
|
attachments: 4,
|
||||||
comments: 8,
|
comments: 8,
|
||||||
checklist: ChecklistInfo { completed: 5, total: 5 },
|
checklist: ChecklistInfo { completed: 5, total: 5 },
|
||||||
|
comments_list: vec![
|
||||||
|
Comment {
|
||||||
|
id: "comment-9".to_string(),
|
||||||
|
author: "Alice Johnson".to_string(),
|
||||||
|
content: "Timeline looks realistic. Good planning!".to_string(),
|
||||||
|
timestamp: "2024-01-02T15:30:00Z".to_string(),
|
||||||
|
},
|
||||||
|
],
|
||||||
},
|
},
|
||||||
],
|
],
|
||||||
},
|
},
|
||||||
@ -164,6 +243,7 @@ fn get_sample_data() -> KanbanData {
|
|||||||
pub fn kanban_board() -> Html {
|
pub fn kanban_board() -> Html {
|
||||||
let data = use_state(|| get_sample_data());
|
let data = use_state(|| get_sample_data());
|
||||||
let editing_card = use_state(|| None::<KanbanCard>);
|
let editing_card = use_state(|| None::<KanbanCard>);
|
||||||
|
let comments_card = use_state(|| None::<KanbanCard>);
|
||||||
let show_save_popup = use_state(|| false);
|
let show_save_popup = use_state(|| false);
|
||||||
let dragging_card = use_state(|| None::<String>);
|
let dragging_card = use_state(|| None::<String>);
|
||||||
|
|
||||||
@ -174,6 +254,13 @@ pub fn kanban_board() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_comments_click = {
|
||||||
|
let comments_card = comments_card.clone();
|
||||||
|
Callback::from(move |card: KanbanCard| {
|
||||||
|
comments_card.set(Some(card));
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_close_edit = {
|
let on_close_edit = {
|
||||||
let editing_card = editing_card.clone();
|
let editing_card = editing_card.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |_| {
|
||||||
@ -181,6 +268,13 @@ pub fn kanban_board() -> Html {
|
|||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_close_comments = {
|
||||||
|
let comments_card = comments_card.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
comments_card.set(None);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let on_save_card = {
|
let on_save_card = {
|
||||||
let data = data.clone();
|
let data = data.clone();
|
||||||
let editing_card = editing_card.clone();
|
let editing_card = editing_card.clone();
|
||||||
@ -263,6 +357,7 @@ pub fn kanban_board() -> Html {
|
|||||||
<KanbanColumnComponent
|
<KanbanColumnComponent
|
||||||
column={column.clone()}
|
column={column.clone()}
|
||||||
on_edit_card={on_edit_card.clone()}
|
on_edit_card={on_edit_card.clone()}
|
||||||
|
on_comments_click={on_comments_click.clone()}
|
||||||
on_card_drag_start={on_card_drag_start.clone()}
|
on_card_drag_start={on_card_drag_start.clone()}
|
||||||
on_card_drop={on_card_drop.clone()}
|
on_card_drop={on_card_drop.clone()}
|
||||||
/>
|
/>
|
||||||
@ -296,6 +391,18 @@ pub fn kanban_board() -> Html {
|
|||||||
} else {
|
} else {
|
||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
|
|
||||||
|
// Comments modal
|
||||||
|
{if let Some(card) = (*comments_card).clone() {
|
||||||
|
html! {
|
||||||
|
<CommentsModal
|
||||||
|
card={card}
|
||||||
|
on_close={on_close_comments.clone()}
|
||||||
|
/>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
@ -305,6 +412,7 @@ pub fn kanban_board() -> Html {
|
|||||||
pub struct KanbanColumnProps {
|
pub struct KanbanColumnProps {
|
||||||
pub column: KanbanColumn,
|
pub column: KanbanColumn,
|
||||||
pub on_edit_card: Callback<KanbanCard>,
|
pub on_edit_card: Callback<KanbanCard>,
|
||||||
|
pub on_comments_click: Callback<KanbanCard>,
|
||||||
pub on_card_drag_start: Callback<String>,
|
pub on_card_drag_start: Callback<String>,
|
||||||
pub on_card_drop: Callback<(String, usize)>,
|
pub on_card_drop: Callback<(String, usize)>,
|
||||||
}
|
}
|
||||||
@ -361,6 +469,7 @@ pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
|
|||||||
<KanbanCardComponent
|
<KanbanCardComponent
|
||||||
card={card.clone()}
|
card={card.clone()}
|
||||||
on_edit={props.on_edit_card.clone()}
|
on_edit={props.on_edit_card.clone()}
|
||||||
|
on_comments_click={props.on_comments_click.clone()}
|
||||||
on_drag_start={props.on_card_drag_start.clone()}
|
on_drag_start={props.on_card_drag_start.clone()}
|
||||||
index={index}
|
index={index}
|
||||||
/>
|
/>
|
||||||
@ -376,6 +485,7 @@ pub struct KanbanCardProps {
|
|||||||
pub card: KanbanCard,
|
pub card: KanbanCard,
|
||||||
pub on_edit: Callback<KanbanCard>,
|
pub on_edit: Callback<KanbanCard>,
|
||||||
pub on_drag_start: Callback<String>,
|
pub on_drag_start: Callback<String>,
|
||||||
|
pub on_comments_click: Callback<KanbanCard>,
|
||||||
pub index: usize,
|
pub index: usize,
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -405,11 +515,21 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
|
|||||||
let onclick = {
|
let onclick = {
|
||||||
let on_edit = props.on_edit.clone();
|
let on_edit = props.on_edit.clone();
|
||||||
let card = card.clone();
|
let card = card.clone();
|
||||||
Callback::from(move |_| {
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
on_edit.emit(card.clone());
|
on_edit.emit(card.clone());
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
|
|
||||||
|
let on_comments_click = {
|
||||||
|
let on_comments_click = props.on_comments_click.clone();
|
||||||
|
let card = card.clone();
|
||||||
|
Callback::from(move |e: MouseEvent| {
|
||||||
|
e.stop_propagation();
|
||||||
|
on_comments_click.emit(card.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
let ondragstart = {
|
let ondragstart = {
|
||||||
let on_drag_start = props.on_drag_start.clone();
|
let on_drag_start = props.on_drag_start.clone();
|
||||||
let card_id = card.id.clone();
|
let card_id = card.id.clone();
|
||||||
@ -471,7 +591,7 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
|
|||||||
|
|
||||||
{if card.comments > 0 {
|
{if card.comments > 0 {
|
||||||
html! {
|
html! {
|
||||||
<div class="stat-item">
|
<div class="stat-item" onclick={on_comments_click.clone()}>
|
||||||
<i class="bi bi-chat-dots"></i>
|
<i class="bi bi-chat-dots"></i>
|
||||||
<span>{card.comments}</span>
|
<span>{card.comments}</span>
|
||||||
</div>
|
</div>
|
||||||
@ -496,6 +616,41 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
|
|||||||
html! {}
|
html! {}
|
||||||
}}
|
}}
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
// Dense comments display
|
||||||
|
{if !card.comments_list.is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="card-comments-dense">
|
||||||
|
<div class="comments-preview">
|
||||||
|
{for card.comments_list.iter().take(2).map(|comment| {
|
||||||
|
html! {
|
||||||
|
<div class="comment-preview" key={comment.id.clone()}>
|
||||||
|
<span class="comment-author">{format!("{}:", &comment.author)}</span>
|
||||||
|
<span class="comment-text">{
|
||||||
|
if comment.content.len() > 50 {
|
||||||
|
format!("{}...", &comment.content[..50])
|
||||||
|
} else {
|
||||||
|
comment.content.clone()
|
||||||
|
}
|
||||||
|
}</span>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
{if card.comments_list.len() > 2 {
|
||||||
|
html! {
|
||||||
|
<div class="more-comments" onclick={on_comments_click.clone()}>
|
||||||
|
{format!("View all {} comments", card.comments_list.len())}
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@ -554,10 +709,35 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
|
|||||||
|
|
||||||
let on_due_date_change = {
|
let on_due_date_change = {
|
||||||
let card = card.clone();
|
let card = card.clone();
|
||||||
Callback::from(move |e: Event| {
|
Callback::from(move |date: String| {
|
||||||
let input: HtmlInputElement = e.target_unchecked_into();
|
|
||||||
let mut updated_card = (*card).clone();
|
let mut updated_card = (*card).clone();
|
||||||
updated_card.due_date = input.value();
|
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);
|
card.set(updated_card);
|
||||||
})
|
})
|
||||||
};
|
};
|
||||||
@ -648,16 +828,12 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
|
|||||||
</select>
|
</select>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<DateSelector
|
||||||
<label for="card-due-date">{"Due Date"}</label>
|
value={card.due_date.clone()}
|
||||||
<input
|
on_change={on_due_date_change}
|
||||||
type="date"
|
label={"Due Date".to_string()}
|
||||||
id="card-due-date"
|
id={"card-due-date".to_string()}
|
||||||
class="form-control"
|
/>
|
||||||
value={card.due_date.clone()}
|
|
||||||
onchange={on_due_date_change}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="form-group">
|
<div class="form-group">
|
||||||
@ -716,6 +892,12 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
|
|||||||
</span>
|
</span>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
<CommentsCard
|
||||||
|
comments={card.comments_list.clone()}
|
||||||
|
on_add_comment={on_add_comment}
|
||||||
|
on_delete_comment={on_delete_comment}
|
||||||
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
<div class="modal-footer">
|
<div class="modal-footer">
|
||||||
@ -730,3 +912,285 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
|
|||||||
</div>
|
</div>
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
// Reusable DateSelector Component
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct DateSelectorProps {
|
||||||
|
pub value: String,
|
||||||
|
pub on_change: Callback<String>,
|
||||||
|
pub label: String,
|
||||||
|
pub id: String,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(DateSelector)]
|
||||||
|
pub fn date_selector(props: &DateSelectorProps) -> Html {
|
||||||
|
// Convert date format for HTML5 date input (expects YYYY-MM-DD)
|
||||||
|
let formatted_value = format_date_for_input(&props.value);
|
||||||
|
|
||||||
|
let on_change = {
|
||||||
|
let on_change = props.on_change.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let input: HtmlInputElement = e.target_unchecked_into();
|
||||||
|
let date_value = input.value();
|
||||||
|
// HTML5 date input returns YYYY-MM-DD format, which is what we want
|
||||||
|
on_change.emit(date_value);
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="form-group">
|
||||||
|
<label for={props.id.clone()}>{&props.label}</label>
|
||||||
|
<input
|
||||||
|
type="date"
|
||||||
|
id={props.id.clone()}
|
||||||
|
class="form-control date-picker"
|
||||||
|
value={formatted_value}
|
||||||
|
onchange={on_change}
|
||||||
|
placeholder="Select date"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Helper function to ensure date is in YYYY-MM-DD format for HTML5 date input
|
||||||
|
fn format_date_for_input(date_str: &str) -> String {
|
||||||
|
// If already in YYYY-MM-DD format, return as is
|
||||||
|
if date_str.len() == 10 && date_str.chars().nth(4) == Some('-') && date_str.chars().nth(7) == Some('-') {
|
||||||
|
return date_str.to_string();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Try to parse different date formats and convert to YYYY-MM-DD
|
||||||
|
// Handle DD/MM/YYYY format
|
||||||
|
if date_str.len() == 10 && date_str.chars().nth(2) == Some('/') && date_str.chars().nth(5) == Some('/') {
|
||||||
|
let parts: Vec<&str> = date_str.split('/').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
return format!("{}-{:02}-{:02}",
|
||||||
|
parts[2],
|
||||||
|
parts[1].parse::<u32>().unwrap_or(1),
|
||||||
|
parts[0].parse::<u32>().unwrap_or(1)
|
||||||
|
);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Handle MM/DD/YYYY format
|
||||||
|
if date_str.len() == 10 && date_str.chars().nth(2) == Some('/') && date_str.chars().nth(5) == Some('/') {
|
||||||
|
let parts: Vec<&str> = date_str.split('/').collect();
|
||||||
|
if parts.len() == 3 {
|
||||||
|
// Assume MM/DD/YYYY if day > 12
|
||||||
|
let first_num = parts[0].parse::<u32>().unwrap_or(1);
|
||||||
|
let second_num = parts[1].parse::<u32>().unwrap_or(1);
|
||||||
|
|
||||||
|
if first_num > 12 {
|
||||||
|
// First number is day, so it's DD/MM/YYYY
|
||||||
|
return format!("{}-{:02}-{:02}", parts[2], second_num, first_num);
|
||||||
|
} else {
|
||||||
|
// Assume MM/DD/YYYY
|
||||||
|
return format!("{}-{:02}-{:02}", parts[2], first_num, second_num);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// If we can't parse it, return the original string
|
||||||
|
date_str.to_string()
|
||||||
|
}
|
||||||
|
|
||||||
|
// Reusable CommentsCard Component
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CommentsCardProps {
|
||||||
|
pub comments: Vec<Comment>,
|
||||||
|
pub on_add_comment: Callback<String>,
|
||||||
|
pub on_delete_comment: Callback<String>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CommentsCard)]
|
||||||
|
pub fn comments_card(props: &CommentsCardProps) -> Html {
|
||||||
|
let new_comment = use_state(|| String::new());
|
||||||
|
|
||||||
|
let on_comment_input = {
|
||||||
|
let new_comment = new_comment.clone();
|
||||||
|
Callback::from(move |e: Event| {
|
||||||
|
let textarea: HtmlTextAreaElement = e.target_unchecked_into();
|
||||||
|
new_comment.set(textarea.value());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let on_add_click = {
|
||||||
|
let on_add_comment = props.on_add_comment.clone();
|
||||||
|
let new_comment = new_comment.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
if !new_comment.trim().is_empty() {
|
||||||
|
on_add_comment.emit((*new_comment).clone());
|
||||||
|
new_comment.set(String::new());
|
||||||
|
}
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let format_timestamp = |timestamp: &str| -> String {
|
||||||
|
// Simple timestamp formatting - in a real app you'd use a proper date library
|
||||||
|
if let Some(date_part) = timestamp.split('T').next() {
|
||||||
|
date_part.to_string()
|
||||||
|
} else {
|
||||||
|
timestamp.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="comments-card">
|
||||||
|
<div class="comments-header">
|
||||||
|
<h4>{"Comments "}<span class="comment-count">{format!("({})", props.comments.len())}</span></h4>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-list">
|
||||||
|
{for props.comments.iter().map(|comment| {
|
||||||
|
let on_delete = {
|
||||||
|
let on_delete_comment = props.on_delete_comment.clone();
|
||||||
|
let comment_id = comment.id.clone();
|
||||||
|
Callback::from(move |_| {
|
||||||
|
on_delete_comment.emit(comment_id.clone());
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="comment-item" key={comment.id.clone()}>
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-author">
|
||||||
|
<div class="author-avatar">
|
||||||
|
{comment.author.chars().take(2).collect::<String>().to_uppercase()}
|
||||||
|
</div>
|
||||||
|
<span class="author-name">{&comment.author}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-meta">
|
||||||
|
<span class="comment-date">{format_timestamp(&comment.timestamp)}</span>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn-delete-comment"
|
||||||
|
onclick={on_delete}
|
||||||
|
title="Delete comment"
|
||||||
|
>
|
||||||
|
<i class="bi bi-trash"></i>
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
{&comment.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="add-comment">
|
||||||
|
<textarea
|
||||||
|
class="form-control"
|
||||||
|
placeholder="Add a comment..."
|
||||||
|
rows="3"
|
||||||
|
value={(*new_comment).clone()}
|
||||||
|
onchange={on_comment_input}
|
||||||
|
></textarea>
|
||||||
|
<button
|
||||||
|
type="button"
|
||||||
|
class="btn btn-primary btn-sm mt-2"
|
||||||
|
onclick={on_add_click}
|
||||||
|
disabled={new_comment.trim().is_empty()}
|
||||||
|
>
|
||||||
|
{"Add Comment"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// Comments Modal Component
|
||||||
|
#[derive(Properties, PartialEq)]
|
||||||
|
pub struct CommentsModalProps {
|
||||||
|
pub card: KanbanCard,
|
||||||
|
pub on_close: Callback<()>,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[function_component(CommentsModal)]
|
||||||
|
pub fn comments_modal(props: &CommentsModalProps) -> Html {
|
||||||
|
let card = &props.card;
|
||||||
|
|
||||||
|
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 format_timestamp = |timestamp: &str| -> String {
|
||||||
|
// Simple timestamp formatting - in a real app you'd use a proper date library
|
||||||
|
if let Some(date_part) = timestamp.split('T').next() {
|
||||||
|
date_part.to_string()
|
||||||
|
} else {
|
||||||
|
timestamp.to_string()
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
html! {
|
||||||
|
<div class="modal-overlay" onclick={on_close_click_modal.clone()}>
|
||||||
|
<div class="modal-content comments-modal" onclick={Callback::from(|e: MouseEvent| e.stop_propagation())}>
|
||||||
|
<div class="modal-header">
|
||||||
|
<h2>{format!("Comments - {}", &card.title)}</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="card-info">
|
||||||
|
<h4>{&card.title}</h4>
|
||||||
|
<p class="text-muted">{&card.description}</p>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="comments-section">
|
||||||
|
<h5>{"Comments "}<span class="comment-count">{format!("({})", card.comments_list.len())}</span></h5>
|
||||||
|
|
||||||
|
<div class="comments-list-modal">
|
||||||
|
{for card.comments_list.iter().map(|comment| {
|
||||||
|
html! {
|
||||||
|
<div class="comment-item-modal" key={comment.id.clone()}>
|
||||||
|
<div class="comment-header">
|
||||||
|
<div class="comment-author">
|
||||||
|
<div class="author-avatar">
|
||||||
|
{comment.author.chars().take(2).collect::<String>().to_uppercase()}
|
||||||
|
</div>
|
||||||
|
<span class="author-name">{&comment.author}</span>
|
||||||
|
</div>
|
||||||
|
<span class="comment-date">{format_timestamp(&comment.timestamp)}</span>
|
||||||
|
</div>
|
||||||
|
<div class="comment-content">
|
||||||
|
{&comment.content}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
})}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
{if card.comments_list.is_empty() {
|
||||||
|
html! {
|
||||||
|
<div class="no-comments">
|
||||||
|
<p class="text-muted">{"No comments yet."}</p>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
html! {}
|
||||||
|
}}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div class="modal-footer">
|
||||||
|
<button type="button" class="btn btn-secondary" onclick={on_close_click_modal.clone()}>
|
||||||
|
{"Close"}
|
||||||
|
</button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
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;
|
||||||
|
}
|
||||||
|
}
|
Loading…
Reference in New Issue
Block a user