feat: add word implemented + refactor of handlers as an organization test
This commit is contained in:
@@ -1,18 +1,15 @@
|
|||||||
use crate::modules::source::{FileDatabase, Word, WordSource};
|
use crate::modules::source::{FileDatabase, Word, WordSource};
|
||||||
use crossterm::event::{
|
use crate::modules::ui::handler::handle_user_input;
|
||||||
self,
|
|
||||||
Event::{self},
|
|
||||||
};
|
|
||||||
use ratatui::{
|
use ratatui::{
|
||||||
DefaultTerminal, Frame,
|
DefaultTerminal, Frame,
|
||||||
layout::{Constraint, Direction, Flex, Layout},
|
layout::{Constraint, Direction, Flex, Layout},
|
||||||
style::{Color, Style},
|
style::{Color, Style},
|
||||||
text::Text,
|
text::Text,
|
||||||
widgets::{Block, Borders, List, ListDirection, ListItem, ListState, Padding, Paragraph},
|
widgets::{Block, Borders, List, ListDirection, ListItem, ListState, Padding, Paragraph, Wrap},
|
||||||
};
|
};
|
||||||
use std::io;
|
use std::io;
|
||||||
|
|
||||||
#[derive(PartialEq)]
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
pub enum UiState {
|
pub enum UiState {
|
||||||
LIST,
|
LIST,
|
||||||
FILTERWORD,
|
FILTERWORD,
|
||||||
@@ -22,18 +19,29 @@ pub enum UiState {
|
|||||||
DELETE,
|
DELETE,
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[derive(Copy, Clone, PartialEq)]
|
||||||
|
pub enum AddState {
|
||||||
|
KEY,
|
||||||
|
DESCRIPTION,
|
||||||
|
LANG,
|
||||||
|
}
|
||||||
|
|
||||||
pub struct Preferences {
|
pub struct Preferences {
|
||||||
help: bool,
|
help: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
pub struct App {
|
pub struct App {
|
||||||
state: UiState,
|
pub state: UiState,
|
||||||
file_database: FileDatabase,
|
pub file_database: FileDatabase,
|
||||||
list_state: ListState,
|
pub list_state: ListState,
|
||||||
word_list: Vec<Word>,
|
pub add_state: AddState,
|
||||||
word_filter: Option<String>,
|
pub description_scroll: u16,
|
||||||
lang_filter: Option<String>,
|
pub word_list: Vec<Word>,
|
||||||
exit: bool,
|
pub word_to_add: Option<Word>,
|
||||||
|
pub word_to_edit: Option<Word>,
|
||||||
|
pub word_filter: Option<String>,
|
||||||
|
pub lang_filter: Option<String>,
|
||||||
|
pub exit: bool,
|
||||||
}
|
}
|
||||||
|
|
||||||
impl App {
|
impl App {
|
||||||
@@ -44,7 +52,11 @@ impl App {
|
|||||||
state: UiState::LIST,
|
state: UiState::LIST,
|
||||||
file_database: db,
|
file_database: db,
|
||||||
list_state: ListState::default().with_selected(Some(0)),
|
list_state: ListState::default().with_selected(Some(0)),
|
||||||
|
add_state: AddState::KEY,
|
||||||
|
description_scroll: 0,
|
||||||
word_list: wl,
|
word_list: wl,
|
||||||
|
word_to_add: None,
|
||||||
|
word_to_edit: None,
|
||||||
word_filter: None,
|
word_filter: None,
|
||||||
lang_filter: None,
|
lang_filter: None,
|
||||||
exit: false,
|
exit: false,
|
||||||
@@ -74,20 +86,15 @@ impl App {
|
|||||||
])
|
])
|
||||||
.split(frame.area());
|
.split(frame.area());
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Filters block
|
||||||
|
* */
|
||||||
let filters_layout = Layout::default()
|
let filters_layout = Layout::default()
|
||||||
.direction(Direction::Horizontal)
|
.direction(Direction::Horizontal)
|
||||||
.spacing(1)
|
.spacing(1)
|
||||||
.constraints(vec![Constraint::Fill(1), Constraint::Length(8)])
|
.constraints(vec![Constraint::Fill(1), Constraint::Length(8)])
|
||||||
.split(main_layout[0]);
|
.split(main_layout[0]);
|
||||||
|
|
||||||
let words_layout =
|
|
||||||
Layout::horizontal(vec![Constraint::Percentage(30), Constraint::Percentage(70)])
|
|
||||||
.split(main_layout[1]);
|
|
||||||
|
|
||||||
let help_layout = Layout::horizontal(vec![Constraint::Fill(1)])
|
|
||||||
.flex(Flex::Center)
|
|
||||||
.split(main_layout[2]);
|
|
||||||
|
|
||||||
let word_filter: String = {
|
let word_filter: String = {
|
||||||
if let Some(l) = &self.word_filter {
|
if let Some(l) = &self.word_filter {
|
||||||
l.to_string()
|
l.to_string()
|
||||||
@@ -113,42 +120,87 @@ impl App {
|
|||||||
filters_layout[1],
|
filters_layout[1],
|
||||||
);
|
);
|
||||||
|
|
||||||
let word_key_list_items: Vec<ListItem> = self
|
/*
|
||||||
.word_list
|
* Word list / add / edit block
|
||||||
.clone()
|
* */
|
||||||
.iter()
|
if self.state == UiState::ADD {
|
||||||
.map(|w| ListItem::new(w.key.clone()))
|
let add_words_heading_layout =
|
||||||
.collect();
|
Layout::horizontal(vec![Constraint::Fill(1), Constraint::Length(8)])
|
||||||
|
.spacing(1)
|
||||||
|
.split(main_layout[0]);
|
||||||
|
|
||||||
let word_key_list = List::new(word_key_list_items)
|
// PERF: maybe not clone
|
||||||
.direction(ListDirection::TopToBottom)
|
let word_to_add = self.word_to_add.clone().unwrap_or(Word {
|
||||||
.block(
|
key: "".to_string(),
|
||||||
Block::default()
|
description: "".to_string(),
|
||||||
.title("Entries")
|
lang: "".to_string(),
|
||||||
.borders(Borders::ALL)
|
});
|
||||||
.padding(Padding::horizontal(1)),
|
|
||||||
)
|
|
||||||
.highlight_style(Style::default().fg(Color::default()))
|
|
||||||
.highlight_symbol("> ")
|
|
||||||
.style(Style::default().fg(Color::DarkGray));
|
|
||||||
frame.render_stateful_widget(word_key_list, words_layout[0], &mut self.list_state);
|
|
||||||
|
|
||||||
let scroll_position: usize = {
|
|
||||||
if let Some(i) = self.list_state.selected() {
|
|
||||||
i
|
|
||||||
} else {
|
|
||||||
0
|
|
||||||
}
|
|
||||||
};
|
|
||||||
// PERF: do not clone
|
|
||||||
let selected_word = self.word_list.clone().into_iter().nth(scroll_position);
|
|
||||||
if let Some(w) = selected_word {
|
|
||||||
frame.render_widget(
|
frame.render_widget(
|
||||||
Paragraph::new(w.description).block(Block::new().borders(Borders::ALL)),
|
Paragraph::new(word_to_add.key)
|
||||||
words_layout[1],
|
.block(Block::new().title("Word").borders(Borders::ALL)),
|
||||||
|
add_words_heading_layout[0],
|
||||||
);
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(word_to_add.lang)
|
||||||
|
.block(Block::new().title("Lang").borders(Borders::ALL)),
|
||||||
|
add_words_heading_layout[1],
|
||||||
|
);
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(word_to_add.description)
|
||||||
|
.block(Block::new().title("Description").borders(Borders::ALL)),
|
||||||
|
main_layout[1],
|
||||||
|
);
|
||||||
|
} else {
|
||||||
|
let words_layout =
|
||||||
|
Layout::horizontal(vec![Constraint::Percentage(30), Constraint::Percentage(70)])
|
||||||
|
.split(main_layout[1]);
|
||||||
|
let word_key_list_items: Vec<ListItem> = self
|
||||||
|
.word_list
|
||||||
|
.clone()
|
||||||
|
.iter()
|
||||||
|
.map(|w| ListItem::new(w.key.clone()))
|
||||||
|
.collect();
|
||||||
|
|
||||||
|
let word_key_list = List::new(word_key_list_items)
|
||||||
|
.direction(ListDirection::TopToBottom)
|
||||||
|
.block(
|
||||||
|
Block::default()
|
||||||
|
.title("Entries")
|
||||||
|
.borders(Borders::ALL)
|
||||||
|
.padding(Padding::horizontal(1)),
|
||||||
|
)
|
||||||
|
.highlight_style(Style::default().fg(Color::default()))
|
||||||
|
.highlight_symbol("> ")
|
||||||
|
.style(Style::default().fg(Color::DarkGray));
|
||||||
|
frame.render_stateful_widget(word_key_list, words_layout[0], &mut self.list_state);
|
||||||
|
|
||||||
|
let scroll_position: usize = {
|
||||||
|
if let Some(i) = self.list_state.selected() {
|
||||||
|
i
|
||||||
|
} else {
|
||||||
|
0
|
||||||
|
}
|
||||||
|
};
|
||||||
|
// PERF: do not clone
|
||||||
|
let selected_word = self.word_list.clone().into_iter().nth(scroll_position);
|
||||||
|
if let Some(w) = selected_word {
|
||||||
|
frame.render_widget(
|
||||||
|
Paragraph::new(w.description)
|
||||||
|
.wrap(Wrap { trim: true })
|
||||||
|
.scroll((self.description_scroll, 0))
|
||||||
|
.block(Block::new().borders(Borders::ALL)),
|
||||||
|
words_layout[1],
|
||||||
|
);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/*
|
||||||
|
* Help block
|
||||||
|
* */
|
||||||
|
let help_layout = Layout::horizontal(vec![Constraint::Fill(1)])
|
||||||
|
.flex(Flex::Center)
|
||||||
|
.split(main_layout[2]);
|
||||||
|
|
||||||
let help_text = Text::raw("(f)ilter | (l)ang | (d)elete | (e)dit | (a)dd | (q)uit ")
|
let help_text = Text::raw("(f)ilter | (l)ang | (d)elete | (e)dit | (a)dd | (q)uit ")
|
||||||
.style(Style::new().fg(Color::DarkGray));
|
.style(Style::new().fg(Color::DarkGray));
|
||||||
let help_area = help_layout[0].centered(
|
let help_area = help_layout[0].centered(
|
||||||
@@ -159,7 +211,7 @@ impl App {
|
|||||||
frame.render_widget(help_text, help_area);
|
frame.render_widget(help_text, help_area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn update_word_list(&mut self) {
|
pub fn update_word_list(&mut self) {
|
||||||
let word_list = {
|
let word_list = {
|
||||||
if let Some(w_filter) = &self.word_filter
|
if let Some(w_filter) = &self.word_filter
|
||||||
&& let Some(l_filter) = &self.lang_filter
|
&& let Some(l_filter) = &self.lang_filter
|
||||||
@@ -175,96 +227,11 @@ impl App {
|
|||||||
}
|
}
|
||||||
};
|
};
|
||||||
self.list_state.select(Some(0));
|
self.list_state.select(Some(0));
|
||||||
|
self.description_scroll = 0;
|
||||||
self.word_list = word_list;
|
self.word_list = word_list;
|
||||||
}
|
}
|
||||||
|
|
||||||
fn handle_events(&mut self) -> io::Result<()> {
|
fn handle_events(&mut self) -> io::Result<()> {
|
||||||
if let Event::Key(key) = event::read()? {
|
return handle_user_input(self);
|
||||||
match key.code {
|
|
||||||
event::KeyCode::Char(c) => {
|
|
||||||
match self.state {
|
|
||||||
UiState::FILTERWORD => {
|
|
||||||
if let Some(mut prev_word) = self.word_filter.clone() {
|
|
||||||
prev_word.push(c);
|
|
||||||
self.word_filter = Some(prev_word);
|
|
||||||
} else {
|
|
||||||
self.word_filter = Some(c.to_string());
|
|
||||||
}
|
|
||||||
self.update_word_list();
|
|
||||||
}
|
|
||||||
UiState::FILTERLANG => {
|
|
||||||
if let Some(mut prev_lang) = self.lang_filter.clone() {
|
|
||||||
prev_lang.push(c);
|
|
||||||
self.lang_filter = Some(prev_lang);
|
|
||||||
} else {
|
|
||||||
self.lang_filter = Some(c.to_string());
|
|
||||||
}
|
|
||||||
self.update_word_list();
|
|
||||||
}
|
|
||||||
_ => {
|
|
||||||
// Quit
|
|
||||||
if c == 'q' || c == 'x' {
|
|
||||||
return Err(io::Error::last_os_error()); // FIXME: this is not right
|
|
||||||
}
|
|
||||||
// State change
|
|
||||||
if c == 'f' || c == '/' {
|
|
||||||
self.state = UiState::FILTERWORD;
|
|
||||||
}
|
|
||||||
if c == 'l' {
|
|
||||||
self.state = UiState::FILTERLANG;
|
|
||||||
}
|
|
||||||
if c == 'd' {
|
|
||||||
self.state = UiState::DELETE;
|
|
||||||
}
|
|
||||||
if c == 'e' {
|
|
||||||
self.state = UiState::EDIT;
|
|
||||||
}
|
|
||||||
if c == 'a' {
|
|
||||||
self.state = UiState::ADD;
|
|
||||||
}
|
|
||||||
// Movement
|
|
||||||
if c == 'j' {
|
|
||||||
self.list_state.select_next();
|
|
||||||
}
|
|
||||||
if c == 'k' {
|
|
||||||
self.list_state.select_previous();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
}
|
|
||||||
event::KeyCode::Backspace => match self.state {
|
|
||||||
UiState::FILTERWORD => {
|
|
||||||
if let Some(w) = &self.word_filter {
|
|
||||||
if w.len() <= 1 {
|
|
||||||
self.word_filter = None;
|
|
||||||
} else {
|
|
||||||
let mut new_w = w.clone();
|
|
||||||
new_w.pop();
|
|
||||||
self.word_filter = Some(new_w);
|
|
||||||
}
|
|
||||||
self.update_word_list();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
UiState::FILTERLANG => {
|
|
||||||
if let Some(l) = &self.lang_filter {
|
|
||||||
if l.len() <= 1 {
|
|
||||||
self.lang_filter = None;
|
|
||||||
} else {
|
|
||||||
let mut new_l = l.clone();
|
|
||||||
new_l.pop();
|
|
||||||
self.lang_filter = Some(new_l);
|
|
||||||
}
|
|
||||||
self.update_word_list();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
_ => {}
|
|
||||||
},
|
|
||||||
event::KeyCode::Esc => {
|
|
||||||
self.state = UiState::LIST;
|
|
||||||
}
|
|
||||||
_ => return Ok(()),
|
|
||||||
}
|
|
||||||
}
|
|
||||||
Ok(())
|
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|||||||
209
src/modules/ui/handler.rs
Normal file
209
src/modules/ui/handler.rs
Normal file
@@ -0,0 +1,209 @@
|
|||||||
|
use std::io;
|
||||||
|
|
||||||
|
use crossterm::event::{self, Event};
|
||||||
|
|
||||||
|
use crate::modules::{
|
||||||
|
source::{Word, WordSource},
|
||||||
|
ui::app::{AddState, App, UiState},
|
||||||
|
};
|
||||||
|
|
||||||
|
pub fn handle_user_input(app: &mut App) -> io::Result<()> {
|
||||||
|
if let Event::Key(key) = event::read()? {
|
||||||
|
match key.code {
|
||||||
|
event::KeyCode::Char(c) => {
|
||||||
|
match app.state {
|
||||||
|
UiState::FILTERWORD => {
|
||||||
|
if let Some(mut prev_word) = app.word_filter.clone() {
|
||||||
|
prev_word.push(c);
|
||||||
|
app.word_filter = Some(prev_word);
|
||||||
|
} else {
|
||||||
|
app.word_filter = Some(c.to_string());
|
||||||
|
}
|
||||||
|
app.update_word_list();
|
||||||
|
}
|
||||||
|
UiState::FILTERLANG => {
|
||||||
|
if let Some(mut prev_lang) = app.lang_filter.clone() {
|
||||||
|
prev_lang.push(c);
|
||||||
|
app.lang_filter = Some(prev_lang);
|
||||||
|
} else {
|
||||||
|
app.lang_filter = Some(c.to_string());
|
||||||
|
}
|
||||||
|
app.update_word_list();
|
||||||
|
}
|
||||||
|
UiState::ADD => match app.add_state {
|
||||||
|
AddState::KEY => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.key.push(c);
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
} else {
|
||||||
|
app.word_to_add = Some(Word {
|
||||||
|
key: c.to_string(),
|
||||||
|
description: "".to_string(),
|
||||||
|
lang: "".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddState::DESCRIPTION => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.description.push(c);
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
} else {
|
||||||
|
app.word_to_add = Some(Word {
|
||||||
|
key: c.to_string(),
|
||||||
|
description: "".to_string(),
|
||||||
|
lang: "".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddState::LANG => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.lang.push(c);
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
} else {
|
||||||
|
app.word_to_add = Some(Word {
|
||||||
|
key: c.to_string(),
|
||||||
|
description: "".to_string(),
|
||||||
|
lang: "".to_string(),
|
||||||
|
});
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {
|
||||||
|
// Quit
|
||||||
|
if c == 'q' || c == 'x' {
|
||||||
|
return Err(io::Error::last_os_error()); // FIXME: this is not right
|
||||||
|
}
|
||||||
|
// State change
|
||||||
|
if c == 'f' || c == '/' {
|
||||||
|
app.state = UiState::FILTERWORD;
|
||||||
|
}
|
||||||
|
if c == 'l' {
|
||||||
|
app.state = UiState::FILTERLANG;
|
||||||
|
}
|
||||||
|
if c == 'd' {
|
||||||
|
app.state = UiState::DELETE;
|
||||||
|
}
|
||||||
|
if c == 'e' {
|
||||||
|
app.state = UiState::EDIT;
|
||||||
|
}
|
||||||
|
if c == 'a' {
|
||||||
|
app.state = UiState::ADD;
|
||||||
|
}
|
||||||
|
// Movement
|
||||||
|
if c == 'j' {
|
||||||
|
app.list_state.select_next();
|
||||||
|
app.description_scroll = 0;
|
||||||
|
}
|
||||||
|
if c == 'k' {
|
||||||
|
app.list_state.select_previous();
|
||||||
|
app.description_scroll = 0;
|
||||||
|
}
|
||||||
|
if c == 'J' {
|
||||||
|
app.description_scroll += 1;
|
||||||
|
}
|
||||||
|
if c == 'K' {
|
||||||
|
if app.description_scroll > 0 {
|
||||||
|
app.description_scroll -= 1;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
event::KeyCode::Enter => match app.state {
|
||||||
|
UiState::ADD => {
|
||||||
|
if let Some(w) = app.word_to_add.clone()
|
||||||
|
&& w.key.len() > 0
|
||||||
|
{
|
||||||
|
let res = app.file_database.insert_word(w);
|
||||||
|
match res {
|
||||||
|
Ok(_) => {
|
||||||
|
app.update_word_list();
|
||||||
|
app.word_to_add = None;
|
||||||
|
app.state = get_next_ui_state(app.state);
|
||||||
|
}
|
||||||
|
Err(_) => app.state = get_next_ui_state(app.state),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
event::KeyCode::Tab => match app.state {
|
||||||
|
UiState::ADD => {
|
||||||
|
app.add_state = get_next_add_state(app.add_state);
|
||||||
|
}
|
||||||
|
_ => {
|
||||||
|
app.state = get_next_ui_state(app.state);
|
||||||
|
}
|
||||||
|
},
|
||||||
|
event::KeyCode::Backspace => match app.state {
|
||||||
|
UiState::FILTERWORD => {
|
||||||
|
app.word_filter = remove_last_char(&app.word_filter);
|
||||||
|
app.update_word_list();
|
||||||
|
}
|
||||||
|
UiState::FILTERLANG => {
|
||||||
|
app.lang_filter = remove_last_char(&app.lang_filter);
|
||||||
|
app.update_word_list();
|
||||||
|
}
|
||||||
|
UiState::ADD => match app.add_state {
|
||||||
|
AddState::KEY => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.key.pop();
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddState::DESCRIPTION => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.description.pop();
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
AddState::LANG => {
|
||||||
|
if let Some(mut prev_word) = app.word_to_add.clone() {
|
||||||
|
prev_word.lang.pop();
|
||||||
|
app.word_to_add = Some(prev_word);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
},
|
||||||
|
_ => {}
|
||||||
|
},
|
||||||
|
event::KeyCode::Esc => {
|
||||||
|
app.state = UiState::LIST;
|
||||||
|
}
|
||||||
|
_ => return Ok(()),
|
||||||
|
}
|
||||||
|
}
|
||||||
|
Ok(())
|
||||||
|
}
|
||||||
|
|
||||||
|
fn remove_last_char(input: &Option<String>) -> Option<String> {
|
||||||
|
if let Some(l) = input {
|
||||||
|
if l.len() <= 1 {
|
||||||
|
None
|
||||||
|
} else {
|
||||||
|
let mut new_l = l.clone();
|
||||||
|
new_l.pop();
|
||||||
|
Some(new_l)
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
None
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_next_add_state(current: AddState) -> AddState {
|
||||||
|
if current == AddState::KEY {
|
||||||
|
AddState::LANG
|
||||||
|
} else if current == AddState::LANG {
|
||||||
|
AddState::DESCRIPTION
|
||||||
|
} else {
|
||||||
|
AddState::KEY
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn get_next_ui_state(current: UiState) -> UiState {
|
||||||
|
match current {
|
||||||
|
UiState::LIST => UiState::FILTERWORD,
|
||||||
|
UiState::FILTERWORD => UiState::FILTERLANG,
|
||||||
|
UiState::FILTERLANG => UiState::LIST,
|
||||||
|
_ => UiState::LIST,
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -1 +1,2 @@
|
|||||||
pub mod app;
|
pub mod app;
|
||||||
|
pub mod handler;
|
||||||
|
|||||||
Reference in New Issue
Block a user