feat(app-frontend): implement settings UI structure, state reducer, and navigation
This commit is contained in:
+217
@@ -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;
|
||||
@@ -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"),
|
||||
};
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>,
|
||||
);
|
||||
@@ -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;
|
||||
}
|
||||
}
|
||||
@@ -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 };
|
||||
Vendored
+1
@@ -0,0 +1 @@
|
||||
/// <reference types="vite/client" />
|
||||
Reference in New Issue
Block a user