feat(ui): refresh branding and account identity copying
Replace every active application icon asset with the new high-resolution artwork, including transparent PNG, complete Windows ICO sizes, and the documentation screenshot. Turn populated username and email cells into non-selectable copy actions backed by validated native Electron clipboard IPC while keeping empty cells and row interactions unchanged.
This commit is contained in:
Binary file not shown.
|
Before Width: | Height: | Size: 279 KiB After Width: | Height: | Size: 105 KiB |
Binary file not shown.
|
Before Width: | Height: | Size: 292 KiB After Width: | Height: | Size: 1.4 MiB |
Binary file not shown.
|
Before Width: | Height: | Size: 161 KiB After Width: | Height: | Size: 510 KiB |
@@ -37,6 +37,7 @@ function validatePlainObject(value: unknown, name: string): Record<string, unkno
|
|||||||
}
|
}
|
||||||
|
|
||||||
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
const IMPORT_QUEUE_MAX_BYTES = 10 * 1024 * 1024;
|
||||||
|
const CLIPBOARD_WRITE_MAX_BYTES = 4096;
|
||||||
const RENAME_PACKAGE_MAX_CHARS = 240;
|
const RENAME_PACKAGE_MAX_CHARS = 240;
|
||||||
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
const RESETTABLE_PROVIDER_KEYS = new Set<DebridProvider>([
|
||||||
"realdebrid",
|
"realdebrid",
|
||||||
@@ -611,6 +612,23 @@ function registerIpcHandlers(): void {
|
|||||||
updateClipboardWatcher();
|
updateClipboardWatcher();
|
||||||
return next;
|
return next;
|
||||||
});
|
});
|
||||||
|
handleTrusted(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, (_event: IpcMainInvokeEvent, rawText: unknown) => {
|
||||||
|
const text = validateString(rawText, "text");
|
||||||
|
const bytes = Buffer.byteLength(text, "utf8");
|
||||||
|
if (!text.trim()) {
|
||||||
|
throw new Error("text darf nicht leer sein");
|
||||||
|
}
|
||||||
|
if (bytes > CLIPBOARD_WRITE_MAX_BYTES) {
|
||||||
|
throw new Error(`text ist zu groß (max ${CLIPBOARD_WRITE_MAX_BYTES} Bytes)`);
|
||||||
|
}
|
||||||
|
try {
|
||||||
|
clipboard.writeText(text);
|
||||||
|
return true;
|
||||||
|
} catch (error) {
|
||||||
|
logger.warn(`Zwischenablage-Schreibfehler: bytes=${bytes}, error=${String(error)}`);
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
});
|
||||||
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
handleTrusted(IPC_CHANNELS.PICK_FOLDER, async () => {
|
||||||
const options = {
|
const options = {
|
||||||
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
properties: ["openDirectory", "createDirectory"] as Array<"openDirectory" | "createDirectory">
|
||||||
|
|||||||
@@ -65,6 +65,7 @@ const api: ElectronApi = {
|
|||||||
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
exportQueue: (): Promise<{ saved: boolean }> => ipcRenderer.invoke(IPC_CHANNELS.EXPORT_QUEUE),
|
||||||
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
importQueue: (json: string): Promise<{ addedPackages: number; addedLinks: number }> => ipcRenderer.invoke(IPC_CHANNELS.IMPORT_QUEUE, json),
|
||||||
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
toggleClipboard: (): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.TOGGLE_CLIPBOARD),
|
||||||
|
writeClipboardText: (text: string): Promise<boolean> => ipcRenderer.invoke(IPC_CHANNELS.WRITE_CLIPBOARD_TEXT, text),
|
||||||
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
pickFolder: (): Promise<string | null> => ipcRenderer.invoke(IPC_CHANNELS.PICK_FOLDER),
|
||||||
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
pickContainers: (): Promise<string[]> => ipcRenderer.invoke(IPC_CHANNELS.PICK_CONTAINERS),
|
||||||
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
getSessionStats: (): Promise<SessionStats> => ipcRenderer.invoke(IPC_CHANNELS.GET_SESSION_STATS),
|
||||||
|
|||||||
@@ -4922,6 +4922,11 @@ export function App(): ReactElement {
|
|||||||
const row = accountRowBindings.get(rowId);
|
const row = accountRowBindings.get(rowId);
|
||||||
if (row) setAccountContextMenu({ x, y, rowId });
|
if (row) setAccountContextMenu({ x, y, rowId });
|
||||||
},
|
},
|
||||||
|
onCopyIdentity: (label, value) => {
|
||||||
|
void window.rd.writeClipboardText(value)
|
||||||
|
.then(() => showToast(`${label} kopiert`))
|
||||||
|
.catch(() => showToast("Kopieren fehlgeschlagen"));
|
||||||
|
},
|
||||||
onAdd: openCreateAccountDialog,
|
onAdd: openCreateAccountDialog,
|
||||||
onRemoveSelected: () => {
|
onRemoveSelected: () => {
|
||||||
const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null;
|
const row = selectedAccountViewId ? accountRowBindings.get(selectedAccountViewId) : null;
|
||||||
|
|||||||
@@ -74,6 +74,7 @@ export interface AccountWorkspaceActions {
|
|||||||
onToggleEnabled: (rowId: string) => void;
|
onToggleEnabled: (rowId: string) => void;
|
||||||
onEdit: (rowId: string) => void;
|
onEdit: (rowId: string) => void;
|
||||||
onContextMenu: (rowId: string, x: number, y: number) => void;
|
onContextMenu: (rowId: string, x: number, y: number) => void;
|
||||||
|
onCopyIdentity: (label: "Benutzername" | "E-Mail", value: string) => void;
|
||||||
onAdd: () => void;
|
onAdd: () => void;
|
||||||
onRemoveSelected: () => void;
|
onRemoveSelected: () => void;
|
||||||
onCheckAll: () => void;
|
onCheckAll: () => void;
|
||||||
@@ -91,6 +92,36 @@ export interface AccountWorkspaceActions {
|
|||||||
onRoutingAdd?: (hosterId: string) => void;
|
onRoutingAdd?: (hosterId: string) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function renderAccountIdentityCell({
|
||||||
|
className,
|
||||||
|
label,
|
||||||
|
value,
|
||||||
|
onCopy
|
||||||
|
}: {
|
||||||
|
className: string;
|
||||||
|
label: "Benutzername" | "E-Mail";
|
||||||
|
value: string;
|
||||||
|
onCopy: (label: "Benutzername" | "E-Mail", value: string) => void;
|
||||||
|
}): ReactElement {
|
||||||
|
const copyable = value.trim() !== "" && value !== "—";
|
||||||
|
return (
|
||||||
|
<span className={className} role="cell" title={copyable ? `${value}\nKlicken zum Kopieren` : value}>
|
||||||
|
{copyable ? (
|
||||||
|
<button
|
||||||
|
aria-label={`${label} kopieren`}
|
||||||
|
className="settings-account-copy-button"
|
||||||
|
onClick={(event) => {
|
||||||
|
event.stopPropagation();
|
||||||
|
onCopy(label, value);
|
||||||
|
}}
|
||||||
|
onDoubleClick={(event) => event.stopPropagation()}
|
||||||
|
type="button"
|
||||||
|
>{value}</button>
|
||||||
|
) : value}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export interface AccountWorkspaceProps {
|
export interface AccountWorkspaceProps {
|
||||||
model: AccountWorkspaceViewModel;
|
model: AccountWorkspaceViewModel;
|
||||||
actions: AccountWorkspaceActions;
|
actions: AccountWorkspaceActions;
|
||||||
@@ -240,8 +271,8 @@ function AccountRow({
|
|||||||
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
|
<span className={`settings-account-status-badge is-${row.status.tone}`}>{row.status.text}</span>
|
||||||
</span>
|
</span>
|
||||||
<span className="settings-account-traffic" role="cell">{row.traffic}</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>
|
{renderAccountIdentityCell({ className: "settings-account-username", label: "Benutzername", onCopy: actions.onCopyIdentity, value: row.username })}
|
||||||
<span className="settings-account-email settings-copyable" role="cell" title={row.email}>{row.email}</span>
|
{renderAccountIdentityCell({ className: "settings-account-email", label: "E-Mail", onCopy: actions.onCopyIdentity, value: row.email })}
|
||||||
<span className="settings-account-expires" role="cell">{row.expires}</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-credential" role="cell">{row.credential}</span>
|
||||||
<span className="settings-account-column-actions" role="cell">
|
<span className="settings-account-column-actions" role="cell">
|
||||||
|
|||||||
@@ -732,6 +732,27 @@
|
|||||||
white-space: nowrap;
|
white-space: nowrap;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
.settings-account-copy-button {
|
||||||
|
display: block;
|
||||||
|
width: 100%;
|
||||||
|
padding: 0;
|
||||||
|
overflow: hidden;
|
||||||
|
border: 0;
|
||||||
|
background: transparent;
|
||||||
|
color: inherit;
|
||||||
|
cursor: copy;
|
||||||
|
font: inherit;
|
||||||
|
text-align: left;
|
||||||
|
text-overflow: ellipsis;
|
||||||
|
user-select: none;
|
||||||
|
white-space: nowrap;
|
||||||
|
}
|
||||||
|
|
||||||
|
.settings-account-copy-button:hover,
|
||||||
|
.settings-account-copy-button:focus-visible {
|
||||||
|
color: var(--ui-accent);
|
||||||
|
}
|
||||||
|
|
||||||
.settings-account-action-button {
|
.settings-account-action-button {
|
||||||
display: grid;
|
display: grid;
|
||||||
width: 30px;
|
width: 30px;
|
||||||
|
|||||||
@@ -35,6 +35,7 @@ export const IPC_CHANNELS = {
|
|||||||
STATE_UPDATE: "state:update",
|
STATE_UPDATE: "state:update",
|
||||||
CLIPBOARD_DETECTED: "clipboard:detected",
|
CLIPBOARD_DETECTED: "clipboard:detected",
|
||||||
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
TOGGLE_CLIPBOARD: "clipboard:toggle",
|
||||||
|
WRITE_CLIPBOARD_TEXT: "clipboard:write-text",
|
||||||
GET_SESSION_STATS: "stats:get-session-stats",
|
GET_SESSION_STATS: "stats:get-session-stats",
|
||||||
RESET_SESSION_STATS: "stats:reset-session",
|
RESET_SESSION_STATS: "stats:reset-session",
|
||||||
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
RESET_DOWNLOAD_STATS: "stats:reset-download",
|
||||||
|
|||||||
@@ -62,6 +62,7 @@ export interface ElectronApi {
|
|||||||
exportQueue: () => Promise<{ saved: boolean }>;
|
exportQueue: () => Promise<{ saved: boolean }>;
|
||||||
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
importQueue: (json: string) => Promise<{ addedPackages: number; addedLinks: number }>;
|
||||||
toggleClipboard: () => Promise<boolean>;
|
toggleClipboard: () => Promise<boolean>;
|
||||||
|
writeClipboardText: (text: string) => Promise<boolean>;
|
||||||
pickFolder: () => Promise<string | null>;
|
pickFolder: () => Promise<string | null>;
|
||||||
pickContainers: () => Promise<string[]>;
|
pickContainers: () => Promise<string[]>;
|
||||||
getSessionStats: () => Promise<SessionStats>;
|
getSessionStats: () => Promise<SessionStats>;
|
||||||
|
|||||||
@@ -1,3 +1,4 @@
|
|||||||
|
import fs from "node:fs";
|
||||||
import path from "node:path";
|
import path from "node:path";
|
||||||
import { describe, expect, it } from "vitest";
|
import { describe, expect, it } from "vitest";
|
||||||
import { resolveAppIconPath } from "../src/main/app-icon";
|
import { resolveAppIconPath } from "../src/main/app-icon";
|
||||||
@@ -14,4 +15,20 @@ describe("application icon path", () => {
|
|||||||
path.join("C:\\repo", "assets", "app_icon.ico")
|
path.join("C:\\repo", "assets", "app_icon.ico")
|
||||||
);
|
);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("ships a high-resolution transparent PNG and complete Windows icon sizes", () => {
|
||||||
|
const png = fs.readFileSync(path.resolve("assets/app_icon.png"));
|
||||||
|
expect(png.subarray(0, 8)).toEqual(Buffer.from([137, 80, 78, 71, 13, 10, 26, 10]));
|
||||||
|
expect(png.readUInt32BE(16)).toBeGreaterThanOrEqual(1024);
|
||||||
|
expect(png.readUInt32BE(20)).toBeGreaterThanOrEqual(1024);
|
||||||
|
expect([4, 6]).toContain(png[25]);
|
||||||
|
|
||||||
|
const ico = fs.readFileSync(path.resolve("assets/app_icon.ico"));
|
||||||
|
const frameCount = ico.readUInt16LE(4);
|
||||||
|
const sizes = Array.from({ length: frameCount }, (_, index) => {
|
||||||
|
const offset = 6 + index * 16;
|
||||||
|
return ico[offset] || 256;
|
||||||
|
});
|
||||||
|
expect(sizes).toEqual(expect.arrayContaining([16, 32, 48, 256]));
|
||||||
|
});
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -301,6 +301,7 @@ function workspaceActions(overrides: Partial<AccountWorkspaceActions> = {}): Acc
|
|||||||
onToggleEnabled: () => {},
|
onToggleEnabled: () => {},
|
||||||
onEdit: () => {},
|
onEdit: () => {},
|
||||||
onContextMenu: () => {},
|
onContextMenu: () => {},
|
||||||
|
onCopyIdentity: () => {},
|
||||||
onAdd: () => {},
|
onAdd: () => {},
|
||||||
onRemoveSelected: () => {},
|
onRemoveSelected: () => {},
|
||||||
onCheckAll: () => {},
|
onCheckAll: () => {},
|
||||||
@@ -740,6 +741,31 @@ describe("account workspace", () => {
|
|||||||
]);
|
]);
|
||||||
});
|
});
|
||||||
|
|
||||||
|
it("copies only populated username and email cells without triggering the row", () => {
|
||||||
|
const copies: string[] = [];
|
||||||
|
const tree = AccountWorkspace({
|
||||||
|
model: workspaceModel(),
|
||||||
|
actions: workspaceActions({
|
||||||
|
onCopyIdentity: (label, value) => copies.push(`${label}:${value}`)
|
||||||
|
})
|
||||||
|
});
|
||||||
|
const buttons = findElements(tree, (element) => String(element.props.className || "").includes("settings-account-copy-button"));
|
||||||
|
const username = buttons.find((button) => button.props["aria-label"] === "Benutzername kopieren");
|
||||||
|
const email = buttons.find((button) => button.props["aria-label"] === "E-Mail kopieren");
|
||||||
|
let stopped = 0;
|
||||||
|
|
||||||
|
username?.props.onClick({ stopPropagation: () => { stopped += 1; } });
|
||||||
|
email?.props.onClick({ stopPropagation: () => { stopped += 1; } });
|
||||||
|
username?.props.onDoubleClick({ stopPropagation: () => { stopped += 1; } });
|
||||||
|
|
||||||
|
expect(buttons.some((button) => button.props.children === "—")).toBe(false);
|
||||||
|
expect(copies).toEqual([
|
||||||
|
"Benutzername:stored-user",
|
||||||
|
"E-Mail:verified@example.test"
|
||||||
|
]);
|
||||||
|
expect(stopped).toBe(3);
|
||||||
|
});
|
||||||
|
|
||||||
it("keeps overview and rules in the same workspace while only one panel is active", () => {
|
it("keeps overview and rules in the same workspace while only one panel is active", () => {
|
||||||
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
const html = renderToStaticMarkup(<AccountWorkspace actions={workspaceActions()} model={workspaceModel()} />);
|
||||||
expect(count(html, "class=\"settings-account-panel\"")).toBe(2);
|
expect(count(html, "class=\"settings-account-panel\"")).toBe(2);
|
||||||
|
|||||||
@@ -139,6 +139,7 @@ export function createVisualElectronApi(
|
|||||||
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
fixture.snapshot.settings.clipboardWatch = fixture.snapshot.clipboardActive;
|
||||||
return fixture.snapshot.clipboardActive;
|
return fixture.snapshot.clipboardActive;
|
||||||
},
|
},
|
||||||
|
writeClipboardText: async () => true,
|
||||||
pickFolder: async () => "C:\\Visual\\Selected",
|
pickFolder: async () => "C:\\Visual\\Selected",
|
||||||
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
pickContainers: async () => ["C:\\Visual\\Containers\\visual.dlc"],
|
||||||
getSessionStats: async () => ({
|
getSessionStats: async () => ({
|
||||||
|
|||||||
Reference in New Issue
Block a user