diff --git a/Cargo.toml b/Cargo.toml index b315c7c..4912a35 100644 --- a/Cargo.toml +++ b/Cargo.toml @@ -31,4 +31,7 @@ gloo-storage = "0.2" gloo-net = "0.4" wasm-bindgen-futures = "0.4" serde = { version = "1.0", features = ["derive"] } -chrono = { version = "0.4", features = ["serde", "wasmbind"] } \ No newline at end of file +serde_json = "1.0" # Added for JSON serialization/deserialization +chrono = { version = "0.4", features = ["serde", "wasmbind"] } +pulldown-cmark = "0.9" +html-escape = "0.2" \ No newline at end of file diff --git a/index.scss b/index.scss index aab6577..fcb0a6b 100644 --- a/index.scss +++ b/index.scss @@ -2,6 +2,7 @@ @import 'styles/modal.scss'; @import 'styles/comments.scss'; @import 'styles/forms.scss'; +@import 'styles/markdown.scss'; // CSS Custom Properties for theming :root { diff --git a/src/app.rs b/src/app.rs index 18b3bc0..7081482 100644 --- a/src/app.rs +++ b/src/app.rs @@ -5,6 +5,7 @@ use gloo_storage::{LocalStorage, Storage}; use serde::{Deserialize, Serialize}; use crate::home::HomePage; use crate::kanban::KanbanBoard; +use crate::edit_card_page::EditCardPage; #[derive(Debug, PartialEq, Clone, Copy, Serialize, Deserialize)] enum Theme { @@ -22,11 +23,17 @@ impl Theme { } #[derive(Clone, Routable, PartialEq)] -enum Route { +pub enum Route { #[at("/")] Home, #[at("/kanban")] Kanban, + #[at("/edit/:card_id")] + EditCard { card_id: String }, + #[at("/edit?documentid=:document_id")] + EditByDocumentId { document_id: String }, + #[at("/edit?content=:content")] + EditByContent { content: String }, #[not_found] #[at("/404")] NotFound, @@ -36,6 +43,13 @@ fn switch(routes: Route) -> Html { match routes { Route::Home => html! { }, Route::Kanban => html! { }, + Route::EditCard { card_id } => html! { }, + Route::EditByDocumentId { document_id } => html! { }, + Route::EditByContent { content } => { + // For content-based editing, we could create a new component or handle it differently + // For now, redirect to kanban board + html! { } + }, Route::NotFound => html! {

{ "404 - Page not found" }

}, } } diff --git a/src/edit_card_page.rs b/src/edit_card_page.rs new file mode 100644 index 0000000..a8a5bd4 --- /dev/null +++ b/src/edit_card_page.rs @@ -0,0 +1,311 @@ +use yew::prelude::*; +use yew_router::prelude::*; +use web_sys::{HtmlInputElement, HtmlSelectElement}; +use wasm_bindgen::JsCast; +use chrono::Utc; +use gloo_storage::{LocalStorage, Storage}; +use serde_json; + +use crate::kanban::{KanbanCard, Comment, get_sample_data}; +use crate::markdown_editor::MarkdownDescriptionView; +use crate::app::Route; +use crate::kanban::DateSelector; // Assuming DateSelector is public and needed + +#[derive(Properties, PartialEq)] +pub struct EditCardPageProps { + pub card_id: String, +} + +#[function_component(EditCardPage)] +pub fn edit_card_page(props: &EditCardPageProps) -> Html { + let navigator = use_navigator().unwrap(); + + // Load kanban data from local storage or use sample data + let kanban_data = use_state(|| { + match LocalStorage::get::("kanban_data") { + Ok(data_str) => { + serde_json::from_str(&data_str).unwrap_or_else(|_| get_sample_data()) + } + Err(_) => get_sample_data() + } + }); + + // Find the card to edit + let card_to_edit = use_state(|| { + let data = (*kanban_data).clone(); + data.columns.iter() + .flat_map(|col| &col.cards) + .find(|card| card.id == props.card_id) + .cloned() + }); + + let card = use_state(|| card_to_edit.as_ref().cloned().unwrap_or_else(|| KanbanCard { + id: "".to_string(), + title: "New Card".to_string(), + description: "".to_string(), + priority: "medium".to_string(), + assignee: "".to_string(), + tags: vec![], + due_date: "".to_string(), + attachments: 0, + comments: 0, + checklist: crate::kanban::ChecklistInfo { completed: 0, total: 0 }, + comments_list: vec![], + })); + + // If card_to_edit is None, navigate back to kanban board + if card_to_edit.is_none() { + navigator.push(&Route::Kanban); + return html! {

