This commit is contained in:
despiegk 2025-08-08 09:02:39 +02:00
parent e34d527089
commit ef3a0e82b8
6 changed files with 1167 additions and 227 deletions

View File

@ -30,4 +30,5 @@ gloo-utils = "0.1"
gloo-storage = "0.2"
gloo-net = "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"] }

View File

@ -1,3 +1,8 @@
// Import component styles
@import 'styles/modal.scss';
@import 'styles/comments.scss';
@import 'styles/forms.scss';
// CSS Custom Properties for theming
:root {
--bs-body-bg: #ffffff;
@ -14,6 +19,12 @@
--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
@ -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 {
@ -763,64 +624,3 @@ body {
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

@ -2,6 +2,7 @@ use yew::prelude::*;
use serde::{Deserialize, Serialize};
use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent};
use wasm_bindgen::JsCast;
use chrono::Utc;
#[derive(Debug, Clone, PartialEq, Serialize, Deserialize)]
pub struct KanbanData {
@ -31,6 +32,16 @@ pub struct KanbanCard {
pub attachments: u32,
pub comments: u32,
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)]
@ -60,6 +71,20 @@ fn get_sample_data() -> KanbanData {
attachments: 2,
comments: 3,
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 {
id: "card-2".to_string(),
@ -72,6 +97,14 @@ fn get_sample_data() -> KanbanData {
attachments: 0,
comments: 1,
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,
comments: 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 {
id: "card-4".to_string(),
@ -103,6 +150,14 @@ fn get_sample_data() -> KanbanData {
attachments: 3,
comments: 2,
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,
comments: 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,
comments: 2,
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 {
id: "card-7".to_string(),
@ -153,6 +224,14 @@ fn get_sample_data() -> KanbanData {
attachments: 4,
comments: 8,
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 {
let data = use_state(|| get_sample_data());
let editing_card = use_state(|| None::<KanbanCard>);
let comments_card = use_state(|| None::<KanbanCard>);
let show_save_popup = use_state(|| false);
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 editing_card = editing_card.clone();
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 data = data.clone();
let editing_card = editing_card.clone();
@ -263,6 +357,7 @@ pub fn kanban_board() -> Html {
<KanbanColumnComponent
column={column.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_drop={on_card_drop.clone()}
/>
@ -296,6 +391,18 @@ pub fn kanban_board() -> Html {
} else {
html! {}
}}
// Comments modal
{if let Some(card) = (*comments_card).clone() {
html! {
<CommentsModal
card={card}
on_close={on_close_comments.clone()}
/>
}
} else {
html! {}
}}
</div>
</div>
}
@ -305,6 +412,7 @@ pub fn kanban_board() -> Html {
pub struct KanbanColumnProps {
pub column: KanbanColumn,
pub on_edit_card: Callback<KanbanCard>,
pub on_comments_click: Callback<KanbanCard>,
pub on_card_drag_start: Callback<String>,
pub on_card_drop: Callback<(String, usize)>,
}
@ -361,6 +469,7 @@ pub fn kanban_column_component(props: &KanbanColumnProps) -> Html {
<KanbanCardComponent
card={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()}
index={index}
/>
@ -376,6 +485,7 @@ pub struct KanbanCardProps {
pub card: KanbanCard,
pub on_edit: Callback<KanbanCard>,
pub on_drag_start: Callback<String>,
pub on_comments_click: Callback<KanbanCard>,
pub index: usize,
}
@ -405,11 +515,21 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
let onclick = {
let on_edit = props.on_edit.clone();
let card = card.clone();
Callback::from(move |_| {
Callback::from(move |e: MouseEvent| {
e.stop_propagation();
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 on_drag_start = props.on_drag_start.clone();
let card_id = card.id.clone();
@ -471,7 +591,7 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
{if card.comments > 0 {
html! {
<div class="stat-item">
<div class="stat-item" onclick={on_comments_click.clone()}>
<i class="bi bi-chat-dots"></i>
<span>{card.comments}</span>
</div>
@ -496,6 +616,41 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html {
html! {}
}}
</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>
}
}
@ -554,10 +709,35 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
let on_due_date_change = {
let card = card.clone();
Callback::from(move |e: Event| {
let input: HtmlInputElement = e.target_unchecked_into();
Callback::from(move |date: String| {
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);
})
};
@ -648,16 +828,12 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
</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>
<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 class="form-group">
@ -716,6 +892,12 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
</span>
</div>
</div>
<CommentsCard
comments={card.comments_list.clone()}
on_add_comment={on_add_comment}
on_delete_comment={on_delete_comment}
/>
</div>
<div class="modal-footer">
@ -729,4 +911,286 @@ 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
View 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
View 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
View 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;
}
}