From 168b7ac6d4e03a88bfd1c3d089526bd22d235c86 Mon Sep 17 00:00:00 2001 From: zhom <2717306+zhom@users.noreply.github.com> Date: Sun, 14 Jun 2026 19:25:21 +0400 Subject: [PATCH] feat: amek window resizable --- AGENTS.md | 6 + src-tauri/Cargo.lock | 18 +- src-tauri/Cargo.toml | 1 + src-tauri/src/lib.rs | 54 +- src/components/account-page.tsx | 10 +- src/components/bandwidth-mini-chart.tsx | 6 +- src/components/camoufox-config-dialog.tsx | 4 +- src/components/clone-profile-dialog.tsx | 2 +- src/components/command-palette.tsx | 8 +- src/components/cookie-copy-dialog.tsx | 10 +- src/components/cookie-management-dialog.tsx | 4 +- src/components/create-profile-dialog.tsx | 4 +- src/components/custom-toast.tsx | 2 +- src/components/data-table-action-bar.tsx | 2 +- src/components/delete-confirmation-dialog.tsx | 5 +- src/components/delete-group-dialog.tsx | 4 +- src/components/dns-blocklist-dialog.tsx | 2 +- .../extension-group-assignment-dialog.tsx | 2 +- .../extension-management-dialog.tsx | 137 ++-- src/components/group-assignment-dialog.tsx | 2 +- src/components/group-badges.tsx | 195 ------ src/components/group-management-dialog.tsx | 41 +- src/components/home-header.tsx | 29 +- src/components/import-profile-dialog.tsx | 17 +- src/components/integrations-dialog.tsx | 18 +- src/components/location-proxy-dialog.tsx | 2 +- src/components/multiple-selector.tsx | 35 +- src/components/profile-data-table.tsx | 261 ++++++-- src/components/profile-info-dialog.tsx | 2 +- src/components/profile-password-dialog.tsx | 2 +- src/components/profile-selector-dialog.tsx | 2 +- src/components/profile-sync-dialog.tsx | 222 +++---- src/components/proxy-assignment-dialog.tsx | 4 +- src/components/proxy-export-dialog.tsx | 4 +- src/components/proxy-form-dialog.tsx | 10 +- src/components/proxy-import-dialog.tsx | 10 +- src/components/proxy-management-dialog.tsx | 616 +++++++++++------- src/components/rail-nav.tsx | 82 +-- src/components/settings-dialog.tsx | 8 +- .../shared-camoufox-config-form.tsx | 18 +- src/components/shortcuts-page.tsx | 16 +- src/components/sync-follower-dialog.tsx | 2 +- src/components/traffic-details-dialog.tsx | 12 +- src/components/ui/animated-tabs.tsx | 2 +- src/components/ui/auto-height.tsx | 6 +- src/components/ui/chart.tsx | 2 +- src/components/ui/combobox.tsx | 19 +- src/components/ui/command.tsx | 4 +- src/components/ui/dialog.tsx | 10 +- src/components/ui/dropdown-menu.tsx | 4 +- src/components/ui/popover.tsx | 4 +- src/components/ui/table.tsx | 11 +- src/components/ui/tabs.tsx | 6 +- src/components/ui/tooltip.tsx | 2 +- src/components/vpn-form-dialog.tsx | 2 +- src/components/vpn-import-dialog.tsx | 2 +- src/components/wayfern-config-form.tsx | 26 +- src/components/welcome-dialog.tsx | 2 +- src/components/window-drag-area.tsx | 76 ++- src/i18n/locales/en.json | 5 +- src/i18n/locales/es.json | 5 +- src/i18n/locales/fr.json | 5 +- src/i18n/locales/ja.json | 5 +- src/i18n/locales/ko.json | 5 +- src/i18n/locales/pt.json | 5 +- src/i18n/locales/ru.json | 5 +- src/i18n/locales/vi.json | 5 +- src/i18n/locales/zh.json | 5 +- 68 files changed, 1257 insertions(+), 857 deletions(-) delete mode 100644 src/components/group-badges.tsx diff --git a/AGENTS.md b/AGENTS.md index 34b97a6..8a5151c 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -1,3 +1,9 @@ +# ⛔ ABSOLUTE GIT RULE — READ FIRST (2026-06-11) + +**NEVER run any git command that modifies git history OR the working tree, in ANY repo** (wayfern, wayfern-macos, wayfern-test, donutbrowser, build/src), **unless the user EXPLICITLY authorizes that exact command.** Forbidden without per-command authorization: `commit`, `revert`, `cherry-pick`, `restore`, `checkout` (files/branches), `reset`, `rebase`, `merge`, `stash`, `clean`, `apply`, `add`, `rm`, `push`, any force op. Only read-only git (`status`, `log`, `show`, `diff`, `ls-files`, `rev-parse`) is allowed without asking. **Authorization is per-command: 1 explicit authorization = exactly 1 command.** If a git mutation seems needed, STOP and ask for that one command. + +--- + # Project Guidelines > **NOTE**: CLAUDE.md is a symlink to AGENTS.md — editing either file updates both. diff --git a/src-tauri/Cargo.lock b/src-tauri/Cargo.lock index 684e90f..dd22537 100644 --- a/src-tauri/Cargo.lock +++ b/src-tauri/Cargo.lock @@ -1853,6 +1853,7 @@ dependencies = [ "tauri-plugin-opener", "tauri-plugin-shell", "tauri-plugin-single-instance", + "tauri-plugin-window-state", "tempfile", "thiserror 2.0.18", "tokio", @@ -3089,7 +3090,7 @@ dependencies = [ "js-sys", "log", "wasm-bindgen", - "windows-core 0.61.2", + "windows-core 0.62.2", ] [[package]] @@ -6858,6 +6859,21 @@ dependencies = [ "zbus", ] +[[package]] +name = "tauri-plugin-window-state" +version = "2.4.1" +source = "registry+https://github.com/rust-lang/crates.io-index" +checksum = "73736611e14142408d15353e21e3cca2f12a3cfb523ad0ce85999b6d2ef1a704" +dependencies = [ + "bitflags 2.11.1", + "log", + "serde", + "serde_json", + "tauri", + "tauri-plugin", + "thiserror 2.0.18", +] + [[package]] name = "tauri-runtime" version = "2.11.2" diff --git a/src-tauri/Cargo.toml b/src-tauri/Cargo.toml index 6b6513a..4b3181a 100644 --- a/src-tauri/Cargo.toml +++ b/src-tauri/Cargo.toml @@ -41,6 +41,7 @@ tauri-plugin-dialog = "2" tauri-plugin-macos-permissions = "2" tauri-plugin-log = "2" tauri-plugin-clipboard-manager = "2" +tauri-plugin-window-state = "2" log = "0.4" env_logger = "0.11" diff --git a/src-tauri/src/lib.rs b/src-tauri/src/lib.rs index c713b5d..7939d7a 100644 --- a/src-tauri/src/lib.rs +++ b/src-tauri/src/lib.rs @@ -150,6 +150,8 @@ use api_server::{get_api_server_status, start_api_server, stop_api_server}; pub trait WindowExt { #[cfg(target_os = "macos")] fn set_transparent_titlebar(&self, transparent: bool) -> Result<(), String>; + #[cfg(target_os = "macos")] + fn disable_native_fullscreen(&self) -> Result<(), String>; } impl WindowExt for WebviewWindow { @@ -164,7 +166,7 @@ impl WindowExt for WebviewWindow { if transparent { // Hide the title text - ns_window.setTitleVisibility(NSWindowTitleVisibility(2)); // NSWindowTitleHidden + ns_window.setTitleVisibility(NSWindowTitleVisibility(1)); // NSWindowTitleHidden // Make titlebar transparent ns_window.setTitlebarAppearsTransparent(true); @@ -189,6 +191,33 @@ impl WindowExt for WebviewWindow { Ok(()) } + + #[cfg(target_os = "macos")] + fn disable_native_fullscreen(&self) -> Result<(), String> { + use objc2::rc::Retained; + use objc2_app_kit::{NSWindow, NSWindowCollectionBehavior}; + + unsafe { + let ns_window: Retained = + Retained::retain(self.ns_window().unwrap().cast()).unwrap(); + + // Make the green title-bar button (and titlebar double-click) "zoom" + // the window to fill the screen as an ordinary window instead of + // entering immersive native fullscreen that hides the menu bar and + // moves to its own Space. Mirrors Electron's `fullscreenable: false`: + // clear FullScreenPrimary and set FullScreenNone. AppKit then maps the + // green button to the standard zoom, expanding to the visible screen + // frame while keeping the window chrome and the current Space. + const FULL_SCREEN_PRIMARY: usize = 1 << 7; + const FULL_SCREEN_NONE: usize = 1 << 9; + let current = ns_window.collectionBehavior(); + let updated = + NSWindowCollectionBehavior((current.0 & !FULL_SCREEN_PRIMARY) | FULL_SCREEN_NONE); + ns_window.setCollectionBehavior(updated); + } + + Ok(()) + } } // Called internally for deep-link / startup URL handling — not invoked from the @@ -1388,6 +1417,21 @@ pub fn run() { .plugin(tauri_plugin_dialog::init()) .plugin(tauri_plugin_macos_permissions::init()) .plugin(tauri_plugin_clipboard_manager::init()) + // Persist window size/position across restarts. VISIBLE is excluded + // because the app hides to tray: restoring visibility would otherwise + // relaunch with an invisible window after quitting from the tray while + // hidden. FULLSCREEN is excluded because native fullscreen is disabled + // (the green button zooms instead) — the maximized flag captures the + // "filled screen" state, including green-button zoom on macOS. + .plugin( + tauri_plugin_window_state::Builder::default() + .with_state_flags( + tauri_plugin_window_state::StateFlags::all() + & !tauri_plugin_window_state::StateFlags::VISIBLE + & !tauri_plugin_window_state::StateFlags::FULLSCREEN, + ) + .build(), + ) .setup(|app| { // Recover ephemeral dir mappings from RAM-backed storage (tmpfs/ramdisk) ephemeral_dirs::recover_ephemeral_dirs(); @@ -1403,7 +1447,8 @@ pub fn run() { let win_builder = WebviewWindowBuilder::new(app, "main", WebviewUrl::default()) .title("Donut Browser") .inner_size(880.0, 500.0) - .resizable(false) + .min_inner_size(640.0, 400.0) + .resizable(true) .fullscreen(false) .center() .focused(true) @@ -1447,6 +1492,11 @@ pub fn run() { if let Err(e) = window.set_transparent_titlebar(true) { log::warn!("Failed to set transparent titlebar: {e}"); } + // Green title-bar button maximizes (zoom) the window rather than + // entering immersive native fullscreen. + if let Err(e) = window.disable_native_fullscreen() { + log::warn!("Failed to disable native fullscreen: {e}"); + } } // Set up deep link handler diff --git a/src/components/account-page.tsx b/src/components/account-page.tsx index 076fe08..77440d5 100644 --- a/src/components/account-page.tsx +++ b/src/components/account-page.tsx @@ -27,6 +27,7 @@ import { useCloudAuth } from "@/hooks/use-cloud-auth"; import { translateBackendError } from "@/lib/backend-errors"; import { getEntitlements } from "@/lib/entitlements"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; import type { SyncSettings } from "@/types"; interface AccountPageProps { @@ -197,8 +198,13 @@ export function AccountPage({ return ( - -
+ +
diff --git a/src/components/bandwidth-mini-chart.tsx b/src/components/bandwidth-mini-chart.tsx index 18653c8..dade541 100644 --- a/src/components/bandwidth-mini-chart.tsx +++ b/src/components/bandwidth-mini-chart.tsx @@ -63,11 +63,11 @@ export function BandwidthMiniChart({ type="button" onClick={onClick} className={cn( - "relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors min-w-[120px] border-none bg-transparent", + "relative flex items-center gap-1.5 px-2 rounded cursor-pointer hover:bg-accent/50 transition-colors w-full min-w-0 border-none bg-transparent", className, )} > -
+
- + {formatBytes(currentBandwidth)} diff --git a/src/components/camoufox-config-dialog.tsx b/src/components/camoufox-config-dialog.tsx index fe93043..bafed30 100644 --- a/src/components/camoufox-config-dialog.tsx +++ b/src/components/camoufox-config-dialog.tsx @@ -149,7 +149,7 @@ export function CamoufoxConfigDialog({ return ( - + {isRunning @@ -164,7 +164,7 @@ export function CamoufoxConfigDialog({ - +
{profile.browser === "wayfern" ? ( - + {t("profileInfo.clone.title")} diff --git a/src/components/command-palette.tsx b/src/components/command-palette.tsx index cf2fbbb..1edaabb 100644 --- a/src/components/command-palette.tsx +++ b/src/components/command-palette.tsx @@ -157,7 +157,7 @@ export function CommandPalette({ return ( - + {t("commandPalette.empty")} @@ -205,7 +205,7 @@ export function CommandPalette({ }} > - + {t("commandPalette.actions.stopProfile", { name: p.name, })} @@ -221,7 +221,7 @@ export function CommandPalette({ }} > - + {t("commandPalette.actions.launchProfile", { name: p.name, })} @@ -239,7 +239,7 @@ export function CommandPalette({ }} > - + {t("commandPalette.actions.profileInfo", { name: p.name })} diff --git a/src/components/cookie-copy-dialog.tsx b/src/components/cookie-copy-dialog.tsx index 6eb0f68..15810ec 100644 --- a/src/components/cookie-copy-dialog.tsx +++ b/src/components/cookie-copy-dialog.tsx @@ -332,7 +332,7 @@ export function CookieCopyDialog({ return ( - + @@ -463,7 +463,7 @@ export function CookieCopyDialog({ : t("cookies.copy.noFound")}
) : ( - +
{filteredDomains.map((domain) => ( diff --git a/src/components/cookie-management-dialog.tsx b/src/components/cookie-management-dialog.tsx index 0fb3f89..d931c88 100644 --- a/src/components/cookie-management-dialog.tsx +++ b/src/components/cookie-management-dialog.tsx @@ -390,7 +390,7 @@ export function CookieManagementDialog({ return ( - + {t("cookies.management.title")} @@ -563,7 +563,7 @@ export function CookieManagementDialog({ {t("cookies.management.noCookies")}
) : ( - +
{exportCookieData.domains.map((domain) => ( - + {currentStep === "browser-selection" @@ -557,7 +557,7 @@ export function CreateProfileDialog({
-
+
{currentStep === "browser-selection" ? ( <> diff --git a/src/components/custom-toast.tsx b/src/components/custom-toast.tsx index d0f91ec..b9e39f7 100644 --- a/src/components/custom-toast.tsx +++ b/src/components/custom-toast.tsx @@ -201,7 +201,7 @@ export function UnifiedToast(props: ToastProps) { const progress = "progress" in props ? props.progress : undefined; return ( -
+
{getToastIcon(type, stage)}
diff --git a/src/components/data-table-action-bar.tsx b/src/components/data-table-action-bar.tsx index ce38a00..c07ed1d 100644 --- a/src/components/data-table-action-bar.tsx +++ b/src/components/data-table-action-bar.tsx @@ -65,7 +65,7 @@ function DataTableActionBar({ exit={{ opacity: 0, y: 20 }} transition={{ duration: 0.2, ease: "easeInOut" }} className={cn( - "fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm", + "fixed inset-x-0 bottom-6 z-50 mx-auto flex w-fit max-w-[calc(100%-2rem)] flex-wrap items-center justify-center gap-2 rounded-md border bg-background p-2 text-foreground shadow-sm", className, )} {...props} diff --git a/src/components/delete-confirmation-dialog.tsx b/src/components/delete-confirmation-dialog.tsx index 159b6b1..db50e66 100644 --- a/src/components/delete-confirmation-dialog.tsx +++ b/src/components/delete-confirmation-dialog.tsx @@ -57,7 +57,10 @@ export function DeleteConfirmationDialog({ const profile = profiles.find((p) => p.id === id); const displayName = profile ? profile.name : id; return ( -
  • +
  • • {displayName}
  • ); diff --git a/src/components/delete-group-dialog.tsx b/src/components/delete-group-dialog.tsx index f7352d2..741a9fc 100644 --- a/src/components/delete-group-dialog.tsx +++ b/src/components/delete-group-dialog.tsx @@ -136,10 +136,10 @@ export function DeleteGroupDialog({ count: associatedProfiles.length, })} - +
    {associatedProfiles.map((profile) => ( -
    +
    • {profile.name}
    ))} diff --git a/src/components/dns-blocklist-dialog.tsx b/src/components/dns-blocklist-dialog.tsx index 362f5be..d71bba3 100644 --- a/src/components/dns-blocklist-dialog.tsx +++ b/src/components/dns-blocklist-dialog.tsx @@ -87,7 +87,7 @@ export function DnsBlocklistDialog({ {t("dnsBlocklist.settingsDescription")}

    -
    +
    {statuses.map((status) => (
    -
    +
      {selectedProfiles.map((profileId) => { const profile = profiles.find( diff --git a/src/components/extension-management-dialog.tsx b/src/components/extension-management-dialog.tsx index a4b7540..22e9c71 100644 --- a/src/components/extension-management-dialog.tsx +++ b/src/components/extension-management-dialog.tsx @@ -75,6 +75,7 @@ import { } from "@/components/ui/tooltip"; import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; import type { Extension, ExtensionGroup } from "@/types"; import { DeleteConfirmationDialog } from "./delete-confirmation-dialog"; import { RippleButton } from "./ui/ripple"; @@ -770,6 +771,7 @@ export function ExtensionManagementDialog({ }, { id: "compat", + size: 56, enableSorting: false, header: () => null, cell: ({ row }) => @@ -821,6 +823,7 @@ export function ExtensionManagementDialog({ }, { id: "actions", + size: 80, enableSorting: false, header: () => null, cell: ({ row }) => { @@ -942,6 +945,7 @@ export function ExtensionManagementDialog({ }, { id: "extensions", + size: 120, enableSorting: false, header: () => null, cell: ({ row }) => { @@ -952,7 +956,7 @@ export function ExtensionManagementDialog({ const visibleExts = groupExts.slice(0, MAX_VISIBLE_ICONS); const overflowCount = groupExts.length - MAX_VISIBLE_ICONS; return ( -
      +
      {visibleExts.map((ext) => ( @@ -985,7 +989,7 @@ export function ExtensionManagementDialog({ )} {groupExts.length === 0 && ( - + {t("extensions.noExtensionsInGroup")} )} @@ -1043,6 +1047,7 @@ export function ExtensionManagementDialog({ }, { id: "actions", + size: 80, enableSorting: false, header: () => null, cell: ({ row }) => { @@ -1111,7 +1116,7 @@ export function ExtensionManagementDialog({ return ( <> - + {!subPage && ( @@ -1125,7 +1130,7 @@ export function ExtensionManagementDialog({ )} -
      +
      {limitedMode && ( <>
      @@ -1150,7 +1155,7 @@ export function ExtensionManagementDialog({ onValueChange={(v) => setActiveTab(v as "extensions" | "groups")} className="flex-1 min-h-0 flex flex-col" > -
      +
      {activeTab === "extensions" && ( - - document.getElementById("ext-file-input")?.click() - } - > - - {t("extensions.upload")} - + + + + document.getElementById("ext-file-input")?.click() + } + aria-label={t("extensions.upload")} + > + + + {t("extensions.upload")} + + + + {t("extensions.upload")} + )} {activeTab === "groups" && ( - setShowCreateGroup(true)} - > - - {t("extensions.newGroup")} - + + + setShowCreateGroup(true)} + aria-label={t("extensions.newGroup")} + > + + + {t("extensions.newGroup")} + + + + + {t("extensions.newGroup")} + + )}
      @@ -1267,14 +1290,20 @@ export function ExtensionManagementDialog({
      ) : ( 0 && "pb-16", + )} style={ { "--scroll-fade-top-offset": "32px", } as React.CSSProperties } > - +
      {extTable.getHeaderGroups().map((headerGroup) => ( @@ -1282,10 +1311,14 @@ export function ExtensionManagementDialog({ {header.isPlaceholder ? null @@ -1308,10 +1341,14 @@ export function ExtensionManagementDialog({ {flexRender( cell.column.columnDef.cell, @@ -1374,14 +1411,20 @@ export function ExtensionManagementDialog({ ) : ( 0 && "pb-16", + )} style={ { "--scroll-fade-top-offset": "32px", } as React.CSSProperties } > -
      +
      {groupTable.getHeaderGroups().map((headerGroup) => ( @@ -1389,10 +1432,14 @@ export function ExtensionManagementDialog({ {header.isPlaceholder ? null @@ -1415,10 +1462,14 @@ export function ExtensionManagementDialog({ {flexRender( cell.column.columnDef.cell, @@ -1515,7 +1566,7 @@ export function ExtensionManagementDialog({ {t("extensions.noExtensionsInGroup")} ) : ( -
      +
      {editGroupExtensionIds.map((extId) => { const ext = extensions.find((e) => e.id === extId); if (!ext) return null; @@ -1612,7 +1663,7 @@ export function ExtensionManagementDialog({ -
      +
      {editingExtension.version && ( <> @@ -1660,7 +1711,7 @@ export function ExtensionManagementDialog({ href={editingExtension.homepage_url} target="_blank" rel="noopener noreferrer" - className="text-primary hover:underline flex items-center gap-1 truncate" + className="text-primary hover:underline flex items-center gap-1 min-w-0" > {editingExtension.homepage_url} diff --git a/src/components/group-assignment-dialog.tsx b/src/components/group-assignment-dialog.tsx index 3f18c89..f693651 100644 --- a/src/components/group-assignment-dialog.tsx +++ b/src/components/group-assignment-dialog.tsx @@ -134,7 +134,7 @@ export function GroupAssignmentDialog({
      -
      +
        {selectedProfiles.map((profileId) => { // Find the profile name for display diff --git a/src/components/group-badges.tsx b/src/components/group-badges.tsx deleted file mode 100644 index 3bf4d6a..0000000 --- a/src/components/group-badges.tsx +++ /dev/null @@ -1,195 +0,0 @@ -"use client"; - -import { useCallback, useEffect, useRef, useState } from "react"; -import { useTranslation } from "react-i18next"; -import { Badge } from "@/components/ui/badge"; -import type { GroupWithCount } from "@/types"; - -interface GroupBadgesProps { - selectedGroupId: string | null; - onGroupSelect: (groupId: string) => void; - refreshTrigger?: number; - groups: GroupWithCount[]; - isLoading: boolean; -} - -export function GroupBadges({ - selectedGroupId, - onGroupSelect, - groups, - isLoading, -}: GroupBadgesProps) { - const { t } = useTranslation(); - const scrollContainerRef = useRef(null); - const [showLeftFade, setShowLeftFade] = useState(false); - const [showRightFade, setShowRightFade] = useState(false); - const [isDragging, setIsDragging] = useState(false); - const dragStartRef = useRef<{ x: number; scrollLeft: number } | null>(null); - const hasMovedRef = useRef(false); - const clickBlockedRef = useRef(false); - - const checkScrollPosition = useCallback(() => { - const container = scrollContainerRef.current; - if (!container) return; - - const { scrollLeft, scrollWidth, clientWidth } = container; - setShowLeftFade(scrollLeft > 0); - setShowRightFade(scrollLeft < scrollWidth - clientWidth - 1); - }, []); - - const handleMouseDown = useCallback((e: React.MouseEvent) => { - const container = scrollContainerRef.current; - if (!container) return; - - e.preventDefault(); - - dragStartRef.current = { - x: e.clientX, - scrollLeft: container.scrollLeft, - }; - hasMovedRef.current = false; - setIsDragging(true); - container.style.cursor = "grabbing"; - container.style.userSelect = "none"; - }, []); - - const handleMouseMove = useCallback( - (e: MouseEvent) => { - if (!isDragging || !dragStartRef.current) return; - - const container = scrollContainerRef.current; - if (!container) return; - - const deltaX = e.clientX - dragStartRef.current.x; - const distance = Math.abs(deltaX); - - if (distance > 5) { - hasMovedRef.current = true; - } - - container.scrollLeft = dragStartRef.current.scrollLeft - deltaX; - checkScrollPosition(); - }, - [isDragging, checkScrollPosition], - ); - - const handleMouseUp = useCallback(() => { - if (!isDragging) return; - - const container = scrollContainerRef.current; - if (container) { - container.style.cursor = ""; - container.style.userSelect = ""; - } - - clickBlockedRef.current = hasMovedRef.current; - setIsDragging(false); - dragStartRef.current = null; - - setTimeout(() => { - hasMovedRef.current = false; - clickBlockedRef.current = false; - }, 100); - }, [isDragging]); - - useEffect(() => { - if (isDragging) { - document.addEventListener("mousemove", handleMouseMove); - document.addEventListener("mouseup", handleMouseUp); - return () => { - document.removeEventListener("mousemove", handleMouseMove); - document.removeEventListener("mouseup", handleMouseUp); - }; - } - }, [isDragging, handleMouseMove, handleMouseUp]); - - useEffect(() => { - const container = scrollContainerRef.current; - if (!container) return; - - checkScrollPosition(); - container.addEventListener("scroll", checkScrollPosition); - const resizeObserver = new ResizeObserver(checkScrollPosition); - resizeObserver.observe(container); - - return () => { - container.removeEventListener("scroll", checkScrollPosition); - resizeObserver.disconnect(); - }; - }, [checkScrollPosition]); - - useEffect(() => { - if (groups.length === 0) { - setShowLeftFade(false); - setShowRightFade(false); - return; - } - - const container = scrollContainerRef.current; - if (!container) return; - - requestAnimationFrame(() => { - requestAnimationFrame(() => { - checkScrollPosition(); - }); - }); - }, [groups, checkScrollPosition]); - - if (isLoading && !groups.length) { - return ( -
        -
        - {t("groups.loading")} -
        -
        - ); - } - - return ( -
        - {showLeftFade && ( -
        - )} - {showRightFade && ( -
        - )} -
        - {groups.map((group) => ( - { - if (hasMovedRef.current || clickBlockedRef.current) { - e.preventDefault(); - e.stopPropagation(); - return; - } - onGroupSelect( - selectedGroupId === group.id ? "default" : group.id, - ); - }} - onMouseDown={(e) => { - if (isDragging) { - e.preventDefault(); - e.stopPropagation(); - } - }} - > - {group.name} - - {group.count} - - - ))} -
        -
        - ); -} diff --git a/src/components/group-management-dialog.tsx b/src/components/group-management-dialog.tsx index dce219a..4186962 100644 --- a/src/components/group-management-dialog.tsx +++ b/src/components/group-management-dialog.tsx @@ -59,6 +59,7 @@ import { } from "@/components/ui/tooltip"; import { parseBackendError, translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; import type { GroupWithCount, ProfileGroup } from "@/types"; import { RippleButton } from "./ui/ripple"; @@ -345,7 +346,7 @@ export function GroupManagementDialog({ groupSyncErrors[group.id], ); return ( -
        +
        {syncDot.tooltip}

        - - {group.name} + + {group.name}
        ); }, @@ -552,7 +553,7 @@ export function GroupManagementDialog({ return ( <> - + {!subPage && ( {t("groups.management")} @@ -562,7 +563,7 @@ export function GroupManagementDialog({ )} -
        +

        @@ -601,14 +602,20 @@ export function GroupManagementDialog({

        ) : ( 0 && "pb-16", + )} style={ { "--scroll-fade-top-offset": "32px", } as React.CSSProperties } > -
      +
      {table.getHeaderGroups().map((headerGroup) => ( @@ -616,10 +623,14 @@ export function GroupManagementDialog({ {header.isPlaceholder ? null @@ -642,10 +653,14 @@ export function GroupManagementDialog({ {flexRender( cell.column.columnDef.cell, diff --git a/src/components/home-header.tsx b/src/components/home-header.tsx index 28c0c29..2e023f0 100644 --- a/src/components/home-header.tsx +++ b/src/components/home-header.tsx @@ -131,6 +131,16 @@ const HomeHeader = ({ [clearHold], ); + const handleDoubleClick = useCallback( + (e: React.MouseEvent) => { + if (isTextInputTarget(e.target)) return; + if (e.target instanceof Element && e.target.closest("button")) return; + clearHold(); + void getCurrentWindow().toggleMaximize(); + }, + [clearHold], + ); + // Horizontal scroll fades for the group filter strip — when the user // has more groups than fit, the right edge fades to hint at overflow. const groupsScrollRef = useRef(null); @@ -156,20 +166,22 @@ const HomeHeader = ({ const isWindows = platform === "windows"; return ( + // biome-ignore lint/a11y/noStaticElementInteractions: titlebar drag surface; the interactive controls inside are real buttons/inputs
      {isMacOS && ( @@ -248,6 +260,7 @@ const HomeHeader = ({
      -

      +

      {t("importProfile.examplePaths")}
      macOS: ~/Library/Application @@ -600,7 +605,9 @@ export function ImportProfileDialog({

      {currentStep === "select" ? ( diff --git a/src/components/integrations-dialog.tsx b/src/components/integrations-dialog.tsx index 9924e98..803ce1f 100644 --- a/src/components/integrations-dialog.tsx +++ b/src/components/integrations-dialog.tsx @@ -32,6 +32,7 @@ import { Label } from "@/components/ui/label"; import { useWayfernTerms } from "@/hooks/use-wayfern-terms"; import { translateBackendError } from "@/lib/backend-errors"; import { showErrorToast, showSuccessToast } from "@/lib/toast-utils"; +import { cn } from "@/lib/utils"; import { CopyToClipboard } from "./ui/copy-to-clipboard"; interface AppSettings { @@ -307,14 +308,19 @@ export function IntegrationsDialog({ }} subPage={subPage} > - + {!subPage && ( {t("integrations.title")} )} -
      +
      @@ -327,7 +333,7 @@ export function IntegrationsDialog({
      @@ -364,7 +370,7 @@ export function IntegrationsDialog({ {settings.api_enabled && ( <> -
      +
      -
      +
      -
      +
      {agents.map((agent) => { const busy = busyAgentIds.has(agent.id); return ( diff --git a/src/components/location-proxy-dialog.tsx b/src/components/location-proxy-dialog.tsx index 1c559c2..e739208 100644 --- a/src/components/location-proxy-dialog.tsx +++ b/src/components/location-proxy-dialog.tsx @@ -233,7 +233,7 @@ export function LocationProxyDialog({ -
      +
      {/* Country - always visible */}
      -
      +
      {open && hasAvailableOptions && ( - + {isLoading ? ( loadingIndicator ) : ( @@ -527,7 +554,7 @@ const MultipleSelector = React.forwardRef< {dropdowns.map((option) => { return ( diff --git a/src/components/profile-data-table.tsx b/src/components/profile-data-table.tsx index f839a4c..94837ef 100644 --- a/src/components/profile-data-table.tsx +++ b/src/components/profile-data-table.tsx @@ -5,9 +5,11 @@ import { flexRender, getCoreRowModel, getSortedRowModel, + type RowData, type RowSelectionState, type SortingState, useReactTable, + type VisibilityState, } from "@tanstack/react-table"; import { useVirtualizer } from "@tanstack/react-virtual"; import { invoke } from "@tauri-apps/api/core"; @@ -81,7 +83,6 @@ import { isCrossOsProfile, } from "@/lib/browser-utils"; import { formatRelativeTime } from "@/lib/flag-utils"; -import { trimName } from "@/lib/name-utils"; import { cn } from "@/lib/utils"; import type { BrowserProfile, @@ -105,6 +106,15 @@ import { TrafficDetailsDialog } from "./traffic-details-dialog"; import { Input } from "./ui/input"; import { RippleButton } from "./ui/ripple"; +declare module "@tanstack/react-table" { + interface ColumnMeta { + // Emit no width for this column so table-fixed hands it all remaining + // space. Checking columnDef.size alone can't express this: TanStack + // resolves an unspecified size to its 150px default. + flexWidth?: boolean; + } +} + // Stable table meta type to pass volatile state/handlers into TanStack Table without // causing column definitions to be recreated on every render. interface TableMeta { @@ -822,6 +832,96 @@ const NonHoverableTooltip = React.memo<{ NonHoverableTooltip.displayName = "NonHoverableTooltip"; +// CSS-truncated text whose tooltip only appears when the text actually +// overflows its column (measured on hover, so it tracks live resizes). +const OverflowTooltipText = React.memo<{ + text: string; + className?: string; +}>(({ text, className }) => { + const textRef = React.useRef(null); + const [isOverflowing, setIsOverflowing] = React.useState(false); + + return ( + { + if (!open) return; + const el = textRef.current; + if (el) setIsOverflowing(el.scrollWidth > el.clientWidth); + }} + > + + + {text} + + + {isOverflowing && {text}} + + ); +}); + +OverflowTooltipText.displayName = "OverflowTooltipText"; + +// Must be rendered inside a ; the tooltip shows the full assignment +// name only when it is truncated in the cell. +const ProxyCellTrigger = React.memo<{ + displayName: string; + hasAssignment: boolean; + vpnBadge: string | null; + isDisabled: boolean; +}>(({ displayName, hasAssignment, vpnBadge, isDisabled }) => { + const textRef = React.useRef(null); + const [isOverflowing, setIsOverflowing] = React.useState(false); + + return ( + { + if (!open) return; + const el = textRef.current; + if (el) setIsOverflowing(el.scrollWidth > el.clientWidth); + }} + > + + + + {vpnBadge && ( + + {vpnBadge} + + )} + + {displayName} + + + + + {hasAssignment && isOverflowing && ( + {displayName} + )} + + ); +}); + +ProxyCellTrigger.displayName = "ProxyCellTrigger"; + const NoteCell = React.memo<{ profile: BrowserProfile; isDisabled: boolean; @@ -2276,7 +2376,9 @@ export function ProfilesDataTable({ }, { accessorKey: "name", - size: 130, + // 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 }) => { const meta = table.options.meta as TableMeta; return ( @@ -2341,27 +2443,18 @@ export function ProfilesDataTable({ meta.setRenameError(null); } }} - className="w-30 h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0" + className="w-full min-w-0 max-w-full h-6 px-2 py-1 text-sm font-medium leading-none border-0 shadow-none focus-visible:ring-0" />
      ); } - const display = - name.length < 14 ? ( -
      - {name} -
      - ) : ( - - - - {trimName(name, 14)} - - - {name} - - ); + const display = ( + + ); const isCrossOs = isCrossOsProfile(profile); const isCrossOsBlocked = isCrossOs; @@ -2528,7 +2621,6 @@ export function ProfilesDataTable({ ? effectiveProxy.name : meta.t("profiles.table.notSelected"); const vpnBadge = effectiveVpn ? "WG" : null; - const tooltipText = hasAssignment ? displayName : null; const isSelectorOpen = meta.openProxySelectorFor === profile.id; const selectedId = effectiveVpnId ?? effectiveProxyId ?? null; @@ -2562,42 +2654,12 @@ export function ProfilesDataTable({ meta.setOpenProxySelectorFor(open ? profile.id : null); }} > - - - - - {vpnBadge && ( - - {vpnBadge} - - )} - - {hasAssignment - ? trimName(displayName, 10) - : displayName} - - - - - {tooltipText && ( - {tooltipText} - )} - + {!isDisabled && ( ({}); + + // Content columns grow proportionally with the container but never drop + // below the compact-layout floor; the name column takes the remainder. + // Computed in px from the observed container width because fixed table + // layout ignores max()/calc() column widths. + const [containerWidth, setContainerWidth] = React.useState(0); + const table = useReactTable({ data: profiles, columns, state: { sorting, rowSelection, + columnVisibility, }, onSortingChange: handleSortingChange, onRowSelectionChange: handleRowSelectionChange, + onColumnVisibilityChange: setColumnVisibility, enableRowSelection: (row) => { const profile = row.original; const isRunning = @@ -2885,9 +2961,50 @@ export function ProfilesDataTable({ }); const scrollParentRef = React.useRef(null); + const columnWidth = React.useCallback( + (id: string, sizePx: number) => { + const proportions: Record = { + tags: { pct: 0.12, floor: 100 }, + note: { pct: 0.1, floor: 80 }, + proxy: { pct: 0.13, floor: 110 }, + ext: { pct: 0.11, floor: 95 }, + dns: { pct: 0.11, floor: 95 }, + }; + const p = proportions[id]; + if (!p) return `${sizePx}px`; + return `${Math.max(p.floor, Math.round(containerWidth * p.pct))}px`; + }, + [containerWidth], + ); const sortedRows = table.getRowModel().rows; useScrollFade(scrollParentRef); + React.useEffect(() => { + const el = scrollParentRef.current; + if (!el) return; + const update = () => { + const w = el.clientWidth; + setContainerWidth(Math.round(w / 8) * 8); + setColumnVisibility((prev) => { + const next: VisibilityState = { + dns: w >= 768, + ext: w >= 672, + note: w >= 576, + tags: w >= 512, + }; + return Object.keys(next).every((k) => prev[k] === next[k]) + ? prev + : next; + }); + }; + update(); + const ro = new ResizeObserver(update); + ro.observe(el); + return () => { + ro.disconnect(); + }; + }, []); + // Compact 36px row from the redesign spec; estimateSize must match the // actual rendered row height or virtualizer placement drifts under scroll. const ROW_HEIGHT = 36; @@ -2912,7 +3029,13 @@ export function ProfilesDataTable({
      0 && "pb-20", + )} style={ { // Sticky table header is 32px tall (h-8); shift the top @@ -2922,7 +3045,7 @@ export function ProfilesDataTable({ } as React.CSSProperties } > -
      +
      {table.getHeaderGroups().map((headerGroup) => ( {header.isPlaceholder @@ -2955,7 +3081,7 @@ export function ProfilesDataTable({ {sortedRows.length === 0 ? ( {t("profiles.table.empty")} @@ -2965,7 +3091,7 @@ export function ProfilesDataTable({ <> {paddingTop > 0 && ( - )} {virtualRows.map((virtualRow) => { @@ -2997,9 +3123,12 @@ export function ProfilesDataTable({ key={cell.id} className="overflow-visible py-0" style={{ - width: cell.column.columnDef.size - ? `${cell.column.getSize()}px` - : undefined, + width: cell.column.columnDef.meta?.flexWidth + ? undefined + : columnWidth( + cell.column.id, + cell.column.getSize(), + ), }} > {flexRender( @@ -3013,7 +3142,7 @@ export function ProfilesDataTable({ })} {paddingBottom > 0 && ( - )} diff --git a/src/components/profile-info-dialog.tsx b/src/components/profile-info-dialog.tsx index 3453891..9bebda5 100644 --- a/src/components/profile-info-dialog.tsx +++ b/src/components/profile-info-dialog.tsx @@ -503,7 +503,7 @@ export function ProfileInfoDialog({ > {/* The dialog renders its own custom header, so the accessible title is visually hidden but present for screen readers (Radix requires it). */} diff --git a/src/components/profile-password-dialog.tsx b/src/components/profile-password-dialog.tsx index f91e2bc..57bdfc9 100644 --- a/src/components/profile-password-dialog.tsx +++ b/src/components/profile-password-dialog.tsx @@ -193,7 +193,7 @@ export function ProfilePasswordDialog({ if (!open) onClose(); }} > - + {t(titleKey)} diff --git a/src/components/profile-selector-dialog.tsx b/src/components/profile-selector-dialog.tsx index 762af84..9f5c0ca 100644 --- a/src/components/profile-selector-dialog.tsx +++ b/src/components/profile-selector-dialog.tsx @@ -180,7 +180,7 @@ export function ProfileSelectorDialog({ successMessage={t("profileSelector.urlCopied")} /> -
      +
      {url}
      diff --git a/src/components/profile-sync-dialog.tsx b/src/components/profile-sync-dialog.tsx index 40e5e0d..56402c1 100644 --- a/src/components/profile-sync-dialog.tsx +++ b/src/components/profile-sync-dialog.tsx @@ -172,8 +172,8 @@ export function ProfileSyncDialog({ return ( - - + + {t("sync.mode.title")} {t("sync.mode.description", { @@ -183,115 +183,117 @@ export function ProfileSyncDialog({ - {isCheckingConfig ? ( -
      -
      -
      - ) : ( -
      - {!hasConfig && ( -
      -

      {t("sync.mode.notConfigured")}

      - -
      - )} - - {hasConfig && ( - <> - -
      - - -
      - -
      - - -
      - -
      - - -
      -
      - - {syncMode === "Encrypted" && - !hasE2ePassword && - userChangedMode && ( -
      - {t("sync.mode.noPasswordWarning")} -
      - )} - -
      - -
      - - {formatLastSync(profile.last_sync)} - - {isSyncEnabled(profile) && ( - - {profile.last_sync - ? t("common.status.synced") - : t("common.status.pending")} - - )} -
      +
      + {isCheckingConfig ? ( +
      +
      +
      + ) : ( +
      + {!hasConfig && ( +
      +

      {t("sync.mode.notConfigured")}

      +
      - - )} -
      - )} + )} + + {hasConfig && ( + <> + +
      + + +
      + +
      + + +
      + +
      + + +
      +
      + + {syncMode === "Encrypted" && + !hasE2ePassword && + userChangedMode && ( +
      + {t("sync.mode.noPasswordWarning")} +
      + )} + +
      + +
      + + {formatLastSync(profile.last_sync)} + + {isSyncEnabled(profile) && ( + + {profile.last_sync + ? t("common.status.synced") + : t("common.status.pending")} + + )} +
      +
      + + )} +
      + )} +
      ), cell: ({ row }) => ( - {row.original.name} + + {row.original.name} + ), }, { id: "protocol", + size: 96, enableSorting: false, header: () => t("proxies.management.protocolCol"), cell: ({ row }) => ( @@ -564,8 +568,20 @@ export function ProxyManagementDialog({ ), }, + { + id: "hostPort", + enableSorting: false, + header: () => t("proxies.management.hostPort"), + cell: ({ row }) => ( + + {row.original.proxy_settings.host}: + {row.original.proxy_settings.port} + + ), + }, { id: "usage", + size: 80, enableSorting: false, header: () => t("proxies.management.usage"), cell: ({ row }) => ( @@ -574,6 +590,7 @@ export function ProxyManagementDialog({ }, { id: "sync", + size: 96, enableSorting: false, header: () => t("proxies.management.syncCol"), cell: ({ row }) => { @@ -607,6 +624,7 @@ export function ProxyManagementDialog({ }, { id: "actions", + size: 144, enableSorting: false, header: () => t("common.labels.actions"), cell: ({ row }) => { @@ -775,7 +793,7 @@ export function ProxyManagementDialog({ vpnSyncErrors[vpn.id], ); return ( -
      +
      {syncDot.tooltip}

      - {vpn.name} + {vpn.name}
      ); }, }, { id: "type", + size: 96, enableSorting: false, header: () => t("common.labels.type"), cell: () => WG, }, { id: "usage", + size: 80, enableSorting: false, header: () => t("proxies.management.usage"), cell: ({ row }) => ( @@ -809,6 +829,7 @@ export function ProxyManagementDialog({ }, { id: "sync", + size: 96, enableSorting: false, header: () => t("proxies.management.syncCol"), cell: ({ row }) => { @@ -842,6 +863,7 @@ export function ProxyManagementDialog({ }, { id: "actions", + size: 144, enableSorting: false, header: () => t("common.labels.actions"), cell: ({ row }) => { @@ -1068,7 +1090,7 @@ export function ProxyManagementDialog({ return ( <> - + {!subPage && ( {t("proxies.management.title")} @@ -1078,251 +1100,355 @@ export function ProxyManagementDialog({ )} - setActiveTab(v as "proxies" | "vpns")} - className="flex-1 min-h-0 flex flex-col" - > -
      - - - {t("proxies.management.tabProxies")} - - {storedProxies.length} - - - - {t("proxies.management.tabVpns")} - - {vpnConfigs.length} - - - -
      - {activeTab === "proxies" && ( - <> - { - setShowImportDialog(true); - }} - className="flex gap-2 items-center" - > - - {t("common.buttons.import")} - - { - setShowExportDialog(true); - }} - className="flex gap-2 items-center" - disabled={storedProxies.length === 0} - > - - {t("common.buttons.export")} - - - - {t("proxies.management.newProxy")} - - - )} - {activeTab === "vpns" && ( - <> - { - setShowVpnImportDialog(true); - }} - className="flex gap-2 items-center" - > - - {t("common.buttons.import")} - - - - {t("proxies.management.newVpn")} - - - )} -
      -
      - - + setActiveTab(v as "proxies" | "vpns")} + className="flex-1 min-h-0 flex flex-col" > -
      - {isLoading ? ( -
      - {t("proxies.management.loading")} -
      - ) : storedProxies.length === 0 ? ( -
      - {t("proxies.management.noneCreated")} -
      - ) : ( - -
      +
      +
      - - {proxiesTable.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ))} - - ))} - - - {proxiesTable.getRowModel().rows.map((row) => ( - + + + {t("proxies.management.tabProxies")} + + {storedProxies.length} + + + + {t("proxies.management.tabVpns")} + + {vpnConfigs.length} + + + +
      + {activeTab === "proxies" && ( + <> + + + { + setShowImportDialog(true); + }} + className="flex gap-2 items-center" + aria-label={t("common.buttons.import")} > - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - ))} - -
      -
      - )} + + + {t("common.buttons.import")} + + + + +

      {t("common.buttons.import")}

      +
      + + + + { + setShowExportDialog(true); + }} + className="flex gap-2 items-center" + aria-label={t("common.buttons.export")} + disabled={storedProxies.length === 0} + > + + + {t("common.buttons.export")} + + + + +

      {t("common.buttons.export")}

      +
      +
      + + + + + + {t("proxies.management.newProxy")} + + + + +

      {t("proxies.management.newProxy")}

      +
      +
      + + )} + {activeTab === "vpns" && ( + <> + + + { + setShowVpnImportDialog(true); + }} + className="flex gap-2 items-center" + aria-label={t("common.buttons.import")} + > + + + {t("common.buttons.import")} + + + + +

      {t("common.buttons.import")}

      +
      +
      + + + + + + {t("proxies.management.newVpn")} + + + + +

      {t("proxies.management.newVpn")}

      +
      +
      + + )} +
      - - -
      - {isLoadingVpns ? ( -
      - {t("vpns.management.loading")} -
      - ) : vpnConfigs.length === 0 ? ( -
      - {t("vpns.management.noneCreated")} -
      - ) : ( - - - - {vpnsTable.getHeaderGroups().map((headerGroup) => ( - - {headerGroup.headers.map((header) => ( - - {header.isPlaceholder - ? null - : flexRender( - header.column.columnDef.header, - header.getContext(), - )} - - ))} - - ))} - - - {vpnsTable.getRowModel().rows.map((row) => ( - - {row.getVisibleCells().map((cell) => ( - - {flexRender( - cell.column.columnDef.cell, - cell.getContext(), - )} - - ))} - - ))} - -
      -
      - )} -
      -
      - + +
      + {isLoading ? ( +
      + {t("proxies.management.loading")} +
      + ) : storedProxies.length === 0 ? ( +
      + {t("proxies.management.noneCreated")} +
      + ) : ( + 0 && "pb-16", + )} + style={ + { + "--scroll-fade-top-offset": "32px", + } as React.CSSProperties + } + > + + + {proxiesTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {proxiesTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
      +
      + )} +
      +
      + + +
      + {isLoadingVpns ? ( +
      + {t("vpns.management.loading")} +
      + ) : vpnConfigs.length === 0 ? ( +
      + {t("vpns.management.noneCreated")} +
      + ) : ( + 0 && "pb-16", + )} + style={ + { + "--scroll-fade-top-offset": "32px", + } as React.CSSProperties + } + > + + + {vpnsTable.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + {header.isPlaceholder + ? null + : flexRender( + header.column.columnDef.header, + header.getContext(), + )} + + ))} + + ))} + + + {vpnsTable.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + {flexRender( + cell.column.columnDef.cell, + cell.getContext(), + )} + + ))} + + ))} + +
      +
      + )} +
      +
      + +
      {!subPage && ( diff --git a/src/components/rail-nav.tsx b/src/components/rail-nav.tsx index 590c0e4..0f4f0c0 100644 --- a/src/components/rail-nav.tsx +++ b/src/components/rail-nav.tsx @@ -74,8 +74,6 @@ function useLogoEasterEgg({ const rect = el.getBoundingClientRect(); const startX = rect.left; const startY = rect.top; - const floorY = window.innerHeight; - const rightWall = window.innerWidth; const clone = el.cloneNode(true) as HTMLElement; clone.style.position = "fixed"; @@ -99,6 +97,10 @@ function useLogoEasterEgg({ const dt = Math.min((time - lastTime) / 1000, 0.05); lastTime = time; + // Read live so a mid-animation window resize moves the floor/wall. + const floorY = window.innerHeight; + const rightWall = window.innerWidth; + vy += GRAVITY * dt; x += vx * dt; y += vy * dt; @@ -294,7 +296,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) { ref={logoRef} type="button" aria-label={t("header.donutLogo")} - className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent" + className="grid place-items-center size-7 rounded-md cursor-pointer select-none text-foreground bg-transparent shrink-0" onClick={handleClick} onPointerDown={() => { setIsPressed(true); @@ -331,43 +333,45 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) {
      ) : ( -
      +
      )} -
      +
      - {TOP_ITEMS.map(({ page, Icon, labelKey }) => { - const active = currentPage === page; - return ( - - - - - {t(labelKey)} - - ); - })} +
      + {TOP_ITEMS.map(({ page, Icon, labelKey }) => { + const active = currentPage === page; + return ( + + + + + {t(labelKey)} + + ); + })} +
      @@ -381,7 +385,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) { aria-label={t("rail.more.label")} aria-expanded={moreOpen} className={cn( - "grid place-items-center size-7 rounded-md transition-colors duration-100", + "grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0", moreOpen ? "text-foreground bg-accent" : "text-muted-foreground hover:text-card-foreground hover:bg-accent/50", @@ -403,7 +407,7 @@ export function RailNav({ currentPage, onNavigate }: RailNavProps) { aria-label={t("rail.settings")} aria-current={currentPage === "settings" ? "page" : undefined} className={cn( - "relative grid place-items-center size-7 rounded-md transition-colors duration-100", + "relative grid place-items-center size-7 rounded-md transition-colors duration-100 shrink-0", currentPage === "settings" ? "text-foreground bg-accent" : "text-muted-foreground hover:text-card-foreground hover:bg-accent/50", diff --git a/src/components/settings-dialog.tsx b/src/components/settings-dialog.tsx index 46f1fcb..e0adb0e 100644 --- a/src/components/settings-dialog.tsx +++ b/src/components/settings-dialog.tsx @@ -633,7 +633,7 @@ export function SettingsDialog({ return ( <> - + {!subPage && ( {t("settings.title")} @@ -643,7 +643,7 @@ export function SettingsDialog({
      {/* Appearance Section */} @@ -748,7 +748,7 @@ export function SettingsDialog({
      {t("settings.appearance.customColors")}
      -
      +
      {THEME_VARIABLES.map(({ key, label }) => { const colorValue = customThemeState.colors[key] ?? "#000000"; @@ -1314,7 +1314,7 @@ export function SettingsDialog({
      {subPage ? ( -
      +
      -
      +
      -
      +