diff --git a/App.tsx b/App.tsx index 8b60eff..2b439ec 100644 --- a/App.tsx +++ b/App.tsx @@ -1,168 +1,154 @@ -import React, { useState, useEffect } from 'react'; -import { ViewState, UserPreferences, Summary, Note, RoutineTask, UserStats, Flashcard, NoteElement, Folder, UserRole } from './types'; -import { StorageService } from './services/storageService'; -import { auth, isFirebaseConfigured } from './firebaseConfig'; -import { onAuthStateChanged, signInWithEmailAndPassword, createUserWithEmailAndPassword, signOut } from 'firebase/auth'; -import { authRateLimiter } from './services/rateLimiter'; -import { validateUserInput } from './services/validation'; -import { initializeSecureKeys } from './services/secureKeyManager'; -import logger from './services/securityLogger'; - -import Sidebar from './components/Sidebar'; -import Landing from './pages/Landing'; -import Dashboard from './pages/Dashboard'; -import Summarizer from './pages/Summarizer'; -import Notes from './pages/Notes'; -import Routine from './pages/Routine'; -import Focus from './pages/Focus'; -import QuizPage from './pages/Quiz'; -import NoteFeed from './pages/NoteFeed'; -import NotesStore from './pages/NotesStore'; -import Classrooms from './pages/Classrooms'; -import Auth from './pages/Auth'; -import RoleSelection from './pages/RoleSelection'; -import TeacherDashboard from './pages/TeacherDashboard'; -import Folders from './pages/Folders'; -import ClassroomDetail from './pages/ClassroomDetail'; -import StudentClassrooms from './pages/StudentClassrooms'; -import StudentClassroomView from './pages/StudentClassroomView'; -import { WorkflowBoard } from './components/WorkflowBoard'; -import { AlertCircle, LogIn, X, Loader2 } from 'lucide-react'; -import { ExamTracker } from './pages/ExamTracker'; - +import React, { useState, useEffect } from "react"; +import { + ViewState, + UserPreferences, + Summary, + Note, + RoutineTask, + UserStats, + Flashcard, + Folder, +} from "./types"; + +import { StorageService } from "./services/storageService"; +import { auth, isFirebaseConfigured } from "./firebaseConfig"; +import { + onAuthStateChanged, + signOut, +} from "firebase/auth"; + +import Sidebar from "./components/Sidebar"; +import Landing from "./pages/Landing"; +import Dashboard from "./pages/Dashboard"; +import Summarizer from "./pages/Summarizer"; +import Notes from "./pages/Notes"; +import Routine from "./pages/Routine"; +import Focus from "./pages/Focus"; +import QuizPage from "./pages/Quiz"; +import NoteFeed from "./pages/NoteFeed"; +import NotesStore from "./pages/NotesStore"; +import Classrooms from "./pages/Classrooms"; +import Auth from "./pages/Auth"; +import RoleSelection from "./pages/RoleSelection"; +import TeacherDashboard from "./pages/TeacherDashboard"; +import Folders from "./pages/Folders"; +import ClassroomDetail from "./pages/ClassroomDetail"; +import StudentClassrooms from "./pages/StudentClassrooms"; +import StudentClassroomView from "./pages/StudentClassroomView"; +import { WorkflowBoard } from "./components/WorkflowBoard"; +import { ExamTracker } from "./pages/ExamTracker"; +import { Loader2 } from "lucide-react"; const App: React.FC = () => { - const [view, setView] = useState("landing"); + const [view, setView] = useState("landing"); const [user, setUser] = useState(null); const [loadingAuth, setLoadingAuth] = useState(true); const [summaries, setSummaries] = useState([]); const [notes, setNotes] = useState([]); const [folders, setFolders] = useState([]); const [stats, setStats] = useState(null); - const [focusTask, setFocusTask] = useState( - undefined, - ); + const [focusTask, setFocusTask] = useState(undefined); const [sidebarCollapsed, setSidebarCollapsed] = useState(false); const [selectedClassroomId, setSelectedClassroomId] = useState(undefined); + const [activeFolderId, setActiveFolderId] = useState(undefined); - // Folder filtering state - const [activeFolderId, setActiveFolderId] = useState< - string | null | undefined - >(undefined); - - const deriveName = (email?: string | null) => { - if (!email) return "User"; - return email.split("@")[0]; - }; + const deriveName = (email?: string | null) => + email ? email.split("@")[0] : "User"; + /* ======================================================= + SAFE AUTH INITIALIZATION + ======================================================= */ useEffect(() => { + // 🔐 If Firebase not configured → fallback to guest / landing + if (!isFirebaseConfigured() || !auth) { + console.warn("Firebase not configured — running UI-only mode"); + + const guestUser = StorageService.getGuestSession(); + if (guestUser) { + StorageService.setSession(guestUser); + setUser(guestUser); + loadUserData(); + setView("dashboard"); + } else { + setUser(null); + setView("landing"); + } + + setLoadingAuth(false); + return; + } + + // ✅ Firebase configured → attach listener const unsubscribe = onAuthStateChanged(auth, async (firebaseUser) => { if (firebaseUser) { let profile = await StorageService.getUserProfile(firebaseUser.uid); if (!profile) { - // Generate avatar URL from Firebase photoURL or create one - const avatarUrl = firebaseUser.photoURL || - `https://api.dicebear.com/7.x/notionists/svg?seed=${firebaseUser.displayName || firebaseUser.email}`; - profile = { id: firebaseUser.uid, isGuest: false, name: firebaseUser.displayName || deriveName(firebaseUser.email), email: firebaseUser.email || undefined, - avatarUrl: avatarUrl, + avatarUrl: + firebaseUser.photoURL || + `https://api.dicebear.com/7.x/notionists/svg?seed=${firebaseUser.displayName || firebaseUser.email}`, freeTimeHours: 2, energyPeak: "morning", goal: "Productivity", distractionLevel: "medium", }; await StorageService.saveUserProfile(profile); - } else { - // Always update email and avatar if missing or different (ensures existing profiles get them) - let needsUpdate = false; - if (!profile.email && firebaseUser.email) { - profile.email = firebaseUser.email; - needsUpdate = true; - } - if (!profile.avatarUrl) { - profile.avatarUrl = firebaseUser.photoURL || - `https://api.dicebear.com/7.x/notionists/svg?seed=${profile.name}`; - needsUpdate = true; - } - if (needsUpdate) { - await StorageService.saveUserProfile(profile); - } } StorageService.setSession(profile); setUser(profile); - loadUserData(); + await loadUserData(); - // Check if user has selected a role - if (!profile.role) { - setView("roleSelection"); - } else { - setView("dashboard"); - } + if (!profile.role) setView("roleSelection"); + else setView("dashboard"); } else { const guestUser = StorageService.getGuestSession(); if (guestUser) { StorageService.setSession(guestUser); setUser(guestUser); - loadUserData(); + await loadUserData(); setView("dashboard"); } else { setUser(null); setView("landing"); } } + setLoadingAuth(false); }); return () => unsubscribe(); }, []); + /* ======================================================= + LOAD USER DATA + ======================================================= */ const loadUserData = async () => { try { await StorageService.checkLoginStreak(); - const n = await StorageService.getNotes(); - const s = await StorageService.getSummaries(); - const st = await StorageService.getStats(); - const f = await StorageService.getFolders(); - setNotes(n); - setSummaries(s); - setStats(st); - setFolders(f); + setNotes(await StorageService.getNotes()); + setSummaries(await StorageService.getSummaries()); + setStats(await StorageService.getStats()); + setFolders(await StorageService.getFolders()); } catch (error) { console.error("Error loading user data:", error); } }; - const handleGuestAccess = () => { - const guestUser = StorageService.createGuestUser(); - StorageService.saveUserProfile(guestUser); - StorageService.setSession(guestUser); - setUser(guestUser); - loadUserData(); - setView("dashboard"); - }; - - const handleRoleSelected = async (role: "student" | "teacher") => { - if (!user) return; - - const updatedUser = { ...user, role }; - await StorageService.saveUserProfile(updatedUser); - StorageService.setSession(updatedUser); - setUser(updatedUser); - setView("dashboard"); - }; - + /* ======================================================= + LOGOUT (SAFE) + ======================================================= */ const handleLogout = async () => { if (user?.isGuest) { localStorage.removeItem("procastify_session"); setUser(null); setView("landing"); - } else { + } else if (auth) { await signOut(auth); } }; @@ -171,6 +157,16 @@ const App: React.FC = () => { setFocusTask(task); setView("focus"); }; + const handleGuestAccess = () => { + const guestUser = StorageService.createGuestUser(); + + StorageService.saveUserProfile(guestUser); + StorageService.setSession(guestUser); + + setUser(guestUser); + loadUserData(); + setView("dashboard"); +}; const handleFocusExit = (minutesSpent: number) => { if (minutesSpent > 0) { @@ -180,146 +176,9 @@ const App: React.FC = () => { setView("routine"); }; - const handleNavigate = ( - newView: ViewState | "folders", - folderId?: string | null, - ) => { - if (newView === "notes") { - setActiveFolderId(folderId); - } else if (newView === "folders") { - // Folders view - accessible through Notes page button only - setActiveFolderId(undefined); - } else if (newView === "classroomDetail" || newView === "studentClassroomView") { - // Store classroom ID for detail views - setSelectedClassroomId(folderId || undefined); - } else { - setActiveFolderId(undefined); - } - setView(newView); - }; - - const handleAddToNote = async ( - noteId: string | null, - summary: Summary, - flashcards: Flashcard[], - ) => { - if (!user) return; - - const timestamp = Date.now(); - - // --- Generate Blocks for Document Section --- - const newBlocks: any[] = []; - - // 1. Summary Header - newBlocks.push({ - id: `${timestamp}-h1`, - type: "h1", - content: `Summary: ${new Date().toLocaleDateString()}`, - }); - - // 2. Summary Text - const formattedSummary = summary.summaryText.replace(/\n/g, "
"); - newBlocks.push({ - id: `${timestamp}-text`, - type: "text", - content: formattedSummary, - }); - - // 3. Flashcards Section - if (flashcards.length > 0) { - newBlocks.push({ - id: `${timestamp}-fc-h2`, - type: "h2", - content: "Flashcards (Key Learning Concepts)", - }); - - flashcards.forEach((fc, i) => { - newBlocks.push({ - id: `${timestamp}-fc-${i}-q`, - type: "h3", - content: fc.front, - }); - newBlocks.push({ - id: `${timestamp}-fc-${i}-a`, - type: "text", - content: fc.back, - }); - newBlocks.push({ - id: `${timestamp}-fc-${i}-d`, - type: "text", - content: "", - }); - }); - } - - let updatedNotes = [...notes]; - let noteWasCreated = false; - let noteToSave: Note | null = null; - - if (noteId === null) { - // --- Create New Note --- - const newNote: Note = { - id: timestamp.toString(), - userId: user.id, - title: `Summary: ${new Date().toLocaleDateString()}`, - document: { blocks: newBlocks }, - canvas: { elements: [] }, - elements: [], - tags: [], - folder: "Summaries", - folderId: null, - lastModified: timestamp, - createdAt: timestamp, - }; - updatedNotes = [newNote, ...updatedNotes]; - noteToSave = newNote; - noteWasCreated = true; - } else { - // --- Update Existing Note --- - updatedNotes = updatedNotes.map((n) => { - if (n.id === noteId) { - const existingBlocks = n.document?.blocks || []; - - const separatorBlock = { - id: `${timestamp}-sep`, - type: "text", - content: "
---
", - }; - - const updatedBlocks = [ - ...existingBlocks, - separatorBlock, - ...newBlocks, - ]; - - const updated = { - ...n, - document: { blocks: updatedBlocks }, - lastModified: timestamp, - }; - noteToSave = updated; - return updated; - } - return n; - }); - } - - setNotes(updatedNotes); - - if (noteToSave) { - await StorageService.saveNote(noteToSave); - } - - if (noteWasCreated) { - await StorageService.updateStats((s) => ({ - ...s, - notesCreated: (s.notesCreated || 0) + 1, - })); - const updatedStats = await StorageService.getStats(); - setStats(updatedStats); - } - }; - + /* ======================================================= + LOADING SCREEN + ======================================================= */ if (loadingAuth) { return (
@@ -328,215 +187,76 @@ const App: React.FC = () => { ); } - if (view === "auth") { - return ( - setView("dashboard")} - onGuestAccess={handleGuestAccess} - onBack={user ? () => setView("dashboard") : () => setView("landing")} - /> - ); - } - - if (view === "roleSelection") { - return ; - } - if (!user || view === "landing") { return ( setView("auth")} - onGuestAccess={handleGuestAccess} - /> + onLogin={() => setView("auth")} + onGuestAccess={handleGuestAccess} +/> ); } - if (view === "focus") - return ; + if (view === "focus") + return + /* ======================================================= + MAIN LAYOUT + ======================================================= */ return (
setView(v)} onLogout={handleLogout} collapsed={sidebarCollapsed} onToggleCollapse={() => setSidebarCollapsed(!sidebarCollapsed)} userRole={user.role} - user={user ? { name: user.name, avatarUrl: user.avatarUrl } : undefined} + user={{ name: user.name, avatarUrl: user.avatarUrl }} /> -
- {/* User Context Bar (Small) */} - {user.isGuest && ( -
- Guest Mode: Data saved to this device only. - -
- )} +
{view === "dashboard" && stats && ( - <> - {user.role === "teacher" ? ( - - ) : ( - { - setView("notes"); - }} - onNavigate={(view) => handleNavigate(view as any)} - /> - )} - - )} - - {view === "summarizer" && ( - { - const sWithUser = { ...s, userId: user.id }; - const newSums = [sWithUser, ...summaries]; - setSummaries(newSums); - await StorageService.saveSummaries(newSums); - }} - notes={notes} - onAddToNote={handleAddToNote} - /> - )} - - {view === "notes" && ( - { - setNotes(newNotes); - const notesArray = typeof newNotes === 'function' ? newNotes(notes) : newNotes; - StorageService.saveNotes(notesArray); - }} - onDeleteNote={async (noteId) => { - await StorageService.deleteNote(noteId); - setNotes((prev) => prev.filter((n) => n.id !== noteId)); - console.log("[DELETE] Removed from local React state:", noteId); - }} + - )} - - {view === "folders" && ( - setView(v as any)} /> )} {view === "routine" && ( { - await StorageService.saveUserProfile(u); - setUser(u); - }} + setUser={setUser} notes={notes} - setNotes={(n) => { - setNotes(n); - StorageService.saveNotes(n); - }} + setNotes={setNotes} onStartTask={handleStartFocus} - onNavigate={(view) => handleNavigate(view as any)} - /> - )} - - {view === "quiz" && ( - setView(v as any)} /> )} - {view === "feed" && ( - setView("dashboard")} - /> - )} - - {view === "store" && ( - { - setNotes([newNote, ...notes]); - StorageService.saveNote(newNote); - setView("notes"); - }} - onNavigate={handleNavigate} - /> - )} - - {view === "classrooms" && ( - - )} - - {view === "classroomDetail" && selectedClassroomId && ( - - )} - - {view === "studentClassrooms" && ( - - )} - - {view === "studentClassroomView" && selectedClassroomId && ( - - )} + {view === "notes" && ( + setView(v as any)} + activeFolderId={activeFolderId} + folders={folders} + onDeleteNote={async (noteId: string) => { + await StorageService.deleteNote(noteId); + setNotes((prev) => prev.filter((n) => n.id !== noteId)); + }} + /> +)} - {view === "workflow" && ( - setView("dashboard")} - sidebarCollapsed={sidebarCollapsed} - /> - )} - {view === "examTracker" && ( )} -
); }; -export default App; +export default App; \ No newline at end of file diff --git a/firebaseConfig.ts b/firebaseConfig.ts index ca672f3..8b478c9 100644 --- a/firebaseConfig.ts +++ b/firebaseConfig.ts @@ -1,61 +1,87 @@ import { initializeApp, FirebaseApp } from "firebase/app"; import { getAuth, Auth } from "firebase/auth"; import { getFirestore, Firestore, connectFirestoreEmulator } from "firebase/firestore"; -import { getStorage, connectStorageEmulator, FirebaseStorage } from "firebase/storage"; +import { getStorage, FirebaseStorage, connectStorageEmulator } from "firebase/storage"; import { getSecureKey } from "./services/secureKeyManager"; -const getEnv = (key: string) => { - if (typeof import.meta !== 'undefined' && (import.meta as any).env) { - return (import.meta as any).env[`VITE_${key}`] || (import.meta as any).env[`REACT_APP_${key}`]; +/** + * Safely read environment variables (Vite / CRA / Node fallback) + */ +const getEnv = (key: string): string | undefined => { + if (typeof import.meta !== "undefined" && (import.meta as any).env) { + return ( + (import.meta as any).env[`VITE_${key}`] || + (import.meta as any).env[`REACT_APP_${key}`] + ); } - if (typeof process !== 'undefined' && process.env) { + + if (typeof process !== "undefined" && process.env) { return process.env[`REACT_APP_${key}`] || process.env[key]; } + return undefined; }; - +/** + * Firebase Configuration + */ const firebaseConfig = { - apiKey: getSecureKey('FIREBASE_API_KEY') || getEnv('FIREBASE_API_KEY'), - authDomain: getEnv('FIREBASE_AUTH_DOMAIN'), - projectId: getEnv('FIREBASE_PROJECT_ID'), - storageBucket: getEnv('FIREBASE_STORAGE_BUCKET'), - messagingSenderId: getEnv('FIREBASE_MESSAGING_SENDER_ID'), - appId: getEnv('FIREBASE_APP_ID') + apiKey: getSecureKey("FIREBASE_API_KEY") || getEnv("FIREBASE_API_KEY"), + authDomain: getEnv("FIREBASE_AUTH_DOMAIN"), + projectId: getEnv("FIREBASE_PROJECT_ID"), + storageBucket: getEnv("FIREBASE_STORAGE_BUCKET"), + messagingSenderId: getEnv("FIREBASE_MESSAGING_SENDER_ID"), + appId: getEnv("FIREBASE_APP_ID"), }; -// Check if Firebase is properly configured +/** + * Validate Firebase Configuration + */ export const isFirebaseConfigured = (): boolean => { return !!( firebaseConfig.apiKey && - firebaseConfig.apiKey !== 'AIzaSy_DUMMY_KEY_FOR_DEV_MODE' && + firebaseConfig.apiKey.length > 20 && firebaseConfig.authDomain && - firebaseConfig.projectId && - firebaseConfig.apiKey.length > 20 // Basic validation + firebaseConfig.projectId ); }; -export const app = initializeApp(firebaseConfig); -export const auth = getAuth(app); -export const db = getFirestore(app); -export const storage = getStorage(app); +/** + * Safe Firebase Initialization + */ +let app: FirebaseApp | null = null; +let auth: Auth | null = null; +let db: Firestore | null = null; +let storage: FirebaseStorage | null = null; -// Connect to Firebase Emulators in development (optional) -if (typeof window !== 'undefined' && window.location.hostname === 'localhost') { - try { - // Check if we should use emulators (set USE_EMULATORS=true in .env to enable) - const useEmulators = getEnv('USE_EMULATORS') === 'true'; +if (isFirebaseConfigured()) { + app = initializeApp(firebaseConfig); + auth = getAuth(app); + db = getFirestore(app); + storage = getStorage(app); + + console.log("✅ Firebase initialized successfully"); + + // Optional Emulator Support + if (typeof window !== "undefined" && window.location.hostname === "localhost") { + const useEmulators = getEnv("USE_EMULATORS") === "true"; if (useEmulators) { - console.log('🔧 Connecting to Firebase Emulators...'); - connectFirestoreEmulator(db, 'localhost', 8080); - connectStorageEmulator(storage, 'localhost', 9199); - console.log('✅ Connected to Firebase Emulators (Firestore: 8080, Storage: 9199)'); - } else { - console.log('📡 Using Production Firebase (set USE_EMULATORS=true in .env to use emulators)'); + try { + console.log("🔧 Connecting to Firebase Emulators..."); + connectFirestoreEmulator(db, "localhost", 8080); + connectStorageEmulator(storage, "localhost", 9199); + console.log("✅ Connected to Firebase Emulators"); + } catch (error: any) { + console.warn("⚠️ Emulator connection failed:", error.message); + } } - } catch (error: any) { - // Silently fail if emulators are not running - use production instead - console.warn('⚠️ Could not connect to emulators, using production Firebase:', error.message); } +} else { + console.warn("⚠️ Firebase not configured — running in UI-only mode"); } + +/** + * Export instances (can be null if not configured) + */ +export { app, auth, db, storage }; \ No newline at end of file diff --git a/package-lock.json b/package-lock.json index dfcf56f..07c406d 100644 --- a/package-lock.json +++ b/package-lock.json @@ -70,7 +70,6 @@ "integrity": "sha512-CGOfOJqWjg2qW/Mb6zNsDm+u5vFQ8DxXfbM09z69p5Z6+mE1ikP2jUXw+j42Pf1XTYED2Rni5f95npYeuwMDQA==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "@babel/code-frame": "^7.29.0", "@babel/generator": "^7.29.0", @@ -827,7 +826,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app/-/app-0.14.8.tgz", "integrity": "sha512-WiE9uCGRLUnShdjb9iP20sA3ToWrBbNXr14/N5mow7Nls9dmKgfGaGX5cynLvrltxq2OrDLh1VDNaUgsnS/k/g==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/component": "0.7.0", "@firebase/logger": "0.5.0", @@ -894,7 +892,6 @@ "resolved": "https://registry.npmjs.org/@firebase/app-compat/-/app-compat-0.5.8.tgz", "integrity": "sha512-4De6SUZ36zozl9kh5rZSxKWULpgty27rMzZ6x+xkoo7+NWyhWyFdsdvhFsWhTw/9GGj0wXIcbTjwHYCUIUuHyg==", "license": "Apache-2.0", - "peer": true, "dependencies": { "@firebase/app": "0.14.8", "@firebase/component": "0.7.0", @@ -910,8 +907,7 @@ "version": "0.9.3", "resolved": "https://registry.npmjs.org/@firebase/app-types/-/app-types-0.9.3.tgz", "integrity": "sha512-kRVpIl4vVGJ4baogMDINbyrIOtOxqhkZQg4jTq3l8Lw6WSk0xfpEYzezFu+Kl4ve4fbPl79dvwRtaFqAC/ucCw==", - "license": "Apache-2.0", - "peer": true + "license": "Apache-2.0" }, "node_modules/@firebase/auth": { "version": "1.12.0", @@ -1362,7 +1358,6 @@ "integrity": "sha512-0AZUyYUfpMNcztR5l09izHwXkZpghLgCUaAGjtMwXnCg3bj4ml5VgiwqOMOxJ+Nw4qN/zJAaOQBcJ7KGkWStqQ==", "hasInstallScript": true, "license": "Apache-2.0", - "peer": true, "dependencies": { "tslib": "^2.1.0" }, @@ -1386,6 +1381,17 @@ "@floating-ui/utils": "^0.2.10" } }, + "node_modules/@floating-ui/dom": { + "version": "1.7.5", + "resolved": "https://registry.npmjs.org/@floating-ui/dom/-/dom-1.7.5.tgz", + "integrity": "sha512-N0bD2kIPInNHUHehXhMke1rBGs1dwqvC9O9KYMyyjK7iXt7GAhnro7UlcuYcGdS/yYOlq0MAVgrow8IbWJwyqg==", + "license": "MIT", + "optional": true, + "dependencies": { + "@floating-ui/core": "^1.7.4", + "@floating-ui/utils": "^0.2.10" + } + }, "node_modules/@floating-ui/utils": { "version": "0.2.10", "resolved": "https://registry.npmjs.org/@floating-ui/utils/-/utils-0.2.10.tgz", @@ -2253,7 +2259,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/core/-/core-3.19.0.tgz", "integrity": "sha512-bpqELwPW+DG8gWiD8iiFtSl4vIBooG5uVJod92Qxn3rA9nFatyXRr4kNbMJmOZ66ezUvmCjXVe/5/G4i5cyzKA==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2337,7 +2342,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-code-block/-/extension-code-block-3.19.0.tgz", "integrity": "sha512-b/2qR+tMn8MQb+eaFYgVk4qXnLNkkRYmwELQ8LEtEDQPxa5Vl7J3eu8+4OyoIFhZrNDZvvoEp80kHMCP8sI6rg==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2507,7 +2511,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extension-list/-/extension-list-3.19.0.tgz", "integrity": "sha512-N6nKbFB2VwMsPlCw67RlAtYSK48TAsAUgjnD+vd3ieSlIufdQnLXDFUP6hFKx9mwoUVUgZGz02RA6bkxOdYyTw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2613,7 +2616,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/extensions/-/extensions-3.19.0.tgz", "integrity": "sha512-ZmGUhLbMWaGqnJh2Bry+6V4M6gMpUDYo4D1xNux5Gng/E/eYtc+PMxMZ/6F7tNTAuujLBOQKj6D+4SsSm457jw==", "license": "MIT", - "peer": true, "funding": { "type": "github", "url": "https://github.com/sponsors/ueberdosis" @@ -2628,7 +2630,6 @@ "resolved": "https://registry.npmjs.org/@tiptap/pm/-/pm-3.19.0.tgz", "integrity": "sha512-789zcnM4a8OWzvbD2DL31d0wbSm9BVeO/R7PLQwLIGysDI3qzrcclyZ8yhqOEVuvPitRRwYLq+mY14jz7kY4cw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-changeset": "^2.3.0", "prosemirror-collab": "^1.3.1", @@ -3107,7 +3108,6 @@ } ], "license": "MIT", - "peer": true, "dependencies": { "baseline-browser-mapping": "^2.9.0", "caniuse-lite": "^1.0.30001759", @@ -3430,7 +3430,8 @@ "version": "3.2.3", "resolved": "https://registry.npmjs.org/csstype/-/csstype-3.2.3.tgz", "integrity": "sha512-z1HGKcYy2xA8AGQfwrn0PAy+PB7X/GSj3UVJW9qKyn43xWa+gl5nXmU4qqLMRzWVLFC8KusUX8T/0kCiOYpAIQ==", - "license": "MIT" + "license": "MIT", + "peer": true }, "node_modules/d3-array": { "version": "3.2.4", @@ -4166,7 +4167,6 @@ "resolved": "https://registry.npmjs.org/highlight.js/-/highlight.js-11.11.1.tgz", "integrity": "sha512-Xwwo44whKBVCYoliBQwaPvtd/2tYFkRQtXDWj1nackaV2JPXx3L0+Jvd8/qCJ2p+ML0/XVkJ2q+Mr+UVdpJK5w==", "license": "BSD-3-Clause", - "peer": true, "engines": { "node": ">=12.0.0" } @@ -4515,7 +4515,6 @@ "resolved": "https://registry.npmjs.org/lowlight/-/lowlight-3.3.0.tgz", "integrity": "sha512-0JNhgFoPvP6U6lE/UdVsSq99tn6DhjjpAj5MxG49ewd2mOBVtwWYIT8ClyABhq198aXXODMU6Ox8DrGy/CpTZQ==", "license": "MIT", - "peer": true, "dependencies": { "@types/hast": "^3.0.0", "devlop": "^1.0.0", @@ -5500,7 +5499,6 @@ "integrity": "sha512-5gTmgEY/sqK6gFXLIsQNH19lWb4ebPDLA4SdLP7dsWkIXHWlG66oPuVvXSGFPppYZz8ZDZq0dYYrbHfBCVUb1Q==", "dev": true, "license": "MIT", - "peer": true, "engines": { "node": ">=12" }, @@ -5681,7 +5679,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-model/-/prosemirror-model-1.25.4.tgz", "integrity": "sha512-PIM7E43PBxKce8OQeezAs9j4TP+5yDpZVbuurd1h5phUxEKIu+G2a+EUZzIC5nS1mJktDJWzbqS23n1tsAf5QA==", "license": "MIT", - "peer": true, "dependencies": { "orderedmap": "^2.0.0" } @@ -5711,7 +5708,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-state/-/prosemirror-state-1.4.4.tgz", "integrity": "sha512-6jiYHH2CIGbCfnxdHbXZ12gySFY/fz/ulZE333G6bPqIZ4F+TXo9ifiR86nAHpWnfoNjOb3o5ESi7J8Uz1jXHw==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.0.0", "prosemirror-transform": "^1.0.0", @@ -5760,7 +5756,6 @@ "resolved": "https://registry.npmjs.org/prosemirror-view/-/prosemirror-view-1.41.5.tgz", "integrity": "sha512-UDQbIPnDrjE8tqUBbPmCOZgtd75htE6W3r0JCmY9bL6W1iemDM37MZEKC49d+tdQ0v/CKx4gjxLoLsfkD2NiZA==", "license": "MIT", - "peer": true, "dependencies": { "prosemirror-model": "^1.20.0", "prosemirror-state": "^1.0.0", @@ -5805,7 +5800,6 @@ "resolved": "https://registry.npmjs.org/react/-/react-19.2.4.tgz", "integrity": "sha512-9nfp2hYpCwOjAN+8TZFGhtWEwgvWHXqESH8qT89AT/lWklpLON22Lc8pEtnpsZz7VmawabSU0gCjnj8aC0euHQ==", "license": "MIT", - "peer": true, "engines": { "node": ">=0.10.0" } @@ -5815,7 +5809,6 @@ "resolved": "https://registry.npmjs.org/react-dom/-/react-dom-19.2.4.tgz", "integrity": "sha512-AXJdLo8kgMbimY95O2aKQqsz2iWi9jMgKJhRBAxECE4IFxfcazB2LmzloIoibJI3C12IlY20+KFaLv+71bUJeQ==", "license": "MIT", - "peer": true, "dependencies": { "scheduler": "^0.27.0" }, @@ -5862,7 +5855,6 @@ "resolved": "https://registry.npmjs.org/react-redux/-/react-redux-9.2.0.tgz", "integrity": "sha512-ROY9fvHhwOD9ySfrF0wmvu//bKCQ6AeZZq1nJNtbDC+kk5DuSuNX/n6YWYF/SYy7bSba4D4FSz8DJeKY/S/r+g==", "license": "MIT", - "peer": true, "dependencies": { "@types/use-sync-external-store": "^0.0.6", "use-sync-external-store": "^1.4.0" @@ -5946,8 +5938,7 @@ "version": "5.0.1", "resolved": "https://registry.npmjs.org/redux/-/redux-5.0.1.tgz", "integrity": "sha512-M9/ELqF6fy8FwmkpnF0S3YKOqMyoWJ4+CS5Efg2ct3oY9daQvd/Pc71FpGZsVsbl3Cpb+IIcjBDUnnyBdQbq4w==", - "license": "MIT", - "peer": true + "license": "MIT" }, "node_modules/redux-thunk": { "version": "3.1.0", @@ -6636,7 +6627,6 @@ "integrity": "sha512-+Oxm7q9hDoLMyJOYfUYBuHQo+dkAloi33apOPP56pzj+vsdJDzr+j1NISE5pyaAuKL4A3UD34qd0lx5+kfKp2g==", "dev": true, "license": "MIT", - "peer": true, "dependencies": { "esbuild": "^0.25.0", "fdir": "^6.4.4", diff --git a/pages/Focus.tsx b/pages/Focus.tsx index 9fc28ab..f335cf9 100644 --- a/pages/Focus.tsx +++ b/pages/Focus.tsx @@ -1,335 +1,472 @@ -import React, { useState, useEffect, useRef, useMemo } from 'react'; -import { RoutineTask } from '../types'; -import { - Play, Pause, Square, Coffee, BrainCircuit, ChevronLeft, Volume2, VolumeX, - Settings2, Sparkles, Zap, Wind, Timer, StopCircle, RotateCcw, BarChart3, ShieldAlert -} from 'lucide-react'; +import React, { useState, useEffect, useMemo } from "react"; +import { RoutineTask } from "../types"; +import { Play, Pause, Square, ChevronLeft, BarChart3 } from "lucide-react"; interface FocusProps { initialTask?: RoutineTask; onExit: (minutesSpent: number) => void; } -type FocusPhase = 'warmup' | 'flow' | 'fatigue' | 'completed' | 'break' | 'stopwatch'; -type FocusMode = 'countdown' | 'stopwatch'; -type SoundType = 'brown-noise' | 'heavy-rain' | 'forest' | 'none'; +type SoundType = "brown-noise" | "heavy-rain" | "forest" | "none"; const PRESETS = [ - { label: 'Pomodoro', focus: 25 }, - { label: 'Deep Work', focus: 50 }, - { label: 'Extended', focus: 90 }, + { label: "Pomodoro", minutes: 25 }, + { label: "Extended Focus", minutes: 50 }, + { label: "Deep Work", minutes: 90 }, ]; - const SOUND_LIBRARY: Record = { - 'brown-noise': null, // Generated via Web Audio - 'heavy-rain': 'https://www.soundjay.com/nature/rain-01.mp3', - 'forest': 'https://www.soundjay.com/nature/forest-01.mp3', - 'none': null + "brown-noise": null, + "heavy-rain": "/sounds/rain.mp3", + forest: "/sounds/forest.mp3", + none: null, }; -// --- Enhanced Audio Engine --- -class AmbientSoundEngine { +/* ---------------- SOUND ENGINE ---------------- */ + +class SoundEngine { + private audio: HTMLAudioElement | null = null; private ctx: AudioContext | null = null; - private gainNode: GainNode | null = null; - // Use AudioNode as the base type to support both buffers and media elements - private source: AudioNode | null = null; - private audioTag: HTMLAudioElement | null = null; - private isPlaying = false; - - init() { - if (!this.ctx) { - this.ctx = new (window.AudioContext || (window as any).webkitAudioContext)(); - this.gainNode = this.ctx.createGain(); - this.gainNode.connect(this.ctx.destination); - this.gainNode.gain.value = 0; - } - } + private brownSource: AudioBufferSourceNode | null = null; play(type: SoundType) { this.stop(); - this.init(); - if (!this.ctx || !this.gainNode) return; - - if (type === 'brown-noise') { - const bufferSize = this.ctx.sampleRate * 2; - const buffer = this.ctx.createBuffer(1, bufferSize, this.ctx.sampleRate); - const data = buffer.getChannelData(0); - let lastOut = 0; - for (let i = 0; i < bufferSize; i++) { - const white = Math.random() * 2 - 1; - lastOut = (lastOut + (0.02 * white)) / 1.02; - data[i] = lastOut * 3.5; - } - const noise = this.ctx.createBufferSource(); - noise.buffer = buffer; - noise.loop = true; - const filter = this.ctx.createBiquadFilter(); - filter.type = 'lowpass'; - filter.frequency.value = 400; - noise.connect(filter); - filter.connect(this.gainNode); - noise.start(); - this.source = noise; - } else if (SOUND_LIBRARY[type]) { - this.audioTag = new Audio(SOUND_LIBRARY[type]!); - this.audioTag.loop = true; - this.source = this.ctx.createMediaElementSource(this.audioTag); - this.source.connect(this.gainNode); - this.audioTag.play(); + + if (type === "brown-noise") { + this.playBrownNoise(); + return; } - this.isPlaying = true; - this.fadeIn(); + if (SOUND_LIBRARY[type]) { + this.audio = new Audio(SOUND_LIBRARY[type]!); + this.audio.loop = true; + this.audio.volume = 0.4; + this.audio.play().catch(() => {}); + } } - stop() { - if (this.isPlaying) { - this.fadeOut(() => { - if (this.audioTag) { this.audioTag.pause(); this.audioTag = null; } - if (this.source instanceof AudioBufferSourceNode) this.source.stop(); - this.isPlaying = false; - }); + private playBrownNoise() { + this.ctx = new (window.AudioContext || + (window as any).webkitAudioContext)(); + + const bufferSize = 2 * this.ctx.sampleRate; + const noiseBuffer = this.ctx.createBuffer( + 1, + bufferSize, + this.ctx.sampleRate + ); + const output = noiseBuffer.getChannelData(0); + + let lastOut = 0; + for (let i = 0; i < bufferSize; i++) { + const white = Math.random() * 2 - 1; + lastOut = (lastOut + 0.02 * white) / 1.02; + output[i] = lastOut * 3.5; } - } - private fadeIn() { - if (!this.gainNode || !this.ctx) return; - this.gainNode.gain.linearRampToValueAtTime(0.1, this.ctx.currentTime + 2); + const source = this.ctx.createBufferSource(); + source.buffer = noiseBuffer; + source.loop = true; + + const gain = this.ctx.createGain(); + gain.gain.value = 0.15; + + source.connect(gain); + gain.connect(this.ctx.destination); + source.start(0); + + this.brownSource = source; } - private fadeOut(cb: () => void) { - if (!this.gainNode || !this.ctx) return cb(); - this.gainNode.gain.linearRampToValueAtTime(0.001, this.ctx.currentTime + 1); - setTimeout(cb, 1000); + stop() { + if (this.audio) { + this.audio.pause(); + this.audio.currentTime = 0; + this.audio = null; + } + + if (this.brownSource) { + this.brownSource.stop(); + this.brownSource.disconnect(); + this.brownSource = null; + } + + if (this.ctx) { + this.ctx.close(); + this.ctx = null; + } } } -const soundEngine = new AmbientSoundEngine(); +const soundEngine = new SoundEngine(); + +/* ---------------- COMPONENT ---------------- */ const Focus: React.FC = ({ initialTask, onExit }) => { - // --- State --- - const [mode, setMode] = useState('countdown'); - const [initialSeconds, setInitialSeconds] = useState((initialTask?.durationMinutes || 25) * 60); - const [timeLeft, setTimeLeft] = useState(initialSeconds); + const defaultMinutes = initialTask?.durationMinutes || 25; + + const [hours, setHours] = useState(0); + const [minutes, setMinutes] = useState(defaultMinutes); + const [seconds, setSeconds] = useState(0); + + const [initialSeconds, setInitialSeconds] = useState(defaultMinutes * 60); + const [timeLeft, setTimeLeft] = useState(defaultMinutes * 60); + const [isActive, setIsActive] = useState(false); const [secondsSpent, setSecondsSpent] = useState(0); const [pauseCount, setPauseCount] = useState(0); + const [distractionCount, setDistractionCount] = useState(0); const [showSummary, setShowSummary] = useState(false); + const [soundType, setSoundType] = useState("none"); - // Customization & Features - const [soundType, setSoundType] = useState('none'); - const [blockedUrls, setBlockedUrls] = useState(['facebook.com', 'twitter.com', 'youtube.com']); - const [distractionCount, setDistractionCount] = useState(0); + /* -------- Sync Manual Time -------- */ + + useEffect(() => { + const total = hours * 3600 + minutes * 60 + seconds; + setInitialSeconds(total); + if (!isActive) setTimeLeft(total); + }, [hours, minutes, seconds]); - // --- Logic --- - const progressPercent = mode === 'countdown' ? ((initialSeconds - timeLeft) / initialSeconds) * 100 : 0; + /* -------- Timer -------- */ useEffect(() => { - let interval: any = null; + let interval: any; + if (isActive) { interval = setInterval(() => { - if (mode === 'countdown') { - if (timeLeft > 0) { - setTimeLeft(t => t - 1); - setSecondsSpent(s => s + 1); - } else { - setIsActive(false); - setShowSummary(true); - } + if (timeLeft > 0) { + setTimeLeft((t) => t - 1); + setSecondsSpent((s) => s + 1); } else { - setTimeLeft(t => t + 1); - setSecondsSpent(s => s + 1); + setIsActive(false); + setShowSummary(true); } }, 1000); } + return () => clearInterval(interval); - }, [isActive, timeLeft, mode]); + }, [isActive, timeLeft]); + + /* -------- Sound -------- */ useEffect(() => { - if (isActive && soundType !== 'none') soundEngine.play(soundType); + if (isActive) soundEngine.play(soundType); else soundEngine.stop(); }, [isActive, soundType]); - // Distraction Blocking (Tab Visibility API) + /* -------- Distraction Tracking -------- */ + useEffect(() => { const handleVisibility = () => { if (document.hidden && isActive) { - setDistractionCount(prev => prev + 1); + setDistractionCount((d) => d + 1); } }; - document.addEventListener('visibilitychange', handleVisibility); - return () => document.removeEventListener('visibilitychange', handleVisibility); + + document.addEventListener("visibilitychange", handleVisibility); + return () => + document.removeEventListener("visibilitychange", handleVisibility); }, [isActive]); - const currentPhase: FocusPhase = useMemo(() => { - if (mode === 'stopwatch') return 'stopwatch'; - if (timeLeft === 0) return 'completed'; - if (progressPercent < 15) return 'warmup'; - if (progressPercent > 85) return 'fatigue'; - return 'flow'; - }, [timeLeft, progressPercent, mode]); + /* -------- Progress -------- */ + + const progressPercent = + initialSeconds > 0 + ? ((initialSeconds - timeLeft) / initialSeconds) * 100 + : 0; + + const completionPercent = + initialSeconds > 0 + ? (secondsSpent / initialSeconds) * 100 + : 0; + + const ambientColor = useMemo(() => { + if (!isActive) return "rgba(99,102,241,0.15)"; + if (progressPercent < 50) return "rgba(59,130,246,0.18)"; + if (progressPercent < 90) return "rgba(99,102,241,0.20)"; + return "rgba(251,146,60,0.22)"; + }, [progressPercent, isActive]); + + const sessionInsight = useMemo(() => { + if (completionPercent >= 100 && distractionCount === 0) + return "Outstanding discipline. You maintained deep focus throughout."; + if (completionPercent >= 75) + return "Strong effort. Your focus consistency is improving."; + if (completionPercent >= 40) + return "Solid progress. Try reducing pauses next time."; + if (completionPercent > 0) + return "Session ended early. Consider smaller goals for momentum."; + return "Session not completed."; + }, [completionPercent, distractionCount]); const formatTime = (s: number) => { - const mins = Math.floor(s / 60); - const secs = s % 60; - return `${mins}:${secs < 10 ? '0' : ''}${secs}`; - }; + const h = Math.floor(s / 3600); + const m = Math.floor((s % 3600) / 60); + const sec = s % 60; - const getPhaseStyles = () => { - switch (currentPhase) { - case 'warmup': return { color: '#60a5fa', text: 'Entering Flow' }; - case 'flow': return { color: '#5865F2', text: 'Deep Flow' }; - case 'fatigue': return { color: '#fb923c', text: 'Fatigue Zone' }; - case 'stopwatch': return { color: '#a855f7', text: 'Open Focus' }; - default: return { color: '#10b981', text: 'Completed' }; - } + return `${h.toString().padStart(2, "0")}:${m + .toString() + .padStart(2, "0")}:${sec.toString().padStart(2, "0")}`; }; - const style = getPhaseStyles(); + const handleEndEarly = () => { + if (secondsSpent === 0) return; - return ( -
+ soundEngine.stop(); + setIsActive(false); + + // Add to routine + onExit(Math.floor(secondsSpent / 60)); - {/* --- Top Bar --- */} -
- -
- - +
+ Focus Mode
-
- {/* --- Main Timer Circle --- */} -
-
- -
-
-
- {style.text} -
+
+
-

+ {/* Timer Core */} +
+ +
+ + + + + + +

{formatTime(timeLeft)}

+
-
- {mode === 'stopwatch' && ( - - )} +
+ - + +
- + {isActive && ( +
+ Stay present. Stay intentional.
-
+ )}
- {/* --- Part 1: Custom Duration & Part 2: Sound Controls --- */} + {/* Config Panel */} {!isActive && !showSummary && ( -
-
- Adjust Duration -
- { - const val = parseInt(e.target.value) || 0; - setInitialSeconds(val * 60); - if (mode === 'countdown') setTimeLeft(val * 60); - }} - className="w-20 bg-white/5 border border-white/10 rounded-lg px-3 py-2 text-sm focus:outline-none focus:border-white/30" - /> - {PRESETS.map(p => ( - - ))} +
+
+ +
+

+ Session Duration +

+ +
+ {PRESETS.map((p) => ( + + ))} +
+ +
+ + + +
-
-
- Focus Sounds -
- {(['none', 'brown-noise', 'heavy-rain', 'forest'] as SoundType[]).map(s => ( - - ))} +
+

+ Focus Sounds +

+ +
+ {(["none","brown-noise","heavy-rain","forest"] as SoundType[]).map((s) => ( + + ))} +
+
)} - {/* --- Part 1: Session Summary Report --- */} + {/* Summary */} {showSummary && ( -
-
+
+
-

Session Report

+

Session Report

-
-
- Focused Time - {formatTime(secondsSpent)} -
-
-
- Pauses - {pauseCount} -
-
- Distractions - {distractionCount} -
-
+
+ + + + +
+ +
+ {sessionInsight}
+
)} - - {/* --- Part 3: Distraction Block Indicator --- */} -
- 0 ? 'text-orange-500' : ''} /> - Guard Active: {blockedUrls.length} Sites Restricted -
); }; +const TimeInput = ({ label, value, setValue, max }) => ( +
+ { + let num = parseInt(e.target.value) || 0; + if (num > max) num = max; + if (num < 0) num = 0; + setValue(num); + }} + className="w-20 h-14 bg-[#15171c] border border-white/10 rounded-xl text-center text-lg font-mono focus:outline-none focus:border-indigo-500" + /> + + {label} + +
+); + +const StatCard = ({ label, value }) => ( +
+
+ {label} +
+
{value}
+
+); + export default Focus; \ No newline at end of file diff --git a/public/sounds/forest.mp3 b/public/sounds/forest.mp3 new file mode 100644 index 0000000..ea4bea6 Binary files /dev/null and b/public/sounds/forest.mp3 differ diff --git a/public/sounds/rain.mp3 b/public/sounds/rain.mp3 new file mode 100644 index 0000000..360f11b Binary files /dev/null and b/public/sounds/rain.mp3 differ diff --git a/types.ts b/types.ts index 6163765..745a16d 100644 --- a/types.ts +++ b/types.ts @@ -18,7 +18,8 @@ export type ViewState = | "studentClassrooms" | "studentClassroomView" | "workflow" - | "examTracker"; + | "examTracker" + | "folders"; export type CanvasLayoutMode = 'topbar' | 'sidebar-left' | 'sidebar-right' | 'minimal';