Skip to content
Merged
Show file tree
Hide file tree
Changes from all commits
Commits
File filter

Filter by extension

Filter by extension

Conversations
Failed to load comments.
Loading
Jump to
Jump to file
Failed to load files.
Loading
Diff view
Diff view
42 changes: 21 additions & 21 deletions src/App.jsx
Original file line number Diff line number Diff line change
Expand Up @@ -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("");
Expand All @@ -38,17 +40,26 @@ 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__;
if (mockUser) {
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;
}
Expand Down Expand Up @@ -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));
Expand All @@ -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);
};

Expand All @@ -114,6 +125,7 @@ export default function App() {
};

const handleSignOut = async () => {
clearAccessToken();
await signOut(auth);
setApiKey("");
setOutput("");
Expand Down Expand Up @@ -246,19 +258,7 @@ export default function App() {
{!output && !processing ? (
<div className="chat-empty">
<div className="chat-empty-logo">
<svg
width="24"
height="24"
viewBox="0 0 24 24"
fill="none"
stroke="currentColor"
strokeWidth="2.2"
strokeLinecap="round"
strokeLinejoin="round"
>
<path d="M12 1a3 3 0 0 0-3 3v8a3 3 0 0 0 6 0V4a3 3 0 0 0-3-3z" />
<path d="M19 10v2a7 7 0 0 1-14 0v-2" />
</svg>
<MicIcon />
</div>
<p className="chat-empty-text">
Tap the mic to start recording
Expand Down
4 changes: 2 additions & 2 deletions src/components/AuthScreen.css
Original file line number Diff line number Diff line change
Expand Up @@ -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;
Expand Down
29 changes: 29 additions & 0 deletions src/components/ErrorBoundary.jsx
Original file line number Diff line number Diff line change
@@ -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 (
<div style={{ padding: 40, textAlign: "center" }}>
<h2>Something went wrong</h2>
<p>Try refreshing the page.</p>
<button onClick={() => window.location.reload()}>Refresh</button>
</div>
);
}
return this.props.children;
}
}
12 changes: 6 additions & 6 deletions src/components/ExportModal.css
Original file line number Diff line number Diff line change
@@ -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;
}
Expand Down Expand Up @@ -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);
Expand Down Expand Up @@ -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;
}
Expand Down
43 changes: 10 additions & 33 deletions src/components/ExportModal.jsx
Original file line number Diff line number Diff line change
@@ -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 {
Expand All @@ -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 = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);

const DownloadIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);

const DriveIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 19.5h20L12 2z" />
<path d="M2 19.5l5-8.5h14" />
<path d="M16 11L22 19.5" />
</svg>
);

const FORMAT_OPTIONS = [
{ id: "pdf", label: "PDF" },
{ id: "docx", label: "DOCX" },
Expand All @@ -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);
Expand All @@ -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 {
Expand All @@ -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 {
Expand Down
111 changes: 111 additions & 0 deletions src/components/Icons.jsx
Original file line number Diff line number Diff line change
@@ -1,3 +1,5 @@
// ── Shared icon components ──────────────────────────────────────────────────

export const EyeIcon = ({ open }) =>
open ? (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
Expand Down Expand Up @@ -41,3 +43,112 @@ export const GoogleIcon = () => (
<path d="M12 5.38c1.62 0 3.06.56 4.21 1.64l3.15-3.15C17.45 2.09 14.97 1 12 1 7.7 1 3.99 3.47 2.18 7.07l3.66 2.84c.87-2.6 3.3-4.53 6.16-4.53z" fill="#EA4335" />
</svg>
);

export const GearIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="3" />
<path d="M19.4 15a1.65 1.65 0 0 0 .33 1.82l.06.06a2 2 0 0 1-2.83 2.83l-.06-.06a1.65 1.65 0 0 0-1.82-.33 1.65 1.65 0 0 0-1 1.51V21a2 2 0 0 1-4 0v-.09A1.65 1.65 0 0 0 9 19.4a1.65 1.65 0 0 0-1.82.33l-.06.06a2 2 0 0 1-2.83-2.83l.06-.06A1.65 1.65 0 0 0 4.68 15a1.65 1.65 0 0 0-1.51-1H3a2 2 0 0 1 0-4h.09A1.65 1.65 0 0 0 4.6 9a1.65 1.65 0 0 0-.33-1.82l-.06-.06a2 2 0 0 1 2.83-2.83l.06.06A1.65 1.65 0 0 0 9 4.68a1.65 1.65 0 0 0 1-1.51V3a2 2 0 0 1 4 0v.09a1.65 1.65 0 0 0 1 1.51 1.65 1.65 0 0 0 1.82-.33l.06-.06a2 2 0 0 1 2.83 2.83l-.06.06A1.65 1.65 0 0 0 19.4 9a1.65 1.65 0 0 0 1.51 1H21a2 2 0 0 1 0 4h-.09a1.65 1.65 0 0 0-1.51 1z" />
</svg>
);

export const SignOutIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M9 21H5a2 2 0 0 1-2-2V5a2 2 0 0 1 2-2h4" />
<polyline points="16 17 21 12 16 7" />
<line x1="21" y1="12" x2="9" y2="12" />
</svg>
);

export const ChatIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15a2 2 0 0 1-2 2H7l-4 4V5a2 2 0 0 1 2-2h14a2 2 0 0 1 2 2z" />
</svg>
);

