* '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:
vffuunnyy
2025-08-16 01:57:32 +07:00
9 changed files with 1083 additions and 853 deletions

Binary file not shown.

Before

Width:  |  Height:  |  Size: 77 KiB

After

Width:  |  Height:  |  Size: 85 KiB

View File

@@ -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>
); );
}; };

View File

@@ -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>

View File

@@ -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>
); );

View File

@@ -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,
}; };

View File

@@ -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,
}; };

View File

@@ -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;

View File

@@ -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;

View File

@@ -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
} }