From 4b7957e0566c9fa600d2858f382e69533be5f2e0 Mon Sep 17 00:00:00 2001 From: Stevan Freeborn <65925598+StevanFreeborn@users.noreply.github.com> Date: Thu, 23 Jul 2026 12:05:46 -0500 Subject: [PATCH] feat(app-frontend): implement settings UI structure, state reducer, and navigation --- app/src/App.tsx | 217 ++++++++++++++++++++++++++++ app/src/api.ts | 21 +++ app/src/components/GeneralTab.tsx | 114 +++++++++++++++ app/src/components/LicenseTab.tsx | 149 +++++++++++++++++++ app/src/components/RulesTab.tsx | 153 ++++++++++++++++++++ app/src/components/TabHeaders.tsx | 45 ++++++ app/src/components/ToggleSwitch.tsx | 19 +++ app/src/main.tsx | 9 ++ app/src/reducer.ts | 118 +++++++++++++++ app/src/types.ts | 43 ++++++ app/src/vite-env.d.ts | 1 + 11 files changed, 889 insertions(+) create mode 100644 app/src/App.tsx create mode 100644 app/src/api.ts create mode 100644 app/src/components/GeneralTab.tsx create mode 100644 app/src/components/LicenseTab.tsx create mode 100644 app/src/components/RulesTab.tsx create mode 100644 app/src/components/TabHeaders.tsx create mode 100644 app/src/components/ToggleSwitch.tsx create mode 100644 app/src/main.tsx create mode 100644 app/src/reducer.ts create mode 100644 app/src/types.ts create mode 100644 app/src/vite-env.d.ts diff --git a/app/src/App.tsx b/app/src/App.tsx new file mode 100644 index 0000000..3dbc9d7 --- /dev/null +++ b/app/src/App.tsx @@ -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) => { + 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 ( +
+
+
+
C
+
CleanCopy
+
+
+ + dispatch({ type: "SET_TAB", payload: tab })} + /> + + {state.tabState.tab === "general" && ( + + dispatch({ type: "SET_RECORDING", payload: !isRecording }) + } + onUpdateConfig={updateConfig} + /> + )} + + {state.tabState.tab === "rules" && ( + + )} + + {state.tabState.tab === "license" && ( + + dispatch({ type: "SET_LICENSE_KEY_INPUT", payload: val }) + } + onActivate={handleActivate} + onOfflineActivate={handleOfflineActivate} + /> + )} +
+ ); +} + +export default App; diff --git a/app/src/api.ts b/app/src/api.ts new file mode 100644 index 0000000..f54e356 --- /dev/null +++ b/app/src/api.ts @@ -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("get_initial_state"), + updateConfig: (config: CleanConfig) => + invoke("update_config", { config }), + getHotkeyStatus: () => invoke<[string, boolean]>("get_hotkey_status"), + activateLicense: (licenseKey: string) => + invoke("activate_license_command", { licenseKey }), + verifyOfflineToken: (token: string) => + invoke("verify_and_save_offline_token", { token }), + showSettingsWindow: () => invoke("show_settings_window"), +}; diff --git a/app/src/components/GeneralTab.tsx b/app/src/components/GeneralTab.tsx new file mode 100644 index 0000000..23bbe03 --- /dev/null +++ b/app/src/components/GeneralTab.tsx @@ -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) => void; +}; + +export function GeneralTab({ + config, + isConflict, + isRecording, + onToggleRecording, + onUpdateConfig, +}: GeneralTabProps) { + const renderHotkeyKeys = () => { + if (isRecording) { + return ( + + Press keys to record... + + ); + } + + return config.hotkey.split("+").map((key, i, arr) => ( + + {key} + {i < arr.length - 1 && +} + + )); + }; + + const hotkeyGroupClass = [ + styles["hotkey-display-group"], + isConflict ? styles["disabled"] : "", + isRecording ? styles["recording"] : "", + ] + .filter(Boolean) + .join(" "); + + return ( +
+
+
Keyboard settings
+
+
+
+
+ Global Keyboard Shortcut +
+
+ Press shortcut to clean clipboard. +
+
+ +
+
+ {isConflict && ( +
+ Shortcut registration conflict! It may already be in use. +
+ )} +
+ +
+
General settings
+
+
+
+
Play sound on clean
+
+ Play sound confirmation when clipboard is cleaned. +
+
+ onUpdateConfig({ play_sound: checked })} + /> +
+ +
+
+
Start automatically
+
+ Launch CleanCopy automatically when starting Windows. +
+
+ onUpdateConfig({ autostart: checked })} + /> +
+
+
+
+ ); +} diff --git a/app/src/components/LicenseTab.tsx b/app/src/components/LicenseTab.tsx new file mode 100644 index 0000000..f67f9c6 --- /dev/null +++ b/app/src/components/LicenseTab.tsx @@ -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 ( +
+
+
License status
+
+
+
+
CleanCopy License
+
+ Status:{" "} + + {isLicensed ? "Active" : "Unregistered"} + +
+
+
+
+
+ + {!isLicensed && ( +
+
Activation
+
+ {isOfflineMode ? ( + <> +
+ setOfflineToken(e.target.value)} + disabled={isActivating} + /> + +
+ + + ) : ( + <> +
+ onKeyInputChange(e.target.value)} + disabled={isActivating} + /> + +
+ + + )} + {error &&
{error}
} +
+
+ )} + +
+
Hardware ID:
+
{hwid}
+
+
+ ); +} diff --git a/app/src/components/RulesTab.tsx b/app/src/components/RulesTab.tsx new file mode 100644 index 0000000..73aa6c6 --- /dev/null +++ b/app/src/components/RulesTab.tsx @@ -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) => void; +}; + +export function RulesTab({ config, onUpdateConfig }: RulesTabProps) { + const [customParamsInput, setCustomParamsInput] = useState( + config.custom_params.join(", "), + ); + const [saveStatus, setSaveStatus] = useState("idle"); + const debounceTimerRef = useRef | null>(null); + const savedTimerRef = useRef | 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 ( + + {saveStatus === "debouncing" ? "..." : "\u2713"} + + ); + }; + + return ( +
+
+
Cleaning rules
+
+
+
+
+ Strip tracking parameters +
+
+ Removes analytics tokens like utm_source, ref, fbclid, etc. +
+
+ onUpdateConfig({ strip_utms: checked })} + /> +
+ + {config.strip_utms && ( +
+
+
+ Additional parameters to strip +
+
+ Comma-separated list of custom parameter names. +
+
+ handleCustomParamsChange(e.target.value)} + /> + {renderSaveIndicator()} +
+
+
+ )} + +
+
+
+ Normalize typography +
+
+ Replaces curly quotes, em dashes, and en dashes with plain ASCII + equivalents. +
+
+ onUpdateConfig({ fix_quotes: checked })} + /> +
+ +
+
+
+ Clean trailing newlines +
+
+ Trims duplicate carriage returns and surrounding whitespaces. +
+
+ onUpdateConfig({ fix_newlines: checked })} + /> +
+
+
+
+ ); +} diff --git a/app/src/components/TabHeaders.tsx b/app/src/components/TabHeaders.tsx new file mode 100644 index 0000000..5362088 --- /dev/null +++ b/app/src/components/TabHeaders.tsx @@ -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 ( +
+ + + +
+ ); +} diff --git a/app/src/components/ToggleSwitch.tsx b/app/src/components/ToggleSwitch.tsx new file mode 100644 index 0000000..6f687c4 --- /dev/null +++ b/app/src/components/ToggleSwitch.tsx @@ -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 ( + + ); +} diff --git a/app/src/main.tsx b/app/src/main.tsx new file mode 100644 index 0000000..2be325e --- /dev/null +++ b/app/src/main.tsx @@ -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( + + + , +); diff --git a/app/src/reducer.ts b/app/src/reducer.ts new file mode 100644 index 0000000..e90d977 --- /dev/null +++ b/app/src/reducer.ts @@ -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; + } +} diff --git a/app/src/types.ts b/app/src/types.ts new file mode 100644 index 0000000..0cb5a0b --- /dev/null +++ b/app/src/types.ts @@ -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 } + | { 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 }; diff --git a/app/src/vite-env.d.ts b/app/src/vite-env.d.ts new file mode 100644 index 0000000..11f02fe --- /dev/null +++ b/app/src/vite-env.d.ts @@ -0,0 +1 @@ +///