mirror of
https://github.com/house-of-vanity/khm.git
synced 2025-08-21 14:27:14 +00:00
web wasm ui
This commit is contained in:
@@ -209,6 +209,46 @@ pub struct WebAdminApp {
|
|||||||
pending_operation: String,
|
pending_operation: String,
|
||||||
flows_loaded: bool,
|
flows_loaded: bool,
|
||||||
auto_load_keys: bool,
|
auto_load_keys: bool,
|
||||||
|
|
||||||
|
// Window state management
|
||||||
|
windows: WindowState,
|
||||||
|
|
||||||
|
// Action states for windows
|
||||||
|
pending_bulk_action: BulkAction,
|
||||||
|
pending_key_action: KeyAction,
|
||||||
|
}
|
||||||
|
|
||||||
|
#[derive(Debug, Clone)]
|
||||||
|
pub struct WindowState {
|
||||||
|
pub show_statistics: bool,
|
||||||
|
pub show_bulk_actions: bool,
|
||||||
|
pub show_keys_table: bool,
|
||||||
|
pub show_flow_selector: bool,
|
||||||
|
|
||||||
|
// Window positions (for persistence)
|
||||||
|
pub statistics_pos: Option<egui::Pos2>,
|
||||||
|
pub bulk_actions_pos: Option<egui::Pos2>,
|
||||||
|
pub keys_table_pos: Option<egui::Pos2>,
|
||||||
|
pub flow_selector_pos: Option<egui::Pos2>,
|
||||||
|
}
|
||||||
|
|
||||||
|
impl Default for WindowState {
|
||||||
|
fn default() -> Self {
|
||||||
|
Self {
|
||||||
|
// Show essential windows by default
|
||||||
|
show_statistics: true,
|
||||||
|
show_bulk_actions: false, // Show only when items selected
|
||||||
|
show_keys_table: true,
|
||||||
|
show_flow_selector: true,
|
||||||
|
|
||||||
|
// Smart initial positioning to avoid overlap
|
||||||
|
// Layout: Flow selector (top-left), Statistics (top-right), Keys (bottom-center), Bulk Actions (top-center when needed)
|
||||||
|
flow_selector_pos: Some(egui::pos2(20.0, 70.0)), // Top-left
|
||||||
|
statistics_pos: Some(egui::pos2(450.0, 70.0)), // Top-right
|
||||||
|
keys_table_pos: Some(egui::pos2(20.0, 280.0)), // Bottom-large area
|
||||||
|
bulk_actions_pos: Some(egui::pos2(230.0, 70.0)), // Top-center (when visible)
|
||||||
|
}
|
||||||
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl Default for WebAdminApp {
|
impl Default for WebAdminApp {
|
||||||
@@ -226,6 +266,9 @@ impl Default for WebAdminApp {
|
|||||||
pending_operation: String::new(),
|
pending_operation: String::new(),
|
||||||
flows_loaded: false,
|
flows_loaded: false,
|
||||||
auto_load_keys: false,
|
auto_load_keys: false,
|
||||||
|
windows: WindowState::default(),
|
||||||
|
pending_bulk_action: BulkAction::None,
|
||||||
|
pending_key_action: KeyAction::None,
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
@@ -451,131 +494,304 @@ impl eframe::App for WebAdminApp {
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
egui::CentralPanel::default().show(ctx, |ui| {
|
// Main control panel with window toggles
|
||||||
let title_size = if is_mobile { 22.0 } else { 28.0 };
|
egui::TopBottomPanel::top("control_panel").show(ctx, |ui| {
|
||||||
ui.add_space(base_spacing);
|
ui.horizontal(|ui| {
|
||||||
ui.heading(egui::RichText::new("🔑 KHM Web Admin Panel").size(title_size));
|
let title_size = if is_mobile { 18.0 } else { 22.0 };
|
||||||
ui.separator();
|
ui.heading(egui::RichText::new("🔑 KHM Admin").size(title_size));
|
||||||
ui.add_space(base_spacing * 1.5);
|
|
||||||
|
ui.separator();
|
||||||
// Flow Selection
|
|
||||||
ui.group(|ui| {
|
// Window toggle buttons
|
||||||
ui.set_min_width(ui.available_width());
|
ui.label("Windows:");
|
||||||
ui.vertical(|ui| {
|
|
||||||
let section_title_size = if is_mobile { 16.0 } else { 18.0 };
|
if ui.selectable_label(self.windows.show_flow_selector, "📂 Flow").clicked() {
|
||||||
ui.label(egui::RichText::new("📂 Flow Selection").size(section_title_size).strong());
|
self.windows.show_flow_selector = !self.windows.show_flow_selector;
|
||||||
ui.add_space(base_spacing);
|
}
|
||||||
|
|
||||||
// Use vertical layout on mobile for better space usage
|
if ui.selectable_label(self.windows.show_statistics, "📊 Stats").clicked() {
|
||||||
if is_mobile {
|
self.windows.show_statistics = !self.windows.show_statistics;
|
||||||
ui.vertical(|ui| {
|
}
|
||||||
ui.label(egui::RichText::new("Current Flow:").size(14.0));
|
|
||||||
ui.add_space(5.0);
|
if ui.selectable_label(self.windows.show_keys_table, "🔑 Keys").clicked() {
|
||||||
|
self.windows.show_keys_table = !self.windows.show_keys_table;
|
||||||
let mut flow_changed = false;
|
}
|
||||||
let old_flow = self.settings.selected_flow.clone();
|
|
||||||
|
// Show bulk actions button only when items are selected
|
||||||
egui::ComboBox::from_id_salt("flow_selector")
|
let selected_count = self.admin_state.selected_servers.values().filter(|&&v| v).count();
|
||||||
.selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow })
|
if selected_count > 0 {
|
||||||
.width(ui.available_width() - 20.0)
|
if ui.selectable_label(self.windows.show_bulk_actions, &format!("📋 Actions({})", selected_count)).clicked() {
|
||||||
.show_ui(ui, |ui| {
|
self.windows.show_bulk_actions = !self.windows.show_bulk_actions;
|
||||||
for flow in &self.available_flows {
|
|
||||||
if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() {
|
|
||||||
flow_changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if flow_changed && old_flow != self.settings.selected_flow {
|
|
||||||
self.auto_load_keys = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(base_spacing);
|
|
||||||
|
|
||||||
if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("🔄 Refresh").size(14.0))).clicked() && !self.loading {
|
|
||||||
if !self.settings.selected_flow.is_empty() {
|
|
||||||
self.load_keys();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
} else {
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label(egui::RichText::new("Current Flow:").size(16.0));
|
|
||||||
ui.add_space(10.0);
|
|
||||||
|
|
||||||
let mut flow_changed = false;
|
|
||||||
let old_flow = self.settings.selected_flow.clone();
|
|
||||||
|
|
||||||
egui::ComboBox::from_id_salt("flow_selector")
|
|
||||||
.selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow })
|
|
||||||
.width(300.0)
|
|
||||||
.show_ui(ui, |ui| {
|
|
||||||
for flow in &self.available_flows {
|
|
||||||
if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() {
|
|
||||||
flow_changed = true;
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
|
|
||||||
if flow_changed && old_flow != self.settings.selected_flow {
|
|
||||||
self.auto_load_keys = true;
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(20.0);
|
|
||||||
|
|
||||||
if ui.add_sized([120.0, button_height], egui::Button::new(egui::RichText::new("🔄 Refresh").size(14.0))).clicked() && !self.loading {
|
|
||||||
if !self.settings.selected_flow.is_empty() {
|
|
||||||
self.load_keys();
|
|
||||||
}
|
|
||||||
}
|
|
||||||
});
|
|
||||||
}
|
}
|
||||||
|
} else {
|
||||||
|
self.windows.show_bulk_actions = false;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.with_layout(egui::Layout::right_to_left(egui::Align::Center), |ui| {
|
||||||
|
// Status display
|
||||||
|
ui.colored_label(egui::Color32::LIGHT_BLUE, &self.status_message);
|
||||||
|
ui.label("Status:");
|
||||||
});
|
});
|
||||||
});
|
});
|
||||||
|
|
||||||
ui.add_space(base_spacing);
|
|
||||||
|
|
||||||
// Statistics
|
|
||||||
if !self.admin_state.keys.is_empty() {
|
|
||||||
self.render_statistics(ui, is_mobile);
|
|
||||||
ui.add_space(base_spacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Search and filters
|
|
||||||
if !self.admin_state.keys.is_empty() {
|
|
||||||
self.render_search_controls(ui, is_mobile);
|
|
||||||
ui.add_space(base_spacing);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Bulk actions
|
|
||||||
let bulk_action = self.render_bulk_actions(ui, is_mobile, button_height);
|
|
||||||
if bulk_action != BulkAction::None {
|
|
||||||
self.handle_bulk_action(bulk_action);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Keys display
|
|
||||||
if !self.admin_state.filtered_keys.is_empty() {
|
|
||||||
let key_action = self.render_keys_table(ui, is_mobile, button_height);
|
|
||||||
if key_action != KeyAction::None {
|
|
||||||
self.handle_key_action(key_action);
|
|
||||||
}
|
|
||||||
} else if !self.admin_state.keys.is_empty() {
|
|
||||||
self.render_empty_state(ui);
|
|
||||||
}
|
|
||||||
|
|
||||||
ui.add_space(10.0);
|
|
||||||
|
|
||||||
// Status bar
|
|
||||||
ui.separator();
|
|
||||||
ui.horizontal(|ui| {
|
|
||||||
ui.label("Status:");
|
|
||||||
ui.colored_label(egui::Color32::LIGHT_BLUE, &self.status_message);
|
|
||||||
});
|
|
||||||
});
|
});
|
||||||
|
|
||||||
|
// Show individual windows
|
||||||
|
self.show_flow_selector_window(ctx, is_mobile, button_height);
|
||||||
|
self.show_statistics_window(ctx, is_mobile);
|
||||||
|
self.show_bulk_actions_window(ctx, is_mobile, button_height);
|
||||||
|
self.show_keys_table_window(ctx, is_mobile, button_height);
|
||||||
|
|
||||||
|
// Handle actions from windows
|
||||||
|
if self.pending_bulk_action != BulkAction::None {
|
||||||
|
let action = self.pending_bulk_action.clone();
|
||||||
|
self.pending_bulk_action = BulkAction::None;
|
||||||
|
self.handle_bulk_action(action);
|
||||||
|
}
|
||||||
|
|
||||||
|
if self.pending_key_action != KeyAction::None {
|
||||||
|
let action = self.pending_key_action.clone();
|
||||||
|
self.pending_key_action = KeyAction::None;
|
||||||
|
self.handle_key_action(action);
|
||||||
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
impl WebAdminApp {
|
impl WebAdminApp {
|
||||||
|
fn get_smart_window_positions(&self, ctx: &egui::Context, is_mobile: bool) -> (egui::Pos2, egui::Pos2, egui::Pos2, egui::Pos2) {
|
||||||
|
let screen_rect = ctx.screen_rect();
|
||||||
|
|
||||||
|
if is_mobile {
|
||||||
|
// Mobile: stack windows vertically with small gaps
|
||||||
|
(
|
||||||
|
egui::pos2(10.0, 70.0), // Flow selector
|
||||||
|
egui::pos2(10.0, 220.0), // Statistics
|
||||||
|
egui::pos2(10.0, 370.0), // Keys table
|
||||||
|
egui::pos2(10.0, 170.0), // Bulk actions (between stats and keys)
|
||||||
|
)
|
||||||
|
} else {
|
||||||
|
// Desktop: intelligent positioning based on screen size
|
||||||
|
let width = screen_rect.width();
|
||||||
|
let flow_x = 20.0;
|
||||||
|
let stats_x = (width * 0.6).min(600.0);
|
||||||
|
let bulk_x = (width * 0.3).min(350.0);
|
||||||
|
|
||||||
|
(
|
||||||
|
egui::pos2(flow_x, 70.0), // Flow selector (left)
|
||||||
|
egui::pos2(stats_x, 70.0), // Statistics (right)
|
||||||
|
egui::pos2(20.0, 280.0), // Keys table (bottom, full width)
|
||||||
|
egui::pos2(bulk_x, 70.0), // Bulk actions (center)
|
||||||
|
)
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_flow_selector_window(&mut self, ctx: &egui::Context, is_mobile: bool, button_height: f32) {
|
||||||
|
if !self.windows.show_flow_selector {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window = egui::Window::new("📂 Flow Selection")
|
||||||
|
.resizable(true)
|
||||||
|
.collapsible(true)
|
||||||
|
.default_width(if is_mobile { 300.0 } else { 400.0 })
|
||||||
|
.default_height(150.0);
|
||||||
|
|
||||||
|
// Use saved position or smart default
|
||||||
|
let (flow_pos, stats_pos, keys_pos, bulk_pos) = self.get_smart_window_positions(ctx, is_mobile);
|
||||||
|
let pos = self.windows.flow_selector_pos.unwrap_or(flow_pos);
|
||||||
|
window = window.current_pos(pos);
|
||||||
|
|
||||||
|
let response = window.show(ctx, |ui| {
|
||||||
|
ui.vertical(|ui| {
|
||||||
|
if is_mobile {
|
||||||
|
ui.label(egui::RichText::new("Current Flow:").size(14.0));
|
||||||
|
ui.add_space(5.0);
|
||||||
|
|
||||||
|
let mut flow_changed = false;
|
||||||
|
let old_flow = self.settings.selected_flow.clone();
|
||||||
|
|
||||||
|
egui::ComboBox::from_id_salt("flow_selector")
|
||||||
|
.selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow })
|
||||||
|
.width(ui.available_width() - 20.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for flow in &self.available_flows {
|
||||||
|
if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() {
|
||||||
|
flow_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if flow_changed && old_flow != self.settings.selected_flow {
|
||||||
|
self.auto_load_keys = true;
|
||||||
|
}
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
if ui.add_sized([ui.available_width(), button_height], egui::Button::new(egui::RichText::new("🔄 Refresh Keys").size(14.0))).clicked() && !self.loading {
|
||||||
|
if !self.settings.selected_flow.is_empty() {
|
||||||
|
self.load_keys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} else {
|
||||||
|
ui.horizontal(|ui| {
|
||||||
|
ui.label(egui::RichText::new("Current Flow:").size(16.0));
|
||||||
|
ui.add_space(10.0);
|
||||||
|
|
||||||
|
let mut flow_changed = false;
|
||||||
|
let old_flow = self.settings.selected_flow.clone();
|
||||||
|
|
||||||
|
egui::ComboBox::from_id_salt("flow_selector")
|
||||||
|
.selected_text(if self.settings.selected_flow.is_empty() { "Select flow..." } else { &self.settings.selected_flow })
|
||||||
|
.width(200.0)
|
||||||
|
.show_ui(ui, |ui| {
|
||||||
|
for flow in &self.available_flows {
|
||||||
|
if ui.selectable_value(&mut self.settings.selected_flow, flow.clone(), egui::RichText::new(flow).size(14.0)).clicked() {
|
||||||
|
flow_changed = true;
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
if flow_changed && old_flow != self.settings.selected_flow {
|
||||||
|
self.auto_load_keys = true;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
ui.add_space(8.0);
|
||||||
|
|
||||||
|
if ui.add_sized([120.0, button_height], egui::Button::new(egui::RichText::new("🔄 Refresh Keys").size(14.0))).clicked() && !self.loading {
|
||||||
|
if !self.settings.selected_flow.is_empty() {
|
||||||
|
self.load_keys();
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
});
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save window position
|
||||||
|
if let Some(response) = response {
|
||||||
|
if let Some(pos) = response.response.rect.left_top().into() {
|
||||||
|
self.windows.flow_selector_pos = Some(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_statistics_window(&mut self, ctx: &egui::Context, is_mobile: bool) {
|
||||||
|
if !self.windows.show_statistics || self.admin_state.keys.is_empty() {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window = egui::Window::new("📊 Statistics")
|
||||||
|
.resizable(true)
|
||||||
|
.collapsible(true)
|
||||||
|
.default_width(if is_mobile { 300.0 } else { 500.0 })
|
||||||
|
.default_height(200.0);
|
||||||
|
|
||||||
|
// Use saved position or smart default
|
||||||
|
let (flow_pos, stats_pos, keys_pos, bulk_pos) = self.get_smart_window_positions(ctx, is_mobile);
|
||||||
|
let pos = self.windows.statistics_pos.unwrap_or(stats_pos);
|
||||||
|
window = window.current_pos(pos);
|
||||||
|
|
||||||
|
let response = window.show(ctx, |ui| {
|
||||||
|
self.render_statistics(ui, is_mobile);
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save window position
|
||||||
|
if let Some(response) = response {
|
||||||
|
if let Some(pos) = response.response.rect.left_top().into() {
|
||||||
|
self.windows.statistics_pos = Some(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
|
||||||
|
fn show_bulk_actions_window(&mut self, ctx: &egui::Context, is_mobile: bool, button_height: f32) {
|
||||||
|
if !self.windows.show_bulk_actions {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let selected_count = self.admin_state.selected_servers.values().filter(|&&v| v).count();
|
||||||
|
if selected_count == 0 {
|
||||||
|
self.windows.show_bulk_actions = false;
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window = egui::Window::new(&format!("📋 Bulk Actions ({} selected)", selected_count))
|
||||||
|
.resizable(true)
|
||||||
|
.collapsible(true)
|
||||||
|
.default_width(if is_mobile { 300.0 } else { 350.0 })
|
||||||
|
.default_height(150.0);
|
||||||
|
|
||||||
|
// Use saved position or smart default
|
||||||
|
let (flow_pos, stats_pos, keys_pos, bulk_pos) = self.get_smart_window_positions(ctx, is_mobile);
|
||||||
|
let pos = self.windows.bulk_actions_pos.unwrap_or(bulk_pos);
|
||||||
|
window = window.current_pos(pos);
|
||||||
|
|
||||||
|
let response = window.show(ctx, |ui| {
|
||||||
|
let action = self.render_bulk_actions(ui, is_mobile, button_height);
|
||||||
|
if action != BulkAction::None {
|
||||||
|
self.pending_bulk_action = action;
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save window position
|
||||||
|
if let Some(response) = response {
|
||||||
|
if let Some(pos) = response.response.rect.left_top().into() {
|
||||||
|
self.windows.bulk_actions_pos = Some(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
fn show_keys_table_window(&mut self, ctx: &egui::Context, is_mobile: bool, button_height: f32) {
|
||||||
|
if !self.windows.show_keys_table {
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
let mut window = egui::Window::new("🔑 SSH Keys")
|
||||||
|
.resizable(true)
|
||||||
|
.collapsible(true)
|
||||||
|
.default_width(if is_mobile { 380.0 } else { 900.0 })
|
||||||
|
.default_height(if is_mobile { 500.0 } else { 700.0 })
|
||||||
|
.scroll([false, true]); // Enable vertical scrolling
|
||||||
|
|
||||||
|
// Use saved position or smart default
|
||||||
|
let (flow_pos, stats_pos, keys_pos, bulk_pos) = self.get_smart_window_positions(ctx, is_mobile);
|
||||||
|
let pos = self.windows.keys_table_pos.unwrap_or(keys_pos);
|
||||||
|
window = window.current_pos(pos);
|
||||||
|
|
||||||
|
let response = window.show(ctx, |ui| {
|
||||||
|
// Search and Filter section at the top
|
||||||
|
if !self.admin_state.keys.is_empty() {
|
||||||
|
ui.group(|ui| {
|
||||||
|
ui.set_min_width(ui.available_width());
|
||||||
|
self.render_search_controls(ui, is_mobile);
|
||||||
|
});
|
||||||
|
ui.add_space(10.0);
|
||||||
|
}
|
||||||
|
|
||||||
|
// Keys content
|
||||||
|
if !self.admin_state.filtered_keys.is_empty() {
|
||||||
|
let action = self.render_keys_table(ui, is_mobile, button_height);
|
||||||
|
if action != KeyAction::None {
|
||||||
|
self.pending_key_action = action;
|
||||||
|
}
|
||||||
|
} else if !self.admin_state.keys.is_empty() {
|
||||||
|
self.render_empty_state(ui);
|
||||||
|
} else {
|
||||||
|
ui.vertical_centered(|ui| {
|
||||||
|
ui.add_space(50.0);
|
||||||
|
ui.label(egui::RichText::new("🔑").size(48.0).color(egui::Color32::GRAY));
|
||||||
|
ui.label(egui::RichText::new("No keys loaded").size(18.0).color(egui::Color32::GRAY));
|
||||||
|
ui.label(egui::RichText::new("Select a flow to load keys").size(14.0).color(egui::Color32::DARK_GRAY));
|
||||||
|
});
|
||||||
|
}
|
||||||
|
});
|
||||||
|
|
||||||
|
// Save window position
|
||||||
|
if let Some(response) = response {
|
||||||
|
if let Some(pos) = response.response.rect.left_top().into() {
|
||||||
|
self.windows.keys_table_pos = Some(pos);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}
|
||||||
fn load_flows(&mut self) {
|
fn load_flows(&mut self) {
|
||||||
self.status_message = "Loading flows...".to_string();
|
self.status_message = "Loading flows...".to_string();
|
||||||
|
|
||||||
|
Reference in New Issue
Block a user