Multi-select tasks with Shift+Arrows and bulk actions
- Shift+Up/Down extends selection in task list - Enter opens BulkAction popup with 3 options: 1. Mark as completed 2. Set due date to Today 3. Move to new list (creates list, copies tasks, deletes originals) - Plain Up/Down clears selection, Escape clears it - Selected items highlighted in Yellow
This commit is contained in:
+160
-2
@@ -1,3 +1,4 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
use std::sync::mpsc as std_mpsc;
|
use std::sync::mpsc as std_mpsc;
|
||||||
use std::sync::Arc;
|
use std::sync::Arc;
|
||||||
|
|
||||||
@@ -51,6 +52,8 @@ pub struct App {
|
|||||||
editing_task_id: Option<String>,
|
editing_task_id: Option<String>,
|
||||||
pending_date_key: bool,
|
pending_date_key: bool,
|
||||||
pending_new_key: bool,
|
pending_new_key: bool,
|
||||||
|
pending_bulk_move: bool,
|
||||||
|
pub selected_tasks: BTreeSet<usize>,
|
||||||
auth_tx: std_mpsc::Sender<AuthEvent>,
|
auth_tx: std_mpsc::Sender<AuthEvent>,
|
||||||
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
auth_rx: std_mpsc::Receiver<AuthEvent>,
|
||||||
sync_tx: mpsc::Sender<SyncCommand>,
|
sync_tx: mpsc::Sender<SyncCommand>,
|
||||||
@@ -122,6 +125,8 @@ impl App {
|
|||||||
editing_task_id: None,
|
editing_task_id: None,
|
||||||
pending_date_key: false,
|
pending_date_key: false,
|
||||||
pending_new_key: false,
|
pending_new_key: false,
|
||||||
|
pending_bulk_move: false,
|
||||||
|
selected_tasks: BTreeSet::new(),
|
||||||
auth_tx,
|
auth_tx,
|
||||||
auth_rx,
|
auth_rx,
|
||||||
sync_tx,
|
sync_tx,
|
||||||
@@ -333,6 +338,22 @@ impl App {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task > 0 {
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.selected_task -= 1;
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
KeyCode::Down if key.modifiers.contains(KeyModifiers::SHIFT) => {
|
||||||
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() && self.selected_task + 1 < self.tasks.len() {
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.selected_task += 1;
|
||||||
|
self.selected_tasks.insert(self.selected_task);
|
||||||
|
self.task_list_scroll += 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
KeyCode::Up if key.modifiers.contains(KeyModifiers::ALT) => {
|
||||||
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||||
self.reorder_task(-1);
|
self.reorder_task(-1);
|
||||||
@@ -346,6 +367,7 @@ impl App {
|
|||||||
KeyCode::Up => match self.focus {
|
KeyCode::Up => match self.focus {
|
||||||
Focus::TaskList => {
|
Focus::TaskList => {
|
||||||
if self.selected_task > 0 {
|
if self.selected_task > 0 {
|
||||||
|
self.clear_selection();
|
||||||
self.selected_task -= 1;
|
self.selected_task -= 1;
|
||||||
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
self.task_list_scroll = self.task_list_scroll.saturating_sub(1);
|
||||||
}
|
}
|
||||||
@@ -361,6 +383,7 @@ impl App {
|
|||||||
KeyCode::Down => match self.focus {
|
KeyCode::Down => match self.focus {
|
||||||
Focus::TaskList => {
|
Focus::TaskList => {
|
||||||
if self.selected_task + 1 < self.tasks.len() {
|
if self.selected_task + 1 < self.tasks.len() {
|
||||||
|
self.clear_selection();
|
||||||
self.selected_task += 1;
|
self.selected_task += 1;
|
||||||
self.task_list_scroll += 1;
|
self.task_list_scroll += 1;
|
||||||
}
|
}
|
||||||
@@ -437,6 +460,9 @@ impl App {
|
|||||||
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
if self.focus == Focus::Detail && !self.tasks.is_empty() {
|
||||||
self.show_popup = Some(Popup::DatePicker);
|
self.show_popup = Some(Popup::DatePicker);
|
||||||
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
} else if self.focus == Focus::TaskList && !self.tasks.is_empty() {
|
||||||
|
if !self.selected_tasks.is_empty() {
|
||||||
|
self.show_popup = Some(Popup::BulkAction);
|
||||||
|
} else {
|
||||||
let task = &mut self.tasks[self.selected_task];
|
let task = &mut self.tasks[self.selected_task];
|
||||||
task.status = match task.status {
|
task.status = match task.status {
|
||||||
TaskStatus::Completed => TaskStatus::NeedsAction,
|
TaskStatus::Completed => TaskStatus::NeedsAction,
|
||||||
@@ -453,8 +479,13 @@ impl App {
|
|||||||
self.load_tasks();
|
self.load_tasks();
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
}
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
if self.show_popup.is_some() {
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
|
} else {
|
||||||
|
self.clear_selection();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
KeyCode::Char('r') if key.modifiers.contains(KeyModifiers::CONTROL) => {
|
||||||
self.trigger_full_sync();
|
self.trigger_full_sync();
|
||||||
@@ -492,11 +523,16 @@ impl App {
|
|||||||
},
|
},
|
||||||
Popup::Input => match key.code {
|
Popup::Input => match key.code {
|
||||||
KeyCode::Esc => {
|
KeyCode::Esc => {
|
||||||
|
self.pending_bulk_move = false;
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
}
|
}
|
||||||
KeyCode::Enter => {
|
KeyCode::Enter => {
|
||||||
let input = self.popup_input.trim().to_string();
|
let input = self.popup_input.trim().to_string();
|
||||||
if !input.is_empty() {
|
if self.pending_bulk_move && !input.is_empty() {
|
||||||
|
self.pending_bulk_move = false;
|
||||||
|
self.bulk_move_to_new_list(&input);
|
||||||
|
self.show_popup = None;
|
||||||
|
} else if !input.is_empty() {
|
||||||
let list = TaskList {
|
let list = TaskList {
|
||||||
id: uuid_v4(),
|
id: uuid_v4(),
|
||||||
title: input,
|
title: input,
|
||||||
@@ -510,8 +546,10 @@ impl App {
|
|||||||
).ok();
|
).ok();
|
||||||
self.trigger_sync();
|
self.trigger_sync();
|
||||||
self.load_lists();
|
self.load_lists();
|
||||||
}
|
|
||||||
self.show_popup = None;
|
self.show_popup = None;
|
||||||
|
} else {
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
}
|
}
|
||||||
KeyCode::Char(c) => {
|
KeyCode::Char(c) => {
|
||||||
self.popup_input.insert(self.popup_cursor, c);
|
self.popup_input.insert(self.popup_cursor, c);
|
||||||
@@ -807,7 +845,122 @@ impl App {
|
|||||||
}
|
}
|
||||||
_ => {}
|
_ => {}
|
||||||
},
|
},
|
||||||
|
Popup::BulkAction => match key.code {
|
||||||
|
KeyCode::Esc => {
|
||||||
|
self.show_popup = None;
|
||||||
}
|
}
|
||||||
|
KeyCode::Char('1') => {
|
||||||
|
self.bulk_mark_completed();
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
KeyCode::Char('2') => {
|
||||||
|
self.bulk_set_due_today();
|
||||||
|
self.show_popup = None;
|
||||||
|
}
|
||||||
|
KeyCode::Char('3') => {
|
||||||
|
self.popup_input.clear();
|
||||||
|
self.popup_cursor = 0;
|
||||||
|
self.pending_bulk_move = true;
|
||||||
|
self.show_popup = Some(Popup::Input);
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_mark_completed(&mut self) {
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.status = TaskStatus::Completed;
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_set_due_today(&mut self) {
|
||||||
|
let now = chrono::Local::now().naive_local();
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let task = &mut self.tasks[i];
|
||||||
|
task.due = Some(now);
|
||||||
|
self.db.update_task(task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Update,
|
||||||
|
&task.id,
|
||||||
|
&task.list_id,
|
||||||
|
&serde_json::to_string(task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_tasks();
|
||||||
|
}
|
||||||
|
|
||||||
|
fn bulk_move_to_new_list(&mut self, list_name: &str) {
|
||||||
|
let list = TaskList {
|
||||||
|
id: uuid_v4(),
|
||||||
|
title: list_name.to_string(),
|
||||||
|
};
|
||||||
|
self.db.insert_list(&list).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::CreateList,
|
||||||
|
&list.id,
|
||||||
|
&list.id,
|
||||||
|
&serde_json::to_string(&list).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
let indices: Vec<usize> = self.selected_tasks.iter().copied().collect();
|
||||||
|
for &i in &indices {
|
||||||
|
if i >= self.tasks.len() { continue; }
|
||||||
|
let original = &self.tasks[i];
|
||||||
|
let new_task = Task {
|
||||||
|
id: uuid_v4(),
|
||||||
|
list_id: list.id.clone(),
|
||||||
|
title: original.title.clone(),
|
||||||
|
notes: original.notes.clone(),
|
||||||
|
status: original.status.clone(),
|
||||||
|
due: original.due,
|
||||||
|
position: 0,
|
||||||
|
created_at: None,
|
||||||
|
updated_at: None,
|
||||||
|
};
|
||||||
|
self.db.insert_task(&new_task).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Create,
|
||||||
|
&new_task.id,
|
||||||
|
&list.id,
|
||||||
|
&serde_json::to_string(&new_task).unwrap_or_default(),
|
||||||
|
).ok();
|
||||||
|
|
||||||
|
// Delete original
|
||||||
|
self.db.delete_task(&original.id).ok();
|
||||||
|
self.db.push_sync(
|
||||||
|
SyncAction::Delete,
|
||||||
|
&original.id,
|
||||||
|
&original.list_id,
|
||||||
|
"",
|
||||||
|
).ok();
|
||||||
|
}
|
||||||
|
|
||||||
|
self.clear_selection();
|
||||||
|
self.trigger_sync();
|
||||||
|
self.load_lists();
|
||||||
|
// Switch to the new list
|
||||||
|
if let Some(pos) = self.lists.iter().position(|l| l.id == list.id) {
|
||||||
|
self.selected_list = pos;
|
||||||
|
}
|
||||||
|
self.load_tasks();
|
||||||
}
|
}
|
||||||
|
|
||||||
fn reorder_task(&mut self, direction: i64) {
|
fn reorder_task(&mut self, direction: i64) {
|
||||||
@@ -856,6 +1009,7 @@ impl App {
|
|||||||
}
|
}
|
||||||
|
|
||||||
fn load_tasks(&mut self) {
|
fn load_tasks(&mut self) {
|
||||||
|
self.selected_tasks.clear();
|
||||||
if self.selected_list < self.lists.len() {
|
if self.selected_list < self.lists.len() {
|
||||||
let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
let mut tasks = self.db.get_tasks(&self.lists[self.selected_list].id);
|
||||||
sort_tasks(&mut tasks);
|
sort_tasks(&mut tasks);
|
||||||
@@ -869,6 +1023,10 @@ impl App {
|
|||||||
self.selected_task = 0;
|
self.selected_task = 0;
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
fn clear_selection(&mut self) {
|
||||||
|
self.selected_tasks.clear();
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
fn sort_tasks(tasks: &mut Vec<Task>) {
|
fn sort_tasks(tasks: &mut Vec<Task>) {
|
||||||
|
|||||||
@@ -154,6 +154,7 @@ fn main() -> io::Result<()> {
|
|||||||
calendar_active_week: app.calendar_active_week,
|
calendar_active_week: app.calendar_active_week,
|
||||||
auth_error: app.auth_error.as_deref(),
|
auth_error: app.auth_error.as_deref(),
|
||||||
sync_stats: &app.sync_stats,
|
sync_stats: &app.sync_stats,
|
||||||
|
selected_tasks: &app.selected_tasks,
|
||||||
};
|
};
|
||||||
draw(frame, view);
|
draw(frame, view);
|
||||||
})?;
|
})?;
|
||||||
|
|||||||
+60
-13
@@ -1,3 +1,5 @@
|
|||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use chrono::Datelike;
|
use chrono::Datelike;
|
||||||
|
|
||||||
use ratatui::style::{Color, Modifier, Style};
|
use ratatui::style::{Color, Modifier, Style};
|
||||||
@@ -76,6 +78,7 @@ pub fn render_task_list(
|
|||||||
selected: usize,
|
selected: usize,
|
||||||
focused: bool,
|
focused: bool,
|
||||||
_scroll: u16,
|
_scroll: u16,
|
||||||
|
selected_tasks: &BTreeSet<usize>,
|
||||||
) {
|
) {
|
||||||
let total = tasks.len();
|
let total = tasks.len();
|
||||||
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
let done = tasks.iter().filter(|t| t.status == TaskStatus::Completed).count();
|
||||||
@@ -85,7 +88,9 @@ pub fn render_task_list(
|
|||||||
|
|
||||||
let items: Vec<ListItem> = tasks
|
let items: Vec<ListItem> = tasks
|
||||||
.iter()
|
.iter()
|
||||||
.map(|task| {
|
.enumerate()
|
||||||
|
.map(|(idx, task)| {
|
||||||
|
let is_selected = selected_tasks.contains(&idx);
|
||||||
let checkbox = match task.status {
|
let checkbox = match task.status {
|
||||||
TaskStatus::Completed => "[\u{2713}]",
|
TaskStatus::Completed => "[\u{2713}]",
|
||||||
TaskStatus::NeedsAction => "[ ]",
|
TaskStatus::NeedsAction => "[ ]",
|
||||||
@@ -121,25 +126,29 @@ pub fn render_task_list(
|
|||||||
content_width.saturating_sub(used)
|
content_width.saturating_sub(used)
|
||||||
};
|
};
|
||||||
|
|
||||||
let mut spans = vec![
|
let checkbox_style = if is_selected {
|
||||||
Span::styled(
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
checkbox_str,
|
} else if task.status == TaskStatus::Completed {
|
||||||
Style::default().fg(if task.status == TaskStatus::Completed {
|
Style::default().fg(Color::Green)
|
||||||
Color::Green
|
} else {
|
||||||
|
Style::default().fg(Color::DarkGray)
|
||||||
|
};
|
||||||
|
|
||||||
|
let title_style = if is_selected {
|
||||||
|
Style::default().fg(Color::Yellow).add_modifier(Modifier::BOLD)
|
||||||
} else {
|
} else {
|
||||||
Color::DarkGray
|
|
||||||
}),
|
|
||||||
),
|
|
||||||
Span::styled(
|
|
||||||
display_title,
|
|
||||||
Style::default().fg(DETAIL_COLOR).add_modifier(
|
Style::default().fg(DETAIL_COLOR).add_modifier(
|
||||||
if task.status == TaskStatus::Completed {
|
if task.status == TaskStatus::Completed {
|
||||||
Modifier::CROSSED_OUT
|
Modifier::CROSSED_OUT
|
||||||
} else {
|
} else {
|
||||||
Modifier::empty()
|
Modifier::empty()
|
||||||
},
|
},
|
||||||
),
|
)
|
||||||
),
|
};
|
||||||
|
|
||||||
|
let mut spans = vec![
|
||||||
|
Span::styled(checkbox_str, checkbox_style),
|
||||||
|
Span::styled(display_title, title_style),
|
||||||
];
|
];
|
||||||
|
|
||||||
if !due_text.is_empty() {
|
if !due_text.is_empty() {
|
||||||
@@ -498,6 +507,44 @@ pub fn render_confirm_popup(frame: &mut Frame, area: Rect) {
|
|||||||
frame.render_widget(paragraph, popup_area);
|
frame.render_widget(paragraph, popup_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
pub fn render_bulk_action_popup(frame: &mut Frame, area: Rect, count: usize) {
|
||||||
|
let popup_area = centered_rect(55, 9, area);
|
||||||
|
frame.render_widget(Clear, popup_area);
|
||||||
|
let block = Block::default()
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.style(Style::default().bg(POPUP_BG))
|
||||||
|
.border_style(Style::default().fg(POPUP_BORDER))
|
||||||
|
.title(format!(" Bulk Actions ({} selected) ", count))
|
||||||
|
.title_alignment(Alignment::Left);
|
||||||
|
|
||||||
|
let text = Text::from(vec![
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" 1. Mark as completed",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" 2. Set due date to Today",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
)),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" 3. Move to new list...",
|
||||||
|
Style::default().fg(Color::Cyan),
|
||||||
|
)),
|
||||||
|
Line::from(""),
|
||||||
|
Line::from(Span::styled(
|
||||||
|
" Press 1-3 or Esc to cancel",
|
||||||
|
Style::default().fg(Color::DarkGray),
|
||||||
|
)),
|
||||||
|
]);
|
||||||
|
|
||||||
|
let paragraph = Paragraph::new(text)
|
||||||
|
.block(block)
|
||||||
|
.alignment(Alignment::Left);
|
||||||
|
|
||||||
|
frame.render_widget(paragraph, popup_area);
|
||||||
|
}
|
||||||
|
|
||||||
pub fn render_device_auth_popup(
|
pub fn render_device_auth_popup(
|
||||||
frame: &mut Frame,
|
frame: &mut Frame,
|
||||||
area: Rect,
|
area: Rect,
|
||||||
|
|||||||
@@ -1,5 +1,7 @@
|
|||||||
pub mod components;
|
pub mod components;
|
||||||
|
|
||||||
|
use std::collections::BTreeSet;
|
||||||
|
|
||||||
use ratatui::layout::{Constraint, Direction, Layout};
|
use ratatui::layout::{Constraint, Direction, Layout};
|
||||||
use ratatui::Frame;
|
use ratatui::Frame;
|
||||||
|
|
||||||
@@ -21,6 +23,7 @@ pub enum Popup {
|
|||||||
EditTask { field: usize },
|
EditTask { field: usize },
|
||||||
DatePicker,
|
DatePicker,
|
||||||
ConfirmDelete,
|
ConfirmDelete,
|
||||||
|
BulkAction,
|
||||||
DeviceAuth { url: String, code: String },
|
DeviceAuth { url: String, code: String },
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -52,6 +55,7 @@ pub struct AppView<'a> {
|
|||||||
pub calendar_active_week: usize,
|
pub calendar_active_week: usize,
|
||||||
pub auth_error: Option<&'a str>,
|
pub auth_error: Option<&'a str>,
|
||||||
pub sync_stats: &'a SyncStats,
|
pub sync_stats: &'a SyncStats,
|
||||||
|
pub selected_tasks: &'a BTreeSet<usize>,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub fn draw(frame: &mut Frame, view: AppView) {
|
pub fn draw(frame: &mut Frame, view: AppView) {
|
||||||
@@ -88,6 +92,7 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
view.selected_task,
|
view.selected_task,
|
||||||
is_task_list_focused,
|
is_task_list_focused,
|
||||||
view.task_list_scroll,
|
view.task_list_scroll,
|
||||||
|
view.selected_tasks,
|
||||||
);
|
);
|
||||||
|
|
||||||
let is_detail_focused = view.focus == Focus::Detail;
|
let is_detail_focused = view.focus == Focus::Detail;
|
||||||
@@ -121,6 +126,7 @@ pub fn draw(frame: &mut Frame, view: AppView) {
|
|||||||
),
|
),
|
||||||
Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
|
Popup::DatePicker => render_date_picker(frame, area, view.draft_date),
|
||||||
Popup::ConfirmDelete => render_confirm_popup(frame, area),
|
Popup::ConfirmDelete => render_confirm_popup(frame, area),
|
||||||
|
Popup::BulkAction => render_bulk_action_popup(frame, area, view.selected_tasks.len()),
|
||||||
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
|
Popup::DeviceAuth { url, code } => render_device_auth_popup(frame, area, url, code, view.auth_error),
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user