feat(super-search): extend across the app — Processor group + portal palette
The editor's super search gains a Processor group: the portal's in-app views (labelled by the same portal.nav.* keys its sidebar uses), gated the way the portal itself is — admin (or a login-disabled single-user operator), and only in builds that mount the portal route-set. The index is a flavor seam: core and desktop ship it empty, so the group never renders where there is no portal to navigate to. The portal's ⌘K palette becomes the portal face of the same search: instead of a mock quick-actions endpoint whose rows didn't even navigate, it fuzzy-ranks the sidebar's flavor-aware destinations (plus the editor) with keyboard navigation, and jumps on select. The mock search API, handler, and fixtures are deleted.
This commit is contained in:
@@ -7897,13 +7897,12 @@ title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
goTo = "Go to"
|
||||
placeholder = "Search the portal…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
noMatchesDescription = "Try a different keyword."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
@@ -9733,6 +9732,7 @@ placeholder = "Search files, tools and settings…"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
|
||||
@@ -7987,13 +7987,12 @@ title = "Nothing here yet"
|
||||
|
||||
[portal.search]
|
||||
ariaLabel = "Search"
|
||||
placeholder = "Search Stirling — endpoints, pipelines, docs…"
|
||||
goTo = "Go to"
|
||||
placeholder = "Search the portal…"
|
||||
|
||||
[portal.search.empty]
|
||||
noActionsDescription = "Quick actions will appear here once they're available."
|
||||
noActionsTitle = "No quick actions"
|
||||
noMatches = "No matches for \"{{query}}\""
|
||||
noMatchesDescription = "Try a different keyword or browse the catalogue."
|
||||
noMatchesDescription = "Try a different keyword."
|
||||
|
||||
[portal.settings.groups]
|
||||
admin = "Admin"
|
||||
@@ -9855,6 +9854,7 @@ placeholder = "Search files, tools and settings…"
|
||||
|
||||
[superSearch.group]
|
||||
files = "Files"
|
||||
processor = "Processor"
|
||||
settings = "Settings"
|
||||
tools = "Tools"
|
||||
|
||||
|
||||
@@ -0,0 +1,21 @@
|
||||
/**
|
||||
* Search index for the admin portal ("Processor") destinations offered by the
|
||||
* global super search. Core and desktop builds ship no portal, so the index is
|
||||
* empty and the Processor group never renders; the proprietary build (which
|
||||
* mounts the portal as a route-set) shadows this with the real view list.
|
||||
*/
|
||||
export interface ProcessorSearchEntry {
|
||||
/** Portal view id — stable key for the result row. */
|
||||
id: string;
|
||||
/** i18n key for the view's display name (shared with the portal sidebar). */
|
||||
labelKey: string;
|
||||
labelFallback: string;
|
||||
/** In-app path to navigate to. Empty when externalUrl is set. */
|
||||
path: string;
|
||||
/** Opens in a new tab instead of navigating (e.g. hosted docs). */
|
||||
externalUrl?: string;
|
||||
/** Extra fuzzy-match terms beyond the label. */
|
||||
keywords?: string[];
|
||||
}
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = [];
|
||||
@@ -21,8 +21,9 @@ import type { StirlingFileStub } from "@app/types/fileContext";
|
||||
import type { ToolId } from "@app/types/toolId";
|
||||
import { SETTINGS_SEARCH_INDEX } from "@app/data/settingsSearchIndex";
|
||||
import { SETTINGS_SECTION_REGISTRY } from "@app/data/settingsSectionRegistry";
|
||||
import { PROCESSOR_SEARCH_INDEX } from "@app/data/processorSearchIndex";
|
||||
|
||||
export type SuperSearchGroupId = "files" | "tools" | "settings";
|
||||
export type SuperSearchGroupId = "files" | "tools" | "settings" | "processor";
|
||||
|
||||
export interface SuperSearchResult {
|
||||
/** Stable unique key across all groups. */
|
||||
@@ -46,7 +47,12 @@ export interface SuperSearchGroup {
|
||||
/** Per-group result caps so the dropdown stays scannable. */
|
||||
const GROUP_LIMIT = 6;
|
||||
/** Group display order in the dropdown. */
|
||||
const GROUP_ORDER: SuperSearchGroupId[] = ["files", "tools", "settings"];
|
||||
const GROUP_ORDER: SuperSearchGroupId[] = [
|
||||
"files",
|
||||
"tools",
|
||||
"settings",
|
||||
"processor",
|
||||
];
|
||||
|
||||
export interface UseSuperSearchResult {
|
||||
/** Non-empty groups, in display order. */
|
||||
@@ -244,24 +250,56 @@ export function useSuperSearch(
|
||||
.slice(0, GROUP_LIMIT);
|
||||
}, [trimmed, config, t, openSettings]);
|
||||
|
||||
// --- Processor (admin portal) results ------------------------------------
|
||||
const processorResults = useMemo<SuperSearchResult[]>(() => {
|
||||
if (!trimmed || PROCESSOR_SEARCH_INDEX.length === 0) return [];
|
||||
// The portal is an admin surface; mirror the settings modal's admin gate
|
||||
// (a login-disabled single-user deployment has a full-access operator).
|
||||
const isAdmin = config?.isAdmin ?? false;
|
||||
const loginEnabled = config?.enableLogin ?? false;
|
||||
if (!(isAdmin || !loginEnabled)) return [];
|
||||
return rankByFuzzy(PROCESSOR_SEARCH_INDEX, trimmed, [
|
||||
(e) => t(e.labelKey, e.labelFallback),
|
||||
(e) => e.labelFallback,
|
||||
(e) => e.keywords?.join(" ") ?? "",
|
||||
])
|
||||
.slice(0, GROUP_LIMIT)
|
||||
.map(({ item, score }) => ({
|
||||
key: `processor:${item.id}`,
|
||||
group: "processor" as const,
|
||||
title: t(item.labelKey, item.labelFallback),
|
||||
iconName: "grid-view-rounded",
|
||||
score,
|
||||
onSelect: () => {
|
||||
if (item.externalUrl) {
|
||||
window.open(item.externalUrl, "_blank", "noopener,noreferrer");
|
||||
} else {
|
||||
navigate(item.path);
|
||||
}
|
||||
},
|
||||
}));
|
||||
}, [trimmed, config, t, navigate]);
|
||||
|
||||
// --- Assemble ----------------------------------------------------------
|
||||
const groups = useMemo<SuperSearchGroup[]>(() => {
|
||||
const byId: Record<SuperSearchGroupId, SuperSearchResult[]> = {
|
||||
files: fileResults,
|
||||
tools: toolResults,
|
||||
settings: settingsResults,
|
||||
processor: processorResults,
|
||||
};
|
||||
const labels: Record<SuperSearchGroupId, string> = {
|
||||
files: t("superSearch.group.files", "Files"),
|
||||
tools: t("superSearch.group.tools", "Tools"),
|
||||
settings: t("superSearch.group.settings", "Settings"),
|
||||
processor: t("superSearch.group.processor", "Processor"),
|
||||
};
|
||||
return GROUP_ORDER.map((id) => ({
|
||||
id,
|
||||
label: labels[id],
|
||||
results: byId[id],
|
||||
})).filter((g) => g.results.length > 0);
|
||||
}, [fileResults, toolResults, settingsResults, t]);
|
||||
}, [fileResults, toolResults, settingsResults, processorResults, t]);
|
||||
|
||||
const flatResults = useMemo(() => groups.flatMap((g) => g.results), [groups]);
|
||||
|
||||
|
||||
@@ -0,0 +1,7 @@
|
||||
/**
|
||||
* Desktop inherits proprietary's app but must NOT ship the portal (see the
|
||||
* admin-route seam) — shadow the index back to core's empty list so the super
|
||||
* search never offers Processor destinations that don't exist on desktop.
|
||||
*/
|
||||
export type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
export { PROCESSOR_SEARCH_INDEX } from "@core/data/processorSearchIndex";
|
||||
@@ -1,13 +0,0 @@
|
||||
import { apiClient } from "@portal/api/http";
|
||||
|
||||
export interface QuickAction {
|
||||
group: "Jump to" | "Create" | "Theme";
|
||||
label: string;
|
||||
/** Keyboard hint shown to the right. */
|
||||
hint: string;
|
||||
}
|
||||
|
||||
/** GET /v1/search/quick-actions */
|
||||
export async function fetchQuickActions(): Promise<QuickAction[]> {
|
||||
return apiClient.local.json<QuickAction[]>("/v1/search/quick-actions");
|
||||
}
|
||||
@@ -68,3 +68,7 @@
|
||||
font-size: 0.6875rem;
|
||||
color: var(--color-text-5);
|
||||
}
|
||||
|
||||
.portal-search__item--active {
|
||||
background: var(--color-bg-hover);
|
||||
}
|
||||
|
||||
@@ -1,49 +1,91 @@
|
||||
import { useEffect, useMemo, useRef, useState } from "react";
|
||||
import { Button, EmptyState, Modal, Skeleton } from "@app/ui";
|
||||
import { Button, EmptyState, Modal } from "@app/ui";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { rankByFuzzy } from "@app/utils/fuzzySearch";
|
||||
import { useUI } from "@portal/contexts/UIContext";
|
||||
import { useAsync, useSectionFlags } from "@portal/hooks/useAsync";
|
||||
import { fetchQuickActions, type QuickAction } from "@portal/api/search";
|
||||
import { useView, type ViewId } from "@portal/contexts/ViewContext";
|
||||
import {
|
||||
GROUP_PRIMARY,
|
||||
GROUP_OPERATIONAL,
|
||||
GROUP_PLATFORM,
|
||||
type NavEntry,
|
||||
} from "@portal/components/sidebarGroups";
|
||||
import { SearchIcon } from "@portal/components/icons";
|
||||
import "@portal/components/SearchModal.css";
|
||||
|
||||
/**
|
||||
* The portal's ⌘K palette — the portal face of the global super search. It
|
||||
* searches the portal's own destinations (the same flavor-aware nav set the
|
||||
* sidebar shows, plus the editor) and navigates on select; the editor's top-bar
|
||||
* search is the same idea pointed at files/tools/settings/Processor.
|
||||
*/
|
||||
export function SearchModal() {
|
||||
const { t } = useTranslation();
|
||||
const { searchOpen, closeSearch } = useUI();
|
||||
const { setActiveView } = useView();
|
||||
const inputRef = useRef<HTMLInputElement | null>(null);
|
||||
const [query, setQuery] = useState("");
|
||||
const state = useAsync<QuickAction[]>(() => fetchQuickActions(), []);
|
||||
const { data: actions } = state;
|
||||
const { isLoading } = useSectionFlags(state);
|
||||
const [highlight, setHighlight] = useState(0);
|
||||
|
||||
useEffect(() => {
|
||||
if (searchOpen) {
|
||||
setQuery("");
|
||||
const t = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(t);
|
||||
setHighlight(0);
|
||||
const timer = setTimeout(() => inputRef.current?.focus(), 0);
|
||||
return () => clearTimeout(timer);
|
||||
}
|
||||
return undefined;
|
||||
}, [searchOpen]);
|
||||
|
||||
const filtered = useMemo(() => {
|
||||
if (!actions) return [] as QuickAction[];
|
||||
if (!query.trim()) return actions;
|
||||
const needle = query.trim().toLowerCase();
|
||||
return actions.filter((item) => item.label.toLowerCase().includes(needle));
|
||||
}, [actions, query]);
|
||||
|
||||
const groups = useMemo(
|
||||
() =>
|
||||
filtered.reduce<Record<string, QuickAction[]>>((acc, item) => {
|
||||
if (!acc[item.group]) acc[item.group] = [];
|
||||
acc[item.group].push(item);
|
||||
return acc;
|
||||
}, {}),
|
||||
[filtered],
|
||||
// Every destination the palette can jump to: the sidebar's nav groups (a
|
||||
// flavor seam — saas ships a reduced set) plus the editor app itself.
|
||||
const entries = useMemo<NavEntry[]>(
|
||||
() => [
|
||||
...GROUP_PRIMARY,
|
||||
...GROUP_OPERATIONAL,
|
||||
...GROUP_PLATFORM,
|
||||
{ id: "editor" as ViewId, icon: null },
|
||||
],
|
||||
[],
|
||||
);
|
||||
|
||||
const groupKeys = Object.keys(groups);
|
||||
const isEmpty = !isLoading && groupKeys.length === 0;
|
||||
const results = useMemo(() => {
|
||||
const q = query.trim();
|
||||
if (!q) return entries;
|
||||
return rankByFuzzy(entries, q, [
|
||||
(e) => t(`portal.nav.${e.id}`),
|
||||
(e) => e.id,
|
||||
]).map(({ item }) => item);
|
||||
}, [entries, query, t]);
|
||||
|
||||
useEffect(() => {
|
||||
setHighlight((h) =>
|
||||
results.length === 0 ? 0 : Math.min(h, results.length - 1),
|
||||
);
|
||||
}, [results.length]);
|
||||
|
||||
const select = (entry: NavEntry | undefined) => {
|
||||
if (!entry) return;
|
||||
closeSearch();
|
||||
if (entry.externalUrl) {
|
||||
window.open(entry.externalUrl, "_blank", "noopener,noreferrer");
|
||||
return;
|
||||
}
|
||||
setActiveView(entry.id);
|
||||
};
|
||||
|
||||
const onKeyDown = (e: React.KeyboardEvent) => {
|
||||
if (e.key === "ArrowDown") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.min(h + 1, results.length - 1));
|
||||
} else if (e.key === "ArrowUp") {
|
||||
e.preventDefault();
|
||||
setHighlight((h) => Math.max(h - 1, 0));
|
||||
} else if (e.key === "Enter") {
|
||||
e.preventDefault();
|
||||
select(results[highlight]);
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<Modal
|
||||
@@ -58,7 +100,11 @@ export function SearchModal() {
|
||||
<input
|
||||
ref={inputRef}
|
||||
value={query}
|
||||
onChange={(e) => setQuery(e.target.value)}
|
||||
onChange={(e) => {
|
||||
setQuery(e.target.value);
|
||||
setHighlight(0);
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
placeholder={t("portal.search.placeholder")}
|
||||
aria-label={t("portal.search.ariaLabel")}
|
||||
className="portal-search__input"
|
||||
@@ -71,53 +117,38 @@ export function SearchModal() {
|
||||
</div>
|
||||
|
||||
<div className="portal-search__results">
|
||||
{isLoading && (
|
||||
<div className="portal-search__loading">
|
||||
<Skeleton height="1rem" width="40%" />
|
||||
<Skeleton height="0.875rem" />
|
||||
<Skeleton height="0.875rem" width="80%" />
|
||||
<Skeleton height="0.875rem" width="65%" />
|
||||
</div>
|
||||
)}
|
||||
{isEmpty && (
|
||||
{results.length === 0 ? (
|
||||
<EmptyState
|
||||
size="compact"
|
||||
title={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatches", { query: query.trim() })
|
||||
: t("portal.search.empty.noActionsTitle")
|
||||
}
|
||||
description={
|
||||
query.trim()
|
||||
? t("portal.search.empty.noMatchesDescription")
|
||||
: t("portal.search.empty.noActionsDescription")
|
||||
}
|
||||
title={t("portal.search.empty.noMatches", {
|
||||
query: query.trim(),
|
||||
})}
|
||||
description={t("portal.search.empty.noMatchesDescription")}
|
||||
/>
|
||||
)}
|
||||
{!isLoading &&
|
||||
!isEmpty &&
|
||||
Object.entries(groups).map(([group, items]) => (
|
||||
<div key={group} className="portal-search__group">
|
||||
<div className="portal-search__group-label">{group}</div>
|
||||
{items.map((item) => (
|
||||
<Button
|
||||
key={item.label}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className="portal-search__item"
|
||||
onClick={closeSearch}
|
||||
>
|
||||
<span className="portal-search__item-label">
|
||||
{item.label}
|
||||
</span>
|
||||
<span className="portal-search__item-hint">
|
||||
{item.hint}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
) : (
|
||||
<div className="portal-search__group">
|
||||
<div className="portal-search__group-label">
|
||||
{t("portal.search.goTo")}
|
||||
</div>
|
||||
))}
|
||||
{results.map((entry, i) => (
|
||||
<Button
|
||||
key={entry.id}
|
||||
variant="tertiary"
|
||||
justify="start"
|
||||
fullWidth
|
||||
className={`portal-search__item${
|
||||
i === highlight ? " portal-search__item--active" : ""
|
||||
}`}
|
||||
onClick={() => select(entry)}
|
||||
onMouseEnter={() => setHighlight(i)}
|
||||
>
|
||||
<span className="portal-search__item-label">
|
||||
{t(`portal.nav.${entry.id}`)}
|
||||
</span>
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
@@ -1,7 +1,6 @@
|
||||
import { assistantHandlers } from "@portal/mocks/handlers/assistant";
|
||||
import { authHandlers } from "@portal/mocks/handlers/auth";
|
||||
import { notificationsHandlers } from "@portal/mocks/handlers/notifications";
|
||||
import { searchHandlers } from "@portal/mocks/handlers/search";
|
||||
import { pipelinesHandlers } from "@portal/mocks/handlers/pipelines";
|
||||
import { sourcesHandlers } from "@portal/mocks/handlers/sources";
|
||||
import { infrastructureHandlers } from "@portal/mocks/handlers/infrastructure";
|
||||
@@ -22,7 +21,6 @@ export const handlers = [
|
||||
...authHandlers,
|
||||
...notificationsHandlers,
|
||||
...assistantHandlers,
|
||||
...searchHandlers,
|
||||
...pipelinesHandlers,
|
||||
...sourcesHandlers,
|
||||
...infrastructureHandlers,
|
||||
|
||||
@@ -1,8 +0,0 @@
|
||||
import { http, HttpResponse } from "msw";
|
||||
import { QUICK_ACTIONS } from "@portal/mocks/search";
|
||||
|
||||
export const searchHandlers = [
|
||||
http.get("/v1/search/quick-actions", () => {
|
||||
return HttpResponse.json(QUICK_ACTIONS);
|
||||
}),
|
||||
];
|
||||
@@ -1,17 +0,0 @@
|
||||
/**
|
||||
* Mock quick-action catalogue for the ⌘K search palette. The QuickAction type
|
||||
* lives in api/search.ts (the backend contract); this module only builds fake
|
||||
* data for Storybook and tests.
|
||||
*/
|
||||
|
||||
import type { QuickAction } from "@portal/api/search";
|
||||
|
||||
export const QUICK_ACTIONS: QuickAction[] = [
|
||||
{ group: "Jump to", label: "Home", hint: "G H" },
|
||||
{ group: "Jump to", label: "Pipelines", hint: "G P" },
|
||||
{ group: "Jump to", label: "Sources", hint: "G S" },
|
||||
{ group: "Jump to", label: "Documents", hint: "G D" },
|
||||
{ group: "Create", label: "New pipeline", hint: "N P" },
|
||||
{ group: "Create", label: "New API key", hint: "N K" },
|
||||
{ group: "Theme", label: "Toggle dark / light", hint: "T" },
|
||||
];
|
||||
@@ -0,0 +1,94 @@
|
||||
import { PORTAL_BASENAME } from "@app/routes/portalBasename";
|
||||
import type { ProcessorSearchEntry } from "@core/data/processorSearchIndex";
|
||||
|
||||
export type { ProcessorSearchEntry };
|
||||
|
||||
// Mirrors the admin-route seam's gate: the portal route-set is only mounted in
|
||||
// dev and in builds made with VITE_INCLUDE_PORTAL=true, so the search must not
|
||||
// offer destinations that would 404 elsewhere.
|
||||
const includePortal =
|
||||
import.meta.env.VITE_INCLUDE_PORTAL === "true" || import.meta.env.DEV;
|
||||
|
||||
/**
|
||||
* The portal's in-app views. Deliberately a static mirror of the portal's nav
|
||||
* (labels via the same portal.nav.* keys its sidebar uses) rather than an
|
||||
* import from @portal/* — referencing the portal package here would pull the
|
||||
* lazy portal chunk into the main bundle.
|
||||
*/
|
||||
const VIEWS: ProcessorSearchEntry[] = [
|
||||
{
|
||||
id: "home",
|
||||
labelKey: "portal.nav.home",
|
||||
labelFallback: "Home",
|
||||
path: PORTAL_BASENAME,
|
||||
keywords: ["portal", "processor", "admin"],
|
||||
},
|
||||
{
|
||||
id: "users",
|
||||
labelKey: "portal.nav.users",
|
||||
labelFallback: "Users",
|
||||
path: `${PORTAL_BASENAME}/users`,
|
||||
keywords: ["team", "members", "roles", "admin"],
|
||||
},
|
||||
{
|
||||
id: "sources",
|
||||
labelKey: "portal.nav.sources",
|
||||
labelFallback: "Sources",
|
||||
path: `${PORTAL_BASENAME}/sources`,
|
||||
keywords: ["s3", "connections", "webhooks", "folders"],
|
||||
},
|
||||
{
|
||||
id: "policies",
|
||||
labelKey: "portal.nav.policies",
|
||||
labelFallback: "Policies",
|
||||
path: `${PORTAL_BASENAME}/policies`,
|
||||
keywords: ["enforcement", "redact", "compliance"],
|
||||
},
|
||||
{
|
||||
id: "pipelines",
|
||||
labelKey: "portal.nav.pipelines",
|
||||
labelFallback: "Pipelines",
|
||||
path: `${PORTAL_BASENAME}/pipelines`,
|
||||
keywords: ["automation", "workflows", "operations"],
|
||||
},
|
||||
{
|
||||
id: "documents",
|
||||
labelKey: "portal.nav.documents",
|
||||
labelFallback: "Documents",
|
||||
path: `${PORTAL_BASENAME}/documents`,
|
||||
keywords: ["audit", "files"],
|
||||
},
|
||||
{
|
||||
id: "components",
|
||||
labelKey: "portal.nav.components",
|
||||
labelFallback: "Components",
|
||||
path: `${PORTAL_BASENAME}/components`,
|
||||
keywords: ["sdk", "embed"],
|
||||
},
|
||||
{
|
||||
id: "infrastructure",
|
||||
labelKey: "portal.nav.infrastructure",
|
||||
labelFallback: "Infrastructure",
|
||||
path: `${PORTAL_BASENAME}/infrastructure`,
|
||||
keywords: ["deployment", "instances", "health"],
|
||||
},
|
||||
{
|
||||
id: "usage",
|
||||
labelKey: "portal.nav.usage",
|
||||
labelFallback: "Usage & Billing",
|
||||
path: `${PORTAL_BASENAME}/usage`,
|
||||
keywords: ["billing", "invoices", "plan", "wallet"],
|
||||
},
|
||||
{
|
||||
id: "docs",
|
||||
labelKey: "portal.nav.docs",
|
||||
labelFallback: "Developer Docs",
|
||||
path: "",
|
||||
externalUrl: "https://docs.stirlingpdf.com/",
|
||||
keywords: ["api", "documentation", "reference"],
|
||||
},
|
||||
];
|
||||
|
||||
export const PROCESSOR_SEARCH_INDEX: ProcessorSearchEntry[] = includePortal
|
||||
? VIEWS
|
||||
: [];
|
||||
Reference in New Issue
Block a user