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