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:
Sucukdeluxe
2026-08-11 00:46:46 +02:00
parent 9b1eeada2e
commit ef5c20c8df
25 changed files with 1953 additions and 952 deletions
+36 -25
View File
@@ -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}
+15 -13
View File
@@ -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);
+10 -2
View File
@@ -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}
+254 -162
View File
@@ -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>
);
}
+56 -3
View File
@@ -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,
+177 -18
View File
@@ -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;
}
}