feat: profile sorting

This commit is contained in:
zhom
2026-06-24 00:41:19 +04:00
parent 4007dedcf0
commit f29b161cf4
12 changed files with 189 additions and 34 deletions
+85 -16
View File
@@ -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 (AZ / ZA) 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,
+11
View File
@@ -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={
+10 -2
View File
@@ -325,6 +325,12 @@
"confirmDescription": "Stop {{count}} running profiles?",
"confirmButton": "Stop {{count}}",
"noneToStop": "No selected profiles to stop."
},
"sort": {
"nameAsc": "Name (AZ)",
"nameDesc": "Name (ZA)",
"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",
+10 -2
View File
@@ -325,6 +325,12 @@
"confirmDescription": "¿Detener {{count}} perfiles en ejecución?",
"confirmButton": "Detener {{count}}",
"noneToStop": "No hay perfiles seleccionados para detener."
},
"sort": {
"nameAsc": "Nombre (AZ)",
"nameDesc": "Nombre (ZA)",
"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",
+10 -2
View File
@@ -325,6 +325,12 @@
"confirmDescription": "Arrêter {{count}} profils en cours dexécution ?",
"confirmButton": "Arrêter {{count}}",
"noneToStop": "Aucun profil sélectionné à arrêter."
},
"sort": {
"nameAsc": "Nom (AZ)",
"nameDesc": "Nom (ZA)",
"newest": "Plus récents dabord",
"oldest": "Plus anciens dabord"
}
},
"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",
+10 -2
View File
@@ -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": "プロキシバイパスルール",
+10 -2
View File
@@ -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": "프록시 우회 규칙",
+10 -2
View File
@@ -325,6 +325,12 @@
"confirmDescription": "Parar {{count}} perfis em execução?",
"confirmButton": "Parar {{count}}",
"noneToStop": "Nenhum perfil selecionado para parar."
},
"sort": {
"nameAsc": "Nome (AZ)",
"nameDesc": "Nome (ZA)",
"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",
+10 -2
View File
@@ -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": "Правила обхода прокси",
+10 -2
View File
@@ -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 (AZ)",
"nameDesc": "Tên (ZA)",
"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",
+10 -2
View File
@@ -325,6 +325,12 @@
"confirmDescription": "停止 {{count}} 个正在运行的配置文件?",
"confirmButton": "停止 {{count}} 个",
"noneToStop": "没有选中可停止的配置文件。"
},
"sort": {
"nameAsc": "名称 (AZ)",
"nameDesc": "名称 (ZA)",
"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": "代理绕过规则",
+3
View File
@@ -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;
}