diff --git a/src/App.jsx b/src/App.jsx
index f6fd1ce..924a734 100644
--- a/src/App.jsx
+++ b/src/App.jsx
@@ -11,17 +11,19 @@ import Header from "./components/Header.jsx";
import Sidebar from "./components/Sidebar.jsx";
import ExportModal from "./components/ExportModal.jsx";
import SettingsModal from "./components/SettingsModal.jsx";
-import { HamburgerIcon } from "./components/Icons.jsx";
+import { HamburgerIcon, MicIcon } from "./components/Icons.jsx";
+import { clearAccessToken } from "./utils/googleDrive";
+import { STORAGE_KEYS } from "./utils/constants";
import "./App.css";
export default function App() {
const [showWelcome, setShowWelcome] = useState(
- () => !localStorage.getItem("sidekick_seen_welcome")
+ () => !localStorage.getItem(STORAGE_KEYS.SEEN_WELCOME)
);
const [user, setUser] = useState(undefined);
const [authLoading, setAuthLoading] = useState(true);
const [dark, setDark] = useState(() => {
- const saved = localStorage.getItem("sidekick_dark_mode");
+ const saved = localStorage.getItem(STORAGE_KEYS.DARK_MODE);
return saved === null ? true : saved === "1";
});
const [apiKey, setApiKey] = useState("");
@@ -38,9 +40,18 @@ export default function App() {
// Dark mode
useEffect(() => {
document.body.classList.toggle("dark", dark);
- localStorage.setItem("sidekick_dark_mode", dark ? "1" : "0");
+ localStorage.setItem(STORAGE_KEYS.DARK_MODE, dark ? "1" : "0");
}, [dark]);
+ // Auto-collapse sidebar on resize to mobile
+ useEffect(() => {
+ const handleResize = () => {
+ if (window.innerWidth < 768) setSidebarCollapsed(true);
+ };
+ window.addEventListener("resize", handleResize);
+ return () => window.removeEventListener("resize", handleResize);
+ }, []);
+
// Auth listener
useEffect(() => {
const mockUser = window.__CYPRESS_MOCK_USER__;
@@ -48,7 +59,7 @@ export default function App() {
setUser(mockUser);
setAuthLoading(false);
// In Cypress tests, check localStorage for mock API key
- const savedKey = localStorage.getItem(`sidekick_apikey_${mockUser.uid}`);
+ const savedKey = localStorage.getItem(STORAGE_KEYS.apiKey(mockUser.uid));
if (savedKey) setApiKey(savedKey);
return;
}
@@ -76,7 +87,7 @@ export default function App() {
// Load conversations when user authenticates
useEffect(() => {
if (user && user.uid) {
- const saved = localStorage.getItem(`sidekick_conversations_${user.uid}`);
+ const saved = localStorage.getItem(STORAGE_KEYS.conversations(user.uid));
if (saved) {
try {
setConversations(JSON.parse(saved));
@@ -91,14 +102,14 @@ export default function App() {
useEffect(() => {
if (user && user.uid && conversations.length > 0) {
localStorage.setItem(
- `sidekick_conversations_${user.uid}`,
+ STORAGE_KEYS.conversations(user.uid),
JSON.stringify(conversations)
);
}
}, [conversations, user]);
const handleWelcomeContinue = () => {
- localStorage.setItem("sidekick_seen_welcome", "1");
+ localStorage.setItem(STORAGE_KEYS.SEEN_WELCOME, "1");
setShowWelcome(false);
};
@@ -114,6 +125,7 @@ export default function App() {
};
const handleSignOut = async () => {
+ clearAccessToken();
await signOut(auth);
setApiKey("");
setOutput("");
@@ -246,19 +258,7 @@ export default function App() {
{!output && !processing ? (
Tap the mic to start recording
diff --git a/src/components/AuthScreen.css b/src/components/AuthScreen.css
index 452d106..858a1ae 100644
--- a/src/components/AuthScreen.css
+++ b/src/components/AuthScreen.css
@@ -211,14 +211,14 @@
/* ── Messages ── */
.auth-error {
font-size: 12px;
- color: #e63e2f;
+ color: var(--color-error);
line-height: 1.5;
margin-top: 8px;
}
.auth-success {
font-size: 12px;
- color: #2d9e6b;
+ color: var(--color-success);
font-weight: 600;
line-height: 1.5;
margin-top: 8px;
diff --git a/src/components/ErrorBoundary.jsx b/src/components/ErrorBoundary.jsx
new file mode 100644
index 0000000..9afcf19
--- /dev/null
+++ b/src/components/ErrorBoundary.jsx
@@ -0,0 +1,29 @@
+import { Component } from "react";
+
+export default class ErrorBoundary extends Component {
+ constructor(props) {
+ super(props);
+ this.state = { hasError: false };
+ }
+
+ static getDerivedStateFromError() {
+ return { hasError: true };
+ }
+
+ componentDidCatch(error, info) {
+ console.error("ErrorBoundary caught:", error, info);
+ }
+
+ render() {
+ if (this.state.hasError) {
+ return (
+
+
Something went wrong
+
Try refreshing the page.
+
+
+ );
+ }
+ return this.props.children;
+ }
+}
diff --git a/src/components/ExportModal.css b/src/components/ExportModal.css
index f4dac24..57c8f3a 100644
--- a/src/components/ExportModal.css
+++ b/src/components/ExportModal.css
@@ -1,11 +1,11 @@
.export-overlay {
position: fixed;
inset: 0;
- background: rgba(0, 0, 0, 0.35);
+ background: var(--overlay-bg);
display: flex;
align-items: center;
justify-content: center;
- z-index: 1000;
+ z-index: var(--modal-z);
animation: fadeIn 0.15s ease;
padding: 20px;
}
@@ -37,8 +37,8 @@
}
.export-close-btn {
- width: 28px;
- height: 28px;
+ width: var(--icon-btn-size);
+ height: var(--icon-btn-size);
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface2);
@@ -159,14 +159,14 @@
.export-error {
padding: 0 18px 14px;
font-size: 11px;
- color: #e63e2f;
+ color: var(--color-error);
line-height: 1.5;
}
.export-success {
padding: 0 18px 14px;
font-size: 11px;
- color: #2d9e6b;
+ color: var(--color-success);
font-weight: 600;
line-height: 1.5;
}
diff --git a/src/components/ExportModal.jsx b/src/components/ExportModal.jsx
index db0f575..5ad8468 100644
--- a/src/components/ExportModal.jsx
+++ b/src/components/ExportModal.jsx
@@ -1,4 +1,4 @@
-import { useState, useEffect } from "react";
+import { useState, useEffect, useRef } from "react";
import { createPortal } from "react-dom";
import { generateFile } from "../utils/exportFile";
import {
@@ -8,32 +8,10 @@ import {
getAccessToken,
uploadToDrive,
} from "../utils/googleDrive";
-import { CloseIcon } from "./Icons";
+import { CloseIcon, FileIcon, DownloadIcon, DriveIcon } from "./Icons";
+import { useEscapeKey } from "../hooks/useEscapeKey";
import "./ExportModal.css";
-const FileIcon = () => (
-
-);
-
-const DownloadIcon = () => (
-
-);
-
-const DriveIcon = () => (
-
-);
-
const FORMAT_OPTIONS = [
{ id: "pdf", label: "PDF" },
{ id: "docx", label: "DOCX" },
@@ -59,13 +37,12 @@ export default function ExportModal({ output, onClose }) {
}
}, []);
+ useEscapeKey(onClose);
+
+ const closeTimerRef = useRef(null);
useEffect(() => {
- const handleEsc = (e) => {
- if (e.key === "Escape") onClose();
- };
- window.addEventListener("keydown", handleEsc);
- return () => window.removeEventListener("keydown", handleEsc);
- }, [onClose]);
+ return () => clearTimeout(closeTimerRef.current);
+ }, []);
const handleSaveToComputer = async () => {
setExporting(true);
@@ -81,7 +58,7 @@ export default function ExportModal({ output, onClose }) {
document.body.removeChild(a);
URL.revokeObjectURL(url);
setSuccess("File downloaded!");
- setTimeout(onClose, 1200);
+ closeTimerRef.current = setTimeout(onClose, 1200);
} catch (err) {
setError(err.message);
} finally {
@@ -106,7 +83,7 @@ export default function ExportModal({ output, onClose }) {
const { blob, filename, mimeType } = await generateFile(output, format);
await uploadToDrive(blob, filename, mimeType);
setSuccess("Uploaded to Google Drive!");
- setTimeout(onClose, 1500);
+ closeTimerRef.current = setTimeout(onClose, 1500);
} catch (err) {
setError(err.message);
} finally {
diff --git a/src/components/Icons.jsx b/src/components/Icons.jsx
index c3b1bd2..8f78e48 100644
--- a/src/components/Icons.jsx
+++ b/src/components/Icons.jsx
@@ -1,3 +1,5 @@
+// ── Shared icon components ──────────────────────────────────────────────────
+
export const EyeIcon = ({ open }) =>
open ? (
);
+
+export const GearIcon = () => (
+
+);
+
+export const SignOutIcon = () => (
+
+);
+
+export const ChatIcon = () => (
+
+);
+
+export const FileIcon = () => (
+
+);
+
+export const DownloadIcon = () => (
+
+);
+
+export const DriveIcon = () => (
+
+);
+
+export const UserIcon = ({ size = 15 }) => (
+
+);
+
+export const FormatsIcon = () => (
+
+);
+
+export const InfoIcon = () => (
+
+);
+
+export const CopyIcon = () => (
+
+);
+
+export const CheckIcon = () => (
+
+);
+
+export const SpeakIcon = () => (
+
+);
+
+export const StopIcon = () => (
+
+);
+
+export const MicIcon = () => (
+
+);
diff --git a/src/components/MicButton.jsx b/src/components/MicButton.jsx
index b8b4b2d..3aa7f03 100644
--- a/src/components/MicButton.jsx
+++ b/src/components/MicButton.jsx
@@ -1,22 +1,8 @@
import { useState, useRef } from "react";
import { MOCK_ESSAY } from "../utils/mockData";
+import { StopIcon, MicIcon } from "./Icons";
import "./MicButton.css";
-const StopIcon = () => (
-
-);
-
-const MicIcon = () => (
-
-);
-
const Waveform = ({ active }) => (
{[...Array(7)].map((_, i) => (
diff --git a/src/components/OutputBox.css b/src/components/OutputBox.css
index 426a38d..5d97478 100644
--- a/src/components/OutputBox.css
+++ b/src/components/OutputBox.css
@@ -26,8 +26,8 @@
}
.output-icon-btn {
- width: 28px;
- height: 28px;
+ width: var(--icon-btn-size);
+ height: var(--icon-btn-size);
border-radius: 7px;
border: 1px solid var(--border);
background: var(--surface);
@@ -50,8 +50,8 @@
}
.output-icon-btn.copied {
- color: #2d9e6b;
- border-color: #2d9e6b;
+ color: var(--color-success);
+ border-color: var(--color-success);
background: rgba(45, 158, 107, 0.08);
}
diff --git a/src/components/OutputBox.jsx b/src/components/OutputBox.jsx
index 5669c87..3603d6a 100644
--- a/src/components/OutputBox.jsx
+++ b/src/components/OutputBox.jsx
@@ -1,43 +1,21 @@
-import { useState } from "react";
+import { useState, useRef, useEffect } from "react";
+import { CopyIcon, CheckIcon, SpeakIcon, DownloadIcon } from "./Icons";
import "./OutputBox.css";
-const CopyIcon = () => (
-
-);
-
-const CheckIcon = () => (
-
-);
-
-const SpeakIcon = () => (
-
-);
-
-const ExportIcon = () => (
-
-);
-
export default function OutputBox({ output, processing, onExport }) {
const [copied, setCopied] = useState(false);
+ const copyTimerRef = useRef(null);
+
+ useEffect(() => {
+ return () => clearTimeout(copyTimerRef.current);
+ }, []);
const handleCopy = () => {
if (!output) return;
navigator.clipboard.writeText(output);
setCopied(true);
- setTimeout(() => setCopied(false), 1800);
+ clearTimeout(copyTimerRef.current);
+ copyTimerRef.current = setTimeout(() => setCopied(false), 1800);
};
const handleSpeak = () => {
@@ -58,7 +36,7 @@ export default function OutputBox({ output, processing, onExport }) {
disabled={!output}
title="Export"
>
-
+