mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-06-24 23:49:55 +02:00
feat: profile sorting
This commit is contained in:
@@ -51,6 +51,12 @@ import {
|
||||
CommandItem,
|
||||
CommandList,
|
||||
} from "@/components/ui/command";
|
||||
import {
|
||||
DropdownMenu,
|
||||
DropdownMenuContent,
|
||||
DropdownMenuItem,
|
||||
DropdownMenuTrigger,
|
||||
} from "@/components/ui/dropdown-menu";
|
||||
import {
|
||||
Popover,
|
||||
PopoverContent,
|
||||
@@ -2381,28 +2387,89 @@ export function ProfilesDataTable({
|
||||
);
|
||||
},
|
||||
},
|
||||
{
|
||||
// Hidden, sort-only column so profiles can be sorted by creation date
|
||||
// without showing a Created column in the table (issue #454). Kept
|
||||
// hidden via columnVisibility; sorting still works on hidden columns.
|
||||
id: "created_at",
|
||||
accessorFn: (row) => row.created_at ?? 0,
|
||||
enableSorting: true,
|
||||
enableHiding: true,
|
||||
sortingFn: "basic",
|
||||
header: () => null,
|
||||
cell: () => null,
|
||||
},
|
||||
{
|
||||
accessorKey: "name",
|
||||
// The only column without a fixed width: table-fixed hands it all
|
||||
// remaining space as the window grows or shrinks.
|
||||
meta: { flexWidth: true },
|
||||
header: ({ column, table }) => {
|
||||
// The Name header doubles as the sort control: clicking opens a menu to
|
||||
// sort by name (A–Z / Z–A) or by creation date (newest / oldest), so
|
||||
// creation-date sorting needs no visible column.
|
||||
header: ({ table }) => {
|
||||
const meta = table.options.meta as TableMeta;
|
||||
const sort = table.getState().sorting[0];
|
||||
const isActive = (id: string, desc: boolean) =>
|
||||
sort?.id === id && !!sort.desc === desc;
|
||||
return (
|
||||
<Button
|
||||
variant="ghost"
|
||||
onClick={() => {
|
||||
column.toggleSorting(column.getIsSorted() === "asc");
|
||||
}}
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{column.getIsSorted() === "asc" ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : column.getIsSorted() === "desc" ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : null}
|
||||
</Button>
|
||||
<DropdownMenu>
|
||||
<DropdownMenuTrigger asChild>
|
||||
<Button
|
||||
variant="ghost"
|
||||
className="justify-start p-0 h-auto font-semibold text-left cursor-pointer"
|
||||
>
|
||||
{meta.t("common.labels.name")}
|
||||
{isActive("name", false) ? (
|
||||
<LuChevronUp className="ml-2 size-4" />
|
||||
) : isActive("name", true) ? (
|
||||
<LuChevronDown className="ml-2 size-4" />
|
||||
) : (
|
||||
<LuChevronDown className="ml-2 size-4 opacity-50" />
|
||||
)}
|
||||
</Button>
|
||||
</DropdownMenuTrigger>
|
||||
<DropdownMenuContent align="start">
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "name", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("name", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameAsc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() => table.setSorting([{ id: "name", desc: true }])}
|
||||
>
|
||||
{isActive("name", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.nameDesc")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: true }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", true) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.newest")}
|
||||
</DropdownMenuItem>
|
||||
<DropdownMenuItem
|
||||
onClick={() =>
|
||||
table.setSorting([{ id: "created_at", desc: false }])
|
||||
}
|
||||
>
|
||||
{isActive("created_at", false) && (
|
||||
<LuCheck className="mr-2 size-3.5" />
|
||||
)}
|
||||
{meta.t("profiles.sort.oldest")}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenuContent>
|
||||
</DropdownMenu>
|
||||
);
|
||||
},
|
||||
enableSorting: true,
|
||||
@@ -2934,7 +3001,7 @@ export function ProfilesDataTable({
|
||||
// expendable first); their data stays reachable via the profile info
|
||||
// dialog. Visibility (not CSS hiding) so table-fixed reclaims the width.
|
||||
const [columnVisibility, setColumnVisibility] =
|
||||
React.useState<VisibilityState>({});
|
||||
React.useState<VisibilityState>({ created_at: false });
|
||||
|
||||
// Content columns grow proportionally with the container but never drop
|
||||
// below the compact-layout floor; the name column takes the remainder.
|
||||
@@ -2994,6 +3061,8 @@ export function ProfilesDataTable({
|
||||
setContainerWidth(Math.round(w / 8) * 8);
|
||||
setColumnVisibility((prev) => {
|
||||
const next: VisibilityState = {
|
||||
// Always hidden — sort-only column (issue #454).
|
||||
created_at: false,
|
||||
dns: w >= 768,
|
||||
ext: w >= 672,
|
||||
note: w >= 576,
|
||||
|
||||
@@ -878,6 +878,17 @@ function ProfileInfoLayout({
|
||||
{t("profileInfo.sections.activity")}
|
||||
</span>
|
||||
<div className="grid grid-cols-2 gap-2">
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.created")}
|
||||
value={
|
||||
profile.created_at
|
||||
? new Date(profile.created_at * 1000).toLocaleString(
|
||||
undefined,
|
||||
{ dateStyle: "medium", timeStyle: "short" },
|
||||
)
|
||||
: t("profileInfo.values.unknown")
|
||||
}
|
||||
/>
|
||||
<InfoCard
|
||||
label={t("profileInfo.fields.lastLaunched")}
|
||||
value={
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "Stop {{count}} running profiles?",
|
||||
"confirmButton": "Stop {{count}}",
|
||||
"noneToStop": "No selected profiles to stop."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Name (A–Z)",
|
||||
"nameDesc": "Name (Z–A)",
|
||||
"newest": "Newest first",
|
||||
"oldest": "Oldest first"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stored",
|
||||
"localDataTransfer": "Local data transfer"
|
||||
"localDataTransfer": "Local data transfer",
|
||||
"created": "Created"
|
||||
},
|
||||
"values": {
|
||||
"none": "None",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Yes",
|
||||
"activeNow": "Active now",
|
||||
"direct": "Direct",
|
||||
"loading": "Loading…"
|
||||
"loading": "Loading…",
|
||||
"unknown": "Unknown"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Proxy Bypass Rules",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "¿Detener {{count}} perfiles en ejecución?",
|
||||
"confirmButton": "Detener {{count}}",
|
||||
"noneToStop": "No hay perfiles seleccionados para detener."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nombre (A–Z)",
|
||||
"nameDesc": "Nombre (Z–A)",
|
||||
"newest": "Más recientes primero",
|
||||
"oldest": "Más antiguos primero"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies guardadas",
|
||||
"localDataTransfer": "Transferencia de datos local"
|
||||
"localDataTransfer": "Transferencia de datos local",
|
||||
"created": "Creado"
|
||||
},
|
||||
"values": {
|
||||
"none": "Ninguno",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Sí",
|
||||
"activeNow": "Activo ahora",
|
||||
"direct": "Directa",
|
||||
"loading": "Cargando…"
|
||||
"loading": "Cargando…",
|
||||
"unknown": "Desconocido"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Reglas de Omisión de Proxy",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "Arrêter {{count}} profils en cours d’exécution ?",
|
||||
"confirmButton": "Arrêter {{count}}",
|
||||
"noneToStop": "Aucun profil sélectionné à arrêter."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nom (A–Z)",
|
||||
"nameDesc": "Nom (Z–A)",
|
||||
"newest": "Plus récents d’abord",
|
||||
"oldest": "Plus anciens d’abord"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies stockés",
|
||||
"localDataTransfer": "Transfert de données local"
|
||||
"localDataTransfer": "Transfert de données local",
|
||||
"created": "Créé le"
|
||||
},
|
||||
"values": {
|
||||
"none": "Aucun",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Oui",
|
||||
"activeNow": "Actif maintenant",
|
||||
"direct": "Direct",
|
||||
"loading": "Chargement…"
|
||||
"loading": "Chargement…",
|
||||
"unknown": "Inconnu"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Règles de Contournement du Proxy",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "実行中の {{count}} 個のプロファイルを停止しますか?",
|
||||
"confirmButton": "{{count}} 個を停止",
|
||||
"noneToStop": "停止する選択中のプロファイルがありません。"
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "名前 (A→Z)",
|
||||
"nameDesc": "名前 (Z→A)",
|
||||
"newest": "新しい順",
|
||||
"oldest": "古い順"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "プロキシ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "保存された Cookie",
|
||||
"localDataTransfer": "ローカルデータ転送量"
|
||||
"localDataTransfer": "ローカルデータ転送量",
|
||||
"created": "作成日"
|
||||
},
|
||||
"values": {
|
||||
"none": "なし",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "はい",
|
||||
"activeNow": "現在アクティブ",
|
||||
"direct": "直接",
|
||||
"loading": "読み込み中…"
|
||||
"loading": "読み込み中…",
|
||||
"unknown": "不明"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "プロキシバイパスルール",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "실행 중인 {{count}}개의 프로필을 중지할까요?",
|
||||
"confirmButton": "{{count}}개 중지",
|
||||
"noneToStop": "중지할 선택된 프로필이 없습니다."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "이름 (A→Z)",
|
||||
"nameDesc": "이름 (Z→A)",
|
||||
"newest": "최신순",
|
||||
"oldest": "오래된순"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "프록시",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "저장된 쿠키",
|
||||
"localDataTransfer": "로컬 데이터 전송"
|
||||
"localDataTransfer": "로컬 데이터 전송",
|
||||
"created": "생성일"
|
||||
},
|
||||
"values": {
|
||||
"none": "없음",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "예",
|
||||
"activeNow": "지금 활성",
|
||||
"direct": "직접",
|
||||
"loading": "불러오는 중…"
|
||||
"loading": "불러오는 중…",
|
||||
"unknown": "알 수 없음"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "프록시 우회 규칙",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "Parar {{count}} perfis em execução?",
|
||||
"confirmButton": "Parar {{count}}",
|
||||
"noneToStop": "Nenhum perfil selecionado para parar."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Nome (A–Z)",
|
||||
"nameDesc": "Nome (Z–A)",
|
||||
"newest": "Mais recentes primeiro",
|
||||
"oldest": "Mais antigos primeiro"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookies armazenados",
|
||||
"localDataTransfer": "Transferência de dados local"
|
||||
"localDataTransfer": "Transferência de dados local",
|
||||
"created": "Criado em"
|
||||
},
|
||||
"values": {
|
||||
"none": "Nenhum",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Sim",
|
||||
"activeNow": "Ativo agora",
|
||||
"direct": "Direto",
|
||||
"loading": "Carregando…"
|
||||
"loading": "Carregando…",
|
||||
"unknown": "Desconhecido"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Regras de Bypass de Proxy",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "Остановить {{count}} запущенных профилей?",
|
||||
"confirmButton": "Остановить {{count}}",
|
||||
"noneToStop": "Нет выбранных профилей для остановки."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Имя (А–Я)",
|
||||
"nameDesc": "Имя (Я–А)",
|
||||
"newest": "Сначала новые",
|
||||
"oldest": "Сначала старые"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "ПРОКСИ",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Хранится Cookie",
|
||||
"localDataTransfer": "Локальный трафик"
|
||||
"localDataTransfer": "Локальный трафик",
|
||||
"created": "Создан"
|
||||
},
|
||||
"values": {
|
||||
"none": "Нет",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Да",
|
||||
"activeNow": "Сейчас активен",
|
||||
"direct": "Без прокси",
|
||||
"loading": "Загрузка…"
|
||||
"loading": "Загрузка…",
|
||||
"unknown": "Неизвестно"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Правила обхода прокси",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "Dừng {{count}} hồ sơ đang chạy?",
|
||||
"confirmButton": "Dừng {{count}}",
|
||||
"noneToStop": "Không có hồ sơ nào được chọn để dừng."
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "Tên (A–Z)",
|
||||
"nameDesc": "Tên (Z–A)",
|
||||
"newest": "Mới nhất trước",
|
||||
"oldest": "Cũ nhất trước"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "PROXY",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "Cookie đã lưu",
|
||||
"localDataTransfer": "Truyền dữ liệu cục bộ"
|
||||
"localDataTransfer": "Truyền dữ liệu cục bộ",
|
||||
"created": "Đã tạo"
|
||||
},
|
||||
"values": {
|
||||
"none": "Không có",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "Có",
|
||||
"activeNow": "Đang hoạt động",
|
||||
"direct": "Trực tiếp",
|
||||
"loading": "Đang tải…"
|
||||
"loading": "Đang tải…",
|
||||
"unknown": "Không rõ"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "Quy tắc bỏ qua proxy",
|
||||
|
||||
@@ -325,6 +325,12 @@
|
||||
"confirmDescription": "停止 {{count}} 个正在运行的配置文件?",
|
||||
"confirmButton": "停止 {{count}} 个",
|
||||
"noneToStop": "没有选中可停止的配置文件。"
|
||||
},
|
||||
"sort": {
|
||||
"nameAsc": "名称 (A–Z)",
|
||||
"nameDesc": "名称 (Z–A)",
|
||||
"newest": "最新优先",
|
||||
"oldest": "最早优先"
|
||||
}
|
||||
},
|
||||
"createProfile": {
|
||||
@@ -1149,7 +1155,8 @@
|
||||
"proxy": "代理",
|
||||
"vpn": "VPN",
|
||||
"cookieCount": "存储的 Cookie",
|
||||
"localDataTransfer": "本地数据传输"
|
||||
"localDataTransfer": "本地数据传输",
|
||||
"created": "创建时间"
|
||||
},
|
||||
"values": {
|
||||
"none": "无",
|
||||
@@ -1158,7 +1165,8 @@
|
||||
"yes": "是",
|
||||
"activeNow": "当前活动",
|
||||
"direct": "直连",
|
||||
"loading": "加载中…"
|
||||
"loading": "加载中…",
|
||||
"unknown": "未知"
|
||||
},
|
||||
"network": {
|
||||
"bypassRules": "代理绕过规则",
|
||||
|
||||
@@ -36,6 +36,9 @@ export interface BrowserProfile {
|
||||
proxy_bypass_rules?: string[];
|
||||
created_by_id?: string;
|
||||
created_by_email?: string;
|
||||
/** Profile creation timestamp (epoch seconds, UTC). Undefined for legacy
|
||||
* profiles created before this field existed. */
|
||||
created_at?: number;
|
||||
dns_blocklist?: string;
|
||||
password_protected?: boolean;
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user