release: prepare v2.0.18 interface and reset reliability update
Preserve package progress and history across immediate cleanup, make extraction resets wait for all post-processing tasks, and keep archive diagnostics out of compact status cells. Rework account creation and settings selectors, improve context-menu placement, remove accidental row dragging, and expand regression coverage for the corrected workflows.
This commit is contained in:
+10
-54
@@ -36,7 +36,7 @@ import {
|
||||
getProviderDailyUsageBytes,
|
||||
getProviderUsageDayKey
|
||||
} from "../shared/provider-daily-limits";
|
||||
import { reorderPackageOrderByDrop, sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||
import { sortPackageOrderByName, sortPackagesForDisplay } from "./package-order";
|
||||
import { pruneSelection, shouldClearDownloadSelection, shouldClearDownloadSelectionOnEscape } from "./selection";
|
||||
import { buildBulkAccountEnabledState, buildConfiguredProviderOrder, getAccountDialogSelectableOptions, matchesAccountModeFilter, pruneAccountRowSelection, resolveAccountUsername, resolveVisibleAccountKind } from "./account-ui";
|
||||
import type { AccountModeFilter } from "./account-ui";
|
||||
@@ -107,6 +107,7 @@ import {
|
||||
buildSettingsFormViewModel,
|
||||
buildTargetedAccountCheck,
|
||||
projectAccountRows,
|
||||
resolveHistoryRetentionSelection,
|
||||
sortAccountRows,
|
||||
type AccountAddOption,
|
||||
type AccountRowSource,
|
||||
@@ -1765,7 +1766,6 @@ export function App(): ReactElement {
|
||||
const serverPackageOrderRef = useRef<string[]>([]);
|
||||
const pendingPackageOrderRef = useRef<string[] | null>(null);
|
||||
const pendingPackageOrderAtRef = useRef(0);
|
||||
const draggedPackageIdRef = useRef<string | null>(null);
|
||||
const [collapsedPackages, setCollapsedPackages] = useState<Record<string, boolean>>({});
|
||||
const [downloadSearch, setDownloadSearch] = useState("");
|
||||
const [downloadDisplayMode, setDownloadDisplayMode] = useState<DownloadDisplayMode>("packages");
|
||||
@@ -3871,34 +3871,6 @@ export function App(): ReactElement {
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
const reorderPackagesByDrop = useCallback((draggedPackageId: string, targetPackageId: string) => {
|
||||
const currentOrder = packageOrderRef.current;
|
||||
const nextOrder = reorderPackageOrderByDrop(currentOrder, draggedPackageId, targetPackageId);
|
||||
const unchanged = nextOrder.length === currentOrder.length
|
||||
&& nextOrder.every((id, index) => id === currentOrder[index]);
|
||||
if (unchanged) {
|
||||
return;
|
||||
}
|
||||
setDownloadsSortDescending(false);
|
||||
pendingPackageOrderRef.current = [...nextOrder];
|
||||
pendingPackageOrderAtRef.current = Date.now();
|
||||
packageOrderRef.current = [...nextOrder];
|
||||
setSnapshot((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, session: { ...prev.session, packageOrder: [...nextOrder] } };
|
||||
});
|
||||
void window.rd.reorderPackages(nextOrder).catch((error) => {
|
||||
pendingPackageOrderRef.current = null;
|
||||
pendingPackageOrderAtRef.current = 0;
|
||||
packageOrderRef.current = serverPackageOrderRef.current;
|
||||
setSnapshot((prev) => {
|
||||
if (!prev) return prev;
|
||||
return { ...prev, session: { ...prev.session, packageOrder: serverPackageOrderRef.current } };
|
||||
});
|
||||
showToast(`Sortierung fehlgeschlagen: ${String(error)}`, 2400);
|
||||
});
|
||||
}, [showToast]);
|
||||
|
||||
const addCollectorTab = (): void => {
|
||||
const id = `tab-${nextCollectorId++}`;
|
||||
setCollectorTabs((prev) => {
|
||||
@@ -3992,23 +3964,6 @@ export function App(): ReactElement {
|
||||
setCollectorError("");
|
||||
};
|
||||
|
||||
const onPackageDragStart = useCallback((packageId: string) => {
|
||||
draggedPackageIdRef.current = packageId;
|
||||
}, []);
|
||||
|
||||
const onPackageDrop = useCallback((targetPackageId: string) => {
|
||||
const draggedPackageId = draggedPackageIdRef.current;
|
||||
draggedPackageIdRef.current = null;
|
||||
if (!draggedPackageId || draggedPackageId === targetPackageId) {
|
||||
return;
|
||||
}
|
||||
reorderPackagesByDrop(draggedPackageId, targetPackageId);
|
||||
}, [reorderPackagesByDrop]);
|
||||
|
||||
const onPackageDragEnd = useCallback(() => {
|
||||
draggedPackageIdRef.current = null;
|
||||
}, []);
|
||||
|
||||
const onPackageStartEdit = useCallback((packageId: string, packageName: string): void => {
|
||||
setEditingPackageId(packageId);
|
||||
setEditingName(packageName);
|
||||
@@ -5026,9 +4981,6 @@ export function App(): ReactElement {
|
||||
});
|
||||
},
|
||||
onShowAllPackages: () => setShowAllPackages(true),
|
||||
onPackageDragStart,
|
||||
onPackageDrop,
|
||||
onPackageDragEnd,
|
||||
onSetVisibleSelection: (ids, selected) => {
|
||||
setSelectedIds((current) => {
|
||||
const next = new Set(current);
|
||||
@@ -5358,6 +5310,12 @@ export function App(): ReactElement {
|
||||
applyTheme(next);
|
||||
return;
|
||||
}
|
||||
if (fieldId === "historyRetentionMode" && typeof value === "string") {
|
||||
const next = resolveHistoryRetentionSelection(settingsDraft.historyRetentionMode, settingsDraft.historyMaxEntries, value);
|
||||
setText("historyRetentionMode", next.historyRetentionMode);
|
||||
setNum("historyMaxEntries", next.historyMaxEntries);
|
||||
return;
|
||||
}
|
||||
if (typeof value === "boolean") {
|
||||
setBool(fieldId as keyof AppSettings, value);
|
||||
return;
|
||||
@@ -5579,8 +5537,7 @@ export function App(): ReactElement {
|
||||
className={`md-runtime-root${dragOver ? " drag-over" : ""}${tab === "settings" ? " settings-active" : ""}`}
|
||||
onDragEnter={(event) => {
|
||||
event.preventDefault();
|
||||
if (draggedPackageIdRef.current) { return; }
|
||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||
const hasFiles = event.dataTransfer.types.includes("Files");
|
||||
const hasUri = event.dataTransfer.types.includes("text/uri-list");
|
||||
if (!hasFiles && !hasUri) { return; }
|
||||
dragDepthRef.current += 1;
|
||||
@@ -5593,8 +5550,7 @@ export function App(): ReactElement {
|
||||
e.preventDefault();
|
||||
}}
|
||||
onDragLeave={() => {
|
||||
if (draggedPackageIdRef.current) { return; }
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
dragDepthRef.current = Math.max(0, dragDepthRef.current - 1);
|
||||
if (dragDepthRef.current === 0 && dragOverRef.current) {
|
||||
dragOverRef.current = false;
|
||||
setDragOver(false);
|
||||
|
||||
@@ -30,7 +30,7 @@ export function compactProviderLabels(labels: string[]): string {
|
||||
}
|
||||
|
||||
export function normalizeDownloadServiceLabel(label: string): string {
|
||||
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 $2")).filter(Boolean))].join(", ");
|
||||
return [...new Set(label.split(",").map((entry) => entry.trim().replace(/^(Mega-Debrid)\s+(Web|API)(?:\s+\([^)]*\))?$/i, "$1 ($2)")).filter(Boolean))].join(", ");
|
||||
}
|
||||
|
||||
export function compactDownloadServiceLabel(label: string): string {
|
||||
|
||||
@@ -11,7 +11,7 @@ const pairs = [
|
||||
["Speicherort, Download-Verhalten, Verlauf, Oberfläche und Benachrichtigungen.", "Storage location, download behavior, history, interface and notifications."],
|
||||
["Download-Ordner", "Download folder"], ["Paketname (optional)", "Package name (optional)"], ["Max. gleichzeitige Downloads", "Max. concurrent downloads"], ["Automatische Wiederholungen", "Automatic retries"],
|
||||
["Zielordner für heruntergeladene Dateien.", "Destination folder for downloaded files."],
|
||||
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Dauerhaft", "Permanent"],
|
||||
["Beim Start automatisch fortsetzen", "Resume automatically on startup"], ["Zwischenablage überwachen", "Monitor clipboard"], ["Verlauf speichern", "Save history"], ["Nur aktuelle Session", "Current session only"], ["Nur letzte 100 Einträge", "Last 100 entries only"], ["Nur letzte 250 Einträge", "Last 250 entries only"], ["Dauerhaft", "Permanent"],
|
||||
["Maximale Verlauf-Einträge", "Maximum history entries"], ["Einträge löschen älter als (Tage)", "Delete entries older than (days)"], ["Neue Pakete eingeklappt zeigen", "Show new packages collapsed"],
|
||||
["Nach Fortschritt sortieren", "Sort by progress"], ["In den Infobereich minimieren", "Minimize to tray"], ["Vor dem Löschen nachfragen", "Confirm before deleting"], ["Download-Liste mitsichern", "Include download list in backup"],
|
||||
["Ferndiagnose-Einstellungen mitsichern", "Include remote diagnostics settings in backup"], ["Webhook-Adresse", "Webhook address"], ["Discord-Erwähnung (optional)", "Discord mention (optional)"],
|
||||
@@ -29,7 +29,7 @@ const pairs = [
|
||||
["Hoch", "High"], ["Normal", "Normal"], ["Niedrig", "Low"], ["In Warteschlange", "Queued"], ["Abgeschlossen", "Completed"], ["Entpackt", "Extracted"], ["Automatisch entpacken", "Extract automatically"],
|
||||
["Liste leeren", "Clear list"], ["Sitzung", "Session"], ["Gesamt", "Total"], ["Bereit", "Ready"], ["Download läuft", "Download running"], ["Wartet", "Waiting"], ["Offline", "Offline"],
|
||||
["Übersicht", "Overview"], ["Verwendungsregeln", "Usage rules"], ["Accountverwaltung", "Account management"], ["Accounts hinzufügen, prüfen und verwalten.", "Add, check and manage accounts."],
|
||||
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
||||
["Accounts zum Herunterladen verwenden", "Use accounts for downloads"], ["Download-Traffic übrig", "Download traffic remaining"], ["Benutzername", "Username"], ["E-Mail", "Email"], ["Verfallsdatum", "Expiration date"], ["Passwort/Zugang", "Password/access"],
|
||||
["Account hinzufügen", "Add account"], ["Ausgewählte prüfen", "Check selected"], ["Ausgewählte entfernen", "Remove selected"], ["Aktivieren", "Enable"], ["Deaktivieren", "Disable"], ["Noch nicht geprüft", "Not checked yet"],
|
||||
["Aktiviert", "Enabled"], ["Aktionen", "Actions"], ["Deaktiviert", "Disabled"], ["Premium aktiv", "Premium active"], ["API-Key aktiv", "API key active"], ["API-Account", "API account"], ["API-Key", "API key"],
|
||||
["Ungültiger API-Key (nicht autorisiert)", "Invalid API key (not authorized)"], ["Free Account", "Free account"], ["Unbeschränkt", "Unlimited"], ["Keine Accounts eingerichtet", "No accounts configured"],
|
||||
@@ -75,7 +75,7 @@ const pairs = [
|
||||
["Füge einen Account hinzu, um Downloads über einen Anbieter zu starten.", "Add an account to start downloads through a provider."], ["Noch keine Accounts", "No accounts yet"], ["Keine Provider konfiguriert.", "No providers configured."],
|
||||
["Keine eigenen Zuordnungen.", "No custom assignments."], ["Hoster-Routing hinzufügen", "Add hoster routing"], ["Hoster hinzufügen…", "Add hoster…"], ["Eigener Hoster…", "Custom hoster…"], ["Noch keine Rotations-Ereignisse.", "No rotation events yet."],
|
||||
["Prüfen und speichern", "Check and save"], ["Wähle einen Dienst und trage die passenden Zugangsdaten ein.", "Choose a service and enter the matching credentials."], ["Accounts durchsuchen", "Search accounts"],
|
||||
["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||
["Dienst / Zugangstyp", "Service / access type"], ["Dienst", "Service"], ["Typ/Funktion", "Type/function"], ["Dienst oder Zugangstyp suchen", "Search service or access type"], ["Account-Typ filtern", "Filter account type"], ["Verfügbare Account-Typen", "Available account types"], ["Keine passenden Account-Typen.", "No matching account types."],
|
||||
["Prüfen", "Check"], ["Bearbeite ausschließlich den ausgewählten Account.", "Edit only the selected account."], ["Account bearbeiten", "Edit account"], ["Account aktiviert", "Account enabled"],
|
||||
["Immer erste Tonspur", "Always first audio track"], ["Pro Download", "Per download"], ["Keine Archive löschen", "Do not delete archives"], ["Archive in Papierkorb", "Move archives to recycle bin"], ["Archive löschen", "Delete archives"],
|
||||
["Accounts und Verwendungsregeln.", "Accounts and usage rules."], ["Premium Account", "Premium account"], ["Zugang ungültig", "Invalid access"], ["Prüft…", "Checking…"], ["Geschützter Zugang", "Protected access"],
|
||||
@@ -238,6 +238,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
if (assignment) return `Remove ${assignment[1]} assignment`;
|
||||
const providerFor = value.match(/^Provider für (.+)$/);
|
||||
if (providerFor) return `Provider for ${providerFor[1]}`;
|
||||
const credentialsFor = value.match(/^Zugangsdaten für (.+)$/);
|
||||
if (credentialsFor) return `Credentials for ${credentialsFor[1]}`;
|
||||
const move = value.match(/^(.+) nach (oben|unten)$/);
|
||||
if (move) return `Move ${move[1]} ${move[2] === "oben" ? "up" : "down"}`;
|
||||
const audio = value.match(/^Tonspur: (.+)$/);
|
||||
@@ -385,6 +387,8 @@ function translateDynamic(value: string, language: AppLanguage): string {
|
||||
if (extracting) return `Entpacken ${extracting[1]}`;
|
||||
const checkedUntil = value.match(/^Account checked — (.+) until (.+)$/);
|
||||
if (checkedUntil) return `Account geprüft — ${checkedUntil[1]} bis ${checkedUntil[2]}`;
|
||||
const credentialsFor = value.match(/^Credentials for (.+)$/);
|
||||
if (credentialsFor) return `Zugangsdaten für ${credentialsFor[1]}`;
|
||||
const checked = value.match(/^Account checked — (.+)$/);
|
||||
if (checked) return `Account geprüft — ${checked[1]}`;
|
||||
const invalid = value.match(/^Invalid account — (.+)$/);
|
||||
|
||||
@@ -773,11 +773,7 @@
|
||||
background: var(--ui-border);
|
||||
}
|
||||
|
||||
.md-context-menu .ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items {
|
||||
display: block;
|
||||
}
|
||||
|
||||
.md-toast {
|
||||
.md-toast {
|
||||
right: 20px;
|
||||
bottom: 84px;
|
||||
z-index: var(--md-layer-toast);
|
||||
|
||||
+24
-13
@@ -2710,9 +2710,9 @@ td {
|
||||
content: "";
|
||||
}
|
||||
|
||||
.ctx-menu-sub-items {
|
||||
display: none;
|
||||
position: absolute;
|
||||
.ctx-menu-sub-items {
|
||||
display: block;
|
||||
position: absolute;
|
||||
left: 100%;
|
||||
top: 0;
|
||||
min-width: 120px;
|
||||
@@ -2720,13 +2720,19 @@ td {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 6px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 1001;
|
||||
}
|
||||
|
||||
.ctx-menu-sub:hover .ctx-menu-sub-items {
|
||||
display: block;
|
||||
}
|
||||
box-shadow: 0 4px 12px rgba(0,0,0,.3);
|
||||
z-index: 1001;
|
||||
opacity: 0;
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
}
|
||||
|
||||
.ctx-menu-sub:hover > .ctx-menu-sub-items.is-positioned,
|
||||
.ctx-menu-sub.is-keyboard-open > .ctx-menu-sub-items.is-positioned {
|
||||
opacity: 1;
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
}
|
||||
|
||||
.ctx-menu-active {
|
||||
color: var(--accent) !important;
|
||||
@@ -3049,7 +3055,7 @@ td {
|
||||
z-index: 50;
|
||||
}
|
||||
|
||||
.ctx-menu {
|
||||
.ctx-menu {
|
||||
position: fixed;
|
||||
z-index: 100;
|
||||
min-width: 200px;
|
||||
@@ -3057,8 +3063,13 @@ td {
|
||||
border: 1px solid var(--border);
|
||||
border-radius: 8px;
|
||||
padding: 4px 0;
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
box-shadow: 0 8px 24px rgba(0, 0, 0, 0.4);
|
||||
}
|
||||
|
||||
.ctx-menu:not(.is-positioned) {
|
||||
visibility: hidden;
|
||||
opacity: 0;
|
||||
}
|
||||
|
||||
.ctx-menu-item {
|
||||
display: block;
|
||||
|
||||
+320
-316
@@ -1,323 +1,327 @@
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject
|
||||
} from "react";
|
||||
import { restoreFocus } from "./focus";
|
||||
|
||||
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export interface ContextMenuProps {
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
ignoreOutsideRefs?: Array<RefObject<HTMLElement>>;
|
||||
}
|
||||
|
||||
export type ContextMenuKeyboardAction =
|
||||
| { type: "focus"; index: number }
|
||||
| { type: "activate"; index: number }
|
||||
| { type: "close" };
|
||||
|
||||
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
|
||||
|
||||
export function clampContextMenuPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
|
||||
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextSubmenuPosition(
|
||||
trigger: { left: number; right: number; top: number },
|
||||
submenu: { width: number; height: number },
|
||||
viewport: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0;
|
||||
return clampContextMenuPosition(
|
||||
opensRight ? trigger.right : trigger.left - submenu.width,
|
||||
trigger.top,
|
||||
submenu.width,
|
||||
submenu.height,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
);
|
||||
}
|
||||
|
||||
export function getContextMenuKeyboardAction(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
enabled: boolean[]
|
||||
): ContextMenuKeyboardAction | null {
|
||||
const indexes = enabled.flatMap((value, index) => value ? [index] : []);
|
||||
if (key === "Escape") {
|
||||
return { type: "close" };
|
||||
}
|
||||
if (indexes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (key === "Enter" || key === " ") {
|
||||
return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] };
|
||||
}
|
||||
if (key === "Home") {
|
||||
return { type: "focus", index: indexes[0] };
|
||||
}
|
||||
if (key === "End") {
|
||||
return { type: "focus", index: indexes[indexes.length - 1] };
|
||||
}
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp") {
|
||||
return null;
|
||||
}
|
||||
const enabledPosition = indexes.indexOf(currentIndex);
|
||||
if (enabledPosition < 0) {
|
||||
return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] };
|
||||
}
|
||||
const direction = key === "ArrowDown" ? 1 : -1;
|
||||
const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length;
|
||||
return { type: "focus", index: indexes[nextPosition] };
|
||||
}
|
||||
|
||||
export function getContextMenuSubmenuKeyboardAction(
|
||||
key: string,
|
||||
hasSubmenu: boolean,
|
||||
insideSubmenu: boolean
|
||||
): ContextMenuSubmenuKeyboardAction | null {
|
||||
if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) {
|
||||
return "open";
|
||||
}
|
||||
if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) {
|
||||
return "close";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMenuItemSemantics(node: ReactNode): ReactNode {
|
||||
return Children.map(node, (child) => {
|
||||
if (!isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
const element = child as ReactElement<{
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
role?: string;
|
||||
tabIndex?: number;
|
||||
}>;
|
||||
if (typeof element.type === "string" && element.type === "button") {
|
||||
return cloneElement(element, { role: "menuitem", tabIndex: -1 });
|
||||
}
|
||||
if (element.props.children === undefined) {
|
||||
return element;
|
||||
}
|
||||
return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) });
|
||||
});
|
||||
}
|
||||
|
||||
function getMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return Array.from(menu?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? []).filter((item) => {
|
||||
if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") {
|
||||
return false;
|
||||
}
|
||||
return item.getClientRects().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items"));
|
||||
}
|
||||
|
||||
function getSubmenuParts(item: HTMLElement | null): {
|
||||
container: HTMLElement;
|
||||
trigger: HTMLElement;
|
||||
items: HTMLElement;
|
||||
} | null {
|
||||
const container = item?.closest<HTMLElement>(".ctx-menu-sub") ?? null;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']"));
|
||||
const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items"));
|
||||
if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
return { container, trigger, items };
|
||||
}
|
||||
|
||||
function openSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
parts.container.classList.add("is-keyboard-open");
|
||||
parts.trigger.setAttribute("aria-expanded", "true");
|
||||
positionSubmenu(parts);
|
||||
getMenuItems(parts.items)[0]?.focus();
|
||||
}
|
||||
|
||||
function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>): void {
|
||||
const triggerRect = parts.trigger.getBoundingClientRect();
|
||||
const submenuRect = parts.items.getBoundingClientRect();
|
||||
const position = getContextSubmenuPosition(
|
||||
triggerRect,
|
||||
submenuRect,
|
||||
{ width: window.innerWidth, height: window.innerHeight }
|
||||
);
|
||||
import {
|
||||
Children,
|
||||
cloneElement,
|
||||
forwardRef,
|
||||
isValidElement,
|
||||
useEffect,
|
||||
useImperativeHandle,
|
||||
useLayoutEffect,
|
||||
useRef,
|
||||
useState,
|
||||
type KeyboardEvent,
|
||||
type ReactElement,
|
||||
type ReactNode,
|
||||
type RefObject
|
||||
} from "react";
|
||||
import { restoreFocus } from "./focus";
|
||||
|
||||
const useImmediateEffect = typeof document === "undefined" ? useEffect : useLayoutEffect;
|
||||
|
||||
export interface ContextMenuProps {
|
||||
open: boolean;
|
||||
x: number;
|
||||
y: number;
|
||||
onClose: () => void;
|
||||
children: ReactNode;
|
||||
ariaLabel?: string;
|
||||
className?: string;
|
||||
ignoreOutsideRefs?: Array<RefObject<HTMLElement>>;
|
||||
}
|
||||
|
||||
export type ContextMenuKeyboardAction =
|
||||
| { type: "focus"; index: number }
|
||||
| { type: "activate"; index: number }
|
||||
| { type: "close" };
|
||||
|
||||
export type ContextMenuSubmenuKeyboardAction = "open" | "close";
|
||||
|
||||
export function clampContextMenuPosition(
|
||||
x: number,
|
||||
y: number,
|
||||
width: number,
|
||||
height: number,
|
||||
viewportWidth: number,
|
||||
viewportHeight: number
|
||||
): { x: number; y: number } {
|
||||
return {
|
||||
x: Math.max(0, Math.min(x, Math.max(0, viewportWidth - width))),
|
||||
y: Math.max(0, Math.min(y, Math.max(0, viewportHeight - height)))
|
||||
};
|
||||
}
|
||||
|
||||
export function getContextSubmenuPosition(
|
||||
trigger: { left: number; right: number; top: number },
|
||||
submenu: { width: number; height: number },
|
||||
viewport: { width: number; height: number }
|
||||
): { x: number; y: number } {
|
||||
const opensRight = trigger.right + submenu.width <= viewport.width || trigger.left - submenu.width < 0;
|
||||
return clampContextMenuPosition(
|
||||
opensRight ? trigger.right : trigger.left - submenu.width,
|
||||
trigger.top,
|
||||
submenu.width,
|
||||
submenu.height,
|
||||
viewport.width,
|
||||
viewport.height
|
||||
);
|
||||
}
|
||||
|
||||
export function getContextMenuKeyboardAction(
|
||||
key: string,
|
||||
currentIndex: number,
|
||||
enabled: boolean[]
|
||||
): ContextMenuKeyboardAction | null {
|
||||
const indexes = enabled.flatMap((value, index) => value ? [index] : []);
|
||||
if (key === "Escape") {
|
||||
return { type: "close" };
|
||||
}
|
||||
if (indexes.length === 0) {
|
||||
return null;
|
||||
}
|
||||
if (key === "Enter" || key === " ") {
|
||||
return { type: "activate", index: enabled[currentIndex] ? currentIndex : indexes[0] };
|
||||
}
|
||||
if (key === "Home") {
|
||||
return { type: "focus", index: indexes[0] };
|
||||
}
|
||||
if (key === "End") {
|
||||
return { type: "focus", index: indexes[indexes.length - 1] };
|
||||
}
|
||||
if (key !== "ArrowDown" && key !== "ArrowUp") {
|
||||
return null;
|
||||
}
|
||||
const enabledPosition = indexes.indexOf(currentIndex);
|
||||
if (enabledPosition < 0) {
|
||||
return { type: "focus", index: key === "ArrowDown" ? indexes[0] : indexes[indexes.length - 1] };
|
||||
}
|
||||
const direction = key === "ArrowDown" ? 1 : -1;
|
||||
const nextPosition = (enabledPosition + direction + indexes.length) % indexes.length;
|
||||
return { type: "focus", index: indexes[nextPosition] };
|
||||
}
|
||||
|
||||
export function getContextMenuSubmenuKeyboardAction(
|
||||
key: string,
|
||||
hasSubmenu: boolean,
|
||||
insideSubmenu: boolean
|
||||
): ContextMenuSubmenuKeyboardAction | null {
|
||||
if (hasSubmenu && (key === "Enter" || key === "ArrowRight")) {
|
||||
return "open";
|
||||
}
|
||||
if (insideSubmenu && (key === "ArrowLeft" || key === "Escape")) {
|
||||
return "close";
|
||||
}
|
||||
return null;
|
||||
}
|
||||
|
||||
function applyMenuItemSemantics(node: ReactNode): ReactNode {
|
||||
return Children.map(node, (child) => {
|
||||
if (!isValidElement(child)) {
|
||||
return child;
|
||||
}
|
||||
const element = child as ReactElement<{
|
||||
children?: ReactNode;
|
||||
disabled?: boolean;
|
||||
role?: string;
|
||||
tabIndex?: number;
|
||||
}>;
|
||||
if (typeof element.type === "string" && element.type === "button") {
|
||||
return cloneElement(element, { role: "menuitem", tabIndex: -1 });
|
||||
}
|
||||
if (element.props.children === undefined) {
|
||||
return element;
|
||||
}
|
||||
return cloneElement(element, { children: applyMenuItemSemantics(element.props.children) });
|
||||
});
|
||||
}
|
||||
|
||||
function getMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return Array.from(menu?.querySelectorAll<HTMLElement>("[role='menuitem']") ?? []).filter((item) => {
|
||||
if (item.matches(":disabled") || item.getAttribute("aria-disabled") === "true") {
|
||||
return false;
|
||||
}
|
||||
return item.getClientRects().length > 0;
|
||||
});
|
||||
}
|
||||
|
||||
function getTopLevelMenuItems(menu: HTMLElement | null): HTMLElement[] {
|
||||
return getMenuItems(menu).filter((item) => !item.closest(".ctx-menu-sub-items"));
|
||||
}
|
||||
|
||||
function getSubmenuParts(item: HTMLElement | null): {
|
||||
container: HTMLElement;
|
||||
trigger: HTMLElement;
|
||||
items: HTMLElement;
|
||||
} | null {
|
||||
const container = item?.closest<HTMLElement>(".ctx-menu-sub") ?? null;
|
||||
if (!container) {
|
||||
return null;
|
||||
}
|
||||
const trigger = Array.from(container.children).find((child) => child.matches("[role='menuitem']"));
|
||||
const items = Array.from(container.children).find((child) => child.matches(".ctx-menu-sub-items"));
|
||||
if (!(trigger instanceof HTMLElement) || !(items instanceof HTMLElement)) {
|
||||
return null;
|
||||
}
|
||||
return { container, trigger, items };
|
||||
}
|
||||
|
||||
function openSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
parts.container.classList.add("is-keyboard-open");
|
||||
parts.trigger.setAttribute("aria-expanded", "true");
|
||||
positionSubmenu(parts);
|
||||
getMenuItems(parts.items)[0]?.focus();
|
||||
}
|
||||
|
||||
function positionSubmenu(parts: NonNullable<ReturnType<typeof getSubmenuParts>>): void {
|
||||
const triggerRect = parts.trigger.getBoundingClientRect();
|
||||
const submenuRect = parts.items.getBoundingClientRect();
|
||||
const position = getContextSubmenuPosition(
|
||||
triggerRect,
|
||||
submenuRect,
|
||||
{ width: window.innerWidth, height: window.innerHeight }
|
||||
);
|
||||
parts.items.style.position = "fixed";
|
||||
parts.items.style.left = `${position.x}px`;
|
||||
parts.items.style.top = `${position.y}px`;
|
||||
parts.items.classList.add("is-positioned");
|
||||
}
|
||||
|
||||
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
|
||||
function closeSubmenu(parts: ReturnType<typeof getSubmenuParts>): void {
|
||||
if (!parts) {
|
||||
return;
|
||||
}
|
||||
parts.container.classList.remove("is-keyboard-open");
|
||||
parts.trigger.setAttribute("aria-expanded", "false");
|
||||
parts.trigger.focus();
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu({
|
||||
open,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
children,
|
||||
ariaLabel = "Kontextmenü",
|
||||
className = "",
|
||||
ignoreOutsideRefs = []
|
||||
}, forwardedRef): ReactElement | null {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
|
||||
const [position, setPosition] = useState({ x, y });
|
||||
onCloseRef.current = onClose;
|
||||
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
|
||||
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
|
||||
|
||||
useImmediateEffect(() => {
|
||||
if (!open || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!previousFocusRef.current) {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
||||
setPosition((current) => current.x === next.x && current.y === next.y ? current : next);
|
||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||
}, [open, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onOutside = (event: MouseEvent): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
window.addEventListener("mousedown", onOutside);
|
||||
parts.items.classList.remove("is-positioned");
|
||||
parts.trigger.setAttribute("aria-expanded", "false");
|
||||
parts.trigger.focus();
|
||||
}
|
||||
|
||||
export const ContextMenu = forwardRef<HTMLDivElement, ContextMenuProps>(function ContextMenu({
|
||||
open,
|
||||
x,
|
||||
y,
|
||||
onClose,
|
||||
children,
|
||||
ariaLabel = "Kontextmenü",
|
||||
className = "",
|
||||
ignoreOutsideRefs = []
|
||||
}, forwardedRef): ReactElement | null {
|
||||
const menuRef = useRef<HTMLDivElement>(null);
|
||||
const previousFocusRef = useRef<HTMLElement | null>(null);
|
||||
const onCloseRef = useRef(onClose);
|
||||
const ignoreOutsideRefsRef = useRef(ignoreOutsideRefs);
|
||||
const [position, setPosition] = useState({ x, y, sourceX: x, sourceY: y, ready: false });
|
||||
onCloseRef.current = onClose;
|
||||
ignoreOutsideRefsRef.current = ignoreOutsideRefs;
|
||||
useImperativeHandle(forwardedRef, () => menuRef.current as HTMLDivElement);
|
||||
|
||||
useImmediateEffect(() => {
|
||||
if (!open || !menuRef.current) {
|
||||
return;
|
||||
}
|
||||
if (!previousFocusRef.current) {
|
||||
previousFocusRef.current = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
}
|
||||
const rect = menuRef.current.getBoundingClientRect();
|
||||
const next = clampContextMenuPosition(x, y, rect.width, rect.height, window.innerWidth, window.innerHeight);
|
||||
setPosition((current) => current.x === next.x && current.y === next.y && current.sourceX === x && current.sourceY === y && current.ready
|
||||
? current
|
||||
: { ...next, sourceX: x, sourceY: y, ready: true });
|
||||
getTopLevelMenuItems(menuRef.current)[0]?.focus();
|
||||
}, [open, x, y]);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) {
|
||||
return;
|
||||
}
|
||||
const onOutside = (event: MouseEvent): void => {
|
||||
const target = event.target;
|
||||
if (!(target instanceof Node) || menuRef.current?.contains(target)) {
|
||||
return;
|
||||
}
|
||||
if (ignoreOutsideRefsRef.current.some((ref) => ref.current?.contains(target))) {
|
||||
return;
|
||||
}
|
||||
onCloseRef.current();
|
||||
};
|
||||
window.addEventListener("pointerdown", onOutside, true);
|
||||
window.addEventListener("contextmenu", onOutside);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", onOutside);
|
||||
window.removeEventListener("contextmenu", onOutside);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
previousFocusRef.current = null;
|
||||
restoreFocus(previousFocus);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const submenu = getSubmenuParts(activeItem);
|
||||
const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items"));
|
||||
const hasSubmenu = submenu?.trigger === activeItem;
|
||||
const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu);
|
||||
if (submenuAction) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (submenuAction === "open") {
|
||||
openSubmenu(submenu);
|
||||
} else {
|
||||
closeSubmenu(submenu);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const submenuItems = insideSubmenu ? activeItem?.closest<HTMLElement>(".ctx-menu-sub-items") ?? null : null;
|
||||
const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current);
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true));
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.type === "close") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (action.type === "activate") {
|
||||
items[action.index]?.click();
|
||||
return;
|
||||
}
|
||||
items[action.index]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className={["ctx-menu", "md-context-menu", className].filter(Boolean).join(" ")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
event.preventDefault();
|
||||
openSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseOver={(event) => {
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
positionSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{applyMenuItemSemantics(children)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
window.removeEventListener("pointerdown", onOutside, true);
|
||||
window.removeEventListener("contextmenu", onOutside);
|
||||
const previousFocus = previousFocusRef.current;
|
||||
previousFocusRef.current = null;
|
||||
restoreFocus(previousFocus);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
if (!open) {
|
||||
return null;
|
||||
}
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLDivElement>): void => {
|
||||
const activeItem = document.activeElement instanceof HTMLElement ? document.activeElement : null;
|
||||
const submenu = getSubmenuParts(activeItem);
|
||||
const insideSubmenu = Boolean(activeItem?.closest(".ctx-menu-sub-items"));
|
||||
const hasSubmenu = submenu?.trigger === activeItem;
|
||||
const submenuAction = getContextMenuSubmenuKeyboardAction(event.key, hasSubmenu, insideSubmenu);
|
||||
if (submenuAction) {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (submenuAction === "open") {
|
||||
openSubmenu(submenu);
|
||||
} else {
|
||||
closeSubmenu(submenu);
|
||||
}
|
||||
return;
|
||||
}
|
||||
const submenuItems = insideSubmenu ? activeItem?.closest<HTMLElement>(".ctx-menu-sub-items") ?? null : null;
|
||||
const items = submenuItems ? getMenuItems(submenuItems) : getTopLevelMenuItems(menuRef.current);
|
||||
const currentIndex = items.findIndex((item) => item === document.activeElement);
|
||||
const action = getContextMenuKeyboardAction(event.key, currentIndex, items.map(() => true));
|
||||
if (!action) {
|
||||
return;
|
||||
}
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
if (action.type === "close") {
|
||||
onClose();
|
||||
return;
|
||||
}
|
||||
if (action.type === "activate") {
|
||||
items[action.index]?.click();
|
||||
return;
|
||||
}
|
||||
items[action.index]?.focus();
|
||||
};
|
||||
|
||||
return (
|
||||
<div
|
||||
aria-label={ariaLabel}
|
||||
className={["ctx-menu", "md-context-menu", position.ready && position.sourceX === x && position.sourceY === y ? "is-positioned" : "", className].filter(Boolean).join(" ")}
|
||||
onClick={(event) => {
|
||||
event.stopPropagation();
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
event.preventDefault();
|
||||
openSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
onKeyDown={onKeyDown}
|
||||
onMouseOver={(event) => {
|
||||
const item = event.target instanceof Element ? event.target.closest<HTMLElement>("[role='menuitem']") : null;
|
||||
const submenu = getSubmenuParts(item);
|
||||
if (submenu?.trigger === item) {
|
||||
positionSubmenu(submenu);
|
||||
}
|
||||
}}
|
||||
ref={menuRef}
|
||||
role="menu"
|
||||
style={{ left: position.x, top: position.y }}
|
||||
>
|
||||
{applyMenuItemSemantics(children)}
|
||||
</div>
|
||||
);
|
||||
});
|
||||
|
||||
@@ -124,6 +124,12 @@ export function compactDownloadStatus(value: string): string {
|
||||
if (/Link wird umgewandelt/i.test(status)) return "Umwandeln";
|
||||
if (/Download läuft\b/i.test(status)) return "Download läuft";
|
||||
if (/Download running\b/i.test(status)) return "Download running";
|
||||
if (/^Passwort gefunden\b/i.test(status)) return "Passwort gefunden";
|
||||
if (/^Password found\b/i.test(status)) return "Password found";
|
||||
if (/^Entpack-Fehler\b/i.test(status)) return "Entpack-Fehler";
|
||||
if (/^Extraction error\b/i.test(status)) return "Extraction error";
|
||||
const extractionPending = status.match(/^(Entpacken|Extracting)\s*-\s*(Ausstehend|Pending|Warten auf Parts|Waiting for parts)/i);
|
||||
if (extractionPending) return `${extractionPending[1]} - ${extractionPending[2]}`;
|
||||
const extracting = status.match(/Entpacken\s+(\d+)%/i);
|
||||
if (extracting) return `Entpacken - ${extracting[1]}%`;
|
||||
const extractingEnglish = status.match(/Extracting\s+(\d+)%/i);
|
||||
@@ -340,15 +346,15 @@ function PackageItemsTransition({ actions, collapsed, columnOrder, gridTemplate,
|
||||
);
|
||||
}
|
||||
|
||||
function packageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
let done = 0;
|
||||
export function getPackageProgress(row: DownloadPackageRow): { done: number; failed: number; cancelled: number; total: number; value: number } {
|
||||
let done = Math.max(0, Number(row.package.cleanedCompletedItemCount || 0));
|
||||
let failed = 0;
|
||||
let cancelled = 0;
|
||||
let extracted = 0;
|
||||
let extracted = Math.max(0, Number(row.package.cleanedExtractedItemCount || 0));
|
||||
let extracting = false;
|
||||
let activeProgress = 0;
|
||||
let extractingProgress = 0;
|
||||
for (const item of row.items) {
|
||||
for (const item of row.allItems) {
|
||||
if (item.status === "completed") done += 1;
|
||||
else if (item.status === "failed") failed += 1;
|
||||
else if (item.status === "cancelled") cancelled += 1;
|
||||
@@ -364,7 +370,7 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe
|
||||
activeProgress += (item.progressPercent || 0) / 100;
|
||||
}
|
||||
}
|
||||
const total = Math.max(1, row.items.length);
|
||||
const total = Math.max(1, Math.max(0, Number(row.package.cleanedCompletedItemCount || 0)) + row.allItems.length);
|
||||
const allDownloaded = done + failed + cancelled >= total;
|
||||
const allExtracted = extracted >= total;
|
||||
const useExtractSplit = extracting || row.package.status === "extracting" || (allDownloaded && !allExtracted && done > 0 && extracted > 0 && failed === 0 && cancelled === 0);
|
||||
@@ -374,9 +380,17 @@ function packageProgress(row: DownloadPackageRow): { done: number; failed: numbe
|
||||
return { done, failed, cancelled, total, value };
|
||||
}
|
||||
|
||||
export function getPackageSizeProgress(row: DownloadPackageRow): { downloaded: number; total: number; value: number } {
|
||||
const downloaded = Math.max(0, Number(row.package.cleanedDownloadedBytes || 0))
|
||||
+ row.allItems.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
||||
const total = Math.max(0, Number(row.package.cleanedTotalBytes || 0))
|
||||
+ row.allItems.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
|
||||
return { downloaded, total, value: total > 0 ? progress((downloaded / total) * 100) : 0 };
|
||||
}
|
||||
|
||||
function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: number, editing: boolean, editingName: string, actions: DownloadsTableActions, finishRename: (value: string) => void): ReactElement | null {
|
||||
const entry = row.package;
|
||||
const stats = packageProgress(row);
|
||||
const stats = getPackageProgress(row);
|
||||
if (column === "name") {
|
||||
return (
|
||||
<span className="downloads-cell downloads-name-cell">
|
||||
@@ -397,9 +411,7 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
);
|
||||
}
|
||||
if (column === "size") {
|
||||
const total = row.items.reduce((sum, item) => sum + (item.totalBytes || item.downloadedBytes || 0), 0);
|
||||
const downloaded = row.items.reduce((sum, item) => sum + item.downloadedBytes, 0);
|
||||
const value = total > 0 ? progress((downloaded / total) * 100) : 0;
|
||||
const { downloaded, total, value } = getPackageSizeProgress(row);
|
||||
const text = `${humanSize(downloaded)} / ${humanSize(total)}`;
|
||||
return <span className="downloads-cell downloads-size-cell">{total > 0 ? <DownloadMeter text={text} value={value} /> : null}</span>;
|
||||
}
|
||||
@@ -415,18 +427,25 @@ function packageCell(row: DownloadPackageRow, column: string, packageSpeedBps: n
|
||||
if (column === "prio") return <span className="downloads-cell">{entry.priority === "high" ? "Hoch" : entry.priority === "low" ? "Niedrig" : ""}</span>;
|
||||
if (column === "status") {
|
||||
const audio = entry.audioStripSummary ? formatAudioStripSummary(entry.audioStripSummary) : null;
|
||||
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${entry.postProcessLabel ? ` · ${entry.postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||
const rawPostProcessLabel = entry.postProcessLabel?.trim() || "";
|
||||
const postProcessLabel = entry.status === "extracting" && /(?:^|[\\/])[^\\/]+\.(?:rar|zip|7z|tar|gz|bz2|xz)(?:\.\d+)?$/i.test(rawPostProcessLabel)
|
||||
? "Entpacken - Ausstehend"
|
||||
: compactDownloadStatus(rawPostProcessLabel);
|
||||
const details = `${stats.done}/${stats.total}${stats.failed > 0 ? ` · ${stats.failed} Fehler` : ""}${stats.cancelled > 0 ? ` · ${stats.cancelled} abgebrochen` : ""}${postProcessLabel ? ` · ${postProcessLabel}` : ""}${audio ? ` · ${audio.text}` : ""}`;
|
||||
const downloading = entry.status === "downloading" || entry.status === "validating" || row.items.some((item) => item.status === "downloading" || item.status === "validating");
|
||||
const status = entry.postProcessLabel && /Entpacken\s+\d+%/i.test(entry.postProcessLabel)
|
||||
? entry.postProcessLabel
|
||||
const status = postProcessLabel && (/Entpacken\s+\d+%/i.test(postProcessLabel) || entry.status === "extracting")
|
||||
? postProcessLabel
|
||||
: downloading ? "Download läuft" : details;
|
||||
const title = audio?.tooltip ? `${details}\n${audio.tooltip}` : details;
|
||||
return <DownloadStatusCell status={status} title={title} />;
|
||||
}
|
||||
if (column === "speed") return <span className="downloads-cell">{packageSpeedBps > 0 ? formatSpeedMbps(packageSpeedBps) : ""}</span>;
|
||||
if (column === "availability") {
|
||||
const availability = getAvailabilitySummary(row.items);
|
||||
return <Availability {...availability} />;
|
||||
const availability = getAvailabilitySummary(row.allItems);
|
||||
const text = availability.state === "checking"
|
||||
? row.allItems.some((item) => item.onlineStatus === "checking") ? "Prüfung" : "Ungeprüft"
|
||||
: undefined;
|
||||
return <Availability {...availability} text={text} />;
|
||||
}
|
||||
if (column === "added") return <span className="downloads-cell">{formatDateTime(entry.createdAt)}</span>;
|
||||
return null;
|
||||
@@ -443,13 +462,9 @@ export interface PackageCardProps {
|
||||
columnOrder: readonly string[];
|
||||
gridTemplate: string;
|
||||
actions: DownloadsTableActions;
|
||||
draggable?: boolean;
|
||||
onDragStart?: (packageId: string) => void;
|
||||
onDrop?: (packageId: string) => void;
|
||||
onDragEnd?: () => void;
|
||||
}
|
||||
|
||||
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions, draggable = true, onDragStart, onDrop, onDragEnd }: PackageCardProps): ReactElement {
|
||||
export function PackageCardContent({ row, selectedIds, editing, editingName, packageSpeedBps, sessionRunning = true, columnOrder, gridTemplate, actions }: PackageCardProps): ReactElement {
|
||||
const entry = row.package;
|
||||
let renameFinished = false;
|
||||
const finishRename = (value: string): void => {
|
||||
@@ -461,16 +476,12 @@ export function PackageCardContent({ row, selectedIds, editing, editingName, pac
|
||||
<article
|
||||
className={`downloads-package-card${entry.enabled ? "" : " is-disabled"}${selectedIds.has(entry.id) ? " is-selected" : ""}`}
|
||||
data-download-package-id={entry.id}
|
||||
draggable={draggable}
|
||||
onContextMenu={(event) => {
|
||||
event.preventDefault();
|
||||
event.stopPropagation();
|
||||
actions.onOpenContextMenu(entry.id, event.clientX, event.clientY, entry.id);
|
||||
}}
|
||||
onDragStart={(event) => { event.stopPropagation(); onDragStart?.(entry.id); }}
|
||||
onDragOver={(event) => { event.preventDefault(); event.stopPropagation(); }}
|
||||
onDrop={(event) => { event.preventDefault(); event.stopPropagation(); onDrop?.(entry.id); }}
|
||||
onDragEnd={(event) => { event.stopPropagation(); onDragEnd?.(); }}
|
||||
onDragStart={(event) => event.preventDefault()}
|
||||
>
|
||||
<div
|
||||
className="downloads-package-row"
|
||||
@@ -498,7 +509,7 @@ export function arePackageCardPropsEqual(previous: PackageCardProps, next: Packa
|
||||
const a = previous.row.package;
|
||||
const b = next.row.package;
|
||||
if (a.id !== b.id || a.updatedAt !== b.updatedAt || a.status !== b.status || a.enabled !== b.enabled || a.name !== b.name || a.priority !== b.priority || a.createdAt !== b.createdAt) return false;
|
||||
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions || previous.draggable !== next.draggable || previous.onDragStart !== next.onDragStart || previous.onDrop !== next.onDrop || previous.onDragEnd !== next.onDragEnd) return false;
|
||||
if (previous.packageSpeedBps !== next.packageSpeedBps || previous.editing !== next.editing || previous.editingName !== next.editingName || previous.row.collapsed !== next.row.collapsed || previous.sessionRunning !== next.sessionRunning || previous.columnOrder !== next.columnOrder || previous.gridTemplate !== next.gridTemplate || previous.actions !== next.actions) return false;
|
||||
if (previous.selectedVersion !== next.selectedVersion || previous.selectedIds !== next.selectedIds) {
|
||||
if (previous.selectedIds.has(a.id) !== next.selectedIds.has(a.id)) return false;
|
||||
for (const itemId of b.itemIds) {
|
||||
|
||||
@@ -70,9 +70,6 @@ export interface DownloadsViewActions extends DownloadsTableActions {
|
||||
onClearAll: () => void;
|
||||
onToggleAllPackages: () => void;
|
||||
onShowAllPackages: () => void;
|
||||
onPackageDragStart: (packageId: string) => void;
|
||||
onPackageDrop: (packageId: string) => void;
|
||||
onPackageDragEnd: () => void;
|
||||
}
|
||||
|
||||
const filters: Array<{ id: DownloadSidebarFilter; label: string }> = [
|
||||
@@ -152,10 +149,7 @@ function packageRows(model: DownloadsViewModel, actions: DownloadsViewActions):
|
||||
gridTemplate={model.gridTemplate}
|
||||
key={row.package.id}
|
||||
packageSpeedBps={model.packageSpeedBps[row.package.id] ?? 0}
|
||||
onDragEnd={actions.onPackageDragEnd}
|
||||
onDragStart={actions.onPackageDragStart}
|
||||
onDrop={actions.onPackageDrop}
|
||||
row={row}
|
||||
row={row}
|
||||
selectedIds={model.selectedIds}
|
||||
selectedVersion={model.actionableSelectedIds.length}
|
||||
sessionRunning={model.running}
|
||||
|
||||
@@ -27,11 +27,12 @@ export interface DownloadFilterCounts {
|
||||
failed: number;
|
||||
}
|
||||
|
||||
export interface DownloadPackageRow {
|
||||
package: PackageEntry;
|
||||
items: DownloadItem[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
export interface DownloadPackageRow {
|
||||
package: PackageEntry;
|
||||
items: DownloadItem[];
|
||||
allItems: DownloadItem[];
|
||||
collapsed: boolean;
|
||||
}
|
||||
|
||||
export interface DownloadsViewModelCore {
|
||||
displayMode: DownloadDisplayMode;
|
||||
@@ -137,12 +138,13 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
|
||||
const query = input.query.trim().toLocaleLowerCase("de-DE");
|
||||
const collapsed = new Set(input.collapsedPackageIds);
|
||||
const selectedIds = new Set(input.selectedIds);
|
||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||
const items = entry.itemIds
|
||||
.map((id) => input.items[id])
|
||||
.filter((item): item is DownloadItem => Boolean(item))
|
||||
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
||||
const selectedIds = new Set(input.selectedIds);
|
||||
let packageRows = allPackages.flatMap((entry): DownloadPackageRow[] => {
|
||||
const allPackageItems = entry.itemIds
|
||||
.map((id) => input.items[id])
|
||||
.filter((item): item is DownloadItem => Boolean(item));
|
||||
const items = allPackageItems
|
||||
.filter((item) => !input.hideExtractedItems || !isExtracted(item));
|
||||
const packageMatchesQuery = query === "" || matchesQuery(entry.name, query) || matchesQuery(entry.status, query);
|
||||
const matchingItems = items.filter((item) => {
|
||||
const itemMatchesQuery = query === ""
|
||||
@@ -158,8 +160,8 @@ export function buildDownloadsViewModel(input: DownloadsModelInput): DownloadsVi
|
||||
const visibleItems = packageMatchesQuery && query !== ""
|
||||
? items.filter((item) => matchesFilter(item, input.filter) && matchesProvider(item, input.providerFilter))
|
||||
: matchingItems;
|
||||
return [{ package: entry, items: visibleItems, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
return [{ package: entry, items: visibleItems, allItems: allPackageItems, collapsed: collapsed.has(entry.id) }];
|
||||
});
|
||||
|
||||
const totalPackageRows = packageRows.length;
|
||||
const allMatchingFileRows = packageRows.flatMap((row) => row.items);
|
||||
|
||||
@@ -43,11 +43,13 @@
|
||||
display: flex;
|
||||
min-height: 36px;
|
||||
align-items: center;
|
||||
justify-content: center;
|
||||
padding: 0 10px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
color: var(--ui-text);
|
||||
background: var(--ui-active);
|
||||
color: #0a0f1a;
|
||||
background: #90cdf4;
|
||||
text-align: center;
|
||||
font-weight: 700;
|
||||
}
|
||||
|
||||
@@ -397,6 +399,12 @@
|
||||
text-overflow: ellipsis;
|
||||
}
|
||||
|
||||
.downloads-name-cell .downloads-rename-input {
|
||||
flex: 1 1 auto;
|
||||
width: 100%;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.downloads-selection-cell,
|
||||
.downloads-action-cell {
|
||||
display: flex;
|
||||
|
||||
@@ -220,9 +220,10 @@ function AccountRow({
|
||||
<span className="settings-account-status" role="cell">
|
||||
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
|
||||
</span>
|
||||
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
|
||||
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
|
||||
<span className="settings-account-expires" role="cell">{row.expires}</span>
|
||||
<span className="settings-account-traffic" role="cell">{row.traffic}</span>
|
||||
<span className="settings-account-username settings-copyable" role="cell" title={row.username}>{row.username}</span>
|
||||
<span className="settings-account-email settings-copyable" role="cell" title={row.email}>{row.email}</span>
|
||||
<span className="settings-account-expires" role="cell">{row.expires}</span>
|
||||
<span className="settings-account-credential" role="cell">{row.credential}</span>
|
||||
<span className="settings-account-column-actions" role="cell">
|
||||
<button
|
||||
@@ -498,32 +499,49 @@ export function AccountAddDialog({
|
||||
size="account"
|
||||
title="Account hinzufügen"
|
||||
>
|
||||
<label className="settings-account-picker-selector">
|
||||
<span>Dienst / Zugangstyp</span>
|
||||
<select
|
||||
aria-label="Dienst / Zugangstyp"
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onOptionSelect(event.target.value)}
|
||||
value={model.selectedOptionId ?? ""}
|
||||
>
|
||||
{model.options.map((option) => (
|
||||
<option key={option.id} value={option.id}>{option.title} · {option.mode}</option>
|
||||
))}
|
||||
</select>
|
||||
</label>
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="settings-account-option-meta">
|
||||
<div>
|
||||
<strong>{selectedOption.title}</strong>
|
||||
<span>{selectedOption.description}</span>
|
||||
</div>
|
||||
<div>
|
||||
<strong>{selectedOption.mode}</strong>
|
||||
<span>{selectedOption.functionLabel}</span>
|
||||
</div>
|
||||
</div>
|
||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||
<div className="settings-account-picker-selector">
|
||||
<span>Dienst / Zugangstyp</span>
|
||||
<input
|
||||
aria-label="Dienst oder Zugangstyp suchen"
|
||||
className="settings-control"
|
||||
onChange={(event) => actions.onQueryChange(event.target.value)}
|
||||
placeholder="Dienst oder Zugangstyp suchen"
|
||||
type="search"
|
||||
value={model.query}
|
||||
/>
|
||||
</div>
|
||||
<div className="settings-account-picker-table">
|
||||
<div aria-hidden="true" className="settings-account-picker-header">
|
||||
<span>Dienst</span>
|
||||
<span>Typ/Funktion</span>
|
||||
</div>
|
||||
<div aria-label="Dienst / Zugangstyp" className="settings-account-picker-list" role="listbox">
|
||||
{model.options.map((option) => (
|
||||
<button
|
||||
aria-selected={option.id === model.selectedOptionId}
|
||||
className={`settings-account-picker-row${option.id === model.selectedOptionId ? " is-selected" : ""}`}
|
||||
data-account-option-id={option.id}
|
||||
key={option.id}
|
||||
onClick={() => actions.onOptionSelect(option.id)}
|
||||
role="option"
|
||||
type="button"
|
||||
>
|
||||
<span className="settings-account-picker-service">
|
||||
{option.icon ? <img alt="" aria-hidden="true" draggable={false} height="18" src={option.icon} width="18" /> : null}
|
||||
<span>{option.title}</span>
|
||||
</span>
|
||||
<span>{option.functionLabel}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
{selectedOption ? (
|
||||
<>
|
||||
<div className="settings-account-option-summary">
|
||||
<strong>Zugangsdaten für {selectedOption.title}</strong>
|
||||
<span>{selectedOption.description}</span>
|
||||
</div>
|
||||
<AccountDialogFields fields={model.fields} onChange={actions.onFieldChange} />
|
||||
</>
|
||||
) : null}
|
||||
{model.error ? <p className="settings-account-dialog-error" role="alert">{model.error}</p> : null}
|
||||
|
||||
@@ -1,177 +1,269 @@
|
||||
import { cloneElement, type ChangeEvent, type ReactElement } from "react";
|
||||
import { cloneElement, useEffect, useRef, useState, type ChangeEvent, type FocusEvent, type KeyboardEvent, type ReactElement } from "react";
|
||||
import { getSettingsSelectNavigationIndex } from "./settings-model";
|
||||
import type {
|
||||
SettingsFieldViewModel,
|
||||
SettingsFormViewModel,
|
||||
SettingsSelectFieldViewModel,
|
||||
SettingsTextFieldViewModel
|
||||
} from "./settings-model";
|
||||
|
||||
export interface SettingsFormActions {
|
||||
onChange: (fieldId: string, value: string | boolean) => void;
|
||||
onAction: (fieldId: string) => void;
|
||||
onCommit?: (fieldId: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface SettingsFormProps {
|
||||
model: SettingsFormViewModel;
|
||||
actions: SettingsFormActions;
|
||||
}
|
||||
|
||||
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
||||
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
||||
}
|
||||
|
||||
|
||||
export interface SettingsFormActions {
|
||||
onChange: (fieldId: string, value: string | boolean) => void;
|
||||
onAction: (fieldId: string) => void;
|
||||
onCommit?: (fieldId: string, value: string) => void;
|
||||
}
|
||||
|
||||
export interface SettingsFormProps {
|
||||
model: SettingsFormViewModel;
|
||||
actions: SettingsFormActions;
|
||||
}
|
||||
|
||||
function FieldHelp({ field }: { field: SettingsFieldViewModel }): ReactElement | null {
|
||||
return field.help ? <span className="settings-field-help" id={`${field.id}-help`}>{field.help}</span> : null;
|
||||
}
|
||||
|
||||
function TextControl({ field, actions }: { field: SettingsTextFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
const describedBy = field.help ? `${field.id}-help` : undefined;
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
actions.onChange(field.id, event.target.value);
|
||||
const describedBy = field.help ? `${field.id}-help` : undefined;
|
||||
const onChange = (event: ChangeEvent<HTMLInputElement | HTMLTextAreaElement>): void => {
|
||||
actions.onChange(field.id, event.target.value);
|
||||
};
|
||||
const control = field.kind === "textarea" ? (
|
||||
<textarea
|
||||
aria-describedby={describedBy}
|
||||
className="settings-control settings-textarea"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
value={field.value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-describedby={describedBy}
|
||||
className={`settings-control${field.kind === "path" ? " settings-copyable" : ""}`}
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
inputMode={field.inputMode}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step}
|
||||
type={field.kind === "number" ? "number" : "text"}
|
||||
value={field.value}
|
||||
/>
|
||||
);
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label htmlFor={field.id}>{field.label}</label>
|
||||
{field.actionLabel ? (
|
||||
<div className="settings-control-row">
|
||||
{control}
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
</div>
|
||||
) : control}
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SelectControl({ field, actions }: { field: SettingsSelectFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
const [open, setOpen] = useState(false);
|
||||
const rootRef = useRef<HTMLDivElement>(null);
|
||||
const triggerRef = useRef<HTMLButtonElement>(null);
|
||||
const optionRefs = useRef<Array<HTMLButtonElement | null>>([]);
|
||||
const selected = field.options.find((option) => option.value === field.value) ?? field.options[0];
|
||||
const selectedIndex = Math.max(0, field.options.findIndex((option) => option.value === selected?.value));
|
||||
|
||||
const focusOption = (nextIndex: number): void => {
|
||||
requestAnimationFrame(() => optionRefs.current[nextIndex]?.focus());
|
||||
};
|
||||
const control = field.kind === "textarea" ? (
|
||||
<textarea
|
||||
aria-describedby={describedBy}
|
||||
className="settings-control settings-textarea"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
rows={4}
|
||||
value={field.value}
|
||||
/>
|
||||
) : (
|
||||
<input
|
||||
aria-describedby={describedBy}
|
||||
className={`settings-control${field.kind === "path" ? " settings-copyable" : ""}`}
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
inputMode={field.inputMode}
|
||||
max={field.max}
|
||||
min={field.min}
|
||||
onChange={onChange}
|
||||
onBlur={field.commitOnBlur ? (event) => actions.onCommit?.(field.id, event.target.value) : undefined}
|
||||
placeholder={field.placeholder}
|
||||
step={field.step}
|
||||
type={field.kind === "number" ? "number" : "text"}
|
||||
value={field.value}
|
||||
/>
|
||||
);
|
||||
|
||||
useEffect(() => {
|
||||
if (!open) return;
|
||||
const close = (event: MouseEvent): void => {
|
||||
if (event.target instanceof Node && !rootRef.current?.contains(event.target)) setOpen(false);
|
||||
};
|
||||
const onKeyDown = (event: globalThis.KeyboardEvent): void => {
|
||||
if (event.key === "Escape") setOpen(false);
|
||||
};
|
||||
window.addEventListener("mousedown", close);
|
||||
window.addEventListener("keydown", onKeyDown);
|
||||
return () => {
|
||||
window.removeEventListener("mousedown", close);
|
||||
window.removeEventListener("keydown", onKeyDown);
|
||||
};
|
||||
}, [open]);
|
||||
|
||||
const onKeyDown = (event: KeyboardEvent<HTMLButtonElement>): void => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
const nextIndex = getSettingsSelectNavigationIndex(selectedIndex, field.options.length, event.key);
|
||||
setOpen(true);
|
||||
focusOption(nextIndex);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Enter" || event.key === " ") {
|
||||
event.preventDefault();
|
||||
setOpen((current) => !current);
|
||||
if (!open) focusOption(selectedIndex);
|
||||
}
|
||||
};
|
||||
|
||||
const onOptionKeyDown = (event: KeyboardEvent<HTMLButtonElement>, index: number): void => {
|
||||
if (event.key === "ArrowDown" || event.key === "ArrowUp" || event.key === "Home" || event.key === "End") {
|
||||
event.preventDefault();
|
||||
const nextIndex = getSettingsSelectNavigationIndex(index, field.options.length, event.key);
|
||||
focusOption(nextIndex);
|
||||
return;
|
||||
}
|
||||
if (event.key === "Escape") {
|
||||
event.preventDefault();
|
||||
setOpen(false);
|
||||
triggerRef.current?.focus();
|
||||
}
|
||||
};
|
||||
|
||||
const onBlur = (event: FocusEvent<HTMLDivElement>): void => {
|
||||
if (!event.currentTarget.contains(event.relatedTarget as Node | null)) setOpen(false);
|
||||
};
|
||||
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label htmlFor={field.id}>{field.label}</label>
|
||||
{field.actionLabel ? (
|
||||
<div className="settings-control-row">
|
||||
{control}
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
<label id={`${field.id}-label`}>{field.label}</label>
|
||||
<div className={`settings-select${open ? " is-open" : ""}${field.disabled ? " is-disabled" : ""}`} onBlur={onBlur} ref={rootRef}>
|
||||
<button
|
||||
aria-controls={`${field.id}-options`}
|
||||
aria-expanded={open}
|
||||
aria-haspopup="listbox"
|
||||
aria-labelledby={`${field.id}-label`}
|
||||
className="settings-select-trigger"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onClick={() => setOpen((current) => !current)}
|
||||
onKeyDown={onKeyDown}
|
||||
ref={triggerRef}
|
||||
role="combobox"
|
||||
type="button"
|
||||
>
|
||||
<span>{selected?.label ?? ""}</span>
|
||||
<span aria-hidden="true" className="settings-select-chevron">⌄</span>
|
||||
</button>
|
||||
<div aria-hidden={!open} className="settings-select-options" id={`${field.id}-options`} role="listbox">
|
||||
{field.options.map((option, index) => (
|
||||
<button
|
||||
aria-selected={field.value === option.value}
|
||||
className={`settings-select-option${field.value === option.value ? " is-selected" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => {
|
||||
actions.onChange(field.id, option.value);
|
||||
setOpen(false);
|
||||
}}
|
||||
onKeyDown={(event) => onOptionKeyDown(event, index)}
|
||||
ref={(element) => { optionRefs.current[index] = element; }}
|
||||
role="option"
|
||||
type="button"
|
||||
>{option.label}</button>
|
||||
))}
|
||||
</div>
|
||||
) : control}
|
||||
</div>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
|
||||
return <TextControl actions={actions} field={field} />;
|
||||
}
|
||||
|
||||
function SettingsField({ field, actions }: { field: SettingsFieldViewModel; actions: SettingsFormActions }): ReactElement {
|
||||
if (field.kind === "text" || field.kind === "path" || field.kind === "number" || field.kind === "textarea") {
|
||||
return <TextControl actions={actions} field={field} />;
|
||||
}
|
||||
if (field.kind === "select") {
|
||||
return (
|
||||
<div className="settings-field">
|
||||
<label htmlFor={field.id}>{field.label}</label>
|
||||
<select
|
||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
||||
className="settings-control"
|
||||
disabled={field.disabled}
|
||||
id={field.id}
|
||||
onChange={(event) => actions.onChange(field.id, event.target.value)}
|
||||
value={field.value}
|
||||
>
|
||||
{field.options.map((option) => <option key={option.value} value={option.value}>{option.label}</option>)}
|
||||
</select>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
);
|
||||
}
|
||||
if (field.kind === "theme") {
|
||||
return (
|
||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||
<legend>{field.label}</legend>
|
||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
aria-checked={field.value === option.value}
|
||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => actions.onChange(field.id, option.value)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FieldHelp field={field} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (field.kind === "switch") {
|
||||
return (
|
||||
<div className="settings-field settings-switch-field">
|
||||
<div>
|
||||
<span className="settings-switch-label" id={`${field.id}-label`}>{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
aria-checked={field.value}
|
||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
||||
aria-labelledby={`${field.id}-label`}
|
||||
className={`settings-switch${field.value ? " is-on" : ""}`}
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onChange(field.id, !field.value)}
|
||||
role="switch"
|
||||
type="button"
|
||||
><span /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-field settings-action-field">
|
||||
<div>
|
||||
<span className="settings-switch-label">{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsForm({ model, actions }: SettingsFormProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-form-column">
|
||||
<header className="settings-form-heading">
|
||||
<h2>{model.title}</h2>
|
||||
<p>{model.description}</p>
|
||||
</header>
|
||||
{model.groups.map((group) => (
|
||||
<section className="settings-form-group" key={group.id}>
|
||||
<header>
|
||||
<h3>{group.title}</h3>
|
||||
{group.description ? <p>{group.description}</p> : null}
|
||||
</header>
|
||||
<div className="settings-form-fields">
|
||||
{group.fields.map((field) => cloneElement(SettingsField({ actions, field }), { key: field.id }))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return <SelectControl actions={actions} field={field} />;
|
||||
}
|
||||
if (field.kind === "theme") {
|
||||
return (
|
||||
<fieldset className="settings-field settings-theme-field" disabled={field.disabled}>
|
||||
<legend>{field.label}</legend>
|
||||
<div aria-describedby={field.help ? `${field.id}-help` : undefined} className="settings-theme-options" role="radiogroup">
|
||||
{field.options.map((option) => (
|
||||
<button
|
||||
aria-checked={field.value === option.value}
|
||||
className={`settings-theme-option${field.value === option.value ? " is-active" : ""}`}
|
||||
key={option.value}
|
||||
onClick={() => actions.onChange(field.id, option.value)}
|
||||
role="radio"
|
||||
type="button"
|
||||
>
|
||||
<span aria-hidden="true" className={`settings-theme-preview is-${option.value}`} />
|
||||
<span>{option.label}</span>
|
||||
</button>
|
||||
))}
|
||||
</div>
|
||||
<FieldHelp field={field} />
|
||||
</fieldset>
|
||||
);
|
||||
}
|
||||
if (field.kind === "switch") {
|
||||
return (
|
||||
<div className="settings-field settings-switch-field">
|
||||
<div>
|
||||
<span className="settings-switch-label" id={`${field.id}-label`}>{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
aria-checked={field.value}
|
||||
aria-describedby={field.help ? `${field.id}-help` : undefined}
|
||||
aria-labelledby={`${field.id}-label`}
|
||||
className={`settings-switch${field.value ? " is-on" : ""}`}
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onChange(field.id, !field.value)}
|
||||
role="switch"
|
||||
type="button"
|
||||
><span /></button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
return (
|
||||
<div className="settings-field settings-action-field">
|
||||
<div>
|
||||
<span className="settings-switch-label">{field.label}</span>
|
||||
<FieldHelp field={field} />
|
||||
</div>
|
||||
<button
|
||||
className="settings-button settings-button-secondary"
|
||||
disabled={field.disabled}
|
||||
onClick={() => actions.onAction(field.id)}
|
||||
type="button"
|
||||
>{field.actionLabel}</button>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function SettingsForm({ model, actions }: SettingsFormProps): ReactElement {
|
||||
return (
|
||||
<div className="settings-form-column">
|
||||
<header className="settings-form-heading">
|
||||
<h2>{model.title}</h2>
|
||||
<p>{model.description}</p>
|
||||
</header>
|
||||
{model.groups.map((group) => (
|
||||
<section className="settings-form-group" key={group.id}>
|
||||
<header>
|
||||
<h3>{group.title}</h3>
|
||||
{group.description ? <p>{group.description}</p> : null}
|
||||
</header>
|
||||
<div className="settings-form-fields">
|
||||
{group.fields.map((field) => cloneElement(SettingsField({ actions, field }), { key: field.id }))}
|
||||
</div>
|
||||
</section>
|
||||
))}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -1,6 +1,5 @@
|
||||
import type { AppSettings } from "../../../shared/types";
|
||||
import type { AccountService } from "../../account-edit";
|
||||
import { resolveAccountUsername } from "../../account-ui";
|
||||
import { ACCOUNT_SERVICE_ICONS } from "../../account-service-icons";
|
||||
|
||||
export type SettingsSection = "allgemein" | "accounts" | "extract" | "speed" | "cleanup" | "updates";
|
||||
@@ -20,6 +19,7 @@ export const ACCOUNT_COLUMNS = [
|
||||
"Status",
|
||||
"Download-Traffic übrig",
|
||||
"Benutzername",
|
||||
"E-Mail",
|
||||
"Verfallsdatum",
|
||||
"Passwort/Zugang"
|
||||
] as const;
|
||||
@@ -38,6 +38,42 @@ export function getSettingsSaveLabel(state: SettingsSaveState): string {
|
||||
}
|
||||
}
|
||||
|
||||
export function getSettingsSelectNavigationIndex(currentIndex: number, optionCount: number, key: string): number {
|
||||
if (optionCount <= 0) return -1;
|
||||
const current = Math.max(0, Math.min(optionCount - 1, currentIndex));
|
||||
if (key === "Home") return 0;
|
||||
if (key === "End") return optionCount - 1;
|
||||
if (key === "ArrowDown") return (current + 1) % optionCount;
|
||||
if (key === "ArrowUp") return (current - 1 + optionCount) % optionCount;
|
||||
return current;
|
||||
}
|
||||
|
||||
export function resolveHistoryRetentionSelection(
|
||||
currentMode: AppSettings["historyRetentionMode"],
|
||||
currentMaxEntries: number,
|
||||
value: string
|
||||
): Pick<AppSettings, "historyRetentionMode" | "historyMaxEntries"> {
|
||||
const preset = /^permanent-(100|250)$/.exec(value);
|
||||
if (preset) {
|
||||
return {
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: Number(preset[1])
|
||||
};
|
||||
}
|
||||
if (value === "permanent") {
|
||||
return {
|
||||
historyRetentionMode: "permanent",
|
||||
historyMaxEntries: currentMode === "permanent" && (currentMaxEntries === 100 || currentMaxEntries === 250)
|
||||
? 500
|
||||
: currentMaxEntries
|
||||
};
|
||||
}
|
||||
return {
|
||||
historyRetentionMode: value as AppSettings["historyRetentionMode"],
|
||||
historyMaxEntries: currentMaxEntries
|
||||
};
|
||||
}
|
||||
|
||||
export type AccountStatusSourceState = "premium" | "free" | "invalid" | "checking" | "unchecked" | "disabled";
|
||||
export type AccountStatusTone = "ok" | "free" | "invalid" | "unknown" | "disabled";
|
||||
|
||||
@@ -75,6 +111,7 @@ export interface AccountRowViewModel {
|
||||
};
|
||||
traffic: string;
|
||||
username: string;
|
||||
email: string;
|
||||
expires: string;
|
||||
credential: string;
|
||||
canCheck: boolean;
|
||||
@@ -450,10 +487,14 @@ export function buildSettingsFormViewModel({
|
||||
id: "historyRetentionMode",
|
||||
kind: "select",
|
||||
label: "Verlauf speichern",
|
||||
value: settings.historyRetentionMode,
|
||||
value: settings.historyRetentionMode === "permanent" && (settings.historyMaxEntries === 100 || settings.historyMaxEntries === 250)
|
||||
? `permanent-${settings.historyMaxEntries}`
|
||||
: settings.historyRetentionMode,
|
||||
options: [
|
||||
{ value: "never", label: "Nie" },
|
||||
{ value: "session", label: "Nur aktuelle Session" },
|
||||
{ value: "permanent-100", label: "Nur letzte 100 Einträge" },
|
||||
{ value: "permanent-250", label: "Nur letzte 250 Einträge" },
|
||||
{ value: "permanent", label: "Dauerhaft" }
|
||||
]
|
||||
},
|
||||
@@ -553,6 +594,16 @@ function projectCredential(kind: AccountRowSource["credentialKind"]): string {
|
||||
return kind === "password" ? "••••••" : "Geschützter Zugang";
|
||||
}
|
||||
|
||||
function projectAccountIdentity(username: string, checkedEmail?: string): { username: string; email: string } {
|
||||
const stored = username.trim();
|
||||
const verifiedEmail = checkedEmail?.trim() || "";
|
||||
const storedIsEmail = stored.includes("@");
|
||||
return {
|
||||
username: stored && !storedIsEmail ? stored : "—",
|
||||
email: verifiedEmail || (storedIsEmail ? stored : "—")
|
||||
};
|
||||
}
|
||||
|
||||
export function projectAccountRows(
|
||||
sources: readonly AccountRowSource[],
|
||||
selectedIds: readonly string[],
|
||||
@@ -565,6 +616,7 @@ export function projectAccountRows(
|
||||
const premiumUntilMs = source.status.premiumUntilMs && source.status.premiumUntilMs > nowMs
|
||||
? source.status.premiumUntilMs
|
||||
: null;
|
||||
const identity = projectAccountIdentity(source.username, source.status.email);
|
||||
return {
|
||||
id,
|
||||
service: source.service,
|
||||
@@ -575,7 +627,8 @@ export function projectAccountRows(
|
||||
selected: selected.has(id),
|
||||
status,
|
||||
traffic: formatTraffic(source.dailyLimitBytes, source.dailyUsageBytes),
|
||||
username: resolveAccountUsername(source.username, source.status.email),
|
||||
username: identity.username,
|
||||
email: identity.email,
|
||||
expires: formatExpiry(source.status.premiumUntilMs),
|
||||
credential: projectCredential(source.credentialKind),
|
||||
canCheck: source.canCheck,
|
||||
|
||||
@@ -218,6 +218,91 @@
|
||||
font: inherit;
|
||||
}
|
||||
|
||||
.settings-select {
|
||||
position: relative;
|
||||
min-width: 0;
|
||||
}
|
||||
|
||||
.settings-select-trigger {
|
||||
display: flex;
|
||||
width: 100%;
|
||||
height: 44px;
|
||||
align-items: center;
|
||||
justify-content: space-between;
|
||||
gap: 12px;
|
||||
padding: 0 12px;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-select-chevron {
|
||||
color: var(--ui-text-secondary);
|
||||
font-size: 16px;
|
||||
line-height: 1;
|
||||
transition: transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1);
|
||||
}
|
||||
|
||||
.settings-select-options {
|
||||
position: absolute;
|
||||
top: calc(100% + 5px);
|
||||
right: 0;
|
||||
left: 0;
|
||||
z-index: var(--md-layer-menu);
|
||||
display: grid;
|
||||
max-height: 0;
|
||||
overflow: hidden;
|
||||
padding: 0 4px;
|
||||
border: 1px solid transparent;
|
||||
border-radius: 7px;
|
||||
background: var(--ui-surface);
|
||||
box-shadow: 0 8px 24px rgb(0 0 0 / 35%);
|
||||
opacity: 0;
|
||||
transform: translateY(-6px);
|
||||
visibility: hidden;
|
||||
pointer-events: none;
|
||||
transition: max-height 220ms cubic-bezier(0.2, 0.8, 0.2, 1), opacity 150ms ease, transform 180ms cubic-bezier(0.2, 0.8, 0.2, 1), padding 180ms ease, border-color 180ms ease, visibility 0s linear 220ms;
|
||||
}
|
||||
|
||||
.settings-select.is-open .settings-select-options {
|
||||
max-height: 280px;
|
||||
padding: 4px;
|
||||
border-color: var(--ui-border);
|
||||
opacity: 1;
|
||||
transform: translateY(0);
|
||||
visibility: visible;
|
||||
pointer-events: auto;
|
||||
transition-delay: 0s;
|
||||
}
|
||||
|
||||
.settings-select.is-open .settings-select-chevron {
|
||||
transform: rotate(180deg);
|
||||
}
|
||||
|
||||
.settings-select-option {
|
||||
min-height: 34px;
|
||||
padding: 0 10px;
|
||||
border: 0;
|
||||
border-radius: 5px;
|
||||
background: transparent;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-select-option:hover,
|
||||
.settings-select-option.is-selected {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-select.is-disabled {
|
||||
opacity: 0.5;
|
||||
}
|
||||
|
||||
.settings-control:focus-visible,
|
||||
.settings-button:focus-visible,
|
||||
.settings-switch:focus-visible,
|
||||
@@ -467,8 +552,8 @@
|
||||
.settings-account-table-grid,
|
||||
.settings-account-row {
|
||||
display: grid;
|
||||
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(190px, 1.15fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
|
||||
min-width: 1110px;
|
||||
grid-template-columns: 42px minmax(170px, 1.1fr) minmax(150px, 0.9fr) minmax(190px, 1.2fr) minmax(145px, 0.9fr) minmax(190px, 1.1fr) minmax(130px, 0.8fr) minmax(145px, 0.85fr) 44px;
|
||||
min-width: 1260px;
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
@@ -622,6 +707,13 @@
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-email {
|
||||
overflow: hidden;
|
||||
color: var(--ui-text);
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-action-button {
|
||||
display: grid;
|
||||
width: 30px;
|
||||
@@ -751,29 +843,90 @@
|
||||
line-height: 18px;
|
||||
}
|
||||
|
||||
.settings-account-option-meta {
|
||||
display: grid;
|
||||
grid-template-columns: minmax(0, 1fr) 150px;
|
||||
gap: 12px;
|
||||
padding: 10px 12px;
|
||||
.settings-account-picker-table {
|
||||
overflow: hidden;
|
||||
border: 1px solid var(--ui-border);
|
||||
border-radius: 6px;
|
||||
background: var(--ui-input);
|
||||
color: var(--ui-text-secondary);
|
||||
}
|
||||
|
||||
.settings-account-option-meta > div {
|
||||
.settings-account-picker-header,
|
||||
.settings-account-picker-row {
|
||||
display: grid;
|
||||
min-width: 0;
|
||||
gap: 2px;
|
||||
grid-template-columns: minmax(0, 1fr) minmax(150px, 0.8fr);
|
||||
align-items: center;
|
||||
}
|
||||
|
||||
.settings-account-option-meta span {
|
||||
overflow: hidden;
|
||||
.settings-account-picker-header {
|
||||
min-height: 32px;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: var(--ui-surface-elevated, var(--ui-surface));
|
||||
color: var(--ui-text);
|
||||
font-size: 11px;
|
||||
font-weight: 700;
|
||||
letter-spacing: 0.04em;
|
||||
text-transform: uppercase;
|
||||
}
|
||||
|
||||
.settings-account-picker-header > span,
|
||||
.settings-account-picker-row > span {
|
||||
min-width: 0;
|
||||
padding: 0 11px;
|
||||
}
|
||||
|
||||
.settings-account-picker-list {
|
||||
max-height: 190px;
|
||||
overflow-y: auto;
|
||||
}
|
||||
|
||||
.settings-account-picker-row {
|
||||
width: 100%;
|
||||
min-height: 36px;
|
||||
padding: 0;
|
||||
border: 0;
|
||||
border-bottom: 1px solid var(--ui-border);
|
||||
background: transparent;
|
||||
color: var(--ui-text-secondary);
|
||||
font: inherit;
|
||||
text-align: left;
|
||||
}
|
||||
|
||||
.settings-account-picker-row:last-child {
|
||||
border-bottom: 0;
|
||||
}
|
||||
|
||||
.settings-account-picker-row:hover,
|
||||
.settings-account-picker-row.is-selected {
|
||||
background: var(--ui-hover);
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-account-picker-row.is-selected {
|
||||
box-shadow: inset 3px 0 0 var(--ui-accent);
|
||||
}
|
||||
|
||||
.settings-account-picker-service {
|
||||
display: flex;
|
||||
min-width: 0;
|
||||
align-items: center;
|
||||
gap: 8px;
|
||||
}
|
||||
|
||||
.settings-account-picker-service img {
|
||||
flex: 0 0 18px;
|
||||
object-fit: contain;
|
||||
}
|
||||
|
||||
.settings-account-option-summary {
|
||||
display: grid;
|
||||
gap: 3px;
|
||||
padding-top: 2px;
|
||||
color: var(--ui-text);
|
||||
}
|
||||
|
||||
.settings-account-option-summary span {
|
||||
color: var(--ui-text-muted);
|
||||
font-size: 12px;
|
||||
text-overflow: ellipsis;
|
||||
white-space: nowrap;
|
||||
}
|
||||
|
||||
.settings-account-dialog-fields {
|
||||
@@ -839,8 +992,8 @@
|
||||
|
||||
.settings-account-table-grid,
|
||||
.settings-account-row {
|
||||
grid-template-columns: 40px 160px 140px 180px 180px 120px 140px 42px;
|
||||
min-width: 1002px;
|
||||
grid-template-columns: 40px 160px 140px 180px 135px 170px 120px 140px 42px;
|
||||
min-width: 1127px;
|
||||
}
|
||||
}
|
||||
|
||||
@@ -850,7 +1003,13 @@
|
||||
}
|
||||
|
||||
.settings-theme-options,
|
||||
.settings-account-option-meta {
|
||||
.settings-account-picker-header,
|
||||
.settings-account-picker-row {
|
||||
grid-template-columns: minmax(0, 1fr);
|
||||
}
|
||||
|
||||
.settings-account-picker-header > span:last-child,
|
||||
.settings-account-picker-row > span:last-child {
|
||||
display: none;
|
||||
}
|
||||
}
|
||||
|
||||
Reference in New Issue
Block a user