{"Card not found, redirecting..."}

}; + } + + let on_title_change = { + let card = card.clone(); + Callback::from(move |e: Event| { + let input: HtmlInputElement = e.target_unchecked_into(); + let mut updated_card = (*card).clone(); + updated_card.title = input.value(); + card.set(updated_card); + }) + }; + + let on_description_change = { + let card = card.clone(); + Callback::from(move |value: String| { + let mut updated_card = (*card).clone(); + updated_card.description = value; + card.set(updated_card); + }) + }; + + let on_priority_change = { + let card = card.clone(); + Callback::from(move |e: Event| { + let select: HtmlSelectElement = e.target_unchecked_into(); + let mut updated_card = (*card).clone(); + updated_card.priority = select.value(); + card.set(updated_card); + }) + }; + + let on_assignee_change = { + let card = card.clone(); + Callback::from(move |e: Event| { + let input: HtmlInputElement = e.target_unchecked_into(); + let mut updated_card = (*card).clone(); + updated_card.assignee = input.value(); + card.set(updated_card); + }) + }; + + let on_due_date_change = { + let card = card.clone(); + Callback::from(move |date: String| { + let mut updated_card = (*card).clone(); + updated_card.due_date = date; + card.set(updated_card); + }) + }; + + let on_add_comment = { + let card = card.clone(); + Callback::from(move |content: String| { + let mut updated_card = (*card).clone(); + let new_comment = Comment { + id: format!("comment-{}", Utc::now().timestamp_millis()), + author: "Current User".to_string(), // In a real app, this would be the logged-in user + content, + timestamp: Utc::now().to_rfc3339(), + }; + updated_card.comments_list.push(new_comment); + updated_card.comments = updated_card.comments_list.len() as u32; + card.set(updated_card); + }) + }; + + let on_delete_comment = { + let card = card.clone(); + Callback::from(move |comment_id: String| { + let mut updated_card = (*card).clone(); + updated_card.comments_list.retain(|c| c.id != comment_id); + updated_card.comments = updated_card.comments_list.len() as u32; + card.set(updated_card); + }) + }; + + let on_tags_change = { + let card = card.clone(); + Callback::from(move |e: Event| { + let input: HtmlInputElement = e.target_unchecked_into(); + let mut updated_card = (*card).clone(); + updated_card.tags = input.value() + .split(',') + .map(|s| s.trim().to_string()) + .filter(|s| !s.is_empty()) + .collect(); + card.set(updated_card); + }) + }; + + let on_save_click = { + let card = card.clone(); + let kanban_data = kanban_data.clone(); + let navigator = navigator.clone(); + Callback::from(move |_| { + let mut new_data = (*kanban_data).clone(); + let updated_card = (*card).clone(); + + // Find and update the card in the data + for column in &mut new_data.columns { + if let Some(card_index) = column.cards.iter().position(|c| c.id == updated_card.id) { + column.cards[card_index] = updated_card.clone(); + break; + } + } + + // Save updated data to local storage + LocalStorage::set("kanban_data", serde_json::to_string(&new_data).unwrap()).unwrap(); + kanban_data.set(new_data); // Update state + navigator.push(&Route::Kanban); // Navigate back to kanban board + }) + }; + + let on_cancel_click = { + let navigator = navigator.clone(); + Callback::from(move |_| { + navigator.push(&Route::Kanban); // Navigate back to kanban board without saving + }) + }; + + let tags_string = card.tags.join(", "); + + html! { +
+
+
+

{"Edit Card"}

+
+
+
+ + +
+ +
+ +
+ +
+
+
+ + +
+
+
+ +
+
+ +
+ + +
+ +
+ + +
+ +
+
+
+ +
+ + {card.attachments} +
+
+
+
+
+ +
+ + {card.comments} +
+
+
+
+ +
+ +
+
+
0 { + (card.checklist.completed as f32 / card.checklist.total as f32 * 100.0) as u32 + } else { 0 })} + >
+
+ + {format!("{}/{} completed", card.checklist.completed, card.checklist.total)} + +
+
+ + +
+ + +
+
+ } +} \ No newline at end of file diff --git a/src/kanban.rs b/src/kanban.rs index 57eccaa..6d848ff 100644 --- a/src/kanban.rs +++ b/src/kanban.rs @@ -3,6 +3,7 @@ use serde::{Deserialize, Serialize}; use web_sys::{DragEvent, HtmlInputElement, HtmlTextAreaElement, HtmlSelectElement, MouseEvent}; use wasm_bindgen::JsCast; use chrono::Utc; +use crate::markdown_editor::{MarkdownViewer, MarkdownEditor}; #[derive(Debug, Clone, PartialEq, Serialize, Deserialize)] pub struct KanbanData { @@ -50,7 +51,7 @@ pub struct ChecklistInfo { pub total: u32, } -fn get_sample_data() -> KanbanData { +pub fn get_sample_data() -> KanbanData { KanbanData { title: "Project Management Board".to_string(), description: "Track project progress with this kanban board".to_string(), @@ -512,7 +513,7 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html { .collect::() .to_uppercase(); - let onclick = { + let on_edit_button_click = { let on_edit = props.on_edit.clone(); let card = card.clone(); Callback::from(move |e: MouseEvent| { @@ -521,6 +522,46 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html { }) }; + let on_card_click = { + let on_edit = props.on_edit.clone(); + let card = card.clone(); + Callback::from(move |e: MouseEvent| { + // Only trigger if the click is not on an interactive element + let target = e.target().unwrap(); + let element = target.dyn_into::().unwrap(); + + // Check if the clicked element or its parent is an interactive element + let tag_name = element.tag_name().to_lowercase(); + let class_name = element.class_name(); + + // Don't trigger card edit if clicking on interactive elements + if tag_name == "button" || + tag_name == "a" || + tag_name == "input" || + class_name.contains("btn") || + class_name.contains("stat-item") || + class_name.contains("more-comments") || + class_name.contains("comment-preview") { + return; + } + + // Check parent elements for interactive classes + let mut current_element = Some(element); + while let Some(elem) = current_element { + let class_name = elem.class_name(); + if class_name.contains("btn") || + class_name.contains("stat-item") || + class_name.contains("more-comments") || + class_name.contains("comment-preview") { + return; + } + current_element = elem.parent_element(); + } + + on_edit.emit(card.clone()); + }) + }; + let on_comments_click = { let on_comments_click = props.on_comments_click.clone(); let card = card.clone(); @@ -539,10 +580,24 @@ pub fn kanban_card_component(props: &KanbanCardProps) -> Html { }; html! { -
+
-

