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 ? ( @@ -41,3 +43,112 @@ export const GoogleIcon = () => ( ); + +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" > - + -
- )} - -
- -
- - Google Drive - - {driveConnected ? "Connected" : "Not connected"} - -
-
- -
- -
- {dark ? "Dark mode" : "Light mode"} - -
-
- - - - {/* ── Delete account ── */} -
- - - {deleteStep === "idle" && ( - - )} - - {deleteStep === "confirm" && ( -
-

- This will permanently delete your account and all associated data. This action cannot be undone. -

-
- - -
-
- )} - - {deleteStep === "password" && ( -
-

- Enter your password to confirm account deletion. -

- setDeletePassword(e.target.value)} - onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()} - autoFocus - /> - {deleteError &&

{deleteError}

} -
- - -
-
- )} - - {deleteStep === "deleting" && ( -
-

Deleting your account...

-
- )} - - {deleteStep === "error" && ( -
-

{deleteError}

-
- -
-
- )} -
-
- ); -} - -function FormatsTab({ presets, onToggle }) { - return ( -
-

- Voice commands that control how your text is formatted. - Say these keywords while recording to trigger formatting. -

-
- {presets.map((preset) => ( -
-
- "{preset.keyword}" - {preset.description} -
- -
- ))} -
-
- ); -} - -function AboutTab() { - return ( -
-
-
- - - - -
-
-

Sidekick

- Version 1.0.0 -
-
- -
-

What is Sidekick?

-

- Sidekick turns your voice into clean, formatted notes. - Tap the mic, speak naturally, and get structured text back instantly. -

-
- -
-

Privacy

-

- Your API key is stored only in your browser and is sent directly to OpenAI. - Your account data is securely managed by Firebase. -

-
- -
-

Disclaimer

-

- Sidekick is a note-taking tool only. It cannot answer questions, - generate content, or assist with academic tasks. -

-
- -
-

Credits

-

- Built with React, powered by OpenAI Whisper and GPT-4o-mini. -

-
-
- ); -} - -/* ── Main modal ── */ - export default function SettingsModal({ onClose, user, onSignOut, dark, onToggleTheme }) { const [activeTab, setActiveTab] = useState("account"); const [presets, setPresets] = useState(() => { - const saved = localStorage.getItem("sidekick_format_presets"); + const saved = localStorage.getItem(STORAGE_KEYS.FORMAT_PRESETS); if (saved) { try { const parsed = JSON.parse(saved); @@ -421,20 +40,14 @@ export default function SettingsModal({ onClose, user, onSignOut, dark, onToggle return DEFAULT_PRESETS; }); - useEffect(() => { - const handleEsc = (e) => { - if (e.key === "Escape") onClose(); - }; - window.addEventListener("keydown", handleEsc); - return () => window.removeEventListener("keydown", handleEsc); - }, [onClose]); + useEscapeKey(onClose); const handleTogglePreset = (presetId) => { setPresets((prev) => { const updated = prev.map((p) => p.id === presetId ? { ...p, enabled: !p.enabled } : p ); - localStorage.setItem("sidekick_format_presets", JSON.stringify(updated)); + localStorage.setItem(STORAGE_KEYS.FORMAT_PRESETS, JSON.stringify(updated)); return updated; }); }; diff --git a/src/components/Sidebar.css b/src/components/Sidebar.css index e551702..b77180c 100644 --- a/src/components/Sidebar.css +++ b/src/components/Sidebar.css @@ -172,7 +172,7 @@ position: fixed; top: 0; left: 0; - z-index: 1000; + z-index: var(--modal-z); box-shadow: 4px 0 24px rgba(0, 0, 0, 0.15); } @@ -185,7 +185,7 @@ display: block; position: fixed; inset: 0; - background: rgba(0, 0, 0, 0.35); + background: var(--overlay-bg); z-index: 999; animation: sidebarFadeIn 0.2s ease; } diff --git a/src/components/Sidebar.jsx b/src/components/Sidebar.jsx index c20f575..4f788ec 100644 --- a/src/components/Sidebar.jsx +++ b/src/components/Sidebar.jsx @@ -1,27 +1,6 @@ -import { PlusIcon, HamburgerIcon } from "./Icons"; +import { PlusIcon, HamburgerIcon, GearIcon, SignOutIcon, ChatIcon } from "./Icons"; import "./Sidebar.css"; -const GearIcon = () => ( - - - - -); - -const SignOutIcon = () => ( - - - - - -); - -const ChatIcon = () => ( - - - -); - export default function Sidebar({ conversations, activeConversationId, diff --git a/src/components/settings/AboutTab.jsx b/src/components/settings/AboutTab.jsx new file mode 100644 index 0000000..8810146 --- /dev/null +++ b/src/components/settings/AboutTab.jsx @@ -0,0 +1,48 @@ +import { MicIcon } from "../Icons"; + +export default function AboutTab() { + return ( +
+
+
+ +
+
+

Sidekick

+ Version 1.0.0 +
+
+ +
+

What is Sidekick?

+

+ Sidekick turns your voice into clean, formatted notes. + Tap the mic, speak naturally, and get structured text back instantly. +

+
+ +
+

Privacy

+

+ Your API key is stored securely in your Firebase account and is sent directly to OpenAI. + Your account data is securely managed by Firebase. +

+
+ +
+

Disclaimer

+

+ Sidekick is a note-taking tool only. It cannot answer questions, + generate content, or assist with academic tasks. +

+
+ +
+

Credits

+

+ Built with React, powered by OpenAI Whisper and GPT-4o-mini. +

+
+
+ ); +} diff --git a/src/components/settings/AccountTab.jsx b/src/components/settings/AccountTab.jsx new file mode 100644 index 0000000..9935831 --- /dev/null +++ b/src/components/settings/AccountTab.jsx @@ -0,0 +1,257 @@ +import { useState } from "react"; +import { sendPasswordResetEmail, deleteUser, reauthenticateWithCredential, EmailAuthProvider, reauthenticateWithPopup } from "firebase/auth"; +import { doc, deleteDoc } from "firebase/firestore"; +import { auth, db, googleProvider } from "../../utils/firebase"; +import { getAccessToken } from "../../utils/googleDrive"; +import { STORAGE_KEYS } from "../../utils/constants"; +import { UserIcon, DriveIcon } from "../Icons"; + +export default function AccountTab({ user, onSignOut, onClose, dark, onToggleTheme }) { + const [resetStatus, setResetStatus] = useState(""); + const [deleteStep, setDeleteStep] = useState("idle"); // idle | confirm | password | deleting | error + const [deletePassword, setDeletePassword] = useState(""); + const [deleteError, setDeleteError] = useState(""); + const driveConnected = !!getAccessToken(); + + const isGoogleUser = user?.providerData?.some( + (p) => p.providerId === "google.com" + ); + + const handleResetPassword = async () => { + if (!user?.email) return; + setResetStatus("sending"); + try { + await sendPasswordResetEmail(auth, user.email); + setResetStatus("sent"); + } catch { + setResetStatus("error"); + } + }; + + const handleDeleteAccount = async () => { + if (deleteStep === "idle") { + setDeleteStep("confirm"); + return; + } + + if (deleteStep === "confirm") { + if (isGoogleUser) { + setDeleteStep("deleting"); + try { + await reauthenticateWithPopup(user, googleProvider); + await performDeletion(); + } catch (err) { + setDeleteError(err.code === "auth/popup-closed-by-user" + ? "Sign-in popup was closed. Please try again." + : "Re-authentication failed. Please try again."); + setDeleteStep("error"); + } + } else { + setDeleteStep("password"); + } + return; + } + + if (deleteStep === "password") { + if (!deletePassword) { + setDeleteError("Please enter your password."); + return; + } + setDeleteStep("deleting"); + try { + const credential = EmailAuthProvider.credential(user.email, deletePassword); + await reauthenticateWithCredential(user, credential); + await performDeletion(); + } catch (err) { + setDeleteError( + err.code === "auth/wrong-password" || err.code === "auth/invalid-credential" + ? "Incorrect password. Please try again." + : "Something went wrong. Please try again." + ); + setDeleteStep("error"); + } + return; + } + + if (deleteStep === "error") { + setDeleteError(""); + setDeletePassword(""); + setDeleteStep("idle"); + } + }; + + const performDeletion = async () => { + const uid = user.uid; + try { + await deleteDoc(doc(db, "users", uid)); + } catch { + // Firestore doc may not exist, continue anyway + } + localStorage.removeItem(STORAGE_KEYS.apiKey(uid)); + await deleteUser(user); + onClose(); + }; + + return ( +
+
+ {user?.photoURL ? ( + Profile + ) : ( +
+ +
+ )} +
+ {user?.displayName || "User"} + {user?.email || ""} +
+
+ +
+ + +
+ +
+ + +
+ + {!isGoogleUser && ( +
+ + +
+ )} + +
+ +
+ + Google Drive + + {driveConnected ? "Connected" : "Not connected"} + +
+
+ +
+ +
+ {dark ? "Dark mode" : "Light mode"} + +
+
+ + + + {/* ── Delete account ── */} +
+ + + {deleteStep === "idle" && ( + + )} + + {deleteStep === "confirm" && ( +
+

+ This will permanently delete your account and all associated data. This action cannot be undone. +

+
+ + +
+
+ )} + + {deleteStep === "password" && ( +
+

+ Enter your password to confirm account deletion. +

+ setDeletePassword(e.target.value)} + onKeyDown={(e) => e.key === "Enter" && handleDeleteAccount()} + autoFocus + /> + {deleteError &&

{deleteError}

} +
+ + +
+
+ )} + + {deleteStep === "deleting" && ( +
+

Deleting your account...

+
+ )} + + {deleteStep === "error" && ( +
+

{deleteError}

+
+ +
+
+ )} +
+
+ ); +} diff --git a/src/components/settings/FormatsTab.jsx b/src/components/settings/FormatsTab.jsx new file mode 100644 index 0000000..34c942e --- /dev/null +++ b/src/components/settings/FormatsTab.jsx @@ -0,0 +1,29 @@ +export default function FormatsTab({ presets, onToggle }) { + return ( +
+

+ Voice commands that control how your text is formatted. + Say these keywords while recording to trigger formatting. +

+
+ {presets.map((preset) => ( +
+
+ "{preset.keyword}" + {preset.description} +
+ +
+ ))} +
+
+ ); +} diff --git a/src/hooks/useEscapeKey.js b/src/hooks/useEscapeKey.js new file mode 100644 index 0000000..6dfbb1b --- /dev/null +++ b/src/hooks/useEscapeKey.js @@ -0,0 +1,11 @@ +import { useEffect } from "react"; + +export function useEscapeKey(handler) { + useEffect(() => { + const handleEsc = (e) => { + if (e.key === "Escape") handler(); + }; + window.addEventListener("keydown", handleEsc); + return () => window.removeEventListener("keydown", handleEsc); + }, [handler]); +} diff --git a/src/index.css b/src/index.css index 0706964..4af4782 100644 --- a/src/index.css +++ b/src/index.css @@ -20,6 +20,11 @@ --shadow: 0 1px 3px rgba(0,0,0,0.06), 0 4px 16px rgba(0,0,0,0.04); --radius: 16px; --radius-sm: 10px; + --color-error: #e63e2f; + --color-success: #2d9e6b; + --overlay-bg: rgba(0, 0, 0, 0.35); + --modal-z: 1000; + --icon-btn-size: 28px; font-family: 'Syne', sans-serif; } diff --git a/src/main.jsx b/src/main.jsx index 54b1c91..8becab9 100644 --- a/src/main.jsx +++ b/src/main.jsx @@ -1,10 +1,13 @@ import { StrictMode } from "react"; import { createRoot } from "react-dom/client"; +import ErrorBoundary from "./components/ErrorBoundary.jsx"; import App from "./App.jsx"; import "./index.css"; createRoot(document.getElementById("root")).render( - + + + ); \ No newline at end of file diff --git a/src/utils/constants.js b/src/utils/constants.js new file mode 100644 index 0000000..95f7bd2 --- /dev/null +++ b/src/utils/constants.js @@ -0,0 +1,7 @@ +export const STORAGE_KEYS = { + SEEN_WELCOME: "sidekick_seen_welcome", + DARK_MODE: "sidekick_dark_mode", + FORMAT_PRESETS: "sidekick_format_presets", + conversations: (uid) => `sidekick_conversations_${uid}`, + apiKey: (uid) => `sidekick_apikey_${uid}`, +}; diff --git a/src/utils/exportFile.js b/src/utils/exportFile.js index d02c7d7..485de6c 100644 --- a/src/utils/exportFile.js +++ b/src/utils/exportFile.js @@ -2,6 +2,9 @@ import { jsPDF } from "jspdf"; import { Document, Packer, Paragraph, TextRun } from "docx"; export async function generateFile(text, format) { + if (!text || typeof text !== "string") throw new Error("No text to export."); + if (!format) throw new Error("Export format is required."); + const timestamp = new Date().toISOString().slice(0, 10); const baseName = `sidekick-export-${timestamp}`; diff --git a/src/utils/firebase.js b/src/utils/firebase.js index 41b2003..77c473e 100644 --- a/src/utils/firebase.js +++ b/src/utils/firebase.js @@ -2,6 +2,18 @@ import { initializeApp } from "firebase/app"; import { getAuth, GoogleAuthProvider } from "firebase/auth"; import { getFirestore } from "firebase/firestore"; +const REQUIRED_ENV_VARS = [ + "VITE_FIREBASE_API_KEY", + "VITE_FIREBASE_AUTH_DOMAIN", + "VITE_FIREBASE_PROJECT_ID", +]; +const missingVars = REQUIRED_ENV_VARS.filter((key) => !import.meta.env[key]); +if (missingVars.length > 0) { + console.error( + `Missing required Firebase env vars: ${missingVars.join(", ")}. Copy .env.example to .env and fill in your Firebase config.` + ); +} + const firebaseConfig = { apiKey: import.meta.env.VITE_FIREBASE_API_KEY || "", authDomain: import.meta.env.VITE_FIREBASE_AUTH_DOMAIN || "", diff --git a/src/utils/googleDrive.js b/src/utils/googleDrive.js index 1122381..557d665 100644 --- a/src/utils/googleDrive.js +++ b/src/utils/googleDrive.js @@ -39,10 +39,15 @@ export function getAccessToken() { return currentAccessToken; } +export function clearAccessToken() { + currentAccessToken = null; + tokenClient = null; +} + export async function uploadToDrive(blob, filename, mimeType) { - if (!currentAccessToken) { - throw new Error("Not authenticated with Google."); - } + if (!currentAccessToken) throw new Error("Not authenticated with Google."); + if (!blob) throw new Error("No file data to upload."); + if (!filename || typeof filename !== "string") throw new Error("Filename is required."); const metadata = { name: filename, mimeType }; const form = new FormData(); diff --git a/src/utils/openai.js b/src/utils/openai.js index a9c1af6..cb258b3 100644 --- a/src/utils/openai.js +++ b/src/utils/openai.js @@ -29,6 +29,9 @@ Do not add commentary, suggestions, or any text beyond what the speaker actually // ─── Whisper transcription ──────────────────────────────────────────────────── // TODO: Wire up when ready export async function transcribeAudio(audioBlob, apiKey) { + if (!audioBlob) throw new Error("No audio data provided."); + if (!apiKey || typeof apiKey !== "string") throw new Error("A valid API key is required."); + const formData = new FormData(); formData.append("file", audioBlob, "audio.webm"); formData.append("model", "whisper-1"); @@ -47,6 +50,9 @@ export async function transcribeAudio(audioBlob, apiKey) { // ─── GPT formatting ────────────────────────────────────────────────────────── // TODO: Wire up when ready export async function clarifyWithAI(transcript, apiKey) { + if (!transcript || typeof transcript !== "string") throw new Error("No transcript provided."); + if (!apiKey || typeof apiKey !== "string") throw new Error("A valid API key is required."); + const response = await fetch("https://api.openai.com/v1/chat/completions", { method: "POST", headers: {