1use crate::atoms::{Button, ButtonSize, ButtonVariant, Icon, IconColor, IconSize, Label, TextSize};
6use crate::styles::Style;
7use crate::theme::{use_style, use_theme};
8use dioxus::prelude::*;
9
10#[derive(Clone, PartialEq, Debug)]
12pub struct KanbanCard {
13 pub id: String,
14 pub title: String,
15 pub description: Option<String>,
16 pub tags: Vec<String>,
17 pub assignee: Option<String>,
18 pub due_date: Option<String>,
19}
20
21impl KanbanCard {
22 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
24 Self {
25 id: id.into(),
26 title: title.into(),
27 description: None,
28 tags: Vec::new(),
29 assignee: None,
30 due_date: None,
31 }
32 }
33
34 pub fn with_description(mut self, description: impl Into<String>) -> Self {
36 self.description = Some(description.into());
37 self
38 }
39
40 pub fn with_tags(mut self, tags: Vec<String>) -> Self {
42 self.tags = tags;
43 self
44 }
45
46 pub fn with_assignee(mut self, assignee: impl Into<String>) -> Self {
48 self.assignee = Some(assignee.into());
49 self
50 }
51
52 pub fn with_due_date(mut self, due_date: impl Into<String>) -> Self {
54 self.due_date = Some(due_date.into());
55 self
56 }
57}
58
59#[derive(Clone, PartialEq, Debug)]
61pub struct KanbanColumn {
62 pub id: String,
63 pub title: String,
64 pub cards: Vec<KanbanCard>,
65 pub color: Option<String>,
66}
67
68impl KanbanColumn {
69 pub fn new(id: impl Into<String>, title: impl Into<String>) -> Self {
71 Self {
72 id: id.into(),
73 title: title.into(),
74 cards: Vec::new(),
75 color: None,
76 }
77 }
78
79 pub fn with_cards(mut self, cards: Vec<KanbanCard>) -> Self {
81 self.cards = cards;
82 self
83 }
84
85 pub fn with_color(mut self, color: impl Into<String>) -> Self {
87 self.color = Some(color.into());
88 self
89 }
90
91 pub fn add_card(&mut self, card: KanbanCard) {
93 self.cards.push(card);
94 }
95}
96
97#[derive(Props, Clone, PartialEq)]
99pub struct KanbanProps {
100 pub columns: Vec<KanbanColumn>,
102 #[props(default)]
104 pub on_columns_change: Option<EventHandler<Vec<KanbanColumn>>>,
105 #[props(default)]
107 pub on_card_move: Option<EventHandler<(String, String, usize)>>,
108 #[props(default)]
110 pub on_card_click: Option<EventHandler<String>>,
111 #[props(default = false)]
113 pub allow_add_column: bool,
114 #[props(default)]
116 pub on_add_column: Option<EventHandler<()>>,
117 #[props(default)]
119 pub on_add_card: Option<EventHandler<String>>,
120 #[props(default)]
122 pub style: Option<String>,
123 #[props(default = "280px".to_string())]
125 pub column_width: String,
126 #[props(default = "100%".to_string())]
128 pub height: String,
129 #[props(default = true)]
131 pub show_card_count: bool,
132 #[props(default = true)]
134 pub card_hover: bool,
135}
136
137#[component]
163pub fn Kanban(props: KanbanProps) -> Element {
164 let _theme = use_theme();
165 let height = props.height.clone();
166
167 let board_style = use_style(move |t| {
168 Style::new()
169 .flex()
170 .flex_row()
171 .gap(&t.spacing, "md")
172 .overflow_x_auto()
173 .p(&t.spacing, "md")
174 .h(&height)
175 .build()
176 });
177
178 let final_style = if let Some(custom) = &props.style {
179 format!("{} {}", board_style(), custom)
180 } else {
181 board_style()
182 };
183
184 let columns = props.columns.clone();
185 let column_width = props.column_width.clone();
186
187 rsx! {
188 div {
189 style: "{final_style}",
190
191 for column in columns {
192 KanbanColumnView {
193 key: "{column.id}",
194 column: column,
195 width: column_width.clone(),
196 on_card_click: props.on_card_click,
197 on_add_card: props.on_add_card,
198 show_card_count: props.show_card_count,
199 card_hover: props.card_hover,
200 }
201 }
202
203 if props.allow_add_column {
204 AddColumnButton {
205 on_add_column: props.on_add_column,
206 }
207 }
208 }
209 }
210}
211
212#[derive(Props, Clone, PartialEq)]
214pub struct KanbanColumnViewProps {
215 pub column: KanbanColumn,
216 pub width: String,
217 pub on_card_click: Option<EventHandler<String>>,
218 pub on_add_card: Option<EventHandler<String>>,
219 pub show_card_count: bool,
220 pub card_hover: bool,
221}
222
223#[component]
224pub fn KanbanColumnView(props: KanbanColumnViewProps) -> Element {
225 let theme = use_theme();
226 let width = props.width.clone();
227
228 let column_style = use_style(move |t| {
229 Style::new()
230 .flex()
231 .flex_col()
232 .rounded(&t.radius, "lg")
233 .bg(&t.colors.muted)
234 .min_w(&width)
235 .max_w(&width)
236 .h_full()
237 .build()
238 });
239
240 let header_style = use_style(|t| {
241 Style::new()
242 .flex()
243 .items_center()
244 .justify_between()
245 .p(&t.spacing, "md")
246 .border_bottom(1, &t.colors.border)
247 .build()
248 });
249
250 let title_style = use_style(|t| {
251 Style::new()
252 .flex()
253 .items_center()
254 .gap(&t.spacing, "sm")
255 .font_weight(600)
256 .text(&t.typography, "sm")
257 .build()
258 });
259
260 let cards_container_style = use_style(|t| {
261 Style::new()
262 .flex()
263 .flex_col()
264 .gap(&t.spacing, "sm")
265 .p(&t.spacing, "md")
266 .flex_grow(1)
267 .overflow_y_auto()
268 .build()
269 });
270
271 let column = props.column.clone();
272 let card_count = column.cards.len();
273 let accent_color = column.color.clone();
274 let column_id = column.id.clone();
275
276 rsx! {
277 div {
278 style: "{column_style}",
279
280 div {
282 style: "{header_style}",
283
284 div {
285 style: "{title_style}",
286
287 if let Some(color) = accent_color {
288 div {
289 style: "width: 12px; height: 12px; border-radius: 50%; background: {color}; flex-shrink: 0;",
290 }
291 }
292
293 span {
294 "{column.title}"
295 }
296
297 if props.show_card_count {
298 span {
299 style: "background: {theme.tokens.read().colors.muted_foreground.to_rgba()}20; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; padding: 2px 8px; border-radius: 9999px; font-size: 12px; font-weight: 500;",
300 "{card_count}"
301 }
302 }
303 }
304
305 Button {
307 variant: ButtonVariant::Ghost,
308 size: ButtonSize::Sm,
309 Icon {
310 name: "more-horizontal".to_string(),
311 size: IconSize::Small,
312 color: IconColor::Muted,
313 }
314 }
315 }
316
317 div {
319 style: "{cards_container_style}",
320
321 for card in column.cards {
322 KanbanCardView {
323 key: "{card.id}",
324 card: card,
325 on_click: props.on_card_click.clone(),
326 hover: props.card_hover,
327 }
328 }
329 }
330
331 if let Some(on_add) = props.on_add_card.clone() {
333 div {
334 style: "padding: 0 12px 12px 12px;",
335 Button {
336 variant: ButtonVariant::Ghost,
337 size: ButtonSize::Sm,
338 full_width: true,
339 onclick: move |_| on_add.call(column_id.clone()),
340
341 Icon {
342 name: "plus".to_string(),
343 size: IconSize::Small,
344 color: IconColor::Muted,
345 }
346 "Add card"
347 }
348 }
349 }
350 }
351 }
352}
353
354#[derive(Props, Clone, PartialEq)]
356pub struct KanbanCardViewProps {
357 pub card: KanbanCard,
358 pub on_click: Option<EventHandler<String>>,
359 pub hover: bool,
360}
361
362#[component]
363pub fn KanbanCardView(props: KanbanCardViewProps) -> Element {
364 let theme = use_theme();
365 let mut is_hovered = use_signal(|| false);
366
367 let card_style = use_style(move |t| {
368 let base = Style::new()
369 .bg(&t.colors.background)
370 .rounded(&t.radius, "md")
371 .border(1, &t.colors.border)
372 .p(&t.spacing, "md")
373 .cursor_pointer()
374 .transition("all 150ms ease");
375
376 if props.hover && is_hovered() {
377 base.shadow("0 4px 12px rgba(0, 0, 0, 0.1)")
378 .transform("translateY(-2px)")
379 } else {
380 base
381 }
382 .build()
383 });
384
385 let drag_handle_style = use_style(|t| {
386 Style::new()
387 .flex()
388 .items_center()
389 .justify_center()
390 .cursor("grab")
391 .text_color(&t.colors.muted_foreground)
392 .build()
393 });
394
395 let tags_style = use_style(|t| {
396 Style::new()
397 .flex()
398 .flex_wrap()
399 .gap_px(4)
400 .mt(&t.spacing, "sm")
401 .build()
402 });
403
404 let footer_style = use_style(|t| {
405 Style::new()
406 .flex()
407 .items_center()
408 .justify_between()
409 .mt(&t.spacing, "md")
410 .pt(&t.spacing, "sm")
411 .border_top(1, &t.colors.border)
412 .build()
413 });
414
415 let card = props.card.clone();
416 let card_id = card.id.clone();
417 let onclick_handler = props.on_click.clone();
418
419 rsx! {
420 div {
421 style: "{card_style}",
422 onmouseenter: move |_| is_hovered.set(true),
423 onmouseleave: move |_| is_hovered.set(false),
424 onclick: move |_| {
425 if let Some(handler) = &onclick_handler {
426 handler.call(card_id.clone());
427 }
428 },
429
430 div {
432 style: "display: flex; gap: 8px; align-items: flex-start;",
433
434 div {
436 style: "{drag_handle_style} flex-shrink: 0; padding-top: 2px;",
437 Icon {
438 name: "grip-vertical".to_string(),
439 size: IconSize::Small,
440 color: IconColor::Muted,
441 }
442 }
443
444 div {
446 style: "flex: 1; min-width: 0;",
447
448 Label {
449 size: TextSize::Small,
450 weight: crate::atoms::TextWeight::Medium,
451 "{card.title}"
452 }
453
454 if let Some(description) = card.description {
455 p {
456 style: "margin: 4px 0 0 0; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 13px; line-height: 1.4; overflow: hidden; display: -webkit-box; -webkit-line-clamp: 2; -webkit-box-orient: vertical;",
457 "{description}"
458 }
459 }
460 }
461 }
462
463 if !card.tags.is_empty() {
465 div {
466 style: "{tags_style}",
467
468 for tag in card.tags {
469 KanbanTag {
470 key: "{tag}",
471 label: tag,
472 }
473 }
474 }
475 }
476
477 if card.assignee.is_some() || card.due_date.is_some() {
479 div {
480 style: "{footer_style}",
481
482 div {
483 style: "display: flex; align-items: center; gap: 8px;",
484
485 if let Some(assignee) = card.assignee {
486 div {
487 style: "display: flex; align-items: center; gap: 4px;",
488
489 if assignee.starts_with("http") {
490 img {
492 src: "{assignee}",
493 style: "width: 24px; height: 24px; border-radius: 50%; object-fit: cover;",
494 alt: "Assignee",
495 }
496 } else {
497 div {
499 style: "width: 24px; height: 24px; border-radius: 50%; background: {theme.tokens.read().colors.primary.to_rgba()}; color: white; display: flex; align-items: center; justify-content: center; font-size: 11px; font-weight: 600;",
500 "{assignee.chars().take(2).collect::<String>().to_uppercase()}"
501 }
502 }
503 }
504 }
505 }
506
507 if let Some(due_date) = card.due_date {
508 div {
509 style: "display: flex; align-items: center; gap: 4px; color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 12px;",
510
511 Icon {
512 name: "calendar".to_string(),
513 size: IconSize::Small,
514 color: IconColor::Muted,
515 }
516 "{due_date}"
517 }
518 }
519 }
520 }
521 }
522 }
523}
524
525#[derive(Props, Clone, PartialEq)]
527pub struct KanbanTagProps {
528 pub label: String,
529}
530
531#[component]
532pub fn KanbanTag(props: KanbanTagProps) -> Element {
533 let color_hash = props
535 .label
536 .bytes()
537 .fold(0u32, |acc, b| acc.wrapping_add(b as u32));
538 let hue = color_hash % 360;
539 let bg_color = format!("hsl({}, 70%, 90%)", hue);
540 let text_color = format!("hsl({}, 70%, 30%)", hue);
541
542 rsx! {
543 span {
544 style: "display: inline-block; padding: 2px 8px; border-radius: 4px; font-size: 11px; font-weight: 500; background: {bg_color}; color: {text_color}; white-space: nowrap;",
545 "{props.label}"
546 }
547 }
548}
549
550#[derive(Props, Clone, PartialEq)]
552pub struct AddColumnButtonProps {
553 pub on_add_column: Option<EventHandler<()>>,
554}
555
556#[component]
557pub fn AddColumnButton(props: AddColumnButtonProps) -> Element {
558 let theme = use_theme();
559 let mut is_hovered = use_signal(|| false);
560
561 let button_style = use_style(move |t| {
562 let base = Style::new()
563 .flex()
564 .flex_col()
565 .items_center()
566 .justify_center()
567 .rounded(&t.radius, "lg")
568 .border(2, &t.colors.border)
569 .min_w_px(280)
570 .h_full()
571 .cursor_pointer()
572 .gap(&t.spacing, "sm")
573 .transition("all 150ms ease");
574
575 if is_hovered() {
576 base.border_color(&t.colors.primary).bg(&t.colors.muted)
577 } else {
578 base.border_style("dashed")
579 }
580 .build()
581 });
582
583 let onclick_handler = props.on_add_column.clone();
584
585 rsx! {
586 div {
587 style: "{button_style}",
588 onmouseenter: move |_| is_hovered.set(true),
589 onmouseleave: move |_| is_hovered.set(false),
590 onclick: move |_| {
591 if let Some(handler) = &onclick_handler {
592 handler.call(());
593 }
594 },
595
596 Icon {
597 name: "plus".to_string(),
598 size: IconSize::Large,
599 color: IconColor::Muted,
600 }
601
602 span {
603 style: "color: {theme.tokens.read().colors.muted_foreground.to_rgba()}; font-size: 14px; font-weight: 500;",
604 "Add column"
605 }
606 }
607 }
608}
609
610#[derive(Props, Clone, PartialEq)]
614pub struct SimpleKanbanProps {
615 pub columns: Vec<KanbanColumn>,
616 #[props(default)]
617 pub on_card_click: Option<EventHandler<String>>,
618 #[props(default = "100%".to_string())]
619 pub height: String,
620}
621
622#[component]
623pub fn SimpleKanban(props: SimpleKanbanProps) -> Element {
624 rsx! {
625 Kanban {
626 columns: props.columns.clone(),
627 on_card_click: props.on_card_click.clone(),
628 height: props.height.clone(),
629 show_card_count: true,
630 card_hover: true,
631 }
632 }
633}