refactor: table style unification

This commit is contained in:
zhom committed 2026-08-26 10:29:45 +04:00
1 parent b7e1c791db
commit 82271d8c3b
22 files changed
+2518 -852

No files matched your search

+12 -4
View File
@@ -1492,9 +1492,12 @@ export function ExtensionManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{extTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1524,6 +1527,7 @@ export function ExtensionManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -1613,9 +1617,12 @@ export function ExtensionManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{groupTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1645,6 +1652,7 @@ export function ExtensionManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
+3 -2
View File
@@ -627,9 +627,9 @@ export function GroupManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{table.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow key={headerGroup.id} className="border-0!">
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -659,6 +659,7 @@ export function GroupManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
+112 -1
View File
@@ -4,13 +4,25 @@ import { getCurrentWindow } from "@tauri-apps/api/window";
import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import {
LuChevronLeft,
LuChevronRight,
LuCircleHelp,
LuSearch,
LuX,
} from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import {
PROFILE_SEARCH_EXAMPLES,
PROFILE_SEARCH_FIELDS,
PROFILE_SEARCH_OPERATORS,
} from "@/lib/profile-search";
import { cn } from "@/lib/utils";
import type { GroupWithCount } from "@/types";
import { Button } from "./ui/button";
import { Input } from "./ui/input";
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
const HOLD_MS = 150;
@@ -42,6 +54,103 @@ interface Props {
pageTitle?: string;
}
/**
* What the search box understands. The vocabulary is data owned by the parser
* (`src/lib/profile-search.ts`), so the tokens listed here cannot drift from the
* ones a query is actually matched against; only the prose beside them is
* translated, because the tokens themselves have to mean the same thing to
* everybody who is handed a query.
*/
const SearchSyntaxHelp = () => {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
aria-label={t("search.helpLabel")}
className="grid size-6 shrink-0 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:bg-accent hover:text-accent-foreground"
>
<LuCircleHelp className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent
align="end"
className="w-96 max-w-[calc(100vw-1.5rem)] p-0"
>
<div className="space-y-3 p-3 text-xs">
<div>
<p className="font-medium text-foreground">
{t("search.helpTitle")}
</p>
<p className="mt-1 text-muted-foreground">
{t("search.helpIntro")}
</p>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.fieldsTitle")}
</p>
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{PROFILE_SEARCH_FIELDS.map((field) => (
<div key={field.key} className="contents">
<code className="font-mono text-[11px] text-foreground">
{field.key}:
</code>
<span className="text-muted-foreground">
{t(field.labelKey)}
{field.values ? (
<span className="ml-1.5 font-mono text-[10px] text-muted-foreground/70">
{field.values.join(" ")}
</span>
) : null}
</span>
</div>
))}
</div>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.operatorsTitle")}
</p>
<div className="mt-1.5 grid grid-cols-[auto_1fr] gap-x-3 gap-y-1">
{PROFILE_SEARCH_OPERATORS.map((operator) => (
<div key={operator.labelKey} className="contents">
<code className="font-mono text-[11px] text-foreground">
{operator.token}
</code>
<span className="text-muted-foreground">
{t(operator.labelKey)}
</span>
</div>
))}
</div>
</div>
<div>
<p className="font-medium text-foreground">
{t("search.examplesTitle")}
</p>
<div className="mt-1.5 space-y-1.5">
{PROFILE_SEARCH_EXAMPLES.map((example) => (
<div key={example.labelKey}>
<code className="font-mono text-[11px] text-foreground">
{example.query}
</code>
<p className="text-muted-foreground">{t(example.labelKey)}</p>
</div>
))}
</div>
</div>
</div>
</PopoverContent>
</Popover>
);
};
const HomeHeader = ({
onCreateProfileDialogOpen,
searchQuery,
@@ -354,6 +463,8 @@ const HomeHeader = ({
</div>
)}
{showProfileToolbar && <SearchSyntaxHelp />}
{showProfileToolbar && (
<Tooltip>
<TooltipTrigger asChild>
+73 -33
View File
@@ -70,6 +70,7 @@ import {
CommandItem,
CommandList,
} from "@/components/ui/command";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
DropdownMenu,
DropdownMenuContent,
@@ -327,6 +328,13 @@ const BOT_LABEL_WIDTH = 880;
/** Below this the bot column leaves entirely, like the other low-priority ones. */
const BOT_COLUMN_MIN_WIDTH = 400;
/**
* Above this the table has room for the profile id. Below it the name column,
* which takes whatever the fixed columns leave over, needs those 100px more
* than a value that is already one click away in the info dialog.
*/
const PROFILE_ID_MIN_WIDTH = 1152;
/** Bulk enrolments of this size or larger are confirmed, as run and stop are. */
const BULK_ENROL_CONFIRM_THRESHOLD = 10;
@@ -593,6 +601,41 @@ function DnsCell({
);
}
/**
* The first eight characters of the profile's UUID: the whole first group of a
* v4, which is also the prefix `id:` searches on, so what the row shows can be
* pasted straight back into the search box. The clipboard gets the FULL id —
* the only thing it is for is the REST and MCP APIs, which take nothing less.
*/
function ProfileIdCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
return (
<div className="flex h-7 w-full items-center gap-1">
<Tooltip>
<TooltipTrigger asChild>
<span className="flex-1 truncate font-mono text-[11px] text-muted-foreground select-text">
{profile.id.slice(0, 8)}
</span>
</TooltipTrigger>
<TooltipContent className="font-mono text-[11px]">
{profile.id}
</TooltipContent>
</Tooltip>
<CopyToClipboard
text={profile.id}
variant="ghost"
className="size-6 text-muted-foreground"
successMessage={meta.t("toasts.success.copied")}
/>
</div>
);
}
const TagsCell = React.memo<{
profile: BrowserProfile;
isDisabled: boolean;
@@ -1403,6 +1446,11 @@ interface ProfilesDataTableProps {
onCopyCookiesToProfile?: (profile: BrowserProfile) => void;
onOpenCookieManagement?: (profile: BrowserProfile) => void;
runningProfiles: Set<string>;
/**
* Loaded by the page rather than here, because the search filter resolves
* `ext:` to a group name and needs the same list. One invoke, one listener.
*/
extensionGroups: ExtensionGroup[];
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
@@ -1461,6 +1509,7 @@ export function ProfilesDataTable({
onCopyCookiesToProfile,
onOpenCookieManagement,
runningProfiles,
extensionGroups,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
@@ -1675,35 +1724,6 @@ export function ProfilesDataTable({
const [countries, setCountries] = React.useState<LocationItem[]>([]);
const [countriesLoaded, setCountriesLoaded] = React.useState(false);
// Extension groups for the Ext column lookup. Refreshed when the
// backend emits 'extensions-changed' (group rename/create/delete).
const [extensionGroups, setExtensionGroups] = React.useState<
ExtensionGroup[]
>([]);
React.useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const data = await invoke<ExtensionGroup[]>("list_extension_groups");
if (mounted) setExtensionGroups(data);
} catch (e) {
console.error("Failed to load extension groups:", e);
}
};
void load();
void listen("extensions-changed", () => {
void load();
}).then((u) => {
if (mounted) unlisten = u;
else u();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
const canCreateLocationProxy = false;
const loadCountries = React.useCallback(async () => {
@@ -3148,6 +3168,19 @@ export function ProfilesDataTable({
);
},
},
{
id: "profileId",
size: 100,
enableSorting: false,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profiles.table.profileId");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <ProfileIdCell profile={row.original} meta={meta} />;
},
},
{
id: "tags",
size: 100,
@@ -3562,11 +3595,16 @@ export function ProfilesDataTable({
// Low-priority columns leave the table as the container narrows (most
// expendable first); their data stays reachable via the profile info
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
// `bot` starts hidden and is switched on by the resize effect below. An
// unentitled account must never see a paid column, not even for the frame
// before the observer's first measurement lands.
// `bot` and `profileId` start hidden and are switched on by the resize effect
// below. An unentitled account must never see a paid column, not even for the
// frame before the observer's first measurement lands, and the id must not
// flash into a narrow table for that same frame.
const [columnVisibility, setColumnVisibility] =
React.useState<VisibilityState>({ created_at: false, bot: false });
React.useState<VisibilityState>({
created_at: false,
bot: false,
profileId: false,
});
const table = useReactTable({
data: profiles,
@@ -3630,6 +3668,8 @@ export function ProfilesDataTable({
const next: VisibilityState = {
// Always hidden — sort-only column.
created_at: false,
// First to leave: pure metadata, and the info dialog still has it.
profileId: w >= PROFILE_ID_MIN_WIDTH,
dns: w >= 768,
ext: w >= 672,
note: w >= 576,
+12 -4
View File
@@ -1263,9 +1263,12 @@ export function ProxyManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{proxiesTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1306,6 +1309,7 @@ export function ProxyManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell
@@ -1370,9 +1374,12 @@ export function ProxyManagementDialog({
className="w-full table-fixed"
containerClassName="overflow-visible"
>
<TableHeader className="sticky top-0 z-10 bg-background">
<TableHeader className="sticky top-0 z-10 bg-background [&_tr]:border-0">
{vpnsTable.getHeaderGroups().map((headerGroup) => (
<TableRow key={headerGroup.id}>
<TableRow
key={headerGroup.id}
className="border-0!"
>
{headerGroup.headers.map((header) => (
<TableHead
key={header.id}
@@ -1413,6 +1420,7 @@ export function ProxyManagementDialog({
<TableRow
key={row.id}
data-state={row.getIsSelected() && "selected"}
className="border-0! hover:bg-muted"
>
{row.getVisibleCells().map((cell) => (
<TableCell