Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
11,191 changes: 5,606 additions & 5,585 deletions src/embedded_web_data.h

Large diffs are not rendered by default.

7 changes: 5 additions & 2 deletions web-ui/src/components/status/connections-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -4,6 +4,7 @@ import { formatBandwidth, formatBytes, formatDuration } from "../../lib/format";
import type { Locale } from "../../lib/locale";
import { stateToLabel, stateToVariant } from "../../lib/status";
import type { ClientRow } from "../../types";
import type { BandwidthUnit } from "../../types/ui";
import { Badge } from "../ui/badge";
import { Button } from "../ui/button";
import { Card, CardContent } from "../ui/card";
Expand All @@ -18,6 +19,7 @@ interface ConnectionsSectionProps {
onShowDisconnectedChange: (checked: boolean) => void;
disconnectingIds: Set<string>;
onDisconnect: (clientId: string) => void;
bandwidthUnit: BandwidthUnit;
}

export function ConnectionsSection({
Expand All @@ -27,6 +29,7 @@ export function ConnectionsSection({
onShowDisconnectedChange,
disconnectingIds,
onDisconnect,
bandwidthUnit,
}: ConnectionsSectionProps) {
const t = useStatusTranslation(locale);
return (
Expand Down Expand Up @@ -82,7 +85,7 @@ export function ConnectionsSection({
<TableCell>
{formatDuration(client.isDisconnected ? (client.disconnectDurationMs ?? 0) : client.durationMs)}
</TableCell>
<TableCell>{formatBandwidth(client.currentBandwidth)}</TableCell>
<TableCell>{formatBandwidth(client.currentBandwidth, bandwidthUnit)}</TableCell>
<TableCell>{formatBytes(client.bytesSent)}</TableCell>
<TableCell>
<QueueUsage
Expand Down Expand Up @@ -134,7 +137,7 @@ export function ConnectionsSection({
{formatDuration(client.isDisconnected ? (client.disconnectDurationMs ?? 0) : client.durationMs)}
</span>
<span>
{t("bandwidth")}: {formatBandwidth(client.currentBandwidth)}
{t("bandwidth")}: {formatBandwidth(client.currentBandwidth, bandwidthUnit)}
</span>
<span>
{t("dataSent")}: {formatBytes(client.bytesSent)}
Expand Down
126 changes: 83 additions & 43 deletions web-ui/src/components/status/status-header.tsx
Original file line number Diff line number Diff line change
@@ -1,9 +1,10 @@
import { clsx } from "clsx";
import { Globe, Moon, Sun, Wifi } from "lucide-react";
import { Activity, Globe, Moon, Sun, Wifi } from "lucide-react";
import type { ReactNode } from "react";
import { useStatusTranslation } from "../../hooks/use-status-translation";
import type { TranslationKey } from "../../i18n/status";
import type { Locale } from "../../lib/locale";
import type { ThemeMode } from "../../types/ui";
import type { BandwidthUnit, ThemeMode } from "../../types/ui";
import { Badge } from "../ui/badge";
import { SelectBox } from "../ui/select-box";

Expand All @@ -20,6 +21,50 @@ interface StatusHeaderProps {
onThemeChange: (theme: ThemeMode) => void;
themeOptions: ThemeMode[];
themeLabels: Record<ThemeMode, TranslationKey>;
bandwidthUnit: BandwidthUnit;
onBandwidthUnitChange: (unit: BandwidthUnit) => void;
}

const BANDWIDTH_UNIT_OPTIONS: Array<{ value: BandwidthUnit; label: string }> = [
{ value: "bits", label: "Mbps" },
{ value: "bytes", label: "MB/s" },
];

interface HeaderSelectProps<T extends string> {
icon: ReactNode;
label: string;
value: T;
onChange: (value: T) => void;
options: ReadonlyArray<{ value: T; label: string }>;
containerClassName?: string;
}

function HeaderSelect<T extends string>({
icon,
label,
value,
onChange,
options,
containerClassName = "min-w-[140px]",
}: HeaderSelectProps<T>) {
return (
<div className="flex items-center gap-2">
{icon}
<span className="hidden text-xs font-semibold uppercase tracking-wide md:inline">{label}</span>
<SelectBox
value={value}
onChange={(event) => onChange(event.target.value as T)}
containerClassName={containerClassName}
aria-label={label}
>
{options.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectBox>
</div>
);
}

export function StatusHeader({
Expand All @@ -35,13 +80,16 @@ export function StatusHeader({
onThemeChange,
themeOptions,
themeLabels,
bandwidthUnit,
onBandwidthUnitChange,
}: StatusHeaderProps) {
const t = useStatusTranslation(locale);
const themeSelectOptions = themeOptions.map((option) => ({ value: option, label: t(themeLabels[option]) }));
return (
<header className="rounded-3xl border border-border/60 bg-card/85 p-5 shadow-sm backdrop-blur">
<div className="flex flex-col gap-5 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-4">
<div className="flex flex-wrap items-center gap-3 text-sm">
<header className="rounded-3xl border border-border/60 bg-card/85 p-4 shadow-sm backdrop-blur">
<div className="flex flex-col gap-3 lg:flex-row lg:items-start lg:justify-between">
<div className="space-y-3">
<div className="flex flex-wrap items-center gap-2 text-sm">
<span className={clsx("inline-flex items-center gap-2 font-medium", statusAccent)}>
<Wifi className="h-4 w-4" />
{statusLabel}
Expand All @@ -59,43 +107,35 @@ export function StatusHeader({
</p>
</div>
</div>
<div className="flex flex-col gap-3 text-sm text-muted-foreground md:flex-row md:items-start">
<div className="flex items-center gap-3">
<Globe className="h-4 w-4 text-muted-foreground" />
<span className="hidden text-xs font-semibold uppercase tracking-wide md:inline">{t("language")}</span>
<SelectBox
value={locale}
onChange={(event) => onLocaleChange(event.target.value as Locale)}
containerClassName="min-w-[140px]"
aria-label={t("language")}
>
{localeOptions.map((option) => (
<option key={option.value} value={option.value}>
{option.label}
</option>
))}
</SelectBox>
</div>
<div className="flex items-center gap-3">
{theme === "dark" ? (
<Moon className="h-4 w-4 text-muted-foreground" />
) : (
<Sun className="h-4 w-4 text-muted-foreground" />
)}
<span className="hidden text-xs font-semibold uppercase tracking-wide md:inline">{t("appearance")}</span>
<SelectBox
value={theme}
onChange={(event) => onThemeChange(event.target.value as ThemeMode)}
containerClassName="min-w-[140px]"
aria-label={t("appearance")}
>
{themeOptions.map((option) => (
<option key={option} value={option}>
{t(themeLabels[option])}
</option>
))}
</SelectBox>
</div>
<div className="flex flex-col gap-2 text-sm text-muted-foreground md:flex-row md:flex-wrap md:items-start lg:justify-end">
<HeaderSelect
icon={<Globe className="h-4 w-4 text-muted-foreground" />}
label={t("language")}
value={locale}
onChange={onLocaleChange}
options={localeOptions}
/>
<HeaderSelect
icon={
theme === "dark" ? (
<Moon className="h-4 w-4 text-muted-foreground" />
) : (
<Sun className="h-4 w-4 text-muted-foreground" />
)
}
label={t("appearance")}
value={theme}
onChange={onThemeChange}
options={themeSelectOptions}
/>
<HeaderSelect
icon={<Activity className="h-4 w-4 text-muted-foreground" />}
label={t("bandwidthUnit")}
value={bandwidthUnit}
onChange={onBandwidthUnitChange}
options={BANDWIDTH_UNIT_OPTIONS}
containerClassName="min-w-[120px]"
/>
</div>
</div>
</header>
Expand Down
6 changes: 4 additions & 2 deletions web-ui/src/components/status/workers-section.tsx
Original file line number Diff line number Diff line change
Expand Up @@ -3,6 +3,7 @@ import { useStatusTranslation } from "../../hooks/use-status-translation";
import { formatBandwidth, formatBytes } from "../../lib/format";
import type { Locale } from "../../lib/locale";
import type { PoolStats, WorkerEntry } from "../../types";
import type { BandwidthUnit } from "../../types/ui";
import { Badge } from "../ui/badge";
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from "../ui/card";
import { Progress } from "../ui/progress";
Expand All @@ -11,9 +12,10 @@ import { Separator } from "../ui/separator";
interface WorkersSectionProps {
workers: WorkerEntry[];
locale: Locale;
bandwidthUnit: BandwidthUnit;
}

export function WorkersSection({ workers, locale }: WorkersSectionProps) {
export function WorkersSection({ workers, locale, bandwidthUnit }: WorkersSectionProps) {
const t = useStatusTranslation(locale);
return (
<section className="rounded-3xl border border-border/60 bg-card/90 p-6 shadow-sm">
Expand All @@ -31,7 +33,7 @@ export function WorkersSection({ workers, locale }: WorkersSectionProps) {
{
key: "bandwidth",
label: t("bandwidth"),
value: formatBandwidth(worker.totalBandwidth),
value: formatBandwidth(worker.totalBandwidth, bandwidthUnit),
},
{
key: "dataSent",
Expand Down
9 changes: 9 additions & 0 deletions web-ui/src/hooks/use-bandwidth-unit.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,9 @@
import { BANDWIDTH_UNITS, type BandwidthUnit } from "../types/ui";
import { usePersistedEnum } from "./use-persisted-enum";

const DEFAULT_UNIT: BandwidthUnit = "bits";

export function useBandwidthUnit(storageKey: string) {
const [bandwidthUnit, setBandwidthUnit] = usePersistedEnum<BandwidthUnit>(storageKey, DEFAULT_UNIT, BANDWIDTH_UNITS);
return { bandwidthUnit, setBandwidthUnit };
}
22 changes: 8 additions & 14 deletions web-ui/src/hooks/use-locale.ts
Original file line number Diff line number Diff line change
@@ -1,18 +1,12 @@
import { useEffect, useState } from "react";
import { detectBrowserLocale, detectInitialLocale } from "../lib/locale";
import { useMemo } from "react";
import { detectBrowserLocale, type Locale, SUPPORTED_LOCALES } from "../lib/locale";
import { usePersistedEnum } from "./use-persisted-enum";

export function useLocale(storageKey: string) {
const [locale, setLocale] = useState(() => detectInitialLocale(storageKey));

useEffect(() => {
if (typeof window !== "undefined") {
if (locale === detectBrowserLocale(navigator)) {
window.localStorage.removeItem(storageKey);
} else {
window.localStorage.setItem(storageKey, locale);
}
}
}, [locale, storageKey]);

const browserLocale = useMemo(
() => detectBrowserLocale(typeof navigator === "undefined" ? undefined : navigator),
[],
);
const [locale, setLocale] = usePersistedEnum<Locale>(storageKey, browserLocale, SUPPORTED_LOCALES);
return { locale, setLocale };
}
25 changes: 25 additions & 0 deletions web-ui/src/hooks/use-persisted-enum.ts
Original file line number Diff line number Diff line change
@@ -0,0 +1,25 @@
import { type Dispatch, type SetStateAction, useEffect, useState } from "react";

export function usePersistedEnum<T extends string>(
storageKey: string,
defaultValue: T,
validValues: readonly T[],
): [T, Dispatch<SetStateAction<T>>] {
const [value, setValue] = useState<T>(() => {
if (typeof window === "undefined") return defaultValue;
const stored = window.localStorage.getItem(storageKey);
return stored !== null && (validValues as readonly string[]).includes(stored) ? (stored as T) : defaultValue;
});

useEffect(() => {
if (typeof window === "undefined") return;
const current = window.localStorage.getItem(storageKey);
if (value === defaultValue) {
if (current !== null) window.localStorage.removeItem(storageKey);
} else if (current !== value) {
window.localStorage.setItem(storageKey, value);
}
}, [value, storageKey, defaultValue]);

return [value, setValue];
}
32 changes: 7 additions & 25 deletions web-ui/src/hooks/use-theme.ts
Original file line number Diff line number Diff line change
@@ -1,16 +1,11 @@
import { useCallback, useEffect, useMemo, useState } from "react";
import type { ThemeMode } from "../types/ui";
import { useCallback, useEffect, useMemo } from "react";
import { THEME_MODES, type ThemeMode } from "../types/ui";
import { usePersistedEnum } from "./use-persisted-enum";

function readStoredTheme(storageKey: string): ThemeMode {
if (typeof window === "undefined") {
return "auto";
}
const stored = window.localStorage.getItem(storageKey);
return stored === "light" || stored === "dark" ? stored : "auto";
}
const DEFAULT_THEME: ThemeMode = "auto";

export function useTheme(storageKey: string) {
const [theme, setTheme] = useState<ThemeMode>(() => readStoredTheme(storageKey));
const [theme, setTheme] = usePersistedEnum<ThemeMode>(storageKey, DEFAULT_THEME, THEME_MODES);

const applyTheme = useCallback((mode: ThemeMode, systemDarkOverride?: boolean) => {
if (typeof document === "undefined") return;
Expand All @@ -36,14 +31,7 @@ export function useTheme(storageKey: string) {

useEffect(() => {
applyTheme(theme);
if (typeof window !== "undefined") {
if (theme === "auto") {
window.localStorage.removeItem(storageKey);
} else {
window.localStorage.setItem(storageKey, theme);
}
}
}, [theme, storageKey, applyTheme]);
}, [theme, applyTheme]);

useEffect(() => {
if (typeof window === "undefined" || typeof window.matchMedia !== "function") {
Expand All @@ -61,11 +49,5 @@ export function useTheme(storageKey: string) {
return () => media.removeEventListener("change", handleChange);
}, [theme, applyTheme]);

return useMemo(
() => ({
theme,
setTheme,
}),
[theme],
);
return useMemo(() => ({ theme, setTheme }), [theme, setTheme]);
}
3 changes: 3 additions & 0 deletions web-ui/src/i18n/status.ts
Original file line number Diff line number Diff line change
Expand Up @@ -53,6 +53,7 @@ const base: TranslationDict = {
themeAuto: "Auto",
themeLight: "Light",
themeDark: "Dark",
bandwidthUnit: "Bandwidth Unit",
lastUpdated: "Last updated",
queueUsage: "Queue usage",
version: "Version",
Expand Down Expand Up @@ -153,6 +154,7 @@ const zhHans: TranslationDict = {
themeAuto: "自动",
themeLight: "浅色",
themeDark: "深色",
bandwidthUnit: "带宽单位",
lastUpdated: "更新时间",
queueUsage: "队列占用",
version: "版本",
Expand Down Expand Up @@ -254,6 +256,7 @@ const zhHant: TranslationDict = {
themeAuto: "自動",
themeLight: "淺色",
themeDark: "深色",
bandwidthUnit: "頻寬單位",
lastUpdated: "更新時間",
queueUsage: "隊列使用率",
version: "版本",
Expand Down
9 changes: 8 additions & 1 deletion web-ui/src/lib/format.ts
Original file line number Diff line number Diff line change
@@ -1,11 +1,18 @@
import type { BandwidthUnit } from "../types/ui";

export function formatBytes(bytes: number): string {
if (bytes >= 1_000_000_000) return `${(bytes / 1_000_000_000).toFixed(2)} GB`;
if (bytes >= 1_000_000) return `${(bytes / 1_000_000).toFixed(2)} MB`;
if (bytes >= 1_000) return `${(bytes / 1_000).toFixed(2)} KB`;
return `${bytes} B`;
}

export function formatBandwidth(bytesPerSec: number): string {
export function formatBandwidth(bytesPerSec: number, unit: BandwidthUnit): string {
if (unit === "bytes") {
if (bytesPerSec >= 1_000_000) return `${(bytesPerSec / 1_000_000).toFixed(2)} MB/s`;
if (bytesPerSec >= 1_000) return `${(bytesPerSec / 1_000).toFixed(2)} KB/s`;
return `${bytesPerSec} B/s`;
}
const bps = bytesPerSec * 8;
if (bps >= 1_000_000) return `${(bps / 1_000_000).toFixed(2)} Mbps`;
if (bps >= 1_000) return `${(bps / 1_000).toFixed(2)} Kbps`;
Expand Down
Loading
Loading