export const FileIcon = () => (
<svg width="14" height="14" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M14 2H6a2 2 0 0 0-2 2v16a2 2 0 0 0 2 2h12a2 2 0 0 0 2-2V8z" />
<polyline points="14 2 14 8 20 8" />
</svg>
);

export const DownloadIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M21 15v4a2 2 0 0 1-2 2H5a2 2 0 0 1-2-2v-4" />
<polyline points="7 10 12 15 17 10" />
<line x1="12" y1="15" x2="12" y2="3" />
</svg>
);

export const DriveIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M12 2L2 19.5h20L12 2z" />
<path d="M2 19.5l5-8.5h14" />
<path d="M16 11L22 19.5" />
</svg>
);

export const UserIcon = ({ size = 15 }) => (
<svg width={size} height={size} viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<path d="M20 21v-2a4 4 0 0 0-4-4H8a4 4 0 0 0-4 4v2" />
<circle cx="12" cy="7" r="4" />
</svg>
);

export const FormatsIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<line x1="4" y1="21" x2="4" y2="14" />
<line x1="4" y1="10" x2="4" y2="3" />
<line x1="12" y1="21" x2="12" y2="12" />
<line x1="12" y1="8" x2="12" y2="3" />
<line x1="20" y1="21" x2="20" y2="16" />
<line x1="20" y1="12" x2="20" y2="3" />
<line x1="1" y1="14" x2="7" y2="14" />
<line x1="9" y1="8" x2="15" y2="8" />
<line x1="17" y1="16" x2="23" y2="16" />
</svg>
);

export const InfoIcon = () => (
<svg width="15" height="15" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<circle cx="12" cy="12" r="10" />
<line x1="12" y1="16" x2="12" y2="12" />
<line x1="12" y1="8" x2="12.01" y2="8" />
</svg>
);

export const CopyIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="9" width="13" height="13" rx="2" />
<path d="M5 15H4a2 2 0 0 1-2-2V4a2 2 0 0 1 2-2h9a2 2 0 0 1 2 2v1" />
</svg>
);

export const CheckIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2.5" strokeLinecap="round" strokeLinejoin="round">
<polyline points="20 6 9 17 4 12" />
</svg>
);

export const SpeakIcon = () => (
<svg width="13" height="13" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="2" strokeLinecap="round" strokeLinejoin="round">
<polygon points="11 5 6 9 2 9 2 15 6 15 11 19 11 5" />
<path d="M15.54 8.46a5 5 0 0 1 0 7.07" />
<path d="M19.07 4.93a10 10 0 0 1 0 14.14" />
</svg>
);

export const StopIcon = () => (
<svg width="20" height="20" viewBox="0 0 24 24" fill="currentColor">
<rect x="6" y="6" width="12" height="12" rx="2" />
</svg>
);

export const MicIcon = () => (
<svg width="24" height="24" viewBox="0 0 24 24" fill="none" stroke="currentColor" strokeWidth="1.8" strokeLinecap="round" strokeLinejoin="round">
<rect x="9" y="2" width="6" height="12" rx="3" />
<path d="M5 10a7 7 0 0 0 14 0" />
<line x1="12" y1="19" x2="12" y2="22" />
<line x1="9" y1="22" x2="15" y2="22" />
</svg>
);
Loading