mirror of
https://github.com/zhom/donutbrowser.git
synced 2026-08-12 06:00:23 +02:00
refactor: ui refresh
This commit is contained in:
@@ -44,6 +44,13 @@ export type BackendErrorCode =
|
||||
| "BROWSER_NOT_DOWNLOADED"
|
||||
| "ARCHIVE_EXTRACTION_FAILED"
|
||||
| "UNSUPPORTED_ARCHIVE_FORMAT"
|
||||
| "CLEAR_ON_CLOSE_UNAVAILABLE"
|
||||
| "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE"
|
||||
| "FINGERPRINT_MATCH_FAILED"
|
||||
| "INVALID_DNS_RULES_JSON"
|
||||
| "UNSUPPORTED_DNS_RULES_FORMAT"
|
||||
| "DNS_RULES_SAVE_FAILED"
|
||||
| "DNS_RULES_EXPORT_FAILED"
|
||||
| "INTERNAL_ERROR";
|
||||
|
||||
export interface BackendError {
|
||||
@@ -181,6 +188,22 @@ export function translateBackendError(t: TFunction, err: unknown): string {
|
||||
});
|
||||
case "UNSUPPORTED_ARCHIVE_FORMAT":
|
||||
return t("backendErrors.unsupportedArchiveFormat");
|
||||
case "PROXY_AND_VPN_MUTUALLY_EXCLUSIVE":
|
||||
return t("backendErrors.proxyAndVpnMutuallyExclusive");
|
||||
case "FINGERPRINT_MATCH_FAILED":
|
||||
return t("backendErrors.fingerprintMatchFailed");
|
||||
case "INVALID_DNS_RULES_JSON":
|
||||
return t("backendErrors.invalidDnsRulesJson");
|
||||
case "UNSUPPORTED_DNS_RULES_FORMAT":
|
||||
return t("backendErrors.unsupportedDnsRulesFormat", {
|
||||
format: parsed.params?.format ?? "",
|
||||
});
|
||||
case "DNS_RULES_SAVE_FAILED":
|
||||
return t("backendErrors.dnsRulesSaveFailed");
|
||||
case "DNS_RULES_EXPORT_FAILED":
|
||||
return t("backendErrors.dnsRulesExportFailed");
|
||||
case "CLEAR_ON_CLOSE_UNAVAILABLE":
|
||||
return t("backendErrors.clearOnCloseUnavailable");
|
||||
case "INTERNAL_ERROR":
|
||||
return t("backendErrors.internal", {
|
||||
detail: parsed.params?.detail ?? "",
|
||||
|
||||
@@ -0,0 +1,45 @@
|
||||
import confetti from "canvas-confetti";
|
||||
|
||||
/**
|
||||
* Donut-sprinkle confetti: small rounded bars tinted with the active theme's
|
||||
* chart colors. Used for celebration moments (e.g. a successful profile
|
||||
* import). Callers must skip it under prefers-reduced-motion.
|
||||
*/
|
||||
|
||||
// A 12×6 capsule — reads as a donut sprinkle at small scale.
|
||||
const SPRINKLE_PATH = "M3 0 h6 a3 3 0 0 1 0 6 h-6 a3 3 0 0 1 0 -6 z";
|
||||
|
||||
function themeChartColors(): string[] {
|
||||
const styles = getComputedStyle(document.documentElement);
|
||||
const colors = [1, 2, 3, 4, 5]
|
||||
.map((i) => styles.getPropertyValue(`--chart-${i}`).trim())
|
||||
.filter(Boolean);
|
||||
return colors.length > 0 ? colors : ["#888888"];
|
||||
}
|
||||
|
||||
export function fireSprinkleConfetti(): void {
|
||||
const sprinkle = confetti.shapeFromPath({ path: SPRINKLE_PATH });
|
||||
const colors = themeChartColors();
|
||||
|
||||
const fire = (particleCount: number, opts: confetti.Options = {}) => {
|
||||
void confetti({
|
||||
particleCount,
|
||||
spread: 75,
|
||||
startVelocity: 42,
|
||||
scalar: 0.9,
|
||||
ticks: 130,
|
||||
shapes: [sprinkle],
|
||||
colors,
|
||||
origin: { y: 0.65 },
|
||||
...opts,
|
||||
});
|
||||
};
|
||||
|
||||
fire(70);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 60, origin: { x: 0.2, y: 0.7 } });
|
||||
}, 180);
|
||||
window.setTimeout(() => {
|
||||
fire(45, { angle: 120, origin: { x: 0.8, y: 0.7 } });
|
||||
}, 360);
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
/**
|
||||
* The DNS blocklist levels the backend accepts, mirroring
|
||||
* `BlocklistLevel::as_str` in `src-tauri/src/dns_blocklist.rs`. Ordered from
|
||||
* least to most restrictive, with `custom` (the user's own sources/rules) last.
|
||||
*
|
||||
* Every level picker reads from this list. Keeping it in one place is what
|
||||
* stops a new level from reaching some surfaces and not others — `custom` was
|
||||
* previously missing from two pickers, so a profile already set to it rendered
|
||||
* with nothing selected and could not be restored.
|
||||
*
|
||||
* Labels are translation keys, never the backend's `display_name`: that field
|
||||
* is hardcoded English and renders untranslated to every locale.
|
||||
*/
|
||||
export const DNS_BLOCKLIST_LEVELS = [
|
||||
{ value: "light", labelKey: "dnsBlocklist.light" },
|
||||
{ value: "normal", labelKey: "dnsBlocklist.normal" },
|
||||
{ value: "pro", labelKey: "dnsBlocklist.pro" },
|
||||
{ value: "pro_plus", labelKey: "dnsBlocklist.proPlus" },
|
||||
{ value: "ultimate", labelKey: "dnsBlocklist.ultimate" },
|
||||
{ value: "custom", labelKey: "dnsBlocklist.customLevel" },
|
||||
] as const;
|
||||
|
||||
export type DnsBlocklistLevel = (typeof DNS_BLOCKLIST_LEVELS)[number]["value"];
|
||||
|
||||
/**
|
||||
* Translation key for a level slug. A null/empty level means no filtering, and
|
||||
* an unrecognised one (a level added backend-first) falls back to the same,
|
||||
* which is the honest reading of "we don't know this level".
|
||||
*/
|
||||
export function dnsBlocklistLabelKey(level: string | null | undefined): string {
|
||||
if (!level) {
|
||||
return "dnsBlocklist.none";
|
||||
}
|
||||
return (
|
||||
DNS_BLOCKLIST_LEVELS.find((l) => l.value === level)?.labelKey ??
|
||||
"dnsBlocklist.none"
|
||||
);
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
/**
|
||||
* Shared physics for the Donut logo easter eggs: clones an element into a
|
||||
* fixed-position layer, drops it with gravity, bounces it off the floor and
|
||||
* right wall, and lets the user grab it (1:1, respecting the grab offset) and
|
||||
* throw it — the sim continues at the pointer's release velocity. Used by the
|
||||
* rail logo (5-click trigger) and the About dialog flywheel escape.
|
||||
*/
|
||||
|
||||
const GRAVITY = 2200;
|
||||
const BOUNCE_DAMPING = 0.6;
|
||||
const DEFAULT_HORIZONTAL_SPEED = 350;
|
||||
const DEFAULT_SPIN_SPEED = 720;
|
||||
const MIN_BOUNCE_VELOCITY = 60;
|
||||
|
||||
export interface DonutLaunchOptions {
|
||||
/** Initial horizontal velocity in px/s. Defaults to a rightward roll. */
|
||||
initialVX?: number;
|
||||
/** Initial vertical velocity in px/s (negative = upward). */
|
||||
initialVY?: number;
|
||||
/** Spin speed in deg/s. */
|
||||
spinSpeed?: number;
|
||||
/** Called once the clone has left the screen and been removed. */
|
||||
onExit?: () => void;
|
||||
}
|
||||
|
||||
/**
|
||||
* Launch a physics clone of `el`. The source element is hidden (visibility)
|
||||
* and stays hidden — the caller decides when/if to restore it. Returns a
|
||||
* cancel function that removes the clone and stops the sim.
|
||||
*/
|
||||
export function launchDonutClone(
|
||||
el: HTMLElement,
|
||||
options: DonutLaunchOptions = {},
|
||||
): () => void {
|
||||
// getBoundingClientRect measures the *transformed* box. A caller that rotates
|
||||
// the element before launching (the About dialog spins it up to escape
|
||||
// velocity) would otherwise hand us the rotated bounding box: ~37% too large
|
||||
// at 45°, with an offset origin — so the clone jumps at launch and bounces off
|
||||
// the floor and walls early. Suppress the transform for the measurement to get
|
||||
// the layout box, then put it back.
|
||||
const previousTransform = el.style.transform;
|
||||
if (previousTransform) {
|
||||
el.style.transform = "none";
|
||||
}
|
||||
const rect = el.getBoundingClientRect();
|
||||
if (previousTransform) {
|
||||
el.style.transform = previousTransform;
|
||||
}
|
||||
const startX = rect.left;
|
||||
const startY = rect.top;
|
||||
|
||||
const clone = el.cloneNode(true) as HTMLElement;
|
||||
clone.style.position = "fixed";
|
||||
clone.style.left = `${startX}px`;
|
||||
clone.style.top = `${startY}px`;
|
||||
clone.style.zIndex = "9999";
|
||||
clone.style.margin = "0";
|
||||
// The fallen donut is a toy: it can be grabbed 1:1 and thrown, inheriting
|
||||
// the pointer's release velocity.
|
||||
clone.style.pointerEvents = "auto";
|
||||
clone.style.cursor = "grab";
|
||||
clone.style.touchAction = "none";
|
||||
document.body.appendChild(clone);
|
||||
el.style.visibility = "hidden";
|
||||
|
||||
let x = 0;
|
||||
let y = 0;
|
||||
let vy = options.initialVY ?? -500;
|
||||
// Roll right first, bounce off the right wall, then escape the left.
|
||||
let vx = options.initialVX ?? DEFAULT_HORIZONTAL_SPEED;
|
||||
const spinSpeed = options.spinSpeed ?? DEFAULT_SPIN_SPEED;
|
||||
let rotation = 0;
|
||||
let lastTime = performance.now();
|
||||
let grabbed = false;
|
||||
let grabDX = 0;
|
||||
let grabDY = 0;
|
||||
let animFrame = 0;
|
||||
let cancelled = false;
|
||||
// Recent pointer positions (≤100ms) for release-velocity estimation.
|
||||
let history: { t: number; x: number; y: number }[] = [];
|
||||
|
||||
// Progressive resistance past a window edge — follows less the further out.
|
||||
const rubberband = (overshoot: number, dimension: number, c = 0.55) =>
|
||||
(overshoot * dimension * c) / (dimension + c * Math.abs(overshoot));
|
||||
|
||||
const applyTransform = () => {
|
||||
clone.style.transform = `translate(${x}px, ${y}px) rotate(${rotation}deg)`;
|
||||
};
|
||||
|
||||
const onPointerDown = (e: PointerEvent) => {
|
||||
if (grabbed) return;
|
||||
grabbed = true;
|
||||
clone.setPointerCapture(e.pointerId);
|
||||
clone.style.cursor = "grabbing";
|
||||
// Respect where the donut was grabbed — no snap to center.
|
||||
grabDX = e.clientX - (startX + x);
|
||||
grabDY = e.clientY - (startY + y);
|
||||
vx = 0;
|
||||
vy = 0;
|
||||
history = [{ t: performance.now(), x, y }];
|
||||
};
|
||||
|
||||
const onPointerMove = (e: PointerEvent) => {
|
||||
if (!grabbed) return;
|
||||
let nx = e.clientX - grabDX - startX;
|
||||
let ny = e.clientY - grabDY - startY;
|
||||
const minX = -startX;
|
||||
const maxX = window.innerWidth - rect.width - startX;
|
||||
const minY = -startY;
|
||||
const maxY = window.innerHeight - rect.height - startY;
|
||||
if (nx > maxX) nx = maxX + rubberband(nx - maxX, rect.width);
|
||||
if (nx < minX) nx = minX + rubberband(nx - minX, rect.width);
|
||||
if (ny > maxY) ny = maxY + rubberband(ny - maxY, rect.height);
|
||||
if (ny < minY) ny = minY + rubberband(ny - minY, rect.height);
|
||||
x = nx;
|
||||
y = ny;
|
||||
const now = performance.now();
|
||||
history.push({ t: now, x, y });
|
||||
while (history.length > 1 && now - history[0].t > 100) history.shift();
|
||||
applyTransform();
|
||||
};
|
||||
|
||||
const onPointerUp = (e: PointerEvent) => {
|
||||
if (!grabbed) return;
|
||||
grabbed = false;
|
||||
clone.style.cursor = "grab";
|
||||
try {
|
||||
clone.releasePointerCapture(e.pointerId);
|
||||
} catch {
|
||||
// capture already gone
|
||||
}
|
||||
// Velocity handoff: the sim continues at the finger's speed so a flick
|
||||
// throws the donut instead of dropping it.
|
||||
const now = performance.now();
|
||||
const oldest = history[0];
|
||||
const dt = oldest ? (now - oldest.t) / 1000 : 0;
|
||||
if (oldest && dt > 0.016) {
|
||||
vx = (x - oldest.x) / dt;
|
||||
vy = (y - oldest.y) / dt;
|
||||
}
|
||||
history = [];
|
||||
lastTime = now;
|
||||
};
|
||||
|
||||
clone.addEventListener("pointerdown", onPointerDown);
|
||||
clone.addEventListener("pointermove", onPointerMove);
|
||||
clone.addEventListener("pointerup", onPointerUp);
|
||||
clone.addEventListener("pointercancel", onPointerUp);
|
||||
|
||||
const animate = (time: number) => {
|
||||
if (cancelled) return;
|
||||
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;
|
||||
|
||||
if (!grabbed) {
|
||||
vy += GRAVITY * dt;
|
||||
x += vx * dt;
|
||||
y += vy * dt;
|
||||
rotation += spinSpeed * dt * (vx > 0 ? 1 : -1);
|
||||
|
||||
const currentBottom = startY + y + rect.height;
|
||||
if (currentBottom >= floorY && vy > 0) {
|
||||
y = floorY - startY - rect.height;
|
||||
vy =
|
||||
Math.abs(vy) > MIN_BOUNCE_VELOCITY
|
||||
? -Math.abs(vy) * BOUNCE_DAMPING
|
||||
: -MIN_BOUNCE_VELOCITY * 3;
|
||||
// A donut dropped with no sideways speed would hop in place forever —
|
||||
// nudge it toward its left-edge exit.
|
||||
if (Math.abs(vx) < 40) {
|
||||
vx = -DEFAULT_HORIZONTAL_SPEED * 0.5;
|
||||
}
|
||||
}
|
||||
|
||||
// Right-wall bounce: hit, reverse horizontal velocity (with a tiny
|
||||
// damping), and keep rolling. Left wall has no bounce — the donut
|
||||
// exits the window off the left edge.
|
||||
const currentRight = startX + x + rect.width;
|
||||
if (currentRight >= rightWall && vx > 0) {
|
||||
x = rightWall - startX - rect.width;
|
||||
vx = -Math.abs(vx) * 0.9;
|
||||
}
|
||||
|
||||
applyTransform();
|
||||
}
|
||||
|
||||
const offScreenLeft = startX + x + rect.width < -200;
|
||||
const offScreenBottom = startY + y > floorY + 100;
|
||||
const offScreenTop = startY + y + rect.height < -200;
|
||||
|
||||
if (!grabbed && (offScreenLeft || offScreenBottom || offScreenTop)) {
|
||||
clone.remove();
|
||||
options.onExit?.();
|
||||
return;
|
||||
}
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
};
|
||||
animFrame = requestAnimationFrame(animate);
|
||||
|
||||
return () => {
|
||||
cancelled = true;
|
||||
cancelAnimationFrame(animFrame);
|
||||
clone.remove();
|
||||
};
|
||||
}
|
||||
@@ -98,11 +98,14 @@ export const SHORTCUTS: ShortcutDef[] = [
|
||||
mod: true,
|
||||
},
|
||||
{
|
||||
// Mod+Shift+A (not Mod+A): plain Mod+A must stay select-all in any
|
||||
// focused text field or table context.
|
||||
id: "goAccount",
|
||||
labelKey: "shortcuts.goAccount",
|
||||
group: "navigation",
|
||||
key: "a",
|
||||
mod: true,
|
||||
shift: true,
|
||||
},
|
||||
{
|
||||
id: "goSettings",
|
||||
|
||||
@@ -760,3 +760,27 @@ export function clearThemeColors(): void {
|
||||
root.style.removeProperty(key as string);
|
||||
});
|
||||
}
|
||||
|
||||
/**
|
||||
* Run a theme mutation inside a View Transition so the whole UI cross-fades
|
||||
* (~200ms, tuned in globals.css) instead of hard-cutting between palettes.
|
||||
* Falls back to an instant switch when the API is unavailable or the user
|
||||
* prefers reduced motion.
|
||||
*/
|
||||
export function withThemeTransition(mutate: () => void): void {
|
||||
if (typeof document === "undefined") {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
const reduced =
|
||||
typeof window !== "undefined" &&
|
||||
window.matchMedia("(prefers-reduced-motion: reduce)").matches;
|
||||
const doc = document as Document & {
|
||||
startViewTransition?: (callback: () => void) => unknown;
|
||||
};
|
||||
if (reduced || typeof doc.startViewTransition !== "function") {
|
||||
mutate();
|
||||
return;
|
||||
}
|
||||
doc.startViewTransition(mutate);
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user