feat(app-frontend): implement settings UI structure, state reducer, and navigation

This commit is contained in:
Stevan Freeborn
2026-07-23 12:05:46 -05:00
parent 2f99665e99
commit 4b7957e056
11 changed files with 889 additions and 0 deletions
+217
View File
@@ -0,0 +1,217 @@
import { useEffect, useReducer, useCallback } from "react";
import { appReducer, initialAppState } from "./reducer";
import { api } from "./api";
import { CleanConfig, ActiveTab } from "./types";
import { TabHeaders } from "./components/TabHeaders";
import { GeneralTab } from "./components/GeneralTab";
import { RulesTab } from "./components/RulesTab";
import { LicenseTab } from "./components/LicenseTab";
import "./App.css";
import appStyles from "./App.module.css";
function App() {
const [state, dispatch] = useReducer(appReducer, initialAppState);
useEffect(() => {
const init = async () => {
try {
const payload = await api.getInitialState();
dispatch({
type: "INITIALIZE",
payload: {
config: payload.config,
hotkeyConflict: payload.hotkey_conflict,
hwid: payload.hwid,
isLicensed: payload.is_licensed,
},
});
} catch (err) {
console.error("Failed to initialize settings:", err);
} finally {
try {
await api.showSettingsWindow();
} catch (e) {
console.error("Failed to show window:", e);
}
}
};
init();
}, []);
const updateConfig = useCallback(
async (newConfigParams: Partial<CleanConfig>) => {
const updatedConfig = { ...state.config, ...newConfigParams };
dispatch({ type: "SET_CONFIG", payload: newConfigParams });
try {
await api.updateConfig(updatedConfig);
const status = await api.getHotkeyStatus();
dispatch({ type: "SET_HOTKEY_CONFLICT", payload: status[1] });
} catch (err) {
console.error("Failed to update config:", err);
}
},
[state.config],
);
const handleActivate = async () => {
if (state.tabState.tab !== "license") {
return;
}
const licenseKey = state.tabState.keyInput.trim();
if (!licenseKey) {
dispatch({
type: "ACTIVATION_FAILURE",
payload: "Please enter a license key.",
});
return;
}
dispatch({ type: "START_ACTIVATION" });
try {
await api.activateLicense(licenseKey);
dispatch({ type: "ACTIVATION_SUCCESS" });
} catch (err: unknown) {
dispatch({ type: "ACTIVATION_FAILURE", payload: String(err) });
}
};
const handleOfflineActivate = async (token: string) => {
if (state.tabState.tab !== "license") {
return;
}
dispatch({ type: "START_ACTIVATION" });
try {
await api.verifyOfflineToken(token);
dispatch({ type: "ACTIVATION_SUCCESS" });
} catch (err: unknown) {
dispatch({ type: "ACTIVATION_FAILURE", payload: String(err) });
}
};
const isRecording =
state.tabState.tab === "general" && state.tabState.isRecording;
useEffect(() => {
if (!isRecording) {
return;
}
const handleKeyDown = (e: KeyboardEvent) => {
e.preventDefault();
e.stopPropagation();
if (["Control", "Alt", "Shift", "Meta"].includes(e.key)) {
return;
}
if (!e.ctrlKey && !e.altKey && !e.shiftKey && !e.metaKey) {
return;
}
const parts: string[] = [];
if (e.ctrlKey) {
parts.push("Ctrl");
}
if (e.altKey) {
parts.push("Alt");
}
if (e.shiftKey) {
parts.push("Shift");
}
if (e.metaKey) {
parts.push("Super");
}
let keyName = e.key;
if (keyName === " ") {
keyName = "Space";
}
if (keyName.length === 1) {
keyName = keyName.toUpperCase();
}
parts.push(keyName);
const hotkeyStr = parts.join("+");
updateConfig({ hotkey: hotkeyStr });
dispatch({ type: "SET_RECORDING", payload: false });
};
window.addEventListener("keydown", handleKeyDown);
return () => {
window.removeEventListener("keydown", handleKeyDown);
};
}, [isRecording, updateConfig]);
const activeTab: ActiveTab = state.tabState.tab;
return (
<div className="app-container">
<div className={appStyles["header"]}>
<div className={appStyles["logo-section"]}>
<div className={appStyles["logo-icon"]}>C</div>
<div className={appStyles["logo-title"]}>CleanCopy</div>
</div>
</div>
<TabHeaders
activeTab={activeTab}
isLicensed={state.isLicensed}
onTabChange={(tab) => dispatch({ type: "SET_TAB", payload: tab })}
/>
{state.tabState.tab === "general" && (
<GeneralTab
config={state.config}
isConflict={state.hotkeyConflict}
isRecording={isRecording}
onToggleRecording={() =>
dispatch({ type: "SET_RECORDING", payload: !isRecording })
}
onUpdateConfig={updateConfig}
/>
)}
{state.tabState.tab === "rules" && (
<RulesTab config={state.config} onUpdateConfig={updateConfig} />
)}
{state.tabState.tab === "license" && (
<LicenseTab
isLicensed={state.isLicensed}
hwid={state.hwid}
keyInput={state.tabState.keyInput}
isActivating={state.tabState.isActivating}
error={state.tabState.error}
onKeyInputChange={(val) =>
dispatch({ type: "SET_LICENSE_KEY_INPUT", payload: val })
}
onActivate={handleActivate}
onOfflineActivate={handleOfflineActivate}
/>
)}
</div>
);
}
export default App;
+21
View File
@@ -0,0 +1,21 @@
import { invoke } from "@tauri-apps/api/core";
import { CleanConfig } from "./types";
export type InitialStatePayload = {
config: CleanConfig;
hotkey_conflict: boolean;
hwid: string;
is_licensed: boolean;
};
export const api = {
getInitialState: () => invoke<InitialStatePayload>("get_initial_state"),
updateConfig: (config: CleanConfig) =>
invoke<void>("update_config", { config }),
getHotkeyStatus: () => invoke<[string, boolean]>("get_hotkey_status"),
activateLicense: (licenseKey: string) =>
invoke<void>("activate_license_command", { licenseKey }),
verifyOfflineToken: (token: string) =>
invoke<void>("verify_and_save_offline_token", { token }),
showSettingsWindow: () => invoke<void>("show_settings_window"),
};
+114
View File
@@ -0,0 +1,114 @@
import { CleanConfig } from "../types";
import layout from "./SettingsLayout.module.css";
import styles from "./GeneralTab.module.css";
import { ToggleSwitch } from "./ToggleSwitch";
type GeneralTabProps = {
config: CleanConfig;
isConflict: boolean;
isRecording: boolean;
onToggleRecording: () => void;
onUpdateConfig: (newConfig: Partial<CleanConfig>) => void;
};
export function GeneralTab({
config,
isConflict,
isRecording,
onToggleRecording,
onUpdateConfig,
}: GeneralTabProps) {
const renderHotkeyKeys = () => {
if (isRecording) {
return (
<span style={{ color: "var(--color-error)" }}>
Press keys to record...
</span>
);
}
return config.hotkey.split("+").map((key, i, arr) => (
<span key={key}>
<kbd className={styles["key-cap"]}>{key}</kbd>
{i < arr.length - 1 && <span className={styles["key-plus"]}>+</span>}
</span>
));
};
const hotkeyGroupClass = [
styles["hotkey-display-group"],
isConflict ? styles["disabled"] : "",
isRecording ? styles["recording"] : "",
]
.filter(Boolean)
.join(" ");
return (
<div className="tab-content">
<div className={layout["section"]}>
<div className={layout["section-title"]}>Keyboard settings</div>
<div className={layout["settings-list"]}>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>
Global Keyboard Shortcut
</div>
<div className={layout["settings-desc"]}>
Press shortcut to clean clipboard.
</div>
</div>
<button
data-testid="hotkey-display"
className={hotkeyGroupClass}
onClick={onToggleRecording}
onKeyDown={(e) => {
if (e.key === "Enter" || e.key === " ") {
e.preventDefault();
onToggleRecording();
}
}}
>
{renderHotkeyKeys()}
</button>
</div>
</div>
{isConflict && (
<div className={styles["conflict-warning"]}>
Shortcut registration conflict! It may already be in use.
</div>
)}
</div>
<div className={layout["section"]}>
<div className={layout["section-title"]}>General settings</div>
<div className={layout["settings-list"]}>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>Play sound on clean</div>
<div className={layout["settings-desc"]}>
Play sound confirmation when clipboard is cleaned.
</div>
</div>
<ToggleSwitch
checked={config.play_sound}
onChange={(checked) => onUpdateConfig({ play_sound: checked })}
/>
</div>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>Start automatically</div>
<div className={layout["settings-desc"]}>
Launch CleanCopy automatically when starting Windows.
</div>
</div>
<ToggleSwitch
checked={config.autostart}
onChange={(checked) => onUpdateConfig({ autostart: checked })}
/>
</div>
</div>
</div>
</div>
);
}
+149
View File
@@ -0,0 +1,149 @@
import { useState } from "react";
import layout from "./SettingsLayout.module.css";
import styles from "./LicenseTab.module.css";
type LicenseTabProps = {
isLicensed: boolean;
hwid: string;
keyInput: string;
isActivating: boolean;
error: string;
onKeyInputChange: (val: string) => void;
onActivate: () => void;
onOfflineActivate: (token: string) => void;
};
export function LicenseTab({
isLicensed,
hwid,
keyInput,
isActivating,
error,
onKeyInputChange,
onActivate,
onOfflineActivate,
}: LicenseTabProps) {
const [isOfflineMode, setIsOfflineMode] = useState(false);
const [offlineToken, setOfflineToken] = useState("");
const handleOfflineSubmit = () => {
const token = offlineToken.trim();
if (!token) {
return;
}
onOfflineActivate(token);
};
return (
<div className="tab-content">
<div className={layout["section"]}>
<div className={layout["section-title"]}>License status</div>
<div className={layout["settings-list"]}>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>CleanCopy License</div>
<div className={layout["settings-desc"]}>
Status:{" "}
<span
className={
isLicensed
? styles["license-active"]
: styles["license-unregistered"]
}
>
{isLicensed ? "Active" : "Unregistered"}
</span>
</div>
</div>
</div>
</div>
</div>
{!isLicensed && (
<div className={layout["section"]}>
<div className={layout["section-title"]}>Activation</div>
<div className={styles["license-activation-area"]}>
{isOfflineMode ? (
<>
<div className={styles["license-input-group"]}>
<input
type="text"
className={styles["license-input"]}
placeholder="Paste offline token..."
value={offlineToken}
onChange={(e) => setOfflineToken(e.target.value)}
disabled={isActivating}
/>
<button
className={styles["activate-btn"]}
onClick={handleOfflineSubmit}
disabled={isActivating}
>
{isActivating ? (
<span className={styles["spinner-container"]}>
<span className={styles["spinner"]}></span>
<span>Verifying...</span>
</span>
) : (
"Verify Token"
)}
</button>
</div>
<button
className={styles["offline-toggle"]}
onClick={() => {
setIsOfflineMode(false);
setOfflineToken("");
}}
>
Activate with license key instead
</button>
</>
) : (
<>
<div className={styles["license-input-group"]}>
<input
type="text"
className={styles["license-input"]}
placeholder="Enter license key..."
value={keyInput}
onChange={(e) => onKeyInputChange(e.target.value)}
disabled={isActivating}
/>
<button
className={styles["activate-btn"]}
onClick={onActivate}
disabled={isActivating}
>
{isActivating ? (
<span className={styles["spinner-container"]}>
<span className={styles["spinner"]}></span>
<span>Activating...</span>
</span>
) : (
"Activate"
)}
</button>
</div>
<button
className={styles["offline-toggle"]}
onClick={() => setIsOfflineMode(true)}
>
Have an offline token?
</button>
</>
)}
{error && <div className={styles["error-text"]}>{error}</div>}
</div>
</div>
)}
<div className={styles["hwid-container"]}>
<div className={styles["hwid-label"]}>Hardware ID:</div>
<div className={styles["hwid-val"]}>{hwid}</div>
</div>
</div>
);
}
+153
View File
@@ -0,0 +1,153 @@
import { useState, useEffect, useRef } from "react";
import { CleanConfig } from "../types";
import layout from "./SettingsLayout.module.css";
import styles from "./RulesTab.module.css";
import { ToggleSwitch } from "./ToggleSwitch";
type SaveStatus = "idle" | "debouncing" | "saved";
type RulesTabProps = {
config: CleanConfig;
onUpdateConfig: (newConfig: Partial<CleanConfig>) => void;
};
export function RulesTab({ config, onUpdateConfig }: RulesTabProps) {
const [customParamsInput, setCustomParamsInput] = useState(
config.custom_params.join(", "),
);
const [saveStatus, setSaveStatus] = useState<SaveStatus>("idle");
const debounceTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
const savedTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
useEffect(() => {
return () => {
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
}
};
}, []);
const handleCustomParamsChange = (value: string) => {
setCustomParamsInput(value);
setSaveStatus("debouncing");
if (debounceTimerRef.current) {
clearTimeout(debounceTimerRef.current);
}
if (savedTimerRef.current) {
clearTimeout(savedTimerRef.current);
savedTimerRef.current = null;
}
debounceTimerRef.current = setTimeout(() => {
const params = value
.split(",")
.map((p) => p.trim())
.filter((p) => p.length > 0);
onUpdateConfig({ custom_params: params });
setSaveStatus("saved");
savedTimerRef.current = setTimeout(() => {
setSaveStatus("idle");
}, 1500);
}, 500);
};
const renderSaveIndicator = () => {
if (saveStatus === "idle") return null;
return (
<span
className={`${styles["save-indicator"]} ${
styles[`save-indicator--${saveStatus}`]
}`}
>
{saveStatus === "debouncing" ? "..." : "\u2713"}
</span>
);
};
return (
<div className="tab-content">
<div className={layout["section"]}>
<div className={layout["section-title"]}>Cleaning rules</div>
<div className={layout["settings-list"]}>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>
Strip tracking parameters
</div>
<div className={layout["settings-desc"]}>
Removes analytics tokens like utm_source, ref, fbclid, etc.
</div>
</div>
<ToggleSwitch
checked={config.strip_utms}
onChange={(checked) => onUpdateConfig({ strip_utms: checked })}
/>
</div>
{config.strip_utms && (
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>
Additional parameters to strip
</div>
<div className={layout["settings-desc"]}>
Comma-separated list of custom parameter names.
</div>
<div className={styles["input-row"]}>
<input
type="text"
className={styles["custom-params-input"]}
placeholder="ref, tracking_id, campaign"
value={customParamsInput}
onChange={(e) => handleCustomParamsChange(e.target.value)}
/>
{renderSaveIndicator()}
</div>
</div>
</div>
)}
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>
Normalize typography
</div>
<div className={layout["settings-desc"]}>
Replaces curly quotes, em dashes, and en dashes with plain ASCII
equivalents.
</div>
</div>
<ToggleSwitch
checked={config.fix_quotes}
onChange={(checked) => onUpdateConfig({ fix_quotes: checked })}
/>
</div>
<div className={layout["settings-row"]}>
<div className={layout["settings-info"]}>
<div className={layout["settings-name"]}>
Clean trailing newlines
</div>
<div className={layout["settings-desc"]}>
Trims duplicate carriage returns and surrounding whitespaces.
</div>
</div>
<ToggleSwitch
checked={config.fix_newlines}
onChange={(checked) => onUpdateConfig({ fix_newlines: checked })}
/>
</div>
</div>
</div>
</div>
);
}
+45
View File
@@ -0,0 +1,45 @@
import { ActiveTab } from "../types";
import styles from "./TabHeaders.module.css";
type TabHeadersProps = {
activeTab: ActiveTab;
isLicensed: boolean;
onTabChange: (tab: ActiveTab) => void;
};
export function TabHeaders({
activeTab,
isLicensed,
onTabChange,
}: TabHeadersProps) {
return (
<div className={styles["tab-headers"]} role="tablist">
<button
role="tab"
aria-selected={activeTab === "general"}
className={`${styles["tab-btn"]} ${activeTab === "general" ? styles.active : ""}`}
onClick={() => onTabChange("general")}
disabled={!isLicensed}
>
General
</button>
<button
role="tab"
aria-selected={activeTab === "rules"}
className={`${styles["tab-btn"]} ${activeTab === "rules" ? styles.active : ""}`}
onClick={() => onTabChange("rules")}
disabled={!isLicensed}
>
Rules
</button>
<button
role="tab"
aria-selected={activeTab === "license"}
className={`${styles["tab-btn"]} ${activeTab === "license" ? styles.active : ""}`}
onClick={() => onTabChange("license")}
>
License{!isLicensed && " ⚠️"}
</button>
</div>
);
}
+19
View File
@@ -0,0 +1,19 @@
import styles from "./ToggleSwitch.module.css";
type ToggleSwitchProps = {
checked: boolean;
onChange: (checked: boolean) => void;
};
export function ToggleSwitch({ checked, onChange }: ToggleSwitchProps) {
return (
<label className={styles["switch"]}>
<input
type="checkbox"
checked={checked}
onChange={(e) => onChange(e.target.checked)}
/>
<span className={styles["slider"]}></span>
</label>
);
}
+9
View File
@@ -0,0 +1,9 @@
import React from "react";
import ReactDOM from "react-dom/client";
import App from "./App";
ReactDOM.createRoot(document.getElementById("root") as HTMLElement).render(
<React.StrictMode>
<App />
</React.StrictMode>,
);
+118
View File
@@ -0,0 +1,118 @@
import { AppAction, AppState } from "./types";
export const initialAppState: AppState = {
config: {
strip_utms: true,
fix_quotes: true,
fix_newlines: true,
play_sound: true,
autostart: false,
hotkey: "Alt+Shift+C",
custom_params: [],
},
hotkeyConflict: false,
hwid: "",
isLicensed: false,
tabState: { tab: "general", isRecording: false },
};
export function appReducer(state: AppState, action: AppAction): AppState {
switch (action.type) {
case "INITIALIZE":
return {
...state,
config: action.payload.config,
hotkeyConflict: action.payload.hotkeyConflict,
hwid: action.payload.hwid,
isLicensed: action.payload.isLicensed,
tabState: action.payload.isLicensed
? { tab: "general", isRecording: false }
: { tab: "license", keyInput: "", isActivating: false, error: "" },
};
case "SET_TAB": {
const nextTab = action.payload;
if (!state.isLicensed && nextTab !== "license") {
return state;
}
if (nextTab === "general") {
return { ...state, tabState: { tab: "general", isRecording: false } };
}
if (nextTab === "rules") {
return { ...state, tabState: { tab: "rules" } };
}
return {
...state,
tabState: {
tab: "license",
keyInput: "",
isActivating: false,
error: "",
},
};
}
case "SET_CONFIG":
return {
...state,
config: { ...state.config, ...action.payload },
};
case "SET_RECORDING":
if (state.tabState.tab === "general") {
return {
...state,
tabState: { ...state.tabState, isRecording: action.payload },
};
}
return state;
case "SET_LICENSE_KEY_INPUT":
if (state.tabState.tab === "license") {
return {
...state,
tabState: { ...state.tabState, keyInput: action.payload, error: "" },
};
}
return state;
case "START_ACTIVATION":
if (state.tabState.tab === "license") {
return {
...state,
tabState: { ...state.tabState, isActivating: true, error: "" },
};
}
return state;
case "ACTIVATION_SUCCESS":
return {
...state,
isLicensed: true,
tabState: {
tab: "license",
keyInput: "",
isActivating: false,
error: "",
},
};
case "ACTIVATION_FAILURE":
if (state.tabState.tab === "license") {
return {
...state,
tabState: {
...state.tabState,
isActivating: false,
error: action.payload,
},
};
}
return state;
case "SET_HOTKEY_CONFLICT":
return { ...state, hotkeyConflict: action.payload };
default:
return state;
}
}
+43
View File
@@ -0,0 +1,43 @@
export type CleanConfig = {
strip_utms: boolean;
fix_quotes: boolean;
fix_newlines: boolean;
play_sound: boolean;
autostart: boolean;
hotkey: string;
custom_params: string[];
};
export type ActiveTab = "general" | "rules" | "license";
export type TabSpecificState =
| { tab: "general"; isRecording: boolean }
| { tab: "rules" }
| { tab: "license"; keyInput: string; isActivating: boolean; error: string };
export type AppState = {
config: CleanConfig;
hotkeyConflict: boolean;
hwid: string;
isLicensed: boolean;
tabState: TabSpecificState;
};
export type AppAction =
| {
type: "INITIALIZE";
payload: {
config: CleanConfig;
hotkeyConflict: boolean;
hwid: string;
isLicensed: boolean;
};
}
| { type: "SET_TAB"; payload: ActiveTab }
| { type: "SET_CONFIG"; payload: Partial<CleanConfig> }
| { type: "SET_RECORDING"; payload: boolean }
| { type: "SET_LICENSE_KEY_INPUT"; payload: string }
| { type: "START_ACTIVATION" }
| { type: "ACTIVATION_SUCCESS" }
| { type: "ACTIVATION_FAILURE"; payload: string }
| { type: "SET_HOTKEY_CONFLICT"; payload: boolean };
+1
View File
@@ -0,0 +1 @@
/// <reference types="vite/client" />