mirror of
https://github.com/mudler/LocalAI.git
synced 2026-07-30 09:57:57 -04:00
feat(ui): give the models gallery filter form a deliberate structure
The filter area had accreted controls into one undifferentiated flow. The "Fits in GPU" toggle and the backend select were direct children of .filter-bar, the same wrapping container as the 18 taxonomy chips, so their position was decided by how many chips happened to wrap at the current width rather than by any layout intent. At narrow widths they were pushed past the right edge of that container's horizontal scroll and became unreachable entirely. Restructure into three bands inside the house .filter-bar-group wrapper that components/FilterBar.jsx already uses on Backends and the System tabs: 1. query scope: search plus the backend select 2. taxonomy: the chip row, alone, free to wrap 3. refinements: fits-in-GPU and context size, under a hairline rule The backend select leads the chips rather than trailing them because picking a backend disables the use cases that backend cannot serve, so it gates the row below it. Fits-in-GPU and context size share a band because they are one control group: the context size is the length the VRAM estimate is computed at, and that estimate is what the fits filter tests against. Chips had no visible keyboard focus indicator. The global focus ring is wrapped in :where(), so it carries the specificity of a bare :focus-visible, ties with .filter-btn and loses on source order, leaving focused chips showing their resting drop shadow. Restate the ring where it outranks both resting and hover. Also: aria-pressed on the chips, a real label association and aria-valuetext on the context slider (it steps over an index, so it announced "2"), disabled chip styling moved off inline styles, a prefers-reduced-motion block for the chip transition, and the hard-coded English "Context:" moved into all seven locales. No behaviour change: same filters, same state, same requests. Page reset on change, localStorage persistence and "Clear filters" verified unchanged. Assisted-by: Claude:claude-opus-4-8 Signed-off-by: Ettore Di Giacinto <mudler@localai.io>
This commit is contained in:
@@ -1081,3 +1081,141 @@ test.describe("Models Gallery - Markdown descriptions", () => {
|
||||
await expect(detail).toContainText("Backend");
|
||||
});
|
||||
});
|
||||
|
||||
// The filter block is three deliberate bands: query scope (search + backend
|
||||
// select), the use-case chip row, and the refinements (fits-in-GPU + context).
|
||||
// These assert the separation holds, because the regression they guard against
|
||||
// is the refinements being swept back into the chip row's wrap, where their
|
||||
// position depends on how many chips happen to wrap at the current width.
|
||||
test.describe("Models Gallery - Filter layout structure", () => {
|
||||
test.beforeEach(async ({ page }) => {
|
||||
await page.route("**/api/models*", (route) => {
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(MOCK_MODELS_RESPONSE),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/backends/usecases", (route) => {
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(BACKEND_USECASES_MOCK),
|
||||
});
|
||||
});
|
||||
await page.route("**/api/resources", (route) => {
|
||||
route.fulfill({
|
||||
contentType: "application/json",
|
||||
body: JSON.stringify(MOCK_GPU_RESOURCES_RESPONSE),
|
||||
});
|
||||
});
|
||||
await page.goto("/app/models");
|
||||
await expect(page.locator("th", { hasText: "Backend" })).toBeVisible({
|
||||
timeout: 10_000,
|
||||
});
|
||||
});
|
||||
|
||||
test("the chip row contains only use-case chips", async ({ page }) => {
|
||||
const chipRow = page.locator(".filter-bar");
|
||||
await expect(chipRow).toHaveCount(1);
|
||||
// Nothing but .filter-btn children: no toggle, no select, no slider.
|
||||
const childClasses = await chipRow.evaluate((el) =>
|
||||
Array.from(el.children).map((c) => c.className),
|
||||
);
|
||||
expect(childClasses.length).toBeGreaterThan(0);
|
||||
for (const cls of childClasses) {
|
||||
expect(cls).toContain("filter-btn");
|
||||
}
|
||||
await expect(chipRow.locator("input[type='range']")).toHaveCount(0);
|
||||
await expect(chipRow.locator(".filter-bar-group__toggle")).toHaveCount(0);
|
||||
await expect(chipRow.getByText("All Backends")).toHaveCount(0);
|
||||
});
|
||||
|
||||
test("refinements live in their own band, outside the chip row", async ({
|
||||
page,
|
||||
}) => {
|
||||
const refine = page.getByTestId("models-filters-refine");
|
||||
await expect(refine).toBeVisible();
|
||||
await expect(refine.locator(".filter-bar")).toHaveCount(0);
|
||||
await expect(refine.getByText("Fits in GPU")).toBeVisible();
|
||||
await expect(refine.locator("#models-context-size")).toBeVisible();
|
||||
// The band is a sibling of the chip row, never a descendant.
|
||||
const nested = await page
|
||||
.locator(".filter-bar")
|
||||
.locator('[data-testid="models-filters-refine"]')
|
||||
.count();
|
||||
expect(nested).toBe(0);
|
||||
});
|
||||
|
||||
test("the backend select sits in the query band above the chips", async ({
|
||||
page,
|
||||
}) => {
|
||||
const selectBtn = page.locator("button", { hasText: "All Backends" });
|
||||
await expect(selectBtn).toBeVisible();
|
||||
const inChipRow = await page
|
||||
.locator(".filter-bar")
|
||||
.locator("button", { hasText: "All Backends" })
|
||||
.count();
|
||||
expect(inChipRow).toBe(0);
|
||||
// Reads above the chips it gates.
|
||||
const selectBox = await selectBtn.boundingBox();
|
||||
const chipBox = await page.locator(".filter-bar").boundingBox();
|
||||
expect(selectBox.y).toBeLessThan(chipBox.y);
|
||||
});
|
||||
|
||||
test("refinements stay grouped and on one band at a narrow width", async ({
|
||||
page,
|
||||
}) => {
|
||||
await page.setViewportSize({ width: 900, height: 900 });
|
||||
const refine = page.getByTestId("models-filters-refine");
|
||||
await expect(refine).toBeVisible();
|
||||
const chipBox = await page.locator(".filter-bar").boundingBox();
|
||||
const refineBox = await refine.boundingBox();
|
||||
// Below the chip row, not interleaved with it.
|
||||
expect(refineBox.y).toBeGreaterThanOrEqual(chipBox.y + chipBox.height - 1);
|
||||
await expect(refine.getByText("Fits in GPU")).toBeVisible();
|
||||
await expect(refine.locator("#models-context-size")).toBeVisible();
|
||||
});
|
||||
|
||||
test("chips expose pressed state and the context slider is labelled", async ({
|
||||
page,
|
||||
}) => {
|
||||
const chatBtn = page.locator(".filter-btn", { hasText: "Chat" });
|
||||
await expect(chatBtn).toHaveAttribute("aria-pressed", "false");
|
||||
await chatBtn.click();
|
||||
await expect(chatBtn).toHaveAttribute("aria-pressed", "true");
|
||||
|
||||
const slider = page.locator("#models-context-size");
|
||||
// The slider steps over an index, so the announced value must be the size.
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/);
|
||||
await expect(page.locator("label[for='models-context-size']")).toBeVisible();
|
||||
});
|
||||
|
||||
test("a keyboard-focused chip shows a focus ring", async ({ page }) => {
|
||||
// The global :focus-visible rule is wrapped in :where(), so it ties with
|
||||
// .filter-btn on specificity and loses on order. Without an explicit rule
|
||||
// the chips render their resting shadow while focused, i.e. no indicator.
|
||||
await page.locator(".filter-bar-group__search input").click();
|
||||
await page.keyboard.press("Tab"); // backend select
|
||||
await page.keyboard.press("Tab"); // first chip
|
||||
const focused = page.locator(".filter-btn:focus-visible");
|
||||
await expect(focused).toHaveCount(1);
|
||||
// The ring transitions in, so settle before reading the computed value.
|
||||
await page.waitForTimeout(400);
|
||||
const shadow = await focused.evaluate(
|
||||
(el) => getComputedStyle(el).boxShadow,
|
||||
);
|
||||
// A 3px spread ring, not the 1px/2px resting drop shadow.
|
||||
expect(shadow).toMatch(/0px 0px 0px 3px/);
|
||||
});
|
||||
|
||||
test("the context control is keyboard reachable and drives the value", async ({
|
||||
page,
|
||||
}) => {
|
||||
const slider = page.locator("#models-context-size");
|
||||
const before = await slider.inputValue();
|
||||
await slider.focus();
|
||||
await expect(slider).toBeFocused();
|
||||
await page.keyboard.press("ArrowRight");
|
||||
await expect(slider).not.toHaveValue(before);
|
||||
await expect(slider).toHaveAttribute("aria-valuetext", /^\d+K$/);
|
||||
});
|
||||
});
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"rerank": "Rerank",
|
||||
"fitsGpu": "Passt in die GPU",
|
||||
"allBackends": "Alle Backends",
|
||||
"searchBackends": "Backends suchen..."
|
||||
"searchBackends": "Backends suchen...",
|
||||
"contextSize": "Kontext:",
|
||||
"useCaseLabel": "Nach Anwendungsfall filtern",
|
||||
"unavailableForBackend": "Für das gewählte Backend nicht verfügbar"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Modelle suchen...",
|
||||
|
||||
@@ -44,7 +44,10 @@
|
||||
"ner": "NER",
|
||||
"fitsGpu": "Fits in GPU",
|
||||
"allBackends": "All Backends",
|
||||
"searchBackends": "Search backends..."
|
||||
"searchBackends": "Search backends...",
|
||||
"contextSize": "Context:",
|
||||
"useCaseLabel": "Filter by use case",
|
||||
"unavailableForBackend": "Not available for the selected backend"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Search models...",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"rerank": "Rerank",
|
||||
"fitsGpu": "Cabe en la GPU",
|
||||
"allBackends": "Todos los backends",
|
||||
"searchBackends": "Buscar backends..."
|
||||
"searchBackends": "Buscar backends...",
|
||||
"contextSize": "Contexto:",
|
||||
"useCaseLabel": "Filtrar por caso de uso",
|
||||
"unavailableForBackend": "No disponible para el backend seleccionado"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Buscar modelos...",
|
||||
|
||||
@@ -32,7 +32,10 @@
|
||||
"vad": "VAD",
|
||||
"fitsGpu": "Muat di GPU",
|
||||
"allBackends": "Semua Backend",
|
||||
"searchBackends": "Cari backends..."
|
||||
"searchBackends": "Cari backends...",
|
||||
"contextSize": "Konteks:",
|
||||
"useCaseLabel": "Filter berdasarkan kasus penggunaan",
|
||||
"unavailableForBackend": "Tidak tersedia untuk backend yang dipilih"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Cari model...",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"rerank": "Rerank",
|
||||
"fitsGpu": "Entra nella GPU",
|
||||
"allBackends": "Tutti i backend",
|
||||
"searchBackends": "Cerca backend..."
|
||||
"searchBackends": "Cerca backend...",
|
||||
"contextSize": "Contesto:",
|
||||
"useCaseLabel": "Filtra per caso d'uso",
|
||||
"unavailableForBackend": "Non disponibile per il backend selezionato"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "Cerca modelli...",
|
||||
|
||||
@@ -31,7 +31,10 @@
|
||||
"vad": "VAD",
|
||||
"fitsGpu": "GPU에 적합",
|
||||
"allBackends": "모든 백엔드",
|
||||
"searchBackends": "백엔드 검색..."
|
||||
"searchBackends": "백엔드 검색...",
|
||||
"contextSize": "컨텍스트:",
|
||||
"useCaseLabel": "사용 사례로 필터링",
|
||||
"unavailableForBackend": "선택한 백엔드에서 사용할 수 없음"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "모델 검색...",
|
||||
|
||||
@@ -25,7 +25,10 @@
|
||||
"rerank": "重排",
|
||||
"fitsGpu": "适合 GPU",
|
||||
"allBackends": "所有后端",
|
||||
"searchBackends": "搜索后端..."
|
||||
"searchBackends": "搜索后端...",
|
||||
"contextSize": "上下文:",
|
||||
"useCaseLabel": "按用例筛选",
|
||||
"unavailableForBackend": "所选后端不支持"
|
||||
},
|
||||
"search": {
|
||||
"placeholder": "搜索模型...",
|
||||
|
||||
@@ -2191,6 +2191,68 @@ select.input {
|
||||
user-select: none;
|
||||
white-space: nowrap;
|
||||
}
|
||||
/* Models gallery filter block. Three bands (query / taxonomy / refinements)
|
||||
stacked by .filter-bar-group. The refinements live in their own band so the
|
||||
chip row's wrap height can never displace them. */
|
||||
.models-filters__query {
|
||||
flex-wrap: nowrap;
|
||||
gap: var(--spacing-sm);
|
||||
}
|
||||
.models-filters__backend {
|
||||
flex: 0 0 auto;
|
||||
min-width: 0;
|
||||
}
|
||||
/* .filter-bar carries its own bottom margin for standalone use; inside the
|
||||
group the parent's gap owns the rhythm. */
|
||||
.models-filters > .filter-bar {
|
||||
margin-bottom: 0;
|
||||
}
|
||||
.models-filters__refine {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
flex-wrap: wrap;
|
||||
gap: var(--spacing-sm) var(--spacing-lg);
|
||||
padding-top: var(--spacing-sm);
|
||||
border-top: 1px solid var(--color-border-subtle);
|
||||
}
|
||||
/* Both refinements read as one secondary group: same size, same colour and the
|
||||
same icon-then-label rhythm as .filter-bar-group__toggle. */
|
||||
.models-filters__context {
|
||||
display: flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-sm);
|
||||
font-size: var(--text-xs);
|
||||
color: var(--color-text-secondary);
|
||||
}
|
||||
.models-filters__context > label {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
gap: var(--spacing-xs);
|
||||
white-space: nowrap;
|
||||
cursor: pointer;
|
||||
}
|
||||
.models-filters__context input[type='range'] {
|
||||
width: 140px;
|
||||
accent-color: var(--color-primary);
|
||||
cursor: pointer;
|
||||
}
|
||||
.models-filters__context-value {
|
||||
font-weight: var(--font-weight-medium);
|
||||
min-width: 3em;
|
||||
font-variant-numeric: tabular-nums;
|
||||
}
|
||||
|
||||
@media (max-width: 768px) {
|
||||
/* Search and backend select each take a full row rather than squeezing the
|
||||
input below a usable width. */
|
||||
.models-filters__query {
|
||||
flex-wrap: wrap;
|
||||
}
|
||||
.models-filters__backend {
|
||||
flex: 1 1 100%;
|
||||
}
|
||||
}
|
||||
|
||||
.filter-btn__count {
|
||||
display: inline-flex;
|
||||
align-items: center;
|
||||
@@ -2720,6 +2782,40 @@ button.collapsible-header:focus-visible {
|
||||
border-color: var(--color-primary-border);
|
||||
}
|
||||
|
||||
/* The global :focus-visible ring is wrapped in :where(), so it carries the
|
||||
specificity of a bare :focus-visible - the same (0,1,0) as .filter-btn, which
|
||||
is declared later and therefore won with its resting shadow. Chips were left
|
||||
with no visible keyboard focus indicator at all; this restates the ring where
|
||||
it outranks both the resting and the hover shadow. */
|
||||
.filter-btn:focus-visible {
|
||||
box-shadow: 0 0 0 3px var(--color-focus-ring);
|
||||
border-color: var(--color-primary);
|
||||
}
|
||||
|
||||
/* A chip is disabled when the selected backend cannot serve that use case. It
|
||||
keeps its resting colours so the row still reads as a set, dimmed enough to
|
||||
register as out of play. */
|
||||
.filter-btn:disabled {
|
||||
opacity: 0.4;
|
||||
cursor: not-allowed;
|
||||
box-shadow: none;
|
||||
}
|
||||
.filter-btn:disabled:hover {
|
||||
transform: none;
|
||||
box-shadow: none;
|
||||
color: var(--color-text-secondary);
|
||||
border-color: var(--color-border-default);
|
||||
}
|
||||
|
||||
@media (prefers-reduced-motion: reduce) {
|
||||
.filter-btn {
|
||||
transition: none;
|
||||
}
|
||||
.filter-btn:hover {
|
||||
transform: none;
|
||||
}
|
||||
}
|
||||
|
||||
/* Login page */
|
||||
.login-page {
|
||||
min-height: 100vh;
|
||||
|
||||
@@ -336,73 +336,96 @@ export default function Models() {
|
||||
|
||||
<RecommendedModels addToast={addToast} />
|
||||
|
||||
{/* Search */}
|
||||
<div className="search-bar" style={{ marginBottom: 'var(--spacing-md)' }}>
|
||||
<i className="fas fa-search search-icon" />
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{/* Filters, in three deliberate bands.
|
||||
1. Query scope: free-text search plus the backend select. The backend
|
||||
select leads the taxonomy row rather than trailing it because
|
||||
picking a backend disables the use-cases that backend cannot serve
|
||||
(see isFilterAvailable), so it reads as the gate on what follows.
|
||||
2. Taxonomy: the use-case chips, which wrap freely.
|
||||
3. Refinements: fits-in-GPU and context size. These two are one
|
||||
control group, not two strays - the context size is the length the
|
||||
VRAM estimate is computed at, and that estimate is exactly what the
|
||||
fits filter tests against.
|
||||
Each band owns its container, so how many chips happen to wrap at a
|
||||
given width can no longer decide where the other controls land. */}
|
||||
<div className="filter-bar-group models-filters">
|
||||
<div className="filter-bar-group__row models-filters__query">
|
||||
<div className="search-bar filter-bar-group__search">
|
||||
<i className="fas fa-search search-icon" aria-hidden="true" />
|
||||
<input
|
||||
className="input"
|
||||
type="text"
|
||||
placeholder={t('search.placeholder')}
|
||||
aria-label={t('search.placeholder')}
|
||||
value={search}
|
||||
onChange={(e) => handleSearch(e.target.value)}
|
||||
/>
|
||||
</div>
|
||||
{allBackends.length > 0 && (
|
||||
<div className="models-filters__backend">
|
||||
<SearchableSelect
|
||||
value={backendFilter}
|
||||
onChange={(v) => { setBackendFilter(v); setPage(1) }}
|
||||
options={allBackends}
|
||||
placeholder={t('filters.allBackends')}
|
||||
allOption={t('filters.allBackends')}
|
||||
searchPlaceholder={t('filters.searchBackends')}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{/* Filter buttons */}
|
||||
<div className="filter-bar">
|
||||
{FILTERS.map(f => {
|
||||
const isAll = f.key === ''
|
||||
const active = isAll ? filters.length === 0 : filters.includes(f.key)
|
||||
const available = isFilterAvailable(f.key)
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
className={`filter-btn ${active ? 'active' : ''}`}
|
||||
disabled={!available}
|
||||
style={!available ? { opacity: 0.4, cursor: 'not-allowed' } : undefined}
|
||||
onClick={() => toggleFilter(f.key)}
|
||||
>
|
||||
<i className={`fas ${f.icon}`} style={{ marginRight: 4 }} />
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
{totalGpuMemory > 0 && (
|
||||
<label className="filter-bar-group__toggle">
|
||||
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
|
||||
<i className="fas fa-microchip" />
|
||||
<span>{t('filters.fitsGpu')}</span>
|
||||
</label>
|
||||
)}
|
||||
{allBackends.length > 0 && (
|
||||
<SearchableSelect
|
||||
value={backendFilter}
|
||||
onChange={(v) => { setBackendFilter(v); setPage(1) }}
|
||||
options={allBackends}
|
||||
placeholder={t('filters.allBackends')}
|
||||
allOption={t('filters.allBackends')}
|
||||
searchPlaceholder={t('filters.searchBackends')}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
<div className="filter-bar" role="group" aria-label={t('filters.useCaseLabel')}>
|
||||
{FILTERS.map(f => {
|
||||
const isAll = f.key === ''
|
||||
const active = isAll ? filters.length === 0 : filters.includes(f.key)
|
||||
const available = isFilterAvailable(f.key)
|
||||
return (
|
||||
<button
|
||||
key={f.key}
|
||||
type="button"
|
||||
className={`filter-btn ${active ? 'active' : ''}`}
|
||||
disabled={!available}
|
||||
aria-pressed={active}
|
||||
title={!available ? t('filters.unavailableForBackend') : undefined}
|
||||
onClick={() => toggleFilter(f.key)}
|
||||
>
|
||||
<i className={`fas ${f.icon}`} aria-hidden="true" style={{ marginRight: 4 }} />
|
||||
{t(f.labelKey)}
|
||||
</button>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
|
||||
{/* Context size slider for VRAM estimates */}
|
||||
<div style={{ display: 'flex', alignItems: 'center', gap: 'var(--spacing-sm)', marginBottom: 'var(--spacing-md)', fontSize: '0.8125rem' }}>
|
||||
<label style={{ color: 'var(--color-text-muted)', whiteSpace: 'nowrap' }}>
|
||||
<i className="fas fa-memory" style={{ marginRight: 4 }} />
|
||||
Context:
|
||||
</label>
|
||||
<input
|
||||
type="range"
|
||||
min={0}
|
||||
max={CONTEXT_SIZES.length - 1}
|
||||
value={CONTEXT_SIZES.indexOf(contextSize)}
|
||||
onChange={(e) => setContextSize(CONTEXT_SIZES[e.target.value])}
|
||||
style={{ width: 140, accentColor: 'var(--color-primary)' }}
|
||||
/>
|
||||
<span style={{ fontWeight: 600, minWidth: '3em' }}>
|
||||
{CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
|
||||
</span>
|
||||
<div className="models-filters__refine" data-testid="models-filters-refine">
|
||||
{totalGpuMemory > 0 && (
|
||||
<label className="filter-bar-group__toggle">
|
||||
<Toggle checked={fitsFilter} onChange={setFitsFilter} />
|
||||
<i className="fas fa-microchip" aria-hidden="true" />
|
||||
<span>{t('filters.fitsGpu')}</span>
|
||||
</label>
|
||||
)}
|
||||
<div className="models-filters__context">
|
||||
<label htmlFor="models-context-size">
|
||||
<i className="fas fa-memory" aria-hidden="true" />
|
||||
{t('filters.contextSize')}
|
||||
</label>
|
||||
<input
|
||||
id="models-context-size"
|
||||
type="range"
|
||||
min={0}
|
||||
max={CONTEXT_SIZES.length - 1}
|
||||
value={CONTEXT_SIZES.indexOf(contextSize)}
|
||||
// The slider steps over an index, so the raw value ("2") is
|
||||
// meaningless to a screen reader; announce the size instead.
|
||||
aria-valuetext={CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
|
||||
onChange={(e) => setContextSize(CONTEXT_SIZES[e.target.value])}
|
||||
/>
|
||||
<span className="models-filters__context-value">
|
||||
{CONTEXT_LABELS[CONTEXT_SIZES.indexOf(contextSize)]}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Table */}
|
||||
|
||||
Reference in New Issue
Block a user