...
This commit is contained in:
parent
ef3a0e82b8
commit
7470200fc2
@ -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"] }
|
||||
serde_json = "1.0" # Added for JSON serialization/deserialization
|
||||
chrono = { version = "0.4", features = ["serde", "wasmbind"] }
|
||||
pulldown-cmark = "0.9"
|
||||
html-escape = "0.2"
|
@ -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 {
|
||||
|
16
src/app.rs
16
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! { <HomePage /> },
|
||||
Route::Kanban => html! { <KanbanBoard /> },
|
||||
Route::EditCard { card_id } => html! { <EditCardPage card_id={card_id} /> },
|
||||
Route::EditByDocumentId { document_id } => html! { <EditCardPage card_id={document_id} /> },
|
||||
Route::EditByContent { content } => {
|
||||
// For content-based editing, we could create a new component or handle it differently
|
||||
// For now, redirect to kanban board
|
||||
html! { <KanbanBoard /> }
|
||||
},
|
||||
Route::NotFound => html! { <h1>{ "404 - Page not found" }</h1> },
|
||||
}
|
||||
}
|
||||
|
311
src/edit_card_page.rs
Normal file
311
src/edit_card_page.rs
Normal file
@ -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::<String>("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! { <p>{"Card not found, redirecting..."}</p> };
|
||||
}
|
||||
|
||||
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! {
|
||||
<div class="container mt-4">
|
||||
<div class="card">
|
||||
<div class="card-header">
|
||||
<h2>{"Edit Card"}</h2>
|
||||
</div>
|
||||
<div class="card-body">
|
||||
<div class="form-group mb-3">
|
||||
<label for="card-title" class="form-label">{"Title"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="card-title"
|
||||
class="form-control"
|
||||
value={card.title.clone()}
|
||||
onchange={on_title_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3 description-form-group">
|
||||
<MarkdownDescriptionView
|
||||
content={card.description.clone()}
|
||||
on_change={on_description_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label for="card-priority" class="form-label">{"Priority"}</label>
|
||||
<select
|
||||
id="card-priority"
|
||||
class="form-select"
|
||||
value={card.priority.clone()}
|
||||
onchange={on_priority_change}
|
||||
>
|
||||
<option value="low">{"Low"}</option>
|
||||
<option value="medium">{"Medium"}</option>
|
||||
<option value="high">{"High"}</option>
|
||||
</select>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<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>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="card-assignee" class="form-label">{"Assignee"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="card-assignee"
|
||||
class="form-control"
|
||||
value={card.assignee.clone()}
|
||||
onchange={on_assignee_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label for="card-tags" class="form-label">{"Tags (comma separated)"}</label>
|
||||
<input
|
||||
type="text"
|
||||
id="card-tags"
|
||||
class="form-control"
|
||||
value={tags_string}
|
||||
onchange={on_tags_change}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="row mb-3">
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{"Attachments"}</label>
|
||||
<div class="stat-display">
|
||||
<i class="bi bi-paperclip me-2"></i>
|
||||
{card.attachments}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div class="col-md-6">
|
||||
<div class="form-group">
|
||||
<label class="form-label">{"Comments"}</label>
|
||||
<div class="stat-display">
|
||||
<i class="bi bi-chat-dots me-2"></i>
|
||||
{card.comments}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="form-group mb-3">
|
||||
<label class="form-label">{"Checklist Progress"}</label>
|
||||
<div class="checklist-display">
|
||||
<div class="progress-bar-large">
|
||||
<div
|
||||
class="progress-fill"
|
||||
style={format!("width: {}%", if card.checklist.total > 0 {
|
||||
(card.checklist.completed as f32 / card.checklist.total as f32 * 100.0) as u32
|
||||
} else { 0 })}
|
||||
></div>
|
||||
</div>
|
||||
<span class="progress-text-large">
|
||||
{format!("{}/{} completed", card.checklist.completed, card.checklist.total)}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<crate::kanban::CommentsCard
|
||||
comments={card.comments_list.clone()}
|
||||
on_add_comment={on_add_comment}
|
||||
on_delete_comment={on_delete_comment}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="card-footer text-end">
|
||||
<button type="button" class="btn btn-secondary me-2" onclick={on_cancel_click}>
|
||||
{"Cancel"}
|
||||
</button>
|
||||
<button type="button" class="btn btn-primary" onclick={on_save_click}>
|
||||
{"Save Changes"}
|
||||
</button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
@ -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::<String>()
|
||||
.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::<web_sys::Element>().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! {
|
||||
<div class="kanban-card" draggable="true" {onclick} {ondragstart}>
|
||||
<div class="kanban-card" draggable="true" {ondragstart} onclick={on_card_click}>
|
||||
<div class="card-header">
|
||||
<h3 class="card-title">{&card.title}</h3>
|
||||
<p class="card-description">{&card.description}</p>
|
||||
<div class="card-title-row">
|
||||
<h3 class="card-title">{&card.title}</h3>
|
||||
<button
|
||||
class="btn-edit-card"
|
||||
onclick={on_edit_button_click}
|
||||
title="Edit card"
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="card-description">
|
||||
<MarkdownViewer
|
||||
content={card.description.clone()}
|
||||
class={Some("card-description-content".to_string())}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class="card-meta">
|
||||
@ -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 {
|
||||
|
||||
<div class="form-group">
|
||||
<label for="card-description">{"Description"}</label>
|
||||
<textarea
|
||||
id="card-description"
|
||||
class="form-control"
|
||||
rows="4"
|
||||
<MarkdownEditor
|
||||
value={card.description.clone()}
|
||||
onchange={on_description_change}
|
||||
></textarea>
|
||||
on_change={on_description_change}
|
||||
placeholder={Some("Enter card description in markdown...".to_string())}
|
||||
rows={Some(6)}
|
||||
widescreen={Some(false)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div class="form-row">
|
||||
|
@ -1,6 +1,8 @@
|
||||
mod app;
|
||||
mod home;
|
||||
mod kanban;
|
||||
mod markdown_editor;
|
||||
mod edit_card_page;
|
||||
use app::App;
|
||||
|
||||
fn main() {
|
||||
|
246
src/markdown_editor.rs
Normal file
246
src/markdown_editor.rs
Normal file
@ -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<String>,
|
||||
pub placeholder: Option<String>,
|
||||
pub rows: Option<u32>,
|
||||
pub widescreen: Option<bool>,
|
||||
}
|
||||
|
||||
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! {
|
||||
<div class={container_class}>
|
||||
<div class="markdown-toolbar">
|
||||
<div class="toolbar-left">
|
||||
<button
|
||||
type="button"
|
||||
class={if *widescreen { "btn btn-success btn-sm" } else { "btn btn-outline-success btn-sm" }}
|
||||
onclick={toggle_widescreen.clone()}
|
||||
title="Toggle Widescreen"
|
||||
>
|
||||
<i class="bi bi-arrows-angle-expand me-1"></i>
|
||||
{if *widescreen { "Normal View" } else { "Widescreen" }}
|
||||
</button>
|
||||
</div>
|
||||
|
||||
<div class="toolbar-right">
|
||||
<small class="text-muted">{"Markdown Editor with Live Preview"}</small>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div class={editor_class}>
|
||||
<div class="editor-pane">
|
||||
<textarea
|
||||
class="form-control markdown-textarea"
|
||||
value={props.value.clone()}
|
||||
placeholder={props.placeholder.clone().unwrap_or_else(|| "Enter markdown text...".to_string())}
|
||||
rows={props.rows.unwrap_or(10).to_string()}
|
||||
oninput={on_input}
|
||||
></textarea>
|
||||
</div>
|
||||
<div class="preview-pane">
|
||||
<div class="preview-header">
|
||||
<h6>{"Preview"}</h6>
|
||||
</div>
|
||||
<div class="markdown-preview">
|
||||
<MarkdownContent html={(*rendered_html).clone()} />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[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::<web_sys::HtmlElement>() {
|
||||
div.set_inner_html(html);
|
||||
}
|
||||
}
|
||||
});
|
||||
|
||||
html! {
|
||||
<div ref={div_ref}></div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MarkdownViewerProps {
|
||||
pub content: String,
|
||||
pub class: Option<String>,
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<div class={class}>
|
||||
<MarkdownContent html={(*rendered_html).clone()} />
|
||||
</div>
|
||||
}
|
||||
}
|
||||
|
||||
#[derive(Properties, PartialEq)]
|
||||
pub struct MarkdownDescriptionViewProps {
|
||||
pub content: String,
|
||||
pub on_change: Callback<String>,
|
||||
}
|
||||
|
||||
#[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! {
|
||||
<>
|
||||
<div class="markdown-description-view">
|
||||
<div class="description-header">
|
||||
<label class="form-label">{"Description"}</label>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-edit-description"
|
||||
onclick={on_edit_click}
|
||||
title="Edit Description"
|
||||
>
|
||||
<i class="bi bi-pencil"></i>
|
||||
</button>
|
||||
</div>
|
||||
<div class="description-content">
|
||||
{if props.content.trim().is_empty() {
|
||||
html! {
|
||||
<div class="empty-description">
|
||||
<i class="bi bi-file-text text-muted"></i>
|
||||
<span class="text-muted">{"No description provided. Click the edit icon to add one."}</span>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {
|
||||
<MarkdownViewer
|
||||
content={props.content.clone()}
|
||||
class={Some("card-description-content".to_string())}
|
||||
/>
|
||||
}
|
||||
}}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{if *show_editor_modal {
|
||||
html! {
|
||||
<div class="modal-overlay" onclick={on_modal_close.clone()}>
|
||||
<div class="modal-content markdown-editor-modal" onclick={|e: MouseEvent| e.stop_propagation()}>
|
||||
<div class="modal-header">
|
||||
<h2>{"Edit Description"}</h2>
|
||||
<button
|
||||
type="button"
|
||||
class="btn-close"
|
||||
onclick={on_modal_close}
|
||||
title="Close"
|
||||
>
|
||||
{"×"}
|
||||
</button>
|
||||
</div>
|
||||
<div class="modal-body">
|
||||
<MarkdownEditor
|
||||
value={props.content.clone()}
|
||||
on_change={on_content_change}
|
||||
placeholder={Some("Enter description in Markdown format...".to_string())}
|
||||
rows={Some(15)}
|
||||
widescreen={Some(false)}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
} else {
|
||||
html! {}
|
||||
}}
|
||||
</>
|
||||
}
|
||||
}
|
721
styles/markdown.scss
Normal file
721
styles/markdown.scss
Normal file
@ -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%;
|
||||
}
|
||||
}
|
Loading…
Reference in New Issue
Block a user