feat: add compact account management actions

This commit is contained in:
Sucukdeluxe
2026-08-09 15:18:24 +02:00
parent f84c1f0edd
commit 41b5d62d14
5 changed files with 275 additions and 112 deletions
+6
View File
@@ -10,6 +10,12 @@ Desktop downloader for Windows with package-based queue management, multi-provid
## Changelog ## Changelog
### 2.0.8
- Add a compact account-management table with traffic, status, expiry, username, and credential columns.
- Add account actions through right-click and the row action menu.
- Support quick edit, status checks, enable/disable, and removal without expanding the full account form.
### 2.0.7 ### 2.0.7
- Improve account filter readability in dark and light themes. - Improve account filter readability in dark and light themes.
+2 -2
View File
@@ -1,12 +1,12 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.7", "version": "2.0.8",
"lockfileVersion": 3, "lockfileVersion": 3,
"requires": true, "requires": true,
"packages": { "packages": {
"": { "": {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.7", "version": "2.0.8",
"license": "MIT", "license": "MIT",
"dependencies": { "dependencies": {
"adm-zip": "0.6.0", "adm-zip": "0.6.0",
+1 -1
View File
@@ -1,6 +1,6 @@
{ {
"name": "real-debrid-downloader", "name": "real-debrid-downloader",
"version": "2.0.7", "version": "2.0.8",
"description": "Desktop downloader", "description": "Desktop downloader",
"main": "build/main/main/main.js", "main": "build/main/main/main.js",
"author": "Sucukdeluxe", "author": "Sucukdeluxe",
+193 -43
View File
@@ -167,6 +167,31 @@ interface ConfiguredAccountEntry {
debridLinkKeys: DebridLinkAccountKeyEntry[]; debridLinkKeys: DebridLinkAccountKeyEntry[];
} }
interface AccountTableRow {
rowKey: string;
entry: ConfiguredAccountEntry;
hosterLabel: string;
modeLabel: string;
username: string;
credentialLabel: string;
accountId: string | null;
checkable: boolean;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number;
totalUsedBytes: number;
toggleKind: "mega" | "dl" | "single";
megaLogin?: string;
dlKey?: DebridLinkAccountKeyEntry;
}
interface AccountContextMenuState {
x: number;
y: number;
row: AccountTableRow;
}
function buildDebugSetupDetails(setup: DebugSetupCheckResult): string { function buildDebugSetupDetails(setup: DebugSetupCheckResult): string {
const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => { const formatDiskLine = (label: string, value: DebugSetupCheckResult["diskSpace"]["runtime"]): string => {
if (value.freeBytes === null || value.totalBytes === null) { if (value.freeBytes === null || value.totalBytes === null) {
@@ -446,6 +471,28 @@ function getAccountPickerFunctionLabel(option: AccountOption): string {
} }
} }
function getAccountCredentialLabel(kind: AccountKind): string {
switch (kind) {
case "megadebrid-api":
case "megadebrid-web":
case "ddownload-login":
case "linksnappy-login":
return "••••••";
case "bestdebrid-web":
case "realdebrid-web":
case "alldebrid-web":
return "Login gespeichert";
case "realdebrid-api":
case "bestdebrid-api":
case "alldebrid-api":
case "onefichier-api":
case "debridlink-api":
return "API-Key gespeichert";
default:
return "Zugang gespeichert";
}
}
function hasMegaDebridCredentials(settings: AppSettings): boolean { function hasMegaDebridCredentials(settings: AppSettings): boolean {
if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true; if (parseMegaDebridAccounts(settings.megaCredentials || "").length > 0) return true;
return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim()); return Boolean(settings.megaLogin.trim() && settings.megaPassword.trim());
@@ -1781,6 +1828,8 @@ export function App(): ReactElement {
const importQueueFocusHandlerRef = useRef<(() => void) | null>(null); const importQueueFocusHandlerRef = useRef<(() => void) | null>(null);
const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null); const [contextMenu, setContextMenu] = useState<ContextMenuState | null>(null);
const ctxMenuRef = useRef<HTMLDivElement>(null); const ctxMenuRef = useRef<HTMLDivElement>(null);
const [accountContextMenu, setAccountContextMenu] = useState<AccountContextMenuState | null>(null);
const accountContextMenuRef = useRef<HTMLDivElement>(null);
const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null); const [linkPopup, setLinkPopup] = useState<LinkPopupState | null>(null);
const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null); const [accountDialog, setAccountDialog] = useState<AccountDialogState | null>(null);
const [accountDialogSearch, setAccountDialogSearch] = useState(""); const [accountDialogSearch, setAccountDialogSearch] = useState("");
@@ -2552,24 +2601,7 @@ export function App(): ReactElement {
const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]); const configuredAccountServices = useMemo(() => new Set(configuredAccounts.map((entry) => entry.service)), [configuredAccounts]);
const accountRows = useMemo(() => { const accountRows = useMemo(() => {
type AccountRow = { const rows: AccountTableRow[] = [];
rowKey: string;
entry: ConfiguredAccountEntry;
hosterLabel: string;
modeLabel: string;
username: string;
accountId: string | null;
checkable: boolean;
disabled: boolean;
dailyUsedBytes: number;
dailyLimitBytes: number;
dailyRemainingBytes: number;
totalUsedBytes: number;
toggleKind: "mega" | "dl" | "single";
megaLogin?: string;
dlKey?: DebridLinkAccountKeyEntry;
};
const rows: AccountRow[] = [];
for (const entry of configuredAccounts) { for (const entry of configuredAccounts) {
if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") { if (entry.kind === "megadebrid-api" || entry.kind === "megadebrid-web") {
const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || ""); const accounts = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "");
@@ -2582,6 +2614,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
username: acc.maskedLogin, username: acc.maskedLogin,
credentialLabel: "••••••",
accountId: acc.id, accountId: acc.id,
checkable: true, checkable: true,
disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id), disabled: (settingsDraft.megaDebridDisabledAccountIds || []).includes(acc.id),
@@ -2601,6 +2634,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
username: key.masked, username: key.masked,
credentialLabel: "API-Key",
accountId: key.id, accountId: key.id,
checkable: true, checkable: true,
disabled: key.disabled, disabled: key.disabled,
@@ -2619,6 +2653,7 @@ export function App(): ReactElement {
hosterLabel: entry.serviceLabel, hosterLabel: entry.serviceLabel,
modeLabel: entry.modeLabel, modeLabel: entry.modeLabel,
username: entry.summary, username: entry.summary,
credentialLabel: getAccountCredentialLabel(entry.kind),
accountId: null, accountId: null,
checkable: false, checkable: false,
disabled: entry.disabled, disabled: entry.disabled,
@@ -2687,13 +2722,16 @@ export function App(): ReactElement {
}); });
}; };
const renderAccountRow = (row: (typeof accountRows)[number], isMember: boolean): ReactElement => { const renderAccountRow = (row: AccountTableRow, isMember: boolean): ReactElement => {
const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null; const st = row.accountId ? (snapshot.settings?.debridAccountStatuses?.[row.accountId] ?? null) : null;
const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false; const checking = row.accountId ? megaCheckingIds.has(row.accountId) : false;
let statusCls = "none"; let statusCls = "none";
let statusText = "—"; let statusText = "—";
if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; } if (row.disabled) { statusCls = "disabled"; statusText = "Deaktiviert"; }
else if (!row.checkable) { statusCls = "none"; statusText = "—"; } else if (!row.checkable) {
statusCls = row.entry.statusLabel === "Aktiviert" ? "unknown" : "ok";
statusText = row.entry.statusLabel === "Aktiviert" ? "Aktiv" : row.entry.statusLabel;
}
else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; } else if (checking) { statusCls = "unknown"; statusText = "Prüfe…"; }
else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; } else if (!st) { statusCls = "unknown"; statusText = "Noch nicht geprüft"; }
else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; } else if (!st.valid) { statusCls = "invalid"; statusText = st.message || "Login ungültig"; }
@@ -2707,7 +2745,16 @@ export function App(): ReactElement {
? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig` ? `${humanSize(row.dailyRemainingBytes)} von ${humanSize(row.dailyLimitBytes)} übrig`
: "Unbeschränkt"; : "Unbeschränkt";
return ( return (
<div key={row.rowKey} className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}> <div
key={row.rowKey}
className={`acct2-row${row.disabled ? " acct2-disabled" : ""}${isProblem ? " acct2-problem" : ""}${isMember ? " acct2-member" : ""}`}
onDoubleClick={() => openEditAccountDialog(row.entry.kind)}
onContextMenu={(event) => {
event.preventDefault();
event.stopPropagation();
setAccountContextMenu({ x: event.clientX, y: event.clientY, row });
}}
>
<span className="acct2-c-check"> <span className="acct2-c-check">
<input <input
type="checkbox" type="checkbox"
@@ -2733,18 +2780,21 @@ export function App(): ReactElement {
</span> </span>
<span className="acct2-user" title={usernameTitle}>{username}</span> <span className="acct2-user" title={usernameTitle}>{username}</span>
<span className="acct2-expiry">{expiry}</span> <span className="acct2-expiry">{expiry}</span>
<span className="acct2-credential" title="Zugangsdaten werden niemals im Klartext angezeigt">{row.credentialLabel}</span>
<span className="acct2-c-actions"> <span className="acct2-c-actions">
{row.toggleKind === "single" && getAccountQuickActionMeta(row.entry.kind) && ( <button
<button className="btn btn-sm" disabled={actionBusy} onClick={() => { void onAccountRowQuickAction(row.entry); }}> className="btn btn-sm acct2-menu-button"
{getAccountQuickActionMeta(row.entry.kind)?.label} disabled={actionBusy}
</button> title="Account-Aktionen"
)} aria-label={`${row.hosterLabel} Account-Aktionen`}
<button className="btn btn-sm" disabled={actionBusy} title="Account bearbeiten" onClick={() => openEditAccountDialog(row.entry.kind)}>Bearbeiten</button> onClick={(event) => {
<button className="btn btn-sm danger" disabled={actionBusy} title="Account entfernen" onClick={() => { event.stopPropagation();
if (row.toggleKind === "mega" && row.megaLogin) { void onRemoveMegaAccount(row.megaLogin); } const rect = event.currentTarget.getBoundingClientRect();
else if (row.toggleKind === "dl" && row.dlKey) { void onRemoveDebridLinkKey(row.dlKey); } setAccountContextMenu({ x: rect.right, y: rect.bottom, row });
else { void onRemoveAccount(row.entry); } }}
}}>Entfernen</button> >
</button>
</span> </span>
</div> </div>
); );
@@ -3216,6 +3266,49 @@ export function App(): ReactElement {
}); });
}; };
const toggleAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
void onToggleMegaAccountEnabled(row.megaLogin, row.disabled);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onToggleDebridLinkApiKeyEnabled(row.entry, row.dlKey);
} else {
void onToggleAccountEnabled(row.entry);
}
};
const removeAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
void onRemoveMegaAccount(row.megaLogin);
} else if (row.toggleKind === "dl" && row.dlKey) {
void onRemoveDebridLinkKey(row.dlKey);
} else {
void onRemoveAccount(row.entry);
}
};
const checkAccountTableRow = (row: AccountTableRow): void => {
setAccountContextMenu(null);
if (row.toggleKind === "mega" && row.megaLogin) {
const account = parseMegaDebridAccounts(settingsDraft.megaCredentials || "", settingsDraft.megaPassword || "")
.find((entry) => entry.id === row.accountId);
if (account) {
void runMegaAccountCheck(account.login, account.password);
}
return;
}
if (row.toggleKind === "dl") {
void checkAllAccounts();
return;
}
if (getAccountQuickActionMeta(row.entry.kind)) {
void onAccountRowQuickAction(row.entry);
return;
}
showToast("Für diesen Account ist keine direkte Statusprüfung verfügbar.", 2800);
};
const onCheckUpdates = async (): Promise<void> => { const onCheckUpdates = async (): Promise<void> => {
let updateResult: UpdateCheckResult | null = null; let updateResult: UpdateCheckResult | null = null;
await performQuickAction(async () => { await performQuickAction(async () => {
@@ -4136,6 +4229,17 @@ export function App(): ReactElement {
}; };
}, [contextMenu]); }, [contextMenu]);
useEffect(() => {
if (!accountContextMenu) { return; }
const close = (): void => setAccountContextMenu(null);
window.addEventListener("click", close);
window.addEventListener("contextmenu", close);
return () => {
window.removeEventListener("click", close);
window.removeEventListener("contextmenu", close);
};
}, [accountContextMenu]);
useLayoutEffect(() => { useLayoutEffect(() => {
if (!contextMenu || !ctxMenuRef.current) return; if (!contextMenu || !ctxMenuRef.current) return;
const el = ctxMenuRef.current; const el = ctxMenuRef.current;
@@ -4148,6 +4252,18 @@ export function App(): ReactElement {
} }
}, [contextMenu]); }, [contextMenu]);
useLayoutEffect(() => {
if (!accountContextMenu || !accountContextMenuRef.current) return;
const el = accountContextMenuRef.current;
const rect = el.getBoundingClientRect();
if (rect.bottom > window.innerHeight) {
el.style.top = `${Math.max(0, accountContextMenu.y - rect.height)}px`;
}
if (rect.right > window.innerWidth) {
el.style.left = `${Math.max(0, accountContextMenu.x - rect.width)}px`;
}
}, [accountContextMenu]);
useEffect(() => { useEffect(() => {
if (!colHeaderCtx) return; if (!colHeaderCtx) return;
const close = (e: MouseEvent): void => { const close = (e: MouseEvent): void => {
@@ -5562,8 +5678,8 @@ export function App(): ReactElement {
<div className="settings-section card account-board"> <div className="settings-section card account-board">
<div className="account-board-header"> <div className="account-board-header">
<div> <div>
<h3>Accounts</h3> <h3>Accountverwaltung</h3>
<div className="hint">Accounts werden als Liste verwaltet. Neue Einträge kommen über den Dialog oben rechts dazu.</div> <div className="hint">Kompakte Übersicht mit Hoster, Traffic, Status, Benutzername, Ablauf und Zugang. Rechtsklick oder Doppelklick öffnet die Aktionen.</div>
</div> </div>
<div className="account-board-header-actions"> <div className="account-board-header-actions">
<button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts"> <button className="btn" disabled={actionBusy || accountCheckBusy} onClick={() => { void checkAllAccounts(); }} title="Prüft Login-Gültigkeit und Premium-Restlaufzeit aller Mega-Debrid-/Debrid-Link-Accounts">
@@ -5602,6 +5718,7 @@ export function App(): ReactElement {
<span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span> <span className="acct2-sortable" title="Nach Premium-Restlaufzeit sortieren (längste/kürzeste)" onClick={cycleAccountStatusSort}>Status{accountStatusSort === "desc" ? " ▼" : accountStatusSort === "asc" ? " ▲" : ""}</span>
<span>Benutzername</span> <span>Benutzername</span>
<span>Verfallsdatum</span> <span>Verfallsdatum</span>
<span>Zugang</span>
<span className="acct2-c-actions">Aktion</span> <span className="acct2-c-actions">Aktion</span>
</div> </div>
{groupedAccountRows.flatMap((group) => { {groupedAccountRows.flatMap((group) => {
@@ -5625,15 +5742,16 @@ export function App(): ReactElement {
<span className="acct2-mode">{group.rows.length} Accounts</span> <span className="acct2-mode">{group.rows.length} Accounts</span>
</span> </span>
<span className="acct2-traffic" /> <span className="acct2-traffic" />
<span className="acct2-status acct2-grp-sum"> <span className="acct2-status acct2-grp-sum">
{okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>} {okCount > 0 && <span className="account-validity-badge ok">{okCount} OK</span>}
{problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>} {problemCount > 0 && <span className="account-validity-badge invalid">{problemCount} Problem</span>}
{offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</span>} {offCount > 0 && <span className="account-validity-badge disabled">{offCount} aus</span>}
</span> </span>
<span /> <span />
<span /> <span />
<span /> <span />
</div> <span />
</div>
]; ];
if (!collapsed) { if (!collapsed) {
for (const row of group.rows) elements.push(renderAccountRow(row, true)); for (const row of group.rows) elements.push(renderAccountRow(row, true));
@@ -6702,6 +6820,38 @@ export function App(): ReactElement {
)} )}
{statusToast && <div className="toast">{statusToast}</div>} {statusToast && <div className="toast">{statusToast}</div>}
{dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>} {dragOver && <div className="drop-overlay">Links, .dlc oder Export-Dateien hier ablegen</div>}
{accountContextMenu && (
<div
ref={accountContextMenuRef}
className="ctx-menu account-context-menu"
style={{ left: accountContextMenu.x, top: accountContextMenu.y }}
onClick={(event) => event.stopPropagation()}
>
<div className="account-context-heading">
<strong>{accountContextMenu.row.hosterLabel}</strong>
<span>{accountContextMenu.row.modeLabel} · {accountContextMenu.row.username}</span>
</div>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); openEditAccountDialog(row.entry.kind); }}>
Bearbeiten
</button>
<button className="ctx-menu-item" onClick={() => checkAccountTableRow(accountContextMenu.row)}>
Account prüfen
</button>
{getAccountQuickActionMeta(accountContextMenu.row.entry.kind) && (
<button className="ctx-menu-item" onClick={() => { const row = accountContextMenu.row; setAccountContextMenu(null); void onAccountRowQuickAction(row.entry); }}>
{getAccountQuickActionMeta(accountContextMenu.row.entry.kind)?.label}
</button>
)}
<button className="ctx-menu-item" onClick={() => toggleAccountTableRow(accountContextMenu.row)}>
{accountContextMenu.row.disabled ? "Account aktivieren" : "Account deaktivieren"}
</button>
<div className="ctx-menu-sep" />
<button className="ctx-menu-item ctx-danger" onClick={() => removeAccountTableRow(accountContextMenu.row)}>
Account entfernen
</button>
</div>
)}
{contextMenu && (() => { {contextMenu && (() => {
const multi = selectedIds.size > 1; const multi = selectedIds.size > 1;
const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]); const selectedPackageIds = [...selectedIds].filter((id) => snapshot.session.packages[id]);
+8 -1
View File
@@ -3370,7 +3370,7 @@ td {
.acct2-head, .acct2-head,
.acct2-row { .acct2-row {
display: grid; display: grid;
grid-template-columns: 32px minmax(0, 1.4fr) minmax(0, 1.4fr) minmax(0, 1.3fr) minmax(0, 1.7fr) minmax(0, 1fr) 196px; grid-template-columns: 32px minmax(0, 1.35fr) minmax(0, 1.25fr) minmax(0, 1.3fr) minmax(0, 1.45fr) minmax(0, 1fr) minmax(0, 0.9fr) 56px;
align-items: center; align-items: center;
gap: 10px; gap: 10px;
padding: 7px 12px; padding: 7px 12px;
@@ -3412,6 +3412,7 @@ td {
.acct2-c-check { display: flex; justify-content: center; } .acct2-c-check { display: flex; justify-content: center; }
.acct2-c-check input { cursor: pointer; } .acct2-c-check input { cursor: pointer; }
.acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; } .acct2-c-actions { display: flex; gap: 6px; justify-content: flex-end; }
.acct2-menu-button { min-width: 38px; padding-inline: 8px; font-size: 18px; line-height: 1; }
.acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; } .acct2-hoster { display: flex; flex-direction: column; gap: 1px; min-width: 0; }
.acct2-hoster strong { font-size: 13px; } .acct2-hoster strong { font-size: 13px; }
.acct2-mode { font-size: 11px; color: var(--muted); } .acct2-mode { font-size: 11px; color: var(--muted); }
@@ -3422,9 +3423,15 @@ td {
.acct2-sortable { cursor: pointer; user-select: none; } .acct2-sortable { cursor: pointer; user-select: none; }
.acct2-sortable:hover { color: var(--text); } .acct2-sortable:hover { color: var(--text); }
.acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; } .acct2-user { overflow: hidden; text-overflow: ellipsis; white-space: nowrap; }
.acct2-credential { color: var(--muted); white-space: nowrap; }
.acct2-status .account-validity-badge { margin-top: 0; } .acct2-status .account-validity-badge { margin-top: 0; }
.acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; } .acct2-nostatus { color: var(--muted); opacity: 0.6; font-variant-numeric: tabular-nums; }
.account-context-menu { min-width: 230px; }
.account-context-heading { display: grid; gap: 3px; padding: 8px 12px; }
.account-context-heading strong { color: var(--text); font-size: 13px; }
.account-context-heading span { color: var(--muted); font-size: 11px; }
.rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; } .rotation-panel { display: flex; flex-direction: column; gap: 6px; max-height: 320px; overflow-y: auto; }
.rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; } .rotation-empty { color: var(--muted, #a59c8e); font-size: 12px; }
.rotation-event { .rotation-event {