diff --git a/package-lock.json b/package-lock.json index c3227a1..0cd4877 100644 --- a/package-lock.json +++ b/package-lock.json @@ -13,6 +13,7 @@ "@hookform/resolvers": "^3.9.0", "@supabase/supabase-js": "^2.98.0", "@types/react-big-calendar": "^1.8.9", + "lucide-react": "^0.575.0", "moment": "^2.30.1", "next": "^14.2.35", "next-cloudinary": "^6.13.0", @@ -33,6 +34,7 @@ "dotenv": "^17.3.1", "eslint": "^8", "eslint-config-next": "^14.2.35", + "phoenix": "^1.8.4", "postcss": "^8", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", @@ -7569,6 +7571,15 @@ "dev": true, "license": "ISC" }, + "node_modules/lucide-react": { + "version": "0.575.0", + "resolved": "https://registry.npmjs.org/lucide-react/-/lucide-react-0.575.0.tgz", + "integrity": "sha512-VuXgKZrk0uiDlWjGGXmKV6MSk9Yy4l10qgVvzGn2AWBx1Ylt0iBexKOAoA6I7JO3m+M9oeovJd3yYENfkUbOeg==", + "license": "ISC", + "peerDependencies": { + "react": "^16.5.1 || ^17.0.0 || ^18.0.0 || ^19.0.0" + } + }, "node_modules/luxon": { "version": "3.7.2", "resolved": "https://registry.npmjs.org/luxon/-/luxon-3.7.2.tgz", @@ -8254,6 +8265,13 @@ "url": "https://github.com/sponsors/isaacs" } }, + "node_modules/phoenix": { + "version": "1.8.4", + "resolved": "https://registry.npmjs.org/phoenix/-/phoenix-1.8.4.tgz", + "integrity": "sha512-5wdO9mqU4l0AmcexN8mA0m1BsC4ROv2l55MN31gbxmJPzghc4SxVxnNTh9qaummYa2pbwkoBvG8FezO7cjdr8A==", + "dev": true, + "license": "MIT" + }, "node_modules/picocolors": { "version": "1.1.1", "resolved": "https://registry.npmjs.org/picocolors/-/picocolors-1.1.1.tgz", diff --git a/package.json b/package.json index bb8083b..6d93cf1 100644 --- a/package.json +++ b/package.json @@ -16,6 +16,7 @@ "@hookform/resolvers": "^3.9.0", "@supabase/supabase-js": "^2.98.0", "@types/react-big-calendar": "^1.8.9", + "lucide-react": "^0.575.0", "moment": "^2.30.1", "next": "^14.2.35", "next-cloudinary": "^6.13.0", @@ -36,6 +37,7 @@ "dotenv": "^17.3.1", "eslint": "^8", "eslint-config-next": "^14.2.35", + "phoenix": "^1.8.4", "postcss": "^8", "tailwindcss": "^3.4.1", "ts-node": "^10.9.2", @@ -47,4 +49,4 @@ "@clerk/shared": "2.9.2", "@clerk/clerk-react": "5.12.0" } -} \ No newline at end of file +} diff --git a/src/app/(dashboard)/layout.tsx b/src/app/(dashboard)/layout.tsx index e2ee6e2..ac8758b 100644 --- a/src/app/(dashboard)/layout.tsx +++ b/src/app/(dashboard)/layout.tsx @@ -1,31 +1,28 @@ -import Menu from "@/components/Menu"; -import Navbar from "@/components/Navbar"; -import Image from "next/image"; -import Link from "next/link"; +import DashboardShell from "@/components/DashboardShell"; +import { currentUser } from "@clerk/nextjs/server"; +import { redirect } from "next/navigation"; -export default function DashboardLayout({ +export default async function DashboardLayout({ children, }: Readonly<{ children: React.ReactNode; }>) { + const user = await currentUser(); + + if (!user) { + redirect("/sign-in"); + } + + const role = user.publicMetadata.role as string; + const userMetadata = { + firstName: user.firstName, + lastName: user.lastName, + role: role + }; + return ( -
- {/* LEFT */} -
- - logo - SchooLama - - -
- {/* RIGHT */} -
- - {children} -
-
+ + {children} + ); } diff --git a/src/app/(dashboard)/lesson/page.tsx b/src/app/(dashboard)/lesson/page.tsx new file mode 100644 index 0000000..1b5380c --- /dev/null +++ b/src/app/(dashboard)/lesson/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import WhiteboardCore from "@/components/Whiteboard/WhiteboardCore"; + +const DashboardWhiteboardPage = () => { + const searchParams = useSearchParams(); + const lessonIdParam = searchParams.get("lessonId"); + const lessonId = lessonIdParam ? parseInt(lessonIdParam) : null; + + return ; +}; + +export default DashboardWhiteboardPage; diff --git a/src/app/(fullscreen)/board/page.tsx b/src/app/(fullscreen)/board/page.tsx new file mode 100644 index 0000000..ccf02ad --- /dev/null +++ b/src/app/(fullscreen)/board/page.tsx @@ -0,0 +1,14 @@ +"use client"; + +import { useSearchParams } from "next/navigation"; +import WhiteboardCore from "@/components/Whiteboard/WhiteboardCore"; + +const FullscreenWhiteboardPage = () => { + const searchParams = useSearchParams(); + const lessonIdParam = searchParams.get("lessonId"); + const lessonId = lessonIdParam ? parseInt(lessonIdParam) : null; + + return ; +}; + +export default FullscreenWhiteboardPage; diff --git a/src/app/(fullscreen)/layout.tsx b/src/app/(fullscreen)/layout.tsx new file mode 100644 index 0000000..65d9704 --- /dev/null +++ b/src/app/(fullscreen)/layout.tsx @@ -0,0 +1,11 @@ +export default function FullscreenLayout({ + children, +}: Readonly<{ + children: React.ReactNode; +}>) { + return ( +
+ {children} +
+ ); +} diff --git a/src/app/(whiteboard)/lesson/page.tsx b/src/app/(whiteboard)/lesson/page.tsx deleted file mode 100644 index 4d7de76..0000000 --- a/src/app/(whiteboard)/lesson/page.tsx +++ /dev/null @@ -1,218 +0,0 @@ -"use client"; - -import { useEffect, useState, useRef } from "react"; -import { useSearchParams } from "next/navigation"; -import { Tldraw, createTLStore, TLStore, Editor, getSnapshot, loadSnapshot } from "tldraw"; -import "tldraw/tldraw.css"; -import { getLessonWhiteboard, saveLessonSnapshot, SnapshotType } from "@/lib/whiteboardActions"; -import { toast } from "react-toastify"; -import { useUser } from "@clerk/nextjs"; - -const WhiteboardPage = () => { - const searchParams = useSearchParams(); - const lessonIdParam = searchParams.get("lessonId"); - const lessonId = lessonIdParam ? parseInt(lessonIdParam) : null; - - const { user } = useUser(); - const role = user?.publicMetadata?.role as string | undefined; - const isTeacherOrAdmin = role === "teacher" || role === "admin"; - - const [store, setStore] = useState(null); - const [loading, setLoading] = useState(true); - const [activeState, setActiveState] = useState("planned"); - const [whiteboardRecord, setWhiteboardRecord] = useState(null); - const editorRef = useRef(null); - - useEffect(() => { - const fetchWhiteboard = async () => { - if (!lessonId) { - setLoading(false); - return; - } - - const { success, data } = await getLessonWhiteboard(lessonId); - if (success && data) { - setWhiteboardRecord(data); - - // Initialize store based on role - const newStore = createTLStore(); - - let defaultSnapshot = data.plannedSnapshotData; - let defaultState: SnapshotType = "planned"; - - // For students, default to live, or final if live doesn't exist. If neither, show empty. - if (!isTeacherOrAdmin) { - if (data.liveSnapshotData) { - defaultSnapshot = data.liveSnapshotData; - defaultState = "live"; - } else if (data.finalSnapshotData) { - defaultSnapshot = data.finalSnapshotData; - defaultState = "final"; - } else { - defaultSnapshot = null; - defaultState = "live"; // Wait for live - } - } - - if (defaultSnapshot) { - try { - loadSnapshot(newStore, defaultSnapshot as any); - } catch (e) { - console.error("Failed to load snapshot", e); - } - } - setStore(newStore); - setActiveState(defaultState); - } - setLoading(false); - }; - - if (role !== undefined) { - fetchWhiteboard(); - } - }, [lessonId, role]); - - const handleSave = async (type: SnapshotType) => { - if (!lessonId || !editorRef.current || !isTeacherOrAdmin) return; - - const snapshot = getSnapshot(editorRef.current.store); - const { success } = await saveLessonSnapshot(lessonId, type, snapshot); - - if (success) { - toast.success(`Successfully saved ${type} snapshot!`); - setActiveState(type); - } else { - toast.error("Failed to save snapshot."); - } - }; - - const handleLoadData = (type: SnapshotType) => { - if (!whiteboardRecord || !editorRef.current) return; - - let snapshotData = null; - if (type === "planned") snapshotData = whiteboardRecord.plannedSnapshotData; - if (type === "live") snapshotData = whiteboardRecord.liveSnapshotData; - if (type === "final") snapshotData = whiteboardRecord.finalSnapshotData; - - if (snapshotData) { - try { - loadSnapshot(editorRef.current.store, snapshotData as any); - toast.success(`Loaded ${type} snapshot.`); - setActiveState(type); - } catch (e) { - console.error("Failed to load snapshot", e); - toast.error("Snapshot data is invalid or corrupted."); - } - } else { - toast.info(`No data found for ${type} snapshot.`); - } - }; - - return ( -
-
- {/* Header Banner */} -
-
-

- Lesson Whiteboard {lessonId ? `#${lessonId}` : "(No Lesson Selected)"} -

-

- Current mode: {activeState.toUpperCase()} -

-
- {!isTeacherOrAdmin && ( -
- View Only -
- )} -
- -
- {loading || role === undefined ? ( -
- Loading whiteboard... -
- ) : !lessonId ? ( -
- Please provide a ?lessonId URL parameter. -
- ) : store ? ( -
- { - editorRef.current = editor; - if (!isTeacherOrAdmin) { - editor.updateInstanceState({ isReadonly: true }); - } - }} - /> -
- ) : ( -
- Failed to initialize whiteboard. -
- )} -
-
- -
- {isTeacherOrAdmin && ( -
-

Save Actions

-

Save your current canvas state to the database.

- - - - -
- )} - -
-

Load Actions

-

Switch between different saved states for this lesson.

- - {isTeacherOrAdmin && ( - - )} - - -
-
-
- ); -}; - -export default WhiteboardPage; diff --git a/src/components/BigCalender.tsx b/src/components/BigCalender.tsx index 791bb99..96c58ca 100644 --- a/src/components/BigCalender.tsx +++ b/src/components/BigCalender.tsx @@ -32,7 +32,6 @@ const BigCalendar = ({ {props.event.id && ( Board diff --git a/src/components/DashboardShell.tsx b/src/components/DashboardShell.tsx new file mode 100644 index 0000000..33bb5bc --- /dev/null +++ b/src/components/DashboardShell.tsx @@ -0,0 +1,70 @@ +"use client"; + +import { useState } from "react"; +import Menu from "@/components/Menu"; +import Navbar from "@/components/Navbar"; +import Image from "next/image"; +import Link from "next/link"; +import { usePathname } from "next/navigation"; + +export default function DashboardShell({ + children, + role, + userMetadata +}: { + children: React.ReactNode; + role: string; + userMetadata: any; +}) { + const [isCollapsed, setIsCollapsed] = useState(false); + + const toggleSidebar = () => { + setIsCollapsed((prev) => !prev); + }; + + return ( +
+ {/* LEFT SIDEBAR */} +
+ {/* Logo Area (Hidden when collapsed, moves to Navbar) */} +
+ + logo + SchooLama + +
+ + {/* Collapsed Logo (Shows only when collapsed) */} +
+ + logo + +
+ + +
+ + {/* RIGHT MAIN CONTENT */} +
+ +
+ {children} +
+
+
+ ); +} diff --git a/src/components/Menu.tsx b/src/components/Menu.tsx index b7c2fba..406d295 100644 --- a/src/components/Menu.tsx +++ b/src/components/Menu.tsx @@ -1,4 +1,3 @@ -import { currentUser } from "@clerk/nextjs/server"; import Image from "next/image"; import Link from "next/link"; @@ -117,26 +116,43 @@ const menuItems = [ }, ]; -const Menu = async () => { - const user = await currentUser(); - const role = user?.publicMetadata.role as string; +const Menu = ({ role, isCollapsed }: { role: string; isCollapsed: boolean }) => { return ( -
+
{menuItems.map((i) => ( -
- +
+ {i.title} +
{i.items.map((item) => { if (item.visible.includes(role)) { return ( - - {item.label} + + + {item.label} + + + {/* Tooltip on hover when collapsed */} + {isCollapsed && ( +
+ {item.label} +
+ )} ); } diff --git a/src/components/Navbar.tsx b/src/components/Navbar.tsx index 0a7b058..cd5260e 100644 --- a/src/components/Navbar.tsx +++ b/src/components/Navbar.tsx @@ -1,38 +1,73 @@ import { UserButton } from "@clerk/nextjs"; -import { currentUser } from "@clerk/nextjs/server"; import Image from "next/image"; +import Link from "next/link"; +import { Menu as MenuIcon } from "lucide-react"; -const Navbar = async () => { - const user = await currentUser(); +const Navbar = ({ + role, + userMetadata, + toggleSidebar, + isCollapsed +}: { + role: string; + userMetadata: any; + toggleSidebar: () => void; + isCollapsed: boolean; +}) => { return ( -
- {/* SEARCH BAR */} -
- - +
+ {/* LEFT AREA: Toggle and Animated Logo */} +
+ {/* Hamburger Toggle */} + + + {/* Logo (Appears here when sidebar is collapsed) */} +
+ + logo + SchooLama + +
- {/* ICONS AND USER */} -
-
+ + {/* RIGHT: ICONS AND USER */} +
+ {/* SEARCH BAR */} +
+ + +
+ +
-
+
1
- John Doe + + {userMetadata?.firstName} {userMetadata?.lastName} + - {user?.publicMetadata?.role as string} + {role}
- {/* */}
diff --git a/src/components/Whiteboard/WhiteboardCore.tsx b/src/components/Whiteboard/WhiteboardCore.tsx new file mode 100644 index 0000000..e1b0dd2 --- /dev/null +++ b/src/components/Whiteboard/WhiteboardCore.tsx @@ -0,0 +1,257 @@ +"use client"; + +import { useEffect, useState, useRef, createContext, useContext } from "react"; +import { + Tldraw, + createTLStore, + TLStore, + Editor, + getSnapshot, + loadSnapshot, + useDialogs, + TldrawUiButton, + TldrawUiButtonLabel, + TldrawUiButtonIcon, + TldrawUiDialogHeader, + TldrawUiDialogTitle, + TldrawUiDialogCloseButton, + TldrawUiDialogBody, + TldrawUiIcon +} from "tldraw"; +import "tldraw/tldraw.css"; +import { getLessonWhiteboard, saveLessonSnapshot, SnapshotType } from "@/lib/whiteboardActions"; +import { toast } from "react-toastify"; +import { useUser } from "@clerk/nextjs"; + +type WhiteboardContextType = { + activeState: SnapshotType; + isTeacherOrAdmin: boolean; + handleSave: (type: SnapshotType) => void; + handleLoadData: (type: SnapshotType) => void; + lessonId: number | null; + whiteboardRecord: any; + isFullscreen: boolean; +}; + +const WhiteboardContext = createContext(null); + +const useWhiteboardContext = () => { + const ctx = useContext(WhiteboardContext); + if (!ctx) throw new Error("Missing WhiteboardContext.Provider"); + return ctx; +}; + +function NativeFileManagerDialog({ onClose }: { onClose: () => void }) { + const { activeState, isTeacherOrAdmin, handleSave, handleLoadData, whiteboardRecord } = useWhiteboardContext(); + + const renderFileRow = (type: SnapshotType, title: string) => { + const hasData = whiteboardRecord && whiteboardRecord[`${type}SnapshotData`]; + const isActive = activeState === type; + + return ( +
+
+ + {title} + {isActive && ACTIVE} + + + {hasData ? "Snapshot available" : "No snapshot saved"} + +
+
+ {isTeacherOrAdmin && ( + { handleSave(type); }}> + Save Here + + )} + { handleLoadData(type); onClose(); }}> + Load + +
+
+ ); + }; + + return ( + <> + + File Manager + + + +
+ Manage your lesson whiteboard snapshots below. + {!isTeacherOrAdmin && " As a student, you can only load available snapshots."} +
+ {renderFileRow("planned", "Planned")} + {renderFileRow("live", "Live")} + {renderFileRow("final", "Final")} +
+ + ) +} + +const CustomSharePanel = () => { + const { addDialog } = useDialogs(); + const { isFullscreen, lessonId } = useWhiteboardContext(); + + return ( +
+ {!isFullscreen && lessonId && ( + { + window.open(`/board?lessonId=${lessonId}`, '_blank'); + }} + > + + + )} + addDialog({ component: NativeFileManagerDialog })} + > + + +
+ ); +}; + +const WhiteboardCore = ({ lessonId, isFullscreen = false }: { lessonId: number | null, isFullscreen?: boolean }) => { + const { user } = useUser(); + const role = user?.publicMetadata?.role as string | undefined; + const isTeacherOrAdmin = role === "teacher" || role === "admin"; + + const [store, setStore] = useState(null); + const [loading, setLoading] = useState(true); + const [activeState, setActiveState] = useState("planned"); + const [whiteboardRecord, setWhiteboardRecord] = useState(null); + const editorRef = useRef(null); + + useEffect(() => { + const fetchWhiteboard = async () => { + if (!lessonId) { + setLoading(false); + return; + } + + const { success, data } = await getLessonWhiteboard(lessonId); + if (success && data) { + setWhiteboardRecord(data); + + // Initialize store based on role + const newStore = createTLStore(); + + let defaultSnapshot = data.plannedSnapshotData; + let defaultState: SnapshotType = "planned"; + + // For students, default to live, or final if live doesn't exist. If neither, show empty. + if (!isTeacherOrAdmin) { + if (data.liveSnapshotData) { + defaultSnapshot = data.liveSnapshotData; + defaultState = "live"; + } else if (data.finalSnapshotData) { + defaultSnapshot = data.finalSnapshotData; + defaultState = "final"; + } else { + defaultSnapshot = null; + defaultState = "live"; // Wait for live + } + } + + if (defaultSnapshot) { + try { + loadSnapshot(newStore, defaultSnapshot as any); + } catch (e) { + console.error("Failed to load snapshot", e); + } + } + setStore(newStore); + setActiveState(defaultState); + } + setLoading(false); + }; + + if (role !== undefined) { + fetchWhiteboard(); + } + }, [lessonId, role]); + + const handleSave = async (type: SnapshotType) => { + if (!lessonId || !editorRef.current || !isTeacherOrAdmin) return; + + const snapshot = getSnapshot(editorRef.current.store); + const { success } = await saveLessonSnapshot(lessonId, type, snapshot); + + if (success) { + toast.success(`Successfully saved ${type} snapshot!`); + setActiveState(type); + setWhiteboardRecord((prev: any) => ({ + ...prev, + [`${type}SnapshotData`]: snapshot + })); + } else { + toast.error("Failed to save snapshot."); + } + }; + + const handleLoadData = (type: SnapshotType) => { + if (!whiteboardRecord || !editorRef.current) return; + + let snapshotData = null; + if (type === "planned") snapshotData = whiteboardRecord.plannedSnapshotData; + if (type === "live") snapshotData = whiteboardRecord.liveSnapshotData; + if (type === "final") snapshotData = whiteboardRecord.finalSnapshotData; + + if (snapshotData) { + try { + loadSnapshot(editorRef.current.store, snapshotData as any); + toast.success(`Loaded ${type} snapshot.`); + setActiveState(type); + } catch (e) { + console.error("Failed to load snapshot", e); + toast.error("Snapshot data is invalid or corrupted."); + } + } else { + toast.info(`No data found for ${type} snapshot.`); + } + }; + + return ( +
+ {loading || role === undefined ? ( +
+ Loading whiteboard... +
+ ) : !lessonId ? ( +
+ Please provide a ?lessonId URL parameter. +
+ ) : store ? ( + +
+ { + editorRef.current = editor; + if (!isTeacherOrAdmin) { + editor.updateInstanceState({ isReadonly: true }); + } + }} + /> +
+
+ ) : ( +
+ Failed to initialize whiteboard. +
+ )} +
+ ); +}; + +export default WhiteboardCore;