{&card.title}

-

{&card.description}

+
+

{&card.title}

+ +
+
+ +
@@ -679,10 +734,9 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html { let on_description_change = { let card = card.clone(); - Callback::from(move |e: Event| { - let textarea: HtmlTextAreaElement = e.target_unchecked_into(); + Callback::from(move |new_description: String| { let mut updated_card = (*card).clone(); - updated_card.description = textarea.value(); + updated_card.description = new_description; card.set(updated_card); }) }; @@ -804,13 +858,13 @@ pub fn card_edit_modal(props: &CardEditModalProps) -> Html {
- + on_change={on_description_change} + placeholder={Some("Enter card description in markdown...".to_string())} + rows={Some(6)} + widescreen={Some(false)} + />
diff --git a/src/main.rs b/src/main.rs index e899a91..f9e39fd 100644 --- a/src/main.rs +++ b/src/main.rs @@ -1,6 +1,8 @@ mod app; mod home; mod kanban; +mod markdown_editor; +mod edit_card_page; use app::App; fn main() { diff --git a/src/markdown_editor.rs b/src/markdown_editor.rs new file mode 100644 index 0000000..be86990 --- /dev/null +++ b/src/markdown_editor.rs @@ -0,0 +1,246 @@ +use yew::prelude::*; +use web_sys::{HtmlTextAreaElement, MouseEvent}; +use pulldown_cmark::{Parser, Options, html}; + +#[derive(Properties, PartialEq)] +pub struct MarkdownEditorProps { + pub value: String, + pub on_change: Callback, + pub placeholder: Option, + pub rows: Option, + pub widescreen: Option, +} + +fn markdown_to_html(markdown: &str) -> String { + let mut options = Options::empty(); + options.insert(Options::ENABLE_STRIKETHROUGH); + options.insert(Options::ENABLE_TABLES); + options.insert(Options::ENABLE_FOOTNOTES); + options.insert(Options::ENABLE_TASKLISTS); + + let parser = Parser::new_ext(markdown, options); + let mut html_output = String::new(); + html::push_html(&mut html_output, parser); + html_output +} + +#[function_component(MarkdownEditor)] +pub fn markdown_editor(props: &MarkdownEditorProps) -> Html { + let widescreen = use_state(|| props.widescreen.unwrap_or(false)); + + let toggle_widescreen = { + let widescreen = widescreen.clone(); + Callback::from(move |_| { + widescreen.set(!*widescreen); + }) + }; + + let on_input = { + let on_change = props.on_change.clone(); + Callback::from(move |e: InputEvent| { + let textarea: HtmlTextAreaElement = e.target_unchecked_into(); + on_change.emit(textarea.value()); + }) + }; + + let container_class = if *widescreen { + "markdown-editor-container widescreen" + } else { + "markdown-editor-container" + }; + + // Split view with editor on left and preview on right + let editor_class = "markdown-editor split-view"; + + // Convert markdown to HTML for preview + let rendered_html = use_memo( + props.value.clone(), + |content| markdown_to_html(content), + ); + + html! { +
+
+
+ +
+ +
+ {"Markdown Editor with Live Preview"} +
+
+ +
+
+ +
+
+
+
{"Preview"}
+
+
+ +
+
+
+
+ } +} + +#[derive(Properties, PartialEq)] +pub struct MarkdownContentProps { + pub html: String, +} + +#[function_component(MarkdownContent)] +pub fn markdown_content(props: &MarkdownContentProps) -> Html { + let div_ref = use_node_ref(); + + use_effect_with(props.html.clone(), { + let div_ref = div_ref.clone(); + move |html| { + if let Some(div) = div_ref.cast::() { + div.set_inner_html(html); + } + } + }); + + html! { +
+ } +} + +#[derive(Properties, PartialEq)] +pub struct MarkdownViewerProps { + pub content: String, + pub class: Option, +} + +#[function_component(MarkdownViewer)] +pub fn markdown_viewer(props: &MarkdownViewerProps) -> Html { + // Convert markdown to HTML using use_memo with correct syntax + let rendered_html = use_memo( + props.content.clone(), + |content| markdown_to_html(content), + ); + + let class = props.class.clone().unwrap_or_else(|| "markdown-content".to_string()); + + html! { +
+ +
+ } +} + +#[derive(Properties, PartialEq)] +pub struct MarkdownDescriptionViewProps { + pub content: String, + pub on_change: Callback, +} + +#[function_component(MarkdownDescriptionView)] +pub fn markdown_description_view(props: &MarkdownDescriptionViewProps) -> Html { + let show_editor_modal = use_state(|| false); + + let on_edit_click = { + let show_editor_modal = show_editor_modal.clone(); + Callback::from(move |_| { + show_editor_modal.set(true); + }) + }; + + let on_modal_close = { + let show_editor_modal = show_editor_modal.clone(); + Callback::from(move |_| { + show_editor_modal.set(false); + }) + }; + + let on_content_change = { + let on_change = props.on_change.clone(); + Callback::from(move |new_content: String| { + on_change.emit(new_content); + }) + }; + + html! { + <> +
+
+ + +
+
+ {if props.content.trim().is_empty() { + html! { +
+ + {"No description provided. Click the edit icon to add one."} +
+ } + } else { + html! { + + } + }} +
+
+ + {if *show_editor_modal { + html! { + + } + } else { + html! {} + }} + + } +} \ No newline at end of file diff --git a/styles/markdown.scss b/styles/markdown.scss new file mode 100644 index 0000000..6134994 --- /dev/null +++ b/styles/markdown.scss @@ -0,0 +1,721 @@ +/* Markdown Editor Styles */ +.markdown-editor-container { + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + overflow: hidden; + background: var(--bs-body-bg); + box-shadow: 0 0.125rem 0.25rem rgba(0, 0, 0, 0.075); + height: 500px; /* Increased height for better modal experience */ + min-height: 400px; /* Minimum height to ensure usability */ + + /* In modal context, take more space */ + .modal-body & { + height: 60vh; /* Use viewport height for better responsiveness */ + min-height: 450px; + max-height: 70vh; /* Prevent it from being too tall */ + } + + &.widescreen { + position: fixed; + top: 30px; + left: 30px; + right: 30px; + bottom: 30px; + z-index: 1050; + border: 2px solid var(--bs-primary); + box-shadow: 0 1rem 3rem rgba(0, 0, 0, 0.3); + border-radius: 0.75rem; + height: auto; + } +} + +.markdown-toolbar { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-primary-rgb), 0.05) 100%); + border-bottom: 1px solid var(--bs-border-color); + + .toolbar-left { + display: flex; + align-items: center; + gap: 0.75rem; + + .btn { + border-radius: 0.375rem; + font-weight: 500; + padding: 0.375rem 0.75rem; + transition: all 0.2s ease; + + &:hover { + transform: translateY(-1px); + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); + } + } + } + + .toolbar-right { + display: flex; + align-items: center; + + small { + font-weight: 500; + opacity: 0.8; + } + } +} + +.markdown-editor { + display: flex; + height: calc(100% - 60px); /* Subtract toolbar height */ + + &.full-view { + .editor-pane { + width: 100%; + } + } + + &.split-view { + .editor-pane { + width: 50%; + border-right: 1px solid var(--bs-border-color); + } + + .preview-pane { + width: 50%; + } + } +} + +.editor-pane { + display: flex; + flex-direction: column; + height: 100%; + + .markdown-textarea { + border: none; + border-radius: 0; + resize: none; + flex: 1; + height: 100%; + font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.875rem; + line-height: 1.6; + padding: 1rem; + background: var(--bs-body-bg); + color: var(--bs-body-color); + + &:focus { + box-shadow: none; + border-color: transparent; + outline: none; + background: rgba(var(--bs-primary-rgb), 0.02); + } + + &::placeholder { + color: var(--bs-secondary); + opacity: 0.6; + } + } +} + +.preview-pane { + display: flex; + flex-direction: column; + background: rgba(var(--bs-primary-rgb), 0.01); + height: 100%; + + .preview-header { + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-success-rgb, 25, 135, 84), 0.05) 100%); + border-bottom: 1px solid var(--bs-border-color); + + h6 { + color: var(--bs-secondary); + font-weight: 600; + margin: 0; + display: flex; + align-items: center; + gap: 0.5rem; + + &::before { + content: "👁"; + font-size: 1rem; + } + } + } + + .markdown-preview { + flex: 1; + padding: 1rem; + overflow-y: auto; + height: calc(100% - 50px); + + /* Custom scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(var(--bs-primary-rgb), 0.2); + border-radius: 3px; + + &:hover { + background: rgba(var(--bs-primary-rgb), 0.3); + } + } + } +} + +/* Markdown Content Styles */ +.markdown-content, .card-description-content { + line-height: 1.7; + color: var(--bs-body-color); + font-size: 0.9rem; /* Slightly smaller base font */ + + h1, h2, h3, h4, h5, h6 { + margin-top: 1.75rem; + margin-bottom: 0.875rem; + font-weight: 600; + line-height: 1.3; + color: var(--bs-emphasis-color); + + &:first-child { + margin-top: 0; + } + } + + h1 { + font-size: 1.4rem; /* Even smaller H1 */ + border-bottom: 3px solid var(--bs-primary); + padding-bottom: 0.75rem; + margin-bottom: 1.5rem; + background: linear-gradient(135deg, var(--bs-primary), var(--bs-info)); + -webkit-background-clip: text; + -webkit-text-fill-color: transparent; + background-clip: text; + } + + h2 { + font-size: 1.1rem; /* Even smaller H2 */ + border-bottom: 2px solid rgba(var(--bs-primary-rgb), 0.3); + padding-bottom: 0.5rem; + margin-bottom: 1.25rem; + } + + h3 { + font-size: 1.25rem; + color: var(--bs-primary); + position: relative; + + &::before { + content: "▶"; + color: var(--bs-primary); + margin-right: 0.5rem; + font-size: 0.8em; + } + } + + h4 { + font-size: 1.125rem; + color: var(--bs-secondary); + } + + h5, h6 { + font-size: 1rem; + color: var(--bs-secondary); + text-transform: uppercase; + letter-spacing: 0.5px; + font-weight: 700; + } + + p { + margin-bottom: 1.25rem; + text-align: justify; + + &:last-child { + margin-bottom: 0; + } + } + + ul, ol { + margin-bottom: 1.25rem; + padding-left: 1.75rem; + + li { + margin-bottom: 0.5rem; + line-height: 1.6; + position: relative; + + &::marker { + color: var(--bs-primary); + font-weight: bold; + } + } + + ul, ol { + margin-bottom: 0.75rem; + margin-top: 0.5rem; + } + } + + blockquote { + margin: 1.5rem 0; + padding: 1rem 1.5rem; + border-left: 5px solid var(--bs-primary); + background: linear-gradient(135deg, rgba(var(--bs-primary-rgb), 0.08) 0%, rgba(var(--bs-primary-rgb), 0.03) 100%); + border-radius: 0 0.5rem 0.5rem 0; + font-style: italic; + position: relative; + + &::before { + content: "\201C"; + font-size: 3rem; + color: rgba(var(--bs-primary-rgb), 0.3); + position: absolute; + top: -0.5rem; + left: 0.5rem; + font-family: Georgia, serif; + } + + p:last-child { + margin-bottom: 0; + } + } + + code { + background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-info-rgb), 0.1) 100%); + padding: 0.25rem 0.5rem; + border-radius: 0.375rem; + font-family: 'JetBrains Mono', 'Monaco', 'Menlo', 'Ubuntu Mono', monospace; + font-size: 0.85em; + border: 1px solid rgba(var(--bs-info-rgb), 0.2); + color: var(--bs-info); + font-weight: 500; + } + + pre { + background: linear-gradient(135deg, #1a1a1a 0%, #2d2d2d 100%); + color: #f8f9fa; + padding: 1.5rem; + border-radius: 0.75rem; + overflow-x: auto; + margin: 1.5rem 0; + border: 1px solid rgba(var(--bs-primary-rgb), 0.2); + box-shadow: 0 0.5rem 1rem rgba(0, 0, 0, 0.15); + position: relative; + + &::before { + content: "Code"; + position: absolute; + top: 0.5rem; + right: 1rem; + font-size: 0.75rem; + color: rgba(255, 255, 255, 0.5); + text-transform: uppercase; + letter-spacing: 1px; + } + + code { + background: transparent; + padding: 0; + color: inherit; + border: none; + } + } + + table { + width: 100%; + border-collapse: collapse; + margin: 1.5rem 0; + border-radius: 0.5rem; + overflow: hidden; + box-shadow: 0 0.25rem 0.5rem rgba(0, 0, 0, 0.1); + + th, td { + border: 1px solid var(--bs-border-color); + padding: 0.75rem 1rem; + text-align: left; + } + + th { + background: linear-gradient(135deg, var(--bs-primary) 0%, rgba(var(--bs-primary-rgb), 0.8) 100%); + color: white; + font-weight: 600; + text-transform: uppercase; + letter-spacing: 0.5px; + font-size: 0.875rem; + } + + tr:nth-child(even) { + background: rgba(var(--bs-primary-rgb), 0.03); + } + + tr:hover { + background: rgba(var(--bs-primary-rgb), 0.08); + transition: background-color 0.2s ease; + } + } + + hr { + margin: 3rem 0; + border: none; + height: 3px; + background: linear-gradient(90deg, transparent 0%, var(--bs-primary) 50%, transparent 100%); + border-radius: 1.5px; + } + + a { + color: var(--bs-primary); + text-decoration: none; + font-weight: 500; + position: relative; + transition: all 0.2s ease; + + &::after { + content: ""; + position: absolute; + bottom: -2px; + left: 0; + width: 0; + height: 2px; + background: var(--bs-primary); + transition: width 0.3s ease; + } + + &:hover { + color: var(--bs-primary); + + &::after { + width: 100%; + } + } + } + + strong, b { + font-weight: 700; + color: var(--bs-emphasis-color); + } + + em, i { + font-style: italic; + color: var(--bs-secondary); + } + + del, s { + text-decoration: line-through; + opacity: 0.7; + } + + // Task lists + input[type="checkbox"] { + margin-right: 0.75rem; + transform: scale(1.2); + accent-color: var(--bs-primary); + } + + /* Inline elements */ + mark { + background: linear-gradient(135deg, rgba(var(--bs-warning-rgb), 0.3) 0%, rgba(var(--bs-warning-rgb), 0.1) 100%); + padding: 0.125rem 0.25rem; + border-radius: 0.25rem; + } +} + +/* Card Description Specific Styles */ +.card-description { + .card-description-content { + font-size: 0.8rem; /* Slightly smaller base font for card description */ + color: var(--bs-secondary); + + h1, h2, h3, h4, h5, h6 { + margin-top: 0.75rem; + margin-bottom: 0.25rem; + + &:first-child { + margin-top: 0; + } + } + + h1 { font-size: 0.9rem; } /* Smaller H1 for card description */ + h2 { font-size: 0.85rem; } /* Smaller H2 for card description */ + h3 { font-size: 0.9rem; } + h4 { font-size: 0.875rem; } + h5 { font-size: 0.85rem; } + h6 { font-size: 0.8rem; } + + p { + margin-bottom: 0.5rem; + } + + ul, ol { + margin-bottom: 0.5rem; + padding-left: 1rem; + } + + blockquote { + margin: 0.5rem 0; + padding: 0.25rem 0.5rem; + font-size: 0.8rem; + } + + pre { + margin: 0.5rem 0; + padding: 0.5rem; + font-size: 0.75rem; + } + + table { + margin: 0.5rem 0; + font-size: 0.8rem; + + th, td { + padding: 0.25rem; + } + } + } +} + +/* Dark theme adjustments */ +.dark-theme { + .markdown-toolbar { + background: var(--bs-dark); + border-bottom-color: var(--bs-border-color); + } + + .preview-header { + background: var(--bs-dark); + border-bottom-color: var(--bs-border-color); + } + + .markdown-content, .card-description-content { + blockquote { + background: var(--bs-dark); + } + + code { + background: var(--bs-dark); + } + + pre { + background: var(--bs-light); + color: var(--bs-dark); + } + + table { + th { + background: var(--bs-dark); + } + + tr:nth-child(even) { + background: var(--bs-dark); + } + } + } +} + +/* Responsive adjustments */ +@media (max-width: 768px) { + .markdown-editor-container.widescreen { + top: 20px; + left: 20px; + right: 20px; + bottom: 20px; + } + + .markdown-editor.split-view { + flex-direction: column; + + .editor-pane { + width: 100%; + border-right: none; + border-bottom: 1px solid var(--bs-border-color); + } + + .preview-pane { + width: 100%; + } + } +} + +/* Kanban Card Edit Button Styles */ +.card-title-row { + display: flex; + justify-content: space-between; + align-items: flex-start; + gap: 0.5rem; +} + +.btn-edit-card { + background: none; + border: none; + color: var(--bs-secondary); + padding: 0.25rem; + border-radius: 0.25rem; + transition: all 0.2s ease; + opacity: 0.6; + + &:hover { + color: var(--bs-primary); + background: rgba(var(--bs-primary-rgb), 0.1); + opacity: 1; + transform: scale(1.1); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25); + } + + i { + font-size: 0.875rem; + } +} + +.card-title { + flex: 1; + margin: 0; +} + +/* Markdown Description View Styles */ +.markdown-description-view { + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-body-bg); + + .description-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--bs-light) 0%, rgba(var(--bs-primary-rgb), 0.05) 100%); + border-bottom: 1px solid var(--bs-border-color); + border-radius: 0.5rem 0.5rem 0 0; + + .form-label { + margin: 0; + font-weight: 600; + color: var(--bs-body-color); + } + + .btn-edit-description { + background: none; + border: none; + color: var(--bs-secondary); + padding: 0.375rem; + border-radius: 0.25rem; + transition: all 0.2s ease; + opacity: 0.7; + + &:hover { + color: var(--bs-primary); + background: rgba(var(--bs-primary-rgb), 0.1); + opacity: 1; + transform: scale(1.1); + } + + &:focus { + outline: none; + box-shadow: 0 0 0 2px rgba(var(--bs-primary-rgb), 0.25); + } + + i { + font-size: 0.875rem; + } + } + } + + .description-content { + height: 200px; /* 1/3 of typical form height */ + overflow-y: auto; + padding: 1rem; + + /* Custom scrollbar */ + &::-webkit-scrollbar { + width: 6px; + } + + &::-webkit-scrollbar-track { + background: transparent; + } + + &::-webkit-scrollbar-thumb { + background: rgba(var(--bs-primary-rgb), 0.2); + border-radius: 3px; + + &:hover { + background: rgba(var(--bs-primary-rgb), 0.3); + } + } + + .empty-description { + display: flex; + flex-direction: column; + align-items: center; + justify-content: center; + height: 100%; + text-align: center; + gap: 0.5rem; + + i { + font-size: 2rem; + opacity: 0.5; + } + + span { + font-size: 0.875rem; + opacity: 0.7; + } + } + } +} + +.markdown-description-editor { + border: 1px solid var(--bs-border-color); + border-radius: 0.5rem; + background: var(--bs-body-bg); + + .editor-header { + display: flex; + justify-content: space-between; + align-items: center; + padding: 0.75rem 1rem; + background: linear-gradient(135deg, var(--bs-primary) 0%, rgba(var(--bs-primary-rgb), 0.8) 100%); + color: white; + border-radius: 0.5rem 0.5rem 0 0; + + h6 { + margin: 0; + font-weight: 600; + } + + .btn { + color: white; + border-color: rgba(255, 255, 255, 0.3); + + &:hover { + background: rgba(255, 255, 255, 0.1); + border-color: rgba(255, 255, 255, 0.5); + } + } + } + + .markdown-editor-container { + border: none; + border-radius: 0 0 0.5rem 0.5rem; + height: 400px; /* Larger height for editing */ + } +} + +/* Form group specific styles for description */ +.description-form-group { + .markdown-description-view, + .markdown-description-editor { + width: 100%; + } +} \ No newline at end of file