feat(ui): scroll the focused sidebar panel + refine the nav overlay (#21, ADR-0046 DC3 + DC2)
DC3 — navigation-mode scroll: the focused Tables / Relationships panel scrolls (Up/Down by a line, PageUp/PageDown by its visible-row count). Per-panel offsets are clamped to content at render time, and the renderer reports each panel's visible rows for paging — mirroring the output panel's scroll. render_items_panel / render_relationships_panel take &mut App, count their rows, and store+clamp the offset before building the borrowing lines. DC2 refinement: the expand-on-focus overlay now clears only the sidebar strip plus a one-column gutter, leaving the base output/input/hint visible (unchanged) to the right rather than blanking the whole area — truer to "underneath keeps its layout", with the gutter keeping the cut-off edge clean (chosen after eyeballing both variants). ADR DC2 and the overlay snapshot updated to match. Tests: line/page scroll move only the focused panel and clamp; the render clamps a past-the-end offset so the last row stays visible.
This commit is contained in:
@@ -320,14 +320,23 @@ input-editing arms and routes keys per DC3/DC4.
|
|||||||
|
|
||||||
### DC2 — Expand-on-focus as an overlay
|
### DC2 — Expand-on-focus as an overlay
|
||||||
|
|
||||||
A focused sidebar panel widens to **~40–50 columns**, rendered as an
|
A focused sidebar panel widens to a **45-column** overlay
|
||||||
**overlay**: the renderer draws a `Clear` over the affected right-column
|
(`NAV_EXPANDED_WIDTH`): the renderer `Clear`s the strip the expanded
|
||||||
region and paints the wide panel on top. The output/input/hint panels
|
panel occupies **plus a one-column gutter** (`NAV_OVERLAY_GUTTER`) and
|
||||||
underneath keep their exact layout — **unused and unchanging** while
|
paints the wide panel on top. The output/input/hint panels underneath
|
||||||
browsing — and are restored by the next frame on exit. This is cheap
|
keep their exact layout — **unused and unchanging** while browsing,
|
||||||
because the renderer is a pure function of `App` state: focus state
|
**still visible to the right** of the overlay (just partially occluded
|
||||||
selects the width and the overlay path. (The input underneath is
|
on the left) — and are restored fully by the next frame on exit. The
|
||||||
inactive in navigation mode, so occluding it is harmless.)
|
gutter keeps them from butting against the expanded panel's border so
|
||||||
|
the overlay edge reads cleanly. This is cheap because the renderer is a
|
||||||
|
pure function of `App` state: focus state selects the width and the
|
||||||
|
overlay path. (The input underneath is inactive in navigation mode.)
|
||||||
|
|
||||||
|
*Implementation note (2026-06-10):* a full-area clear (hiding the base
|
||||||
|
panels entirely during browse) was tried first and rejected — leaving
|
||||||
|
the base visible is truer to "underneath keep their layout," and the
|
||||||
|
one-column gutter resolves the only wrinkle (the panels' left edges
|
||||||
|
being cut by the overlay reading harshly without separation).
|
||||||
|
|
||||||
### DC3 — Scroll the focused panel; focus highlight
|
### DC3 — Scroll the focused panel; focus highlight
|
||||||
|
|
||||||
|
|||||||
+77
-2
@@ -323,6 +323,14 @@ pub struct App {
|
|||||||
/// diagram's side-by-side vs vertical layout choice. Defaults to
|
/// diagram's side-by-side vs vertical layout choice. Defaults to
|
||||||
/// `80` until the first render measures the real width.
|
/// `80` until the first render measures the real width.
|
||||||
pub last_output_width: u16,
|
pub last_output_width: u16,
|
||||||
|
/// Top visible row of the Tables / Relationships sidebar panels
|
||||||
|
/// while scrolled in navigation mode (ADR-0046 DC3), with the most
|
||||||
|
/// recent visible-row count the renderer reported for each (used to
|
||||||
|
/// page-scroll and to clamp the offset). `0` = showing from the top.
|
||||||
|
pub tables_scroll: usize,
|
||||||
|
pub relationships_scroll: usize,
|
||||||
|
pub last_tables_visible: usize,
|
||||||
|
pub last_relationships_visible: usize,
|
||||||
/// Prettified display name of the currently-open project,
|
/// Prettified display name of the currently-open project,
|
||||||
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
/// rendered in the status bar (P-NAME-3, ADR-0015 §2). `None`
|
||||||
/// during very-early startup before the runtime has opened a
|
/// during very-early startup before the runtime has opened a
|
||||||
@@ -491,6 +499,10 @@ impl App {
|
|||||||
last_output_visible: 0,
|
last_output_visible: 0,
|
||||||
last_output_total_wrapped: 0,
|
last_output_total_wrapped: 0,
|
||||||
last_output_width: 80,
|
last_output_width: 80,
|
||||||
|
tables_scroll: 0,
|
||||||
|
relationships_scroll: 0,
|
||||||
|
last_tables_visible: 0,
|
||||||
|
last_relationships_visible: 0,
|
||||||
project_name: None,
|
project_name: None,
|
||||||
project_is_temp: false,
|
project_is_temp: false,
|
||||||
fatal_message: None,
|
fatal_message: None,
|
||||||
@@ -973,12 +985,40 @@ impl App {
|
|||||||
/// (wired in DC3); every other key is inert because the command
|
/// (wired in DC3); every other key is inert because the command
|
||||||
/// input is occluded by the expanded sidebar overlay.
|
/// input is occluded by the expanded sidebar overlay.
|
||||||
fn handle_nav_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
fn handle_nav_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||||
if key.code == KeyCode::Esc {
|
match key.code {
|
||||||
self.nav_exit();
|
KeyCode::Esc => self.nav_exit(),
|
||||||
|
KeyCode::Up => self.nav_scroll(-1),
|
||||||
|
KeyCode::Down => self.nav_scroll(1),
|
||||||
|
KeyCode::PageUp => self.nav_scroll_page(-1),
|
||||||
|
KeyCode::PageDown => self.nav_scroll_page(1),
|
||||||
|
_ => {}
|
||||||
}
|
}
|
||||||
Vec::new()
|
Vec::new()
|
||||||
}
|
}
|
||||||
|
|
||||||
|
/// Scroll the focused sidebar panel by `lines` (ADR-0046 DC3); the
|
||||||
|
/// renderer clamps the offset to the panel's content on the next
|
||||||
|
/// frame, so over-scrolling is harmless.
|
||||||
|
const fn nav_scroll(&mut self, lines: i32) {
|
||||||
|
let slot = match self.nav_focus {
|
||||||
|
NavFocus::SidebarTables => &mut self.tables_scroll,
|
||||||
|
NavFocus::SidebarRelationships => &mut self.relationships_scroll,
|
||||||
|
NavFocus::Input => return,
|
||||||
|
};
|
||||||
|
*slot = slot.saturating_add_signed(lines as isize);
|
||||||
|
}
|
||||||
|
|
||||||
|
/// Page-scroll the focused panel by its last reported visible-row
|
||||||
|
/// count (ADR-0046 DC3).
|
||||||
|
fn nav_scroll_page(&mut self, dir: i32) {
|
||||||
|
let visible = match self.nav_focus {
|
||||||
|
NavFocus::SidebarTables => self.last_tables_visible,
|
||||||
|
NavFocus::SidebarRelationships => self.last_relationships_visible,
|
||||||
|
NavFocus::Input => return,
|
||||||
|
};
|
||||||
|
self.nav_scroll(dir * (visible.max(1) as i32));
|
||||||
|
}
|
||||||
|
|
||||||
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
fn handle_key(&mut self, key: KeyEvent) -> Vec<Action> {
|
||||||
// On Windows, key events fire for both Press and Release;
|
// On Windows, key events fire for both Press and Release;
|
||||||
// honour only Press to avoid double-handling. Other
|
// honour only Press to avoid double-handling. Other
|
||||||
@@ -5240,6 +5280,41 @@ mod tests {
|
|||||||
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
|
assert!(actions.is_empty(), "Enter does not submit in navigation mode");
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_scroll_keys_move_only_the_focused_panel() {
|
||||||
|
// ADR-0046 DC3: Up/Down line-scroll the focused sidebar panel.
|
||||||
|
let mut app = App::new();
|
||||||
|
app.nav_focus = NavFocus::SidebarTables;
|
||||||
|
app.update(key(KeyCode::Down));
|
||||||
|
app.update(key(KeyCode::Down));
|
||||||
|
assert_eq!(app.tables_scroll, 2);
|
||||||
|
assert_eq!(app.relationships_scroll, 0, "only the focused panel scrolls");
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.tables_scroll, 1);
|
||||||
|
// Up saturates at the top.
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
app.update(key(KeyCode::Up));
|
||||||
|
assert_eq!(app.tables_scroll, 0);
|
||||||
|
// Switching focus moves the other panel instead.
|
||||||
|
app.nav_focus = NavFocus::SidebarRelationships;
|
||||||
|
app.update(key(KeyCode::Down));
|
||||||
|
assert_eq!(app.relationships_scroll, 1);
|
||||||
|
assert_eq!(app.tables_scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn nav_page_scroll_uses_the_panels_visible_rows() {
|
||||||
|
// ADR-0046 DC3: PageUp/PageDown move by the last reported
|
||||||
|
// visible-row count.
|
||||||
|
let mut app = App::new();
|
||||||
|
app.nav_focus = NavFocus::SidebarTables;
|
||||||
|
app.last_tables_visible = 10;
|
||||||
|
app.update(key(KeyCode::PageDown));
|
||||||
|
assert_eq!(app.tables_scroll, 10);
|
||||||
|
app.update(key(KeyCode::PageUp));
|
||||||
|
assert_eq!(app.tables_scroll, 0);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
|
fn input_scroll_offset_resets_when_the_buffer_is_replaced() {
|
||||||
// ADR-0046 DA3: the horizontal scroll offset must not leak from
|
// ADR-0046 DA3: the horizontal scroll offset must not leak from
|
||||||
|
|||||||
+23
-23
@@ -1,29 +1,29 @@
|
|||||||
---
|
---
|
||||||
source: src/ui.rs
|
source: src/ui.rs
|
||||||
assertion_line: 2895
|
assertion_line: 2967
|
||||||
expression: snapshot
|
expression: snapshot
|
||||||
---
|
---
|
||||||
╭ Tables ───────────────────────────────────╮
|
╭ Tables ───────────────────────────────────╮ ─────────────────────────────────╮
|
||||||
│Customers │
|
│Customers │ │
|
||||||
│Orders │
|
│Orders │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ │
|
||||||
│ │
|
│ │ ─────────────────────────────────╯
|
||||||
│ │
|
│ │ ─────────────────────────────────╮
|
||||||
╰───────────────────────────────────────────╯
|
╰───────────────────────────────────────────╯ │
|
||||||
╭ Relationships ────────────────────────────╮
|
╭ Relationships ────────────────────────────╮ ─────────────────────────────────╯
|
||||||
│Customers_Orders │
|
│Customers_Orders │ ─────────────────────────────────╮
|
||||||
│ Customers.id -> │
|
│ Customers.id -> │ ` for a list │
|
||||||
│ Orders.customer_id │
|
│ Orders.customer_id │ │
|
||||||
╰───────────────────────────────────────────╯
|
╰───────────────────────────────────────────╯ ─────────────────────────────────╯
|
||||||
Project: Term Planner
|
Project: Term Planner
|
||||||
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
Enter submit · : advanced once · mode advanced switch · Ctrl-C quit
|
||||||
|
|||||||
@@ -120,21 +120,32 @@ pub fn render(app: &mut App, theme: &Theme, frame: &mut Frame<'_>) {
|
|||||||
/// one line, turning horizontal truncation into vertical scrolling.
|
/// one line, turning horizontal truncation into vertical scrolling.
|
||||||
const NAV_EXPANDED_WIDTH: u16 = 45;
|
const NAV_EXPANDED_WIDTH: u16 = 45;
|
||||||
|
|
||||||
|
/// Blank columns cleared to the right of the expanded sidebar overlay
|
||||||
|
/// (ADR-0046 DC2), separating it from the base panels left visible
|
||||||
|
/// behind it so the overlay's right edge reads cleanly.
|
||||||
|
const NAV_OVERLAY_GUTTER: u16 = 1;
|
||||||
|
|
||||||
/// Draw the focused sidebar, expanded, as an overlay over the left of
|
/// Draw the focused sidebar, expanded, as an overlay over the left of
|
||||||
/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background
|
/// the main content area (ADR-0046 DC2/DC3). `Clear` + a background
|
||||||
/// repaint hide the base render underneath; the two panels keep the
|
/// repaint hide the base render underneath; the two panels keep the
|
||||||
/// DB4 split. The focused panel is accent-bordered (DC3).
|
/// DB4 split. The focused panel is accent-bordered (DC3).
|
||||||
fn render_nav_sidebar_overlay(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_nav_sidebar_overlay(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
// Clear the whole main content region (the "affected right-column
|
// ADR-0046 DC2: clear the sidebar strip plus a one-column gutter and
|
||||||
// region", DC2) and repaint the background, so the base output /
|
// paint the expanded sidebar over it. The base output / input / hint
|
||||||
// input / hint do not show through half-occluded. They are restored
|
// stay visible to the right — unchanged, just partially occluded —
|
||||||
// unchanged on the next frame when navigation mode exits.
|
// and the gutter keeps them from butting against the sidebar's
|
||||||
frame.render_widget(ratatui::widgets::Clear, area);
|
// border. They are restored fully on the next frame when navigation
|
||||||
paint_background(theme, frame, area);
|
// mode exits.
|
||||||
|
|
||||||
// Paint the expanded sidebar over the left; the rest stays blank
|
|
||||||
// background while browsing.
|
|
||||||
let width = NAV_EXPANDED_WIDTH.min(area.width);
|
let width = NAV_EXPANDED_WIDTH.min(area.width);
|
||||||
|
let cleared_w = (width + NAV_OVERLAY_GUTTER).min(area.width);
|
||||||
|
let cleared = Rect {
|
||||||
|
x: area.x,
|
||||||
|
y: area.y,
|
||||||
|
width: cleared_w,
|
||||||
|
height: area.height,
|
||||||
|
};
|
||||||
|
frame.render_widget(ratatui::widgets::Clear, cleared);
|
||||||
|
paint_background(theme, frame, cleared);
|
||||||
let sidebar = Rect {
|
let sidebar = Rect {
|
||||||
x: area.x,
|
x: area.x,
|
||||||
y: area.y,
|
y: area.y,
|
||||||
@@ -739,7 +750,7 @@ fn paint_background(theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
|||||||
frame.render_widget(block, area);
|
frame.render_widget(block, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_items_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
@@ -755,6 +766,13 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
))
|
))
|
||||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
|
// ADR-0046 DC3: clamp + store the scroll before the (borrowing)
|
||||||
|
// lines are built. Visible rows and the content total are computed
|
||||||
|
// by counting (one row per table + one per index), so the `&mut
|
||||||
|
// app` writes finish before the immutable line borrows begin.
|
||||||
|
let visible = area.height.saturating_sub(2) as usize;
|
||||||
|
app.last_tables_visible = visible;
|
||||||
|
|
||||||
if app.tables.is_empty() {
|
if app.tables.is_empty() {
|
||||||
let placeholder = Paragraph::new(Line::from(Span::styled(
|
let placeholder = Paragraph::new(Line::from(Span::styled(
|
||||||
crate::t!("panel.tables_empty"),
|
crate::t!("panel.tables_empty"),
|
||||||
@@ -767,6 +785,14 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let total: usize = app
|
||||||
|
.tables
|
||||||
|
.iter()
|
||||||
|
.map(|t| 1 + app.schema_cache.table_indexes.get(t).map_or(0, Vec::len))
|
||||||
|
.sum();
|
||||||
|
let offset = app.tables_scroll.min(total.saturating_sub(visible));
|
||||||
|
app.tables_scroll = offset;
|
||||||
|
|
||||||
let highlight = app
|
let highlight = app
|
||||||
.current_table
|
.current_table
|
||||||
.as_ref()
|
.as_ref()
|
||||||
@@ -796,7 +822,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -805,7 +831,7 @@ fn render_items_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rec
|
|||||||
/// name, then the endpoints broken at the arrow to fit — every line
|
/// name, then the endpoints broken at the arrow to fit — every line
|
||||||
/// ellipsized past the inner width. Phase C adds focus + scroll for the
|
/// ellipsized past the inner width. Phase C adds focus + scroll for the
|
||||||
/// overflow; for now content beyond the panel's height is clipped.
|
/// overflow; for now content beyond the panel's height is clipped.
|
||||||
fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
fn render_relationships_panel(app: &mut App, theme: &Theme, frame: &mut Frame<'_>, area: Rect) {
|
||||||
let block = Block::default()
|
let block = Block::default()
|
||||||
.borders(Borders::ALL)
|
.borders(Borders::ALL)
|
||||||
.border_type(BorderType::Rounded)
|
.border_type(BorderType::Rounded)
|
||||||
@@ -821,6 +847,11 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a
|
|||||||
))
|
))
|
||||||
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
.style(Style::default().bg(theme.bg).fg(theme.fg));
|
||||||
|
|
||||||
|
// ADR-0046 DC3: clamp + store the scroll before the borrowing lines
|
||||||
|
// (three rows per relationship).
|
||||||
|
let visible = area.height.saturating_sub(2) as usize;
|
||||||
|
app.last_relationships_visible = visible;
|
||||||
|
|
||||||
if app.relationships.is_empty() {
|
if app.relationships.is_empty() {
|
||||||
let placeholder = Paragraph::new(Line::from(Span::styled(
|
let placeholder = Paragraph::new(Line::from(Span::styled(
|
||||||
crate::t!("panel.relationships_empty"),
|
crate::t!("panel.relationships_empty"),
|
||||||
@@ -833,6 +864,10 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a
|
|||||||
return;
|
return;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
let total = app.relationships.len() * 3;
|
||||||
|
let offset = app.relationships_scroll.min(total.saturating_sub(visible));
|
||||||
|
app.relationships_scroll = offset;
|
||||||
|
|
||||||
let inner_w = area.width.saturating_sub(2) as usize;
|
let inner_w = area.width.saturating_sub(2) as usize;
|
||||||
let name_style = Style::default().fg(theme.fg);
|
let name_style = Style::default().fg(theme.fg);
|
||||||
let detail_style = Style::default().fg(theme.muted);
|
let detail_style = Style::default().fg(theme.muted);
|
||||||
@@ -847,7 +882,7 @@ fn render_relationships_panel(app: &App, theme: &Theme, frame: &mut Frame<'_>, a
|
|||||||
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
|
let child = format!(" {}.{}", rel.child_table, rel.child_columns.join(", "));
|
||||||
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
|
lines.push(Line::from(Span::styled(ellipsize(&child, inner_w), detail_style)));
|
||||||
}
|
}
|
||||||
let paragraph = Paragraph::new(lines).block(block);
|
let paragraph = Paragraph::new(lines).block(block).scroll((offset as u16, 0));
|
||||||
frame.render_widget(paragraph, area);
|
frame.render_widget(paragraph, area);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -2894,6 +2929,31 @@ mod tests {
|
|||||||
assert!(!normal.add_modifier.contains(Modifier::BOLD));
|
assert!(!normal.add_modifier.contains(Modifier::BOLD));
|
||||||
}
|
}
|
||||||
|
|
||||||
|
#[test]
|
||||||
|
fn focused_tables_panel_scrolls_and_clamps() {
|
||||||
|
// ADR-0046 DC3: more tables than fit → a large offset reveals the
|
||||||
|
// lower entries and clamps so it can't scroll past the end.
|
||||||
|
let mut app = App::new();
|
||||||
|
app.tables = (0..30).map(|i| format!("Table{i:02}")).collect();
|
||||||
|
app.nav_focus = NavFocus::SidebarTables;
|
||||||
|
app.tables_scroll = 1000; // way past the end
|
||||||
|
let theme = Theme::dark();
|
||||||
|
let out = render_to_string(&mut app, &theme, 80, 24);
|
||||||
|
assert!(
|
||||||
|
out.contains("Table29"),
|
||||||
|
"the last table is visible after the offset clamps:\n{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
!out.contains("Table00"),
|
||||||
|
"the top tables are scrolled off:\n{out}"
|
||||||
|
);
|
||||||
|
assert!(
|
||||||
|
app.tables_scroll < 30,
|
||||||
|
"the stored offset was clamped to the content: {}",
|
||||||
|
app.tables_scroll
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
#[test]
|
#[test]
|
||||||
fn navigation_overlay_snapshot() {
|
fn navigation_overlay_snapshot() {
|
||||||
// The expanded overlay over a full-width base (sidebar hidden at
|
// The expanded overlay over a full-width base (sidebar hidden at
|
||||||
|
|||||||
Reference in New Issue
Block a user