diff --git a/src/components/FocusTimer.tsx b/src/components/FocusTimer.tsx index 919740e..4802ff6 100644 --- a/src/components/FocusTimer.tsx +++ b/src/components/FocusTimer.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { useAuth } from '@/contexts/useAuth'; import { supabase } from '@/integrations/supabase/client'; import { Play, Square, Coffee, Clock } from 'lucide-react'; @@ -41,22 +41,21 @@ export default function FocusTimer() { fetchProfileState(); }, [user]); - // Timer logic - useEffect(() => { - let interval: NodeJS.Timeout; + const updateProfile = useCallback(async (inFocus: boolean, focusTime?: number) => { + if (!user) return; - if (isActive && timeLeft > 0) { - interval = setInterval(() => { - setTimeLeft((prev) => prev - 1); - }, 1000); - } else if (isActive && timeLeft === 0) { - handleTimerComplete(); + const updates: any = { is_in_focus_mode: inFocus }; + if (focusTime !== undefined) { + updates.focus_time_this_week = focusTime; } - return () => clearInterval(interval); - }, [isActive, timeLeft]); + await supabase + .from('profiles') + .update(updates) + .eq('id', user.id); + }, [user]); - const handleTimerComplete = async () => { + const handleTimerComplete = useCallback(async () => { if (!isBreak) { // Completed a work session toast({ @@ -83,21 +82,22 @@ export default function FocusTimer() { setIsActive(false); setTimeLeft(workDuration * 60); } - }; + }, [isBreak, toast, workDuration, breakDuration, user, focusTimeThisWeek, updateProfile]); - const updateProfile = async (inFocus: boolean, focusTime?: number) => { - if (!user) return; + // Timer logic + useEffect(() => { + let interval: NodeJS.Timeout; - const updates: any = { is_in_focus_mode: inFocus }; - if (focusTime !== undefined) { - updates.focus_time_this_week = focusTime; + if (isActive && timeLeft > 0) { + interval = setInterval(() => { + setTimeLeft((prev) => prev - 1); + }, 1000); + } else if (isActive && timeLeft === 0) { + handleTimerComplete(); } - await supabase - .from('profiles') - .update(updates) - .eq('id', user.id); - }; + return () => clearInterval(interval); + }, [isActive, timeLeft, handleTimerComplete]); const toggleTimer = async () => { const newIsActive = !isActive; diff --git a/src/components/GroupPomodoro.tsx b/src/components/GroupPomodoro.tsx index 5a1d2ac..df45a41 100644 --- a/src/components/GroupPomodoro.tsx +++ b/src/components/GroupPomodoro.tsx @@ -1,4 +1,4 @@ -import { useState, useEffect } from 'react'; +import { useState, useEffect, useCallback } from 'react'; import { supabase } from '@/integrations/supabase/client'; import { Play, Square, Coffee, Clock } from 'lucide-react'; import { Button } from '@/components/ui/button'; @@ -61,32 +61,7 @@ export default function GroupPomodoro({ roomId }: GroupPomodoroProps) { }; }, [roomId]); - // Countdown logic - useEffect(() => { - let interval: NodeJS.Timeout; - - if (timerState !== 'idle' && endTime) { - interval = setInterval(() => { - const now = new Date(); - const diff = Math.max(0, Math.floor((endTime.getTime() - now.getTime()) / 1000)); - - setTimeLeft(diff); - - // Timer completed! - if (diff === 0) { - handleTimerComplete(); - } - }, 1000); - } else { - setTimeLeft(timerState === 'break' ? breakDuration * 60 : workDuration * 60); - } - - return () => { - if (interval) clearInterval(interval); - }; - }, [timerState, endTime, workDuration, breakDuration]); - - const handleTimerComplete = async () => { + const handleTimerComplete = useCallback(async () => { if (timerState === 'work') { toast({ title: "Group Focus Session Complete! 🎉", @@ -100,9 +75,9 @@ export default function GroupPomodoro({ roomId }: GroupPomodoroProps) { }); await setGroupTimer('idle', 0); } - }; + }, [timerState, toast, breakDuration, setGroupTimer]); - const setGroupTimer = async (newState: 'idle' | 'work' | 'break', durationMinutes: number) => { + const setGroupTimer = useCallback(async (newState: 'idle' | 'work' | 'break', durationMinutes: number) => { let newEndTime = null; if (newState !== 'idle') { @@ -119,7 +94,32 @@ export default function GroupPomodoro({ roomId }: GroupPomodoroProps) { timer_break_duration: breakDuration }) .eq('id', roomId); - }; + }, [roomId, workDuration, breakDuration]); + + // Countdown logic + useEffect(() => { + let interval: NodeJS.Timeout; + + if (timerState !== 'idle' && endTime) { + interval = setInterval(() => { + const now = new Date(); + const diff = Math.max(0, Math.floor((endTime.getTime() - now.getTime()) / 1000)); + + setTimeLeft(diff); + + // Timer completed! + if (diff === 0) { + handleTimerComplete(); + } + }, 1000); + } else { + setTimeLeft(timerState === 'break' ? breakDuration * 60 : workDuration * 60); + } + + return () => { + if (interval) clearInterval(interval); + }; + }, [timerState, endTime, workDuration, breakDuration, handleTimerComplete]); const formatTime = (seconds: number) => { const m = Math.floor(seconds / 60); diff --git a/src/components/Room.tsx b/src/components/Room.tsx index 9dd1d5d..004979e 100644 --- a/src/components/Room.tsx +++ b/src/components/Room.tsx @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useState, useEffect, useRef } from 'react'; +import { useState, useEffect, useRef, useCallback } from 'react'; import { useParams, useNavigate } from 'react-router-dom'; import { supabase } from '@/integrations/supabase/client'; import { useAuth } from '@/contexts/useAuth'; @@ -30,6 +29,32 @@ export default function Room() { const [showInviteUI, setShowInviteUI] = useState(false); const messagesEndRef = useRef(null); + const fetchRoomDetails = useCallback(async () => { + const { data, error } = await supabase.from('study_rooms' as any).select('*').eq('id', id).single(); + if (error) { + console.error("Error fetching room:", error); + if (error.code === 'PGRST116') { + alert("Room not found or you don't have access."); + navigate('/rooms'); + } + } + if (data) setRoom(data); + }, [id, navigate]); + + const fetchMessages = useCallback(async () => { + const { data, error } = await supabase + .from('study_room_messages' as any) + .select('*, profiles(name, avatar_url)') + .eq('room_id', id) + .order('created_at', { ascending: true }); + + if (error) { + console.error("Database fetch error:", error.message, error.details); + } else if (data) { + setMessages(data); + } + }, [id]); + useEffect(() => { if (!id || !user) return; @@ -57,9 +82,9 @@ export default function Room() { setParticipants(onlineUsers); - setActivities([ + setActivities((prev) => [ `${onlineUsers.length} participant(s) online`, - ...activities, + ...prev, ]); }) .on('postgres_changes', { @@ -95,38 +120,12 @@ export default function Room() { if (roomChannel) supabase.removeChannel(roomChannel); }; - }, [id, user]); + }, [id, user, fetchMessages, fetchRoomDetails]); useEffect(() => { messagesEndRef.current?.scrollIntoView({ behavior: 'smooth' }); }, [messages]); - const fetchRoomDetails = async () => { - const { data, error } = await supabase.from('study_rooms' as any).select('*').eq('id', id).single(); - if (error) { - console.error("Error fetching room:", error); - if (error.code === 'PGRST116') { - alert("Room not found or you don't have access."); - navigate('/rooms'); - } - } - if (data) setRoom(data); - }; - - const fetchMessages = async () => { - const { data, error } = await supabase - .from('study_room_messages' as any) - .select('*, profiles(name, avatar_url)') - .eq('room_id', id) - .order('created_at', { ascending: true }); - - if (error) { - console.error("Database fetch error:", error.message, error.details); - } else if (data) { - setMessages(data); - } - }; - const handleSendMessage = async (e: React.FormEvent) => { e.preventDefault(); if (!newMessage.trim() || !user) return; diff --git a/src/components/Whiteboard/Canvas.tsx b/src/components/Whiteboard/Canvas.tsx index 598cecc..1c140d6 100644 --- a/src/components/Whiteboard/Canvas.tsx +++ b/src/components/Whiteboard/Canvas.tsx @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { useEffect, useRef, useState } from "react"; +import { useEffect, useRef, useState, useCallback } from "react"; import { supabase } from "@/integrations/supabase/client"; import { useAuth } from "@/contexts/useAuth"; import { @@ -105,7 +104,7 @@ export default function Canvas({ roomId }: Props) { } }; - const replayCanvas = () => { + const replayCanvas = useCallback(() => { const ctx = getContext(); if (!ctx) return; @@ -115,7 +114,7 @@ export default function Canvas({ roomId }: Props) { for (const event of strokesRef.current) { drawEvent(ctx, event); } - }; + }, []); const persistEvent = async (event: WhiteboardEvent) => { await supabase.from("whiteboard_events" as any).insert({ @@ -209,7 +208,7 @@ export default function Canvas({ roomId }: Props) { return () => { supabase.removeChannel(channel); }; - }, [roomId]); + }, [roomId, user?.id, replayCanvas]); const getCoordinates = ( e: React.MouseEvent diff --git a/src/env.ts b/src/env.ts index 575bbf6..2801c4b 100644 --- a/src/env.ts +++ b/src/env.ts @@ -9,6 +9,19 @@ const envSchema = z.object({ NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY: z.string().min(1).optional(), VITE_VAPID_PUBLIC_KEY: z.string().min(1).optional(), VITE_API_URL: z.string().url().optional(), +}).refine((data) => { + // Ensure at least one Supabase URL is provided + if (!data.VITE_SUPABASE_URL && !data.NEXT_PUBLIC_SUPABASE_URL) { + return false; // Validation fails + } + // Ensure at least one Supabase Anon Key is provided + if (!data.VITE_SUPABASE_ANON_KEY && !data.VITE_SUPABASE_PUBLISHABLE_KEY && !data.NEXT_PUBLIC_SUPABASE_ANON_KEY && !data.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) { + return false; // Validation fails + } + return true; // Validation passes +}, { + message: "Supabase URL (VITE_SUPABASE_URL or NEXT_PUBLIC_SUPABASE_URL) and Supabase Anon Key (VITE_SUPABASE_ANON_KEY, VITE_SUPABASE_PUBLISHABLE_KEY, NEXT_PUBLIC_SUPABASE_ANON_KEY, or NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY) are required.", + path: ["SUPABASE_CONFIGURATION"], // Custom path for the error message }); const _env = envSchema.safeParse(import.meta.env); @@ -20,10 +33,9 @@ if (!_env.success) { export const env = _env.data; -export const supabaseUrl = env.VITE_SUPABASE_URL || env.NEXT_PUBLIC_SUPABASE_URL || ""; -export const supabaseAnonKey = - env.VITE_SUPABASE_ANON_KEY || - env.VITE_SUPABASE_PUBLISHABLE_KEY || - env.NEXT_PUBLIC_SUPABASE_ANON_KEY || - env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY || - ""; +export const supabaseUrl: string = env.VITE_SUPABASE_URL ?? env.NEXT_PUBLIC_SUPABASE_URL!; +export const supabaseAnonKey: string = + env.VITE_SUPABASE_ANON_KEY ?? + env.VITE_SUPABASE_PUBLISHABLE_KEY ?? + env.NEXT_PUBLIC_SUPABASE_ANON_KEY ?? + env.NEXT_PUBLIC_SUPABASE_PUBLISHABLE_KEY!; diff --git a/src/pages/Dashboard.tsx b/src/pages/Dashboard.tsx index c1fa0e1..5f70d6b 100644 --- a/src/pages/Dashboard.tsx +++ b/src/pages/Dashboard.tsx @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { Suspense, lazy, useEffect, useState } from "react"; +import { Suspense, lazy, useEffect, useState, useCallback } from "react"; import { motion } from "framer-motion"; import { useNavigate } from "react-router-dom"; import RecommendationPanel from "@/components/recommendations/RecommendationPanel"; @@ -71,35 +70,8 @@ const Dashboard = () => { user?.email?.split("@")[0] || "Learner"; - // Fetch Profile - useEffect(() => { - if (!user) return; - - const fetchProfile = async () => { - const { data, error } = await supabase - .from("profiles") - .select("*") - .eq("id", user.id) - .single(); // 👈 tell TS this is a single Profile row - - if (error) { - console.error(error); - return; - } - - if (data) { - setProfile(data); // TS now knows `data` is Profile - fetchRecommendedPeers(data); // safe to pass - } - }; - - - - fetchProfile(); - }, [user]); - // Recommended Peers - const fetchRecommendedPeers = async (myProfile: Profile) => { + const fetchRecommendedPeers = useCallback(async (myProfile: Profile) => { if (!user?.id) return; try { @@ -136,7 +108,34 @@ const Dashboard = () => { } catch (err) { console.error("Failed to fetch recommended peers:", err); } - }; + }, [user?.id]); + + // Fetch Profile + useEffect(() => { + if (!user) return; + + const fetchProfile = async () => { + const { data, error } = await supabase + .from("profiles") + .select("*") + .eq("id", user.id) + .single(); // 👈 tell TS this is a single Profile row + + if (error) { + console.error(error); + return; + } + + if (data) { + setProfile(data); // TS now knows `data` is Profile + fetchRecommendedPeers(data); // safe to pass + } + }; + + + + fetchProfile(); + }, [user, fetchRecommendedPeers]); // Sessions useEffect(() => { diff --git a/src/pages/Leaderboard.tsx b/src/pages/Leaderboard.tsx index 8d234a0..109e1a3 100644 --- a/src/pages/Leaderboard.tsx +++ b/src/pages/Leaderboard.tsx @@ -1,5 +1,4 @@ -/* eslint-disable @typescript-eslint/no-explicit-any */ -import { memo, useEffect, useRef, useState } from "react"; +import { memo, useEffect, useRef, useState, useCallback } from "react"; import { motion } from "framer-motion"; import { Trophy, @@ -109,8 +108,7 @@ const Leaderboard = () => { const listParentRef = useRef(null); // FETCH LEADERBOARD - const fetchLeaderboard = async () => { - + const fetchLeaderboard = useCallback(async () => { setLoading(true); let query = supabase @@ -199,11 +197,10 @@ const Leaderboard = () => { } setLoading(false); - }; + }, [user, filter]); // AUTO CREATE USER - const ensureUserExists = async () => { - + const ensureUserExists = useCallback(async () => { if (!user) return; const { data: existingUser } = await supabase @@ -224,7 +221,7 @@ const Leaderboard = () => { user.user_metadata?.avatar_url || null, }); } - }; + }, [user]); // INIT useEffect(() => { @@ -236,8 +233,7 @@ const Leaderboard = () => { }; init(); - - }, [user, filter]); + }, [user, filter, fetchLeaderboard, ensureUserExists]); // REALTIME // We use a ref so the realtime listener always calls the latest fetchLeaderboard,