Merge branch 'dev' of https://github.com/vffuunnyy/clash-verge-rev-lite into dev
* 'dev' of https://github.com/vffuunnyy/clash-verge-rev-lite: the Add Profile button has been moved, and the layout has been slightly changed. the connections page has been slightly revised. fixed an issue with the dialog box when the profile name is long. added glass effect to components fixed icon background
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 77 KiB After Width: | Height: | Size: 85 KiB |
@@ -1,15 +1,14 @@
|
|||||||
import dayjs from "dayjs";
|
import dayjs from "dayjs";
|
||||||
import relativeTime from "dayjs/plugin/relativeTime";
|
import relativeTime from "dayjs/plugin/relativeTime";
|
||||||
import React, { useMemo, useState, useEffect, RefObject } from "react";
|
import React, { useMemo, useState, useEffect, useRef } from "react";
|
||||||
import {
|
import {
|
||||||
ColumnDef,
|
ColumnDef,
|
||||||
flexRender,
|
flexRender,
|
||||||
getCoreRowModel,
|
getCoreRowModel,
|
||||||
useReactTable,
|
useReactTable,
|
||||||
Row,
|
Header,
|
||||||
ColumnSizingState,
|
ColumnSizingState,
|
||||||
} from "@tanstack/react-table";
|
} from "@tanstack/react-table";
|
||||||
import { TableVirtuoso, TableComponents } from "react-virtuoso";
|
|
||||||
|
|
||||||
import {
|
import {
|
||||||
Table,
|
Table,
|
||||||
@@ -27,7 +26,30 @@ import { cn } from "@root/lib/utils";
|
|||||||
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
// Интерфейс для строки данных, которую использует react-table
|
interface IConnectionsItem {
|
||||||
|
id: string;
|
||||||
|
metadata: {
|
||||||
|
host: string;
|
||||||
|
destinationIP: string;
|
||||||
|
destinationPort: string;
|
||||||
|
remoteDestination: string;
|
||||||
|
process?: string;
|
||||||
|
processPath?: string;
|
||||||
|
sourceIP: string;
|
||||||
|
sourcePort: string;
|
||||||
|
type: string;
|
||||||
|
network: string;
|
||||||
|
};
|
||||||
|
rule: string;
|
||||||
|
rulePayload?: string;
|
||||||
|
chains: string[];
|
||||||
|
download: number;
|
||||||
|
upload: number;
|
||||||
|
curDownload?: number;
|
||||||
|
curUpload?: number;
|
||||||
|
start: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface ConnectionRow {
|
interface ConnectionRow {
|
||||||
id: string;
|
id: string;
|
||||||
host: string;
|
host: string;
|
||||||
@@ -45,29 +67,81 @@ interface ConnectionRow {
|
|||||||
connectionData: IConnectionsItem;
|
connectionData: IConnectionsItem;
|
||||||
}
|
}
|
||||||
|
|
||||||
// Интерфейс для пропсов, которые компонент получает от родителя
|
|
||||||
interface Props {
|
interface Props {
|
||||||
connections: IConnectionsItem[];
|
connections: IConnectionsItem[];
|
||||||
onShowDetail: (data: IConnectionsItem) => void;
|
onShowDetail: (data: IConnectionsItem) => void;
|
||||||
scrollerRef: (element: HTMLElement | Window | null) => void;
|
scrollerRef: (element: HTMLElement | Window | null) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
const ColumnResizer = ({ header }: { header: Header<ConnectionRow, unknown> }) => {
|
||||||
|
return (
|
||||||
|
<div
|
||||||
|
onMouseDown={header.getResizeHandler()}
|
||||||
|
onTouchStart={header.getResizeHandler()}
|
||||||
|
className={cn(
|
||||||
|
"absolute right-0 top-0 h-full w-1 cursor-col-resize select-none touch-none",
|
||||||
|
"bg-transparent hover:bg-primary/50 active:bg-primary",
|
||||||
|
"transition-colors duration-150",
|
||||||
|
header.column.getIsResizing() && "bg-primary"
|
||||||
|
)}
|
||||||
|
style={{
|
||||||
|
transform: header.column.getIsResizing() ? `translateX(0px)` : "",
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
);
|
||||||
|
};
|
||||||
|
|
||||||
export const ConnectionTable = (props: Props) => {
|
export const ConnectionTable = (props: Props) => {
|
||||||
const { connections, onShowDetail, scrollerRef } = props;
|
const { connections, onShowDetail, scrollerRef } = props;
|
||||||
|
const tableContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (tableContainerRef.current && scrollerRef) {
|
||||||
|
scrollerRef(tableContainerRef.current);
|
||||||
|
}
|
||||||
|
}, [scrollerRef]);
|
||||||
|
|
||||||
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
|
const [columnSizing, setColumnSizing] = useState<ColumnSizingState>(() => {
|
||||||
try {
|
try {
|
||||||
const saved = localStorage.getItem("connection-table-widths");
|
const saved = localStorage.getItem("connection-table-widths");
|
||||||
return saved ? JSON.parse(saved) : {};
|
return saved
|
||||||
|
? JSON.parse(saved)
|
||||||
|
: {
|
||||||
|
host: 220,
|
||||||
|
download: 88,
|
||||||
|
upload: 88,
|
||||||
|
dlSpeed: 88,
|
||||||
|
ulSpeed: 88,
|
||||||
|
chains: 340,
|
||||||
|
rule: 280,
|
||||||
|
process: 220,
|
||||||
|
time: 120,
|
||||||
|
source: 200,
|
||||||
|
remoteDestination: 200,
|
||||||
|
type: 160,
|
||||||
|
};
|
||||||
} catch {
|
} catch {
|
||||||
return {};
|
return {
|
||||||
|
host: 220,
|
||||||
|
download: 88,
|
||||||
|
upload: 88,
|
||||||
|
dlSpeed: 88,
|
||||||
|
ulSpeed: 88,
|
||||||
|
chains: 340,
|
||||||
|
rule: 280,
|
||||||
|
process: 220,
|
||||||
|
time: 120,
|
||||||
|
source: 200,
|
||||||
|
remoteDestination: 200,
|
||||||
|
type: 160,
|
||||||
|
};
|
||||||
}
|
}
|
||||||
});
|
});
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
localStorage.setItem(
|
localStorage.setItem(
|
||||||
"connection-table-widths",
|
"connection-table-widths",
|
||||||
JSON.stringify(columnSizing),
|
JSON.stringify(columnSizing)
|
||||||
);
|
);
|
||||||
}, [columnSizing]);
|
}, [columnSizing]);
|
||||||
|
|
||||||
@@ -77,13 +151,13 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
const chains = [...each.chains].reverse().join(" / ");
|
const chains = [...each.chains].reverse().join(" / ");
|
||||||
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
|
const rule = rulePayload ? `${each.rule}(${rulePayload})` : each.rule;
|
||||||
const Destination = metadata.destinationIP
|
const Destination = metadata.destinationIP
|
||||||
? `${metadata.destinationIP}:${metadata.destinationPort}`
|
? `${metadata.destinationIP}:${metadata.destinationPort}`
|
||||||
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
|
: `${metadata.remoteDestination}:${metadata.destinationPort}`;
|
||||||
return {
|
return {
|
||||||
id: each.id,
|
id: each.id,
|
||||||
host: metadata.host
|
host: metadata.host
|
||||||
? `${metadata.host}:${metadata.destinationPort}`
|
? `${metadata.host}:${metadata.destinationPort}`
|
||||||
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
|
: `${metadata.remoteDestination}:${metadata.destinationPort}`,
|
||||||
download: each.download,
|
download: each.download,
|
||||||
upload: each.upload,
|
upload: each.upload,
|
||||||
dlSpeed: each.curDownload ?? 0,
|
dlSpeed: each.curDownload ?? 0,
|
||||||
@@ -101,102 +175,118 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
}, [connections]);
|
}, [connections]);
|
||||||
|
|
||||||
const columns = useMemo<ColumnDef<ConnectionRow>[]>(
|
const columns = useMemo<ColumnDef<ConnectionRow>[]>(
|
||||||
() => [
|
() => [
|
||||||
{
|
{
|
||||||
accessorKey: "host",
|
accessorKey: "host",
|
||||||
header: () => t("Host"),
|
header: () => t("Host"),
|
||||||
size: columnSizing?.host || 220,
|
size: columnSizing?.host || 220,
|
||||||
minSize: 180,
|
minSize: 180,
|
||||||
},
|
maxSize: 400,
|
||||||
{
|
},
|
||||||
accessorKey: "download",
|
{
|
||||||
header: () => t("Downloaded"),
|
accessorKey: "download",
|
||||||
size: columnSizing?.download || 88,
|
header: () => t("Downloaded"),
|
||||||
cell: ({ getValue }) => (
|
size: columnSizing?.download || 88,
|
||||||
<div className="text-right">
|
minSize: 80,
|
||||||
{parseTraffic(getValue<number>()).join(" ")}
|
maxSize: 150,
|
||||||
</div>
|
cell: ({ getValue }) => (
|
||||||
),
|
<div className="text-right font-mono text-sm">
|
||||||
},
|
{parseTraffic(getValue<number>()).join(" ")}
|
||||||
{
|
</div>
|
||||||
accessorKey: "upload",
|
),
|
||||||
header: () => t("Uploaded"),
|
},
|
||||||
size: columnSizing?.upload || 88,
|
{
|
||||||
cell: ({ getValue }) => (
|
accessorKey: "upload",
|
||||||
<div className="text-right">
|
header: () => t("Uploaded"),
|
||||||
{parseTraffic(getValue<number>()).join(" ")}
|
size: columnSizing?.upload || 88,
|
||||||
</div>
|
minSize: 80,
|
||||||
),
|
maxSize: 150,
|
||||||
},
|
cell: ({ getValue }) => (
|
||||||
{
|
<div className="text-right font-mono text-sm">
|
||||||
accessorKey: "dlSpeed",
|
{parseTraffic(getValue<number>()).join(" ")}
|
||||||
header: () => t("DL Speed"),
|
</div>
|
||||||
size: columnSizing?.dlSpeed || 88,
|
),
|
||||||
cell: ({ getValue }) => (
|
},
|
||||||
<div className="text-right">
|
{
|
||||||
{parseTraffic(getValue<number>()).join(" ")}/s
|
accessorKey: "dlSpeed",
|
||||||
</div>
|
header: () => t("DL Speed"),
|
||||||
),
|
size: columnSizing?.dlSpeed || 88,
|
||||||
},
|
minSize: 80,
|
||||||
{
|
maxSize: 150,
|
||||||
accessorKey: "ulSpeed",
|
cell: ({ getValue }) => (
|
||||||
header: () => t("UL Speed"),
|
<div className="text-right font-mono text-sm">
|
||||||
size: columnSizing?.ulSpeed || 88,
|
{parseTraffic(getValue<number>()).join(" ")}/s
|
||||||
cell: ({ getValue }) => (
|
</div>
|
||||||
<div className="text-right">
|
),
|
||||||
{parseTraffic(getValue<number>()).join(" ")}/s
|
},
|
||||||
</div>
|
{
|
||||||
),
|
accessorKey: "ulSpeed",
|
||||||
},
|
header: () => t("UL Speed"),
|
||||||
{
|
size: columnSizing?.ulSpeed || 88,
|
||||||
accessorKey: "chains",
|
minSize: 80,
|
||||||
header: () => t("Chains"),
|
maxSize: 150,
|
||||||
size: columnSizing?.chains || 340,
|
cell: ({ getValue }) => (
|
||||||
minSize: 180,
|
<div className="text-right font-mono text-sm">
|
||||||
},
|
{parseTraffic(getValue<number>()).join(" ")}/s
|
||||||
{
|
</div>
|
||||||
accessorKey: "rule",
|
),
|
||||||
header: () => t("Rule"),
|
},
|
||||||
size: columnSizing?.rule || 280,
|
{
|
||||||
minSize: 180,
|
accessorKey: "chains",
|
||||||
},
|
header: () => t("Chains"),
|
||||||
{
|
size: columnSizing?.chains || 340,
|
||||||
accessorKey: "process",
|
minSize: 180,
|
||||||
header: () => t("Process"),
|
maxSize: 500,
|
||||||
size: columnSizing?.process || 220,
|
},
|
||||||
minSize: 180,
|
{
|
||||||
},
|
accessorKey: "rule",
|
||||||
{
|
header: () => t("Rule"),
|
||||||
accessorKey: "time",
|
size: columnSizing?.rule || 280,
|
||||||
header: () => t("Time"),
|
minSize: 180,
|
||||||
size: columnSizing?.time || 120,
|
maxSize: 400,
|
||||||
minSize: 100,
|
},
|
||||||
cell: ({ getValue }) => (
|
{
|
||||||
<div className="text-right">
|
accessorKey: "process",
|
||||||
{dayjs(getValue<string>()).fromNow()}
|
header: () => t("Process"),
|
||||||
</div>
|
size: columnSizing?.process || 220,
|
||||||
),
|
minSize: 180,
|
||||||
},
|
maxSize: 350,
|
||||||
{
|
},
|
||||||
accessorKey: "source",
|
{
|
||||||
header: () => t("Source"),
|
accessorKey: "time",
|
||||||
size: columnSizing?.source || 200,
|
header: () => t("Time"),
|
||||||
minSize: 130,
|
size: columnSizing?.time || 120,
|
||||||
},
|
minSize: 100,
|
||||||
{
|
maxSize: 180,
|
||||||
accessorKey: "remoteDestination",
|
cell: ({ getValue }) => (
|
||||||
header: () => t("Destination"),
|
<div className="text-right font-mono text-sm">
|
||||||
size: columnSizing?.remoteDestination || 200,
|
{dayjs(getValue<string>()).fromNow()}
|
||||||
minSize: 130,
|
</div>
|
||||||
},
|
),
|
||||||
{
|
},
|
||||||
accessorKey: "type",
|
{
|
||||||
header: () => t("Type"),
|
accessorKey: "source",
|
||||||
size: columnSizing?.type || 160,
|
header: () => t("Source"),
|
||||||
minSize: 100,
|
size: columnSizing?.source || 200,
|
||||||
},
|
minSize: 130,
|
||||||
],
|
maxSize: 300,
|
||||||
[columnSizing],
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "remoteDestination",
|
||||||
|
header: () => t("Destination"),
|
||||||
|
size: columnSizing?.remoteDestination || 200,
|
||||||
|
minSize: 130,
|
||||||
|
maxSize: 300,
|
||||||
|
},
|
||||||
|
{
|
||||||
|
accessorKey: "type",
|
||||||
|
header: () => t("Type"),
|
||||||
|
size: columnSizing?.type || 160,
|
||||||
|
minSize: 100,
|
||||||
|
maxSize: 220,
|
||||||
|
},
|
||||||
|
],
|
||||||
|
[columnSizing]
|
||||||
);
|
);
|
||||||
|
|
||||||
const table = useReactTable({
|
const table = useReactTable({
|
||||||
@@ -206,92 +296,91 @@ export const ConnectionTable = (props: Props) => {
|
|||||||
onColumnSizingChange: setColumnSizing,
|
onColumnSizingChange: setColumnSizing,
|
||||||
getCoreRowModel: getCoreRowModel(),
|
getCoreRowModel: getCoreRowModel(),
|
||||||
columnResizeMode: "onChange",
|
columnResizeMode: "onChange",
|
||||||
|
enableColumnResizing: true,
|
||||||
});
|
});
|
||||||
|
|
||||||
const VirtuosoTableComponents = useMemo<TableComponents<Row<ConnectionRow>>>(
|
const totalTableWidth = useMemo(() => {
|
||||||
() => ({
|
return table.getCenterTotalSize();
|
||||||
// Явно типизируем `ref` для каждого компонента
|
}, [table.getState().columnSizing]);
|
||||||
Scroller: React.forwardRef<HTMLDivElement>((props, ref) => (
|
|
||||||
<div className="h-full" {...props} ref={ref} />
|
if (connRows.length === 0) {
|
||||||
)),
|
return (
|
||||||
Table: (props) => <Table {...props} className="w-full border-collapse" />,
|
<div className="flex h-full items-center justify-center">
|
||||||
TableHead: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
|
<p className="text-muted-foreground">{t("No connections")}</p>
|
||||||
<TableHeader {...props} ref={ref} />
|
</div>
|
||||||
)),
|
);
|
||||||
// Явно типизируем пропсы и `ref` для TableRow
|
}
|
||||||
TableRow: React.forwardRef<
|
|
||||||
HTMLTableRowElement,
|
|
||||||
{ item: Row<ConnectionRow> } & React.HTMLAttributes<HTMLTableRowElement>
|
|
||||||
>(({ item: row, ...props }, ref) => {
|
|
||||||
// `Virtuoso` передает нам готовую строку `row` в пропсе `item`.
|
|
||||||
// Больше не нужно искать ее по индексу!
|
|
||||||
return (
|
|
||||||
<TableRow
|
|
||||||
{...props}
|
|
||||||
ref={ref}
|
|
||||||
data-state={row.getIsSelected() && "selected"}
|
|
||||||
className="cursor-pointer hover:bg-muted/50"
|
|
||||||
onClick={() => onShowDetail(row.original.connectionData)}
|
|
||||||
/>
|
|
||||||
);
|
|
||||||
}),
|
|
||||||
TableBody: React.forwardRef<HTMLTableSectionElement>((props, ref) => (
|
|
||||||
<TableBody {...props} ref={ref} />
|
|
||||||
)),
|
|
||||||
}),
|
|
||||||
[],
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full rounded-md border overflow-hidden">
|
<div className="rounded-md border relative bg-background">
|
||||||
{connRows.length > 0 ? (
|
<Table
|
||||||
<TableVirtuoso
|
className="w-full border-collapse table-fixed"
|
||||||
scrollerRef={scrollerRef}
|
style={{
|
||||||
data={table.getRowModel().rows}
|
width: totalTableWidth,
|
||||||
components={VirtuosoTableComponents}
|
minWidth: "100%",
|
||||||
fixedHeaderContent={() =>
|
}}
|
||||||
table.getHeaderGroups().map((headerGroup) => (
|
>
|
||||||
<TableRow
|
<TableHeader>
|
||||||
key={headerGroup.id}
|
{table.getHeaderGroups().map((headerGroup) => (
|
||||||
className="hover:bg-transparent bg-background/95 backdrop-blur"
|
<TableRow
|
||||||
>
|
key={headerGroup.id}
|
||||||
{headerGroup.headers.map((header) => (
|
className="hover:bg-transparent border-b-0 h-10"
|
||||||
<TableHead
|
|
||||||
key={header.id}
|
|
||||||
style={{ width: header.getSize() }}
|
|
||||||
className="p-2"
|
|
||||||
>
|
|
||||||
{header.isPlaceholder
|
|
||||||
? null
|
|
||||||
: flexRender(
|
|
||||||
header.column.columnDef.header,
|
|
||||||
header.getContext(),
|
|
||||||
)}
|
|
||||||
</TableHead>
|
|
||||||
))}
|
|
||||||
</TableRow>
|
|
||||||
))
|
|
||||||
}
|
|
||||||
itemContent={(index, row) => (
|
|
||||||
<>
|
|
||||||
{row.getVisibleCells().map((cell) => (
|
|
||||||
<TableCell
|
|
||||||
key={cell.id}
|
|
||||||
style={{ width: cell.column.getSize() }}
|
|
||||||
className="p-2 whitespace-nowrap"
|
|
||||||
onClick={() => onShowDetail(row.original.connectionData)}
|
|
||||||
>
|
>
|
||||||
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
{headerGroup.headers.map((header) => (
|
||||||
</TableCell>
|
<TableHead
|
||||||
))}
|
key={header.id}
|
||||||
</>
|
className={cn(
|
||||||
)}
|
"sticky top-0 z-10",
|
||||||
/>
|
"p-2 text-xs font-semibold select-none border-r last:border-r-0 bg-background h-10"
|
||||||
) : (
|
)}
|
||||||
<div className="flex h-full items-center justify-center">
|
style={{
|
||||||
<p>No results.</p>
|
width: header.getSize(),
|
||||||
</div>
|
minWidth: header.column.columnDef.minSize,
|
||||||
)}
|
maxWidth: header.column.columnDef.maxSize,
|
||||||
</div>
|
}}
|
||||||
|
>
|
||||||
|
<div className="flex items-center justify-between h-full">
|
||||||
|
{header.isPlaceholder
|
||||||
|
? null
|
||||||
|
: flexRender(
|
||||||
|
header.column.columnDef.header,
|
||||||
|
header.getContext()
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
{header.column.getCanResize() && (
|
||||||
|
<ColumnResizer header={header} />
|
||||||
|
)}
|
||||||
|
</TableHead>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableHeader>
|
||||||
|
|
||||||
|
<TableBody>
|
||||||
|
{table.getRowModel().rows.map((row) => (
|
||||||
|
<TableRow
|
||||||
|
key={row.id}
|
||||||
|
data-state={row.getIsSelected() && "selected"}
|
||||||
|
className="cursor-pointer hover:bg-muted/50 transition-colors"
|
||||||
|
onClick={() => onShowDetail(row.original.connectionData)}
|
||||||
|
>
|
||||||
|
{row.getVisibleCells().map((cell) => (
|
||||||
|
<TableCell
|
||||||
|
key={cell.id}
|
||||||
|
className="p-2 whitespace-nowrap overflow-hidden text-ellipsis text-sm border-r last:border-r-0"
|
||||||
|
style={{
|
||||||
|
width: cell.column.getSize(),
|
||||||
|
minWidth: cell.column.columnDef.minSize,
|
||||||
|
maxWidth: cell.column.columnDef.maxSize,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
{flexRender(cell.column.columnDef.cell, cell.getContext())}
|
||||||
|
</TableCell>
|
||||||
|
))}
|
||||||
|
</TableRow>
|
||||||
|
))}
|
||||||
|
</TableBody>
|
||||||
|
</Table>
|
||||||
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
@@ -1,6 +1,5 @@
|
|||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
// Новые импорты из shadcn/ui
|
|
||||||
import {
|
import {
|
||||||
AlertDialog,
|
AlertDialog,
|
||||||
AlertDialogAction,
|
AlertDialogAction,
|
||||||
@@ -18,7 +17,7 @@ interface Props {
|
|||||||
open: boolean;
|
open: boolean;
|
||||||
title: string;
|
title: string;
|
||||||
description: string;
|
description: string;
|
||||||
onOpenChange: (open: boolean) => void; // shadcn использует этот коллбэк
|
onOpenChange: (open: boolean) => void;
|
||||||
onConfirm: () => void;
|
onConfirm: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -30,7 +29,7 @@ export const ConfirmViewer = (props: Props) => {
|
|||||||
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
<AlertDialog open={open} onOpenChange={onOpenChange}>
|
||||||
<AlertDialogContent>
|
<AlertDialogContent>
|
||||||
<AlertDialogHeader>
|
<AlertDialogHeader>
|
||||||
<AlertDialogTitle>{title}</AlertDialogTitle>
|
<AlertDialogTitle className="truncate">{title}</AlertDialogTitle>
|
||||||
<AlertDialogDescription>{description}</AlertDialogDescription>
|
<AlertDialogDescription>{description}</AlertDialogDescription>
|
||||||
</AlertDialogHeader>
|
</AlertDialogHeader>
|
||||||
<AlertDialogFooter>
|
<AlertDialogFooter>
|
||||||
|
|||||||
@@ -23,7 +23,6 @@ import { open } from "@tauri-apps/plugin-shell";
|
|||||||
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
|
import { ProxiesEditorViewer } from "./proxies-editor-viewer";
|
||||||
import { cn } from "@root/lib/utils";
|
import { cn } from "@root/lib/utils";
|
||||||
|
|
||||||
// --- Компоненты shadcn/ui ---
|
|
||||||
import { Card } from "@/components/ui/card";
|
import { Card } from "@/components/ui/card";
|
||||||
import { Progress } from "@/components/ui/progress";
|
import { Progress } from "@/components/ui/progress";
|
||||||
import { Badge } from "@/components/ui/badge";
|
import { Badge } from "@/components/ui/badge";
|
||||||
@@ -46,7 +45,6 @@ import {
|
|||||||
ContextMenuTrigger,
|
ContextMenuTrigger,
|
||||||
} from "@/components/ui/context-menu";
|
} from "@/components/ui/context-menu";
|
||||||
|
|
||||||
// --- Иконки ---
|
|
||||||
import {
|
import {
|
||||||
GripVertical,
|
GripVertical,
|
||||||
File as FileIcon,
|
File as FileIcon,
|
||||||
@@ -71,10 +69,8 @@ import {
|
|||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import { t } from "i18next";
|
import { t } from "i18next";
|
||||||
|
|
||||||
// Активируем плагин для dayjs
|
|
||||||
dayjs.extend(relativeTime);
|
dayjs.extend(relativeTime);
|
||||||
|
|
||||||
// --- Вспомогательные функции ---
|
|
||||||
const parseUrl = (url?: string): string | undefined => {
|
const parseUrl = (url?: string): string | undefined => {
|
||||||
if (!url) return undefined;
|
if (!url) return undefined;
|
||||||
try {
|
try {
|
||||||
@@ -302,6 +298,11 @@ export const ProfileItem = (props: Props) => {
|
|||||||
isDestructive: true,
|
isDestructive: true,
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const MAX_NAME_LENGTH = 25;
|
||||||
|
const truncatedName = name.length > MAX_NAME_LENGTH
|
||||||
|
? `${name.slice(0, MAX_NAME_LENGTH)}...`
|
||||||
|
: name;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={setNodeRef} style={style} {...attributes}>
|
<div ref={setNodeRef} style={style} {...attributes}>
|
||||||
<ContextMenu>
|
<ContextMenu>
|
||||||
@@ -459,7 +460,6 @@ export const ProfileItem = (props: Props) => {
|
|||||||
</ContextMenuContent>
|
</ContextMenuContent>
|
||||||
</ContextMenu>
|
</ContextMenu>
|
||||||
|
|
||||||
{/* Модальные окна для редактирования */}
|
|
||||||
{fileOpen && (
|
{fileOpen && (
|
||||||
<EditorViewer
|
<EditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
@@ -479,10 +479,10 @@ export const ProfileItem = (props: Props) => {
|
|||||||
<RulesEditorViewer
|
<RulesEditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
onClose={() => setRulesOpen(false)}
|
onClose={() => setRulesOpen(false)}
|
||||||
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
|
profileUid={uid}
|
||||||
property={option?.rules ?? ""}
|
property={option?.rules ?? ""}
|
||||||
groupsUid={option?.groups ?? ""} // <-- Добавлен недостающий пропс
|
groupsUid={option?.groups ?? ""}
|
||||||
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
|
mergeUid={option?.merge ?? ""}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
@@ -491,7 +491,7 @@ export const ProfileItem = (props: Props) => {
|
|||||||
<ProxiesEditorViewer
|
<ProxiesEditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
onClose={() => setProxiesOpen(false)}
|
onClose={() => setProxiesOpen(false)}
|
||||||
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
|
profileUid={uid}
|
||||||
property={option?.proxies ?? ""}
|
property={option?.proxies ?? ""}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
@@ -501,20 +501,20 @@ export const ProfileItem = (props: Props) => {
|
|||||||
<GroupsEditorViewer
|
<GroupsEditorViewer
|
||||||
open={true}
|
open={true}
|
||||||
onClose={() => setGroupsOpen(false)}
|
onClose={() => setGroupsOpen(false)}
|
||||||
profileUid={uid} // <-- Был 'uid', стал 'profileUid'
|
profileUid={uid}
|
||||||
property={option?.groups ?? ""}
|
property={option?.groups ?? ""}
|
||||||
proxiesUid={option?.proxies ?? ""} // <-- Добавлен недостающий пропс
|
proxiesUid={option?.proxies ?? ""}
|
||||||
mergeUid={option?.merge ?? ""} // <-- Добавлен недостающий пропс
|
mergeUid={option?.merge ?? ""}
|
||||||
onSave={onSave}
|
onSave={onSave}
|
||||||
/>
|
/>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
<ConfirmViewer
|
<ConfirmViewer
|
||||||
open={confirmOpen}
|
open={confirmOpen}
|
||||||
onOpenChange={setConfirmOpen}
|
onOpenChange={setConfirmOpen}
|
||||||
onConfirm={onDelete}
|
onConfirm={onDelete}
|
||||||
title={t("Delete Profile", { name })}
|
title={t("Delete Profile", { name: truncatedName })}
|
||||||
description={t("This action cannot be undone.")}
|
description={t("This action cannot be undone.")}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|||||||
@@ -5,234 +5,263 @@ import { CheckIcon, ChevronRightIcon, CircleIcon } from "lucide-react";
|
|||||||
import { cn } from "@root/lib/utils";
|
import { cn } from "@root/lib/utils";
|
||||||
|
|
||||||
function DropdownMenu({
|
function DropdownMenu({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Root>) {
|
||||||
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
return <DropdownMenuPrimitive.Root data-slot="dropdown-menu" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuPortal({
|
function DropdownMenuPortal({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Portal>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
<DropdownMenuPrimitive.Portal data-slot="dropdown-menu-portal" {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuTrigger({
|
function DropdownMenuTrigger({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Trigger>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Trigger
|
<DropdownMenuPrimitive.Trigger
|
||||||
data-slot="dropdown-menu-trigger"
|
data-slot="dropdown-menu-trigger"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuContent({
|
function DropdownMenuContent({
|
||||||
className,
|
className,
|
||||||
sideOffset = 4,
|
sideOffset = 4,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Portal>
|
<DropdownMenuPrimitive.Portal>
|
||||||
<DropdownMenuPrimitive.Content
|
<DropdownMenuPrimitive.Content
|
||||||
data-slot="dropdown-menu-content"
|
data-slot="dropdown-menu-content"
|
||||||
sideOffset={sideOffset}
|
sideOffset={sideOffset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border p-1 shadow-md",
|
"z-50 max-h-(--radix-dropdown-menu-content-available-height) min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md p-1",
|
||||||
className,
|
"backdrop-blur-sm bg-white/70 border border-white/40",
|
||||||
)}
|
"dark:bg-white/10 dark:border-white/20",
|
||||||
{...props}
|
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
|
||||||
/>
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
</DropdownMenuPrimitive.Portal>
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
|
</DropdownMenuPrimitive.Portal>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuGroup({
|
function DropdownMenuGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Group>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
<DropdownMenuPrimitive.Group data-slot="dropdown-menu-group" {...props} />
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuItem({
|
function DropdownMenuItem({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
variant = "default",
|
variant = "default",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Item> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
variant?: "default" | "destructive";
|
variant?: "default" | "destructive";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Item
|
<DropdownMenuPrimitive.Item
|
||||||
data-slot="dropdown-menu-item"
|
data-slot="dropdown-menu-item"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
data-variant={variant}
|
data-variant={variant}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[variant=destructive]:text-destructive data-[variant=destructive]:focus:bg-destructive/10 dark:data-[variant=destructive]:focus:bg-destructive/20 data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive [&_svg:not([class*='text-'])]:text-muted-foreground relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 data-[inset]:pl-8",
|
||||||
className,
|
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
|
||||||
)}
|
"dark:hover:bg-white/20 dark:focus:bg-white/20",
|
||||||
{...props}
|
"data-[variant=destructive]:text-destructive data-[variant=destructive]:hover:bg-red-200/80 data-[variant=destructive]:focus:bg-red-200/80",
|
||||||
/>
|
"dark:data-[variant=destructive]:hover:bg-destructive/20 dark:data-[variant=destructive]:focus:bg-destructive/20",
|
||||||
|
"data-[variant=destructive]:focus:text-destructive data-[variant=destructive]:*:[svg]:!text-destructive",
|
||||||
|
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuCheckboxItem({
|
function DropdownMenuCheckboxItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
checked,
|
checked,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.CheckboxItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.CheckboxItem
|
<DropdownMenuPrimitive.CheckboxItem
|
||||||
data-slot="dropdown-menu-checkbox-item"
|
data-slot="dropdown-menu-checkbox-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className,
|
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
|
||||||
)}
|
"dark:hover:bg-white/20 dark:focus:bg-white/20",
|
||||||
checked={checked}
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
{...props}
|
className,
|
||||||
>
|
)}
|
||||||
|
checked={checked}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.CheckboxItem>
|
</DropdownMenuPrimitive.CheckboxItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioGroup({
|
function DropdownMenuRadioGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioGroup>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioGroup
|
<DropdownMenuPrimitive.RadioGroup
|
||||||
data-slot="dropdown-menu-radio-group"
|
data-slot="dropdown-menu-radio-group"
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuRadioItem({
|
function DropdownMenuRadioItem({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.RadioItem>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.RadioItem
|
<DropdownMenuPrimitive.RadioItem
|
||||||
data-slot="dropdown-menu-radio-item"
|
data-slot="dropdown-menu-radio-item"
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"relative flex cursor-default items-center gap-2 rounded-sm py-1.5 pr-2 pl-8 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
className,
|
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
|
||||||
)}
|
"dark:hover:bg-white/20 dark:focus:bg-white/20",
|
||||||
{...props}
|
"[&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
>
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
<span className="pointer-events-none absolute left-2 flex size-3.5 items-center justify-center">
|
||||||
<DropdownMenuPrimitive.ItemIndicator>
|
<DropdownMenuPrimitive.ItemIndicator>
|
||||||
<CircleIcon className="size-2 fill-current" />
|
<CircleIcon className="size-2 fill-current" />
|
||||||
</DropdownMenuPrimitive.ItemIndicator>
|
</DropdownMenuPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
{children}
|
{children}
|
||||||
</DropdownMenuPrimitive.RadioItem>
|
</DropdownMenuPrimitive.RadioItem>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuLabel({
|
function DropdownMenuLabel({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Label> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Label
|
<DropdownMenuPrimitive.Label
|
||||||
data-slot="dropdown-menu-label"
|
data-slot="dropdown-menu-label"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
"px-2 py-1.5 text-sm font-medium data-[inset]:pl-8",
|
||||||
className,
|
"text-foreground/80",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSeparator({
|
function DropdownMenuSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.Separator
|
<DropdownMenuPrimitive.Separator
|
||||||
data-slot="dropdown-menu-separator"
|
data-slot="dropdown-menu-separator"
|
||||||
className={cn("bg-border -mx-1 my-1 h-px", className)}
|
className={cn(
|
||||||
{...props}
|
"-mx-1 my-1 h-px",
|
||||||
/>
|
"bg-gray-400/50 dark:bg-white/20",
|
||||||
|
className
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuShortcut({
|
function DropdownMenuShortcut({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<"span">) {
|
}: React.ComponentProps<"span">) {
|
||||||
return (
|
return (
|
||||||
<span
|
<span
|
||||||
data-slot="dropdown-menu-shortcut"
|
data-slot="dropdown-menu-shortcut"
|
||||||
className={cn(
|
className={cn(
|
||||||
"text-muted-foreground ml-auto text-xs tracking-widest",
|
"ml-auto text-xs tracking-widest",
|
||||||
className,
|
"text-foreground/60",
|
||||||
)}
|
className,
|
||||||
{...props}
|
)}
|
||||||
/>
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSub({
|
function DropdownMenuSub({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.Sub>) {
|
||||||
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
return <DropdownMenuPrimitive.Sub data-slot="dropdown-menu-sub" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubTrigger({
|
function DropdownMenuSubTrigger({
|
||||||
className,
|
className,
|
||||||
inset,
|
inset,
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubTrigger> & {
|
||||||
inset?: boolean;
|
inset?: boolean;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubTrigger
|
<DropdownMenuPrimitive.SubTrigger
|
||||||
data-slot="dropdown-menu-sub-trigger"
|
data-slot="dropdown-menu-sub-trigger"
|
||||||
data-inset={inset}
|
data-inset={inset}
|
||||||
className={cn(
|
className={cn(
|
||||||
"focus:bg-accent focus:text-accent-foreground data-[state=open]:bg-accent data-[state=open]:text-accent-foreground flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
"flex cursor-default items-center rounded-sm px-2 py-1.5 text-sm outline-hidden select-none data-[inset]:pl-8",
|
||||||
className,
|
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
|
||||||
)}
|
"data-[state=open]:backdrop-blur-sm data-[state=open]:bg-gray-200/80",
|
||||||
{...props}
|
"dark:hover:bg-white/20 dark:focus:bg-white/20 dark:data-[state=open]:bg-white/20",
|
||||||
>
|
className,
|
||||||
{children}
|
)}
|
||||||
<ChevronRightIcon className="ml-auto size-4" />
|
{...props}
|
||||||
</DropdownMenuPrimitive.SubTrigger>
|
>
|
||||||
|
{children}
|
||||||
|
<ChevronRightIcon className="ml-auto size-4" />
|
||||||
|
</DropdownMenuPrimitive.SubTrigger>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function DropdownMenuSubContent({
|
function DropdownMenuSubContent({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
}: React.ComponentProps<typeof DropdownMenuPrimitive.SubContent>) {
|
||||||
return (
|
return (
|
||||||
<DropdownMenuPrimitive.SubContent
|
<DropdownMenuPrimitive.SubContent
|
||||||
data-slot="dropdown-menu-sub-content"
|
data-slot="dropdown-menu-sub-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md border p-1 shadow-lg",
|
"z-50 min-w-[8rem] origin-(--radix-dropdown-menu-content-transform-origin) overflow-hidden rounded-md p-1",
|
||||||
className,
|
"backdrop-blur-sm bg-white/70 border border-white/40",
|
||||||
)}
|
"dark:bg-white/10 dark:border-white/20",
|
||||||
{...props}
|
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
|
||||||
/>
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -252,4 +281,4 @@ export {
|
|||||||
DropdownMenuSub,
|
DropdownMenuSub,
|
||||||
DropdownMenuSubTrigger,
|
DropdownMenuSubTrigger,
|
||||||
DropdownMenuSubContent,
|
DropdownMenuSubContent,
|
||||||
};
|
};
|
||||||
@@ -5,179 +5,191 @@ import { CheckIcon, ChevronDownIcon, ChevronUpIcon } from "lucide-react";
|
|||||||
import { cn } from "@root/lib/utils";
|
import { cn } from "@root/lib/utils";
|
||||||
|
|
||||||
function Select({
|
function Select({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Root>) {
|
||||||
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
return <SelectPrimitive.Root data-slot="select" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectGroup({
|
function SelectGroup({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Group>) {
|
||||||
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
return <SelectPrimitive.Group data-slot="select-group" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectValue({
|
function SelectValue({
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Value>) {
|
||||||
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
return <SelectPrimitive.Value data-slot="select-value" {...props} />;
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectTrigger({
|
function SelectTrigger({
|
||||||
className,
|
className,
|
||||||
size = "default",
|
size = "default",
|
||||||
children,
|
children,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
}: React.ComponentProps<typeof SelectPrimitive.Trigger> & {
|
||||||
size?: "sm" | "default";
|
size?: "sm" | "default";
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Trigger
|
<SelectPrimitive.Trigger
|
||||||
data-slot="select-trigger"
|
data-slot="select-trigger"
|
||||||
data-size={size}
|
data-size={size}
|
||||||
className={cn(
|
className={cn(
|
||||||
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive dark:bg-input/30 dark:hover:bg-input/50 flex w-fit items-center justify-between gap-2 rounded-md border bg-transparent px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
"border-input data-[placeholder]:text-muted-foreground [&_svg:not([class*='text-'])]:text-muted-foreground focus-visible:border-ring focus-visible:ring-ring/50 aria-invalid:ring-destructive/20 dark:aria-invalid:ring-destructive/40 aria-invalid:border-destructive flex w-fit items-center justify-between gap-2 rounded-md border px-3 py-2 text-sm whitespace-nowrap shadow-xs transition-[color,box-shadow,background-color] outline-none focus-visible:ring-[3px] disabled:cursor-not-allowed disabled:opacity-50 data-[size=default]:h-9 data-[size=sm]:h-8 *:data-[slot=select-value]:line-clamp-1 *:data-[slot=select-value]:flex *:data-[slot=select-value]:items-center *:data-[slot=select-value]:gap-2 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4",
|
||||||
className,
|
"backdrop-blur-sm bg-white/80 border-gray-300/60",
|
||||||
)}
|
"dark:bg-white/5 dark:border-white/15",
|
||||||
{...props}
|
"hover:bg-white/90 hover:border-gray-400/70",
|
||||||
>
|
"dark:hover:bg-white/10 dark:hover:border-white/20",
|
||||||
{children}
|
className,
|
||||||
<SelectPrimitive.Icon asChild>
|
)}
|
||||||
<ChevronDownIcon className="size-4 opacity-50" />
|
{...props}
|
||||||
</SelectPrimitive.Icon>
|
>
|
||||||
</SelectPrimitive.Trigger>
|
{children}
|
||||||
);
|
<SelectPrimitive.Icon asChild>
|
||||||
|
<ChevronDownIcon className="size-4 opacity-50" />
|
||||||
|
</SelectPrimitive.Icon>
|
||||||
|
</SelectPrimitive.Trigger>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectContent({
|
function SelectContent({
|
||||||
className,
|
className,
|
||||||
children,
|
children,
|
||||||
position = "popper",
|
position = "popper",
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Content>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Portal>
|
<SelectPrimitive.Portal>
|
||||||
<SelectPrimitive.Content
|
<SelectPrimitive.Content
|
||||||
data-slot="select-content"
|
data-slot="select-content"
|
||||||
className={cn(
|
className={cn(
|
||||||
"bg-popover text-popover-foreground data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2 relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md border shadow-md",
|
"relative z-50 max-h-(--radix-select-content-available-height) min-w-[8rem] origin-(--radix-select-content-transform-origin) overflow-x-hidden overflow-y-auto rounded-md",
|
||||||
position === "popper" &&
|
"backdrop-blur-sm bg-white/70 border border-white/40",
|
||||||
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
"dark:bg-white/10 dark:border-white/20",
|
||||||
className,
|
"shadow-[0_8px_32px_rgba(0,0,0,0.12)] dark:shadow-[0_8px_32px_rgba(255,255,255,0.05)]",
|
||||||
)}
|
"data-[state=open]:animate-in data-[state=closed]:animate-out data-[state=closed]:fade-out-0 data-[state=open]:fade-in-0 data-[state=closed]:zoom-out-95 data-[state=open]:zoom-in-95 data-[side=bottom]:slide-in-from-top-2 data-[side=left]:slide-in-from-right-2 data-[side=right]:slide-in-from-left-2 data-[side=top]:slide-in-from-bottom-2",
|
||||||
position={position}
|
position === "popper" &&
|
||||||
{...props}
|
"data-[side=bottom]:translate-y-1 data-[side=left]:-translate-x-1 data-[side=right]:translate-x-1 data-[side=top]:-translate-y-1",
|
||||||
>
|
className,
|
||||||
<SelectScrollUpButton />
|
)}
|
||||||
<SelectPrimitive.Viewport
|
position={position}
|
||||||
className={cn(
|
{...props}
|
||||||
"p-1",
|
>
|
||||||
position === "popper" &&
|
<SelectScrollUpButton />
|
||||||
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
<SelectPrimitive.Viewport
|
||||||
)}
|
className={cn(
|
||||||
>
|
"p-1",
|
||||||
{children}
|
position === "popper" &&
|
||||||
</SelectPrimitive.Viewport>
|
"h-[var(--radix-select-trigger-height)] w-full min-w-[var(--radix-select-trigger-width)] scroll-my-1",
|
||||||
<SelectScrollDownButton />
|
)}
|
||||||
</SelectPrimitive.Content>
|
>
|
||||||
</SelectPrimitive.Portal>
|
{children}
|
||||||
);
|
</SelectPrimitive.Viewport>
|
||||||
|
<SelectScrollDownButton />
|
||||||
|
</SelectPrimitive.Content>
|
||||||
|
</SelectPrimitive.Portal>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectLabel({
|
function SelectLabel({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Label>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Label
|
<SelectPrimitive.Label
|
||||||
data-slot="select-label"
|
data-slot="select-label"
|
||||||
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
className={cn("text-muted-foreground px-2 py-1.5 text-xs", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectItem({
|
function SelectItem({
|
||||||
className,
|
|
||||||
children,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.Item
|
|
||||||
data-slot="select-item"
|
|
||||||
className={cn(
|
|
||||||
"focus:bg-accent focus:text-accent-foreground [&_svg:not([class*='text-'])]:text-muted-foreground relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50 [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
|
||||||
className,
|
className,
|
||||||
)}
|
children,
|
||||||
{...props}
|
...props
|
||||||
>
|
}: React.ComponentProps<typeof SelectPrimitive.Item>) {
|
||||||
|
return (
|
||||||
|
<SelectPrimitive.Item
|
||||||
|
data-slot="select-item"
|
||||||
|
className={cn(
|
||||||
|
"relative flex w-full cursor-default items-center gap-2 rounded-sm py-1.5 pr-8 pl-2 text-sm outline-hidden select-none data-[disabled]:pointer-events-none data-[disabled]:opacity-50",
|
||||||
|
"hover:backdrop-blur-sm hover:bg-gray-200/80 focus:backdrop-blur-sm focus:bg-gray-200/80",
|
||||||
|
"dark:hover:bg-white/20 dark:focus:bg-white/20",
|
||||||
|
"transition-all duration-200",
|
||||||
|
"[&_svg:not([class*='text-'])]:text-muted-foreground [&_svg]:pointer-events-none [&_svg]:shrink-0 [&_svg:not([class*='size-'])]:size-4 *:[span]:last:flex *:[span]:last:items-center *:[span]:last:gap-2",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
<span className="absolute right-2 flex size-3.5 items-center justify-center">
|
||||||
<SelectPrimitive.ItemIndicator>
|
<SelectPrimitive.ItemIndicator>
|
||||||
<CheckIcon className="size-4" />
|
<CheckIcon className="size-4" />
|
||||||
</SelectPrimitive.ItemIndicator>
|
</SelectPrimitive.ItemIndicator>
|
||||||
</span>
|
</span>
|
||||||
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
<SelectPrimitive.ItemText>{children}</SelectPrimitive.ItemText>
|
||||||
</SelectPrimitive.Item>
|
</SelectPrimitive.Item>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectSeparator({
|
function SelectSeparator({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
}: React.ComponentProps<typeof SelectPrimitive.Separator>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.Separator
|
<SelectPrimitive.Separator
|
||||||
data-slot="select-separator"
|
data-slot="select-separator"
|
||||||
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
className={cn("bg-border pointer-events-none -mx-1 my-1 h-px", className)}
|
||||||
{...props}
|
{...props}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollUpButton({
|
function SelectScrollUpButton({
|
||||||
className,
|
className,
|
||||||
...props
|
...props
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollUpButton>) {
|
||||||
return (
|
return (
|
||||||
<SelectPrimitive.ScrollUpButton
|
<SelectPrimitive.ScrollUpButton
|
||||||
data-slot="select-scroll-up-button"
|
data-slot="select-scroll-up-button"
|
||||||
className={cn(
|
className={cn(
|
||||||
"flex cursor-default items-center justify-center py-1",
|
"flex cursor-default items-center justify-center py-1",
|
||||||
className,
|
className,
|
||||||
)}
|
)}
|
||||||
{...props}
|
{...props}
|
||||||
>
|
>
|
||||||
<ChevronUpIcon className="size-4" />
|
<ChevronUpIcon className="size-4" />
|
||||||
</SelectPrimitive.ScrollUpButton>
|
</SelectPrimitive.ScrollUpButton>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function SelectScrollDownButton({
|
function SelectScrollDownButton({
|
||||||
className,
|
|
||||||
...props
|
|
||||||
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
|
||||||
return (
|
|
||||||
<SelectPrimitive.ScrollDownButton
|
|
||||||
data-slot="select-scroll-down-button"
|
|
||||||
className={cn(
|
|
||||||
"flex cursor-default items-center justify-center py-1",
|
|
||||||
className,
|
className,
|
||||||
)}
|
...props
|
||||||
{...props}
|
}: React.ComponentProps<typeof SelectPrimitive.ScrollDownButton>) {
|
||||||
>
|
return (
|
||||||
<ChevronDownIcon className="size-4" />
|
<SelectPrimitive.ScrollDownButton
|
||||||
</SelectPrimitive.ScrollDownButton>
|
data-slot="select-scroll-down-button"
|
||||||
);
|
className={cn(
|
||||||
|
"flex cursor-default items-center justify-center py-1",
|
||||||
|
className,
|
||||||
|
)}
|
||||||
|
{...props}
|
||||||
|
>
|
||||||
|
<ChevronDownIcon className="size-4" />
|
||||||
|
</SelectPrimitive.ScrollDownButton>
|
||||||
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export {
|
export {
|
||||||
Select,
|
Select,
|
||||||
SelectContent,
|
SelectContent,
|
||||||
SelectGroup,
|
SelectGroup,
|
||||||
SelectItem,
|
SelectItem,
|
||||||
SelectLabel,
|
SelectLabel,
|
||||||
SelectScrollDownButton,
|
SelectScrollDownButton,
|
||||||
SelectScrollUpButton,
|
SelectScrollUpButton,
|
||||||
SelectSeparator,
|
SelectSeparator,
|
||||||
SelectTrigger,
|
SelectTrigger,
|
||||||
SelectValue,
|
SelectValue,
|
||||||
};
|
};
|
||||||
@@ -49,18 +49,37 @@ import {
|
|||||||
PauseCircle,
|
PauseCircle,
|
||||||
ArrowDown,
|
ArrowDown,
|
||||||
ArrowUp,
|
ArrowUp,
|
||||||
Menu,
|
|
||||||
} from "lucide-react";
|
} from "lucide-react";
|
||||||
import {SidebarTrigger} from "@/components/ui/sidebar";
|
import {SidebarTrigger} from "@/components/ui/sidebar";
|
||||||
|
|
||||||
|
interface IConnectionsItem {
|
||||||
|
id: string;
|
||||||
|
metadata: {
|
||||||
|
host: string;
|
||||||
|
destinationIP: string;
|
||||||
|
process?: string;
|
||||||
|
};
|
||||||
|
start?: string;
|
||||||
|
curUpload?: number;
|
||||||
|
curDownload?: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface IConnections {
|
||||||
|
uploadTotal: number;
|
||||||
|
downloadTotal: number;
|
||||||
|
connections: IConnectionsItem[];
|
||||||
|
data: IConnectionsItem[];
|
||||||
|
}
|
||||||
|
|
||||||
|
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
|
||||||
|
|
||||||
const initConn: IConnections = {
|
const initConn: IConnections = {
|
||||||
uploadTotal: 0,
|
uploadTotal: 0,
|
||||||
downloadTotal: 0,
|
downloadTotal: 0,
|
||||||
connections: [],
|
connections: [],
|
||||||
|
data: [],
|
||||||
};
|
};
|
||||||
|
|
||||||
type OrderFunc = (list: IConnectionsItem[]) => IConnectionsItem[];
|
|
||||||
|
|
||||||
const ConnectionsPage = () => {
|
const ConnectionsPage = () => {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const pageVisible = useVisibility();
|
const pageVisible = useVisibility();
|
||||||
@@ -72,14 +91,14 @@ const ConnectionsPage = () => {
|
|||||||
|
|
||||||
const orderOpts: Record<string, OrderFunc> = {
|
const orderOpts: Record<string, OrderFunc> = {
|
||||||
Default: (list) =>
|
Default: (list) =>
|
||||||
list.sort(
|
list.sort(
|
||||||
(a, b) =>
|
(a, b) =>
|
||||||
new Date(b.start || "0").getTime()! -
|
new Date(b.start || "0").getTime()! -
|
||||||
new Date(a.start || "0").getTime()!,
|
new Date(a.start || "0").getTime()!,
|
||||||
),
|
),
|
||||||
"Upload Speed": (list) => list.sort((a, b) => b.curUpload! - a.curUpload!),
|
"Upload Speed": (list) => list.sort((a, b) => (b.curUpload ?? 0) - (a.curUpload ?? 0)),
|
||||||
"Download Speed": (list) =>
|
"Download Speed": (list) =>
|
||||||
list.sort((a, b) => b.curDownload! - a.curDownload!),
|
list.sort((a, b) => (b.curDownload ?? 0) - (a.curDownload ?? 0)),
|
||||||
};
|
};
|
||||||
|
|
||||||
const [isPaused, setIsPaused] = useState(false);
|
const [isPaused, setIsPaused] = useState(false);
|
||||||
@@ -91,6 +110,7 @@ const ConnectionsPage = () => {
|
|||||||
uploadTotal: connections.uploadTotal,
|
uploadTotal: connections.uploadTotal,
|
||||||
downloadTotal: connections.downloadTotal,
|
downloadTotal: connections.downloadTotal,
|
||||||
connections: connections.data,
|
connections: connections.data,
|
||||||
|
data: connections.data,
|
||||||
};
|
};
|
||||||
if (isPaused) return frozenData ?? currentData;
|
if (isPaused) return frozenData ?? currentData;
|
||||||
return currentData;
|
return currentData;
|
||||||
@@ -101,7 +121,7 @@ const ConnectionsPage = () => {
|
|||||||
let conns = displayData.connections.filter((conn) => {
|
let conns = displayData.connections.filter((conn) => {
|
||||||
const { host, destinationIP, process } = conn.metadata;
|
const { host, destinationIP, process } = conn.metadata;
|
||||||
return (
|
return (
|
||||||
match(host || "") || match(destinationIP || "") || match(process || "")
|
match(host || "") || match(destinationIP || "") || match(process || "")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
if (orderFunc) conns = orderFunc(conns);
|
if (orderFunc) conns = orderFunc(conns);
|
||||||
@@ -109,24 +129,24 @@ const ConnectionsPage = () => {
|
|||||||
}, [displayData, match, curOrderOpt]);
|
}, [displayData, match, curOrderOpt]);
|
||||||
|
|
||||||
const [scrollingElement, setScrollingElement] = useState<
|
const [scrollingElement, setScrollingElement] = useState<
|
||||||
HTMLElement | Window | null
|
HTMLElement | Window | null
|
||||||
>(null);
|
>(null);
|
||||||
const [isScrolled, setIsScrolled] = useState(false);
|
const [isScrolled, setIsScrolled] = useState(false);
|
||||||
|
|
||||||
const scrollerRefCallback = useCallback(
|
const scrollerRefCallback = useCallback(
|
||||||
(node: HTMLElement | Window | null) => {
|
(node: HTMLElement | Window | null) => {
|
||||||
setScrollingElement(node);
|
setScrollingElement(node);
|
||||||
},
|
},
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!scrollingElement) return;
|
if (!scrollingElement) return;
|
||||||
const handleScroll = () => {
|
const handleScroll = () => {
|
||||||
const scrollTop =
|
const scrollTop =
|
||||||
scrollingElement instanceof Window
|
scrollingElement instanceof Window
|
||||||
? scrollingElement.scrollY
|
? scrollingElement.scrollY
|
||||||
: scrollingElement.scrollTop;
|
: scrollingElement.scrollTop;
|
||||||
setIsScrolled(scrollTop > 5);
|
setIsScrolled(scrollTop > 5);
|
||||||
};
|
};
|
||||||
|
|
||||||
@@ -137,8 +157,8 @@ const ConnectionsPage = () => {
|
|||||||
const onCloseAll = useLockFn(closeAllConnections);
|
const onCloseAll = useLockFn(closeAllConnections);
|
||||||
const detailRef = useRef<ConnectionDetailRef>(null!);
|
const detailRef = useRef<ConnectionDetailRef>(null!);
|
||||||
const handleSearch = useCallback(
|
const handleSearch = useCallback(
|
||||||
(m: (content: string) => boolean) => setMatch(() => m),
|
(m: (content: string) => boolean) => setMatch(() => m),
|
||||||
[],
|
[],
|
||||||
);
|
);
|
||||||
const handlePauseToggle = useCallback(() => {
|
const handlePauseToggle = useCallback(() => {
|
||||||
setIsPaused((prev) => {
|
setIsPaused((prev) => {
|
||||||
@@ -147,6 +167,7 @@ const ConnectionsPage = () => {
|
|||||||
uploadTotal: connections.uploadTotal,
|
uploadTotal: connections.uploadTotal,
|
||||||
downloadTotal: connections.downloadTotal,
|
downloadTotal: connections.downloadTotal,
|
||||||
connections: connections.data,
|
connections: connections.data,
|
||||||
|
data: connections.data,
|
||||||
});
|
});
|
||||||
} else {
|
} else {
|
||||||
setFrozenData(null);
|
setFrozenData(null);
|
||||||
@@ -155,134 +176,138 @@ const ConnectionsPage = () => {
|
|||||||
});
|
});
|
||||||
}, [connections]);
|
}, [connections]);
|
||||||
|
|
||||||
return (
|
const headerHeight = "7rem";
|
||||||
<div className="h-full w-full relative">
|
|
||||||
<div
|
|
||||||
className={cn(
|
|
||||||
"absolute top-0 left-0 right-0 z-10 p-4 transition-all duration-200",
|
|
||||||
{ "bg-background/80 backdrop-blur-sm shadow-sm": isScrolled },
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
<div className="flex justify-between items-center">
|
|
||||||
<div className="w-10">
|
|
||||||
<SidebarTrigger />
|
|
||||||
</div>
|
|
||||||
<h2 className="text-2xl font-semibold tracking-tight">
|
|
||||||
{t("Connections")}
|
|
||||||
</h2>
|
|
||||||
<TooltipProvider delayDuration={100}>
|
|
||||||
<div className="flex items-center gap-2">
|
|
||||||
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ArrowDown className="h-4 w-4 text-green-500" />
|
|
||||||
{parseTraffic(displayData.downloadTotal)}
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<ArrowUp className="h-4 w-4 text-sky-500" />
|
|
||||||
{parseTraffic(displayData.uploadTotal)}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<Separator orientation="vertical" className="h-6 mx-2" />
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={() =>
|
|
||||||
setSetting((o) =>
|
|
||||||
o?.layout !== "table"
|
|
||||||
? { ...o, layout: "table" }
|
|
||||||
: { ...o, layout: "list" },
|
|
||||||
)
|
|
||||||
}
|
|
||||||
>
|
|
||||||
{isTableLayout ? (
|
|
||||||
<List className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<Table2 className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{isTableLayout ? t("List View") : t("Table View")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handlePauseToggle}
|
|
||||||
>
|
|
||||||
{isPaused ? (
|
|
||||||
<PlayCircle className="h-5 w-5" />
|
|
||||||
) : (
|
|
||||||
<PauseCircle className="h-5 w-5" />
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent>
|
|
||||||
<p>{isPaused ? t("Resume") : t("Pause")}</p>
|
|
||||||
</TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
<Button size="sm" variant="destructive" onClick={onCloseAll}>
|
|
||||||
{t("Close All")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
<div className="flex items-center gap-2 mt-2">
|
|
||||||
{!isTableLayout && (
|
|
||||||
<Select
|
|
||||||
value={curOrderOpt}
|
|
||||||
onValueChange={(value) => setOrderOpt(value)}
|
|
||||||
>
|
|
||||||
<SelectTrigger className="w-[180px]">
|
|
||||||
<SelectValue placeholder={t("Sort by")} />
|
|
||||||
</SelectTrigger>
|
|
||||||
<SelectContent>
|
|
||||||
{Object.keys(orderOpts).map((opt) => (
|
|
||||||
<SelectItem key={opt} value={opt}>
|
|
||||||
{t(opt)}
|
|
||||||
</SelectItem>
|
|
||||||
))}
|
|
||||||
</SelectContent>
|
|
||||||
</Select>
|
|
||||||
)}
|
|
||||||
<div className="flex-1">
|
|
||||||
<BaseSearchBox onSearch={handleSearch} />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="absolute top-0 left-0 right-0 bottom-0 pt-28">
|
return (
|
||||||
{filterConn.length === 0 ? (
|
<div className="relative h-full w-full">
|
||||||
<BaseEmpty />
|
<div
|
||||||
) : isTableLayout ? (
|
className="absolute top-0 left-0 right-0 z-20 p-4 bg-background/80 backdrop-blur-sm"
|
||||||
<div className="p-4 pt-0 h-full w-full">
|
style={{ height: headerHeight }}
|
||||||
<ConnectionTable
|
>
|
||||||
connections={filterConn}
|
<div className="flex justify-between items-center">
|
||||||
onShowDetail={(detail) => detailRef.current?.open(detail)}
|
<div className="w-10">
|
||||||
scrollerRef={scrollerRefCallback}
|
<SidebarTrigger />
|
||||||
/>
|
</div>
|
||||||
|
<h2 className="text-2xl font-semibold tracking-tight">
|
||||||
|
{t("Connections")}
|
||||||
|
</h2>
|
||||||
|
<TooltipProvider delayDuration={100}>
|
||||||
|
<div className="flex items-center gap-2">
|
||||||
|
<div className="flex items-center gap-4 text-sm text-muted-foreground">
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowDown className="h-4 w-4 text-green-500" />
|
||||||
|
{parseTraffic(displayData.downloadTotal)}
|
||||||
|
</div>
|
||||||
|
<div className="flex items-center gap-1">
|
||||||
|
<ArrowUp className="h-4 w-4 text-sky-500" />
|
||||||
|
{parseTraffic(displayData.uploadTotal)}
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<Separator orientation="vertical" className="h-6 mx-2" />
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={() =>
|
||||||
|
setSetting((o) =>
|
||||||
|
o?.layout !== "table"
|
||||||
|
? { ...o, layout: "table" }
|
||||||
|
: { ...o, layout: "list" },
|
||||||
|
)
|
||||||
|
}
|
||||||
|
>
|
||||||
|
{isTableLayout ? (
|
||||||
|
<List className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<Table2 className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{isTableLayout ? t("List View") : t("Table View")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handlePauseToggle}
|
||||||
|
>
|
||||||
|
{isPaused ? (
|
||||||
|
<PlayCircle className="h-5 w-5" />
|
||||||
|
) : (
|
||||||
|
<PauseCircle className="h-5 w-5" />
|
||||||
|
)}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{isPaused ? t("Resume") : t("Pause")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
<Button size="sm" variant="destructive" onClick={onCloseAll}>
|
||||||
|
{t("Close All")}
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
) : (
|
<div className="flex items-center gap-2 mt-2">
|
||||||
<Virtuoso
|
{!isTableLayout && (
|
||||||
scrollerRef={scrollerRefCallback}
|
<Select
|
||||||
data={filterConn}
|
value={curOrderOpt}
|
||||||
className="h-full w-full"
|
onValueChange={(value) => setOrderOpt(value)}
|
||||||
itemContent={(_, item) => (
|
>
|
||||||
<ConnectionItem
|
<SelectTrigger className="w-[180px]">
|
||||||
value={item}
|
<SelectValue placeholder={t("Sort by")} />
|
||||||
onShowDetail={() => detailRef.current?.open(item)}
|
</SelectTrigger>
|
||||||
/>
|
<SelectContent>
|
||||||
|
{Object.keys(orderOpts).map((opt) => (
|
||||||
|
<SelectItem key={opt} value={opt}>
|
||||||
|
{t(opt)}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
)}
|
)}
|
||||||
/>
|
<div className="flex-1">
|
||||||
)}
|
<BaseSearchBox onSearch={handleSearch} />
|
||||||
<ConnectionDetail ref={detailRef} />
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<div
|
||||||
|
ref={scrollerRefCallback}
|
||||||
|
className="absolute left-0 right-0 bottom-0 overflow-y-auto"
|
||||||
|
style={{ top: headerHeight }}
|
||||||
|
>
|
||||||
|
{filterConn.length === 0 ? (
|
||||||
|
<BaseEmpty />
|
||||||
|
) : isTableLayout ? (
|
||||||
|
<div className="p-4 pt-0">
|
||||||
|
<ConnectionTable
|
||||||
|
connections={filterConn}
|
||||||
|
onShowDetail={(detail) => detailRef.current?.open(detail)}
|
||||||
|
scrollerRef={scrollerRefCallback}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
) : (
|
||||||
|
<Virtuoso
|
||||||
|
scrollerRef={scrollerRefCallback}
|
||||||
|
data={filterConn}
|
||||||
|
className="h-full w-full"
|
||||||
|
itemContent={(_, item) => (
|
||||||
|
<ConnectionItem
|
||||||
|
value={item}
|
||||||
|
onShowDetail={() => detailRef.current?.open(item)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
/>
|
||||||
|
)}
|
||||||
|
<ConnectionDetail ref={detailRef} />
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default ConnectionsPage;
|
export default ConnectionsPage;
|
||||||
@@ -48,14 +48,14 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
const [isToggling, setIsToggling] = useState(false);
|
const [isToggling, setIsToggling] = useState(false);
|
||||||
const [isUpdating, setIsUpdating] = useState(false);
|
const [isUpdating, setIsUpdating] = useState(false);
|
||||||
const { profiles, patchProfiles, activateSelected, mutateProfiles } =
|
const { profiles, patchProfiles, activateSelected, mutateProfiles } =
|
||||||
useProfiles();
|
useProfiles();
|
||||||
const viewerRef = useRef<ProfileViewerRef>(null);
|
const viewerRef = useRef<ProfileViewerRef>(null);
|
||||||
const [uidToActivate, setUidToActivate] = useState<string | null>(null);
|
const [uidToActivate, setUidToActivate] = useState<string | null>(null);
|
||||||
const { connections } = useAppData();
|
const { connections } = useAppData();
|
||||||
|
|
||||||
const profileItems = useMemo(() => {
|
const profileItems = useMemo(() => {
|
||||||
const items =
|
const items =
|
||||||
profiles && Array.isArray(profiles.items) ? profiles.items : [];
|
profiles && Array.isArray(profiles.items) ? profiles.items : [];
|
||||||
const allowedTypes = ["local", "remote"];
|
const allowedTypes = ["local", "remote"];
|
||||||
return items.filter((i: any) => i && allowedTypes.includes(i.type!));
|
return items.filter((i: any) => i && allowedTypes.includes(i.type!));
|
||||||
}, [profiles]);
|
}, [profiles]);
|
||||||
@@ -66,20 +66,20 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
const currentProfileName = currentProfile?.name || profiles?.current;
|
const currentProfileName = currentProfile?.name || profiles?.current;
|
||||||
|
|
||||||
const activateProfile = useCallback(
|
const activateProfile = useCallback(
|
||||||
async (uid: string, notifySuccess: boolean) => {
|
async (uid: string, notifySuccess: boolean) => {
|
||||||
try {
|
try {
|
||||||
await patchProfiles({ current: uid });
|
await patchProfiles({ current: uid });
|
||||||
await closeAllConnections();
|
await closeAllConnections();
|
||||||
await activateSelected();
|
await activateSelected();
|
||||||
if (notifySuccess) {
|
if (notifySuccess) {
|
||||||
toast.success(t("Profile Switched"));
|
toast.success(t("Profile Switched"));
|
||||||
|
}
|
||||||
|
} catch (err: any) {
|
||||||
|
toast.error(err.message || err.toString());
|
||||||
|
mutateProfiles();
|
||||||
}
|
}
|
||||||
} catch (err: any) {
|
},
|
||||||
toast.error(err.message || err.toString());
|
[patchProfiles, activateSelected, mutateProfiles, t],
|
||||||
mutateProfiles();
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[patchProfiles, activateSelected, mutateProfiles, t],
|
|
||||||
);
|
);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
@@ -90,7 +90,6 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
}
|
}
|
||||||
}, [profileItems, activateProfile]);
|
}, [profileItems, activateProfile]);
|
||||||
|
|
||||||
|
|
||||||
const handleProfileChange = useLockFn(async (uid: string) => {
|
const handleProfileChange = useLockFn(async (uid: string) => {
|
||||||
if (profiles?.current === uid) return;
|
if (profiles?.current === uid) return;
|
||||||
await activateProfile(uid, true);
|
await activateProfile(uid, true);
|
||||||
@@ -102,7 +101,7 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
const isTunAvailable = isServiceMode || isAdminMode;
|
const isTunAvailable = isServiceMode || isAdminMode;
|
||||||
const isProxyEnabled = verge?.enable_system_proxy || verge?.enable_tun_mode;
|
const isProxyEnabled = verge?.enable_system_proxy || verge?.enable_tun_mode;
|
||||||
const showTunAlert =
|
const showTunAlert =
|
||||||
(verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable;
|
(verge?.primary_action ?? "tun-mode") === "tun-mode" && !isTunAvailable;
|
||||||
|
|
||||||
const handleToggleProxy = useLockFn(async () => {
|
const handleToggleProxy = useLockFn(async () => {
|
||||||
const turningOn = !isProxyEnabled;
|
const turningOn = !isProxyEnabled;
|
||||||
@@ -180,111 +179,188 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
}, [isToggling, isProxyEnabled, t]);
|
}, [isToggling, isProxyEnabled, t]);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-full w-full flex flex-col">
|
<div className="h-full w-full flex flex-col">
|
||||||
<div className="absolute inset-0 opacity-20 pointer-events-none z-0 [transform:translateZ(0)]">
|
<div className="absolute inset-0 opacity-20 pointer-events-none z-0 [transform:translateZ(0)]">
|
||||||
<img
|
<img
|
||||||
src={map}
|
src={map}
|
||||||
alt="World map"
|
alt="World map"
|
||||||
className="w-full h-full object-cover"
|
className="w-full h-full object-cover"
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{isProxyEnabled && (
|
|
||||||
<div
|
|
||||||
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0 transition-opacity duration-500"
|
|
||||||
style={{
|
|
||||||
background: 'radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)',
|
|
||||||
filter: 'blur(100px)',
|
|
||||||
}}
|
|
||||||
/>
|
/>
|
||||||
)}
|
|
||||||
|
|
||||||
<header className="flex-shrink-0 p-5 grid grid-cols-3 items-center z-10">
|
|
||||||
<div className="flex justify-start">
|
|
||||||
<SidebarTrigger />
|
|
||||||
</div>
|
</div>
|
||||||
<div className="justify-self-center flex flex-col items-center gap-2">
|
|
||||||
<div className="relative">
|
{isProxyEnabled && (
|
||||||
{profileItems.length > 0 && (
|
<div
|
||||||
<div className="flex-shrink-0">
|
className="absolute top-1/2 left-1/2 -translate-x-1/2 -translate-y-1/2 h-[500px] w-[500px] rounded-full pointer-events-none z-0 transition-opacity duration-500"
|
||||||
<DropdownMenu>
|
style={{
|
||||||
<DropdownMenuTrigger asChild>
|
background: 'radial-gradient(circle, rgba(34,197,94,0.3) 0%, transparent 70%)',
|
||||||
<Button variant="outline" className="w-full max-w-[250px] sm:max-w-xs">
|
filter: 'blur(100px)',
|
||||||
<span className="truncate">{currentProfileName}</span>
|
}}
|
||||||
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
/>
|
||||||
</Button>
|
)}
|
||||||
</DropdownMenuTrigger>
|
|
||||||
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
|
<header className="flex-shrink-0 p-5 grid grid-cols-3 items-center z-10">
|
||||||
<DropdownMenuLabel>{t("Profiles")}</DropdownMenuLabel>
|
<div className="flex justify-start">
|
||||||
<DropdownMenuSeparator />
|
<SidebarTrigger />
|
||||||
{profileItems.map((p) => (
|
|
||||||
<DropdownMenuItem
|
|
||||||
key={p.uid}
|
|
||||||
onSelect={() => handleProfileChange(p.uid)}
|
|
||||||
>
|
|
||||||
<span className="flex-1 truncate">{p.name}</span>
|
|
||||||
{profiles?.current === p.uid && (
|
|
||||||
<Check className="ml-4 h-4 w-4" />
|
|
||||||
)}
|
|
||||||
</DropdownMenuItem>
|
|
||||||
))}
|
|
||||||
<DropdownMenuSeparator />
|
|
||||||
<DropdownMenuItem onSelect={() => viewerRef.current?.create()}>
|
|
||||||
<PlusCircle className="mr-2 h-4 w-4" />
|
|
||||||
<span>{t("Add Profile")}</span>
|
|
||||||
</DropdownMenuItem>
|
|
||||||
</DropdownMenuContent>
|
|
||||||
</DropdownMenu>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{currentProfile?.type === 'remote' && (
|
|
||||||
<div className="absolute top-1/2 -translate-y-1/2 left-full ml-2">
|
|
||||||
<TooltipProvider>
|
|
||||||
<Tooltip>
|
|
||||||
<TooltipTrigger asChild>
|
|
||||||
<Button
|
|
||||||
variant="ghost"
|
|
||||||
size="icon"
|
|
||||||
onClick={handleUpdateProfile}
|
|
||||||
disabled={isUpdating}
|
|
||||||
className="flex-shrink-0"
|
|
||||||
>
|
|
||||||
{isUpdating ? <Loader2 className="h-5 w-5 animate-spin" /> : <RefreshCw className="h-5 w-5" />}
|
|
||||||
</Button>
|
|
||||||
</TooltipTrigger>
|
|
||||||
<TooltipContent><p>{t("Update Profile")}</p></TooltipContent>
|
|
||||||
</Tooltip>
|
|
||||||
</TooltipProvider>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
</div>
|
||||||
</div>
|
<div className="justify-self-center flex flex-col items-center gap-2">
|
||||||
<div className="flex justify-end">
|
<div className="relative flex items-center justify-center">
|
||||||
</div>
|
{profileItems.length > 0 ? (
|
||||||
</header>
|
<>
|
||||||
|
<div className="absolute right-full mr-2">
|
||||||
<main className="flex-1 overflow-y-auto flex items-center justify-center">
|
<TooltipProvider>
|
||||||
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
|
<Tooltip>
|
||||||
{currentProfile?.announce && (
|
<TooltipTrigger asChild>
|
||||||
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
<Button
|
||||||
{currentProfile.announce_url ? (
|
variant="outline"
|
||||||
<a
|
size="icon"
|
||||||
href={currentProfile.announce_url}
|
onClick={() => viewerRef.current?.create()}
|
||||||
target="_blank"
|
className={cn(
|
||||||
rel="noopener noreferrer"
|
"backdrop-blur-sm bg-white/80 border-gray-300/60",
|
||||||
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
|
"dark:bg-white/5 dark:border-white/15",
|
||||||
title={currentProfile.announce_url.replace(/\\n/g, '\n')}
|
"hover:bg-white/90 hover:border-gray-400/70",
|
||||||
>
|
"dark:hover:bg-white/10 dark:hover:border-white/20",
|
||||||
<span>{currentProfile.announce.replace(/\\n/g, '\n')}</span>
|
"transition-all duration-200"
|
||||||
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
)}
|
||||||
</a>
|
>
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("Add Profile")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<DropdownMenu>
|
||||||
|
<DropdownMenuTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
className={cn(
|
||||||
|
"w-full max-w-[250px] sm:max-w-xs",
|
||||||
|
"backdrop-blur-sm bg-white/80 border-gray-300/60",
|
||||||
|
"dark:bg-white/5 dark:border-white/15",
|
||||||
|
"hover:bg-white/90 hover:border-gray-400/70",
|
||||||
|
"dark:hover:bg-white/10 dark:hover:border-white/20",
|
||||||
|
"transition-all duration-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{currentProfileName}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-50" />
|
||||||
|
</Button>
|
||||||
|
</DropdownMenuTrigger>
|
||||||
|
<DropdownMenuContent className="w-[--radix-dropdown-menu-trigger-width]">
|
||||||
|
<DropdownMenuLabel>{t("Profiles")}</DropdownMenuLabel>
|
||||||
|
<DropdownMenuSeparator />
|
||||||
|
{profileItems.map((p) => (
|
||||||
|
<DropdownMenuItem
|
||||||
|
key={p.uid}
|
||||||
|
onSelect={() => handleProfileChange(p.uid)}
|
||||||
|
>
|
||||||
|
<span className="flex-1 truncate">{p.name}</span>
|
||||||
|
{profiles?.current === p.uid && (
|
||||||
|
<Check className="ml-4 h-4 w-4" />
|
||||||
|
)}
|
||||||
|
</DropdownMenuItem>
|
||||||
|
))}
|
||||||
|
</DropdownMenuContent>
|
||||||
|
</DropdownMenu>
|
||||||
|
{currentProfile?.type === 'remote' && (
|
||||||
|
<div className="absolute left-full ml-2">
|
||||||
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="ghost"
|
||||||
|
size="icon"
|
||||||
|
onClick={handleUpdateProfile}
|
||||||
|
disabled={isUpdating}
|
||||||
|
className={cn(
|
||||||
|
"flex-shrink-0",
|
||||||
|
"backdrop-blur-sm bg-white/70 border border-gray-300/50",
|
||||||
|
"dark:bg-white/5 dark:border-white/10",
|
||||||
|
"hover:bg-white/85 hover:border-gray-400/60",
|
||||||
|
"dark:hover:bg-white/10 dark:hover:border-white/15",
|
||||||
|
"transition-all duration-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{isUpdating ? <Loader2 className="h-5 w-5 animate-spin" /> : <RefreshCw className="h-5 w-5" />}
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent><p>{t("Update Profile")}</p></TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</>
|
||||||
) : (
|
) : (
|
||||||
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
<>
|
||||||
{currentProfile.announce}
|
<div className="absolute right-full mr-2">
|
||||||
</p>
|
<TooltipProvider>
|
||||||
|
<Tooltip>
|
||||||
|
<TooltipTrigger asChild>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="icon"
|
||||||
|
onClick={() => viewerRef.current?.create()}
|
||||||
|
className={cn(
|
||||||
|
"backdrop-blur-sm bg-white/80 border-gray-300/60",
|
||||||
|
"dark:bg-white/5 dark:border-white/15",
|
||||||
|
"hover:bg-white/90 hover:border-gray-400/70",
|
||||||
|
"dark:hover:bg-white/10 dark:hover:border-white/20",
|
||||||
|
"transition-all duration-200"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
</Button>
|
||||||
|
</TooltipTrigger>
|
||||||
|
<TooltipContent>
|
||||||
|
<p>{t("Add Profile")}</p>
|
||||||
|
</TooltipContent>
|
||||||
|
</Tooltip>
|
||||||
|
</TooltipProvider>
|
||||||
|
</div>
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
disabled
|
||||||
|
className={cn(
|
||||||
|
"max-w-[250px] sm:max-w-xs opacity-50 cursor-not-allowed",
|
||||||
|
"backdrop-blur-sm bg-white/50 border-gray-300/40",
|
||||||
|
"dark:bg-white/3 dark:border-white/10"
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
<span className="truncate">{t("No profiles available")}</span>
|
||||||
|
<ChevronsUpDown className="ml-2 h-4 w-4 shrink-0 opacity-30" />
|
||||||
|
</Button>
|
||||||
|
</>
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
)}
|
</div>
|
||||||
|
<div className="flex justify-end">
|
||||||
|
</div>
|
||||||
|
</header>
|
||||||
|
|
||||||
|
<main className="flex-1 overflow-y-auto flex items-center justify-center">
|
||||||
|
<div className="relative flex flex-col items-center gap-8 py-10 w-full max-w-4xl px-4">
|
||||||
|
{currentProfile?.announce && (
|
||||||
|
<div className="absolute -top-15 w-full flex justify-center text-center max-w-lg">
|
||||||
|
{currentProfile.announce_url ? (
|
||||||
|
<a
|
||||||
|
href={currentProfile.announce_url}
|
||||||
|
target="_blank"
|
||||||
|
rel="noopener noreferrer"
|
||||||
|
className="inline-flex items-center gap-2 text-base font-semibold text-foreground hover:underline hover:opacity-80 transition-all whitespace-pre-wrap"
|
||||||
|
title={currentProfile.announce_url.replace(/\\n/g, '\n')}
|
||||||
|
>
|
||||||
|
<span>{currentProfile.announce.replace(/\\n/g, '\n')}</span>
|
||||||
|
<ExternalLink className="h-4 w-4 flex-shrink-0" />
|
||||||
|
</a>
|
||||||
|
) : (
|
||||||
|
<p className="text-base font-semibold text-foreground whitespace-pre-wrap">
|
||||||
|
{currentProfile.announce}
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
<div className="relative text-center">
|
<div className="relative text-center">
|
||||||
<h1
|
<h1
|
||||||
className={cn(
|
className={cn(
|
||||||
@@ -295,104 +371,104 @@ const MinimalHomePage: React.FC = () => {
|
|||||||
>
|
>
|
||||||
{statusInfo.text}
|
{statusInfo.text}
|
||||||
</h1>
|
</h1>
|
||||||
{isProxyEnabled && (
|
{isProxyEnabled && (
|
||||||
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6">
|
<div className="absolute top-full left-1/2 -translate-x-1/2 mt-52 flex justify-center items-center text-sm text-muted-foreground gap-6">
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ArrowDown className="h-4 w-4 text-green-500" />
|
<ArrowDown className="h-4 w-4 text-green-500" />
|
||||||
{parseTraffic(connections.downloadTotal)}
|
{parseTraffic(connections.downloadTotal)}
|
||||||
</div>
|
</div>
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
<ArrowUp className="h-4 w-4 text-sky-500" />
|
<ArrowUp className="h-4 w-4 text-sky-500" />
|
||||||
{parseTraffic(connections.uploadTotal)}
|
{parseTraffic(connections.uploadTotal)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</div>
|
|
||||||
|
|
||||||
<div className="relative -translate-y-6">
|
|
||||||
<PowerButton
|
|
||||||
loading={isToggling}
|
|
||||||
checked={!!isProxyEnabled}
|
|
||||||
onClick={handleToggleProxy}
|
|
||||||
disabled={showTunAlert || isToggling || profileItems.length === 0}
|
|
||||||
aria-label={t("Toggle Proxy")}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{showTunAlert && (
|
|
||||||
<div className="w-full max-w-sm">
|
|
||||||
<Alert
|
|
||||||
variant="destructive"
|
|
||||||
className="flex flex-col items-center gap-2 text-center"
|
|
||||||
>
|
|
||||||
<AlertTriangle className="h-4 w-4" />
|
|
||||||
<AlertTitle>{t("Attention Required")}</AlertTitle>
|
|
||||||
<AlertDescription className="text-xs">
|
|
||||||
{t("TUN requires Service Mode or Admin Mode")}
|
|
||||||
</AlertDescription>
|
|
||||||
{!isServiceMode && !isAdminMode && (
|
|
||||||
<Button
|
|
||||||
size="sm"
|
|
||||||
className="mt-2"
|
|
||||||
onClick={installServiceAndRestartCore}
|
|
||||||
>
|
|
||||||
<Wrench className="mr-2 h-4 w-4" />
|
|
||||||
{t("Install Service")}
|
|
||||||
</Button>
|
|
||||||
)}
|
|
||||||
</Alert>
|
|
||||||
</div>
|
</div>
|
||||||
)}
|
|
||||||
|
|
||||||
<div className="w-full max-w-sm mt-4 flex justify-center">
|
<div className="relative -translate-y-6">
|
||||||
{profileItems.length > 0 ? (
|
<PowerButton
|
||||||
<ProxySelectors />
|
loading={isToggling}
|
||||||
) : (
|
checked={!!isProxyEnabled}
|
||||||
<Alert className="flex flex-col items-center gap-2 text-center">
|
onClick={handleToggleProxy}
|
||||||
<PlusCircle className="h-4 w-4" />
|
disabled={showTunAlert || isToggling || profileItems.length === 0}
|
||||||
<AlertTitle>{t("Get Started")}</AlertTitle>
|
aria-label={t("Toggle Proxy")}
|
||||||
<AlertDescription className="whitespace-pre-wrap">
|
/>
|
||||||
{t(
|
</div>
|
||||||
"You don't have any profiles yet. Add your first one to begin.",
|
|
||||||
)}
|
{showTunAlert && (
|
||||||
</AlertDescription>
|
<div className="w-full max-w-sm">
|
||||||
<Button
|
<Alert
|
||||||
className="mt-2"
|
variant="destructive"
|
||||||
onClick={() => viewerRef.current?.create()}
|
className="flex flex-col items-center gap-2 text-center"
|
||||||
>
|
>
|
||||||
{t("Add Profile")}
|
<AlertTriangle className="h-4 w-4" />
|
||||||
</Button>
|
<AlertTitle>{t("Attention Required")}</AlertTitle>
|
||||||
</Alert>
|
<AlertDescription className="text-xs">
|
||||||
|
{t("TUN requires Service Mode or Admin Mode")}
|
||||||
|
</AlertDescription>
|
||||||
|
{!isServiceMode && !isAdminMode && (
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
className="mt-2"
|
||||||
|
onClick={installServiceAndRestartCore}
|
||||||
|
>
|
||||||
|
<Wrench className="mr-2 h-4 w-4" />
|
||||||
|
{t("Install Service")}
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
|
</Alert>
|
||||||
|
</div>
|
||||||
)}
|
)}
|
||||||
|
|
||||||
|
<div className="w-full max-w-sm mt-4 flex justify-center">
|
||||||
|
{profileItems.length > 0 ? (
|
||||||
|
<ProxySelectors />
|
||||||
|
) : (
|
||||||
|
<Alert className="flex flex-col items-center gap-2 text-center">
|
||||||
|
<PlusCircle className="h-4 w-4" />
|
||||||
|
<AlertTitle>{t("Get Started")}</AlertTitle>
|
||||||
|
<AlertDescription className="whitespace-pre-wrap">
|
||||||
|
{t(
|
||||||
|
"You don't have any profiles yet. Add your first one to begin.",
|
||||||
|
)}
|
||||||
|
</AlertDescription>
|
||||||
|
<Button
|
||||||
|
className="mt-2"
|
||||||
|
onClick={() => viewerRef.current?.create()}
|
||||||
|
>
|
||||||
|
{t("Add Profile")}
|
||||||
|
</Button>
|
||||||
|
</Alert>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</main>
|
||||||
</main>
|
<footer className="flex justify-center p-4 flex-shrink-0">
|
||||||
<footer className="flex justify-center p-4 flex-shrink-0">
|
{currentProfile?.support_url && (
|
||||||
{currentProfile?.support_url && (
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
|
||||||
<span>{t("Support")}:</span>
|
<span>{t("Support")}:</span>
|
||||||
<TooltipProvider>
|
<TooltipProvider>
|
||||||
<Tooltip>
|
<Tooltip>
|
||||||
<TooltipTrigger asChild>
|
<TooltipTrigger asChild>
|
||||||
<a href={currentProfile.support_url} target="_blank" rel="noopener noreferrer" className="transition-colors hover:text-primary">
|
<a href={currentProfile.support_url} target="_blank" rel="noopener noreferrer" className="transition-colors hover:text-primary">
|
||||||
{(currentProfile.support_url.includes('t.me') || currentProfile.support_url.includes('telegram') || currentProfile.support_url.startsWith('tg://')) ? (
|
{(currentProfile.support_url.includes('t.me') || currentProfile.support_url.includes('telegram') || currentProfile.support_url.startsWith('tg://')) ? (
|
||||||
<Send className="h-5 w-5" />
|
<Send className="h-5 w-5" />
|
||||||
) : (
|
) : (
|
||||||
<Globe className="h-5 w-5" />
|
<Globe className="h-5 w-5" />
|
||||||
)}
|
)}
|
||||||
</a>
|
</a>
|
||||||
</TooltipTrigger>
|
</TooltipTrigger>
|
||||||
<TooltipContent>
|
<TooltipContent>
|
||||||
<p>{currentProfile.support_url}</p>
|
<p>{currentProfile.support_url}</p>
|
||||||
</TooltipContent>
|
</TooltipContent>
|
||||||
</Tooltip>
|
</Tooltip>
|
||||||
</TooltipProvider>
|
</TooltipProvider>
|
||||||
</div>
|
</div>
|
||||||
)}
|
)}
|
||||||
</footer>
|
</footer>
|
||||||
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
<ProfileViewer ref={viewerRef} onChange={() => mutateProfiles()} />
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
};
|
};
|
||||||
|
|
||||||
export default MinimalHomePage;
|
export default MinimalHomePage;
|
||||||
2
src/services/types.d.ts
vendored
2
src/services/types.d.ts
vendored
@@ -154,7 +154,7 @@ interface IConnectionsItem {
|
|||||||
start: string;
|
start: string;
|
||||||
chains: string[];
|
chains: string[];
|
||||||
rule: string;
|
rule: string;
|
||||||
rulePayload: string;
|
rulePayload?: string;
|
||||||
curUpload?: number; // upload speed, calculate at runtime
|
curUpload?: number; // upload speed, calculate at runtime
|
||||||
curDownload?: number; // download speed, calculate at runtime
|
curDownload?: number; // download speed, calculate at runtime
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user