feat: profile statuses

This commit is contained in:
zhom committed 2026-10-11 23:56:41 +04:00
1 parent 187788b309
commit 69d3c90a7c
41 files changed
+2279 -36

No files matched your search

+12 -1
View File
@@ -72,6 +72,7 @@ import { useKonamiCode } from "@/hooks/use-konami-code";
import type { PermissionType } from "@/hooks/use-permissions";
import { usePermissions } from "@/hooks/use-permissions";
import { useProfileEvents } from "@/hooks/use-profile-events";
import { useProfileStatuses } from "@/hooks/use-profile-statuses";
import { useProfileTableFilters } from "@/hooks/use-profile-table-filters";
import { useProxyEvents } from "@/hooks/use-proxy-events";
import { useSyncSessions } from "@/hooks/use-sync-session";
@@ -283,6 +284,7 @@ export default function Home() {
error: groupsError,
loadGroups,
} = useGroupEvents();
const profileStatuses = useProfileStatuses();
const {
storedProxies,
@@ -2266,9 +2268,17 @@ export default function Home() {
proxyNames: new Map(storedProxies.map((p) => [p.id, p.name])),
vpnNames: new Map(vpnConfigs.map((v) => [v.id, v.name])),
extensionGroupNames: new Map(extensionGroups.map((e) => [e.id, e.name])),
statusNames: new Map(profileStatuses.map((s) => [s.id, s.name])),
runningProfiles,
}),
[groupsData, storedProxies, vpnConfigs, extensionGroups, runningProfiles],
[
groupsData,
storedProxies,
vpnConfigs,
extensionGroups,
profileStatuses,
runningProfiles,
],
);
const clearTableSelection = useCallback(() => setSelectedProfiles([]), []);
@@ -2346,6 +2356,7 @@ export default function Home() {
onSessionChanged={applySession}
/>
<ProfilesDataTable
profileStatuses={profileStatuses}
isLoading={isLoading && profiles.length === 0}
showOnboardingEmptyState={profiles.length === 0}
profiles={profileFilters.rows}
+153 -2
View File
@@ -67,6 +67,10 @@ import {
ProfileInfoDialog,
ProfileLaunchHookDialog,
} from "@/components/profile-info-dialog";
import {
ProfileStatusChip,
ProfileStatusesDialog,
} from "@/components/profile-statuses-dialog";
import {
DATA_TABLE_CLASSES,
TableColumnResizer,
@@ -83,6 +87,7 @@ import {
CommandInput,
CommandItem,
CommandList,
CommandSeparator,
} from "@/components/ui/command";
import { CopyToClipboard } from "@/components/ui/copy-to-clipboard";
import {
@@ -149,6 +154,7 @@ import type {
ExtensionGroup,
LocationItem,
ProfileBotState,
ProfileStatus,
StoredProxy,
SyncSessionInfo,
TrafficSnapshot,
@@ -273,6 +279,9 @@ interface TableMeta {
// Extension groups (for Ext column lookup)
extensionGroups: ExtensionGroup[];
profileStatuses: ProfileStatus[];
onManageStatuses: () => void;
// Click handlers for inline Ext / DNS cell editing
onAssignExtensionGroup?: (profileIds: string[]) => void;
setDnsBlocklistProfile: React.Dispatch<
@@ -690,6 +699,104 @@ function DnsCell({
);
}
// Inline status dropdown, same Popover/Command pattern as DNS. A status is a
// label, so it stays editable while the profile runs.
function StatusCell({
profile,
meta,
}: {
profile: BrowserProfile;
meta: TableMeta;
}) {
const [open, setOpen] = React.useState(false);
const [isSaving, setIsSaving] = React.useState(false);
const current =
meta.profileStatuses.find((status) => status.id === profile.status_id) ??
null;
const onPick = async (statusId: string | null) => {
setIsSaving(true);
try {
await invoke("set_profiles_status", {
profileIds: [profile.id],
statusId,
});
} catch (error) {
showErrorToast(translateBackendError(meta.t as never, error));
} finally {
setIsSaving(false);
setOpen(false);
}
};
return (
<Popover open={open} onOpenChange={setOpen}>
<PopoverTrigger asChild>
<button
type="button"
data-slot="profile-status-cell"
disabled={isSaving}
className="flex h-6 w-full min-w-0 items-center rounded-md px-2 text-left text-sm transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
{current ? (
<ProfileStatusChip status={current} />
) : (
<EmptyCellValue label={meta.t("profileStatuses.none")} />
)}
</button>
</PopoverTrigger>
<PopoverContent className="w-52 p-0" align="start">
<Command>
{meta.profileStatuses.length > 8 && (
<CommandInput placeholder={meta.t("profileStatuses.search")} />
)}
<CommandList>
<CommandEmpty>{meta.t("profileStatuses.noMatch")}</CommandEmpty>
<CommandGroup>
<CommandItem value="__none__" onSelect={() => void onPick(null)}>
{current === null && <LuCheck className="mr-2 size-3.5" />}
<span className={current === null ? "" : "ml-5"}>
{meta.t("profileStatuses.none")}
</span>
</CommandItem>
{meta.profileStatuses.map((status) => (
<CommandItem
key={status.id}
value={`${status.name} ${status.id}`}
onSelect={() => void onPick(status.id)}
>
{current?.id === status.id && (
<LuCheck className="mr-2 size-3.5" />
)}
<span
className={
current?.id === status.id ? "min-w-0" : "ml-5 min-w-0"
}
>
<ProfileStatusChip status={status} />
</span>
</CommandItem>
))}
</CommandGroup>
<CommandSeparator />
<CommandGroup>
<CommandItem
value="__manage__"
onSelect={() => {
setOpen(false);
meta.onManageStatuses();
}}
>
<span className="ml-5">{meta.t("profileStatuses.manage")}</span>
</CommandItem>
</CommandGroup>
</CommandList>
</Command>
</PopoverContent>
</Popover>
);
}
/**
* The first eight characters of the profile's UUID: the whole first group of a
* v4, which is also the prefix `id:` searches on, so what the row shows can be
@@ -1545,6 +1652,8 @@ interface ProfilesDataTableProps {
* `ext:` to a group name and needs the same list. One invoke, one listener.
*/
extensionGroups: ExtensionGroup[];
/** Loaded by the page for the same reason: `state:` resolves to names. */
profileStatuses?: ProfileStatus[];
isUpdating: (browser: string) => boolean;
onDeleteSelectedProfiles: (profileIds: string[]) => Promise<void>;
onAssignProfilesToGroup: (profileIds: string[]) => void;
@@ -1595,6 +1704,8 @@ interface ProfilesDataTableProps {
onImportProfiles?: () => void;
}
const EMPTY_STATUSES: ProfileStatus[] = [];
export function ProfilesDataTable({
filterControls,
onClearFilters,
@@ -1610,6 +1721,7 @@ export function ProfilesDataTable({
onOpenCookieManagement,
runningProfiles,
extensionGroups,
profileStatuses = EMPTY_STATUSES,
isUpdating,
onAssignProfilesToGroup,
onAssignProfilesToProxy,
@@ -1703,6 +1815,7 @@ export function ProfilesDataTable({
}, [allProfiles, profileForInfoDialog, setProfileForInfoDialog]);
const [bypassRulesProfile, setBypassRulesProfile] =
React.useState<BrowserProfile | null>(null);
const [statusesDialogOpen, setStatusesDialogOpen] = React.useState(false);
const [dnsBlocklistProfile, setDnsBlocklistProfile] =
React.useState<BrowserProfile | null>(null);
const [launchHookProfile, setLaunchHookProfile] =
@@ -2388,6 +2501,9 @@ export function ProfilesDataTable({
onAssignExtensionGroup,
setDnsBlocklistProfile,
profileStatuses,
onManageStatuses: () => setStatusesDialogOpen(true),
// Rename helpers
handleRename,
setProfileToRename,
@@ -2530,6 +2646,7 @@ export function ProfilesDataTable({
handleBotStopRun,
handleBotViewActivity,
botFixHandler,
profileStatuses,
],
);
@@ -2999,6 +3116,18 @@ export function ProfilesDataTable({
return <ProfileIdCell profile={row.original} meta={meta} />;
},
},
{
id: "state",
size: 100,
header: ({ table }) => {
const meta = table.options.meta as TableMeta;
return meta.t("profileStatuses.column");
},
cell: ({ row, table }) => {
const meta = table.options.meta as TableMeta;
return <StatusCell profile={row.original} meta={meta} />;
},
},
{
id: "tags",
size: 100,
@@ -3416,6 +3545,7 @@ export function ProfilesDataTable({
{ id: "name", label: t("common.labels.name"), hideable: false },
{ id: "created_at", label: t("search.fields.created"), hideable: false },
{ id: "profileId", label: t("profiles.table.profileId") },
{ id: "state", label: t("profileStatuses.column") },
{ id: "tags", label: t("profileTable.tagsHeader") },
{ id: "note", label: t("profileTable.noteHeader") },
{ id: "proxy", label: t("profiles.table.proxy") },
@@ -3452,6 +3582,11 @@ export function ProfilesDataTable({
)?.name ?? ""
);
if (id === "dns") return row.dns_blocklist ?? "";
if (id === "state")
return (
profileStatuses.find((status) => status.id === row.status_id)
?.name ?? ""
);
return row.id;
};
return {
@@ -3460,7 +3595,15 @@ export function ProfilesDataTable({
enableResizing: !fixed && id !== "created_at",
minSize: fixed ? column.size : 48,
maxSize: 1600,
...(["tags", "note", "proxy", "ext", "dns", "profileId"].includes(id)
...([
"state",
"tags",
"note",
"proxy",
"ext",
"dns",
"profileId",
].includes(id)
? {
accessorFn: accessor,
enableSorting: true,
@@ -3469,7 +3612,7 @@ export function ProfilesDataTable({
: {}),
};
}),
[columns, storedProxies, vpnConfigs, extensionGroups],
[columns, storedProxies, vpnConfigs, extensionGroups, profileStatuses],
);
const table = useReactTable({
@@ -3530,6 +3673,7 @@ export function ProfilesDataTable({
: `${Math.max(84, Math.round(containerWidth * 0.09))}px`;
}
const proportions: Record<string, { pct: number; floor: number }> = {
state: { pct: 0.1, floor: 90 },
tags: { pct: 0.12, floor: 100 },
note: { pct: 0.1, floor: 80 },
proxy: { pct: 0.13, floor: 110 },
@@ -3559,6 +3703,7 @@ export function ProfilesDataTable({
profileId: w >= PROFILE_ID_MIN_WIDTH,
dns: w >= 768,
ext: w >= 672,
state: w >= 560,
note: w >= 576,
tags: w >= 512,
// Bot state survives further down than the other content columns:
@@ -4127,6 +4272,12 @@ export function ProfilesDataTable({
profileId={bypassRulesProfile?.id ?? null}
initialRules={bypassRulesProfile?.proxy_bypass_rules ?? []}
/>
<ProfileStatusesDialog
isOpen={statusesDialogOpen}
onClose={() => setStatusesDialogOpen(false)}
statuses={profileStatuses}
profiles={allProfiles}
/>
<ProfileDnsBlocklistDialog
isOpen={dnsBlocklistProfile !== null}
onClose={() => {
+353
View File
@@ -0,0 +1,353 @@
"use client";
import { invoke } from "@tauri-apps/api/core";
import * as React from "react";
import { useTranslation } from "react-i18next";
import { LuPlus, LuTrash2 } from "react-icons/lu";
import { DeleteConfirmationDialog } from "@/components/delete-confirmation-dialog";
import { Button } from "@/components/ui/button";
import {
Dialog,
DialogContent,
DialogDescription,
DialogHeader,
DialogTitle,
} from "@/components/ui/dialog";
import { Input } from "@/components/ui/input";
import {
Popover,
PopoverContent,
PopoverTrigger,
} from "@/components/ui/popover";
import { translateBackendError } from "@/lib/backend-errors";
import { showErrorToast } from "@/lib/toast-utils";
import { cn } from "@/lib/utils";
import type { BrowserProfile, ProfileStatus } from "@/types";
/**
* Data colors for statuses, picked to stay readable on light and dark themes.
* They label accounts the person tracks; they are not theme colors.
*/
export const STATUS_COLORS = [
"#22c55e",
"#84cc16",
"#eab308",
"#f97316",
"#ef4444",
"#ec4899",
"#a855f7",
"#3b82f6",
"#06b6d4",
"#94a3b8",
] as const;
/** The set offered when there are no statuses yet. */
const STARTER_STATUSES = [
{ key: "profileStatuses.starter.active", color: "#22c55e" },
{ key: "profileStatuses.starter.warming", color: "#eab308" },
{ key: "profileStatuses.starter.review", color: "#3b82f6" },
{ key: "profileStatuses.starter.banned", color: "#ef4444" },
] as const;
export function ProfileStatusDot({
color,
className,
}: {
color: string;
className?: string;
}) {
return (
<span
aria-hidden
className={cn("inline-block size-2 shrink-0 rounded-full", className)}
style={{ backgroundColor: color }}
/>
);
}
export function ProfileStatusChip({ status }: { status: ProfileStatus }) {
return (
<span
className="flex min-w-0 items-center gap-1.5"
data-slot="profile-status-chip"
>
<ProfileStatusDot color={status.color} />
<span className="truncate">{status.name}</span>
</span>
);
}
function ColorChoice({
value,
onChange,
disabled,
}: {
value: string;
onChange: (color: string) => void;
disabled?: boolean;
}) {
const { t } = useTranslation();
return (
<Popover>
<PopoverTrigger asChild>
<button
type="button"
disabled={disabled}
aria-label={t("profileStatuses.color")}
className="grid size-8 shrink-0 place-items-center rounded-md transition-colors duration-100 hover:bg-foreground/6 disabled:opacity-50"
>
<ProfileStatusDot color={value} className="size-3.5" />
</button>
</PopoverTrigger>
<PopoverContent align="start" className="w-auto p-2">
<div className="grid grid-cols-5 gap-1">
{STATUS_COLORS.map((color) => (
<button
key={color}
type="button"
aria-label={color}
aria-pressed={value === color}
onClick={() => onChange(color)}
className={cn(
"grid size-7 place-items-center rounded-md transition-colors duration-100 hover:bg-foreground/6",
value === color && "bg-foreground/10",
)}
>
<ProfileStatusDot color={color} className="size-3.5" />
</button>
))}
</div>
<label className="mt-2 flex cursor-pointer items-center gap-2 rounded-md px-1 py-1 text-xs text-muted-foreground hover:bg-foreground/6">
<input
type="color"
value={value}
onChange={(event) => onChange(event.target.value)}
className="size-5 cursor-pointer rounded border-0 bg-transparent p-0"
/>
{t("profileStatuses.customColor")}
</label>
</PopoverContent>
</Popover>
);
}
function StatusRow({
status,
usage,
onDelete,
}: {
status: ProfileStatus;
usage: number;
onDelete: (status: ProfileStatus) => void;
}) {
const { t } = useTranslation();
const [name, setName] = React.useState(status.name);
React.useEffect(() => {
setName(status.name);
}, [status.name]);
const save = async (next: { name?: string; color?: string }) => {
const nextName = (next.name ?? name).trim();
const nextColor = next.color ?? status.color;
if (nextName === status.name && nextColor === status.color) return;
try {
await invoke("update_profile_status", {
statusId: status.id,
name: nextName,
color: nextColor,
});
} catch (error) {
setName(status.name);
showErrorToast(translateBackendError(t, error));
}
};
return (
<li className="flex items-center gap-1" data-slot="profile-status-row">
<ColorChoice
value={status.color}
onChange={(color) => void save({ color })}
/>
<Input
variant="soft"
value={name}
aria-label={t("profileStatuses.namePlaceholder")}
onChange={(event) => setName(event.target.value)}
onBlur={() => void save({})}
onKeyDown={(event) => {
if (event.key === "Enter") event.currentTarget.blur();
if (event.key === "Escape") setName(status.name);
}}
className="h-8 flex-1"
/>
<span className="w-12 shrink-0 text-right text-xs text-muted-foreground tabular-nums">
{usage}
</span>
<Button
type="button"
variant="subtle"
size="icon"
className="size-8"
aria-label={t("profileStatuses.delete")}
onClick={() => onDelete(status)}
>
<LuTrash2 className="size-3.5" />
</Button>
</li>
);
}
export function ProfileStatusesDialog({
isOpen,
onClose,
statuses,
profiles,
}: {
isOpen: boolean;
onClose: () => void;
statuses: ProfileStatus[];
profiles: BrowserProfile[];
}) {
const { t } = useTranslation();
const [newName, setNewName] = React.useState("");
const [newColor, setNewColor] = React.useState<string>(STATUS_COLORS[0]);
const [isAdding, setIsAdding] = React.useState(false);
const [deleting, setDeleting] = React.useState<ProfileStatus | null>(null);
const usage = React.useMemo(() => {
const counts = new Map<string, number>();
for (const profile of profiles) {
if (profile.status_id) {
counts.set(profile.status_id, (counts.get(profile.status_id) ?? 0) + 1);
}
}
return counts;
}, [profiles]);
React.useEffect(() => {
if (isOpen) {
setNewName("");
setNewColor(STATUS_COLORS[statuses.length % STATUS_COLORS.length]);
}
}, [isOpen, statuses.length]);
const add = async (entries: { name: string; color: string }[]) => {
setIsAdding(true);
try {
for (const entry of entries) {
await invoke("create_profile_status", entry);
}
setNewName("");
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setIsAdding(false);
}
};
const confirmDelete = async () => {
if (!deleting) return;
try {
await invoke("delete_profile_status", { statusId: deleting.id });
} catch (error) {
showErrorToast(translateBackendError(t, error));
} finally {
setDeleting(null);
}
};
return (
<>
<Dialog open={isOpen} onOpenChange={(open) => !open && onClose()}>
<DialogContent className="max-w-md" data-slot="profile-statuses-dialog">
<DialogHeader>
<DialogTitle>{t("profileStatuses.title")}</DialogTitle>
<DialogDescription>
{t("profileStatuses.description")}
</DialogDescription>
</DialogHeader>
{statuses.length === 0 ? (
<div className="flex flex-col items-start gap-2 rounded-lg bg-foreground/4 p-3">
<p className="text-sm text-muted-foreground">
{t("profileStatuses.empty")}
</p>
<Button
type="button"
variant="soft"
size="sm"
disabled={isAdding}
onClick={() =>
void add(
STARTER_STATUSES.map((entry) => ({
name: t(entry.key),
color: entry.color,
})),
)
}
>
{t("profileStatuses.addStarter")}
</Button>
</div>
) : (
<ul className="flex max-h-80 flex-col gap-1 overflow-y-auto">
{statuses.map((status) => (
<StatusRow
key={status.id}
status={status}
usage={usage.get(status.id) ?? 0}
onDelete={setDeleting}
/>
))}
</ul>
)}
<form
className="flex items-center gap-1"
onSubmit={(event) => {
event.preventDefault();
if (newName.trim()) {
void add([{ name: newName.trim(), color: newColor }]);
}
}}
>
<ColorChoice
value={newColor}
onChange={setNewColor}
disabled={isAdding}
/>
<Input
id="new-profile-status"
variant="soft"
value={newName}
placeholder={t("profileStatuses.namePlaceholder")}
onChange={(event) => setNewName(event.target.value)}
disabled={isAdding}
className="h-8 flex-1"
/>
<Button
type="submit"
size="sm"
className="h-8"
disabled={isAdding || !newName.trim()}
>
<LuPlus className="size-3.5" />
{t("common.buttons.add")}
</Button>
</form>
</DialogContent>
</Dialog>
<DeleteConfirmationDialog
isOpen={deleting !== null}
onClose={() => setDeleting(null)}
onConfirm={confirmDelete}
title={t("profileStatuses.deleteTitle", {
name: deleting?.name ?? "",
})}
description={t("profileStatuses.deleteDescription")}
confirmButtonText={t("common.buttons.delete")}
/>
</>
);
}
+35
View File
@@ -0,0 +1,35 @@
import { invoke } from "@tauri-apps/api/core";
import { listen } from "@tauri-apps/api/event";
import { useEffect, useState } from "react";
import type { ProfileStatus } from "@/types";
/** Every profile status, in order, kept current by `profile-statuses-changed`. */
export function useProfileStatuses(): ProfileStatus[] {
const [statuses, setStatuses] = useState<ProfileStatus[]>([]);
useEffect(() => {
let mounted = true;
let unlisten: (() => void) | undefined;
const load = async () => {
try {
const next = await invoke<ProfileStatus[]>("list_profile_statuses");
if (mounted) setStatuses(next);
} catch (error) {
console.error("Failed to load profile statuses:", error);
}
};
void load();
void listen("profile-statuses-changed", () => {
void load();
}).then((stop) => {
if (mounted) unlisten = stop;
else stop();
});
return () => {
mounted = false;
unlisten?.();
};
}, []);
return statuses;
}
+12
View File
@@ -38,6 +38,18 @@ export function useProfileTableFilters(
{ value: "stopped", label: t("tables.stopped") },
],
},
{
id: "state",
label: t("profileStatuses.column"),
value: (row) => row.status_id ?? "",
options: [
{ value: "", label: t("profileStatuses.none") },
...[...context.statusNames].map(([value, label]) => ({
value,
label,
})),
],
},
{
id: "tag",
label: t("profileTable.tagsHeader"),
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Passwortgeschützt",
"ephemeral": "Flüchtiges Profil",
"created": "Erstellungsdatum",
"launched": "Datum des letzten Starts"
"launched": "Datum des letzten Starts",
"state": "Profilstatus"
},
"operators": {
"negate": "Schließt Treffer aus",
@@ -1718,6 +1719,29 @@
"upgraded": "Diese Adresse bietet auch verschlüsseltes DNS, daher verwendet Donut es. Beginnen Sie mit tcp://, um einfaches DNS zu behalten."
}
},
"profileStatuses": {
"column": "Status",
"none": "Kein Status",
"search": "Status suchen…",
"noMatch": "Kein passender Status",
"manage": "Status verwalten…",
"title": "Profilstatus",
"description": "Kennzeichnen Sie jedes Profil danach, wie es um sein Konto steht. Status werden mit Ihren anderen Geräten und Ihrem Team synchronisiert.",
"empty": "Noch keine Status. Beginnen Sie mit einer üblichen Auswahl oder fügen Sie unten eigene hinzu.",
"addStarter": "Aktiv, Aufwärmen, Prüfen und Gesperrt hinzufügen",
"starter": {
"active": "Aktiv",
"warming": "Aufwärmen",
"review": "Prüfen",
"banned": "Gesperrt"
},
"namePlaceholder": "Name des Status",
"color": "Farbe des Status",
"customColor": "Eigene Farbe",
"delete": "Status löschen",
"deleteTitle": "„{{name}}“ löschen?",
"deleteDescription": "Profile mit diesem Status haben danach keinen Status. Dies lässt sich nicht rückgängig machen."
},
"vpns": {
"form": {
"titleEdit": "VPN bearbeiten",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Sie haben in kurzer Zeit viel Feedback gesendet. Versuchen Sie es in ein paar Minuten erneut.",
"feedbackSendFailed": "Das Feedback konnte nicht gesendet werden. Versuchen Sie es später erneut.",
"feedbackLogsUnreadable": "Die Protokolle konnten nicht gelesen werden. Schalten Sie „Aktuelle Protokolle anhängen“ aus und senden Sie erneut.",
"dnsServerInvalid": "Dieser DNS-Server ist ungültig. Geben Sie eine IP-Adresse oder einen Hostnamen ein, tls://host für DNS over TLS oder eine https://-URL für DNS over HTTPS."
"dnsServerInvalid": "Dieser DNS-Server ist ungültig. Geben Sie eine IP-Adresse oder einen Hostnamen ein, tls://host für DNS over TLS oder eine https://-URL für DNS over HTTPS.",
"profileStatusExists": "Ein Status mit diesem Namen existiert bereits.",
"profileStatusNotFound": "Dieser Status existiert nicht mehr.",
"profileStatusColorInvalid": "Wählen Sie eine Farbe für den Status.",
"profileStatusNameTooLong": "Ein Statusname darf höchstens 40 Zeichen haben.",
"profileStatusLimitReached": "Sie haben 100 Status, das Maximum. Löschen Sie einen, um einen weiteren hinzuzufügen."
},
"rail": {
"profiles": "Profile",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Password protected",
"ephemeral": "Ephemeral profile",
"created": "Creation date",
"launched": "Last launch date"
"launched": "Last launch date",
"state": "Profile status"
},
"operators": {
"negate": "Excludes what matches",
@@ -1718,6 +1719,29 @@
"upgraded": "This address also serves encrypted DNS, so Donut uses it. Start with tcp:// to keep plain DNS."
}
},
"profileStatuses": {
"column": "Status",
"none": "No status",
"search": "Search statuses…",
"noMatch": "No matching status",
"manage": "Manage statuses…",
"title": "Profile statuses",
"description": "Label each profile by where its account stands. Statuses sync to your other devices and to your team.",
"empty": "No statuses yet. Start with a common set, or add your own below.",
"addStarter": "Add Active, Warming up, Needs review and Banned",
"starter": {
"active": "Active",
"warming": "Warming up",
"review": "Needs review",
"banned": "Banned"
},
"namePlaceholder": "Status name",
"color": "Status color",
"customColor": "Custom color",
"delete": "Delete status",
"deleteTitle": "Delete \"{{name}}\"?",
"deleteDescription": "Profiles with this status will have no status. You cannot undo this."
},
"vpns": {
"form": {
"titleEdit": "Edit VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "You sent a lot of feedback in a short time. Try again in a few minutes.",
"feedbackSendFailed": "The feedback could not be sent. Try again later.",
"feedbackLogsUnreadable": "The logs could not be read. Turn off \"Include recent logs\" and send again.",
"dnsServerInvalid": "This DNS server is not valid. Enter an IP address or host name, tls://host for DNS over TLS, or an https:// URL for DNS over HTTPS."
"dnsServerInvalid": "This DNS server is not valid. Enter an IP address or host name, tls://host for DNS over TLS, or an https:// URL for DNS over HTTPS.",
"profileStatusExists": "A status with this name already exists.",
"profileStatusNotFound": "This status no longer exists.",
"profileStatusColorInvalid": "Pick a color for the status.",
"profileStatusNameTooLong": "A status name can have at most 40 characters.",
"profileStatusLimitReached": "You have 100 statuses, the maximum. Delete one to add another."
},
"rail": {
"profiles": "Profiles",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protegido con contraseña",
"ephemeral": "Perfil efímero",
"created": "Fecha de creación",
"launched": "Fecha del último inicio"
"launched": "Fecha del último inicio",
"state": "Estado del perfil"
},
"operators": {
"negate": "Excluye lo que coincide",
@@ -1724,6 +1725,29 @@
"upgraded": "Esta dirección también ofrece DNS cifrado, así que Donut lo usa. Empieza con tcp:// para mantener DNS simple."
}
},
"profileStatuses": {
"column": "Estado",
"none": "Sin estado",
"search": "Buscar estados…",
"noMatch": "Ningún estado coincide",
"manage": "Gestionar estados…",
"title": "Estados de perfil",
"description": "Etiqueta cada perfil según la situación de su cuenta. Los estados se sincronizan con tus otros dispositivos y con tu equipo.",
"empty": "Aún no hay estados. Empieza con un conjunto habitual o añade los tuyos abajo.",
"addStarter": "Añadir Activo, Calentando, Revisar y Baneado",
"starter": {
"active": "Activo",
"warming": "Calentando",
"review": "Revisar",
"banned": "Baneado"
},
"namePlaceholder": "Nombre del estado",
"color": "Color del estado",
"customColor": "Color personalizado",
"delete": "Eliminar estado",
"deleteTitle": "¿Eliminar «{{name}}»?",
"deleteDescription": "Los perfiles con este estado quedarán sin estado. No se puede deshacer."
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Enviaste muchos comentarios en poco tiempo. Inténtalo de nuevo en unos minutos.",
"feedbackSendFailed": "No se pudieron enviar los comentarios. Inténtalo más tarde.",
"feedbackLogsUnreadable": "No se pudieron leer los registros. Desactiva «Incluir registros recientes» y envía de nuevo.",
"dnsServerInvalid": "Este servidor DNS no es válido. Introduce una dirección IP o un nombre de host, tls://host para DNS sobre TLS o una URL https:// para DNS sobre HTTPS."
"dnsServerInvalid": "Este servidor DNS no es válido. Introduce una dirección IP o un nombre de host, tls://host para DNS sobre TLS o una URL https:// para DNS sobre HTTPS.",
"profileStatusExists": "Ya existe un estado con este nombre.",
"profileStatusNotFound": "Este estado ya no existe.",
"profileStatusColorInvalid": "Elige un color para el estado.",
"profileStatusNameTooLong": "El nombre de un estado puede tener como máximo 40 caracteres.",
"profileStatusLimitReached": "Tienes 100 estados, el máximo. Elimina uno para añadir otro."
},
"rail": {
"profiles": "Perfiles",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protégé par mot de passe",
"ephemeral": "Profil éphémère",
"created": "Date de création",
"launched": "Date du dernier lancement"
"launched": "Date du dernier lancement",
"state": "Statut du profil"
},
"operators": {
"negate": "Exclut ce qui correspond",
@@ -1724,6 +1725,29 @@
"upgraded": "Cette adresse propose aussi un DNS chiffré, Donut l'utilise donc. Commencez par tcp:// pour garder un DNS simple."
}
},
"profileStatuses": {
"column": "Statut",
"none": "Aucun statut",
"search": "Rechercher un statut…",
"noMatch": "Aucun statut correspondant",
"manage": "Gérer les statuts…",
"title": "Statuts des profils",
"description": "Indiquez pour chaque profil où en est son compte. Les statuts se synchronisent avec vos autres appareils et votre équipe.",
"empty": "Aucun statut pour l'instant. Commencez par un jeu courant ou ajoutez les vôtres ci-dessous.",
"addStarter": "Ajouter Actif, En préchauffe, À vérifier et Banni",
"starter": {
"active": "Actif",
"warming": "En préchauffe",
"review": "À vérifier",
"banned": "Banni"
},
"namePlaceholder": "Nom du statut",
"color": "Couleur du statut",
"customColor": "Couleur personnalisée",
"delete": "Supprimer le statut",
"deleteTitle": "Supprimer « {{name}} » ?",
"deleteDescription": "Les profils ayant ce statut n'auront plus de statut. Cette action est irréversible."
},
"vpns": {
"form": {
"titleEdit": "Modifier le VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Vous avez envoyé beaucoup d'avis en peu de temps. Réessayez dans quelques minutes.",
"feedbackSendFailed": "L'avis n'a pas pu être envoyé. Réessayez plus tard.",
"feedbackLogsUnreadable": "Les journaux n'ont pas pu être lus. Désactivez « Joindre les journaux récents » et renvoyez.",
"dnsServerInvalid": "Ce serveur DNS n'est pas valide. Saisissez une adresse IP ou un nom d'hôte, tls://hôte pour DNS over TLS, ou une URL https:// pour DNS over HTTPS."
"dnsServerInvalid": "Ce serveur DNS n'est pas valide. Saisissez une adresse IP ou un nom d'hôte, tls://hôte pour DNS over TLS, ou une URL https:// pour DNS over HTTPS.",
"profileStatusExists": "Un statut portant ce nom existe déjà.",
"profileStatusNotFound": "Ce statut n'existe plus.",
"profileStatusColorInvalid": "Choisissez une couleur pour le statut.",
"profileStatusNameTooLong": "Le nom d'un statut ne peut pas dépasser 40 caractères.",
"profileStatusLimitReached": "Vous avez 100 statuts, le maximum. Supprimez-en un pour en ajouter un autre."
},
"rail": {
"profiles": "Profils",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "パスワード保護",
"ephemeral": "一時プロファイル",
"created": "作成日",
"launched": "最終起動日"
"launched": "最終起動日",
"state": "プロファイルのステータス"
},
"operators": {
"negate": "一致するものを除外します",
@@ -1718,6 +1719,29 @@
"upgraded": "このアドレスは暗号化 DNS にも対応しているため、Donut はそれを使用します。通常の DNS を使うには先頭に tcp:// を付けてください。"
}
},
"profileStatuses": {
"column": "ステータス",
"none": "ステータスなし",
"search": "ステータスを検索…",
"noMatch": "一致するステータスはありません",
"manage": "ステータスを管理…",
"title": "プロファイルのステータス",
"description": "各プロファイルにアカウントの状況を示すラベルを付けます。ステータスは他のデバイスとチームに同期されます。",
"empty": "ステータスはまだありません。よく使うセットから始めるか、下で独自に追加してください。",
"addStarter": "アクティブ、ウォームアップ中、要確認、BAN を追加",
"starter": {
"active": "アクティブ",
"warming": "ウォームアップ中",
"review": "要確認",
"banned": "BAN"
},
"namePlaceholder": "ステータス名",
"color": "ステータスの色",
"customColor": "カスタムカラー",
"delete": "ステータスを削除",
"deleteTitle": "「{{name}}」を削除しますか?",
"deleteDescription": "このステータスのプロファイルはステータスなしになります。元に戻すことはできません。"
},
"vpns": {
"form": {
"titleEdit": "VPN を編集",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "短時間に多くのフィードバックが送信されました。数分後にもう一度お試しください。",
"feedbackSendFailed": "フィードバックを送信できませんでした。しばらくしてからもう一度お試しください。",
"feedbackLogsUnreadable": "ログを読み込めませんでした。「最近のログを添付」をオフにして、もう一度送信してください。",
"dnsServerInvalid": "この DNS サーバーは無効です。IP アドレスまたはホスト名、DNS over TLS の場合は tls://host、DNS over HTTPS の場合は https:// URL を入力してください。"
"dnsServerInvalid": "この DNS サーバーは無効です。IP アドレスまたはホスト名、DNS over TLS の場合は tls://host、DNS over HTTPS の場合は https:// URL を入力してください。",
"profileStatusExists": "この名前のステータスはすでに存在します。",
"profileStatusNotFound": "このステータスはもう存在しません。",
"profileStatusColorInvalid": "ステータスの色を選択してください。",
"profileStatusNameTooLong": "ステータス名は 40 文字以内にしてください。",
"profileStatusLimitReached": "ステータスが上限の 100 個に達しています。追加するには 1 つ削除してください。"
},
"rail": {
"profiles": "プロファイル",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "비밀번호 보호",
"ephemeral": "임시 프로필",
"created": "생성 날짜",
"launched": "마지막 실행 날짜"
"launched": "마지막 실행 날짜",
"state": "프로필 상태"
},
"operators": {
"negate": "일치하는 항목을 제외합니다",
@@ -1718,6 +1719,29 @@
"upgraded": "이 주소는 암호화된 DNS도 제공하므로 Donut이 이를 사용합니다. 일반 DNS를 유지하려면 tcp://로 시작하세요."
}
},
"profileStatuses": {
"column": "상태",
"none": "상태 없음",
"search": "상태 검색…",
"noMatch": "일치하는 상태가 없습니다",
"manage": "상태 관리…",
"title": "프로필 상태",
"description": "각 프로필에 계정 상황을 나타내는 라벨을 붙입니다. 상태는 다른 기기 및 팀과 동기화됩니다.",
"empty": "아직 상태가 없습니다. 일반적인 세트로 시작하거나 아래에서 직접 추가하세요.",
"addStarter": "활성, 워밍업, 검토 필요, 차단됨 추가",
"starter": {
"active": "활성",
"warming": "워밍업",
"review": "검토 필요",
"banned": "차단됨"
},
"namePlaceholder": "상태 이름",
"color": "상태 색상",
"customColor": "사용자 지정 색상",
"delete": "상태 삭제",
"deleteTitle": "\"{{name}}\"을(를) 삭제할까요?",
"deleteDescription": "이 상태의 프로필은 상태가 없어집니다. 되돌릴 수 없습니다."
},
"vpns": {
"form": {
"titleEdit": "VPN 편집",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "짧은 시간에 피드백을 너무 많이 보냈습니다. 몇 분 후에 다시 시도하세요.",
"feedbackSendFailed": "피드백을 보낼 수 없습니다. 나중에 다시 시도하세요.",
"feedbackLogsUnreadable": "로그를 읽을 수 없습니다. '최근 로그 첨부'를 끄고 다시 보내세요.",
"dnsServerInvalid": "유효하지 않은 DNS 서버입니다. IP 주소 또는 호스트 이름, DNS over TLS의 경우 tls://host, DNS over HTTPS의 경우 https:// URL을 입력하세요."
"dnsServerInvalid": "유효하지 않은 DNS 서버입니다. IP 주소 또는 호스트 이름, DNS over TLS의 경우 tls://host, DNS over HTTPS의 경우 https:// URL을 입력하세요.",
"profileStatusExists": "같은 이름의 상태가 이미 있습니다.",
"profileStatusNotFound": "이 상태는 더 이상 존재하지 않습니다.",
"profileStatusColorInvalid": "상태 색상을 선택하세요.",
"profileStatusNameTooLong": "상태 이름은 최대 40자까지 가능합니다.",
"profileStatusLimitReached": "상태가 최대치인 100개입니다. 추가하려면 하나를 삭제하세요."
},
"rail": {
"profiles": "프로필",
+31 -2
View File
@@ -296,7 +296,8 @@
"locked": "Protegido por senha",
"ephemeral": "Perfil efêmero",
"created": "Data de criação",
"launched": "Data da última execução"
"launched": "Data da última execução",
"state": "Status do perfil"
},
"operators": {
"negate": "Exclui o que corresponde",
@@ -1724,6 +1725,29 @@
"upgraded": "Este endereço também oferece DNS criptografado, então o Donut o usa. Comece com tcp:// para manter o DNS simples."
}
},
"profileStatuses": {
"column": "Status",
"none": "Sem status",
"search": "Buscar status…",
"noMatch": "Nenhum status corresponde",
"manage": "Gerenciar status…",
"title": "Status dos perfis",
"description": "Marque cada perfil conforme a situação da conta. Os status são sincronizados com seus outros dispositivos e com sua equipe.",
"empty": "Ainda não há status. Comece com um conjunto comum ou adicione os seus abaixo.",
"addStarter": "Adicionar Ativo, Aquecendo, Revisar e Banido",
"starter": {
"active": "Ativo",
"warming": "Aquecendo",
"review": "Revisar",
"banned": "Banido"
},
"namePlaceholder": "Nome do status",
"color": "Cor do status",
"customColor": "Cor personalizada",
"delete": "Excluir status",
"deleteTitle": "Excluir \"{{name}}\"?",
"deleteDescription": "Os perfis com este status ficarão sem status. Não é possível desfazer."
},
"vpns": {
"form": {
"titleEdit": "Editar VPN",
@@ -2451,7 +2475,12 @@
"feedbackRateLimited": "Você enviou muito feedback em pouco tempo. Tente novamente em alguns minutos.",
"feedbackSendFailed": "Não foi possível enviar o feedback. Tente novamente mais tarde.",
"feedbackLogsUnreadable": "Não foi possível ler os logs. Desative \"Incluir logs recentes\" e envie novamente.",
"dnsServerInvalid": "Este servidor DNS não é válido. Digite um endereço IP ou nome de host, tls://host para DNS sobre TLS ou uma URL https:// para DNS sobre HTTPS."
"dnsServerInvalid": "Este servidor DNS não é válido. Digite um endereço IP ou nome de host, tls://host para DNS sobre TLS ou uma URL https:// para DNS sobre HTTPS.",
"profileStatusExists": "Já existe um status com este nome.",
"profileStatusNotFound": "Este status não existe mais.",
"profileStatusColorInvalid": "Escolha uma cor para o status.",
"profileStatusNameTooLong": "O nome de um status pode ter no máximo 40 caracteres.",
"profileStatusLimitReached": "Você tem 100 status, o máximo. Exclua um para adicionar outro."
},
"rail": {
"profiles": "Perfis",
+31 -2
View File
@@ -297,7 +297,8 @@
"locked": "Защищён паролем",
"ephemeral": "Временный профиль",
"created": "Дата создания",
"launched": "Дата последнего запуска"
"launched": "Дата последнего запуска",
"state": "Статус профиля"
},
"operators": {
"negate": "Исключает совпадения",
@@ -1730,6 +1731,29 @@
"upgraded": "Этот адрес также поддерживает зашифрованный DNS, поэтому Donut использует его. Начните с tcp://, чтобы оставить обычный DNS."
}
},
"profileStatuses": {
"column": "Статус",
"none": "Без статуса",
"search": "Поиск статусов…",
"noMatch": "Нет подходящего статуса",
"manage": "Управление статусами…",
"title": "Статусы профилей",
"description": "Отмечайте каждый профиль по состоянию его аккаунта. Статусы синхронизируются с другими устройствами и командой.",
"empty": "Статусов пока нет. Начните с обычного набора или добавьте свои ниже.",
"addStarter": "Добавить «Активен», «Прогрев», «Проверить» и «Забанен»",
"starter": {
"active": "Активен",
"warming": "Прогрев",
"review": "Проверить",
"banned": "Забанен"
},
"namePlaceholder": "Название статуса",
"color": "Цвет статуса",
"customColor": "Свой цвет",
"delete": "Удалить статус",
"deleteTitle": "Удалить «{{name}}»?",
"deleteDescription": "Профили с этим статусом останутся без статуса. Это действие нельзя отменить."
},
"vpns": {
"form": {
"titleEdit": "Изменить VPN",
@@ -2461,7 +2485,12 @@
"feedbackRateLimited": "Вы отправили много отзывов за короткое время. Попробуйте снова через несколько минут.",
"feedbackSendFailed": "Не удалось отправить отзыв. Попробуйте позже.",
"feedbackLogsUnreadable": "Не удалось прочитать логи. Выключите «Приложить последние логи» и отправьте снова.",
"dnsServerInvalid": "Недопустимый DNS-сервер. Введите IP-адрес или имя хоста, tls://хост для DNS over TLS или URL https:// для DNS over HTTPS."
"dnsServerInvalid": "Недопустимый DNS-сервер. Введите IP-адрес или имя хоста, tls://хост для DNS over TLS или URL https:// для DNS over HTTPS.",
"profileStatusExists": "Статус с таким названием уже существует.",
"profileStatusNotFound": "Этого статуса больше нет.",
"profileStatusColorInvalid": "Выберите цвет статуса.",
"profileStatusNameTooLong": "Название статуса может содержать не более 40 символов.",
"profileStatusLimitReached": "У вас 100 статусов, это максимум. Удалите один, чтобы добавить другой."
},
"rail": {
"profiles": "Профили",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Parola korumalı",
"ephemeral": "Geçici profil",
"created": "Oluşturulma tarihi",
"launched": "Son başlatma tarihi"
"launched": "Son başlatma tarihi",
"state": "Profil durumu"
},
"operators": {
"negate": "Eşleşenleri hariç tutar",
@@ -1718,6 +1719,29 @@
"upgraded": "Bu adres şifreli DNS de sunduğu için Donut onu kullanır. Düz DNS kullanmak için tcp:// ile başlayın."
}
},
"profileStatuses": {
"column": "Durum",
"none": "Durum yok",
"search": "Durum ara…",
"noMatch": "Eşleşen durum yok",
"manage": "Durumları yönet…",
"title": "Profil durumları",
"description": "Her profili hesabının durumuna göre etiketleyin. Durumlar diğer cihazlarınızla ve ekibinizle eşitlenir.",
"empty": "Henüz durum yok. Yaygın bir setle başlayın veya aşağıdan kendinizinkini ekleyin.",
"addStarter": "Aktif, Isınıyor, İncelenmeli ve Yasaklı ekle",
"starter": {
"active": "Aktif",
"warming": "Isınıyor",
"review": "İncelenmeli",
"banned": "Yasaklı"
},
"namePlaceholder": "Durum adı",
"color": "Durum rengi",
"customColor": "Özel renk",
"delete": "Durumu sil",
"deleteTitle": "\"{{name}}\" silinsin mi?",
"deleteDescription": "Bu durumdaki profillerin durumu kalmaz. Bu işlem geri alınamaz."
},
"vpns": {
"form": {
"titleEdit": "VPN'i Düzenle",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Kısa sürede çok fazla geri bildirim gönderdiniz. Birkaç dakika sonra tekrar deneyin.",
"feedbackSendFailed": "Geri bildirim gönderilemedi. Daha sonra tekrar deneyin.",
"feedbackLogsUnreadable": "Günlükler okunamadı. \"Son günlükleri ekle\" seçeneğini kapatıp tekrar gönderin.",
"dnsServerInvalid": "Bu DNS sunucusu geçerli değil. Bir IP adresi veya ana bilgisayar adı, DNS over TLS için tls://host ya da DNS over HTTPS için bir https:// URL'si girin."
"dnsServerInvalid": "Bu DNS sunucusu geçerli değil. Bir IP adresi veya ana bilgisayar adı, DNS over TLS için tls://host ya da DNS over HTTPS için bir https:// URL'si girin.",
"profileStatusExists": "Bu adda bir durum zaten var.",
"profileStatusNotFound": "Bu durum artık yok.",
"profileStatusColorInvalid": "Durum için bir renk seçin.",
"profileStatusNameTooLong": "Durum adı en fazla 40 karakter olabilir.",
"profileStatusLimitReached": "En fazla sayı olan 100 durumunuz var. Yenisini eklemek için birini silin."
},
"rail": {
"profiles": "Profiller",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "Được bảo vệ bằng mật khẩu",
"ephemeral": "Hồ sơ tạm thời",
"created": "Ngày tạo",
"launched": "Ngày khởi chạy gần nhất"
"launched": "Ngày khởi chạy gần nhất",
"state": "Trạng thái profile"
},
"operators": {
"negate": "Loại trừ những gì khớp",
@@ -1718,6 +1719,29 @@
"upgraded": "Địa chỉ này cũng hỗ trợ DNS mã hóa nên Donut sử dụng nó. Bắt đầu bằng tcp:// để giữ DNS thường."
}
},
"profileStatuses": {
"column": "Trạng thái",
"none": "Không có trạng thái",
"search": "Tìm trạng thái…",
"noMatch": "Không có trạng thái phù hợp",
"manage": "Quản lý trạng thái…",
"title": "Trạng thái profile",
"description": "Gắn nhãn cho mỗi profile theo tình trạng tài khoản. Trạng thái được đồng bộ với các thiết bị khác và nhóm của bạn.",
"empty": "Chưa có trạng thái nào. Hãy bắt đầu với một bộ phổ biến hoặc tự thêm bên dưới.",
"addStarter": "Thêm Hoạt động, Đang làm nóng, Cần xem lại và Bị cấm",
"starter": {
"active": "Hoạt động",
"warming": "Đang làm nóng",
"review": "Cần xem lại",
"banned": "Bị cấm"
},
"namePlaceholder": "Tên trạng thái",
"color": "Màu trạng thái",
"customColor": "Màu tùy chỉnh",
"delete": "Xóa trạng thái",
"deleteTitle": "Xóa \"{{name}}\"?",
"deleteDescription": "Các profile có trạng thái này sẽ không còn trạng thái. Không thể hoàn tác."
},
"vpns": {
"form": {
"titleEdit": "Chỉnh sửa VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "Bạn đã gửi nhiều phản hồi trong thời gian ngắn. Hãy thử lại sau vài phút.",
"feedbackSendFailed": "Không thể gửi phản hồi. Hãy thử lại sau.",
"feedbackLogsUnreadable": "Không thể đọc nhật ký. Hãy tắt \"Đính kèm nhật ký gần đây\" rồi gửi lại.",
"dnsServerInvalid": "Máy chủ DNS này không hợp lệ. Nhập địa chỉ IP hoặc tên máy chủ, tls://host cho DNS over TLS, hoặc URL https:// cho DNS over HTTPS."
"dnsServerInvalid": "Máy chủ DNS này không hợp lệ. Nhập địa chỉ IP hoặc tên máy chủ, tls://host cho DNS over TLS, hoặc URL https:// cho DNS over HTTPS.",
"profileStatusExists": "Đã có trạng thái với tên này.",
"profileStatusNotFound": "Trạng thái này không còn tồn tại.",
"profileStatusColorInvalid": "Hãy chọn màu cho trạng thái.",
"profileStatusNameTooLong": "Tên trạng thái tối đa 40 ký tự.",
"profileStatusLimitReached": "Bạn đã có 100 trạng thái, mức tối đa. Hãy xóa một trạng thái để thêm mới."
},
"rail": {
"profiles": "Profile",
+31 -2
View File
@@ -295,7 +295,8 @@
"locked": "密码保护",
"ephemeral": "临时配置文件",
"created": "创建日期",
"launched": "上次启动日期"
"launched": "上次启动日期",
"state": "配置文件状态"
},
"operators": {
"negate": "排除匹配的结果",
@@ -1718,6 +1719,29 @@
"upgraded": "此地址也提供加密 DNS,因此 Donut 会使用它。以 tcp:// 开头可保留普通 DNS。"
}
},
"profileStatuses": {
"column": "状态",
"none": "无状态",
"search": "搜索状态…",
"noMatch": "没有匹配的状态",
"manage": "管理状态…",
"title": "配置文件状态",
"description": "按账号情况为每个配置文件加上标签。状态会同步到你的其他设备和团队。",
"empty": "还没有状态。可以从常用的一组开始,或在下方自行添加。",
"addStarter": "添加“活跃”“养号中”“待检查”和“已封禁”",
"starter": {
"active": "活跃",
"warming": "养号中",
"review": "待检查",
"banned": "已封禁"
},
"namePlaceholder": "状态名称",
"color": "状态颜色",
"customColor": "自定义颜色",
"delete": "删除状态",
"deleteTitle": "删除“{{name}}”?",
"deleteDescription": "具有此状态的配置文件将不再有状态。此操作无法撤销。"
},
"vpns": {
"form": {
"titleEdit": "编辑 VPN",
@@ -2441,7 +2465,12 @@
"feedbackRateLimited": "你在短时间内发送了大量反馈。请几分钟后再试。",
"feedbackSendFailed": "无法发送反馈。请稍后再试。",
"feedbackLogsUnreadable": "无法读取日志。请关闭“附上最近的日志”后重新发送。",
"dnsServerInvalid": "此 DNS 服务器无效。请输入 IP 地址或主机名、用于 DNS over TLS 的 tls://host,或用于 DNS over HTTPS 的 https:// URL。"
"dnsServerInvalid": "此 DNS 服务器无效。请输入 IP 地址或主机名、用于 DNS over TLS 的 tls://host,或用于 DNS over HTTPS 的 https:// URL。",
"profileStatusExists": "已存在同名状态。",
"profileStatusNotFound": "此状态已不存在。",
"profileStatusColorInvalid": "请为状态选择颜色。",
"profileStatusNameTooLong": "状态名称最多 40 个字符。",
"profileStatusLimitReached": "状态数量已达上限 100 个。请删除一个后再添加。"
},
"rail": {
"profiles": "配置文件",
+15
View File
@@ -25,6 +25,11 @@ export type BackendErrorCode =
| "PASSWORD_TOO_SHORT"
| "INVALID_LAUNCH_HOOK_URL"
| "DNS_SERVER_INVALID"
| "PROFILE_STATUS_EXISTS"
| "PROFILE_STATUS_NOT_FOUND"
| "PROFILE_STATUS_COLOR_INVALID"
| "PROFILE_STATUS_NAME_TOO_LONG"
| "PROFILE_STATUS_LIMIT_REACHED"
| "COOKIE_DB_LOCKED"
| "COOKIE_DB_UNAVAILABLE"
| "COOKIE_IMPORT_BROWSER_RUNNING"
@@ -361,6 +366,16 @@ export function translateBackendError(t: TFunction, err: unknown): string {
return t("backendErrors.invalidLaunchHookUrl");
case "DNS_SERVER_INVALID":
return t("backendErrors.dnsServerInvalid");
case "PROFILE_STATUS_EXISTS":
return t("backendErrors.profileStatusExists");
case "PROFILE_STATUS_NOT_FOUND":
return t("backendErrors.profileStatusNotFound");
case "PROFILE_STATUS_COLOR_INVALID":
return t("backendErrors.profileStatusColorInvalid");
case "PROFILE_STATUS_NAME_TOO_LONG":
return t("backendErrors.profileStatusNameTooLong");
case "PROFILE_STATUS_LIMIT_REACHED":
return t("backendErrors.profileStatusLimitReached");
case "COOKIE_DB_LOCKED":
return t("backendErrors.cookieDbLocked");
case "COOKIE_DB_UNAVAILABLE":
+19
View File
@@ -22,6 +22,10 @@ const ctx = {
proxyNames: new Map([["p1", "Frankfurt residential"]]),
vpnNames: new Map([["v1", "Office WireGuard"]]),
extensionGroupNames: new Map([["e1", "Ad blockers"]]),
statusNames: new Map([
["s1", "Warming up"],
["s2", "Banned"],
]),
runningProfiles: new Set(["shop"]),
now: NOW,
};
@@ -73,6 +77,21 @@ test("plain text also matches the id by prefix, so the table's short id works",
assert.equal(hit("2222", profile()), false);
});
test("state: matches the status name, and none finds profiles without one", () => {
const warming = profile({ status_id: "s1" });
const unlabelled = profile();
assert.equal(hit("state:warming", warming), true);
assert.equal(hit("stage:warm", warming), true);
assert.equal(hit("state:banned", warming), false);
assert.equal(hit("state:none", unlabelled), true);
assert.equal(hit("state:none", warming), false);
assert.equal(hit("state:any", warming), true);
// A status deleted on another device still counts as set until it syncs away.
assert.equal(hit("state:any", profile({ status_id: "gone" })), true);
// The running state keeps its own field.
assert.equal(hit("status:stopped", warming), true);
});
test("a colon inside an ordinary word stays free text", () => {
const noted = profile({
note: "check https://shop.example.com:8080 at 12:30",
+10
View File
@@ -69,6 +69,12 @@ export const PROFILE_SEARCH_FIELDS: readonly ProfileSearchField[] = [
kind: "lookup",
labelKey: "search.fields.group",
},
{
key: "state",
aliases: ["stage"],
kind: "lookup",
labelKey: "search.fields.state",
},
{
key: "proxy",
aliases: [],
@@ -217,6 +223,8 @@ export interface ProfileSearchContext {
readonly proxyNames: ReadonlyMap<string, string>;
readonly vpnNames: ReadonlyMap<string, string>;
readonly extensionGroupNames: ReadonlyMap<string, string>;
/** Profile status id to its name. */
readonly statusNames: ReadonlyMap<string, string>;
readonly runningProfiles: ReadonlySet<string>;
/** Epoch ms the relative dates count back from. Defaults to the wall clock. */
readonly now?: number;
@@ -635,6 +643,8 @@ function fieldValue(
}
case "group":
return lookupValue(profile.group_id, ctx.groupNames);
case "state":
return lookupValue(profile.status_id ?? undefined, ctx.statusNames);
case "proxy":
return lookupValue(profile.proxy_id, ctx.proxyNames);
case "vpn":
+12
View File
@@ -47,6 +47,8 @@ export interface BrowserProfile {
created_at?: number;
dns_blocklist?: string;
dns_server?: string | null;
status_id?: string | null;
start_urls?: string[];
password_protected?: boolean;
}
@@ -348,6 +350,16 @@ export interface ProfileGroup {
last_sync?: number;
}
/** A named, colored label for where a profile's account stands. */
export interface ProfileStatus {
id: string;
name: string;
/** `#rrggbb`, picked by the person. */
color: string;
position: number;
updated_at: number;
}
export interface GroupWithCount {
id: string;
name: string;