feat: Refine filters and dropdown UI for task table
Update task table filters and dropdowns, including due-date options, layout tweaks, and style polish. Keep panel style refinement intact so layout stays stable.
This commit is contained in:
+2
-2
@@ -20,9 +20,9 @@ impl gpui::Render for App {
|
|||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
_cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) -> impl gpui::IntoElement {
|
) -> impl gpui::IntoElement {
|
||||||
let theme = _cx.theme();
|
let theme = cx.theme();
|
||||||
|
|
||||||
let content = div()
|
let content = div()
|
||||||
.flex()
|
.flex()
|
||||||
|
|||||||
@@ -3,17 +3,31 @@ use std::sync::Arc;
|
|||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
|
|
||||||
use crate::components::button::Button;
|
use crate::components::button::Button;
|
||||||
|
use crate::components::label::Label;
|
||||||
use crate::theme::ActiveTheme;
|
use crate::theme::ActiveTheme;
|
||||||
|
|
||||||
#[derive(Clone, Debug)]
|
#[derive(Clone, Debug)]
|
||||||
pub struct DropdownItem {
|
pub struct DropdownItem {
|
||||||
pub label: gpui::SharedString,
|
pub label: gpui::SharedString,
|
||||||
|
pub value: gpui::SharedString,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl DropdownItem {
|
impl DropdownItem {
|
||||||
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
|
pub fn new(label: impl Into<gpui::SharedString>) -> Self {
|
||||||
|
let label = label.into();
|
||||||
|
Self {
|
||||||
|
value: label.clone(),
|
||||||
|
label,
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn with_value(
|
||||||
|
label: impl Into<gpui::SharedString>,
|
||||||
|
value: impl Into<gpui::SharedString>,
|
||||||
|
) -> Self {
|
||||||
Self {
|
Self {
|
||||||
label: label.into(),
|
label: label.into(),
|
||||||
|
value: value.into(),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -27,6 +41,7 @@ pub struct Dropdown {
|
|||||||
disabled: bool,
|
disabled: bool,
|
||||||
loading: bool,
|
loading: bool,
|
||||||
placeholder: gpui::SharedString,
|
placeholder: gpui::SharedString,
|
||||||
|
label_prefix: Option<gpui::SharedString>,
|
||||||
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
|
on_select: Option<Arc<dyn Fn(usize, &DropdownItem, &mut gpui::Context<Self>) + Send + Sync>>,
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -41,6 +56,7 @@ impl Dropdown {
|
|||||||
disabled: false,
|
disabled: false,
|
||||||
loading: false,
|
loading: false,
|
||||||
placeholder: "Seleccionar".into(),
|
placeholder: "Seleccionar".into(),
|
||||||
|
label_prefix: None,
|
||||||
on_select: None,
|
on_select: None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -68,6 +84,11 @@ impl Dropdown {
|
|||||||
self
|
self
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn label_prefix(mut self, prefix: impl Into<gpui::SharedString>) -> Self {
|
||||||
|
self.label_prefix = Some(prefix.into());
|
||||||
|
self
|
||||||
|
}
|
||||||
|
|
||||||
pub fn selected_index(mut self, index: usize) -> Self {
|
pub fn selected_index(mut self, index: usize) -> Self {
|
||||||
self.selected_index = Some(index);
|
self.selected_index = Some(index);
|
||||||
self
|
self
|
||||||
@@ -108,6 +129,23 @@ impl Dropdown {
|
|||||||
.and_then(|index| self.items.get(index).map(|item| item.label.clone()))
|
.and_then(|index| self.items.get(index).map(|item| item.label.clone()))
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn set_selected_index(&mut self, index: usize, cx: &mut gpui::Context<Self>) {
|
||||||
|
if index < self.items.len() {
|
||||||
|
self.selected_index = Some(index);
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn set_items(&mut self, items: Vec<DropdownItem>, cx: &mut gpui::Context<Self>) {
|
||||||
|
self.items = items;
|
||||||
|
if let Some(selected) = self.selected_index {
|
||||||
|
if selected >= self.items.len() {
|
||||||
|
self.selected_index = None;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) {
|
fn toggle_open(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
if self.disabled || self.loading || self.items.is_empty() {
|
if self.disabled || self.loading || self.items.is_empty() {
|
||||||
return;
|
return;
|
||||||
@@ -168,15 +206,21 @@ impl Dropdown {
|
|||||||
let is_selected = self.selected_index == Some(index);
|
let is_selected = self.selected_index == Some(index);
|
||||||
let mut row = gpui::div()
|
let mut row = gpui::div()
|
||||||
.id(index)
|
.id(index)
|
||||||
|
.w_full()
|
||||||
.px_2()
|
.px_2()
|
||||||
.py_1()
|
.py_1()
|
||||||
|
.text_sm()
|
||||||
|
.whitespace_nowrap()
|
||||||
|
.bg(theme.background)
|
||||||
.text_color(if is_selected {
|
.text_color(if is_selected {
|
||||||
theme.selection_foreground
|
theme.selection_foreground
|
||||||
} else {
|
} else {
|
||||||
theme.foreground
|
theme.foreground
|
||||||
})
|
})
|
||||||
.when(is_selected, |el| el.bg(theme.selection))
|
.when(is_selected, |el| el.bg(theme.selection))
|
||||||
.hover(|s: gpui::StyleRefinement| s.bg(theme.selection))
|
.when(!is_disabled, |el| {
|
||||||
|
el.hover(|s: gpui::StyleRefinement| s.bg(theme.selection))
|
||||||
|
})
|
||||||
.child(item.label.clone());
|
.child(item.label.clone());
|
||||||
|
|
||||||
if is_disabled {
|
if is_disabled {
|
||||||
@@ -198,13 +242,17 @@ impl Dropdown {
|
|||||||
.absolute()
|
.absolute()
|
||||||
.top_full()
|
.top_full()
|
||||||
.left_0()
|
.left_0()
|
||||||
.right_0()
|
.min_w(gpui::rems(12.0))
|
||||||
|
.min_w_full()
|
||||||
.mt_1()
|
.mt_1()
|
||||||
|
.occlude()
|
||||||
|
.p_1()
|
||||||
.border_1()
|
.border_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
.bg(theme.panel)
|
.bg(theme.background)
|
||||||
.rounded_md()
|
.rounded_md()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
|
.shadow_lg()
|
||||||
.children(items)
|
.children(items)
|
||||||
.into_any_element()
|
.into_any_element()
|
||||||
}
|
}
|
||||||
@@ -216,28 +264,60 @@ impl gpui::Render for Dropdown {
|
|||||||
_window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) -> impl IntoElement {
|
) -> impl IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
let is_disabled = self.disabled || self.loading;
|
let is_disabled = self.disabled || self.loading;
|
||||||
let label = self
|
let disabled = is_disabled || self.items.is_empty();
|
||||||
|
let mut label = self
|
||||||
.selected_label()
|
.selected_label()
|
||||||
.unwrap_or_else(|| self.placeholder.clone());
|
.unwrap_or_else(|| self.placeholder.clone());
|
||||||
|
if let Some(prefix) = &self.label_prefix {
|
||||||
let mut trigger = if let Some(button) = self.button.clone() {
|
label = format!("{}: {}", prefix, label).into();
|
||||||
let mut button = button;
|
|
||||||
button.on_click = None;
|
|
||||||
button.label = Some(label.clone());
|
|
||||||
button.disabled(is_disabled).loading(self.loading)
|
|
||||||
} else {
|
|
||||||
Button::label(self.id.clone(), label)
|
|
||||||
.disabled(is_disabled)
|
|
||||||
.loading(self.loading)
|
|
||||||
};
|
|
||||||
|
|
||||||
if is_disabled || self.items.is_empty() {
|
|
||||||
trigger = trigger.disabled(true);
|
|
||||||
}
|
}
|
||||||
|
|
||||||
let mut trigger_wrap = gpui::div().child(trigger);
|
let arrow = "▾";
|
||||||
if !is_disabled && !self.items.is_empty() {
|
|
||||||
|
let trigger: gpui::AnyElement = if let Some(button) = self.button.clone() {
|
||||||
|
let mut button = button;
|
||||||
|
button.on_click = None;
|
||||||
|
button.label = Some(format!("{} {}", label, arrow).into());
|
||||||
|
button
|
||||||
|
.disabled(disabled)
|
||||||
|
.loading(self.loading)
|
||||||
|
.bg(theme.background)
|
||||||
|
.border_color(theme.border)
|
||||||
|
.text_color(theme.foreground)
|
||||||
|
.into_any_element()
|
||||||
|
} else {
|
||||||
|
let mut trigger = gpui::div()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.gap_2()
|
||||||
|
.px_3()
|
||||||
|
.py_2()
|
||||||
|
.rounded_md()
|
||||||
|
.border_1()
|
||||||
|
.border_color(theme.border)
|
||||||
|
.bg(theme.background)
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme.foreground)
|
||||||
|
.child(Label::new(label.clone()))
|
||||||
|
.child(Label::new(arrow).text_color(theme.muted));
|
||||||
|
|
||||||
|
if disabled {
|
||||||
|
trigger = trigger.text_color(theme.muted).cursor_not_allowed();
|
||||||
|
} else {
|
||||||
|
trigger = trigger
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|s: gpui::StyleRefinement| s.bg(theme.selection));
|
||||||
|
}
|
||||||
|
|
||||||
|
trigger.into_any_element()
|
||||||
|
};
|
||||||
|
|
||||||
|
let mut trigger_wrap = gpui::div()
|
||||||
|
.min_w(gpui::rems(12.0))
|
||||||
|
.child(trigger);
|
||||||
|
if !disabled {
|
||||||
trigger_wrap = trigger_wrap.on_mouse_down(
|
trigger_wrap = trigger_wrap.on_mouse_down(
|
||||||
gpui::MouseButton::Left,
|
gpui::MouseButton::Left,
|
||||||
cx.listener(Self::handle_trigger_mouse_down),
|
cx.listener(Self::handle_trigger_mouse_down),
|
||||||
|
|||||||
@@ -7,6 +7,7 @@ use gpui::{
|
|||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
use crate::components::icon::{Icon, IconSize};
|
use crate::components::icon::{Icon, IconSize};
|
||||||
|
use crate::components::label::Label;
|
||||||
use crate::theme::ActiveTheme;
|
use crate::theme::ActiveTheme;
|
||||||
|
|
||||||
fn darken(color: gpui::Rgba, amount: f32) -> gpui::Rgba {
|
fn darken(color: gpui::Rgba, amount: f32) -> gpui::Rgba {
|
||||||
@@ -264,7 +265,7 @@ impl RenderOnce for Button {
|
|||||||
}
|
}
|
||||||
|
|
||||||
if let Some(label) = self.label {
|
if let Some(label) = self.label {
|
||||||
base = base.child(label);
|
base = base.child(Label::new(label).text_color(fg));
|
||||||
}
|
}
|
||||||
|
|
||||||
if self.loading {
|
if self.loading {
|
||||||
|
|||||||
@@ -78,7 +78,8 @@ impl gpui::RenderOnce for Panel {
|
|||||||
|
|
||||||
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
|
let children: Vec<gpui::AnyElement> = self.content.drain(..).collect();
|
||||||
|
|
||||||
gpui::div()
|
let mut base = gpui::div()
|
||||||
|
.relative()
|
||||||
.size_full()
|
.size_full()
|
||||||
.bg(theme.panel)
|
.bg(theme.panel)
|
||||||
.border(gpui::px(self.border))
|
.border(gpui::px(self.border))
|
||||||
@@ -87,6 +88,10 @@ impl gpui::RenderOnce for Panel {
|
|||||||
.p(gpui::px(self.padding))
|
.p(gpui::px(self.padding))
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.children(header)
|
.children(header)
|
||||||
.children(children)
|
.children(children);
|
||||||
|
|
||||||
|
base.style().refine(&self.style);
|
||||||
|
|
||||||
|
base
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
+73
-10
@@ -1,5 +1,7 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
|
use chrono::NaiveDate;
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
pub struct FilterState {
|
pub struct FilterState {
|
||||||
pub selected_project: Option<String>,
|
pub selected_project: Option<String>,
|
||||||
@@ -45,6 +47,17 @@ impl StatusFilter {
|
|||||||
Self::Deleted,
|
Self::Deleted,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: usize) -> Self {
|
||||||
|
Self::all_variants().get(index).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_index(&self) -> usize {
|
||||||
|
Self::all_variants()
|
||||||
|
.iter()
|
||||||
|
.position(|v| v == self)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -76,6 +89,17 @@ impl PriorityFilter {
|
|||||||
pub fn all_variants() -> &'static [Self] {
|
pub fn all_variants() -> &'static [Self] {
|
||||||
&[Self::All, Self::High, Self::Medium, Self::Low, Self::None]
|
&[Self::All, Self::High, Self::Medium, Self::Low, Self::None]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: usize) -> Self {
|
||||||
|
Self::all_variants().get(index).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_index(&self) -> usize {
|
||||||
|
Self::all_variants()
|
||||||
|
.iter()
|
||||||
|
.position(|v| v == self)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
#[derive(Debug, Clone, Copy, PartialEq, Eq)]
|
||||||
@@ -85,6 +109,7 @@ pub enum DueFilter {
|
|||||||
Today,
|
Today,
|
||||||
ThisWeek,
|
ThisWeek,
|
||||||
NoDate,
|
NoDate,
|
||||||
|
OnDate(NaiveDate),
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for DueFilter {
|
impl Default for DueFilter {
|
||||||
@@ -94,16 +119,6 @@ impl Default for DueFilter {
|
|||||||
}
|
}
|
||||||
|
|
||||||
impl DueFilter {
|
impl DueFilter {
|
||||||
pub fn as_str(&self) -> &'static str {
|
|
||||||
match self {
|
|
||||||
Self::All => "All",
|
|
||||||
Self::Overdue => "Overdue",
|
|
||||||
Self::Today => "Today",
|
|
||||||
Self::ThisWeek => "This Week",
|
|
||||||
Self::NoDate => "No Date",
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
pub fn all_variants() -> &'static [Self] {
|
pub fn all_variants() -> &'static [Self] {
|
||||||
&[
|
&[
|
||||||
Self::All,
|
Self::All,
|
||||||
@@ -113,6 +128,54 @@ impl DueFilter {
|
|||||||
Self::NoDate,
|
Self::NoDate,
|
||||||
]
|
]
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn from_index(index: usize) -> Self {
|
||||||
|
Self::all_variants().get(index).copied().unwrap_or_default()
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn to_index(&self) -> usize {
|
||||||
|
Self::all_variants()
|
||||||
|
.iter()
|
||||||
|
.position(|v| v == self)
|
||||||
|
.unwrap_or(0)
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn label(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::All => "All".to_string(),
|
||||||
|
Self::Overdue => "Overdue".to_string(),
|
||||||
|
Self::Today => "Today".to_string(),
|
||||||
|
Self::ThisWeek => "This Week".to_string(),
|
||||||
|
Self::NoDate => "No Date".to_string(),
|
||||||
|
Self::OnDate(date) => date.format("%d-%m-%Y").to_string(),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn value_key(&self) -> String {
|
||||||
|
match self {
|
||||||
|
Self::All => "all".to_string(),
|
||||||
|
Self::Overdue => "overdue".to_string(),
|
||||||
|
Self::Today => "today".to_string(),
|
||||||
|
Self::ThisWeek => "this_week".to_string(),
|
||||||
|
Self::NoDate => "none".to_string(),
|
||||||
|
Self::OnDate(date) => format!("date:{}", date.format("%Y-%m-%d")),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
pub fn from_value(value: &str) -> Option<Self> {
|
||||||
|
match value {
|
||||||
|
"all" => Some(Self::All),
|
||||||
|
"overdue" => Some(Self::Overdue),
|
||||||
|
"today" => Some(Self::Today),
|
||||||
|
"this_week" => Some(Self::ThisWeek),
|
||||||
|
"none" => Some(Self::NoDate),
|
||||||
|
_ => value.strip_prefix("date:").and_then(|date| {
|
||||||
|
NaiveDate::parse_from_str(date, "%Y-%m-%d")
|
||||||
|
.ok()
|
||||||
|
.map(Self::OnDate)
|
||||||
|
}),
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl FilterState {
|
impl FilterState {
|
||||||
|
|||||||
+8
-1
@@ -1,6 +1,6 @@
|
|||||||
use std::collections::HashSet;
|
use std::collections::HashSet;
|
||||||
|
|
||||||
use chrono::{DateTime, Utc};
|
use chrono::{DateTime, NaiveDate, Utc};
|
||||||
|
|
||||||
use super::model::{Task, TaskPriority, TaskStatus};
|
use super::model::{Task, TaskPriority, TaskStatus};
|
||||||
use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter};
|
use crate::models::{DueFilter, FilterState, PriorityFilter, StatusFilter};
|
||||||
@@ -20,6 +20,7 @@ pub enum DueDateFilter {
|
|||||||
NoDate,
|
NoDate,
|
||||||
Before(DateTime<Utc>),
|
Before(DateTime<Utc>),
|
||||||
After(DateTime<Utc>),
|
After(DateTime<Utc>),
|
||||||
|
OnDate(NaiveDate),
|
||||||
}
|
}
|
||||||
|
|
||||||
#[derive(Debug, Clone, Default)]
|
#[derive(Debug, Clone, Default)]
|
||||||
@@ -112,6 +113,7 @@ impl From<&FilterState> for TaskFilter {
|
|||||||
DueFilter::Today => Some(DueDateFilter::Today),
|
DueFilter::Today => Some(DueDateFilter::Today),
|
||||||
DueFilter::ThisWeek => Some(DueDateFilter::ThisWeek),
|
DueFilter::ThisWeek => Some(DueDateFilter::ThisWeek),
|
||||||
DueFilter::NoDate => Some(DueDateFilter::NoDate),
|
DueFilter::NoDate => Some(DueDateFilter::NoDate),
|
||||||
|
DueFilter::OnDate(date) => Some(DueDateFilter::OnDate(date)),
|
||||||
};
|
};
|
||||||
|
|
||||||
if !state.search_text.is_empty() {
|
if !state.search_text.is_empty() {
|
||||||
@@ -228,6 +230,11 @@ impl TaskFilter {
|
|||||||
return false;
|
return false;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
DueDateFilter::OnDate(date) => {
|
||||||
|
if !task.due.map(|d| d.date_naive() == *date).unwrap_or(false) {
|
||||||
|
return false;
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
|||||||
+61
-2
@@ -71,6 +71,22 @@ impl Sidebar {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_clear_tags(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.filter_state.update(cx, |filter, cx| {
|
||||||
|
filter.active_tags.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn handle_clear_project(&mut self, _window: &mut Window, cx: &mut Context<Self>) {
|
||||||
|
self.filter_state.update(cx, |filter, cx| {
|
||||||
|
filter.selected_project = None;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
|
||||||
fn render_projects(&self, cx: &mut Context<Self>) -> Vec<Div> {
|
fn render_projects(&self, cx: &mut Context<Self>) -> Vec<Div> {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
let filter = self.filter_state.read(cx);
|
let filter = self.filter_state.read(cx);
|
||||||
@@ -259,6 +275,9 @@ impl Render for Sidebar {
|
|||||||
let theme = cx.theme().clone();
|
let theme = cx.theme().clone();
|
||||||
let projects = self.render_projects(cx);
|
let projects = self.render_projects(cx);
|
||||||
let tags = self.render_tags(cx);
|
let tags = self.render_tags(cx);
|
||||||
|
let filter = self.filter_state.read(cx);
|
||||||
|
let has_project = filter.selected_project.is_some();
|
||||||
|
let has_tags = !filter.active_tags.is_empty();
|
||||||
|
|
||||||
Panel::new("Sidebar").border(1.0).padding(0.0).child(
|
Panel::new("Sidebar").border(1.0).padding(0.0).child(
|
||||||
div()
|
div()
|
||||||
@@ -276,6 +295,9 @@ impl Render for Sidebar {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
@@ -285,7 +307,24 @@ impl Render for Sidebar {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::BOLD)
|
.font_weight(gpui::FontWeight::BOLD)
|
||||||
.text_color(theme.foreground),
|
.text_color(theme.foreground),
|
||||||
),
|
)
|
||||||
|
.when(has_project, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.id("clear-project")
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|s| s.text_color(theme.accent))
|
||||||
|
.on_mouse_down(
|
||||||
|
gpui::MouseButton::Left,
|
||||||
|
cx.listener(|view, _, window, cx| {
|
||||||
|
view.handle_clear_project(window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child("Clear"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
@@ -308,6 +347,9 @@ impl Render for Sidebar {
|
|||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
.flex_shrink_0()
|
.flex_shrink_0()
|
||||||
|
.flex()
|
||||||
|
.items_center()
|
||||||
|
.justify_between()
|
||||||
.px_3()
|
.px_3()
|
||||||
.py_2()
|
.py_2()
|
||||||
.border_b_1()
|
.border_b_1()
|
||||||
@@ -317,7 +359,24 @@ impl Render for Sidebar {
|
|||||||
.text_sm()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::BOLD)
|
.font_weight(gpui::FontWeight::BOLD)
|
||||||
.text_color(theme.foreground),
|
.text_color(theme.foreground),
|
||||||
),
|
)
|
||||||
|
.when(has_tags, |this| {
|
||||||
|
this.child(
|
||||||
|
div()
|
||||||
|
.id("clear-tags")
|
||||||
|
.text_xs()
|
||||||
|
.text_color(theme.muted)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|s| s.text_color(theme.accent))
|
||||||
|
.on_mouse_down(
|
||||||
|
gpui::MouseButton::Left,
|
||||||
|
cx.listener(|view, _, window, cx| {
|
||||||
|
view.handle_clear_tags(window, cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child("Clear"),
|
||||||
|
)
|
||||||
|
}),
|
||||||
)
|
)
|
||||||
.child(
|
.child(
|
||||||
div()
|
div()
|
||||||
|
|||||||
+312
-17
@@ -1,10 +1,16 @@
|
|||||||
use std::cmp::Ordering;
|
use std::cmp::Ordering;
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
use std::sync::Arc;
|
||||||
|
|
||||||
use gpui::prelude::*;
|
use gpui::prelude::*;
|
||||||
|
|
||||||
use crate::{
|
use crate::{
|
||||||
components,
|
components::{
|
||||||
models::FilterState,
|
self,
|
||||||
|
button::{Dropdown, DropdownItem},
|
||||||
|
input::Input,
|
||||||
|
},
|
||||||
|
models::{DueFilter, FilterState, PriorityFilter, StatusFilter},
|
||||||
task::{self, TaskFilter, TaskService},
|
task::{self, TaskFilter, TaskService},
|
||||||
theme::{self, ActiveTheme},
|
theme::{self, ActiveTheme},
|
||||||
};
|
};
|
||||||
@@ -216,14 +222,93 @@ pub struct TaskTable {
|
|||||||
selected_page_idx: Option<usize>,
|
selected_page_idx: Option<usize>,
|
||||||
selected_global_idx: Option<usize>,
|
selected_global_idx: Option<usize>,
|
||||||
need_reload: bool,
|
need_reload: bool,
|
||||||
|
filter_bar_height: gpui::Pixels,
|
||||||
|
search_input: gpui::Entity<Input>,
|
||||||
|
status_dropdown: gpui::Entity<Dropdown>,
|
||||||
|
priority_dropdown: gpui::Entity<Dropdown>,
|
||||||
|
due_dropdown: gpui::Entity<Dropdown>,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl TaskTable {
|
impl TaskTable {
|
||||||
pub fn new(
|
pub fn new(
|
||||||
id: impl Into<gpui::ElementId>,
|
id: impl Into<gpui::ElementId>,
|
||||||
filter_state: gpui::Entity<FilterState>,
|
filter_state: gpui::Entity<FilterState>,
|
||||||
_cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) -> Self {
|
) -> Self {
|
||||||
|
let search_input = {
|
||||||
|
let filter_state = filter_state.clone();
|
||||||
|
cx.new(|cx| {
|
||||||
|
Input::new("filter-search", cx, "Search...").with_on_change(Arc::new(
|
||||||
|
move |value: &str, cx: &mut gpui::Context<Input>| {
|
||||||
|
cx.update_entity(&filter_state, |filter, cx| {
|
||||||
|
filter.search_text = value.to_string();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
},
|
||||||
|
))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let status_items = StatusFilter::all_variants()
|
||||||
|
.iter()
|
||||||
|
.map(|status| DropdownItem::new(status.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let status_dropdown = {
|
||||||
|
let filter_state = filter_state.clone();
|
||||||
|
cx.new(|_cx| {
|
||||||
|
Dropdown::new("filter-status")
|
||||||
|
.items(status_items)
|
||||||
|
.label_prefix("Status")
|
||||||
|
.selected_index(StatusFilter::default().to_index())
|
||||||
|
.on_select(Arc::new(move |index, _item, cx| {
|
||||||
|
let selected = StatusFilter::from_index(index);
|
||||||
|
cx.update_entity(&filter_state, |filter, cx| {
|
||||||
|
filter.status_filter = selected;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let priority_items = PriorityFilter::all_variants()
|
||||||
|
.iter()
|
||||||
|
.map(|priority| DropdownItem::new(priority.as_str()))
|
||||||
|
.collect::<Vec<_>>();
|
||||||
|
let priority_dropdown = {
|
||||||
|
let filter_state = filter_state.clone();
|
||||||
|
cx.new(|_cx| {
|
||||||
|
Dropdown::new("filter-priority")
|
||||||
|
.items(priority_items)
|
||||||
|
.label_prefix("Priority")
|
||||||
|
.selected_index(PriorityFilter::default().to_index())
|
||||||
|
.on_select(Arc::new(move |index, _item, cx| {
|
||||||
|
let selected = PriorityFilter::from_index(index);
|
||||||
|
cx.update_entity(&filter_state, |filter, cx| {
|
||||||
|
filter.priority_filter = selected;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
|
let due_dropdown = {
|
||||||
|
let filter_state = filter_state.clone();
|
||||||
|
cx.new(|_cx| {
|
||||||
|
Dropdown::new("filter-due")
|
||||||
|
.items(vec![DropdownItem::with_value("All", "all")])
|
||||||
|
.label_prefix("Due")
|
||||||
|
.selected_index(0)
|
||||||
|
.on_select(Arc::new(move |_index, item, cx| {
|
||||||
|
let selected =
|
||||||
|
DueFilter::from_value(item.value.as_ref()).unwrap_or(DueFilter::All);
|
||||||
|
cx.update_entity(&filter_state, |filter, cx| {
|
||||||
|
filter.due_filter = selected;
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}))
|
||||||
|
})
|
||||||
|
};
|
||||||
|
|
||||||
Self {
|
Self {
|
||||||
id: id.into(),
|
id: id.into(),
|
||||||
filter_state,
|
filter_state,
|
||||||
@@ -234,6 +319,11 @@ impl TaskTable {
|
|||||||
selected_page_idx: None,
|
selected_page_idx: None,
|
||||||
selected_global_idx: None,
|
selected_global_idx: None,
|
||||||
need_reload: true,
|
need_reload: true,
|
||||||
|
filter_bar_height: gpui::px(52.0),
|
||||||
|
search_input,
|
||||||
|
status_dropdown,
|
||||||
|
priority_dropdown,
|
||||||
|
due_dropdown,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -297,15 +387,18 @@ impl TaskTable {
|
|||||||
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
|
pub fn reload_tasks(&mut self, task_service: &mut TaskService, cx: &mut gpui::Context<Self>) {
|
||||||
let filter_state = self.filter_state.read(cx).clone();
|
let filter_state = self.filter_state.read(cx).clone();
|
||||||
|
|
||||||
let task_filter = TaskFilter::from(&filter_state);
|
let all_tasks = task_service.get_all_tasks().unwrap_or_else(|e| {
|
||||||
|
log::error!("[TaskTable] Failed to load tasks: {}", e);
|
||||||
let filtered_tasks = task_service
|
|
||||||
.get_filtered_tasks(&task_filter)
|
|
||||||
.unwrap_or_else(|e| {
|
|
||||||
log::error!("[TaskTable] Failed to load filtered tasks: {}", e);
|
|
||||||
vec![]
|
vec![]
|
||||||
});
|
});
|
||||||
|
|
||||||
|
let task_filter = TaskFilter::from(&filter_state);
|
||||||
|
let mut due_filter = task_filter.clone();
|
||||||
|
due_filter.due_filter = None;
|
||||||
|
|
||||||
|
let filtered_tasks = task_filter.apply(&all_tasks);
|
||||||
|
let due_tasks = due_filter.apply(&all_tasks);
|
||||||
|
|
||||||
self.cached_tasks = filtered_tasks;
|
self.cached_tasks = filtered_tasks;
|
||||||
self.apply_sort();
|
self.apply_sort();
|
||||||
self.pagination.total_items(self.cached_tasks.len());
|
self.pagination.total_items(self.cached_tasks.len());
|
||||||
@@ -314,6 +407,7 @@ impl TaskTable {
|
|||||||
self.selected_page_idx = None;
|
self.selected_page_idx = None;
|
||||||
|
|
||||||
self.recalculate_rows();
|
self.recalculate_rows();
|
||||||
|
self.sync_filter_dropdowns(&due_tasks, &filter_state, cx);
|
||||||
|
|
||||||
self.need_reload = false;
|
self.need_reload = false;
|
||||||
|
|
||||||
@@ -324,6 +418,121 @@ impl TaskTable {
|
|||||||
self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect();
|
self.cached_rows = self.cached_tasks.iter().map(TaskRow::from).collect();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn sync_filter_dropdowns(
|
||||||
|
&mut self,
|
||||||
|
due_tasks: &[task::Task],
|
||||||
|
filter_state: &FilterState,
|
||||||
|
cx: &mut gpui::Context<Self>,
|
||||||
|
) {
|
||||||
|
let status_index = filter_state.status_filter.to_index();
|
||||||
|
self.status_dropdown.update(cx, |dropdown, cx| {
|
||||||
|
dropdown.set_selected_index(status_index, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let priority_index = filter_state.priority_filter.to_index();
|
||||||
|
self.priority_dropdown.update(cx, |dropdown, cx| {
|
||||||
|
dropdown.set_selected_index(priority_index, cx);
|
||||||
|
});
|
||||||
|
|
||||||
|
let mut due_items = Self::build_due_items(due_tasks);
|
||||||
|
let selected_key = filter_state.due_filter.value_key();
|
||||||
|
let mut selected_index = due_items
|
||||||
|
.iter()
|
||||||
|
.position(|item| item.value.as_ref() == selected_key);
|
||||||
|
|
||||||
|
if selected_index.is_none() && filter_state.due_filter != DueFilter::All {
|
||||||
|
if let Some(item) = Self::due_item_from_filter(&filter_state.due_filter) {
|
||||||
|
due_items.push(item);
|
||||||
|
selected_index = Some(due_items.len() - 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
self.due_dropdown.update(cx, |dropdown, cx| {
|
||||||
|
dropdown.set_items(due_items, cx);
|
||||||
|
if let Some(index) = selected_index {
|
||||||
|
dropdown.set_selected_index(index, cx);
|
||||||
|
}
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn build_due_items(tasks: &[task::Task]) -> Vec<DropdownItem> {
|
||||||
|
let now = chrono::Utc::now();
|
||||||
|
let today = now.date_naive();
|
||||||
|
let week_end = now + chrono::Duration::days(7);
|
||||||
|
|
||||||
|
let mut dates = BTreeSet::new();
|
||||||
|
let mut has_no_date = false;
|
||||||
|
let mut has_overdue = false;
|
||||||
|
let mut has_today = false;
|
||||||
|
let mut has_this_week = false;
|
||||||
|
|
||||||
|
for task in tasks {
|
||||||
|
match task.due {
|
||||||
|
None => {
|
||||||
|
has_no_date = true;
|
||||||
|
}
|
||||||
|
Some(due) => {
|
||||||
|
let date = due.date_naive();
|
||||||
|
dates.insert(date);
|
||||||
|
if due < now {
|
||||||
|
has_overdue = true;
|
||||||
|
}
|
||||||
|
if date == today {
|
||||||
|
has_today = true;
|
||||||
|
}
|
||||||
|
if due >= now && due <= week_end {
|
||||||
|
has_this_week = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut items = vec![DropdownItem::with_value("All", "all")];
|
||||||
|
if has_no_date {
|
||||||
|
items.push(DropdownItem::with_value("No Date", "none"));
|
||||||
|
}
|
||||||
|
if has_overdue {
|
||||||
|
items.push(DropdownItem::with_value("Overdue", "overdue"));
|
||||||
|
}
|
||||||
|
if has_today {
|
||||||
|
items.push(DropdownItem::with_value("Today", "today"));
|
||||||
|
}
|
||||||
|
if has_this_week {
|
||||||
|
items.push(DropdownItem::with_value("This Week", "this_week"));
|
||||||
|
}
|
||||||
|
|
||||||
|
for date in dates {
|
||||||
|
if date == today {
|
||||||
|
continue;
|
||||||
|
}
|
||||||
|
let label = Self::format_due_label(date);
|
||||||
|
let value = format!("date:{}", date.format("%Y-%m-%d"));
|
||||||
|
items.push(DropdownItem::with_value(label, value));
|
||||||
|
}
|
||||||
|
|
||||||
|
items
|
||||||
|
}
|
||||||
|
|
||||||
|
fn due_item_from_filter(filter: &DueFilter) -> Option<DropdownItem> {
|
||||||
|
match filter {
|
||||||
|
DueFilter::All => None,
|
||||||
|
DueFilter::OnDate(date) => Some(DropdownItem::with_value(
|
||||||
|
Self::format_due_label(*date),
|
||||||
|
filter.value_key(),
|
||||||
|
)),
|
||||||
|
_ => Some(DropdownItem::with_value(filter.label(), filter.value_key())),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn format_due_label(date: chrono::NaiveDate) -> String {
|
||||||
|
let today = chrono::Utc::now().date_naive();
|
||||||
|
if date == today {
|
||||||
|
"Today".to_string()
|
||||||
|
} else {
|
||||||
|
date.format("%d-%m-%Y").to_string()
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
|
fn priority_color(&self, row: &TaskRow, cx: &gpui::Context<Self>) -> theme::Color {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
|
||||||
@@ -376,6 +585,74 @@ impl TaskTable {
|
|||||||
cx.notify();
|
cx.notify();
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn handle_clear_filters(&mut self, cx: &mut gpui::Context<Self>) {
|
||||||
|
self.search_input.update(cx, |input, cx| {
|
||||||
|
input.clear(cx);
|
||||||
|
});
|
||||||
|
self.filter_state.update(cx, |filter, cx| {
|
||||||
|
filter.clear();
|
||||||
|
cx.notify();
|
||||||
|
});
|
||||||
|
}
|
||||||
|
|
||||||
|
fn render_filter_bar(&self, cx: &gpui::Context<Self>) -> impl gpui::IntoElement {
|
||||||
|
let theme = cx.theme();
|
||||||
|
let filter = self.filter_state.read(cx);
|
||||||
|
let has_filters = filter.has_active_filters();
|
||||||
|
let view = cx.entity().clone();
|
||||||
|
|
||||||
|
let bar = gpui::div()
|
||||||
|
.id("filter-bar")
|
||||||
|
.flex()
|
||||||
|
.flex_wrap()
|
||||||
|
.gap_2()
|
||||||
|
.items_center()
|
||||||
|
.px_4()
|
||||||
|
.py_2()
|
||||||
|
.bg(theme.panel)
|
||||||
|
.child(
|
||||||
|
gpui::div()
|
||||||
|
.w(gpui::rems(14.0))
|
||||||
|
.child(self.search_input.clone()),
|
||||||
|
)
|
||||||
|
.child(self.status_dropdown.clone())
|
||||||
|
.child(self.priority_dropdown.clone())
|
||||||
|
.child(self.due_dropdown.clone())
|
||||||
|
.when(has_filters, |this| {
|
||||||
|
this.child(
|
||||||
|
gpui::div()
|
||||||
|
.id("clear-all-filters")
|
||||||
|
.px_2()
|
||||||
|
.py_1()
|
||||||
|
.rounded_md()
|
||||||
|
.text_sm()
|
||||||
|
.text_color(theme.error)
|
||||||
|
.cursor_pointer()
|
||||||
|
.hover(|s| s.bg(theme.selection))
|
||||||
|
.on_mouse_down(
|
||||||
|
gpui::MouseButton::Left,
|
||||||
|
cx.listener(|table, _, _, cx| {
|
||||||
|
table.handle_clear_filters(cx);
|
||||||
|
}),
|
||||||
|
)
|
||||||
|
.child("✕ Clear"),
|
||||||
|
)
|
||||||
|
});
|
||||||
|
|
||||||
|
gpui::div().child(bar).on_children_prepainted(move |bounds, _, cx| {
|
||||||
|
let Some(bounds) = bounds.first() else {
|
||||||
|
return;
|
||||||
|
};
|
||||||
|
let height = bounds.size.height;
|
||||||
|
cx.update_entity(&view, |table, cx| {
|
||||||
|
if table.filter_bar_height != height {
|
||||||
|
table.filter_bar_height = height;
|
||||||
|
cx.notify();
|
||||||
|
}
|
||||||
|
});
|
||||||
|
})
|
||||||
|
}
|
||||||
|
|
||||||
fn render_header_column(
|
fn render_header_column(
|
||||||
&self,
|
&self,
|
||||||
column: SortColumn,
|
column: SortColumn,
|
||||||
@@ -424,9 +701,10 @@ impl TaskTable {
|
|||||||
.items_center()
|
.items_center()
|
||||||
.gap_2()
|
.gap_2()
|
||||||
.px_4()
|
.px_4()
|
||||||
.py_1()
|
.py_2()
|
||||||
.border_b_1()
|
.border_t_1()
|
||||||
.border_color(theme.border)
|
.border_color(theme.border)
|
||||||
|
.border_b_1()
|
||||||
.bg(theme.panel)
|
.bg(theme.panel)
|
||||||
.text_sm()
|
.text_sm()
|
||||||
.font_weight(gpui::FontWeight::MEDIUM)
|
.font_weight(gpui::FontWeight::MEDIUM)
|
||||||
@@ -632,12 +910,12 @@ impl TaskTable {
|
|||||||
impl gpui::Render for TaskTable {
|
impl gpui::Render for TaskTable {
|
||||||
fn render(
|
fn render(
|
||||||
&mut self,
|
&mut self,
|
||||||
window: &mut gpui::Window,
|
_window: &mut gpui::Window,
|
||||||
cx: &mut gpui::Context<Self>,
|
cx: &mut gpui::Context<Self>,
|
||||||
) -> impl gpui::IntoElement {
|
) -> impl gpui::IntoElement {
|
||||||
let theme = cx.theme();
|
let theme = cx.theme();
|
||||||
|
|
||||||
let panel = components::panel::Panel::new(self.id.clone());
|
let panel = components::panel::Panel::new(self.id.clone()).flex().flex_col();
|
||||||
|
|
||||||
if self.need_reload {
|
if self.need_reload {
|
||||||
return panel
|
return panel
|
||||||
@@ -656,13 +934,19 @@ impl gpui::Render for TaskTable {
|
|||||||
.map(|(index, row)| self.render_row(index, row, cx))
|
.map(|(index, row)| self.render_row(index, row, cx))
|
||||||
.collect();
|
.collect();
|
||||||
|
|
||||||
panel
|
let filter_bar = self.render_filter_bar(cx);
|
||||||
|
let header = self.render_header(cx);
|
||||||
|
let footer = self.render_footer(cx);
|
||||||
|
|
||||||
|
let body = gpui::div()
|
||||||
.flex()
|
.flex()
|
||||||
.flex_col()
|
.flex_col()
|
||||||
.size_full()
|
.flex_1()
|
||||||
|
.min_h_0()
|
||||||
.overflow_hidden()
|
.overflow_hidden()
|
||||||
.bg(theme.background)
|
.bg(theme.background)
|
||||||
.child(self.render_header(cx))
|
.child(gpui::div().h(self.filter_bar_height))
|
||||||
|
.child(header)
|
||||||
.child(
|
.child(
|
||||||
gpui::div()
|
gpui::div()
|
||||||
.id("task-table-content")
|
.id("task-table-content")
|
||||||
@@ -671,6 +955,17 @@ impl gpui::Render for TaskTable {
|
|||||||
.overflow_y_scroll()
|
.overflow_y_scroll()
|
||||||
.child(gpui::div().flex().flex_col().children(rows)),
|
.child(gpui::div().flex().flex_col().children(rows)),
|
||||||
)
|
)
|
||||||
.child(self.render_footer(cx))
|
.child(footer);
|
||||||
|
|
||||||
|
panel
|
||||||
|
.child(body)
|
||||||
|
.child(
|
||||||
|
gpui::div()
|
||||||
|
.absolute()
|
||||||
|
.top_0()
|
||||||
|
.left_0()
|
||||||
|
.right_0()
|
||||||
|
.child(filter_bar),
|
||||||
|
)
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user