mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-10-21 23:01:26 +02:00
feat: privacy settings
This commit is contained in:
1 parent
759263b63f
commit
d6657eac98
30 files changed
+1229
-418
No files matched your search
+95
-173
@@ -1,17 +1,10 @@
|
||||
"use client";
|
||||
|
||||
import { getCurrentWindow } from "@tauri-apps/api/window";
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import { useCallback, useEffect, useRef, useState } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { GoPlus } from "react-icons/go";
|
||||
import {
|
||||
LuChevronLeft,
|
||||
LuChevronRight,
|
||||
LuCircleHelp,
|
||||
LuSearch,
|
||||
LuX,
|
||||
} from "react-icons/lu";
|
||||
import { LuCircleHelp, LuSearch, LuX } from "react-icons/lu";
|
||||
import { useWindowDecorations } from "@/hooks/use-window-decorations";
|
||||
import { getCurrentOS } from "@/lib/browser-utils";
|
||||
import {
|
||||
@@ -25,6 +18,7 @@ import { UNGROUPED_DROP_ID, useProfileGroupDrag } from "./profile-group-drag";
|
||||
import { Button } from "./ui/button";
|
||||
import { Input } from "./ui/input";
|
||||
import { Popover, PopoverContent, PopoverTrigger } from "./ui/popover";
|
||||
import { ScrollStrip } from "./ui/scroll-strip";
|
||||
import { Tooltip, TooltipContent, TooltipTrigger } from "./ui/tooltip";
|
||||
|
||||
const HOLD_MS = 150;
|
||||
@@ -165,7 +159,6 @@ const HomeHeader = ({
|
||||
}: Props) => {
|
||||
const { t } = useTranslation();
|
||||
const profileDrag = useProfileGroupDrag();
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [platform, setPlatform] = useState<string>("macos");
|
||||
|
||||
useEffect(() => {
|
||||
@@ -265,36 +258,6 @@ const HomeHeader = ({
|
||||
[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<HTMLDivElement | null>(null);
|
||||
const [groupsFadeLeft, setGroupsFadeLeft] = useState(false);
|
||||
const [groupsFadeRight, setGroupsFadeRight] = useState(false);
|
||||
useEffect(() => {
|
||||
if (!showProfileToolbar) return;
|
||||
const el = groupsScrollRef.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setGroupsFadeLeft(el.scrollLeft > 1);
|
||||
setGroupsFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const ro = new ResizeObserver(update);
|
||||
ro.observe(el);
|
||||
const mutations = new MutationObserver(update);
|
||||
mutations.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
ro.disconnect();
|
||||
mutations.disconnect();
|
||||
};
|
||||
}, [showProfileToolbar]);
|
||||
|
||||
const isWindows = platform === "windows";
|
||||
|
||||
return (
|
||||
@@ -351,145 +314,104 @@ const HomeHeader = ({
|
||||
) : null}
|
||||
|
||||
{showProfileToolbar && (
|
||||
<div className="relative flex min-w-0 flex-1 items-center">
|
||||
{groupsFadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsLeft")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: -el.clientWidth * 0.6,
|
||||
behavior:
|
||||
reducedMotion || profileDrag?.active
|
||||
? "instant"
|
||||
: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 left-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
>
|
||||
<LuChevronLeft className="size-3" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={groupsScrollRef}
|
||||
data-slot="profile-group-strip"
|
||||
className="ml-2 flex scrollbar-none items-center gap-3 overflow-x-auto [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden"
|
||||
style={{
|
||||
paddingLeft: groupsFadeLeft ? 22 : 0,
|
||||
paddingRight: groupsFadeRight ? 22 : 0,
|
||||
}}
|
||||
>
|
||||
{/* "All" filter — shows every profile regardless of group. */}
|
||||
{(() => {
|
||||
const active = selectedGroupId === ALL_FILTER_ID;
|
||||
return (
|
||||
<button
|
||||
key="__all__"
|
||||
type="button"
|
||||
onClick={() => {
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span>{t("groups.all")}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{totalProfiles}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{profileDrag && profileDrag.movingIds.size > 0 && (
|
||||
<span
|
||||
data-profile-group-drop={UNGROUPED_DROP_ID}
|
||||
data-drop-state={
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID
|
||||
? "target"
|
||||
: profileDrag.canDrop(UNGROUPED_DROP_ID)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
}
|
||||
title={t("profileMotion.dragTarget", {
|
||||
group: t("groups.noGroup"),
|
||||
})}
|
||||
<ScrollStrip
|
||||
data-slot="profile-group-strip"
|
||||
className="ml-2 flex-1"
|
||||
stripClassName="gap-3"
|
||||
activeKey={selectedGroupId}
|
||||
instant={Boolean(profileDrag?.active)}
|
||||
scrollLeftLabel={t("header.scrollGroupsLeft")}
|
||||
scrollRightLabel={t("header.scrollGroupsRight")}
|
||||
>
|
||||
{/* "All" filter — shows every profile regardless of group. */}
|
||||
{(() => {
|
||||
const active = selectedGroupId === ALL_FILTER_ID;
|
||||
return (
|
||||
<button
|
||||
key="__all__"
|
||||
type="button"
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(ALL_FILTER_ID);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID &&
|
||||
"bg-accent font-medium text-accent-foreground",
|
||||
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
|
||||
"flex h-7 shrink-0 items-center gap-1.5 px-1 text-xs transition-colors duration-100",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{t("groups.noGroup")}
|
||||
</span>
|
||||
)}
|
||||
{groups.map((group) => {
|
||||
const active = selectedGroupId === group.id;
|
||||
const dropTarget = profileDrag?.targetId === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
data-profile-group-drop={group.id}
|
||||
data-drop-state={
|
||||
profileDrag?.active
|
||||
? dropTarget
|
||||
? "target"
|
||||
: profileDrag.canDrop(group.id)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
: undefined
|
||||
}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
|
||||
dropTarget && "bg-accent text-accent-foreground",
|
||||
profileDrag?.active &&
|
||||
!profileDrag.canDrop(group.id) &&
|
||||
"opacity-40",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</div>
|
||||
{groupsFadeRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={t("header.scrollGroupsRight")}
|
||||
onClick={() => {
|
||||
const el = groupsScrollRef.current;
|
||||
if (el)
|
||||
el.scrollBy({
|
||||
left: el.clientWidth * 0.6,
|
||||
behavior:
|
||||
reducedMotion || profileDrag?.active
|
||||
? "instant"
|
||||
: "smooth",
|
||||
});
|
||||
}}
|
||||
className="absolute top-1/2 right-0 z-10 grid size-5 -translate-y-1/2 place-items-center rounded-full bg-card/90 text-muted-foreground shadow-sm transition-colors hover:bg-accent hover:text-accent-foreground"
|
||||
<span>{t("groups.all")}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{totalProfiles}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})()}
|
||||
{profileDrag && profileDrag.movingIds.size > 0 && (
|
||||
<span
|
||||
data-profile-group-drop={UNGROUPED_DROP_ID}
|
||||
data-drop-state={
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID
|
||||
? "target"
|
||||
: profileDrag.canDrop(UNGROUPED_DROP_ID)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
}
|
||||
title={t("profileMotion.dragTarget", {
|
||||
group: t("groups.noGroup"),
|
||||
})}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center rounded-sm px-2 text-xs text-muted-foreground transition-colors duration-100",
|
||||
profileDrag.targetId === UNGROUPED_DROP_ID &&
|
||||
"bg-accent font-medium text-accent-foreground",
|
||||
!profileDrag.canDrop(UNGROUPED_DROP_ID) && "opacity-40",
|
||||
)}
|
||||
>
|
||||
<LuChevronRight className="size-3" />
|
||||
</button>
|
||||
{t("groups.noGroup")}
|
||||
</span>
|
||||
)}
|
||||
</div>
|
||||
{groups.map((group) => {
|
||||
const active = selectedGroupId === group.id;
|
||||
const dropTarget = profileDrag?.targetId === group.id;
|
||||
return (
|
||||
<button
|
||||
key={group.id}
|
||||
type="button"
|
||||
title={group.name}
|
||||
data-profile-group-drop={group.id}
|
||||
data-drop-state={
|
||||
profileDrag?.active
|
||||
? dropTarget
|
||||
? "target"
|
||||
: profileDrag.canDrop(group.id)
|
||||
? "available"
|
||||
: "unavailable"
|
||||
: undefined
|
||||
}
|
||||
aria-current={active ? "true" : undefined}
|
||||
onClick={() => {
|
||||
onGroupSelect(active ? ALL_FILTER_ID : group.id);
|
||||
}}
|
||||
className={cn(
|
||||
"flex h-7 shrink-0 items-center gap-1.5 rounded-sm px-1 text-xs transition-colors duration-100",
|
||||
dropTarget && "bg-accent text-accent-foreground",
|
||||
profileDrag?.active &&
|
||||
!profileDrag.canDrop(group.id) &&
|
||||
"opacity-40",
|
||||
active
|
||||
? "font-medium text-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
<span className="max-w-40 truncate">{group.name}</span>
|
||||
<span className="text-[11px] text-muted-foreground tabular-nums">
|
||||
{group.count}
|
||||
</span>
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollStrip>
|
||||
)}
|
||||
|
||||
{!showProfileToolbar && <div className="flex-1" />}
|
||||
|
||||
+226
-102
@@ -40,6 +40,7 @@ import {
|
||||
PopoverContent,
|
||||
PopoverTrigger,
|
||||
} from "@/components/ui/popover";
|
||||
import { ScrollStrip } from "@/components/ui/scroll-strip";
|
||||
import {
|
||||
Select,
|
||||
SelectContent,
|
||||
@@ -82,8 +83,15 @@ interface AppSettings {
|
||||
keep_decrypted_profiles_in_ram?: boolean;
|
||||
fingerprint_gate_disabled?: boolean;
|
||||
vpn_extension_warning_disabled?: boolean;
|
||||
require_route_for_launch?: boolean;
|
||||
trash_retention_days?: number;
|
||||
record_traffic_domains?: boolean;
|
||||
}
|
||||
|
||||
/** The retention choices offered, in days. 0 turns the trash off. */
|
||||
const TRASH_RETENTION_DAYS = [0, 1, 7, 14, 30, 90] as const;
|
||||
const DEFAULT_TRASH_RETENTION_DAYS = 30;
|
||||
|
||||
interface CustomThemeState {
|
||||
selectedThemeId: string | null;
|
||||
colors: Record<string, string>;
|
||||
@@ -603,10 +611,12 @@ export function SettingsDialog({
|
||||
| "es"
|
||||
| "pt"
|
||||
| "fr"
|
||||
| "de"
|
||||
| "zh"
|
||||
| "ja"
|
||||
| "ko"
|
||||
| "ru"
|
||||
| "tr"
|
||||
| "vi"),
|
||||
);
|
||||
setOriginalLanguage(selectedLanguage);
|
||||
@@ -826,12 +836,21 @@ export function SettingsDialog({
|
||||
"vpn_extension_warning_disabled",
|
||||
"settings.privacy.vpnExtensionWarning",
|
||||
],
|
||||
["require_route_for_launch", "settings.privacy.requireRoute"],
|
||||
] as const
|
||||
)
|
||||
.filter(
|
||||
([key]) => Boolean(settings[key]) !== Boolean(originalSettings[key]),
|
||||
)
|
||||
.map(([, key]) => t(key)),
|
||||
...((settings.record_traffic_domains ?? true) !==
|
||||
(originalSettings.record_traffic_domains ?? true)
|
||||
? [t("settings.privacy.recordSites")]
|
||||
: []),
|
||||
...((settings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS) !==
|
||||
(originalSettings.trash_retention_days ?? DEFAULT_TRASH_RETENTION_DAYS)
|
||||
? [t("settings.trashRetention")]
|
||||
: []),
|
||||
];
|
||||
const hasChanges = pendingChanges.length > 0;
|
||||
const sections = [
|
||||
@@ -859,6 +878,15 @@ export function SettingsDialog({
|
||||
["dns", "dnsBlocklist.title", ["dnsBlocklist"]],
|
||||
["encryption", "settings.encryption.title", ["settings.encryption"]],
|
||||
["commercial", "settings.commercial.title", ["settings.commercial"]],
|
||||
[
|
||||
"privacy",
|
||||
"settings.privacy.title",
|
||||
[
|
||||
"settings.privacy",
|
||||
"settings.trashRetention",
|
||||
"settings.trashRetentionDescription",
|
||||
],
|
||||
],
|
||||
[
|
||||
"advanced",
|
||||
"settings.advanced.title",
|
||||
@@ -868,7 +896,6 @@ export function SettingsDialog({
|
||||
"settings.disableAutoUpdatesDescription",
|
||||
"settings.keepDecryptedProfilesInRam",
|
||||
"settings.keepDecryptedProfilesInRamDescription",
|
||||
"settings.privacy",
|
||||
],
|
||||
],
|
||||
] as Array<[string, string, string[]]>;
|
||||
@@ -965,48 +992,55 @@ export function SettingsDialog({
|
||||
aria-label={t("appFeedback.searchSettings")}
|
||||
className="h-8"
|
||||
/>
|
||||
<nav
|
||||
aria-label={t("settings.title")}
|
||||
className="flex flex-wrap gap-x-1 gap-y-1"
|
||||
>
|
||||
{sections.map(([id, label]) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-slot="settings-nav-item"
|
||||
data-section={id}
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate rounded-md px-2 py-1 text-xs transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-nav-indicator"
|
||||
layoutId={
|
||||
animateNav ? "settings-nav-indicator" : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animateNav ? MOTION_SPRING_POSITION : { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
{t(label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
<nav aria-label={t("settings.title")}>
|
||||
<ScrollStrip
|
||||
data-slot="settings-nav"
|
||||
activeKey={activeSection}
|
||||
scrollLeftLabel={t("settings.scrollSectionsLeft")}
|
||||
scrollRightLabel={t("settings.scrollSectionsRight")}
|
||||
stripClassName="gap-1"
|
||||
>
|
||||
{sections.map(([id, label]) => {
|
||||
const active = activeSection === id;
|
||||
return (
|
||||
<button
|
||||
type="button"
|
||||
key={id}
|
||||
data-slot="settings-nav-item"
|
||||
data-section={id}
|
||||
aria-current={active ? "location" : undefined}
|
||||
onClick={() => {
|
||||
setSearch("");
|
||||
setJumpTo(id);
|
||||
}}
|
||||
className={cn(
|
||||
"relative isolate shrink-0 rounded-md px-2 py-1 text-xs whitespace-nowrap transition-colors duration-150 focus-visible:outline-2 focus-visible:outline-ring",
|
||||
active
|
||||
? "text-accent-foreground"
|
||||
: "text-muted-foreground hover:text-foreground",
|
||||
)}
|
||||
>
|
||||
{active && (
|
||||
<motion.span
|
||||
aria-hidden="true"
|
||||
data-slot="settings-nav-indicator"
|
||||
layoutId={
|
||||
animateNav ? "settings-nav-indicator" : undefined
|
||||
}
|
||||
initial={false}
|
||||
transition={
|
||||
animateNav
|
||||
? MOTION_SPRING_POSITION
|
||||
: { duration: 0 }
|
||||
}
|
||||
className="absolute inset-0 -z-10 rounded-md bg-accent"
|
||||
/>
|
||||
)}
|
||||
{t(label)}
|
||||
</button>
|
||||
);
|
||||
})}
|
||||
</ScrollStrip>
|
||||
</nav>
|
||||
</div>
|
||||
|
||||
@@ -1015,6 +1049,7 @@ export function SettingsDialog({
|
||||
them was the dead-wheel-zone bug. */}
|
||||
<div
|
||||
ref={scrollerRef}
|
||||
data-slot="settings-scroller"
|
||||
onScroll={syncActiveSection}
|
||||
className={cn(
|
||||
"min-h-0 flex-1 overflow-y-auto",
|
||||
@@ -1637,6 +1672,154 @@ export function SettingsDialog({
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Privacy Section */}
|
||||
<div
|
||||
data-settings-section="privacy"
|
||||
tabIndex={-1}
|
||||
hidden={!sectionVisible("privacy")}
|
||||
className="scroll-mt-2 space-y-4 focus:outline-none"
|
||||
>
|
||||
<Label className="text-base font-medium">
|
||||
{t("settings.privacy.title")}
|
||||
</Label>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.requireRoute")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.requireRouteDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.requireRoute")}
|
||||
checked={settings.require_route_for_launch ?? false}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("require_route_for_launch", v === true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.consistencyWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.consistencyWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.consistencyWarning")}
|
||||
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("fingerprint_gate_disabled", v !== true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.vpnExtensionWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.vpnExtensionWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.vpnExtensionWarning")}
|
||||
checked={
|
||||
!(settings.vpn_extension_warning_disabled ?? false)
|
||||
}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting(
|
||||
"vpn_extension_warning_disabled",
|
||||
v !== true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.recordSites")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.recordSitesDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.recordSites")}
|
||||
checked={settings.record_traffic_domains ?? true}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("record_traffic_domains", v === true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<Label
|
||||
htmlFor="trash-retention"
|
||||
className="text-sm font-medium"
|
||||
>
|
||||
{t("settings.trashRetention")}
|
||||
</Label>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.trashRetentionDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<Select
|
||||
value={String(
|
||||
settings.trash_retention_days ??
|
||||
DEFAULT_TRASH_RETENTION_DAYS,
|
||||
)}
|
||||
onValueChange={(value) => {
|
||||
updateSetting("trash_retention_days", Number(value));
|
||||
}}
|
||||
>
|
||||
<SelectTrigger
|
||||
id="trash-retention"
|
||||
className="w-40 shrink-0"
|
||||
>
|
||||
<SelectValue />
|
||||
</SelectTrigger>
|
||||
<SelectContent>
|
||||
{TRASH_RETENTION_DAYS.map((days) => (
|
||||
<SelectItem key={days} value={String(days)}>
|
||||
{days === 0
|
||||
? t("settings.trashRetentionOff")
|
||||
: t("settings.trashRetentionDays", {
|
||||
count: days,
|
||||
})}
|
||||
</SelectItem>
|
||||
))}
|
||||
</SelectContent>
|
||||
</Select>
|
||||
</div>
|
||||
|
||||
<div>
|
||||
<LoadingButton
|
||||
isLoading={isClearingTraffic}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleClearTraffic().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("settings.privacy.clearTraffic")}
|
||||
</LoadingButton>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("settings.privacy.clearTrafficDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* Advanced Section */}
|
||||
<div
|
||||
data-settings-section="advanced"
|
||||
@@ -1750,65 +1933,6 @@ export function SettingsDialog({
|
||||
<p className="text-xs text-muted-foreground">
|
||||
{t("settings.advanced.copyLogsDescription")}
|
||||
</p>
|
||||
|
||||
<div className="flex items-center justify-between gap-3 border-t pt-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.consistencyWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.consistencyWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.consistencyWarning")}
|
||||
checked={!(settings.fingerprint_gate_disabled ?? false)}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting("fingerprint_gate_disabled", v !== true);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between gap-x-3 rounded-lg border p-3">
|
||||
<div className="min-w-0 flex-1">
|
||||
<span className="text-sm font-medium">
|
||||
{t("settings.privacy.vpnExtensionWarning")}
|
||||
</span>
|
||||
<span className="block text-xs text-muted-foreground">
|
||||
{t("settings.privacy.vpnExtensionWarningDescription")}
|
||||
</span>
|
||||
</div>
|
||||
<AnimatedSwitch
|
||||
aria-label={t("settings.privacy.vpnExtensionWarning")}
|
||||
checked={
|
||||
!(settings.vpn_extension_warning_disabled ?? false)
|
||||
}
|
||||
onCheckedChange={(v) => {
|
||||
updateSetting(
|
||||
"vpn_extension_warning_disabled",
|
||||
v !== true,
|
||||
);
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="border-t pt-3">
|
||||
<LoadingButton
|
||||
isLoading={isClearingTraffic}
|
||||
variant="outline"
|
||||
className="w-full"
|
||||
onClick={() => {
|
||||
handleClearTraffic().catch((err: unknown) => {
|
||||
console.error(err);
|
||||
});
|
||||
}}
|
||||
>
|
||||
{t("settings.privacy.clearTraffic")}
|
||||
</LoadingButton>
|
||||
<p className="mt-2 text-xs text-muted-foreground">
|
||||
{t("settings.privacy.clearTrafficDescription")}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{matchingSections.length === 0 && (
|
||||
|
||||
@@ -0,0 +1,257 @@
|
||||
"use client";
|
||||
|
||||
import { useReducedMotion } from "motion/react";
|
||||
import {
|
||||
type HTMLAttributes,
|
||||
type PointerEvent,
|
||||
type ReactNode,
|
||||
useCallback,
|
||||
useEffect,
|
||||
useRef,
|
||||
useState,
|
||||
} from "react";
|
||||
import { LuChevronLeft, LuChevronRight } from "react-icons/lu";
|
||||
import { cn } from "@/lib/utils";
|
||||
|
||||
/** How far a pressed pointer travels before a press becomes a drag. */
|
||||
const DRAG_THRESHOLD_PX = 4;
|
||||
/** Room kept between the active item and a faded edge. */
|
||||
const EDGE_ROOM_PX = 28;
|
||||
/** Clears a 2px focus outline: a row that scrolls sideways also clips
|
||||
* vertically, so a badge's focus ring would be cut at every edge. */
|
||||
const RING_ROOM_PX = 2;
|
||||
|
||||
interface ScrollStripProps extends HTMLAttributes<HTMLDivElement> {
|
||||
children: ReactNode;
|
||||
/** Changes whenever the item marked `aria-current` changes. */
|
||||
activeKey?: string | null;
|
||||
scrollLeftLabel: string;
|
||||
scrollRightLabel: string;
|
||||
/** Classes for the scrolling row itself; `className` styles the frame. */
|
||||
stripClassName?: string;
|
||||
/** Scroll without animation, e.g. while something is dragged over it. */
|
||||
instant?: boolean;
|
||||
}
|
||||
|
||||
/**
|
||||
* One row of badges that never wraps. When the badges do not fit, the row
|
||||
* fades out at the side that has more, shows a chevron there, and scrolls
|
||||
* with the wheel, a drag or the chevrons. The badge marked `aria-current` is
|
||||
* kept in view whenever `activeKey` changes.
|
||||
*/
|
||||
export function ScrollStrip({
|
||||
children,
|
||||
activeKey,
|
||||
scrollLeftLabel,
|
||||
scrollRightLabel,
|
||||
className,
|
||||
stripClassName,
|
||||
instant = false,
|
||||
onPointerDown,
|
||||
onClickCapture,
|
||||
...rest
|
||||
}: ScrollStripProps) {
|
||||
const ref = useRef<HTMLDivElement | null>(null);
|
||||
const reducedMotion = useReducedMotion();
|
||||
const [fadeLeft, setFadeLeft] = useState(false);
|
||||
const [fadeRight, setFadeRight] = useState(false);
|
||||
const [dragging, setDragging] = useState(false);
|
||||
const dragRef = useRef<{
|
||||
pointerId: number;
|
||||
startX: number;
|
||||
startLeft: number;
|
||||
moved: boolean;
|
||||
} | null>(null);
|
||||
const suppressClickRef = useRef(false);
|
||||
const behavior: ScrollBehavior =
|
||||
instant || reducedMotion ? "instant" : "smooth";
|
||||
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el) return;
|
||||
const update = () => {
|
||||
setFadeLeft(el.scrollLeft > 1);
|
||||
setFadeRight(el.scrollWidth - el.clientWidth - el.scrollLeft > 1);
|
||||
};
|
||||
update();
|
||||
el.addEventListener("scroll", update, { passive: true });
|
||||
const resize = new ResizeObserver(update);
|
||||
resize.observe(el);
|
||||
const mutations = new MutationObserver(update);
|
||||
mutations.observe(el, {
|
||||
childList: true,
|
||||
subtree: true,
|
||||
characterData: true,
|
||||
});
|
||||
|
||||
// A vertical wheel has nowhere to go in a single row, so turn it
|
||||
// sideways. At either end the event is left alone.
|
||||
const onWheel = (event: WheelEvent) => {
|
||||
if (event.ctrlKey) return;
|
||||
if (Math.abs(event.deltaY) <= Math.abs(event.deltaX)) return;
|
||||
const room = el.scrollWidth - el.clientWidth;
|
||||
if (room <= 1) return;
|
||||
const delta = event.deltaMode === 1 ? event.deltaY * 16 : event.deltaY;
|
||||
const next = Math.min(room, Math.max(0, el.scrollLeft + delta));
|
||||
if (next === el.scrollLeft) return;
|
||||
event.preventDefault();
|
||||
el.scrollLeft = next;
|
||||
};
|
||||
el.addEventListener("wheel", onWheel, { passive: false });
|
||||
|
||||
return () => {
|
||||
el.removeEventListener("scroll", update);
|
||||
el.removeEventListener("wheel", onWheel);
|
||||
resize.disconnect();
|
||||
mutations.disconnect();
|
||||
};
|
||||
}, []);
|
||||
|
||||
// biome-ignore lint/correctness/useExhaustiveDependencies: activeKey is the trigger; the active badge is read from the DOM
|
||||
useEffect(() => {
|
||||
const el = ref.current;
|
||||
if (!el || dragRef.current?.moved) return;
|
||||
const item = el.querySelector<HTMLElement>(
|
||||
'[aria-current]:not([aria-current="false"])',
|
||||
);
|
||||
if (!item) return;
|
||||
const start = item.offsetLeft;
|
||||
const end = start + item.offsetWidth;
|
||||
let target: number | null = null;
|
||||
if (start < el.scrollLeft + EDGE_ROOM_PX) {
|
||||
target = start - EDGE_ROOM_PX;
|
||||
} else if (end > el.scrollLeft + el.clientWidth - EDGE_ROOM_PX) {
|
||||
target = end - el.clientWidth + EDGE_ROOM_PX;
|
||||
}
|
||||
if (target !== null) {
|
||||
el.scrollTo({ left: Math.max(0, target), behavior });
|
||||
}
|
||||
}, [activeKey]);
|
||||
|
||||
const page = useCallback(
|
||||
(direction: -1 | 1) => {
|
||||
const el = ref.current;
|
||||
if (el) {
|
||||
el.scrollBy({ left: direction * el.clientWidth * 0.6, behavior });
|
||||
}
|
||||
},
|
||||
[behavior],
|
||||
);
|
||||
|
||||
const endDrag = (event: PointerEvent<HTMLDivElement>) => {
|
||||
const drag = dragRef.current;
|
||||
if (!drag || drag.pointerId !== event.pointerId) return;
|
||||
if (drag.moved) {
|
||||
// The click that ends a drag arrives in this same task. If none comes,
|
||||
// the next real click must not be swallowed.
|
||||
suppressClickRef.current = true;
|
||||
window.setTimeout(() => {
|
||||
suppressClickRef.current = false;
|
||||
}, 0);
|
||||
if (event.currentTarget.hasPointerCapture(event.pointerId)) {
|
||||
event.currentTarget.releasePointerCapture(event.pointerId);
|
||||
}
|
||||
}
|
||||
dragRef.current = null;
|
||||
setDragging(false);
|
||||
};
|
||||
|
||||
const overflowing = fadeLeft || fadeRight;
|
||||
const chevron =
|
||||
"absolute top-1/2 z-10 grid size-6 -translate-y-1/2 place-items-center rounded-sm text-muted-foreground transition-colors duration-100 hover:text-foreground focus-visible:outline-2 focus-visible:outline-ring";
|
||||
|
||||
return (
|
||||
<div className={cn("relative flex min-w-0 items-center", className)}>
|
||||
{fadeLeft && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={scrollLeftLabel}
|
||||
onClick={() => page(-1)}
|
||||
className={cn(chevron, "left-0")}
|
||||
>
|
||||
<LuChevronLeft className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
<div
|
||||
ref={ref}
|
||||
data-fade-left={fadeLeft ? "true" : "false"}
|
||||
data-fade-right={fadeRight ? "true" : "false"}
|
||||
{...rest}
|
||||
onPointerDown={(event) => {
|
||||
onPointerDown?.(event);
|
||||
const el = ref.current;
|
||||
if (
|
||||
!el ||
|
||||
event.pointerType !== "mouse" ||
|
||||
event.button !== 0 ||
|
||||
el.scrollWidth - el.clientWidth <= 1
|
||||
) {
|
||||
return;
|
||||
}
|
||||
// The strip owns this press. Without this the title bar under it
|
||||
// would start moving the window on a long press.
|
||||
event.stopPropagation();
|
||||
suppressClickRef.current = false;
|
||||
dragRef.current = {
|
||||
pointerId: event.pointerId,
|
||||
startX: event.clientX,
|
||||
startLeft: el.scrollLeft,
|
||||
moved: false,
|
||||
};
|
||||
}}
|
||||
onPointerMove={(event) => {
|
||||
const drag = dragRef.current;
|
||||
const el = ref.current;
|
||||
if (!drag || !el || drag.pointerId !== event.pointerId) return;
|
||||
const dx = event.clientX - drag.startX;
|
||||
if (!drag.moved) {
|
||||
if (Math.abs(dx) < DRAG_THRESHOLD_PX) return;
|
||||
drag.moved = true;
|
||||
// Keep receiving moves when the pointer leaves the strip. A
|
||||
// pointer the browser no longer tracks cannot be captured, and
|
||||
// the drag still works without it while the pointer stays over.
|
||||
try {
|
||||
el.setPointerCapture(event.pointerId);
|
||||
} catch {}
|
||||
setDragging(true);
|
||||
}
|
||||
el.scrollLeft = drag.startLeft - dx;
|
||||
}}
|
||||
onPointerUp={endDrag}
|
||||
onPointerCancel={endDrag}
|
||||
onClickCapture={(event) => {
|
||||
// The press that ended a drag is not a click on the badge under it.
|
||||
if (suppressClickRef.current) {
|
||||
suppressClickRef.current = false;
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
return;
|
||||
}
|
||||
onClickCapture?.(event);
|
||||
}}
|
||||
className={cn(
|
||||
"scroll-fade-x relative flex scrollbar-none items-center overflow-x-auto py-0.5 [-ms-overflow-style:none] [&::-webkit-scrollbar]:hidden",
|
||||
overflowing && "cursor-grab",
|
||||
dragging && "cursor-grabbing select-none",
|
||||
stripClassName,
|
||||
)}
|
||||
style={{
|
||||
paddingLeft: fadeLeft ? 22 : RING_ROOM_PX,
|
||||
paddingRight: fadeRight ? 22 : RING_ROOM_PX,
|
||||
}}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
{fadeRight && (
|
||||
<button
|
||||
type="button"
|
||||
aria-label={scrollRightLabel}
|
||||
onClick={() => page(1)}
|
||||
className={cn(chevron, "right-0")}
|
||||
>
|
||||
<LuChevronRight className="size-3.5" />
|
||||
</button>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
Reference in new issue
Block a user