mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
refactor: table style unification
This commit is contained in:
1 parent
b7e1c791db
commit
82271d8c3b
22 files changed
+2518
-852
No files matched your search
@@ -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
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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,
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in new issue
Block a user