refactor: cleanup

This commit is contained in:
zhom
2026-08-06 14:39:53 -07:00
parent 39bbdcb547
commit f12a84e18f
49 changed files with 4555 additions and 377 deletions
File diff suppressed because it is too large Load Diff
+110
View File
@@ -11,9 +11,11 @@ import {
TooltipContent,
TooltipTrigger,
} from "@/components/ui/tooltip";
import { parseBackendError, translateBackendError } from "@/lib/backend-errors";
import type {
CookieBotRun,
CookieBotSchedule,
CookieBotSlot,
RemoteHoursQuota,
} from "@/lib/cookie-bot";
import { MOTION_EASE_OUT } from "@/lib/motion";
@@ -66,6 +68,84 @@ export function nightsPerWeek(mask: number): number {
return count;
}
/* -------------------------------------------------------------------------- */
/* Slots */
/* -------------------------------------------------------------------------- */
/**
* Every time-of-day an enrolment fires, from whichever shape the server sent.
*
* ALWAYS at least one slot. A server that predates multi-slot scheduling sends
* only the mirrored `run_at_minute` / `days_mask` pair, and a renderer that read
* `slots` directly would show an enrolment as firing at no time at all. Reading
* the wire through here is what keeps that fallback in one place.
*/
export function scheduleSlots(schedule: {
slots?: CookieBotSlot[];
run_at_minute: number;
days_mask: number;
}): CookieBotSlot[] {
const slots = schedule.slots ?? [];
if (slots.length > 0) return slots;
return [
{ days_mask: schedule.days_mask, run_at_minute: schedule.run_at_minute },
];
}
/**
* How many times a week a whole calendar fires.
*
* Counted across slots, not read off the first one: an enrolment with three
* slots costs three times the hours, and the budget estimate beside it is the
* only place a user sees that before committing.
*
* DISTINCT (weekday, minute) pairs rather than a sum of night counts, because
* two slots landing on the same weekday at the same minute are the same
* instant and the server dispatches them as ONE run — `upcomingSlotsMulti`
* collapses coincident fires. Summing quoted a Mon+Tue and a Tue+Wed slot at
* 02:00 as four nights when it is three, and that inflated figure is what the
* over-budget warning is compared against.
*/
export function weeklyRuns(
slots: { days_mask: number; run_at_minute: number }[],
): number {
const fires = new Set<number>();
for (const slot of slots) {
for (let bit = 0; bit < 7; bit += 1) {
if ((slot.days_mask & (1 << bit)) !== 0) {
fires.add(bit * 1440 + slot.run_at_minute);
}
}
}
return fires.size;
}
/**
* Monday-first weekday names, from the viewer's own locale.
*
* Derived rather than translated into ten locale files: `narrow` already gives
* each language its own single-letter convention, and a hand-written table
* would be ten more places for Monday-first to be got wrong. The reference week
* is formatted in UTC so a user east of Greenwich does not see it shift by a
* day.
*/
export function weekdayNames(): { narrow: string; long: string }[] {
// 2024-01-01 was a Monday, which is bit 0 of the server's mask.
const monday = Date.UTC(2024, 0, 1);
const narrow = new Intl.DateTimeFormat(undefined, {
weekday: "narrow",
timeZone: "UTC",
});
const long = new Intl.DateTimeFormat(undefined, {
weekday: "long",
timeZone: "UTC",
});
return Array.from({ length: 7 }, (_, index) => {
const day = new Date(monday + index * 24 * 60 * 60 * 1000);
return { narrow: narrow.format(day), long: long.format(day) };
});
}
/** A human cadence label. Unknown masks fall back to the night count. */
export function describeCadence(t: TFunction, mask: number): string {
const id = cadenceForMask(mask);
@@ -409,6 +489,36 @@ export function outcomeLabel(
return key ? t(key) : t("cookieBot.outcome.unknown", { code });
}
/**
* The three refusals only the saved-list routes can raise.
*
* They are absent from the shared `backendErrors` table, so
* `translateBackendError` renders them through its unknown-code fallback: a
* user who reuses a name would be told "Something went wrong:
* COOKIE_BOT_TEMPLATE_NAME_TAKEN" instead of that the name is taken, in the one
* dialog where the fix is a single keystroke. Everything else — a signed-out
* desktop, an unreachable cloud — still goes through the shared translator.
*/
const TEMPLATE_ERROR_KEYS: Record<string, string> = {
COOKIE_BOT_TEMPLATE_NAME_TAKEN: "cookieBot.enrol.templateNameTaken",
COOKIE_BOT_INVALID_TEMPLATE_NAME: "cookieBot.enrol.templateNameInvalid",
COOKIE_BOT_TEMPLATE_NOT_FOUND: "cookieBot.enrol.templateMissing",
};
export function templateErrorMessage(t: TFunction, error: unknown): string {
const parsed = parseBackendError(error);
const key = parsed ? TEMPLATE_ERROR_KEYS[parsed.code] : undefined;
if (!key) return translateBackendError(t, error);
const max = parsed?.params?.max;
// The server does not always send a limit. Interpolating an empty string
// rendered "a name of characters or fewer", so fall back to wording that
// does not need the number.
if (key === "cookieBot.enrol.templateNameInvalid" && !max) {
return t("cookieBot.enrol.templateNameInvalidNoMax");
}
return t(key, { max });
}
/**
* The session state machine, named honestly. `provisioning -> ready -> live ->
* closed`, with `error` reachable from any of the first three, is what the
+24 -2
View File
@@ -5,6 +5,7 @@ import { useCallback, useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { GoPlus } from "react-icons/go";
import { LuChevronLeft, LuChevronRight, LuSearch, LuX } from "react-icons/lu";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS } from "@/lib/browser-utils";
import { cn } from "@/lib/utils";
import type { GroupWithCount } from "@/types";
@@ -59,8 +60,14 @@ const HomeHeader = ({
}, []);
const isMacOS = platform === "macos";
const isLinux = platform === "linux";
const showProfileToolbar = !pageTitle;
// Same hook the controls use, so the reserved space can never disagree with
// what is actually drawn.
const decorations = useWindowDecorations();
const linuxLayout = decorations.clientSide ? decorations.layout : null;
// Press-and-hold drag: any pixel of the sys-bar becomes a drag handle after
// HOLD_MS, but quick clicks still reach buttons/inputs underneath.
const holdTimeoutRef = useRef<number | null>(null);
@@ -179,14 +186,29 @@ const HomeHeader = ({
onPointerCancel={handlePointerEnd}
onDoubleClick={handleDoubleClick}
className={cn(
"flex h-11 items-center gap-2 border-b border-border bg-card pl-3 select-none",
"flex h-11 items-center gap-2 border-b border-border bg-card select-none",
// Windows: WindowDragArea renders three 44px native-style controls
// (minimize + maximize/restore + close) fixed at top-right with
// z-50, total 132px wide. Reserve 144px on the right edge so the
// "+ New" button and search input clear them with a few pixels of
// breathing room and never sit underneath the controls.
isWindows ? "pr-[144px]" : "pr-3",
isWindows ? "pl-3 pr-[144px]" : null,
// Linux reserves its space through the inline style below, because the
// desktop chooses which side the controls sit on and how many there
// are. Everything else keeps the plain symmetric padding.
!isWindows && !isLinux ? "pl-3 pr-3" : null,
)}
style={
isLinux
? {
// Each control is 44px wide; add the usual 12px gutter. Before
// the layout resolves, fall back to the gutter alone rather than
// to no padding, which would visibly shift the content.
paddingLeft: (linuxLayout?.left.length ?? 0) * 44 + 12,
paddingRight: (linuxLayout?.right.length ?? 0) * 44 + 12,
}
: undefined
}
>
{isMacOS && (
<div
+40 -4
View File
@@ -89,6 +89,12 @@ export function ProxyFormDialog({
const { t } = useTranslation();
const [isSubmitting, setIsSubmitting] = useState(false);
const [form, setForm] = useState<ProxyFormData>(DEFAULT_FORM);
// The local parse only covers scheme/host/port. Whether Donut can actually
// use the server — REALITY, XTLS Vision, plain TCP — is decided by the Rust
// parser, so ask it (below) and show the specific reason while the user is
// still editing rather than after they save. Declared here because
// `handleSubmit` guards on it.
const [vlessUnsupported, setVlessUnsupported] = useState<string | null>(null);
const resetForm = useCallback(() => {
setForm(DEFAULT_FORM);
@@ -134,6 +140,11 @@ export function ProxyFormDialog({
return;
}
if (isVless && vlessUnsupported) {
toast.error(vlessUnsupported);
return;
}
if (!isVless && (!form.host.trim() || !form.port)) {
toast.error(t("proxies.form.hostPortRequired"));
return;
@@ -183,7 +194,7 @@ export function ProxyFormDialog({
} finally {
setIsSubmitting(false);
}
}, [editingProxy, form, onClose, t]);
}, [editingProxy, form, onClose, t, vlessUnsupported]);
const handleClose = useCallback(() => {
if (!isSubmitting) {
@@ -193,12 +204,37 @@ export function ProxyFormDialog({
const isVless = form.proxy_type === "vless";
const vlessEndpoint = isVless ? parseVlessEndpoint(form.vless_uri) : null;
const trimmedVlessUri = form.vless_uri.trim();
useEffect(() => {
if (!isVless || trimmedVlessUri.length === 0) {
setVlessUnsupported(null);
return;
}
let cancelled = false;
const timer = window.setTimeout(() => {
void invoke("validate_vless_uri", { uri: trimmedVlessUri })
.then(() => {
if (!cancelled) setVlessUnsupported(null);
})
.catch((error: unknown) => {
if (!cancelled) setVlessUnsupported(translateBackendError(t, error));
});
}, 300);
return () => {
cancelled = true;
window.clearTimeout(timer);
};
}, [isVless, trimmedVlessUri, t]);
const hasInvalidVlessUri =
isVless && form.vless_uri.trim().length > 0 && !vlessEndpoint;
isVless &&
trimmedVlessUri.length > 0 &&
(!vlessEndpoint || vlessUnsupported !== null);
const isFormValid =
form.name.trim() &&
(isVless
? vlessEndpoint !== null
? vlessEndpoint !== null && vlessUnsupported === null
: form.host.trim() &&
form.port > 0 &&
form.port <= 65535 &&
@@ -286,7 +322,7 @@ export function ProxyFormDialog({
role={hasInvalidVlessUri ? "alert" : undefined}
>
{hasInvalidVlessUri
? t("proxies.form.vlessUriInvalid")
? (vlessUnsupported ?? t("proxies.form.vlessUriInvalid"))
: t("proxies.form.vlessUriHint")}
</p>
</div>
+165 -118
View File
@@ -3,19 +3,23 @@
import { getCurrentWindow } from "@tauri-apps/api/window";
import { useEffect, useState } from "react";
import { useTranslation } from "react-i18next";
import { useWindowDecorations } from "@/hooks/use-window-decorations";
import { getCurrentOS, type OperatingSystem } from "@/lib/platform";
import type { WindowControl } from "@/lib/window-decorations";
import { WindowResizeHandles } from "./window-resize-handles";
export function WindowDragArea() {
const { t } = useTranslation();
const [platform, setPlatform] = useState<OperatingSystem | null>(null);
const [isMaximized, setIsMaximized] = useState(false);
const decorations = useWindowDecorations();
useEffect(() => {
setPlatform(getCurrentOS());
}, []);
useEffect(() => {
if (platform !== "windows") return;
if (platform !== "windows" && platform !== "linux") return;
const win = getCurrentWindow();
let cancelled = false;
const sync = async () => {
@@ -38,42 +42,6 @@ export function WindowDragArea() {
};
}, [platform]);
const handlePointerDown = (e: React.PointerEvent) => {
if (e.button !== 0) return;
e.preventDefault();
e.stopPropagation();
const startDrag = async () => {
try {
const window = getCurrentWindow();
await window.startDragging();
} catch (error) {
console.error("Failed to start window dragging:", error);
}
};
void startDrag();
};
// Linux: system decorations handle everything
if (!platform || platform === "linux" || platform === "unknown") {
return null;
}
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return null;
}
// Windows: minimize/maximize/close controls anchored at the top-right
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles window
// dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
const handleMinimize = async () => {
try {
await getCurrentWindow().minimize();
@@ -97,93 +65,172 @@ export function WindowDragArea() {
console.error("Failed to close window:", error);
}
};
void handlePointerDown; // kept for backwards-compat; not used on Windows now
const renderControl = (control: WindowControl) => {
switch (control) {
case "minimize":
return (
<button
key="minimize"
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
);
case "maximize":
return (
<button
key="maximize"
type="button"
onClick={() => {
void handleToggleMaximize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={
isMaximized
? t("common.window.restore")
: t("common.window.maximize")
}
>
{isMaximized ? (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.restore")}
>
<rect x="1" y="3" width="6" height="6" />
<path d="M3 3 V1 H9 V7 H7" />
</svg>
) : (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.maximize")}
>
<rect x="1" y="1" width="8" height="8" />
</svg>
)}
</button>
);
case "close":
return (
<button
key="close"
type="button"
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.window.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
);
}
};
if (!platform || platform === "unknown") {
return null;
}
// macOS: nothing to render here. The transparent native titlebar (set via
// `set_transparent_titlebar(true)` in src-tauri/src/lib.rs) lets the OS
// handle dragging directly, and the sys-bar inside `home-header.tsx`
// declares its own `data-tauri-drag-region` overlay for the WebView area.
// The previous full-width fixed z-[999999] button was stealing every
// click in the top 40px of the window.
if (platform === "macos") {
return null;
}
// Linux: the window has no server-side decorations, so the app owns both the
// controls and the resize edges. Which buttons appear and on which side is a
// desktop-wide preference (GNOME's `button-layout`, KWin's decoration
// settings), read through GTK so both environments are honored.
if (platform === "linux") {
// Not resolved yet, or the session keeps server-side decorations (KDE on
// Wayland — see `use_client_side_decorations` in the backend). Either way
// there is a real titlebar and nothing for the app to draw.
if (!decorations.resolved || !decorations.clientSide) {
return null;
}
const { layout } = decorations;
return (
<>
{/* Dropping decorations also drops the compositor's drop shadow, and
neither Tauri nor tao exposes a Linux shadow API. Without some edge
the window is invisible against a similarly coloured desktop, so
draw a hairline. Not rounded: that needs a transparent window, which
would conflict with the WebView and the resize strips. */}
{!isMaximized && (
<div
aria-hidden="true"
className="pointer-events-none fixed inset-0 z-[99999] border border-border"
/>
)}
<WindowResizeHandles isMaximized={isMaximized} />
{layout.left.length > 0 && (
<div className="fixed top-0 left-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
{layout.left.map(renderControl)}
</div>
)}
{layout.right.length > 0 && (
<div className="fixed top-0 right-0 z-[100000] flex h-11 items-center select-none pointer-events-auto">
{layout.right.map(renderControl)}
</div>
)}
</>
);
}
// Windows: minimize/maximize/close controls anchored at the top-right
// corner of the sys-bar. The HomeHeader's own drag-region overlay handles
// window dragging via Tauri 2, so we don't need a separate draggable spacer
// covering the whole width.
return (
<div
className="fixed top-0 right-0 z-50 flex h-11 items-center select-none"
aria-hidden="false"
>
<button
type="button"
onClick={() => {
void handleMinimize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={t("common.window.minimize")}
>
<svg
width="10"
height="1"
viewBox="0 0 10 1"
fill="currentColor"
role="img"
aria-label={t("common.window.minimize")}
>
<rect width="10" height="1" />
</svg>
</button>
<button
type="button"
onClick={() => {
void handleToggleMaximize();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-muted/50 hover:text-foreground"
aria-label={
isMaximized ? t("common.window.restore") : t("common.window.maximize")
}
>
{isMaximized ? (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.restore")}
>
<rect x="1" y="3" width="6" height="6" />
<path d="M3 3 V1 H9 V7 H7" />
</svg>
) : (
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.window.maximize")}
>
<rect x="1" y="1" width="8" height="8" />
</svg>
)}
</button>
<button
type="button"
onClick={() => {
void handleClose();
}}
className="flex h-full w-11 items-center justify-center text-muted-foreground transition-colors hover:bg-destructive hover:text-destructive-foreground"
aria-label={t("common.buttons.close")}
>
<svg
width="10"
height="10"
viewBox="0 0 10 10"
fill="none"
stroke="currentColor"
strokeWidth="1.2"
role="img"
aria-label={t("common.buttons.close")}
>
<line x1="1" y1="1" x2="9" y2="9" />
<line x1="9" y1="1" x2="1" y2="9" />
</svg>
</button>
{(["minimize", "maximize", "close"] as WindowControl[]).map(
renderControl,
)}
</div>
);
}
+137
View File
@@ -0,0 +1,137 @@
"use client";
import { getCurrentWindow } from "@tauri-apps/api/window";
/**
* Mirrors the API's own `ResizeDirection`, which it declares but does not
* export. Structurally identical, so a drift would fail the call below.
*/
type ResizeDirection =
| "East"
| "North"
| "NorthEast"
| "NorthWest"
| "South"
| "SouthEast"
| "SouthWest"
| "West";
/**
* Mouse resize areas for a window with no server-side decorations.
*
* `gtk_window_set_decorated(false)` removes GTK's own invisible resize border
* along with the frame, so without these the window can only be resized through
* window-manager shortcuts (Super+right-drag and friends). Each handle hands the
* pointer to the compositor via `begin_resize_drag`, which is the same call
* GTK's client-side decorations make, so edge snapping and the resize cursor
* come from the WM exactly as they do for a native window.
*
* Rendered only where the app owns the frame; on macOS the native titlebar is
* still in place and the system draws its own resize edges.
*/
/**
* GTK's own grab area is far wider, but all of it sits *outside* the window in
* the shadow margin. Ours is inside, so every pixel is taken from real content.
*/
/** Top edge only — it overlaps the 44px-tall window controls. */
const TOP_EDGE = "6px";
/** Sides and bottom overlap nothing, so they can be comfortably grabbable. */
const EDGE = "8px";
/** Corners need to win over the edges that overlap them. */
const CORNER = "16px";
interface Handle {
direction: ResizeDirection;
style: React.CSSProperties;
cursor: string;
}
const HANDLES: Handle[] = [
// Edges.
{
direction: "North",
cursor: "ns-resize",
style: { top: 0, left: CORNER, right: CORNER, height: TOP_EDGE },
},
{
direction: "South",
cursor: "ns-resize",
style: { bottom: 0, left: CORNER, right: CORNER, height: EDGE },
},
{
direction: "West",
cursor: "ew-resize",
style: { left: 0, top: CORNER, bottom: CORNER, width: EDGE },
},
{
direction: "East",
cursor: "ew-resize",
style: { right: 0, top: CORNER, bottom: CORNER, width: EDGE },
},
// Corners, drawn after the edges so they sit on top of the overlap.
{
direction: "NorthWest",
cursor: "nwse-resize",
style: { top: 0, left: 0, width: CORNER, height: TOP_EDGE },
},
{
direction: "NorthEast",
cursor: "nesw-resize",
style: { top: 0, right: 0, width: CORNER, height: TOP_EDGE },
},
{
direction: "SouthWest",
cursor: "nesw-resize",
style: { bottom: 0, left: 0, width: CORNER, height: CORNER },
},
{
direction: "SouthEast",
cursor: "nwse-resize",
style: { bottom: 0, right: 0, width: CORNER, height: CORNER },
},
];
export function WindowResizeHandles({ isMaximized }: { isMaximized: boolean }) {
// A maximized window has no resizable edge, and leaving the strips live would
// put invisible hit areas over real content. Tauri's own built-in undecorated
// resizing disables itself while maximized for the same reason.
if (isMaximized) {
return null;
}
const startResize =
(direction: ResizeDirection) => (e: React.PointerEvent) => {
// Left button only: right-click belongs to the WM/window menu, and a
// middle-click drag should not resize.
if (e.button !== 0) {
return;
}
e.preventDefault();
e.stopPropagation();
void getCurrentWindow()
.startResizeDragging(direction)
.catch((error: unknown) => {
console.error("Failed to start window resize:", error);
});
};
return (
<>
{HANDLES.map((handle) => (
<div
key={handle.direction}
// Deliberately BELOW the window controls (z-100000) rather than
// above: a real CSD window keeps its resize border outside the
// buttons, but ours is inside the window, so layering it on top
// would steal the corner of whichever control sits in that corner.
// Edges still win over ordinary content, which is all they need.
className="fixed z-[99998] pointer-events-auto"
style={{ ...handle.style, cursor: handle.cursor }}
onPointerDown={startResize(handle.direction)}
aria-hidden="true"
/>
))}
</>
);
}