Enhance chart theming and functionality across fleet and Mihomo components
- Introduced `chartColorWithAlpha` function to manage color transparency, improving visual consistency in charts. - Updated chart configurations in fleet and Mihomo components to utilize dynamic theming for background and border colors, enhancing user experience. - Implemented `chartLineAreaFillCallback` for better area fill effects in line charts, providing a more polished visual representation. - Refactored chart initialization logic to ensure proper handling of color and gradient settings, improving performance and responsiveness.
This commit is contained in:
@@ -27,3 +27,68 @@ export function chartSeriesColors(): string[] {
|
||||
export function chartLegendColor(): string {
|
||||
return chartTickColor();
|
||||
}
|
||||
|
||||
/**
|
||||
* Добавляет альфа-канал к цвету из темы (oklch/hex/rgb).
|
||||
* Нельзя склеивать `oklch(...) + "33"` — в canvas это даёт невалидный цвет и чёрную заливку.
|
||||
*/
|
||||
export function chartColorWithAlpha(base: string, alpha: number): string {
|
||||
const t = base.trim();
|
||||
const indigo = `rgba(99, 102, 241, ${alpha})`;
|
||||
if (!t) return indigo;
|
||||
|
||||
const oklch = /^oklch\(\s*(.+)\s*\)$/i.exec(t);
|
||||
if (oklch) {
|
||||
let inner = oklch[1].trim().replace(/\s*\/\s*[\d.]+%?\s*$/, '').trim();
|
||||
return `oklch(${inner} / ${alpha})`;
|
||||
}
|
||||
|
||||
if (t.startsWith('#')) {
|
||||
let h = t.slice(1);
|
||||
if (h.length === 3) {
|
||||
h = [...h].map((c) => c + c).join('');
|
||||
}
|
||||
if (h.length === 6 || h.length === 8) {
|
||||
const r = parseInt(h.slice(0, 2), 16);
|
||||
const g = parseInt(h.slice(2, 4), 16);
|
||||
const b = parseInt(h.slice(4, 6), 16);
|
||||
return `rgba(${r},${g},${b},${alpha})`;
|
||||
}
|
||||
}
|
||||
|
||||
if (/^rgba?\(/i.test(t)) {
|
||||
const m = t.match(/rgba?\(\s*([^)]+)\s*\)/i);
|
||||
if (m) {
|
||||
const parts = m[1].split(',').map((s) => s.trim());
|
||||
if (parts.length >= 3) {
|
||||
return `rgba(${parts[0]}, ${parts[1]}, ${parts[2]}, ${alpha})`;
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
return `color-mix(in srgb, ${t} ${Math.round(alpha * 100)}%, transparent)`;
|
||||
}
|
||||
|
||||
/** Лёгкая заливка под линией: сверху чуть насыщеннее, к низу к прозрачному. */
|
||||
export function chartLineAreaGradient(
|
||||
ctx: CanvasRenderingContext2D,
|
||||
chartArea: { top: number; bottom: number },
|
||||
lineColor: string
|
||||
): CanvasGradient {
|
||||
const g = ctx.createLinearGradient(0, chartArea.top, 0, chartArea.bottom);
|
||||
const c = lineColor.trim() || '#6366f1';
|
||||
g.addColorStop(0, chartColorWithAlpha(c, 0.16));
|
||||
g.addColorStop(0.45, chartColorWithAlpha(c, 0.045));
|
||||
g.addColorStop(1, chartColorWithAlpha(c, 0));
|
||||
return g;
|
||||
}
|
||||
|
||||
/** Callback для Chart.js `dataset.backgroundColor` у линейных графиков с заливкой. */
|
||||
export function chartLineAreaFillCallback(lineColor: string) {
|
||||
return (context: { chart: { ctx: CanvasRenderingContext2D; chartArea?: { top: number; bottom: number } } }) => {
|
||||
const { chart } = context;
|
||||
const area = chart.chartArea;
|
||||
if (!area) return 'transparent';
|
||||
return chartLineAreaGradient(chart.ctx, area, lineColor);
|
||||
};
|
||||
}
|
||||
|
||||
@@ -3,10 +3,12 @@
|
||||
import Chart from 'chart.js/auto';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import {
|
||||
chartColorWithAlpha,
|
||||
chartGridColor,
|
||||
chartLegendColor,
|
||||
chartSeriesColors,
|
||||
chartTickColor
|
||||
chartTickColor,
|
||||
readCssVar
|
||||
} from '$lib/chart-theme.js';
|
||||
import ServerIcon from '@lucide/svelte/icons/server';
|
||||
import ChartNoAxesCombinedIcon from '@lucide/svelte/icons/chart-no-axes-combined';
|
||||
@@ -31,6 +33,7 @@
|
||||
const colors = chartSeriesColors();
|
||||
const legend = chartLegendColor();
|
||||
chartDonut?.destroy();
|
||||
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
chartDonut = new Chart(elDonut, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -38,8 +41,14 @@
|
||||
datasets: [
|
||||
{
|
||||
data: [Math.max(nodesOk, 0), Math.max(nodesDegraded, 0)],
|
||||
backgroundColor: [colors[1] ?? '#22c55e', colors[4] ?? '#ef4444'],
|
||||
borderWidth: 0
|
||||
backgroundColor: [
|
||||
chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88),
|
||||
chartColorWithAlpha(colors[4] ?? '#ef4444', 0.88)
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: hole,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: hole
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -47,6 +56,7 @@
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
@@ -67,7 +77,9 @@
|
||||
{
|
||||
label: 'MiB',
|
||||
data: slice.map((x) => x.total_megabytes),
|
||||
backgroundColor: colors[0] ?? '#6366f1'
|
||||
backgroundColor: chartColorWithAlpha(colors[0] ?? '#6366f1', 0.52),
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -9,10 +9,13 @@
|
||||
import NetworkIcon from '@lucide/svelte/icons/network';
|
||||
import GaugeIcon from '@lucide/svelte/icons/gauge';
|
||||
import {
|
||||
chartColorWithAlpha,
|
||||
chartGridColor,
|
||||
chartLegendColor,
|
||||
chartLineAreaFillCallback,
|
||||
chartSeriesColors,
|
||||
chartTickColor
|
||||
chartTickColor,
|
||||
readCssVar
|
||||
} from '$lib/chart-theme.js';
|
||||
|
||||
let {
|
||||
@@ -76,6 +79,8 @@
|
||||
|
||||
if (elTraffic) {
|
||||
chartTraffic?.destroy();
|
||||
const c0 = colors[0] ?? '#38bdf8';
|
||||
const c4 = colors[4] ?? '#f472b6';
|
||||
chartTraffic = new Chart(elTraffic, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -84,16 +89,22 @@
|
||||
{
|
||||
label: 'Скачивание',
|
||||
data: [...histDown],
|
||||
borderColor: colors[0] ?? '#38bdf8',
|
||||
backgroundColor: `${colors[0] ?? '#38bdf8'}33`,
|
||||
borderColor: c0,
|
||||
backgroundColor: chartLineAreaFillCallback(c0),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
},
|
||||
{
|
||||
label: 'Загрузка',
|
||||
data: [...histUp],
|
||||
borderColor: colors[4] ?? '#f472b6',
|
||||
backgroundColor: `${colors[4] ?? '#f472b6'}33`,
|
||||
borderColor: c4,
|
||||
backgroundColor: chartLineAreaFillCallback(c4),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
@@ -105,6 +116,7 @@
|
||||
|
||||
if (elFlow) {
|
||||
chartFlow?.destroy();
|
||||
const hole = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
chartFlow = new Chart(elFlow, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -112,7 +124,14 @@
|
||||
datasets: [
|
||||
{
|
||||
data: [Math.max(totalDown, 0), Math.max(totalUp, 0)],
|
||||
backgroundColor: [colors[0] ?? '#38bdf8', colors[4] ?? '#f472b6']
|
||||
backgroundColor: [
|
||||
chartColorWithAlpha(colors[0] ?? '#38bdf8', 0.88),
|
||||
chartColorWithAlpha(colors[4] ?? '#f472b6', 0.88)
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: hole,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: hole
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -120,6 +139,7 @@
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
@@ -127,6 +147,7 @@
|
||||
|
||||
if (elMem) {
|
||||
chartMem?.destroy();
|
||||
const c2 = colors[2] ?? '#a78bfa';
|
||||
chartMem = new Chart(elMem, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -135,8 +156,11 @@
|
||||
{
|
||||
label: 'KiB',
|
||||
data: [...histMem],
|
||||
borderColor: colors[2] ?? '#a78bfa',
|
||||
backgroundColor: `${colors[2] ?? '#a78bfa'}33`,
|
||||
borderColor: c2,
|
||||
backgroundColor: chartLineAreaFillCallback(c2),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
@@ -148,6 +172,7 @@
|
||||
|
||||
if (elConn) {
|
||||
chartConn?.destroy();
|
||||
const c1 = colors[1] ?? '#34d399';
|
||||
chartConn = new Chart(elConn, {
|
||||
type: 'line',
|
||||
data: {
|
||||
@@ -156,8 +181,11 @@
|
||||
{
|
||||
label: 'Соединения',
|
||||
data: [...histConn],
|
||||
borderColor: colors[1] ?? '#34d399',
|
||||
backgroundColor: `${colors[1] ?? '#34d399'}33`,
|
||||
borderColor: c1,
|
||||
backgroundColor: chartLineAreaFillCallback(c1),
|
||||
borderWidth: 1.5,
|
||||
pointRadius: 0,
|
||||
pointHoverRadius: 3,
|
||||
tension: 0.2,
|
||||
fill: true
|
||||
}
|
||||
@@ -169,6 +197,7 @@
|
||||
|
||||
if (elNet) {
|
||||
chartNet?.destroy();
|
||||
const holeNet = readCssVar('--card') || readCssVar('--background') || '#18181b';
|
||||
chartNet = new Chart(elNet, {
|
||||
type: 'doughnut',
|
||||
data: {
|
||||
@@ -176,7 +205,14 @@
|
||||
datasets: [
|
||||
{
|
||||
data: [tcpN, udpN],
|
||||
backgroundColor: [colors[1] ?? '#22c55e', colors[3] ?? '#eab308']
|
||||
backgroundColor: [
|
||||
chartColorWithAlpha(colors[1] ?? '#22c55e', 0.88),
|
||||
chartColorWithAlpha(colors[3] ?? '#eab308', 0.88)
|
||||
],
|
||||
borderWidth: 2,
|
||||
borderColor: holeNet,
|
||||
hoverBorderWidth: 2,
|
||||
hoverBorderColor: holeNet
|
||||
}
|
||||
]
|
||||
},
|
||||
@@ -184,6 +220,7 @@
|
||||
responsive: true,
|
||||
maintainAspectRatio: false,
|
||||
animation: false,
|
||||
cutout: '58%',
|
||||
plugins: { legend: { labels: { color: legend } } }
|
||||
}
|
||||
});
|
||||
@@ -199,7 +236,9 @@
|
||||
{
|
||||
label: 'Сессии',
|
||||
data: topList.map((x) => x.n),
|
||||
backgroundColor: colors[0] ?? '#818cf8'
|
||||
backgroundColor: chartColorWithAlpha(colors[0] ?? '#818cf8', 0.52),
|
||||
borderRadius: 4,
|
||||
borderSkipped: false
|
||||
}
|
||||
]
|
||||
},
|
||||
|
||||
@@ -0,0 +1,39 @@
|
||||
<script lang="ts">
|
||||
import { Checkbox as CheckboxPrimitive } from "bits-ui";
|
||||
import { cn, type WithoutChildrenOrChild } from "$lib/utils.js";
|
||||
import CheckIcon from '@lucide/svelte/icons/check';
|
||||
import MinusIcon from '@lucide/svelte/icons/minus';
|
||||
|
||||
let {
|
||||
ref = $bindable(null),
|
||||
checked = $bindable(false),
|
||||
indeterminate = $bindable(false),
|
||||
class: className,
|
||||
...restProps
|
||||
}: WithoutChildrenOrChild<CheckboxPrimitive.RootProps> = $props();
|
||||
</script>
|
||||
|
||||
<CheckboxPrimitive.Root
|
||||
bind:ref
|
||||
data-slot="checkbox"
|
||||
class={cn(
|
||||
"border-input dark:bg-input/30 data-checked:bg-primary data-checked:text-primary-foreground dark:data-checked:bg-primary data-checked:border-primary aria-invalid:aria-checked:border-primary aria-invalid:border-destructive dark:aria-invalid:border-destructive/50 focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 flex size-4 items-center justify-center rounded-[4px] border transition-colors group-has-disabled/field:opacity-50 focus-visible:ring-3 aria-invalid:ring-3 peer relative shrink-0 outline-none after:absolute after:-inset-x-3 after:-inset-y-2 disabled:cursor-not-allowed disabled:opacity-50",
|
||||
className
|
||||
)}
|
||||
bind:checked
|
||||
bind:indeterminate
|
||||
{...restProps}
|
||||
>
|
||||
{#snippet children({ checked, indeterminate })}
|
||||
<div
|
||||
data-slot="checkbox-indicator"
|
||||
class="[&>svg]:size-3.5 grid place-content-center text-current transition-none"
|
||||
>
|
||||
{#if checked}
|
||||
<CheckIcon />
|
||||
{:else if indeterminate}
|
||||
<MinusIcon />
|
||||
{/if}
|
||||
</div>
|
||||
{/snippet}
|
||||
</CheckboxPrimitive.Root>
|
||||
@@ -0,0 +1,6 @@
|
||||
import Root from "./checkbox.svelte";
|
||||
export {
|
||||
Root,
|
||||
//
|
||||
Root as Checkbox,
|
||||
};
|
||||
@@ -10,8 +10,15 @@
|
||||
import * as Table from '$lib/components/ui/table/index.js';
|
||||
import * as Card from '$lib/components/ui/card/index.js';
|
||||
import DataTableCard from '$lib/components/data-table/data-table-card.svelte';
|
||||
import DataTableToolbar from '$lib/components/data-table/data-table-toolbar.svelte';
|
||||
import SortableTh from '$lib/components/data-table/sortable-th.svelte';
|
||||
import * as Tabs from '$lib/components/ui/tabs/index.js';
|
||||
import * as DropdownMenu from '$lib/components/ui/dropdown-menu/index.js';
|
||||
import { Badge } from '$lib/components/ui/badge/index.js';
|
||||
import { Checkbox } from '$lib/components/ui/checkbox/index.js';
|
||||
import { cn } from '$lib/utils.js';
|
||||
import Columns3Icon from '@lucide/svelte/icons/columns-3';
|
||||
import ChevronRightIcon from '@lucide/svelte/icons/chevron-right';
|
||||
import MoreVerticalIcon from '@lucide/svelte/icons/more-vertical';
|
||||
import { ScrollArea } from '$lib/components/ui/scroll-area/index.js';
|
||||
import SearchIcon from '@lucide/svelte/icons/search';
|
||||
import { nextSortDir, compareStrings, compareNum, type SortDir } from '$lib/table-sort.js';
|
||||
@@ -170,13 +177,27 @@
|
||||
let userSearch = $state('');
|
||||
let sortKey = $state<'name' | 'traffic' | 'ips' | 'exp' | null>(null);
|
||||
let sortDir = $state<SortDir | null>(null);
|
||||
let usersViewTab = $state<'all' | 'traffic' | 'active'>('all');
|
||||
let showColLinks = $state(true);
|
||||
let showColExpires = $state(true);
|
||||
let selectedUsernames = $state<string[]>([]);
|
||||
|
||||
let hasStaleData = $derived(err != null && rows.length > 0);
|
||||
let showSkeleton = $derived(loading && rows.length === 0 && !err);
|
||||
let isDataEmpty = $derived(!loading && rows.length === 0 && !err);
|
||||
|
||||
let countAll = $derived(rows.length);
|
||||
let countTraffic = $derived(rows.filter((r) => (r.total_megabytes ?? 0) > 0).length);
|
||||
let countActive = $derived(rows.filter((r) => (r.active_unique_ips ?? 0) > 0).length);
|
||||
|
||||
let tabFilteredRows = $derived.by(() => {
|
||||
if (usersViewTab === 'traffic') return rows.filter((r) => (r.total_megabytes ?? 0) > 0);
|
||||
if (usersViewTab === 'active') return rows.filter((r) => (r.active_unique_ips ?? 0) > 0);
|
||||
return rows;
|
||||
});
|
||||
|
||||
let processedRows = $derived.by(() => {
|
||||
let r = [...rows];
|
||||
let r = [...tabFilteredRows];
|
||||
const q = userSearch.trim().toLowerCase();
|
||||
if (q) r = r.filter((row) => (row.username ?? '').toLowerCase().includes(q));
|
||||
const k = sortKey;
|
||||
@@ -205,11 +226,40 @@
|
||||
return processedRows.slice(start, start + usersPerPage);
|
||||
});
|
||||
|
||||
let pageUsernames = $derived(
|
||||
pagedRows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
|
||||
);
|
||||
let selectedOnPage = $derived(pageUsernames.filter((u) => selectedUsernames.includes(u)));
|
||||
let headerCheckboxChecked = $derived(
|
||||
pageUsernames.length > 0 && selectedOnPage.length === pageUsernames.length
|
||||
);
|
||||
let headerCheckboxIndeterminate = $derived(
|
||||
selectedOnPage.length > 0 && selectedOnPage.length < pageUsernames.length
|
||||
);
|
||||
|
||||
$effect(() => {
|
||||
aliasFilter;
|
||||
userSearch;
|
||||
usersViewTab;
|
||||
usersPage = 1;
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
usersViewTab;
|
||||
selectedUsernames = [];
|
||||
});
|
||||
|
||||
$effect(() => {
|
||||
rows;
|
||||
const valid = new Set(
|
||||
rows.map((r) => r.username ?? '').filter((u): u is string => u.length > 0)
|
||||
);
|
||||
const next = selectedUsernames.filter((u) => valid.has(u));
|
||||
const same =
|
||||
next.length === selectedUsernames.length &&
|
||||
next.every((u, i) => u === selectedUsernames[i]);
|
||||
if (!same) selectedUsernames = next;
|
||||
});
|
||||
$effect(() => {
|
||||
if (usersPage > pageCount) usersPage = pageCount;
|
||||
});
|
||||
@@ -240,6 +290,23 @@
|
||||
void goto(`/users/${encodeURIComponent(u)}`);
|
||||
}
|
||||
|
||||
function toggleRowSelect(username: string, checked: boolean) {
|
||||
if (!username) return;
|
||||
if (checked) {
|
||||
if (!selectedUsernames.includes(username)) selectedUsernames = [...selectedUsernames, username];
|
||||
} else {
|
||||
selectedUsernames = selectedUsernames.filter((u) => u !== username);
|
||||
}
|
||||
}
|
||||
|
||||
function toggleSelectAllOnPage(checked: boolean) {
|
||||
if (checked) {
|
||||
selectedUsernames = [...new Set([...selectedUsernames, ...pageUsernames])];
|
||||
} else {
|
||||
selectedUsernames = selectedUsernames.filter((u) => !pageUsernames.includes(u));
|
||||
}
|
||||
}
|
||||
|
||||
function fallbackCopy(value: string) {
|
||||
if (typeof document === 'undefined') return;
|
||||
const ta = document.createElement('textarea');
|
||||
@@ -327,35 +394,104 @@
|
||||
description="Слияние по имени между серверами; клик по строке — карточка пользователя"
|
||||
>
|
||||
{#snippet toolbar()}
|
||||
<DataTableToolbar>
|
||||
<div class="relative max-w-sm flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-8 ps-8" placeholder="Поиск по имени…" bind:value={userSearch} />
|
||||
<div class="border-b border-border">
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 bg-muted/30 px-3 py-2.5"
|
||||
>
|
||||
<Tabs.Root bind:value={usersViewTab} class="w-full min-w-0 sm:w-auto">
|
||||
<Tabs.List class="h-auto min-h-8 w-full flex-wrap justify-start gap-1 p-1 sm:w-fit">
|
||||
<Tabs.Trigger value="all" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
Все
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countAll}</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="traffic" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
С трафиком
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countTraffic}</Badge>
|
||||
</Tabs.Trigger>
|
||||
<Tabs.Trigger value="active" class="shrink-0 gap-1.5 px-2.5 py-1 text-xs sm:text-sm">
|
||||
Активные IP
|
||||
<Badge variant="secondary" class="h-5 min-w-5 justify-center px-1.5 text-[10px] font-semibold tabular-nums sm:text-xs">{countActive}</Badge>
|
||||
</Tabs.Trigger>
|
||||
</Tabs.List>
|
||||
</Tabs.Root>
|
||||
{#if selectedUsernames.length > 0}
|
||||
<span class="text-xs text-muted-foreground tabular-nums">
|
||||
Выбрано: {selectedUsernames.length}
|
||||
</span>
|
||||
{/if}
|
||||
</div>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-8"
|
||||
onclick={() => {
|
||||
userSearch = '';
|
||||
sortKey = null;
|
||||
sortDir = null;
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</DataTableToolbar>
|
||||
<div
|
||||
class="flex flex-wrap items-center justify-between gap-2 border-t border-border/60 bg-muted/30 px-3 py-2"
|
||||
>
|
||||
<div class="relative max-w-sm min-w-[200px] flex-1">
|
||||
<SearchIcon
|
||||
class="pointer-events-none absolute top-1/2 left-2.5 size-4 -translate-y-1/2 text-muted-foreground"
|
||||
/>
|
||||
<Input class="h-9 ps-9" placeholder="Поиск по имени…" bind:value={userSearch} />
|
||||
</div>
|
||||
<div class="flex shrink-0 flex-wrap items-center gap-2">
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button {...props} variant="outline" size="sm" class="h-9 gap-1.5">
|
||||
<Columns3Icon class="size-4 opacity-70" />
|
||||
Столбцы
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-52">
|
||||
<DropdownMenu.Label class="text-xs">Видимость столбцов</DropdownMenu.Label>
|
||||
<DropdownMenu.Separator />
|
||||
<DropdownMenu.CheckboxItem bind:checked={showColLinks}>
|
||||
Ссылки
|
||||
</DropdownMenu.CheckboxItem>
|
||||
<DropdownMenu.CheckboxItem bind:checked={showColExpires}>
|
||||
Истекает
|
||||
</DropdownMenu.CheckboxItem>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
<Button
|
||||
variant="outline"
|
||||
size="sm"
|
||||
class="h-9"
|
||||
onclick={() => {
|
||||
userSearch = '';
|
||||
sortKey = null;
|
||||
sortDir = null;
|
||||
selectedUsernames = [];
|
||||
}}
|
||||
>Сброс</Button>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
{/snippet}
|
||||
<ScrollArea class="max-h-[min(70vh,560px)] w-full" orientation="both">
|
||||
<Table.Root>
|
||||
<Table.Header class="sticky top-0 z-10 bg-muted/95 backdrop-blur-sm">
|
||||
<Table.Row>
|
||||
<Table.Header class="sticky top-0 z-10 border-b border-border bg-muted/95 backdrop-blur-sm">
|
||||
<Table.Row class="border-0 hover:bg-transparent">
|
||||
<Table.Head class="w-10 px-2">
|
||||
{#if pageUsernames.length > 0}
|
||||
<Checkbox
|
||||
checked={headerCheckboxChecked}
|
||||
indeterminate={headerCheckboxIndeterminate}
|
||||
aria-label="Выбрать всех на странице"
|
||||
onCheckedChange={(v) => toggleSelectAllOnPage(v === true)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Head>
|
||||
<SortableTh
|
||||
active={sortKey === 'name'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('name')}
|
||||
class="min-w-[8rem]"
|
||||
>Имя</SortableTh>
|
||||
<Table.Head class="hidden sm:table-cell">Ссылки</Table.Head>
|
||||
<Table.Head
|
||||
class={cn(
|
||||
'text-muted-foreground text-xs font-medium uppercase tracking-wide',
|
||||
!showColLinks && 'hidden',
|
||||
showColLinks && 'hidden sm:table-cell'
|
||||
)}
|
||||
>Ссылки</Table.Head>
|
||||
<SortableTh
|
||||
numeric
|
||||
active={sortKey === 'traffic'}
|
||||
@@ -374,17 +510,22 @@
|
||||
active={sortKey === 'exp'}
|
||||
dir={sortDir}
|
||||
onToggle={() => toggleSort('exp')}
|
||||
class="hidden sm:table-cell"
|
||||
class={cn(
|
||||
!showColExpires && 'hidden',
|
||||
showColExpires && 'hidden sm:table-cell'
|
||||
)}
|
||||
>Истекает</SortableTh>
|
||||
<Table.Head class="w-12 px-2 text-right" aria-hidden="true"></Table.Head>
|
||||
</Table.Row>
|
||||
</Table.Header>
|
||||
<Table.Body>
|
||||
{#each pagedRows as row (row.username ?? '')}
|
||||
{@const uname = row.username ?? ''}
|
||||
<Table.Row
|
||||
class="cursor-pointer focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
class="group cursor-pointer border-b border-border transition-colors hover:bg-muted/50 focus-visible:outline-none focus-visible:ring-2 focus-visible:ring-primary/40"
|
||||
tabindex={0}
|
||||
role="button"
|
||||
aria-label={`Открыть пользователя ${row.username ?? ''}`}
|
||||
aria-label={`Открыть пользователя ${uname}`}
|
||||
onclick={() => openUser(row.username)}
|
||||
onkeydown={(e: KeyboardEvent) => {
|
||||
if (e.key === 'Enter' || e.key === ' ') {
|
||||
@@ -393,9 +534,25 @@
|
||||
}
|
||||
}}
|
||||
>
|
||||
<Table.Cell class="font-medium">{row.username}</Table.Cell>
|
||||
<Table.Cell class="hidden sm:table-cell">
|
||||
<div class="flex flex-wrap gap-1">
|
||||
<Table.Cell class="w-10 px-2 align-middle" onclick={(e) => e.stopPropagation()}>
|
||||
{#if uname}
|
||||
<Checkbox
|
||||
checked={selectedUsernames.includes(uname)}
|
||||
aria-label={`Выбрать ${uname}`}
|
||||
onCheckedChange={(v) => toggleRowSelect(uname, v === true)}
|
||||
/>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="py-3 font-semibold text-foreground">{row.username}</Table.Cell>
|
||||
<Table.Cell
|
||||
class={cn(
|
||||
'py-3 align-middle',
|
||||
!showColLinks && 'hidden',
|
||||
showColLinks && 'hidden sm:table-cell'
|
||||
)}
|
||||
onclick={(e) => e.stopPropagation()}
|
||||
>
|
||||
<div class="flex max-w-[220px] flex-wrap gap-1">
|
||||
{#each row.links?.tls ?? [] as link (link)}
|
||||
<Button
|
||||
variant="outline"
|
||||
@@ -424,18 +581,82 @@
|
||||
<CopyIcon class="size-3 opacity-60" />
|
||||
</Button>
|
||||
{/each}
|
||||
{#if !(row.links?.tls?.length) && !(row.links?.secure?.length)}
|
||||
<span class="text-xs text-muted-foreground">—</span>
|
||||
{/if}
|
||||
</div>
|
||||
</Table.Cell>
|
||||
<Table.Cell class="text-right tabular-nums">{formatMiB(row.total_megabytes ?? 0)}</Table.Cell>
|
||||
<Table.Cell class="text-right text-sm font-mono tabular-nums">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span class="text-muted-foreground"> / {row.max_unique_ips}</span>
|
||||
<Table.Cell class="py-3 text-right align-middle tabular-nums">
|
||||
{#if (row.total_megabytes ?? 0) > 0}
|
||||
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
|
||||
{formatMiB(row.total_megabytes ?? 0)}
|
||||
</Badge>
|
||||
{:else}
|
||||
<Badge variant="outline" class="font-mono font-normal text-muted-foreground tabular-nums">
|
||||
{formatMiB(0)}
|
||||
</Badge>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="hidden sm:table-cell text-sm text-muted-foreground">
|
||||
<Table.Cell class="py-3 text-right align-middle">
|
||||
{#if (row.active_unique_ips ?? 0) > 0}
|
||||
<Badge variant="secondary" class="font-mono font-normal tabular-nums">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span class="text-muted-foreground">/{row.max_unique_ips}</span>
|
||||
{/if}
|
||||
</Badge>
|
||||
{:else}
|
||||
<span class="font-mono text-sm tabular-nums text-muted-foreground">
|
||||
{row.active_unique_ips ?? 0}
|
||||
{#if row.max_unique_ips != null}
|
||||
<span> / {row.max_unique_ips}</span>
|
||||
{/if}
|
||||
</span>
|
||||
{/if}
|
||||
</Table.Cell>
|
||||
<Table.Cell
|
||||
class={cn(
|
||||
'py-3 text-sm text-muted-foreground',
|
||||
!showColExpires && 'hidden',
|
||||
showColExpires && 'hidden sm:table-cell'
|
||||
)}
|
||||
>
|
||||
{row.expiration_rfc3339 ? new Date(row.expiration_rfc3339).toLocaleString() : '—'}
|
||||
</Table.Cell>
|
||||
<Table.Cell class="w-14 px-1 py-3 align-middle" onclick={(e) => e.stopPropagation()}>
|
||||
<div class="flex items-center justify-end gap-0.5 pe-0.5">
|
||||
<ChevronRightIcon
|
||||
class="pointer-events-none size-4 text-muted-foreground/50 opacity-0 transition-opacity group-hover:opacity-100 max-sm:hidden"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<DropdownMenu.Root>
|
||||
<DropdownMenu.Trigger>
|
||||
{#snippet child({ props })}
|
||||
<Button
|
||||
{...props}
|
||||
variant="ghost"
|
||||
size="icon"
|
||||
class="size-8 text-muted-foreground hover:text-foreground"
|
||||
aria-label="Действия"
|
||||
>
|
||||
<MoreVerticalIcon class="size-4" />
|
||||
</Button>
|
||||
{/snippet}
|
||||
</DropdownMenu.Trigger>
|
||||
<DropdownMenu.Content align="end" class="w-48">
|
||||
<DropdownMenu.Item onclick={() => openUser(row.username)}>
|
||||
Открыть карточку
|
||||
</DropdownMenu.Item>
|
||||
<DropdownMenu.Item
|
||||
disabled={!uname}
|
||||
onclick={() => uname && copy(uname)}
|
||||
>
|
||||
Копировать имя
|
||||
</DropdownMenu.Item>
|
||||
</DropdownMenu.Content>
|
||||
</DropdownMenu.Root>
|
||||
</div>
|
||||
</Table.Cell>
|
||||
</Table.Row>
|
||||
{/each}
|
||||
</Table.Body>
|
||||
|
||||
Reference in New Issue
Block a user