feat(standalone): floating sheet navigator panel backed by a C++ hierarchy export (sheet-panel)
Canvas-only eeschema sessions (read-only viewers, hide-UI editors) could not reach the wx hierarchy pane, so a viewer of a hierarchical design was stuck on the entry sheet. Adds the React stand-in (SheetPanel) + the bridge: - eeschema_embind: kicadSheetsGetTree() (SCHEMATIC::Hierarchy(), page-number ordered: instance KIID path / parent / Sheetname / file / page / depth) and kicadSheetsEnter(path) (validated synchronously, applied on the coroutine via SCH_ACTIONS::changeSheet — same route as the wx pane, history intact). OnSchSheetChanged pushes window.kicadCollab.onSheetsState after EVERY navigation, wx-driven included, so the panel is event-driven. - kicad → 9ab93b838e: navigate actions allowlisted for read-only viewers. - SheetPanel.tsx: LayerPanel shell conventions; rendered + menu row only when the hierarchy has >1 sheet instance; viewers boot it collapsed. - tests: fixtures/demo/hier (root → Power, IO → Sub) + web/sheet-panel.spec (list/order/depth, navigate + collab rebind, menu toggle/close, flat schematic has no panel, bridge-driven navigation updates the panel); SheetPanel.test.ts for the wire parser. Co-Authored-By: Claude Fable 5 <noreply@anthropic.com> Claude-Session: https://claude.ai/code/session_01CtN6ASBvMGNbjPqY5boycg
This commit is contained in:
parent
fab6108cbb
commit
f3119e690f
12 changed files with 762 additions and 1 deletions
44
web/standalone/src/components/SheetPanel.test.ts
Normal file
44
web/standalone/src/components/SheetPanel.test.ts
Normal file
|
|
@ -0,0 +1,44 @@
|
|||
import { describe, expect, it } from "vitest";
|
||||
import { hasSheetsBridge, parseSheetsState } from "./SheetPanel";
|
||||
|
||||
/** Sheet navigator bridge parsing (sheet-panel) — the wire shape emitted by
|
||||
* eeschema_embind.cpp's sheetsStateJson / kicadSheetsGetTree. */
|
||||
describe("parseSheetsState", () => {
|
||||
it("parses the C++ wire shape, keeping row order (page-number sorted)", () => {
|
||||
const s = parseSheetsState(
|
||||
JSON.stringify({
|
||||
current: "/root",
|
||||
sheets: [
|
||||
{ path: "/root", parent: "", name: "Arduino Leonardo", file: "/p/root.kicad_sch", page: "1", depth: 0 },
|
||||
{ path: "/root/aaa", parent: "/root", name: "Headers", file: "/p/Headers.kicad_sch", page: "2", depth: 1 },
|
||||
{ path: "/root/bbb", parent: "/root", name: "Power", file: "/p/Power.kicad_sch", page: "3", depth: 1 },
|
||||
],
|
||||
}),
|
||||
);
|
||||
expect(s?.current).toBe("/root");
|
||||
expect(s?.sheets.map((r) => r.name)).toEqual(["Arduino Leonardo", "Headers", "Power"]);
|
||||
expect(s?.sheets[1]).toMatchObject({ parent: "/root", depth: 1, page: "2" });
|
||||
});
|
||||
|
||||
it("tolerates missing optional fields and drops rows without a path", () => {
|
||||
const s = parseSheetsState(
|
||||
JSON.stringify({ current: "/r", sheets: [{ path: "/r" }, { name: "no path" }, null] }),
|
||||
);
|
||||
expect(s?.sheets).toEqual([{ path: "/r", parent: "", name: "", file: "", page: "", depth: 0 }]);
|
||||
});
|
||||
|
||||
it("returns null for garbage / an empty bridge answer", () => {
|
||||
expect(parseSheetsState("")).toBeNull();
|
||||
expect(parseSheetsState("null")).toBeNull();
|
||||
expect(parseSheetsState('{"sheets":[]}')).toBeNull();
|
||||
expect(parseSheetsState("{not json")).toBeNull();
|
||||
});
|
||||
});
|
||||
|
||||
describe("hasSheetsBridge", () => {
|
||||
it("needs both exports (older bundles / non-eeschema frames have neither)", () => {
|
||||
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "", kicadSheetsEnter: () => true })).toBe(true);
|
||||
expect(hasSheetsBridge({ kicadSheetsGetTree: () => "" })).toBe(false);
|
||||
expect(hasSheetsBridge(undefined)).toBe(false);
|
||||
});
|
||||
});
|
||||
259
web/standalone/src/components/SheetPanel.tsx
Normal file
259
web/standalone/src/components/SheetPanel.tsx
Normal file
|
|
@ -0,0 +1,259 @@
|
|||
import * as React from "react";
|
||||
import { ChevronDown, ChevronRight, FileText, X } from "lucide-react";
|
||||
import { useDraggablePanel } from "@/components/useDraggablePanel";
|
||||
|
||||
/**
|
||||
* Floating sheet navigator (sheet-panel): the canvas-only stand-in for the
|
||||
* docked wx HIERARCHY_PANE that kicadSetChrome(false) hides. Lists the loaded
|
||||
* schematic hierarchy — every sheet INSTANCE, page-number ordered, indented by
|
||||
* depth, like the wx pane — and navigates through the sheet bridge
|
||||
* (eeschema builds only — kicadSheetsGetTree / kicadSheetsEnter), updating
|
||||
* event-driven from the C++ `window.kicadCollab.onSheetsState` push that
|
||||
* follows every navigation (ours, or a wx-driven one: double-clicking a sheet
|
||||
* symbol, the toolbar arrows).
|
||||
*
|
||||
* Same draggable-panel conventions as the layer panel (viewer-panels):
|
||||
* header = drag handle, collapse-to-header, position/collapse persisted,
|
||||
* always-onscreen restore via useDraggablePanel.
|
||||
*/
|
||||
|
||||
const PANEL_POS_KEY = "pcbjam:sheet-panel-pos";
|
||||
const PANEL_COLLAPSED_KEY = "pcbjam:sheet-panel-collapsed";
|
||||
const PANEL_W = 256; // w-64
|
||||
const PANEL_HEADER_H = 36;
|
||||
|
||||
export interface SheetRow {
|
||||
/** KIID path of this sheet INSTANCE — the navigation key. */
|
||||
path: string;
|
||||
/** KIID path of the enclosing instance; "" for the root. */
|
||||
parent: string;
|
||||
name: string;
|
||||
/** Absolute MEMFS path of the sheet's .kicad_sch. */
|
||||
file: string;
|
||||
page: string;
|
||||
depth: number;
|
||||
}
|
||||
|
||||
export interface SheetsState {
|
||||
current: string;
|
||||
sheets: SheetRow[];
|
||||
}
|
||||
|
||||
export interface SheetsModule {
|
||||
kicadSheetsGetTree(): string;
|
||||
kicadSheetsEnter(path: string): boolean | Promise<boolean>;
|
||||
}
|
||||
|
||||
interface SheetsWindow {
|
||||
kicadCollab?: { onSheetsState?: (json: string) => void };
|
||||
}
|
||||
|
||||
/** True when the loaded wasm exposes the sheet bridge (eeschema builds). */
|
||||
export function hasSheetsBridge(mod: unknown): mod is SheetsModule {
|
||||
const m = mod as Partial<SheetsModule> | undefined;
|
||||
return (
|
||||
typeof m?.kicadSheetsGetTree === "function" &&
|
||||
typeof m?.kicadSheetsEnter === "function"
|
||||
);
|
||||
}
|
||||
|
||||
export function parseSheetsState(json: string): SheetsState | null {
|
||||
try {
|
||||
const v: unknown = JSON.parse(json);
|
||||
if (!v || typeof v !== "object") return null;
|
||||
const o = v as { current?: unknown; sheets?: unknown };
|
||||
if (typeof o.current !== "string" || !Array.isArray(o.sheets)) return null;
|
||||
const sheets: SheetRow[] = [];
|
||||
for (const s of o.sheets) {
|
||||
const r = s as Partial<SheetRow> | null;
|
||||
if (!r || typeof r.path !== "string") continue;
|
||||
sheets.push({
|
||||
path: r.path,
|
||||
parent: typeof r.parent === "string" ? r.parent : "",
|
||||
name: typeof r.name === "string" ? r.name : "",
|
||||
file: typeof r.file === "string" ? r.file : "",
|
||||
page: typeof r.page === "string" ? r.page : "",
|
||||
depth: typeof r.depth === "number" && r.depth >= 0 ? r.depth : 0,
|
||||
});
|
||||
}
|
||||
return { current: o.current, sheets };
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}
|
||||
|
||||
/** Basename of a MEMFS/relative path, for the row tooltip. */
|
||||
function fileLabel(file: string): string {
|
||||
const i = file.lastIndexOf("/");
|
||||
return i >= 0 ? file.slice(i + 1) : file;
|
||||
}
|
||||
|
||||
export function SheetPanel({
|
||||
mod,
|
||||
defaultCollapsed,
|
||||
onClose,
|
||||
}: {
|
||||
mod: SheetsModule;
|
||||
/** Boot state when no per-browser choice is stored (read-only sessions
|
||||
* start as a collapsed header — viewer-panels). */
|
||||
defaultCollapsed?: boolean;
|
||||
onClose: () => void;
|
||||
}) {
|
||||
const rootRef = React.useRef<HTMLDivElement | null>(null);
|
||||
const drag = useDraggablePanel({
|
||||
storageKey: PANEL_POS_KEY,
|
||||
handleWidth: PANEL_W,
|
||||
handleHeight: PANEL_HEADER_H,
|
||||
});
|
||||
const [collapsed, setCollapsedState] = React.useState<boolean>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(PANEL_COLLAPSED_KEY);
|
||||
if (stored !== null) return stored === "1";
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return defaultCollapsed === true;
|
||||
});
|
||||
const setCollapsed = (v: boolean) => {
|
||||
setCollapsedState(v);
|
||||
try {
|
||||
localStorage.setItem(PANEL_COLLAPSED_KEY, v ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
};
|
||||
|
||||
const [state, setState] = React.useState<SheetsState | null>(() =>
|
||||
parseSheetsState(mod.kicadSheetsGetTree() || "null"),
|
||||
);
|
||||
|
||||
// Event-driven refresh: the C++ side pushes the fresh state after every
|
||||
// navigation (the switch runs on the coroutine — a synchronous re-read
|
||||
// right after the call would still see the old sheet). Spread-preserving
|
||||
// install, same etiquette as the layer/presence bridges.
|
||||
React.useEffect(() => {
|
||||
const win = window as SheetsWindow;
|
||||
win.kicadCollab = {
|
||||
...win.kicadCollab,
|
||||
onSheetsState: (json) => {
|
||||
const s = parseSheetsState(json);
|
||||
if (s) setState(s);
|
||||
},
|
||||
};
|
||||
// Re-read on mount: the panel can open after a wx-driven navigation
|
||||
// that happened while no listener was installed.
|
||||
const s = parseSheetsState(mod.kicadSheetsGetTree() || "null");
|
||||
if (s) setState(s);
|
||||
return () => {
|
||||
if (win.kicadCollab) delete win.kicadCollab.onSheetsState;
|
||||
};
|
||||
}, [mod]);
|
||||
|
||||
const enter = (path: string) => {
|
||||
// Optimistic highlight for a snappy click; the onSheetsState push corrects.
|
||||
setState((s) => (s ? { ...s, current: path } : s));
|
||||
void mod.kicadSheetsEnter(path);
|
||||
};
|
||||
|
||||
// Default anchor: below the layer panel's slot (right column) — a schematic
|
||||
// session has no layer panel, so this sits where that would.
|
||||
const style: React.CSSProperties = drag.pos
|
||||
? { left: drag.pos.x, top: drag.pos.y }
|
||||
: { right: 12, top: 56 };
|
||||
|
||||
const single = (state?.sheets.length ?? 0) <= 1;
|
||||
|
||||
return (
|
||||
<div
|
||||
ref={rootRef}
|
||||
data-testid="sheet-panel"
|
||||
className="absolute z-40 flex w-64 flex-col overflow-hidden rounded-xl bg-white/95 text-neutral-900 shadow-2xl ring-1 ring-inset ring-black/10 backdrop-blur-sm dark:bg-neutral-950/90 dark:text-white dark:ring-white/15"
|
||||
style={style}
|
||||
>
|
||||
{/* Header = drag handle. Interactive children stop pointerdown so they
|
||||
don't start a drag. */}
|
||||
<div
|
||||
data-testid="sheet-panel-header"
|
||||
className="flex cursor-grab select-none items-center gap-2 px-3 py-2 text-xs font-semibold active:cursor-grabbing"
|
||||
style={{ touchAction: "none" }}
|
||||
title="Sheets — drag to move"
|
||||
onPointerDown={(e) => drag.onPointerDown(e, rootRef.current!.getBoundingClientRect())}
|
||||
onPointerMove={(e) => void drag.onPointerMove(e)}
|
||||
onPointerUp={() => void drag.onPointerUp()}
|
||||
>
|
||||
<button
|
||||
data-testid="sheet-panel-collapse"
|
||||
aria-expanded={!collapsed}
|
||||
title={collapsed ? "Expand" : "Collapse to header"}
|
||||
onPointerDown={(e) => e.stopPropagation()}
|
||||
onClick={() => setCollapsed(!collapsed)}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
{collapsed ? <ChevronRight size={14} /> : <ChevronDown size={14} />}
|
||||
</button>
|
||||
<span>Sheets</span>
|
||||
{state && state.sheets.length > 1 && (
|
||||
<span className="text-[10px] font-normal text-neutral-400 dark:text-white/40">
|
||||
{state.sheets.length}
|
||||
</span>
|
||||
)}
|
||||
<span className="ml-auto flex items-center gap-0.5" onPointerDown={(e) => e.stopPropagation()}>
|
||||
<button
|
||||
data-testid="sheet-panel-close"
|
||||
title="Close"
|
||||
onClick={onClose}
|
||||
className="rounded p-0.5 text-neutral-500 hover:bg-black/5 hover:text-neutral-900 dark:text-white/60 dark:hover:bg-white/10 dark:hover:text-white"
|
||||
>
|
||||
<X size={14} />
|
||||
</button>
|
||||
</span>
|
||||
</div>
|
||||
|
||||
{!collapsed && (
|
||||
<div data-testid="sheet-panel-list" className="max-h-[60vh] overflow-y-auto pb-1">
|
||||
{!state && (
|
||||
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
|
||||
Sheet hierarchy isn't available yet.
|
||||
</p>
|
||||
)}
|
||||
{state && single && (
|
||||
<p className="px-3 pb-3 text-xs text-neutral-500 dark:text-white/50">
|
||||
This schematic has no sub-sheets.
|
||||
</p>
|
||||
)}
|
||||
{state?.sheets.map((s) => {
|
||||
const active = state.current === s.path;
|
||||
return (
|
||||
<button
|
||||
key={s.path}
|
||||
data-testid="sheet-row"
|
||||
data-sheet-path={s.path}
|
||||
data-active={active || undefined}
|
||||
aria-current={active ? "page" : undefined}
|
||||
title={`${fileLabel(s.file)} — page ${s.page}`}
|
||||
onClick={() => enter(s.path)}
|
||||
className={`flex w-full items-center gap-2 border-t border-black/5 px-3 py-1 text-left text-xs hover:text-sky-600 dark:border-white/5 dark:hover:text-sky-300 ${
|
||||
active ? "bg-sky-500/10 dark:bg-sky-400/10" : ""
|
||||
}`}
|
||||
style={{ paddingLeft: 12 + Math.min(s.depth, 8) * 12 }}
|
||||
>
|
||||
<FileText
|
||||
size={13}
|
||||
className={`shrink-0 ${
|
||||
active ? "text-sky-600 dark:text-sky-300" : "text-neutral-400 dark:text-white/40"
|
||||
}`}
|
||||
/>
|
||||
<span className={`min-w-0 flex-1 truncate ${active ? "font-semibold" : ""}`}>
|
||||
{s.name || fileLabel(s.file)}
|
||||
</span>
|
||||
<span className="shrink-0 tabular-nums text-[10px] text-neutral-400 dark:text-white/40">
|
||||
{s.page}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
@ -83,6 +83,7 @@ import { CommentLayer } from "@/components/CommentLayer";
|
|||
import { hasTunerBridge, PresenceTuner, type TunerModule } from "@/components/PresenceTuner";
|
||||
import { hasLayersBridge, LayerPanel, type LayersModule } from "@/components/LayerPanel";
|
||||
import { SelectionInspector } from "@/components/SelectionInspector";
|
||||
import { hasSheetsBridge, SheetPanel, type SheetsModule } from "@/components/SheetPanel";
|
||||
import { bindLocalSelectionFeed } from "@/wasm/collab/local-selection";
|
||||
import {
|
||||
type SheetCollabManager,
|
||||
|
|
@ -127,6 +128,7 @@ import {
|
|||
INSPECTOR_OPEN_KEY,
|
||||
LAYERS_OPEN_KEY,
|
||||
LIB_KIND_FOR_TOOL,
|
||||
SHEETS_OPEN_KEY,
|
||||
} from "@/components/wasm-tool/ui-helpers";
|
||||
|
||||
/**
|
||||
|
|
@ -382,6 +384,27 @@ export function WasmTool({
|
|||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
// Sheet navigator (sheet-panel): eeschema's stand-in for the wx hierarchy
|
||||
// pane. Open by default like the other viewer panels, but only ever RENDERS
|
||||
// when the hierarchy has sub-sheets (see sheetsMod) — a flat schematic
|
||||
// shouldn't pay for an empty panel.
|
||||
const [sheetsOpen, setSheetsOpenState] = React.useState<boolean>(() => {
|
||||
try {
|
||||
const stored = localStorage.getItem(SHEETS_OPEN_KEY);
|
||||
if (stored !== null) return stored === "1";
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
return true;
|
||||
});
|
||||
const setSheetsOpen = React.useCallback((v: boolean) => {
|
||||
setSheetsOpenState(v);
|
||||
try {
|
||||
localStorage.setItem(SHEETS_OPEN_KEY, v ? "1" : "0");
|
||||
} catch {
|
||||
/* private mode */
|
||||
}
|
||||
}, []);
|
||||
// A doc session that connected but has not been ADOPTED by an owner yet
|
||||
// (collab handle / sheet manager). Owned here so a boot failure, an
|
||||
// open-never-settled degrade, or unmount can destroy it instead of leaking
|
||||
|
|
@ -1510,6 +1533,22 @@ export function WasmTool({
|
|||
return hasLayersBridge(mod) ? mod : null;
|
||||
}, [ready, tool]);
|
||||
|
||||
// Sheet bridge (sheet-panel), eeschema sessions only. Re-evaluated on every
|
||||
// sheet switch (activeSheetPath) so a hierarchy that only gains sub-sheets
|
||||
// later ("Add Sheet") surfaces the panel; hidden for a flat schematic.
|
||||
const sheetsMod = React.useMemo<SheetsModule | null>(() => {
|
||||
if (!ready || tool !== "eeschema") return null;
|
||||
const mod = (window as { Module?: unknown }).Module;
|
||||
if (!hasSheetsBridge(mod)) return null;
|
||||
try {
|
||||
const raw = mod.kicadSheetsGetTree() || "null";
|
||||
const parsed = JSON.parse(raw) as { sheets?: unknown[] } | null;
|
||||
return (parsed?.sheets?.length ?? 0) > 1 ? mod : null;
|
||||
} catch {
|
||||
return null;
|
||||
}
|
||||
}, [ready, tool, activeSheetPath]);
|
||||
|
||||
// Apply the chrome-visibility state to the wasm frame. A LAYOUT effect with
|
||||
// a synchronous first attempt: `ready` unmounts the opaque boot overlay in
|
||||
// this same commit, and a passive effect would let one frame of full chrome
|
||||
|
|
@ -1629,6 +1668,9 @@ export function WasmTool({
|
|||
hasLayers={layersMod !== null}
|
||||
layersOpen={layersOpen}
|
||||
setLayersOpen={setLayersOpen}
|
||||
hasSheets={sheetsMod !== null}
|
||||
sheetsOpen={sheetsOpen}
|
||||
setSheetsOpen={setSheetsOpen}
|
||||
inspectorOpen={inspectorOpen}
|
||||
setInspectorOpen={setInspectorOpen}
|
||||
canToggleChrome={setChromeFn !== null}
|
||||
|
|
@ -1665,6 +1707,13 @@ export function WasmTool({
|
|||
onClose={() => setLayersOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{ready && effectiveChromeHidden && sheetsOpen && sheetsMod && (
|
||||
<SheetPanel
|
||||
mod={sheetsMod}
|
||||
defaultCollapsed={readOnly}
|
||||
onClose={() => setSheetsOpen(false)}
|
||||
/>
|
||||
)}
|
||||
{ready &&
|
||||
effectiveChromeHidden &&
|
||||
inspectorOpen &&
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import {
|
|||
Crosshair,
|
||||
EyeOff,
|
||||
Layers,
|
||||
ListTree,
|
||||
Moon,
|
||||
PanelsTopLeft,
|
||||
RefreshCw,
|
||||
|
|
@ -151,6 +152,9 @@ export function SessionMenu({
|
|||
hasLayers,
|
||||
layersOpen,
|
||||
setLayersOpen,
|
||||
hasSheets,
|
||||
sheetsOpen,
|
||||
setSheetsOpen,
|
||||
inspectorOpen,
|
||||
setInspectorOpen,
|
||||
canToggleChrome,
|
||||
|
|
@ -180,6 +184,10 @@ export function SessionMenu({
|
|||
hasLayers: boolean;
|
||||
layersOpen: boolean;
|
||||
setLayersOpen: (v: boolean) => void;
|
||||
/** The sheet bridge is available AND the schematic has sub-sheets. */
|
||||
hasSheets: boolean;
|
||||
sheetsOpen: boolean;
|
||||
setSheetsOpen: (v: boolean) => void;
|
||||
inspectorOpen: boolean;
|
||||
setInspectorOpen: (v: boolean) => void;
|
||||
/** The loaded bundle exports kicadSetChrome. */
|
||||
|
|
@ -272,6 +280,18 @@ export function SessionMenu({
|
|||
<span>{layersOpen ? "Hide layers" : "Layers"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && hasSheets && (
|
||||
<button
|
||||
data-testid="sheet-panel-toggle"
|
||||
aria-pressed={sheetsOpen}
|
||||
className={overlayRowClass}
|
||||
title="Schematic sheets — navigate the hierarchy"
|
||||
onClick={() => setSheetsOpen(!sheetsOpen)}
|
||||
>
|
||||
<ListTree size={14} className="shrink-0 text-neutral-400 dark:text-white/50" />
|
||||
<span>{sheetsOpen ? "Hide sheets" : "Sheets"}</span>
|
||||
</button>
|
||||
)}
|
||||
{effectiveChromeHidden && (tool === "pcbnew" || tool === "eeschema") && (
|
||||
<button
|
||||
data-testid="inspector-panel-toggle"
|
||||
|
|
|
|||
|
|
@ -32,6 +32,7 @@ export function show3DOpener(win: Window): (() => boolean) | null {
|
|||
// Viewer panels (viewer-panels): floating layer selector + selection
|
||||
// inspector open-state persistence, mirroring the comments panel's keys.
|
||||
export const LAYERS_OPEN_KEY = "pcbjam:layers-panel-open";
|
||||
export const SHEETS_OPEN_KEY = "pcbjam:sheet-panel-open";
|
||||
export const INSPECTOR_OPEN_KEY = "pcbjam:inspector-panel-open";
|
||||
|
||||
// Tooltip only — the matcher accepts both chords on any platform.
|
||||
|
|
|
|||
Loading…
Reference in a new issue