From b9ee4f8b528b6a41d82387743a4333772e1cec9f Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 10:58:26 +0530 Subject: [PATCH 01/13] Restrucuture the components folder. --- src/components/CalendarExport.tsx | 2 +- src/components/CallList.tsx | 6 +- src/components/ErrorPage.tsx | 14 +- src/components/FeatureCard.tsx | 28 - src/components/Logo.tsx | 21 - src/components/SignOutButton.tsx | 20 - src/components/TeamCall.tsx | 927 ------------------ src/components/WorkspaceInitializer.tsx | 39 - src/components/app-sidebar.tsx | 4 +- .../{ => auth}/AcceptInviteForm.tsx | 2 + src/components/auth/AuthStateManager.tsx | 112 --- .../{signup-form.tsx => auth/SignupForm.tsx} | 2 + src/components/auth/index.ts | 2 + src/components/charts/WeeklyMeetingsChart.tsx | 99 -- src/components/{ => email}/EmailTemplate.tsx | 0 src/components/email/index.ts | 1 + src/components/examples/UserDashboard.tsx | 48 - src/components/home/index.ts | 3 + src/components/icons/Home.tsx | 32 - src/components/icons/Personal.tsx | 33 - src/components/icons/Previous.tsx | 39 - src/components/icons/Reccording.tsx | 45 - src/components/icons/Upcoming.tsx | 45 - src/components/index.tsx | 5 - src/components/nav-documents.tsx | 92 -- src/components/nav-main.tsx | 116 ++- src/components/navigation/Logo.tsx | 19 +- src/components/site-header.tsx | 30 - .../{ => subscriptions}/RazorpayButton.tsx | 0 .../RazorpaySubscription.tsx | 0 .../{ => subscriptions}/SubscriptionCard.tsx | 0 src/components/subscriptions/index.ts | 3 + src/components/team-switcher.tsx | 91 -- .../admin}/charts/DailyMeetingsChart.tsx | 0 .../admin/charts/WeeklyMeetingsChart.tsx | 22 + src/components/workspace/calls/CallList.tsx | 337 +++++++ .../calls}/DirectCallButton.tsx | 2 +- .../{ => workspace/calls}/EndCallButton.tsx | 0 .../{ => workspace/calls}/HomeCard.tsx | 0 .../workspace/calls/IncomingCallBanner.tsx | 88 ++ src/components/workspace/calls/index.ts | 3 + src/components/workspace/index.ts | 5 + .../{ => workspace/meeting}/MeetingCard.tsx | 2 +- .../{ => workspace/meeting}/MeetingModal.tsx | 0 .../{ => workspace/meeting}/MeetingRoom.tsx | 4 +- .../{ => workspace/meeting}/MeetingSetup.tsx | 4 +- .../meeting}/MeetingTypeList.tsx | 8 +- src/components/workspace/meeting/index.ts | 13 + .../{ => members}/InviteMemberDialog.tsx | 0 .../{ => workspace/members}/MemberList.tsx | 8 +- src/components/workspace/members/index.ts | 1 + .../{ => workspace}/org-switcher.tsx | 2 +- 52 files changed, 585 insertions(+), 1794 deletions(-) delete mode 100644 src/components/FeatureCard.tsx delete mode 100644 src/components/Logo.tsx delete mode 100644 src/components/SignOutButton.tsx delete mode 100644 src/components/TeamCall.tsx delete mode 100644 src/components/WorkspaceInitializer.tsx rename src/components/{ => auth}/AcceptInviteForm.tsx (99%) delete mode 100644 src/components/auth/AuthStateManager.tsx rename src/components/{signup-form.tsx => auth/SignupForm.tsx} (99%) create mode 100644 src/components/auth/index.ts delete mode 100644 src/components/charts/WeeklyMeetingsChart.tsx rename src/components/{ => email}/EmailTemplate.tsx (100%) create mode 100644 src/components/email/index.ts delete mode 100644 src/components/examples/UserDashboard.tsx create mode 100644 src/components/home/index.ts delete mode 100644 src/components/icons/Home.tsx delete mode 100644 src/components/icons/Personal.tsx delete mode 100644 src/components/icons/Previous.tsx delete mode 100644 src/components/icons/Reccording.tsx delete mode 100644 src/components/icons/Upcoming.tsx delete mode 100644 src/components/index.tsx delete mode 100644 src/components/nav-documents.tsx delete mode 100644 src/components/site-header.tsx rename src/components/{ => subscriptions}/RazorpayButton.tsx (100%) rename src/components/{ => subscriptions}/RazorpaySubscription.tsx (100%) rename src/components/{ => subscriptions}/SubscriptionCard.tsx (100%) create mode 100644 src/components/subscriptions/index.ts delete mode 100644 src/components/team-switcher.tsx rename src/components/{ => workspace/admin}/charts/DailyMeetingsChart.tsx (100%) create mode 100644 src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx create mode 100644 src/components/workspace/calls/CallList.tsx rename src/components/{ => workspace/calls}/DirectCallButton.tsx (98%) rename src/components/{ => workspace/calls}/EndCallButton.tsx (100%) rename src/components/{ => workspace/calls}/HomeCard.tsx (100%) create mode 100644 src/components/workspace/calls/IncomingCallBanner.tsx create mode 100644 src/components/workspace/calls/index.ts create mode 100644 src/components/workspace/index.ts rename src/components/{ => workspace/meeting}/MeetingCard.tsx (97%) rename src/components/{ => workspace/meeting}/MeetingModal.tsx (100%) rename src/components/{ => workspace/meeting}/MeetingRoom.tsx (96%) rename src/components/{ => workspace/meeting}/MeetingSetup.tsx (98%) rename src/components/{ => workspace/meeting}/MeetingTypeList.tsx (97%) create mode 100644 src/components/workspace/meeting/index.ts rename src/components/workspace/{ => members}/InviteMemberDialog.tsx (100%) rename src/components/{ => workspace/members}/MemberList.tsx (89%) create mode 100644 src/components/workspace/members/index.ts rename src/components/{ => workspace}/org-switcher.tsx (98%) diff --git a/src/components/CalendarExport.tsx b/src/components/CalendarExport.tsx index 204a6ce..4881263 100644 --- a/src/components/CalendarExport.tsx +++ b/src/components/CalendarExport.tsx @@ -73,7 +73,7 @@ export function CalendarExport({ - + ); }; diff --git a/src/components/FeatureCard.tsx b/src/components/FeatureCard.tsx deleted file mode 100644 index a267d7e..0000000 --- a/src/components/FeatureCard.tsx +++ /dev/null @@ -1,28 +0,0 @@ -import { Card, CardContent } from "@/components/ui/card"; -import type React from "react"; - -interface FeatureCardProps { - icon: React.ReactNode; - title: string; - description: string; -} - -export default function FeatureCard({ - icon, - title, - description, -}: FeatureCardProps) { - return ( -
- - -
{icon}
-

{title}

-

{description}

-
-
-
- ); -} diff --git a/src/components/Logo.tsx b/src/components/Logo.tsx deleted file mode 100644 index 1986982..0000000 --- a/src/components/Logo.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import React from "react"; -import Image from "next/image"; - -const Logo = () => { - return ( -
- {/* Logo for dark theme */} - collaro logo - -

Collaro

-
- ); -}; - -export default Logo; diff --git a/src/components/SignOutButton.tsx b/src/components/SignOutButton.tsx deleted file mode 100644 index 765ddcd..0000000 --- a/src/components/SignOutButton.tsx +++ /dev/null @@ -1,20 +0,0 @@ -"use client"; - -import { signOut } from "@/lib/auth-client"; -import { Button } from "@/components/ui/button"; -import { useRouter } from "next/navigation"; - -export const SignOutButton = () => { - const router = useRouter(); - - const handleSignOut = async () => { - // Sign out using better-auth - await signOut(); - - // Redirect to home page - router.push("/auth/sign-in"); - router.refresh(); - }; - - return ; -}; diff --git a/src/components/TeamCall.tsx b/src/components/TeamCall.tsx deleted file mode 100644 index 49e087c..0000000 --- a/src/components/TeamCall.tsx +++ /dev/null @@ -1,927 +0,0 @@ -"use client"; -import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useWorkspaceStore } from "@/store/workspace"; -import { useMemo } from "react"; -import { - Card, - CardContent, - CardDescription, - CardFooter, - CardHeader, - CardTitle, -} from "@/components/ui/card"; -import { Badge } from "@/components/ui/badge"; -import { Skeleton } from "@/components/ui/skeleton"; -import { - CalendarIcon, - Users, - Clock, - User, - LucidePhoneOff, - LucidePhoneCall, - Video, - Calendar, - Clock3, - AlarmClock, - CheckCircle2, - BarChart3, - ClipboardList, - Hourglass, -} from "lucide-react"; -import { ScrollArea } from "@/components/ui/scroll-area"; -import { Button } from "@/components/ui/button"; -import Link from "next/link"; -import { useSession } from "@/lib/auth-client"; -import { - Tooltip, - TooltipContent, - TooltipProvider, - TooltipTrigger, -} from "./ui/tooltip"; -import { - BarChart, - Bar, - Cell, - XAxis, - YAxis, - CartesianGrid, - ResponsiveContainer, - PieChart as RPieChart, - Pie, - Legend, - Tooltip as RechartTooltip, - // AreaChart, Area -} from "recharts"; -import { Tabs, TabsContent, TabsList, TabsTrigger } from "./ui/tabs"; - -const TeamCall = () => { - const { data: session } = useSession(); - const { workspaceName } = useWorkspaceStore(); - const { calls, isCallsLoading } = useGetCallsByTeam(workspaceName as string); - - // Function to calculate and format call duration - const formatDuration = (startTime: string, endTime: string | null) => { - if (!endTime) return "In progress"; - - const start = new Date(startTime).getTime(); - const end = new Date(endTime).getTime(); - const durationMs = end - start; - - const hours = Math.floor(durationMs / (1000 * 60 * 60)); - const minutes = Math.floor((durationMs % (1000 * 60 * 60)) / (1000 * 60)); - const seconds = Math.floor((durationMs % (1000 * 60)) / 1000); - - return `${hours > 0 ? `${hours}h ` : ""}${minutes}m ${seconds}s`; - }; - - // Function to determine call status - const getCallStatus = (call: any) => { - if (call.state.endedAt) { - return { - label: "Ended", - color: "destructive", - icon: , - }; - } else if ( - call.state.startedAt && - new Date(call.state.startedAt) < new Date() - ) { - return { - label: "Active", - color: "success", - icon: , - }; - } else if (call.state.custom?.scheduled) { - return { - label: "Scheduled", - color: "warning", - icon: , - }; - } else { - return { - label: "Created", - color: "secondary", - icon: , - }; - } - }; - - // Process data for visualizations - const visualizationData = useMemo(() => { - if (!calls || calls.length === 0) return null; - - // Call status distribution - const statusCounts = { - Active: 0, - Ended: 0, - Scheduled: 0, - Created: 0, - }; - - // Call duration data - const durationData = []; - - // Member participation - const memberParticipation = new Map(); - - // Date distribution - const dateDistribution = new Map(); - - // Weekly distribution - const weeklyDistribution = new Map(); - - // Monthly distribution - const monthlyDistribution = new Map(); - - // Meeting type distribution - const meetingTypeCount = { - Scheduled: 0, - Instant: 0, - }; - - // Duration categories - const durationCategories = { - "< 15 mins": 0, - "15-30 mins": 0, - "30-60 mins": 0, - "> 60 mins": 0, - "In Progress": 0, - }; - - // Time of day distribution - const timeOfDayCount = { - "Morning (6-12)": 0, - "Afternoon (12-17)": 0, - "Evening (17-22)": 0, - "Night (22-6)": 0, - }; - - // Get current date for calculations - const currentDate = new Date(); - const currentDay = currentDate.getDate(); - console.log("Current Day:", currentDay); - // const currentMonth = currentDate.getMonth() + 1; // Months are 0-indexed - // const currentYear = currentDate.getFullYear(); - - // Month names - const monthNames = [ - "January", - "February", - "March", - "April", - "May", - "June", - "July", - "August", - "September", - "October", - "November", - "December", - ]; - - // Day names - const dayNames = [ - "Sunday", - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - ]; - - for (const call of calls) { - // Count statuses - const status = getCallStatus(call); - statusCounts[status.label as keyof typeof statusCounts]++; - - // Meeting type categorization - if (call.state.custom?.scheduled) { - meetingTypeCount.Scheduled++; - } else { - meetingTypeCount.Instant++; - } - - // Calculate duration for ended calls and categorize - if (call.state.startedAt) { - const startDate = new Date(call.state.startedAt); - - // Time of day categorization - - const hour = startDate.getHours(); - if (hour >= 6 && hour < 12) { - timeOfDayCount["Morning (6-12)"]++; - } else if (hour >= 12 && hour < 17) { - timeOfDayCount["Afternoon (12-17)"]++; - } else if (hour >= 17 && hour < 22) { - timeOfDayCount["Evening (17-22)"]++; - } else { - timeOfDayCount["Night (22-6)"]++; - } - - if (call.state.endedAt) { - const start = startDate.getTime(); - const end = new Date(call.state.endedAt).getTime(); - const durationMinutes = Math.round((end - start) / (1000 * 60)); - - // Duration categorization - if (durationMinutes < 15) { - durationCategories["< 15 mins"]++; - } else if (durationMinutes >= 15 && durationMinutes < 30) { - durationCategories["15-30 mins"]++; - } else if (durationMinutes >= 30 && durationMinutes < 60) { - durationCategories["30-60 mins"]++; - } else { - durationCategories["> 60 mins"]++; - } - - durationData.push({ - id: call.id.slice(0, 8), - duration: durationMinutes, - title: call.state?.custom?.description || "Untitled Call", - }); - } else { - // Call is in progress - durationCategories["In Progress"]++; - } - } - - // Count member participation - call.state.members.forEach((member) => { - const memberName = member.user.name || member.user.id.slice(0, 8); - memberParticipation.set( - memberName, - (memberParticipation.get(memberName) || 0) + 1, - ); - }); - - // Time-based aggregations - if (call.state.startedAt) { - const callDate = new Date(call.state.startedAt); - - // Daily distribution - const date = callDate.toLocaleDateString(); - dateDistribution.set(date, (dateDistribution.get(date) || 0) + 1); - - // Weekly distribution - group by day of week - const dayOfWeek = dayNames[callDate.getDay()]; - weeklyDistribution.set( - dayOfWeek, - (weeklyDistribution.get(dayOfWeek) || 0) + 1, - ); - - // Monthly distribution - group by month - const monthYear = `${monthNames[callDate.getMonth()]} ${callDate.getFullYear()}`; - monthlyDistribution.set( - monthYear, - (monthlyDistribution.get(monthYear) || 0) + 1, - ); - } - } - - // Format for charts - const statusData = Object.entries(statusCounts).map(([name, value]) => ({ - name, - value, - color: - name === "Active" - ? "#10b981" - : name === "Ended" - ? "#ef4444" - : name === "Scheduled" - ? "#f59e0b" - : "#9ca3af", - })); - - // Meeting type pie chart data - const meetingTypeData = Object.entries(meetingTypeCount).map( - ([name, value]) => ({ - name, - value, - color: name === "Scheduled" ? "#3b82f6" : "#8b5cf6", - }), - ); - - // Duration categories pie chart data - const durationCategoryData = Object.entries(durationCategories).map( - ([name, value]) => ({ - name, - value, - color: - name === "< 15 mins" - ? "#10b981" - : name === "15-30 mins" - ? "#3b82f6" - : name === "30-60 mins" - ? "#8b5cf6" - : name === "> 60 mins" - ? "#f59e0b" - : "#9ca3af", // In Progress - }), - ); - - // Time of day pie chart data - const timeOfDayData = Object.entries(timeOfDayCount).map( - ([name, value]) => ({ - name, - value, - color: - name === "Morning (6-12)" - ? "#f59e0b" // Orange for morning - : name === "Afternoon (12-17)" - ? "#3b82f6" // Blue for afternoon - : name === "Evening (17-22)" - ? "#8b5cf6" // Purple for evening - : "#1e293b", // Dark blue for night - }), - ); - - const memberData = Array.from(memberParticipation.entries()) - .map(([name, count]) => ({ name, calls: count })) - .sort((a, b) => b.calls - a.calls) - .slice(0, 10); - - const dateData = Array.from(dateDistribution.entries()) - .map(([date, count]) => ({ date, calls: count })) - .sort((a, b) => new Date(a.date).getTime() - new Date(b.date).getTime()) - .slice(-14); // Last 14 days - - // Process weekly data - ensure all days are represented and in correct order - const orderedDays = [ - "Monday", - "Tuesday", - "Wednesday", - "Thursday", - "Friday", - "Saturday", - "Sunday", - ]; - const weeklyData = orderedDays.map((day) => ({ - day, - calls: weeklyDistribution.get(day) || 0, - })); - - // Process monthly data - sort chronologically - const monthlyData = Array.from(monthlyDistribution.entries()) - .map(([month, count]) => ({ month, calls: count })) - .sort((a, b) => { - const [aMonth, aYear] = a.month.split(" "); - const [bMonth, bYear] = b.month.split(" "); - - if (aYear !== bYear) return parseInt(aYear) - parseInt(bYear); - return monthNames.indexOf(aMonth) - monthNames.indexOf(bMonth); - }) - .slice(-6); // Last 6 months - - return { - statusData, - durationData, - memberData, - dateData, - weeklyData, - monthlyData, - meetingTypeData, - durationCategoryData, - timeOfDayData, - }; - }, [calls]); - - // Custom colors for charts - const COLORS = [ - "#10b981", - "#ef4444", - "#f59e0b", - "#9ca3af", - "#3b82f6", - "#8b5cf6", - ]; - - if (isCallsLoading) { - return ( -
-

- Workspace Calls -

-
- {[...Array(3)].map((_, i) => ( - - - - - - -
- -
- - -
-
-
-
- ))} -
-
- ); - } - - return ( -
-

- Workspace Calls -

- - {calls.length === 0 ? ( - - -

- No calls available -

-
-
- ) : ( - - {/* Analytics Dashboard Section */} -
- - - - - Call Analytics Dashboard - - - Visual insights for your workspace calls - - - - - - Overview - - More Insights - - Call Duration - - Member Participation - - Call Trends - - Time Analysis - - - - -

- Call Status Distribution -

-
- {visualizationData && ( - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - innerRadius={60} - outerRadius={120} - fill="#8884d8" - dataKey="value" - > - {visualizationData.statusData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - )} -
-
- Total Calls: {calls.length} -
-
- - - {/* Meeting Type Distribution */} -
-
-

- - Meeting Type Distribution -

-
- {visualizationData && - visualizationData.meetingTypeData && - visualizationData.meetingTypeData.some( - (d) => d.value > 0, - ) ? ( - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {visualizationData.meetingTypeData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - ) : ( -
-

- No meeting type data available -

-
- )} -
-
- Distribution between scheduled and instant meetings -
-
-
-
- - - {/* Call Duration Categories */} -
-

- - Call Duration Categories -

-
- {visualizationData && - visualizationData.durationCategoryData && - visualizationData.durationCategoryData.some( - (d) => d.value > 0, - ) ? ( - <> - - - - `${name}: ${(percent * 100).toFixed(0)}%` - } - outerRadius={80} - fill="#8884d8" - dataKey="value" - > - {visualizationData.durationCategoryData.map( - (entry, index) => ( - - ), - )} - - - [ - `${value} calls`, - name, - ]} - /> - - - - ) : ( -
-

- No duration data available -

-

- No duration data available -

-
- )} -
-
- Breakdown of calls by duration categories -
-
- - {/* Monthly Distribution */} -
-

- Monthly Call Distribution -

-
- {visualizationData && - visualizationData.monthlyData.length > 0 ? ( - - - - - - [ - `${value} calls`, - "Call Count", - ]} - /> - - - - - - - - - - ) : ( -
-

- No monthly data available -

-
- )} -
-
- Call volume by month for the last 6 months -
-
-
-
-
-
-
- - {/* Call Cards Section */} -
- {calls.map((call) => { - const status = getCallStatus(call); - const isMember = call.state.members.some( - (member) => member.user.id === (session?.user?.id || ""), - ); - const hasRecording = call.state.custom?.hasRecording || false; - const meetingType = call.state.custom?.scheduled - ? "Scheduled Meeting" - : "Instant Meeting"; - - return ( - - -
- {call.state?.custom?.description ? ( - - {call.state.custom.description} - - ) : ( - - Untitled Call - - )} - - - {status.icon} {status.label} - -
- -
- - {call.state.startedAt && ( -
- - {new Date(call.state.startedAt).toLocaleString()} -
- )} -
- - - {call.state.custom?.scheduled ? ( -
- {meetingType} -
- ) : ( -
-
- )} -
-
-
- - - {/* Duration Info */} - {call.state.startedAt && ( -
- - - Duration:{" "} - {formatDuration( - call.state.startedAt.toLocaleDateString(), - call.state.endedAt?.toLocaleDateString() || "", - )} - -
- )} - - {/* Members Section */} - {call.state.members && ( -
-
- - - {call.state.members.length} members - -
- -
- - {call.state.members.slice(0, 3).map((member) => ( - - - - {member.user.name || member.user.id} - - - -

Role: {member.role}

-
-
- ))} - - {call.state.members.length > 3 && ( - - +{call.state.members.length - 3} more - - )} -
-
-
- )} - - {/* Creator Info */} - {call.state.createdBy && ( -
- - - Created by: {call.state.createdBy.name} - {call.isCreatedByMe && ( - - You - - )} - -
- )} - - {/* Created Time */} - {call.state.createdAt && ( -
- - - Created:{" "} - {new Date(call.state.createdAt).toLocaleString()} - -
- )} - - {/* Recording Badge */} - {hasRecording && ( -
- - -
- )} -
- - - {!call.state.endedAt || isMember ? ( - - - - ) : ( -
- - - Call Ended - -
- )} -
-
- ); - })} -
-
- )} -
- ); -}; - -export default TeamCall; diff --git a/src/components/WorkspaceInitializer.tsx b/src/components/WorkspaceInitializer.tsx deleted file mode 100644 index 56d9c2c..0000000 --- a/src/components/WorkspaceInitializer.tsx +++ /dev/null @@ -1,39 +0,0 @@ -"use client"; - -import { useEffect, useCallback } from "react"; -import { useWorkspaceStore } from "@/store/workspace"; -import type { WorkspaceInitializerProps } from "@/types"; - -export const WorkspaceInitializer = ({ - workspaceId, - workspaceName, - members, -}: WorkspaceInitializerProps) => { - const { setWorkspace, isInitialized, setInitialized } = useWorkspaceStore(); - - const initializeWorkspace = useCallback(() => { - if (!isInitialized && workspaceId && workspaceName) { - console.log( - "Initializing workspace in store:", - workspaceId, - workspaceName, - ); - - setWorkspace(workspaceId, workspaceName, members); - setInitialized(true); - } - }, [ - workspaceId, - workspaceName, - members, - setWorkspace, - isInitialized, - setInitialized, - ]); - - useEffect(() => { - initializeWorkspace(); - }, [initializeWorkspace]); - - return null; -}; diff --git a/src/components/app-sidebar.tsx b/src/components/app-sidebar.tsx index 41f008e..ee98820 100644 --- a/src/components/app-sidebar.tsx +++ b/src/components/app-sidebar.tsx @@ -14,7 +14,7 @@ import { } from "@/components/ui/sidebar"; import { useSession } from "@/lib/auth-client"; import Loader from "./Loader"; -import Logo from "./Logo"; +import Logo from "./navigation/Logo"; import Link from "next/link"; import { useEffect, useState } from "react"; import { getMember } from "@/action/member.action"; @@ -31,7 +31,7 @@ export function AppSidebar({ const fetchMemberRoel = async () => { const { data } = await getMember( pathname.split("/")[2], - session?.user?.id || "", + session?.user?.id || "" ); if (!data) return; setRole(data.role); diff --git a/src/components/AcceptInviteForm.tsx b/src/components/auth/AcceptInviteForm.tsx similarity index 99% rename from src/components/AcceptInviteForm.tsx rename to src/components/auth/AcceptInviteForm.tsx index a083926..a3c0d8b 100644 --- a/src/components/AcceptInviteForm.tsx +++ b/src/components/auth/AcceptInviteForm.tsx @@ -155,3 +155,5 @@ export function AcceptInviteForm({ invitationId }: AcceptInviteFormProps) { ); } + +export default AcceptInviteForm; diff --git a/src/components/auth/AuthStateManager.tsx b/src/components/auth/AuthStateManager.tsx deleted file mode 100644 index 831e4ec..0000000 --- a/src/components/auth/AuthStateManager.tsx +++ /dev/null @@ -1,112 +0,0 @@ -"use client"; - -import { useSession } from "@/lib/auth-client"; -import { useUserStore } from "@/store/user"; -import { useWorkspaceStore } from "@/store/workspace"; -import { useEffect, useCallback } from "react"; - -/** - * AuthStateManager - Handles post-signin data fetchings - * - * WHY: This component runs immediately after better-auth authentication - * WHEN: Triggered when session becomes available - * HOW: Fetches user data from database and updates stores - */ -export const AuthStateManager = () => { - const { data: session, isPending } = useSession(); - const { - setUserData, - clearUserData, - setDataLoaded, - isAuthenticated, - isDataLoaded, - } = useUserStore(); - const { setWorkspace, clearWorkspace } = useWorkspaceStore(); - - const fetchUserData = useCallback(async () => { - if (!session?.user?.id) return; - - try { - const response = await fetch("/api/user/me"); - const result = await response.json(); - - if (result.success && result.data) { - const userData = result.data; - - // Update user store with all data including workspaceName - setUserData({ - email: userData.email || session.user.email || "", - name: userData.name || session.user.name || "", - userId: userData.userId || session.user.id, - userName: userData.userName || session.user.userName, - currentWorkspaceId: userData.currentWorkspaceId, - currentWorkspaceName: userData.currentWorkspaceName, - role: userData.role, - }); - - // Also update workspace store if user has a workspace - if (userData.currentWorkspaceId && userData.currentWorkspaceName) { - setWorkspace( - userData.currentWorkspaceId, - userData.currentWorkspaceName, - ); - } - - setDataLoaded(true); - console.log("✅ User data loaded successfully:", { - userName: userData.userName, - workspaceName: userData.currentWorkspaceName, - }); - } else { - // Use session data directly if API failed - setUserData({ - email: session.user.email || "", - name: session.user.name || "", - userId: session.user.id, - userName: session.user.userName || "", - currentWorkspaceId: null, - currentWorkspaceName: null, - role: "member", - }); - setDataLoaded(true); - console.warn("⚠️ Using session data, API fetch failed:", result.error); - } - } catch (error) { - console.error("❌ Error fetching user data:", error); - } - }, [session?.user, setUserData, setWorkspace, setDataLoaded]); - - const handleSignOut = useCallback(() => { - clearUserData(); - clearWorkspace(); - console.log("🔄 User data cleared on sign out"); - }, [clearUserData, clearWorkspace]); - - // Effect: Handle authentication state changes - useEffect(() => { - if (isPending) return; - - if (session?.user) { - // User signed in - check if we need to fetch data - if (!isAuthenticated || !isDataLoaded) { - console.log("🔄 Fetching user data after sign in..."); - fetchUserData(); - } - } else { - // User signed out - if (isAuthenticated) { - handleSignOut(); - } - } - }, [ - isPending, - session, - isAuthenticated, - isDataLoaded, - fetchUserData, - handleSignOut, - ]); - - // This component doesn't render anything - return null; -}; diff --git a/src/components/signup-form.tsx b/src/components/auth/SignupForm.tsx similarity index 99% rename from src/components/signup-form.tsx rename to src/components/auth/SignupForm.tsx index 5aba04d..931622f 100644 --- a/src/components/signup-form.tsx +++ b/src/components/auth/SignupForm.tsx @@ -68,3 +68,5 @@ export function SignupForm({ ); } + +export default SignupForm; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts new file mode 100644 index 0000000..e086bf8 --- /dev/null +++ b/src/components/auth/index.ts @@ -0,0 +1,2 @@ +export * from "./AcceptInviteForm"; +export { default as SignupForm } from "./SignupForm"; diff --git a/src/components/charts/WeeklyMeetingsChart.tsx b/src/components/charts/WeeklyMeetingsChart.tsx deleted file mode 100644 index 033bc3b..0000000 --- a/src/components/charts/WeeklyMeetingsChart.tsx +++ /dev/null @@ -1,99 +0,0 @@ -"use client"; - -import { - PieChart, - Pie, - Cell, - ResponsiveContainer, - Legend, - Tooltip, -} from "recharts"; -// import { useWorkspaceStore } from "@/store/workspace"; -// import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useCallback, useMemo, useRef } from "react"; -import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; - -const COLORS = ["#0e0d85", "#00C49F", "#FFBB28", "#FF8042"]; - -export const WeeklyMeetingsChart = () => { - // const { workspaceName } = useWorkspaceStore();. - const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); - - // Keep stable reference to date objects - const dateRangeRef = useRef({ - startOfWeek: new Date(), - endOfWeek: new Date(), - }); - - // Memoize date range calculation - const getDateRange = useCallback(() => { - const now = new Date(); - const startOfWeek = new Date(now); - startOfWeek.setDate(now.getDate() - now.getDay()); - startOfWeek.setHours(0, 0, 0, 0); - - const endOfWeek = new Date(now); - endOfWeek.setDate(now.getDate() - now.getDay() + 6); - endOfWeek.setHours(23, 59, 59, 999); - - return { startOfWeek, endOfWeek }; - }, []); - - const { thisWeekCalls } = useMemo(() => { - const { startOfWeek, endOfWeek } = getDateRange(); - dateRangeRef.current = { startOfWeek, endOfWeek }; - - const filteredCalls = TeamCall.filter((call) => { - const callDate = new Date(call.state.createdAt || 0); - return callDate >= startOfWeek && callDate <= endOfWeek; - }); - - return { - thisWeekCalls: filteredCalls, - weekDates: { startOfWeek, endOfWeek }, - }; - }, [TeamCall, getDateRange]); - - // Stable data processing - const data = useMemo(() => { - const endedCalls = thisWeekCalls.filter((call) => call.state.endedAt); - const totalCalls = thisWeekCalls.length; - - return [ - { name: "Completed Calls", value: endedCalls.length }, - { name: "Active/Pending", value: totalCalls - endedCalls.length }, - ]; - }, [thisWeekCalls]); - - if (isCallsLoading) { - return
Loading...
; - } - - return ( -
- - - `${name}: ${value}`} - > - {data.map((_, index) => ( - - ))} - - - - - -
- ); -}; diff --git a/src/components/EmailTemplate.tsx b/src/components/email/EmailTemplate.tsx similarity index 100% rename from src/components/EmailTemplate.tsx rename to src/components/email/EmailTemplate.tsx diff --git a/src/components/email/index.ts b/src/components/email/index.ts new file mode 100644 index 0000000..ec69c5f --- /dev/null +++ b/src/components/email/index.ts @@ -0,0 +1 @@ +export * from "./EmailTemplate"; diff --git a/src/components/examples/UserDashboard.tsx b/src/components/examples/UserDashboard.tsx deleted file mode 100644 index 63e1bd5..0000000 --- a/src/components/examples/UserDashboard.tsx +++ /dev/null @@ -1,48 +0,0 @@ -"use client"; - -import { useAuthData } from "@/hooks/useAuthData"; -import { useEffect } from "react"; - -/** - * Example component showing how to use the auth data - */ -export const UserDashboard = () => { - const { userInfo, workspaceInfo, isReady } = useAuthData(); - - useEffect(() => { - if (isReady) { - console.log("🎉 User is ready with data:", { - userName: userInfo.userName, - workspaceName: workspaceInfo.currentWorkspaceName, - role: userInfo.role, - }); - } - }, [isReady, userInfo, workspaceInfo]); - - if (!isReady) { - return
Loading user data...
; - } - - return ( -
-

Welcome, {userInfo.name}!

-
-

- Username: {userInfo.userName} -

-

- Email: {userInfo.email} -

-

- Role: {userInfo.role} -

- {workspaceInfo.currentWorkspaceName && ( -

- Current Workspace:{" "} - {workspaceInfo.currentWorkspaceName} -

- )} -
-
- ); -}; diff --git a/src/components/home/index.ts b/src/components/home/index.ts new file mode 100644 index 0000000..b3a5fcf --- /dev/null +++ b/src/components/home/index.ts @@ -0,0 +1,3 @@ +export { default as FAQs } from "./FAQs"; +export * from "./Feature"; +export * from "./infinite-moving-cards"; diff --git a/src/components/icons/Home.tsx b/src/components/icons/Home.tsx deleted file mode 100644 index 6210041..0000000 --- a/src/components/icons/Home.tsx +++ /dev/null @@ -1,32 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Home = ({ selected }: Props) => { - return ( - - - - ); -}; - -export default Home; diff --git a/src/components/icons/Personal.tsx b/src/components/icons/Personal.tsx deleted file mode 100644 index 0a68be9..0000000 --- a/src/components/icons/Personal.tsx +++ /dev/null @@ -1,33 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Personal = ({ selected }: Props) => { - return ( -
- - - -
- ); -}; - -export default Personal; diff --git a/src/components/icons/Previous.tsx b/src/components/icons/Previous.tsx deleted file mode 100644 index 79a6ddb..0000000 --- a/src/components/icons/Previous.tsx +++ /dev/null @@ -1,39 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Previous = ({ selected }: Props) => { - return ( - - - - - - ); -}; - -export default Previous; diff --git a/src/components/icons/Reccording.tsx b/src/components/icons/Reccording.tsx deleted file mode 100644 index eb8c440..0000000 --- a/src/components/icons/Reccording.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import clsx from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Reccording = ({ selected }: Props) => { - return ( - - - - - ); -}; - -export default Reccording; diff --git a/src/components/icons/Upcoming.tsx b/src/components/icons/Upcoming.tsx deleted file mode 100644 index fbbd30c..0000000 --- a/src/components/icons/Upcoming.tsx +++ /dev/null @@ -1,45 +0,0 @@ -import { clsx } from "clsx"; -import React from "react"; - -type Props = { - selected: boolean; -}; - -const Upcoming = ({ selected }: Props) => { - return ( -
- - - - - -
- ); -}; - -export default Upcoming; diff --git a/src/components/index.tsx b/src/components/index.tsx deleted file mode 100644 index 4ba57f2..0000000 --- a/src/components/index.tsx +++ /dev/null @@ -1,5 +0,0 @@ -export * from "./home/infinite-moving-cards"; - -export { default as FAQs } from "./home/FAQs"; - -export * from "./home/Feature"; diff --git a/src/components/nav-documents.tsx b/src/components/nav-documents.tsx deleted file mode 100644 index 0867383..0000000 --- a/src/components/nav-documents.tsx +++ /dev/null @@ -1,92 +0,0 @@ -"use client"; - -import { - IconDots, - IconFolder, - IconShare3, - IconTrash, - type Icon, -} from "@tabler/icons-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuSeparator, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarGroup, - SidebarGroupLabel, - SidebarMenu, - SidebarMenuAction, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -export function NavDocuments({ - items, -}: { - items: { - name: string; - url: string; - icon: Icon; - }[]; -}) { - const { isMobile } = useSidebar(); - - return ( - - Documents - - {items.map((item) => ( - - - - - {item.name} - - - - - - - More - - - - - - Open - - - - Share - - - - - Delete - - - - - ))} - - - - More - - - - - ); -} diff --git a/src/components/nav-main.tsx b/src/components/nav-main.tsx index 7994924..8d45171 100644 --- a/src/components/nav-main.tsx +++ b/src/components/nav-main.tsx @@ -68,49 +68,86 @@ export function NavMain({ ); } + + // Non-Admin Routes + // if (sidebarLinks.filter((items) => items.adminRoute == true).length === 0) { + // return ( + // + // + // + // {sidebarLinks + // .filter((item) => item.adminRoute !== true) + // .map((item) => { + // const route = `/workspace/${workspaceId}${item.route}`; + // const isActive = pathname === route; + // const isAdminRoute = item.adminRoute === true; + // const hasAdminAccess = role === "owner" || role === "admin"; + // const shouldRender = !isAdminRoute || hasAdminAccess; + + // if (!shouldRender) return null; + + // const Component = item.component; + + // return ( + // + // + // + // {Component && } + // {item.label} + // + // + // + // ); + // })} + // + // + // + // ); + // } + return ( {/* Non-Member Routes */} + {(role == "owner" || role == "admin") && ( +
+ Admin Routes + + + {sidebarLinks.filter((items) => items.adminRoute == true) && + sidebarLinks + .filter((item) => item.adminRoute === true) + .map((item) => { + const route = `/workspace/${workspaceId}${item.route}`; + const isActive = pathname === route; + const isAdminRoute = item.adminRoute === true; + const hasAdminAccess = role === "owner" || role === "admin"; + const shouldRender = !isAdminRoute || hasAdminAccess; - Admin Routes - - - {sidebarLinks - .filter((item) => item.adminRoute === true) - .map((item) => { - const route = `/workspace/${workspaceId}${item.route}`; - const isActive = pathname === route; - const isAdminRoute = item.adminRoute === true; - const hasAdminAccess = role === "owner" || role === "admin"; - const shouldRender = !isAdminRoute || hasAdminAccess; - - if (!shouldRender) return null; + if (!shouldRender) return null; - const Component = item.component; + const Component = item.component; - return ( - - - - {Component && ( - - )} - {item.label} - - - - ); - })} - - + return ( + + + + {Component && } + {item.label} + + + + ); + })} + + +
+ )} + {/* Member Routes */} Workspace @@ -135,12 +172,7 @@ export function NavMain({ isActive={isActive} > - {Component && ( - - )} + {Component && } {item.label} diff --git a/src/components/navigation/Logo.tsx b/src/components/navigation/Logo.tsx index 58fe859..1986982 100644 --- a/src/components/navigation/Logo.tsx +++ b/src/components/navigation/Logo.tsx @@ -3,28 +3,17 @@ import Image from "next/image"; const Logo = () => { return ( -
- {/* Logo for light theme */} - collaro logo - +
{/* Logo for dark theme */} collaro logo -

- Collaro -

+

Collaro

); }; diff --git a/src/components/site-header.tsx b/src/components/site-header.tsx deleted file mode 100644 index 823f2d5..0000000 --- a/src/components/site-header.tsx +++ /dev/null @@ -1,30 +0,0 @@ -import { Button } from "@/components/ui/button"; -import { Separator } from "@/components/ui/separator"; -import { SidebarTrigger } from "@/components/ui/sidebar"; - -export function SiteHeader() { - return ( -
-
- - -

Documents

-
- -
-
-
- ); -} diff --git a/src/components/RazorpayButton.tsx b/src/components/subscriptions/RazorpayButton.tsx similarity index 100% rename from src/components/RazorpayButton.tsx rename to src/components/subscriptions/RazorpayButton.tsx diff --git a/src/components/RazorpaySubscription.tsx b/src/components/subscriptions/RazorpaySubscription.tsx similarity index 100% rename from src/components/RazorpaySubscription.tsx rename to src/components/subscriptions/RazorpaySubscription.tsx diff --git a/src/components/SubscriptionCard.tsx b/src/components/subscriptions/SubscriptionCard.tsx similarity index 100% rename from src/components/SubscriptionCard.tsx rename to src/components/subscriptions/SubscriptionCard.tsx diff --git a/src/components/subscriptions/index.ts b/src/components/subscriptions/index.ts new file mode 100644 index 0000000..aa28252 --- /dev/null +++ b/src/components/subscriptions/index.ts @@ -0,0 +1,3 @@ +export * as RazorpayButton from "./RazorpayButton"; +export * as SubscriptionPlans from "./RazorpaySubscription"; +export * as SubscriptionCard from "./SubscriptionCard"; diff --git a/src/components/team-switcher.tsx b/src/components/team-switcher.tsx deleted file mode 100644 index b366d5f..0000000 --- a/src/components/team-switcher.tsx +++ /dev/null @@ -1,91 +0,0 @@ -"use client"; - -import * as React from "react"; -import { ChevronsUpDown, Plus } from "lucide-react"; - -import { - DropdownMenu, - DropdownMenuContent, - DropdownMenuItem, - DropdownMenuLabel, - DropdownMenuSeparator, - DropdownMenuShortcut, - DropdownMenuTrigger, -} from "@/components/ui/dropdown-menu"; -import { - SidebarMenu, - SidebarMenuButton, - SidebarMenuItem, - useSidebar, -} from "@/components/ui/sidebar"; - -export function TeamSwitcher({ - teams, -}: { - teams: { - name: string; - logo: React.ElementType; - plan: string; - }[]; -}) { - const { isMobile } = useSidebar(); - const [activeTeam, setActiveTeam] = React.useState(teams[0]); - - if (!activeTeam) { - return null; - } - - return ( - - - - - -
- -
-
- {activeTeam.name} - {activeTeam.plan} -
- -
-
- - - Teams - - {teams.map((team, index) => ( - setActiveTeam(team)} - className="gap-2 p-2" - > -
- -
- {team.name} - ⌘{index + 1} -
- ))} - - -
- -
-
Add team
-
-
-
-
-
- ); -} diff --git a/src/components/charts/DailyMeetingsChart.tsx b/src/components/workspace/admin/charts/DailyMeetingsChart.tsx similarity index 100% rename from src/components/charts/DailyMeetingsChart.tsx rename to src/components/workspace/admin/charts/DailyMeetingsChart.tsx diff --git a/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx b/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx new file mode 100644 index 0000000..e57f140 --- /dev/null +++ b/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx @@ -0,0 +1,22 @@ +"use client"; + +import { + PieChart, + Pie, + Cell, + ResponsiveContainer, + Legend, + Tooltip, +} from "recharts"; +import { useCallback, useMemo, useRef } from "react"; +import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; + +const COLORS = ["#0e0d85", "#00C49F", "#FFBB28", "#FF8042"]; + +export const WeeklyMeetingsChart = () => { + return ( + //
+ ); +}; diff --git a/src/components/workspace/calls/CallList.tsx b/src/components/workspace/calls/CallList.tsx new file mode 100644 index 0000000..f517ac2 --- /dev/null +++ b/src/components/workspace/calls/CallList.tsx @@ -0,0 +1,337 @@ +"use client"; + +import Loader from "@/components/Loader"; +import { useEffect, useState, useRef } from "react"; +import { useGetCalls } from "@/hooks/useGetCalls"; +import MeetingCard from "@/components/workspace/meeting/MeetingCard"; +import { useDebounce } from "@/hooks/useDebounce"; +import { useRouter } from "next/navigation"; +import { Button } from "@/components/ui/button"; +import { Input } from "@/components/ui/input"; +import type { Call, CallRecording } from "@stream-io/video-react-sdk"; +import { FaSearch } from "react-icons/fa"; +import { getFormattedDate } from "@/hooks/getFormatDate"; + +const MAX_CARDS = 6; + +const CallList = ({ type }: { type: "ended" | "upcoming" | "recordings" }) => { + const router = useRouter(); + const { endedCalls, upcomingCalls, callRecordings, isLoading } = + useGetCalls(); + const [recordings, setRecordings] = useState([]); + const [searchTerm, setSearchTerm] = useState(""); + const debouncedSearchTerm = useDebounce(searchTerm, 3); + const [isSearching, setIsSearching] = useState(false); + const [currentPage, setCurrentPage] = useState(1); + const [selectedDate, setSelectedDate] = useState(""); + + const clearSearch = () => { + setSearchTerm(""); + }; + + const searchInputRef = useRef(null); + + useEffect(() => { + const handleShortcut = (event: KeyboardEvent) => { + if ((event.ctrlKey || event.metaKey) && event.key === "k") { + event.preventDefault(); + searchInputRef.current?.focus(); + } + }; + window.addEventListener("keydown", handleShortcut); + return () => { + window.removeEventListener("keydown", handleShortcut); + }; + }, []); + + // Reset pagination when search term changes + useEffect(() => { + setCurrentPage(1); + }, [debouncedSearchTerm]); + + // Show loading spinner when searching + useEffect(() => { + if (searchTerm) { + setIsSearching(true); + setTimeout(() => setIsSearching(false), 2500); + } else { + setIsSearching(false); + } + }, [debouncedSearchTerm, searchTerm]); + + // Fetch recordings when type is "recordings" + useEffect(() => { + const fetchRecordings = async (): Promise => { + const callData = await Promise.all( + callRecordings?.map((meeting) => meeting.queryRecordings()) ?? [] + ); + + const recordings = callData + .filter((call) => call.recordings.length > 0) + .flatMap((call) => call.recordings); + + setRecordings(recordings); + }; + + if (type === "recordings") { + fetchRecordings(); + } + }, [type, callRecordings]); + + if (isLoading) return ; + + // Filter calls based on type + const getCalls = (): (Call | CallRecording)[] => { + switch (type) { + case "ended": + return endedCalls || []; + case "recordings": + return recordings || []; + case "upcoming": + return upcomingCalls || []; + default: + return []; + } + }; + + // Get message when no calls are available + const getNoCallsMessage = (): string => { + switch (type) { + case "ended": + return "No Previous Calls"; + case "upcoming": + return "No Upcoming Calls"; + case "recordings": + return "No Recordings"; + default: + return ""; + } + }; + + // Get calls based on type + const calls = getCalls(); + const safeCallsArray = Array.isArray(calls) ? calls : []; + + // Filter calls based on search term + const filteredCalls = safeCallsArray.filter((meeting) => { + if (!meeting) return false; + + const title = + (meeting as Call)?.state?.custom?.description || + (meeting as CallRecording)?.filename || + ""; + + const searchLower = debouncedSearchTerm?.toLowerCase() || ""; + + // Fix date extraction + const meetingDate = + getFormattedDate((meeting as Call)?.state?.startsAt) || + getFormattedDate((meeting as CallRecording)?.start_time) || + ""; + + const matchDate = selectedDate ? meetingDate === selectedDate : true; + const matchTitle = title.toLowerCase().includes(searchLower); + return matchTitle && matchDate; + }); + + // Pagination logic + const totalPages = Math.ceil(filteredCalls.length / MAX_CARDS); + const startIndex = (currentPage - 1) * MAX_CARDS; + const endIndex = startIndex + MAX_CARDS; + const paginatedCalls = filteredCalls.slice(startIndex, endIndex); + + const goToNextPage = () => { + if (currentPage < totalPages) { + setCurrentPage(currentPage + 1); + } + }; + + const goToPreviousPage = () => { + if (currentPage > 1) { + setCurrentPage(currentPage - 1); + } + }; + + const goToPage = (pageNumber: number) => { + if (pageNumber >= 1 && pageNumber <= totalPages) { + setCurrentPage(pageNumber); + } + }; + + return ( + <> +

+ {type === "ended" + ? "Previous Meetings" + : type === "upcoming" + ? "Upcoming Meetings" + : "Recordings"} +

+ + {/* SearchBox */} +
+
+ setSearchTerm(e.target.value)} + className="w-full px-4 py-2 border rounded-md focus:outline-none focus:ring focus:ring-blue-300" + /> + {!isSearching && !searchTerm && ( +
+ +
+ )} + {searchTerm && !isSearching && ( + + )} + {isSearching && ( +
+ ⏳ +
+ )} +
+
+ setSelectedDate(e.target.value)} + className="px-4 py-2 border w-full rounded-md focus:outline-none focus:ring focus:ring-blue-300" + /> + {selectedDate && ( + + )} +
+
+ + {isSearching ? ( +
+ {Array.from({ length: 4 }).map((_, index) => ( +
+ ))} +
+ ) : ( + <> +
+ {filteredCalls.length > 0 ? ( + paginatedCalls.map((meeting: Call | CallRecording) => ( + router.push(`${(meeting as CallRecording).url}`) + : () => router.push(`/meeting/${(meeting as Call).id}`) + } + /> + )) + ) : ( +

+ {getNoCallsMessage()} +

+ )} +
+ + {!isSearching && totalPages > 1 && ( +
+ {/* Back Button */} + + + {/* Page Numbers */} +
+ {[...Array(totalPages)].map((_, index) => ( + + ))} +
+ + {/* Next Button */} + +
+ )} + + )} + + ); +}; + +export default CallList; diff --git a/src/components/DirectCallButton.tsx b/src/components/workspace/calls/DirectCallButton.tsx similarity index 98% rename from src/components/DirectCallButton.tsx rename to src/components/workspace/calls/DirectCallButton.tsx index eb00095..46f4c58 100644 --- a/src/components/DirectCallButton.tsx +++ b/src/components/workspace/calls/DirectCallButton.tsx @@ -14,7 +14,7 @@ import { DialogTitle, } from "@/components/ui/dialog"; import { useStreamVideoClient } from "@stream-io/video-react-sdk"; -import Loader from "./Loader"; +import Loader from "@/components/Loader"; interface DirectCallButtonProps { memberId: string; diff --git a/src/components/EndCallButton.tsx b/src/components/workspace/calls/EndCallButton.tsx similarity index 100% rename from src/components/EndCallButton.tsx rename to src/components/workspace/calls/EndCallButton.tsx diff --git a/src/components/HomeCard.tsx b/src/components/workspace/calls/HomeCard.tsx similarity index 100% rename from src/components/HomeCard.tsx rename to src/components/workspace/calls/HomeCard.tsx diff --git a/src/components/workspace/calls/IncomingCallBanner.tsx b/src/components/workspace/calls/IncomingCallBanner.tsx new file mode 100644 index 0000000..2c00c62 --- /dev/null +++ b/src/components/workspace/calls/IncomingCallBanner.tsx @@ -0,0 +1,88 @@ +"use client"; + +import React, { useState } from "react"; +import { useRouter } from "next/navigation"; +import { useToast } from "@/components/ui/use-toast"; +import { RingingCall } from "@stream-io/video-react-sdk"; +import { Button } from "@/components/ui/button"; + +type Props = { + meeting: any | null; + workspaceSlug: string; +}; + +export default function IncomingCallBanner({ meeting, workspaceSlug }: Props) { + const router = useRouter(); + const { toast } = useToast(); + const [isEnding, setIsEnding] = useState(false); + + if (!meeting) return null; + + const joinMeeting = () => { + router.push(`/meeting/${meeting.meetingId}`); + }; + + const dismissMeeting = async () => { + setIsEnding(true); + try { + const res = await fetch("/api/meeting/end", { + method: "POST", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ meetingId: meeting.meetingId }), + }); + + const data = await res.json(); + if (!data || !data.success) { + toast({ title: "Failed to dismiss meeting" }); + setIsEnding(false); + return; + } + + toast({ title: "Meeting dismissed" }); + router.refresh(); + } catch (err) { + console.error(err); + toast({ title: "Failed to dismiss meeting" }); + } finally { + setIsEnding(false); + } + }; + + return ( +
+
+
+ {/* presentational ringing list if call context exists; safe to render even if it returns null */} +
+ +
+
+

Incoming Meeting

+

+ {meeting.description || "Instant Meeting"} +

+

+ Hosted by {meeting.hostedBy} +

+
+
+ +
+ + +
+
+
+ ); +} diff --git a/src/components/workspace/calls/index.ts b/src/components/workspace/calls/index.ts new file mode 100644 index 0000000..83d5663 --- /dev/null +++ b/src/components/workspace/calls/index.ts @@ -0,0 +1,3 @@ +export { default as CallList } from "./CallList"; +export { default as DirectCallButton } from "./DirectCallButton"; +export { default as IncomingCallBanner } from "./IncomingCallBanner"; diff --git a/src/components/workspace/index.ts b/src/components/workspace/index.ts new file mode 100644 index 0000000..3109304 --- /dev/null +++ b/src/components/workspace/index.ts @@ -0,0 +1,5 @@ +export * as Calls from "./calls"; +export * as Members from "./members"; +export * as InviteMemberDialog from "./members/InviteMemberDialog"; +export * as Meeting from "./meeting"; +export * as OrgSwitcher from "./org-switcher"; diff --git a/src/components/MeetingCard.tsx b/src/components/workspace/meeting/MeetingCard.tsx similarity index 97% rename from src/components/MeetingCard.tsx rename to src/components/workspace/meeting/MeetingCard.tsx index b57af7f..4fc4455 100644 --- a/src/components/MeetingCard.tsx +++ b/src/components/workspace/meeting/MeetingCard.tsx @@ -5,7 +5,7 @@ import { cn } from "@/lib/utils"; import { Button } from "@/components/ui/button"; import { useToast } from "@/components/ui/use-toast"; import type { MeetingCardProps } from "@/types"; -import { CalendarExport } from "./CalendarExport"; +import { CalendarExport } from "../../CalendarExport"; const MeetingCard = ({ icon, diff --git a/src/components/MeetingModal.tsx b/src/components/workspace/meeting/MeetingModal.tsx similarity index 100% rename from src/components/MeetingModal.tsx rename to src/components/workspace/meeting/MeetingModal.tsx diff --git a/src/components/MeetingRoom.tsx b/src/components/workspace/meeting/MeetingRoom.tsx similarity index 96% rename from src/components/MeetingRoom.tsx rename to src/components/workspace/meeting/MeetingRoom.tsx index 0cd309f..e547745 100644 --- a/src/components/MeetingRoom.tsx +++ b/src/components/workspace/meeting/MeetingRoom.tsx @@ -19,8 +19,8 @@ import { DropdownMenuSeparator, DropdownMenuTrigger, } from "@/components/ui/dropdown-menu"; -import Loader from "./Loader"; -import EndCallButton from "./EndCallButton"; +import Loader from "@/components/Loader"; +import EndCallButton from "@/components/workspace/calls/EndCallButton"; import { cn } from "@/lib/utils"; type CallLayoutType = "grid" | "speaker-left" | "speaker-right"; diff --git a/src/components/MeetingSetup.tsx b/src/components/workspace/meeting/MeetingSetup.tsx similarity index 98% rename from src/components/MeetingSetup.tsx rename to src/components/workspace/meeting/MeetingSetup.tsx index 13d9b3b..e79b6be 100644 --- a/src/components/MeetingSetup.tsx +++ b/src/components/workspace/meeting/MeetingSetup.tsx @@ -7,7 +7,7 @@ import { useCallStateHooks, } from "@stream-io/video-react-sdk"; -import Alert from "./Alert"; +import Alert from "@/components/Alert"; import { Button } from "@/components/ui/button"; const MeetingSetup = ({ @@ -27,7 +27,7 @@ const MeetingSetup = ({ if (!call) { throw new Error( - "useStreamCall must be used within a StreamCall component.", + "useStreamCall must be used within a StreamCall component." ); } diff --git a/src/components/MeetingTypeList.tsx b/src/components/workspace/meeting/MeetingTypeList.tsx similarity index 97% rename from src/components/MeetingTypeList.tsx rename to src/components/workspace/meeting/MeetingTypeList.tsx index a9047c0..bf507de 100644 --- a/src/components/MeetingTypeList.tsx +++ b/src/components/workspace/meeting/MeetingTypeList.tsx @@ -6,13 +6,13 @@ import { useToast } from "@/components/ui/use-toast"; import { useSession } from "@/lib/auth-client"; import { Input } from "@/components/ui/input"; import { Textarea } from "@/components/ui/textarea"; -import HomeCard from "./HomeCard"; +import HomeCard from "../calls/HomeCard"; import MeetingModal from "./MeetingModal"; -import Loader from "./Loader"; +import Loader from "../../Loader"; import ReactDatePicker from "react-datepicker"; import { type Call, useStreamVideoClient } from "@stream-io/video-react-sdk"; -import { CalendarExport } from "./CalendarExport"; -import { Label } from "./ui/label"; +import { CalendarExport } from "../../CalendarExport"; +import { Label } from "../../ui/label"; import { session } from "@/db/schema/auth-schema"; const initialValues = { diff --git a/src/components/workspace/meeting/index.ts b/src/components/workspace/meeting/index.ts new file mode 100644 index 0000000..a33eba1 --- /dev/null +++ b/src/components/workspace/meeting/index.ts @@ -0,0 +1,13 @@ +import MeetingCard from "./MeetingCard"; +import MeetingModal from "./MeetingModal"; +import MeetingRoom from "./MeetingRoom"; +import MeetingSetup from "./MeetingSetup"; +import MeetingTypeList from "./MeetingTypeList"; + +export { + MeetingCard, + MeetingModal, + MeetingRoom, + MeetingSetup, + MeetingTypeList, +}; diff --git a/src/components/workspace/InviteMemberDialog.tsx b/src/components/workspace/members/InviteMemberDialog.tsx similarity index 100% rename from src/components/workspace/InviteMemberDialog.tsx rename to src/components/workspace/members/InviteMemberDialog.tsx diff --git a/src/components/MemberList.tsx b/src/components/workspace/members/MemberList.tsx similarity index 89% rename from src/components/MemberList.tsx rename to src/components/workspace/members/MemberList.tsx index 4dc5639..8b63e05 100644 --- a/src/components/MemberList.tsx +++ b/src/components/workspace/members/MemberList.tsx @@ -2,10 +2,10 @@ import { useState, useEffect } from "react"; import { useWorkspaceStore } from "@/store/workspace"; -import DirectCallButton from "./DirectCallButton"; -import { Avatar, AvatarFallback } from "./ui/avatar"; -import { Card, CardContent } from "./ui/card"; -import Loader from "./Loader"; +import DirectCallButton from "@/components/workspace/calls/DirectCallButton"; +import { Avatar, AvatarFallback } from "@/components/ui/avatar"; +import { Card, CardContent } from "@/components/ui/card"; +import Loader from "@/components/Loader"; interface Member { id: string; diff --git a/src/components/workspace/members/index.ts b/src/components/workspace/members/index.ts new file mode 100644 index 0000000..ed87649 --- /dev/null +++ b/src/components/workspace/members/index.ts @@ -0,0 +1 @@ +export { default as MemberList } from "./MemberList"; diff --git a/src/components/org-switcher.tsx b/src/components/workspace/org-switcher.tsx similarity index 98% rename from src/components/org-switcher.tsx rename to src/components/workspace/org-switcher.tsx index 2c16445..bc4aebd 100644 --- a/src/components/org-switcher.tsx +++ b/src/components/workspace/org-switcher.tsx @@ -10,7 +10,7 @@ import { SelectSeparator, SelectTrigger, SelectValue, -} from "./ui/select"; +} from "@/components/ui/select"; import type { SelectWorkspaceType } from "@/db/schema/schema"; import { getAllWorkspaces } from "@/action"; import { useEffect, useState } from "react"; From 40861a15b42f5f715368114931a041b86fb13b76 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 11:19:31 +0530 Subject: [PATCH 02/13] Refactor imports and remove unused sidebar configuration files; add NotFound component and health check API route. --- drizzle.config.ts | 3 +- package.json | 6 +- pnpm-lock.yaml | 123 +++++++++--------- .../(root)/(dashboard)/meeting/[id]/page.tsx | 3 +- src/app/(root)/(home)/_components/Navbar.tsx | 2 +- src/app/{ => (root)}/not-found.tsx | 0 src/app/api/heatlh/route.ts | 16 +++ src/constants/sidebar-items.ts | 75 ----------- src/constants/sidebar.json | 52 -------- 9 files changed, 86 insertions(+), 194 deletions(-) rename src/app/{ => (root)}/not-found.tsx (100%) create mode 100644 src/app/api/heatlh/route.ts delete mode 100644 src/constants/sidebar-items.ts delete mode 100644 src/constants/sidebar.json diff --git a/drizzle.config.ts b/drizzle.config.ts index 6ada99e..9715245 100644 --- a/drizzle.config.ts +++ b/drizzle.config.ts @@ -1,5 +1,5 @@ import { defineConfig } from "drizzle-kit"; -import { config } from "./src/lib/config"; +import { config } from "@/lib/config"; export default defineConfig({ dialect: "postgresql", @@ -8,4 +8,5 @@ export default defineConfig({ dbCredentials: { url: config.database, }, + casing: "snake_case", }); diff --git a/package.json b/package.json index 5312ba0..4267e47 100644 --- a/package.json +++ b/package.json @@ -86,7 +86,6 @@ "drizzle-seed": "^0.3.1", "drizzle-zod": "^0.8.3", "embla-carousel-react": "^8.6.0", - "framer-motion": "12.23.0", "geist": "^1.4.2", "globals": "^16.0.0", "ical-generator": "^9.0.0", @@ -121,7 +120,6 @@ "zustand": "^5.0.3" }, "devDependencies": { - "@eslint/eslintrc": "^3", "@tailwindcss/postcss": "^4.0.17", "@types/node": "^22.15.3", "@types/nodemailer": "^6.4.14", @@ -129,13 +127,11 @@ "@types/react-datepicker": "^6.2.0", "@types/react-dom": "^19", "@types/uuid": "^10.0.0", - "@vitest/ui": "^3.2.4", "babel-plugin-react-compiler": "^1.0.0", "husky": "^9.1.7", "inngest-cli": "^1.15.3", "tailwindcss": "^4.1.18", - "typescript": "^5", - "vitest": "^3.2.4" + "typescript": "^5" }, "trustedDependencies": [ "inngest-cli", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 5303398..5a93b83 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -161,9 +161,6 @@ importers: embla-carousel-react: specifier: ^8.6.0 version: 8.6.0(react@19.2.3) - framer-motion: - specifier: 12.23.0 - version: 12.23.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3) geist: specifier: ^1.4.2 version: 1.5.1(next@16.1.1(@opentelemetry/api@1.9.0)(babel-plugin-react-compiler@1.0.0)(react-dom@19.2.3(react@19.2.3))(react@19.2.3)) @@ -261,9 +258,6 @@ importers: specifier: ^5.0.3 version: 5.0.9(@types/react@19.2.7)(react@19.2.3)(use-sync-external-store@1.6.0(react@19.2.3)) devDependencies: - '@eslint/eslintrc': - specifier: ^3 - version: 3.3.3 '@tailwindcss/postcss': specifier: ^4.0.17 version: 4.1.18 @@ -285,9 +279,6 @@ importers: '@types/uuid': specifier: ^10.0.0 version: 10.0.0 - '@vitest/ui': - specifier: ^3.2.4 - version: 3.2.4(vitest@3.2.4) babel-plugin-react-compiler: specifier: ^1.0.0 version: 1.0.0 @@ -303,9 +294,6 @@ importers: typescript: specifier: ^5 version: 5.9.3 - vitest: - specifier: ^3.2.4 - version: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2) packages: @@ -4010,20 +3998,6 @@ packages: forwarded-parse@2.1.2: resolution: {integrity: sha512-alTFZZQDKMporBH77856pXgzhEzaUVmLCDk+egLgIgHst3Tpndzz8MnKe+GzRJRfvVdn69HhpW7cmXzvtLvJAw==} - framer-motion@12.23.0: - resolution: {integrity: sha512-xf6NxTGAyf7zR4r2KlnhFmsRfKIbjqeBupEDBAaEtVIBJX96sAon00kMlsKButSIRwPSHjbRrAPnYdJJ9kyhbA==} - peerDependencies: - '@emotion/is-prop-valid': '*' - react: ^18.0.0 || ^19.0.0 - react-dom: ^18.0.0 || ^19.0.0 - peerDependenciesMeta: - '@emotion/is-prop-valid': - optional: true - react: - optional: true - react-dom: - optional: true - framer-motion@12.23.26: resolution: {integrity: sha512-cPcIhgR42xBn1Uj+PzOyheMtZ73H927+uWPDVhUMqxy8UHt6Okavb6xIz9J/phFUHUj0OncR6UvMfJTXoc/LKA==} peerDependencies: @@ -6975,7 +6949,8 @@ snapshots: '@pinojs/redact@0.4.0': {} - '@polka/url@1.0.0-next.29': {} + '@polka/url@1.0.0-next.29': + optional: true '@protobuf-ts/runtime-rpc@2.11.1': dependencies: @@ -8398,6 +8373,7 @@ snapshots: dependencies: '@types/deep-eql': 4.0.2 assertion-error: 2.0.1 + optional: true '@types/connect@3.4.38': dependencies: @@ -8431,7 +8407,8 @@ snapshots: dependencies: '@types/ms': 2.1.0 - '@types/deep-eql@4.0.2': {} + '@types/deep-eql@4.0.2': + optional: true '@types/estree@1.0.8': {} @@ -8608,6 +8585,7 @@ snapshots: '@vitest/utils': 3.2.4 chai: 5.3.3 tinyrainbow: 2.0.0 + optional: true '@vitest/mocker@3.2.4(vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2))': dependencies: @@ -8616,26 +8594,31 @@ snapshots: magic-string: 0.30.21 optionalDependencies: vite: 7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2) + optional: true '@vitest/pretty-format@3.2.4': dependencies: tinyrainbow: 2.0.0 + optional: true '@vitest/runner@3.2.4': dependencies: '@vitest/utils': 3.2.4 pathe: 2.0.3 strip-literal: 3.1.0 + optional: true '@vitest/snapshot@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 magic-string: 0.30.21 pathe: 2.0.3 + optional: true '@vitest/spy@3.2.4': dependencies: tinyspy: 4.0.4 + optional: true '@vitest/ui@3.2.4(vitest@3.2.4)': dependencies: @@ -8647,12 +8630,14 @@ snapshots: tinyglobby: 0.2.15 tinyrainbow: 2.0.0 vitest: 3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2) + optional: true '@vitest/utils@3.2.4': dependencies: '@vitest/pretty-format': 3.2.4 loupe: 3.2.1 tinyrainbow: 2.0.0 + optional: true acorn-import-attributes@1.9.5(acorn@8.15.0): dependencies: @@ -8689,7 +8674,8 @@ snapshots: dependencies: tslib: 2.8.1 - assertion-error@2.0.1: {} + assertion-error@2.0.1: + optional: true asynckit@0.4.0: {} @@ -8760,7 +8746,8 @@ snapshots: buffer-from@1.1.2: {} - cac@6.7.14: {} + cac@6.7.14: + optional: true call-bind-apply-helpers@1.0.2: dependencies: @@ -8780,6 +8767,7 @@ snapshots: deep-eql: 5.0.2 loupe: 3.2.1 pathval: 2.0.1 + optional: true chalk@4.1.2: dependencies: @@ -8790,7 +8778,8 @@ snapshots: dependencies: '@kurkle/color': 0.3.4 - check-error@2.1.1: {} + check-error@2.1.1: + optional: true chownr@2.0.0: {} @@ -8913,7 +8902,8 @@ snapshots: decimal.js-light@2.5.1: {} - deep-eql@5.0.2: {} + deep-eql@5.0.2: + optional: true deep-is@0.1.4: {} @@ -9029,7 +9019,8 @@ snapshots: es-errors@1.3.0: {} - es-module-lexer@1.7.0: {} + es-module-lexer@1.7.0: + optional: true es-object-atoms@1.1.1: dependencies: @@ -9131,6 +9122,7 @@ snapshots: '@esbuild/win32-arm64': 0.27.2 '@esbuild/win32-ia32': 0.27.2 '@esbuild/win32-x64': 0.27.2 + optional: true escalade@3.2.0: {} @@ -9205,12 +9197,14 @@ snapshots: estree-walker@3.0.3: dependencies: '@types/estree': 1.0.8 + optional: true esutils@2.0.3: {} eventemitter3@4.0.7: {} - expect-type@1.3.0: {} + expect-type@1.3.0: + optional: true extend@3.0.2: {} @@ -9232,7 +9226,8 @@ snapshots: optionalDependencies: picomatch: 4.0.3 - fflate@0.8.2: {} + fflate@0.8.2: + optional: true file-entry-cache@8.0.0: dependencies: @@ -9262,15 +9257,6 @@ snapshots: forwarded-parse@2.1.2: {} - framer-motion@12.23.0(react-dom@19.2.3(react@19.2.3))(react@19.2.3): - dependencies: - motion-dom: 12.23.23 - motion-utils: 12.23.6 - tslib: 2.8.1 - optionalDependencies: - react: 19.2.3 - react-dom: 19.2.3(react@19.2.3) - framer-motion@12.23.26(react-dom@19.2.3(react@19.2.3))(react@19.2.3): dependencies: motion-dom: 12.23.23 @@ -9501,7 +9487,8 @@ snapshots: js-tokens@4.0.0: {} - js-tokens@9.0.1: {} + js-tokens@9.0.1: + optional: true js-yaml@4.1.1: dependencies: @@ -9644,7 +9631,8 @@ snapshots: dependencies: js-tokens: 4.0.0 - loupe@3.2.1: {} + loupe@3.2.1: + optional: true lucide-react@0.561.0(react@19.2.3): dependencies: @@ -9705,7 +9693,8 @@ snapshots: react: 19.2.3 react-dom: 19.2.3(react@19.2.3) - mrmime@2.0.1: {} + mrmime@2.0.1: + optional: true ms@2.1.3: {} @@ -9790,9 +9779,11 @@ snapshots: path-key@3.1.1: {} - pathe@2.0.3: {} + pathe@2.0.3: + optional: true - pathval@2.0.1: {} + pathval@2.0.1: + optional: true peberminta@0.9.0: {} @@ -10111,6 +10102,7 @@ snapshots: '@rollup/rollup-win32-x64-gnu': 4.54.0 '@rollup/rollup-win32-x64-msvc': 4.54.0 fsevents: 2.3.3 + optional: true rou3@0.7.12: {} @@ -10191,13 +10183,15 @@ snapshots: shebang-regex@3.0.0: {} - siginfo@2.0.0: {} + siginfo@2.0.0: + optional: true sirv@3.0.2: dependencies: '@polka/url': 1.0.0-next.29 mrmime: 2.0.1 totalist: 3.0.1 + optional: true sonic-boom@4.2.0: dependencies: @@ -10219,9 +10213,11 @@ snapshots: split2@4.2.0: {} - stackback@0.0.2: {} + stackback@0.0.2: + optional: true - std-env@3.10.0: {} + std-env@3.10.0: + optional: true string-width@4.2.3: dependencies: @@ -10242,6 +10238,7 @@ snapshots: strip-literal@3.1.0: dependencies: js-tokens: 9.0.1 + optional: true strnum@2.1.2: {} @@ -10287,22 +10284,28 @@ snapshots: tiny-invariant@1.3.3: {} - tinybench@2.9.0: {} + tinybench@2.9.0: + optional: true - tinyexec@0.3.2: {} + tinyexec@0.3.2: + optional: true tinyglobby@0.2.15: dependencies: fdir: 6.5.0(picomatch@4.0.3) picomatch: 4.0.3 - tinypool@1.1.1: {} + tinypool@1.1.1: + optional: true - tinyrainbow@2.0.0: {} + tinyrainbow@2.0.0: + optional: true - tinyspy@4.0.4: {} + tinyspy@4.0.4: + optional: true - totalist@3.0.1: {} + totalist@3.0.1: + optional: true tr46@0.0.3: {} @@ -10412,6 +10415,7 @@ snapshots: - terser - tsx - yaml + optional: true vite@7.3.0(@types/node@22.19.3)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: @@ -10426,6 +10430,7 @@ snapshots: fsevents: 2.3.3 jiti: 2.6.1 lightningcss: 1.30.2 + optional: true vitest@3.2.4(@types/debug@4.1.12)(@types/node@22.19.3)(@vitest/ui@3.2.4)(jiti@2.6.1)(lightningcss@1.30.2): dependencies: @@ -10469,6 +10474,7 @@ snapshots: - terser - tsx - yaml + optional: true wasm-feature-detect@1.8.0: {} @@ -10491,6 +10497,7 @@ snapshots: dependencies: siginfo: 2.0.0 stackback: 0.0.2 + optional: true word-wrap@1.2.5: {} diff --git a/src/app/(root)/(dashboard)/meeting/[id]/page.tsx b/src/app/(root)/(dashboard)/meeting/[id]/page.tsx index 049db35..caee6b8 100644 --- a/src/app/(root)/(dashboard)/meeting/[id]/page.tsx +++ b/src/app/(root)/(dashboard)/meeting/[id]/page.tsx @@ -8,8 +8,7 @@ import { Loader } from "lucide-react"; import { useGetCallById } from "@/hooks/useGetCallById"; import Alert from "@/components/Alert"; -import MeetingSetup from "@/components/MeetingSetup"; -import MeetingRoom from "@/components/MeetingRoom"; +import { MeetingRoom, MeetingSetup } from "@/components/workspace/meeting"; const MeetingPage = () => { const { id } = useParams(); diff --git a/src/app/(root)/(home)/_components/Navbar.tsx b/src/app/(root)/(home)/_components/Navbar.tsx index 443d274..217410a 100644 --- a/src/app/(root)/(home)/_components/Navbar.tsx +++ b/src/app/(root)/(home)/_components/Navbar.tsx @@ -2,7 +2,7 @@ import { ThemeToggle } from "@/components/navigation/theme-toggle"; import { homeTabs } from "@/constants"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import Link from "next/link"; import { Button } from "@/components/ui/button"; import { Avatar, AvatarFallback, AvatarImage } from "@/components/ui/avatar"; diff --git a/src/app/not-found.tsx b/src/app/(root)/not-found.tsx similarity index 100% rename from src/app/not-found.tsx rename to src/app/(root)/not-found.tsx diff --git a/src/app/api/heatlh/route.ts b/src/app/api/heatlh/route.ts new file mode 100644 index 0000000..1629640 --- /dev/null +++ b/src/app/api/heatlh/route.ts @@ -0,0 +1,16 @@ +import { APIResponse } from "@/types"; + +type HealthStatus = "OK" | "UNHEALTHY"; + +export const GET = async (): Promise> => { + try { + const status: HealthStatus = "OK"; + return { success: true, data: status }; + } catch (error: unknown) { + return { + success: false, + error: + error instanceof Error ? error.message : "An unknown error occurred", + }; + } +}; diff --git a/src/constants/sidebar-items.ts b/src/constants/sidebar-items.ts deleted file mode 100644 index bf3299d..0000000 --- a/src/constants/sidebar-items.ts +++ /dev/null @@ -1,75 +0,0 @@ -import { - LayoutDashboard, - BarChart3, - FolderOpen, - Settings, - HelpCircle, - Search, - FileText, - PieChart, - Zap, - type LucideIcon, -} from "lucide-react"; - -export interface NavItem { - title: string; - url: string; - icon: LucideIcon; - badge?: string; - items?: { - title: string; - url: string; - }[]; -} - -export interface SidebarData { - user?: { - name: string; - email: string; - avatar: string; - }; - navMain: NavItem[]; - navSecondary: NavItem[]; -} - -export const data: SidebarData = { - user: { - name: "John Doe", - email: "john@example.com", - avatar: "https://api.dicebear.com/7.x/avataaars/svg?seed=John", - }, - navMain: [ - { - title: "Dashboard", - url: "/dashboard", - icon: LayoutDashboard, - }, - { - title: "Analytics", - url: "/analytics", - icon: BarChart3, - }, - { - title: "Projects", - url: "/projects", - icon: FolderOpen, - }, - ], - navSecondary: [ - { - title: "Settings", - url: "/settings", - icon: Settings, - }, - { - title: "Help", - url: "/help", - icon: HelpCircle, - }, - { - title: "Search", - url: "/search", - icon: Search, - }, - ], -}; diff --git a/src/constants/sidebar.json b/src/constants/sidebar.json deleted file mode 100644 index d373ac5..0000000 --- a/src/constants/sidebar.json +++ /dev/null @@ -1,52 +0,0 @@ -[ - { - "id": "workspace-alpha", - "name": "Workspace Alpha", - "route": "/dashboard/workspace/workspace-alpha" - }, - { - "id": "workspace-beta", - "name": "Workspace Beta", - "route": "/dashboard/workspace/workspace-beta" - }, - { - "id": "workspace-gamma", - "name": "Workspace Gamma", - "route": "/dashboard/workspace/workspace-gamma" - }, - { - "id": "workspace-delta", - "name": "Workspace Delta", - "route": "/dashboard/workspace/workspace-delta" - }, - { - "id": "workspace-epsilon", - "name": "Workspace Epsilon", - "route": "/dashboard/workspace/workspace-epsilon" - }, - { - "id": "workspace-zeta", - "name": "Workspace Zeta", - "route": "/dashboard/workspace/workspace-zeta" - }, - { - "id": "workspace-eta", - "name": "Workspace Eta", - "route": "/dashboard/workspace/workspace-eta" - }, - { - "id": "workspace-theta", - "name": "Workspace Theta", - "route": "/dashboard/workspace/workspace-theta" - }, - { - "id": "workspace-iota", - "name": "Workspace Iota", - "route": "/dashboard/workspace/workspace-iota" - }, - { - "id": "workspace-kappa", - "name": "Workspace Kappa", - "route": "/dashboard/workspace/workspace-kappa" - } -] From edd5fd66ffff6d0dc7f5dfe5833f0bef63b0875b Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:05:54 +0530 Subject: [PATCH 03/13] Implement the members table. --- src/action/meeting.action.ts | 46 ++++++++++++- .../(members)/workspace-details/page.tsx | 12 +++- src/app/api/workspace/[slug]/members/route.ts | 65 +++++++++++++++++++ src/app/api/workspace/[workspaceId]/route.ts | 12 ---- 4 files changed, 119 insertions(+), 16 deletions(-) create mode 100644 src/app/api/workspace/[slug]/members/route.ts delete mode 100644 src/app/api/workspace/[workspaceId]/route.ts diff --git a/src/action/meeting.action.ts b/src/action/meeting.action.ts index 541d806..d241fb1 100644 --- a/src/action/meeting.action.ts +++ b/src/action/meeting.action.ts @@ -1,6 +1,50 @@ "use server"; import { db } from "@/db/client"; -import { workspacesTable, workspaceMeetingTable } from "@/db/schema/schema"; +import { + workspacesTable, + workspaceMeetingTable, + membersTable, +} from "@/db/schema/schema"; +import { APIResponse, Call } from "@/types"; +import { eq, and } from "drizzle-orm"; + +export async function getCallsBySlug( + slug: string +): Promise> { + try { + const [workspace] = await db + .select() + .from(workspacesTable) + .where(eq(workspacesTable.slug, slug)) + .execute(); + + if (!workspace) { + return { + error: `Workspace with slug ${slug} not found`, + success: false, + }; + } + + const meeting = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.workspaceId, workspace.id)) + .execute(); + + if (!meeting) + return { + error: `No meeting found for workspace with slug ${slug}`, + success: false, + }; + + return { + data: meeting, + success: true, + }; + } catch (error: unknown) { + return { success: false, error: `Failed to get call by slug: ${error}` }; + } +} export async function getMeetingsData() { try { diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx index 79601ad..8ccf4ae 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-details/page.tsx @@ -13,12 +13,14 @@ import { auth } from "@/lib/auth-config"; import { headers } from "next/headers"; import { getCurrentUser } from "@/lib/dal"; import { getMember } from "@/action/member.action"; -import { InviteMemberDialog } from "@/components/workspace/InviteMemberDialog"; +import { InviteMemberDialog } from "@/components/workspace/members/InviteMemberDialog"; +import { TOrganizationMember } from "@/types"; +import MembersTable from "@/components/workspace/meeting/charts/members-table"; export default async function OrgDetailsPage({ params, }: { - params: { slug: string }; + params: Promise<{ slug: string }>; }) { const { slug } = await params; @@ -40,10 +42,12 @@ export default async function OrgDetailsPage({ const orgMember = await getMember(slug, user.id); - if (!orgMember) { + if (!orgMember || !orgMember.success || !orgMember?.data) { throw new Error("Organization member not found"); } + const role = orgMember.data as TOrganizationMember; + const workspace = { ...activeOrg, member: orgMember }; return ( @@ -131,6 +135,8 @@ export default async function OrgDetailsPage({
+ + ); } diff --git a/src/app/api/workspace/[slug]/members/route.ts b/src/app/api/workspace/[slug]/members/route.ts new file mode 100644 index 0000000..86c46b6 --- /dev/null +++ b/src/app/api/workspace/[slug]/members/route.ts @@ -0,0 +1,65 @@ +import { NextRequest, NextResponse } from "next/server"; +import { db } from "@/db/client"; +import { membersTable, usersTable } from "@/db/schema/schema"; +import { account, session } from "@/db/schema/auth-schema"; +import { eq } from "drizzle-orm"; +import { auth } from "@/lib/auth-config"; +import { headers } from "next/headers"; +import { TUser, TWorkspaceMembersTableRow, TWorkspaceUser } from "@/types"; + +export const GET = async ( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) => { + try { + const { slug } = await params; + + if (!slug) { + return NextResponse.json( + { success: false, error: "Missing slug" }, + { status: 400 } + ); + } + + const workspace = await auth.api.getFullOrganization({ + query: { + organizationSlug: slug, + }, + headers: await headers(), + }); + + if (!workspace) { + return NextResponse.json( + { success: false, error: "Workspace not found" }, + { status: 404 } + ); + } + + // Fetch members from DB with user details, as well as their accounts and sessions + const rows = await db + .select({ member: membersTable, user: usersTable }) + .from(membersTable) + .leftJoin(usersTable, eq(membersTable.userId, usersTable.id)) + .where(eq(membersTable.workspaceId, workspace.id)); + + const members: TWorkspaceMembersTableRow = rows.map(({ member, user }) => ({ + id: member.id, + userId: member.userId, + workspaceId: member.workspaceId, + role: member.role, + createdAt: member.createdAt, + updatedAt: member.updatedAt, + name: user?.name || "", + email: user?.email || "", + emailVerified: user?.emailVerified || false, + userName: user?.userName || "", + })); + + return NextResponse.json({ success: true, data: members }); + } catch (error) { + return NextResponse.json( + { success: false, error: "Server error" }, + { status: 500 } + ); + } +}; diff --git a/src/app/api/workspace/[workspaceId]/route.ts b/src/app/api/workspace/[workspaceId]/route.ts deleted file mode 100644 index 31f4f8b..0000000 --- a/src/app/api/workspace/[workspaceId]/route.ts +++ /dev/null @@ -1,12 +0,0 @@ -import { NextResponse } from "next/server"; -import { getWorkspaceById } from "@/action/workspace.action"; - -export async function GET( - _: Request, - { params }: { params: Promise<{ workspaceId: string }> }, -) { - const { workspaceId } = await params; - const res = await getWorkspaceById(workspaceId); - if (res?.data) return NextResponse.json(res.data); - return NextResponse.json({ error: "Workspace not found" }, { status: 404 }); -} From 6b87a253ce4738b7deacf313a3681aa0b37188b5 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:12:22 +0530 Subject: [PATCH 04/13] Refactor admin routes. --- .../workspace/[slug]/(members)/user/page.tsx | 7 ------- .../workspace/[slug]/@navbar/default.tsx | 4 ++-- .../workspace/[slug]/@navbar/page.tsx | 4 ++-- .../{(members) => admin}/recordings/page.tsx | 0 .../_components/danger-zone.tsx | 0 .../_components/org-settings-form.tsx | 0 src/db/schema/schema.ts | 5 +++++ src/types/api.ts | 18 +++++++++++++++++- 8 files changed, 26 insertions(+), 12 deletions(-) rename src/app/(root)/(dashboard)/workspace/[slug]/{(members) => admin}/recordings/page.tsx (100%) rename src/app/(root)/(dashboard)/workspace/[slug]/{(members) => admin}/workspace-settings/_components/danger-zone.tsx (100%) rename src/app/(root)/(dashboard)/workspace/[slug]/{(members) => admin}/workspace-settings/_components/org-settings-form.tsx (100%) diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx index 88c7374..a25bdcf 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx @@ -3,9 +3,6 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; import Image from "next/image"; import Usercall from "./_components/usercall"; -import { WeeklyMeetingsChart } from "@/components/charts/WeeklyMeetingsChart"; -import { DailyMeetingsChart } from "@/components/charts/DailyMeetingsChart"; - const User = async () => { const session = await auth.api.getSession({ headers: await headers(), @@ -29,10 +26,6 @@ const User = async () => {

{user?.email}

-
- - -
diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx index 34be9e5..cb14c3b 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/default.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import { useListOrganizations, useActiveOrganization, @@ -23,7 +23,7 @@ import { } from "@/components/ui/breadcrumb"; import { sidebarLinks } from "@/constants/component"; import { IconSlash } from "@tabler/icons-react"; -import OrgSwitcher from "@/components/org-switcher"; +import OrgSwitcher from "@/components/workspace/org-switcher"; export default function Navbar() { const params = useParams<{ slug: string }>(); diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx index 34be9e5..cb14c3b 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/@navbar/page.tsx @@ -3,7 +3,7 @@ import { useEffect, useMemo } from "react"; import Link from "next/link"; import { useParams, usePathname } from "next/navigation"; -import Logo from "@/components/Logo"; +import Logo from "@/components/navigation/Logo"; import { useListOrganizations, useActiveOrganization, @@ -23,7 +23,7 @@ import { } from "@/components/ui/breadcrumb"; import { sidebarLinks } from "@/constants/component"; import { IconSlash } from "@tabler/icons-react"; -import OrgSwitcher from "@/components/org-switcher"; +import OrgSwitcher from "@/components/workspace/org-switcher"; export default function Navbar() { const params = useParams<{ slug: string }>(); diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/recordings/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/recordings/page.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/recordings/page.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/recordings/page.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/danger-zone.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/danger-zone.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/danger-zone.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/danger-zone.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/org-settings-form.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/org-settings-form.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/_components/org-settings-form.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/_components/org-settings-form.tsx diff --git a/src/db/schema/schema.ts b/src/db/schema/schema.ts index a520b05..7aae3ef 100644 --- a/src/db/schema/schema.ts +++ b/src/db/schema/schema.ts @@ -182,6 +182,9 @@ export const SelectUserSchema = createSelectSchema(usersTable, { email: z.string().regex(/^[^\s@]+@[^\s@]+\.[^\s@]+$/), }); +export const SelectCallSchema = createSelectSchema(workspaceMeetingTable); +export const SelectCallMember = createSelectSchema(membersTable); + export type CreateMeetingType = typeof workspaceMeetingTable.$inferInsert; export type CreateWorkspaceType = typeof workspacesTable.$inferInsert; export type CreateMemberType = typeof membersTable.$inferInsert; @@ -190,3 +193,5 @@ export type SelectMemberType = typeof membersTable.$inferSelect; export type SelectMeetingType = typeof workspaceMeetingTable.$inferSelect; export type SelectWorkspaceType = typeof workspacesTable.$inferSelect; export type SelectUserType = typeof usersTable.$inferSelect; +export type SelectParticipantType = + typeof meetingParticipantsTable.$inferSelect; diff --git a/src/types/api.ts b/src/types/api.ts index d877e9c..d926f48 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,4 +1,4 @@ -import type { CreateUserType } from "@/db/schema/schema"; +import type { CreateUserType, CreateMemberType } from "@/db/schema/schema"; export type APIResponse = { success: boolean; @@ -6,8 +6,20 @@ export type APIResponse = { error?: string; }; +export type APISuccessResponse = { + success: true; + data: T; +}; + +type APIErrorResponse = { + success: false; + error: string; +}; + export type UserResponse = Omit; +export type TUser = CreateUserType; + export type WorkspaceResponse = { id: string; name: string; @@ -45,3 +57,7 @@ export type AnalyticsResponse = { totalWorkspaces: number; createdAt: Date; }; + +export type TWorkspaceUser = CreateMemberType; + +export type TWorkspaceMembersTableRow = (TUser & TWorkspaceUser)[]; From 308b173c5bdbf6db21e043870179339e07a3f12b Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:15:29 +0530 Subject: [PATCH 05/13] Add organization settings page and permission checks for admin roles. --- .../workspace-settings/page.tsx | 13 +- .../(dashboard)/workspace/[slug]/page.tsx | 4 +- src/lib/auth-client.ts | 132 +++++++++++++++++- src/lib/form/new-workspace-form.tsx | 41 +++++- src/lib/permission.ts | 20 +-- 5 files changed, 190 insertions(+), 20 deletions(-) rename src/app/(root)/(dashboard)/workspace/[slug]/{(members) => admin}/workspace-settings/page.tsx (91%) diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx similarity index 91% rename from src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx rename to src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx index e3803c0..3171172 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/workspace-settings/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/workspace-settings/page.tsx @@ -12,11 +12,13 @@ import { headers } from "next/headers"; import { getCurrentUser } from "@/lib/dal"; import { getMember } from "@/action/member.action"; import { redirect } from "next/navigation"; +import { + canRoleDeleteOrganization, + canRoleUpdateOrganization, +} from "@/lib/auth-client"; import { OrgSettingsForm } from "./_components/org-settings-form"; import { DangerZone } from "./_components/danger-zone"; -type OrgMemberRole = "owner" | "admin" | "member"; - export default async function OrgSettingsPage({ params, }: { @@ -46,15 +48,16 @@ export default async function OrgSettingsPage({ redirect(`/workspace/${slug}`); } - const userRole = orgMember.data.role as OrgMemberRole; + const userRole = orgMember.data.role; // Only allow owner and admin to access this page if (userRole === "member") { redirect(`/workspace/${slug}/org-details`); } - const canDelete = userRole === "owner"; - const canUpdate = userRole === "owner" || userRole === "admin"; + // Check permissions using utility functions + const canDelete = canRoleDeleteOrganization(userRole); + const canUpdate = canRoleUpdateOrganization(userRole); return (
diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx index 9c64e38..0f6f151 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/page.tsx @@ -1,4 +1,4 @@ -import MeetingTypeList from "@/components/MeetingTypeList"; +import MeetingTypeList from "@/components/workspace/meeting/MeetingTypeList"; import ProfileCard from "@/components/ProfileCard"; import { checkWorkspaceAccess } from "@/lib/workspace-auth"; @@ -12,7 +12,7 @@ const Home = async ({ params }: { params: Promise<{ slug: string }> }) => { minute: "2-digit", }); const date = new Intl.DateTimeFormat("en-IN", { dateStyle: "full" }).format( - now, + now ); return ( diff --git a/src/lib/auth-client.ts b/src/lib/auth-client.ts index 63f2480..00ec240 100644 --- a/src/lib/auth-client.ts +++ b/src/lib/auth-client.ts @@ -45,6 +45,42 @@ export type AuthSession = typeof authClient.$Infer.Session; */ export function canRoleUpdateWorkspace( role: "owner" | "admin" | "member" +): boolean { + return authClient.organization.checkRolePermission({ + permissions: { organization: ["create"] }, + role, + }); +} + +/** + * Check if a role can delete an organization (synchronous, client-side only) + */ +export function canRoleDeleteOrganization( + role: "owner" | "admin" | "member" +): boolean { + return authClient.organization.checkRolePermission({ + permissions: { organization: ["delete"] }, + role, + }); +} + +/** + * Check if a role can view an organization (synchronous, client-side only) + */ +export function canRoleViewOrganization( + role: "owner" | "admin" | "member" +): boolean { + return authClient.organization.checkRolePermission({ + permissions: { organization: ["view"] }, + role, + }); +} + +/** + * Check if a role can update an organization (synchronous, client-side only) + */ +export function canRoleUpdateOrganization( + role: "owner" | "admin" | "member" ): boolean { return authClient.organization.checkRolePermission({ permissions: { organization: ["update"] }, @@ -53,17 +89,44 @@ export function canRoleUpdateWorkspace( } /** - * Check if a role can delete workspace (synchronous, client-side only) + * Deprecated: Use canRoleDeleteOrganization instead + * @deprecated */ export function canRoleDeleteWorkspace( role: "owner" | "admin" | "member" +): boolean { + return canRoleDeleteOrganization(role); +} + +/** + * Deprecated: Use canRoleCreateOrganization instead + * @deprecated + */ +export function canRoleCreateWorkspace( + role: "owner" | "admin" | "member" +): boolean { + return canRoleCreateOrganization(role); +} + +export function canRoleCreateOrganization( + role: "owner" | "admin" | "member" ): boolean { return authClient.organization.checkRolePermission({ - permissions: { organization: ["delete"] }, + permissions: { organization: ["create"] }, role, }); } +/** + * Deprecated: Use canRoleViewOrganization instead + * @deprecated + */ +export function canRoleViewWorkspace( + role: "owner" | "admin" | "member" +): boolean { + return canRoleViewOrganization(role); +} + /** * Check if a role can manage members (synchronous, client-side only) */ @@ -88,6 +151,71 @@ export function canRoleInviteMembers( }); } +class Permission { + /** + * Synchronous permission check - client-side only + * Use this for quick checks based on role without server calls + */ + static checkPermissionSync( + role: "owner" | "admin" | "member", + permissions: Record + ): boolean { + return authClient.organization.checkRolePermission({ + permissions, + role, + }); + } + + private static canRoleCreateOrganization( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { organization: ["create"] }); + } + + private static canRoleDeleteOrganization( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { organization: ["delete"] }); + } + + private static canRoleViewOrganization( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { organization: ["view"] }); + } + + private static canRoleUpdateOrganization( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { organization: ["update"] }); + } + + private static canRoleManageMembers( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { + member: ["create", "update", "delete"], + }); + } + + private static canRoleInviteMembers( + role: "owner" | "admin" | "member" + ): boolean { + return Permission.checkPermissionSync(role, { invitation: ["create"] }); + } + + public static get Role() { + return { + canCreateOrganization: this.canRoleCreateOrganization, + canDeleteOrganization: this.canRoleDeleteOrganization, + canViewOrganization: this.canRoleViewOrganization, + canUpdateOrganization: this.canRoleUpdateOrganization, + canManageMembers: this.canRoleManageMembers, + canInviteMembers: this.canRoleInviteMembers, + }; + } +} + /** * Async permission check - hits the server for accurate permission check * Use this when you need to validate permissions before an action diff --git a/src/lib/form/new-workspace-form.tsx b/src/lib/form/new-workspace-form.tsx index 38dd9b5..d64e5b6 100644 --- a/src/lib/form/new-workspace-form.tsx +++ b/src/lib/form/new-workspace-form.tsx @@ -5,6 +5,11 @@ import { useForm } from "@tanstack/react-form"; import { useRouter } from "next/navigation"; import { toast } from "sonner"; import { createWorkspace } from "@/action"; +import { + canRoleCreateOrganization, + useListOrganizations, + useSession, +} from "@/lib/auth-client"; import { Field, FieldDescription, @@ -15,12 +20,22 @@ import { import { Input } from "@/components/ui/input"; import { IconLink, IconLoader2, IconBriefcase } from "@tabler/icons-react"; import { Button } from "@/components/ui/button"; -import { NewWorkspaceFormSchema } from "@/types"; +import { NewWorkspaceFormSchema, TUserRole } from "@/types"; const NewWorkspaceForm = () => { const router = useRouter(); + const { data: session } = useSession(); + const { data: organizations } = useListOrganizations(); + const orgMember = organizations?.find( + (org) => org.id === organizations[0]?.id + ); + const [isPending, setPending] = useState(false); + const userRole = orgMember?.role as TUserRole; + + const canCreateWorkspace = canRoleCreateOrganization(userRole); + const form = useForm({ defaultValues: { name: "", @@ -43,13 +58,33 @@ const NewWorkspaceForm = () => { toast.success("Workspace created successfully"); router.push(`/workspace/${result.data.slug}`); } catch (error: unknown) { - toast.error("An unexpected error occurred. Please try again."); - console.error("Login error:", error); + // Handle permission denied errors + if (error instanceof Error && error.message.includes("permission")) { + toast.error( + "You don't have permission to create a workspace. Contact your admin." + ); + } else { + toast.error("An unexpected error occurred. Please try again."); + } + console.error("Workspace creation error:", error); } finally { setPending(false); } }, }); + if (!canCreateWorkspace) { + return ( +
+

+ You don't have permission to create a workspace. +

+

+ Contact your organization admin to request workspace creation access. +

+
+ ); + } + return (
{ diff --git a/src/lib/permission.ts b/src/lib/permission.ts index 5753f1f..3916ae5 100644 --- a/src/lib/permission.ts +++ b/src/lib/permission.ts @@ -22,10 +22,11 @@ import { */ const statement = { ...defaultStatements, - // Workspace-specific permissions (maps to organization in better-auth) - workspace: ["update", "delete"], + // Organization/Workspace permissions (maps to organization in better-auth) + // Actions: create, delete, update, view + organization: ["create", "delete", "update", "view"], // Meeting permissions - meeting: ["create", "update", "delete"], + meeting: ["create", "update", "delete", "view", "stats"], } as const; /** @@ -45,21 +46,24 @@ export const ac = createAccessControl(statement); // Owner: Full control over everything export const owner = ac.newRole({ ...ownerAc.statements, - workspace: ["update", "delete"], + organization: ["create", "delete", "update", "view"], meeting: ["create", "update", "delete"], }); -// Admin: Full control except deleting workspace +// Admin: Full control except deleting organization export const admin = ac.newRole({ ...adminAc.statements, - workspace: ["update"], + // Admins can create, update, and view organizations but cannot delete + organization: ["create", "update", "view"], meeting: ["create", "update", "delete"], }); -// Member: Limited permissions - can only create meetings +// Member: Limited permissions - can view organizations and create/manage meetings export const member = ac.newRole({ ...memberAc.statements, - meeting: ["create"], + // Members can create and view organizations but cannot delete or update + organization: ["create", "view"], + meeting: ["create", "update", "delete"], }); /** From e753c99fbb4b6bdf9b987103013cedbb5636dd70 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:18:24 +0530 Subject: [PATCH 06/13] Add admin dashboard components for meeting analytics and member management. --- .../_components/month-meeting-chart.tsx | 125 +++++++++ .../_components/weekly-meeting-chart.tsx | 129 ++++++++++ .../workspace/[slug]/admin/dashboard/page.tsx | 35 +++ src/app/(root)/(dashboard)/workspace/page.tsx | 49 ++-- .../meeting/charts/members-table.tsx | 238 ++++++++++++++++++ src/hooks/useGetCallsBySlug.ts | 31 +++ src/types/action.ts | 11 +- src/types/database.ts | 35 +-- 8 files changed, 602 insertions(+), 51 deletions(-) create mode 100644 src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx create mode 100644 src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx create mode 100644 src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx create mode 100644 src/components/workspace/meeting/charts/members-table.tsx create mode 100644 src/hooks/useGetCallsBySlug.ts diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx new file mode 100644 index 0000000..bc8b407 --- /dev/null +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx @@ -0,0 +1,125 @@ +"use client"; + +import Loader from "@/components/Loader"; +import { useGetCallsBySlug } from "@/hooks/useGetCallsBySlug"; +import { useMemo } from "react"; +import { + BarChart, + Bar, + XAxis, + YAxis, + CartesianGrid, + Tooltip, + ResponsiveContainer, +} from "recharts"; + +type Props = { + slug: string; +}; + +const MonthlyMeetingChart = (props: Props) => { + const { data, isPending } = useGetCallsBySlug(props.slug); + + // Bucket meetings into weeks of the current month + const { chartData, totalMeetings, maxMeetings, monthLabel } = useMemo(() => { + const now = new Date(); + const month = now.getMonth(); + const year = now.getFullYear(); + + // 5 buckets: Week 1 (1-7), Week 2 (8-14), Week 3 (15-21), Week 4 (22-28), Week 5 (29-end) + const buckets = [0, 0, 0, 0, 0]; + + if (data && data.length > 0) { + data.forEach((meeting: any) => { + const date = new Date(meeting.createdAt || meeting.startTime); + if (date.getFullYear() === year && date.getMonth() === month) { + const weekIndex = Math.floor((date.getDate() - 1) / 7); // 0..4 + buckets[weekIndex] = (buckets[weekIndex] || 0) + 1; + } + }); + } + + const chartData = buckets.map((count, idx) => ({ + week: `Week ${idx + 1}`, + meetings: count, + })); + + const totalMeetings = buckets.reduce((s, v) => s + v, 0); + const maxMeetings = Math.max(...buckets, 1); + const monthLabel = now.toLocaleString("default", { + month: "long", + year: "numeric", + }); + + return { chartData, totalMeetings, maxMeetings, monthLabel }; + }, [data]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!chartData || chartData.length === 0 || totalMeetings === 0) { + return ( +
+

No meetings this month

+

+ Meetings in {monthLabel} will appear once created +

+
+ ); + } + + return ( +
+
+

Meetings — {monthLabel}

+

+ Total this month: {totalMeetings} meetings +

+
+ +
+ + + + + + [`${value} meetings`, "Count"]} + contentStyle={{ backgroundColor: "var(--color-chart-1)" }} + itemStyle={{ color: "var(--color-primary)" }} + wrapperStyle={{ borderRadius: 6 }} + /> + + + +
+
+ ); +}; + +export default MonthlyMeetingChart; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx new file mode 100644 index 0000000..49c9028 --- /dev/null +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx @@ -0,0 +1,129 @@ +"use client"; + +import { useGetCallsBySlug } from "@/hooks/useGetCallsBySlug"; +import { useMemo } from "react"; +import { + PieChart, + Pie, + Cell, + Legend, + Tooltip, + ResponsiveContainer, +} from "recharts"; +import Loader from "@/components/Loader"; +import { dayNames } from "@/constants"; + +const COLORS = [ + "#3b82f6", + "#10b981", + "#f59e0b", + "#ef4444", + "#8b5cf6", + "#ec4899", + "#14b8a6", +]; + +const WeeklyMeetingsChart = ({ slug }: { slug: string }) => { + const { data, isPending } = useGetCallsBySlug(slug); + + // Process data to show meetings by day of week + const chartData = useMemo(() => { + if (!data || data.length === 0) { + return []; + } + + const meetingsByDay: Record = { + Sunday: 0, + Monday: 0, + Tuesday: 0, + Wednesday: 0, + Thursday: 0, + Friday: 0, + Saturday: 0, + }; + + // Count meetings by day of week + data.forEach((meeting: any) => { + const date = new Date(meeting.createdAt || meeting.startTime); + const dayName = dayNames[date.getDay()]; + meetingsByDay[dayName]++; + }); + + // Convert to chart format and filter out empty days + return Object.entries(meetingsByDay) + .filter(([_, count]) => count > 0) + .map(([day, count]) => ({ + name: day, + value: count, + })); + }, [data]); + + if (isPending) { + return ( +
+ +
+ ); + } + + if (!chartData || chartData.length === 0) { + return ( +
+

No meetings yet

+

Meetings will appear here once created

+
+ ); + } + + const totalMeetings = chartData.reduce((sum, item) => sum + item.value, 0); + + return ( +
+
+

Meetings by Day

+

+ Total this week: {totalMeetings} meetings +

+
+ + + + `${name}: ${value} (${(percent * 100).toFixed(0)}%)` + } + outerRadius={80} + fill="#8884d8" + dataKey="value" + > + {chartData.map((_, index) => ( + + ))} + + [`${value} meetings`, "Count"]} + contentStyle={{ + backgroundColor: "rgba(0, 0, 0, 0.8)", + border: "1px solid rgba(255, 255, 255, 0.2)", + borderRadius: "8px", + color: "#fff", + }} + /> + + + +
+ ); +}; + +export default WeeklyMeetingsChart; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx new file mode 100644 index 0000000..1e52f07 --- /dev/null +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx @@ -0,0 +1,35 @@ +import WeeklyMeetingsChart from "./_components/weekly-meeting-chart"; +import { DailyMeetingsChart } from "@/components/workspace/admin/charts/DailyMeetingsChart"; + +const AdminDashboardPage = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { + const { slug } = await params; + + return ( +
+
+

Admin Dashboard

+
+ +

+ This is the admin dashboard where workspace admins can manage settings, + view analytics, and oversee user activities. +

+ +
+
+ +
+ +
+ +
+
+
+ ); +}; + +export default AdminDashboardPage; diff --git a/src/app/(root)/(dashboard)/workspace/page.tsx b/src/app/(root)/(dashboard)/workspace/page.tsx index f0e80ba..abff4d3 100644 --- a/src/app/(root)/(dashboard)/workspace/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/page.tsx @@ -3,6 +3,14 @@ import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; import { Card, CardContent, CardHeader, CardTitle } from "@/components/ui/card"; import Link from "next/link"; +import { + Empty, + EmptyDescription, + EmptyHeader, + EmptyMedia, + EmptyTitle, +} from "@/components/ui/empty"; +import { IconBuildingSkyscraper } from "@tabler/icons-react"; const WorkspacePage = async () => { const session = await auth.api.getSession({ @@ -17,8 +25,11 @@ const WorkspacePage = async () => { headers: await headers(), }); - if (org.length === 0) { - return ( + return ( +
+ {/* */} +

Your Workspace

+ {/* {session?.session?.activeOrganizationId} */}

Your Workspace

{ Create Workspace
- ); - } - - return ( -
- {/* */} -

Your Workspace

- {/* {session?.session?.activeOrganizationId} */} -
- {org.length > 0 && + {org.length > 0 ? ( org.map((org) => ( - // {

{org.logo}

- - + +

{org.slug}

+
- // - ))} + )) + ) : ( + + + No Workspaces Found + + + + + + You have not created any workspaces yet. Get started by creating a + new workspace. + + + )}
); diff --git a/src/components/workspace/meeting/charts/members-table.tsx b/src/components/workspace/meeting/charts/members-table.tsx new file mode 100644 index 0000000..d26e086 --- /dev/null +++ b/src/components/workspace/meeting/charts/members-table.tsx @@ -0,0 +1,238 @@ +"use client"; + +import { useEffect, useMemo, useState } from "react"; +import { + ColumnDef, + PaginationState, + flexRender, + getCoreRowModel, + getFilteredRowModel, + getPaginationRowModel, + getSortedRowModel, + useReactTable, +} from "@tanstack/react-table"; +import { TUser, TWorkspaceMembersTableRow, TWorkspaceUser } from "@/types"; +import Loader from "@/components/Loader"; + +type Props = { + workspaceSlug: string; +}; + +const MembersTable = ({ workspaceSlug }: Props) => { + const [data, setData] = useState([]); + const [loading, setLoading] = useState(true); + const [error, setError] = useState(null); + + const [pagination, setPagination] = useState({ + pageIndex: 0, + pageSize: 10, + }); + + useEffect(() => { + let mounted = true; + + async function fetchMembers() { + setLoading(true); + setError(null); + try { + const res = await fetch(`/api/workspace/${workspaceSlug}/members`); + console.log("Fetch members response: \n", res); + const json = await res.json(); + if (!json || !json.success) { + setError(json?.error || "Failed to load members"); + setData([]); + } else { + setData(json.data || []); + } + } catch (err) { + setError(String(err)); + setData([]); + } finally { + if (mounted) setLoading(false); + } + } + + fetchMembers(); + + return () => { + mounted = false; + }; + }, [workspaceSlug]); + + const columns = useMemo[]>( + () => [ + { + accessorKey: "id", + header: "ID", + cell: (info) => info.getValue(), + }, + { + accessorKey: "name", + header: "Name", + cell: (info) => info.getValue(), + }, + { + accessorKey: "username", + header: "Username", + cell: (info) => info.getValue(), + }, + { + accessorKey: "email", + header: "Email", + cell: (info) => info.getValue(), + }, + { + accessorKey: "role", + header: "Role", + cell: (info) => info.getValue(), + }, + { + accessorKey: "emailVerified", + header: "Email Verified", + cell: (info) => (info.getValue() ? "Yes" : "No"), + }, + ], + [] + ); + + const table = useReactTable({ + data, + columns, + state: { + pagination, + }, + onPaginationChange: setPagination, + getCoreRowModel: getCoreRowModel(), + getSortedRowModel: getSortedRowModel(), + getFilteredRowModel: getFilteredRowModel(), + getPaginationRowModel: getPaginationRowModel(), + debugTable: false, + }); + + return ( +
+ {loading ? ( + + ) : error ? ( +
{error}
+ ) : ( + <> + + + {table.getHeaderGroups().map((headerGroup) => ( + + {headerGroup.headers.map((header) => ( + + ))} + + ))} + + + {table.getRowModel().rows.length === 0 ? ( + + + + ) : ( + table.getRowModel().rows.map((row) => ( + + {row.getVisibleCells().map((cell) => ( + + ))} + + )) + )} + +
+ {flexRender( + header.column.columnDef.header, + header.getContext() + )} +
+ No members found +
+ {flexRender( + cell.column.columnDef.cell, + cell.getContext() + )} +
+ +
+ + + + + + + Page{" "} + + {table.getState().pagination.pageIndex + 1} of{" "} + {table.getPageCount()} + + + + + | Go to page:{" "} + { + const page = e.target.value ? Number(e.target.value) - 1 : 0; + table.setPageIndex(page); + }} + className="border p-1 rounded w-20 ml-2" + /> + + + + +
+ Showing {table.getRowModel().rows.length} of {data.length} members +
+
+ + )} +
+ ); +}; + +export default MembersTable; diff --git a/src/hooks/useGetCallsBySlug.ts b/src/hooks/useGetCallsBySlug.ts new file mode 100644 index 0000000..975dfb7 --- /dev/null +++ b/src/hooks/useGetCallsBySlug.ts @@ -0,0 +1,31 @@ +"use client"; + +import React, { useEffect, useState } from "react"; +import type { Call } from "@/types/action"; +import { getCallsBySlug } from "@/action"; +import { APISuccessResponse } from "@/types"; + +export const useGetCallsBySlug = (slug: string) => { + const [data, setData] = useState([]); + const [isPending, setIsPending] = useState(true); + + useEffect(() => { + const fetchCalls = async () => { + setIsPending(true); + const res = await getCallsBySlug(slug); + + if (!res || !res.success || !res.data) { + setData([]); + } + + const { data } = res as APISuccessResponse; + + setData(data); + setIsPending(false); + }; + + fetchCalls(); + }, [slug]); + + return { data, isPending }; +}; diff --git a/src/types/action.ts b/src/types/action.ts index 8be535b..c283327 100644 --- a/src/types/action.ts +++ b/src/types/action.ts @@ -1,3 +1,6 @@ +import { SelectCallSchema } from "@/db/schema/schema"; +import z from "zod"; + export type clientCall = { id: string; name: string; @@ -17,7 +20,11 @@ export interface Response { status: number; } -export type TUserRole = "owner" | "admin" | "member"; +export type TUserRole = TAdminRole | TMemberRole | TInviteMemberRole; + +export type TAdminRole = "owner" | "admin"; + +export type TMemberRole = "member"; export type TInviteMemberRole = "admin" | "member"; @@ -29,3 +36,5 @@ export interface TOrganizationMember { createdAt: Date; updatedAt: Date; } + +export type Call = z.infer; diff --git a/src/types/database.ts b/src/types/database.ts index 7adafc8..6ac545d 100644 --- a/src/types/database.ts +++ b/src/types/database.ts @@ -1,35 +1,4 @@ -export const UserRole = { - OWNER: "owner", - ADMIN: "admin", - MEMBER: "member", -} as const; - -export type UserRoleType = (typeof UserRole)[keyof typeof UserRole]; - -export interface User { - id: string; - name: string; - email: string; - userName: string; - clerkId: string; - role: UserRoleType; - workspaceId: string; - createdAt: Date; - updatedAt: Date | null; -} - -// Type guard for checking valid user roles -export const isValidUserRole = (role: string): role is UserRoleType => { - return Object.values(UserRole).includes(role as UserRoleType); -}; - -// Utility type for creating new users -export type CreateUserInput = Omit; - -// Utility type for updating users -export type UpdateUserInput = Partial< - Omit ->; +import { TUserRole } from "./action"; export interface Workspace { id: string; @@ -43,7 +12,7 @@ export interface WorkspaceUser { name: string; workspaceId: string; userId: string; - role: UserRoleType; + role: TUserRole; createdAt: Date; updatedAt: Date | null; } From 085ebb32e4d8dad13967c386d95d912e83eebf03 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:52:44 +0530 Subject: [PATCH 07/13] Update loginAction to return organization existence status in response. --- src/action/user.action.ts | 20 +++++--- src/app/(root)/auth/login/page.tsx | 6 +++ src/app/api/meeting/new/route.ts | 9 +++- .../workspace/members/MemberList.tsx | 49 ++++++++----------- 4 files changed, 47 insertions(+), 37 deletions(-) diff --git a/src/action/user.action.ts b/src/action/user.action.ts index 2ea7a26..eea3ad0 100644 --- a/src/action/user.action.ts +++ b/src/action/user.action.ts @@ -90,16 +90,20 @@ export async function loginAction(email: string, password: string) { headers: authHeaders, }); - if (data.length > 0) { - await auth.api.setActiveOrganization({ - body: { - organizationId: data[0].id, - }, - headers: authHeaders, - }); + if (data.length < 0) { + return { + data: { user: result.user, organizationExists: false }, + success: true, + }; } - return { data: result.user, success: true }; + return { + data: { + user: result.user, + organizationExists: true, + }, + success: true, + }; } catch (error: unknown) { let message = "An unknown error occurred"; diff --git a/src/app/(root)/auth/login/page.tsx b/src/app/(root)/auth/login/page.tsx index 7d4aa00..826093a 100644 --- a/src/app/(root)/auth/login/page.tsx +++ b/src/app/(root)/auth/login/page.tsx @@ -64,6 +64,12 @@ export default function LoginPage() { toast.success("Welcome back!", { description: "You have been signed in successfully.", }); + + if (result.data?.organizationExists === false) { + router.push("/organization/new"); + return; + } + router.push("/workspace"); } catch (error) { toast.error("An unexpected error occurred. Please try again."); diff --git a/src/app/api/meeting/new/route.ts b/src/app/api/meeting/new/route.ts index 666ee50..c4551c4 100644 --- a/src/app/api/meeting/new/route.ts +++ b/src/app/api/meeting/new/route.ts @@ -4,6 +4,7 @@ import { membersTable, usersTable, workspaceMeetingTable, + meetingParticipantsTable, } from "@/db/schema/schema"; import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; @@ -61,11 +62,17 @@ export async function POST(): Response { }) .returning(); + // Add the meeting host as a participant + await db.insert(meetingParticipantsTable).values({ + meetingId: meeting.meetingId, + memberId: membership.id, + }); + return NextResponse.json({ success: true, user: dbUser, meeting }); } catch (error: unknown) { return NextResponse.json( { success: false, error: (error as Error).message }, - { status: 500 }, + { status: 500 } ); } } diff --git a/src/components/workspace/members/MemberList.tsx b/src/components/workspace/members/MemberList.tsx index 8b63e05..f94bfea 100644 --- a/src/components/workspace/members/MemberList.tsx +++ b/src/components/workspace/members/MemberList.tsx @@ -1,58 +1,51 @@ "use client"; import { useState, useEffect } from "react"; -import { useWorkspaceStore } from "@/store/workspace"; import DirectCallButton from "@/components/workspace/calls/DirectCallButton"; import { Avatar, AvatarFallback } from "@/components/ui/avatar"; import { Card, CardContent } from "@/components/ui/card"; +import { + APISuccessResponse, + TWorkspaceMembersTableRow as TWorkspaceUser, +} from "@/types"; import Loader from "@/components/Loader"; -interface Member { - id: string; - name: string; - imageUrl?: string; -} - -const MemberList = () => { - const [memberDetails, setMemberDetails] = useState([]); +const MemberList = ({ organisationSlug }: { organisationSlug: string }) => { + const [members, setMembers] = useState([]); const [isLoading, setIsLoading] = useState(true); - const { members, workspaceId } = useWorkspaceStore(); - useEffect(() => { - const fetchMemberDetails = async () => { - if (!members || members.length === 0 || !workspaceId) { - setIsLoading(false); - return; - } - + const fetchMembers = async () => { try { - // Fetch detailed member information from your API - const response = await fetch(`/api/workspace/${workspaceId}/members`); - if (!response.ok) throw new Error("Failed to fetch member details"); + setIsLoading(true); + const orgMember = await fetch( + `/api/workspace/${organisationSlug}/members` + ); + const res = await orgMember.json(); - const data = await response.json(); - console.log(`Response \n`, data); - setMemberDetails(data.members); + if (res.success) { + const data = res as APISuccessResponse; + setMembers(data.data); + } } catch (error) { - console.error("Error fetching member details:", error); + console.error("Error fetching members:", error); } finally { setIsLoading(false); } }; - fetchMemberDetails(); - }, [members, workspaceId]); + fetchMembers(); + }, [organisationSlug]); if (isLoading) return ; return (
- {memberDetails.map((member) => ( + {members?.map((member) => (
- + {member.name.substring(0, 2).toUpperCase()} From 6df82948c698f5b941e66df48534070a6fe76b3c Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Mon, 29 Dec 2025 23:53:35 +0530 Subject: [PATCH 08/13] Refactor admin dashboard routes and components; update Docker Hub token in CI workflow. --- .github/workflows/publish.yaml | 2 +- .../admin/charts/WeeklyMeetingsChart.tsx | 201 +++++++++++++++++- src/constants/component.ts | 40 ++-- src/types/api.ts | 10 +- 4 files changed, 228 insertions(+), 25 deletions(-) diff --git a/.github/workflows/publish.yaml b/.github/workflows/publish.yaml index 8786eab..e398adc 100644 --- a/.github/workflows/publish.yaml +++ b/.github/workflows/publish.yaml @@ -19,7 +19,7 @@ jobs: uses: docker/login-action@v3 with: username: ${{ vars.DOCKERHUB_USERNAME }} - password: ${{ secrets.TOKEN }} + password: ${{ secrets.DOCKERHUB_TOKEN }} - name: Build Docker image uses: docker/build-push-action@v5 diff --git a/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx b/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx index e57f140..7d36221 100644 --- a/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx +++ b/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx @@ -8,15 +8,206 @@ import { Legend, Tooltip, } from "recharts"; -import { useCallback, useMemo, useRef } from "react"; +import React, { useMemo, useState, Suspense } from "react"; import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; +import { + ChartContainer, + ChartTooltipContent, + ChartLegendContent, +} from "@/components/ui/chart"; -const COLORS = ["#0e0d85", "#00C49F", "#FFBB28", "#FF8042"]; +const sanitizeKey = (key: string) => + key.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase(); + +const defaultConfig = { + mon: { label: "Mon", color: "#0e0d85" }, + tue: { label: "Tue", color: "#00C49F" }, + wed: { label: "Wed", color: "#FFBB28" }, + thu: { label: "Thu", color: "#FF8042" }, + fri: { label: "Fri", color: "#6F42C1" }, + sat: { label: "Sat", color: "#E11D48" }, + sun: { label: "Sun", color: "#0ea5e9" }, + w1: { label: "Week 1", color: "#0e0d85" }, + w2: { label: "Week 2", color: "#00C49F" }, + w3: { label: "Week 3", color: "#FFBB28" }, + w4: { label: "Week 4", color: "#FF8042" }, + w5: { label: "Week 5", color: "#6F42C1" }, +}; + +function buildWeeklyData(calls: any[]) { + const days = [...Array(7)].map((_, i) => { + const d = new Date(); + d.setHours(0, 0, 0, 0); + d.setDate(d.getDate() - (6 - i)); + const label = d.toLocaleDateString("en-US", { weekday: "short" }); + const key = label.slice(0, 3).toLowerCase(); + return { key, label }; + }); + + const map = new Map(days.map((d) => [d.key, 0])); + + calls.forEach((call) => { + const dt = new Date(call.state?.createdAt || 0); + const key = dt + .toLocaleDateString("en-US", { weekday: "short" }) + .slice(0, 3) + .toLowerCase(); + if (map.has(key)) map.set(key, (map.get(key) || 0) + 1); + }); + + return days.map((d) => ({ + key: sanitizeKey(d.key), + name: d.label, + value: map.get(d.key) || 0, + })); +} + +function buildMonthlyData(calls: any[]) { + // Group last 30 days into weeks (Week 1..Week 5) + const today = new Date(); + const start = new Date(today); + start.setDate(today.getDate() - 29); + start.setHours(0, 0, 0, 0); + + // Create week buckets + const buckets: { + key: string; + label: string; + start: Date; + end: Date; + value: number; + }[] = []; + for (let i = 0; i < 5; i++) { + const s = new Date(start); + s.setDate(start.getDate() + i * 7); + const e = new Date(s); + e.setDate(s.getDate() + 6); + buckets.push({ + key: `w${i + 1}`, + label: `Week ${i + 1}`, + start: s, + end: e, + value: 0, + }); + } + + calls.forEach((call) => { + const dt = new Date(call.state?.createdAt || 0); + if (dt < start) return; + for (const b of buckets) { + if (dt >= b.start && dt <= b.end) { + b.value += 1; + break; + } + } + }); + + return buckets.map((b) => ({ + key: sanitizeKey(b.key), + name: b.label, + value: b.value, + })); +} + +const ChartLoader = ({ + message = "Loading chart...", +}: { + message?: string; +}) => ( +
+
{message}
+
+); + +const ChartEmpty = ({ + message = "No meeting data available", +}: { + message?: string; +}) => ( +
+

{message}

+
+); + +const ChartInner = ({ scope = "weekly" }: { scope?: "weekly" | "monthly" }) => { + const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); + + const data = useMemo(() => { + if (!TeamCall) return []; + return scope === "weekly" + ? buildWeeklyData(TeamCall) + : buildMonthlyData(TeamCall); + }, [TeamCall, scope]); + + const payload = data.map((d) => ({ + value: d.value, + dataKey: d.key, + name: d.name, + color: `var(--color-${d.key})`, + payload: { ...d, strokeDasharray: 0 }, + })); + + if (isCallsLoading) return ; + if (!TeamCall || TeamCall.length === 0) return ; -export const WeeklyMeetingsChart = () => { return ( - // +

+ {scope === "weekly" + ? "Weekly meeting distribution" + : "Monthly meeting distribution"} +

+ + + + + `${name}: ${Math.round(percent * 100)}%` + } + > + {data.map((entry) => ( + + ))} + + } /> + } /> + + + +
+ ); +}; -
+export const WeeklyMeetingsChart = () => { + const [scope, setScope] = useState<"weekly" | "monthly">("weekly"); + + return ( + }> +
+
+
+ + +
+
+ +
+
); }; diff --git a/src/constants/component.ts b/src/constants/component.ts index 651a2f4..4e1c264 100644 --- a/src/constants/component.ts +++ b/src/constants/component.ts @@ -13,20 +13,27 @@ import { } from "@tabler/icons-react"; export const sidebarLinks: SidebarLink[] = [ - { - route: "/workspace-settings", - label: "Workspace Settings", - details: "Manage workspace settings (Admin/Owner only)", - component: IconSettings, - adminRoute: true, - }, { component: IconUserPentagon, label: "Admin", - route: "/admin", + route: "/admin/dashboard", details: "Manage workspace settings and permissions", adminRoute: true, }, + { + route: "/admin/recordings", + label: "Recordings", + details: "List of recordings", + component: IconVideoFilled, + adminRoute: true, + }, + { + route: "/admin/workspace-settings", + label: "Workspace Settings", + details: "Manage workspace settings (Admin/Owner only)", + component: IconSettings, + adminRoute: true, + }, { route: "", label: "Home", @@ -55,13 +62,6 @@ export const sidebarLinks: SidebarLink[] = [ component: IconHistory, adminRoute: false, }, - { - route: "/recordings", - label: "Recordings", - details: "List of recordings", - component: IconVideoFilled, - adminRoute: false, - }, { route: "/personal-room", label: "User", @@ -70,3 +70,13 @@ export const sidebarLinks: SidebarLink[] = [ adminRoute: false, }, ]; + +export const dayNames = [ + "Sunday", + "Monday", + "Tuesday", + "Wednesday", + "Thursday", + "Friday", + "Saturday", +]; diff --git a/src/types/api.ts b/src/types/api.ts index d926f48..665420d 100644 --- a/src/types/api.ts +++ b/src/types/api.ts @@ -1,4 +1,4 @@ -import type { CreateUserType, CreateMemberType } from "@/db/schema/schema"; +import type { CreateUserType, SelectMemberType } from "@/db/schema/schema"; export type APIResponse = { success: boolean; @@ -18,7 +18,7 @@ type APIErrorResponse = { export type UserResponse = Omit; -export type TUser = CreateUserType; +export type TUser = UserResponse; export type WorkspaceResponse = { id: string; @@ -58,6 +58,8 @@ export type AnalyticsResponse = { createdAt: Date; }; -export type TWorkspaceUser = CreateMemberType; +export type TWorkspaceUser = SelectMemberType; -export type TWorkspaceMembersTableRow = (TUser & TWorkspaceUser)[]; +export type TWorkspaceMember = TUser & TWorkspaceUser; + +export type TWorkspaceMembersTableRow = TWorkspaceMember[]; From eed10e8da491efb6a8dcd105c1c8af91ab73f761 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Tue, 30 Dec 2025 23:31:12 +0530 Subject: [PATCH 09/13] feat: Implement custom participant view and layouts with role indicators and audio level visualization. --- src/app/api/meeting/new/route.ts | 86 ++++++- src/app/api/meeting/route.ts | 70 +++++ .../workspace/meeting/MeetingRoom.tsx | 25 +- .../workspace/meeting/MeetingSetup.tsx | 239 +++++++++++++++--- .../meeting/participant/CustomGridLayout.tsx | 113 +++++++++ .../participant/CustomParticipantView.tsx | 211 ++++++++++++++++ .../participant/CustomSpeakerLayout.tsx | 105 ++++++++ .../meeting/participant/ParticipantAvatar.tsx | 84 ++++++ .../meeting/participant/ParticipantBadge.tsx | 71 ++++++ .../meeting/participant/ParticipantStats.tsx | 122 +++++++++ .../workspace/meeting/participant/index.ts | 7 + src/types/api.ts | 29 +-- 12 files changed, 1088 insertions(+), 74 deletions(-) create mode 100644 src/app/api/meeting/route.ts create mode 100644 src/components/workspace/meeting/participant/CustomGridLayout.tsx create mode 100644 src/components/workspace/meeting/participant/CustomParticipantView.tsx create mode 100644 src/components/workspace/meeting/participant/CustomSpeakerLayout.tsx create mode 100644 src/components/workspace/meeting/participant/ParticipantAvatar.tsx create mode 100644 src/components/workspace/meeting/participant/ParticipantBadge.tsx create mode 100644 src/components/workspace/meeting/participant/ParticipantStats.tsx create mode 100644 src/components/workspace/meeting/participant/index.ts diff --git a/src/app/api/meeting/new/route.ts b/src/app/api/meeting/new/route.ts index c4551c4..0a6cbce 100644 --- a/src/app/api/meeting/new/route.ts +++ b/src/app/api/meeting/new/route.ts @@ -9,12 +9,12 @@ import { import { headers } from "next/headers"; import { auth } from "@/lib/auth-config"; import { eq } from "drizzle-orm"; -import { NextResponse } from "next/server"; +import { NextResponse, type NextRequest } from "next/server"; import type { APIResponse } from "@/types"; type Response = Promise>>; -export async function POST(): Response { +export async function POST(request: NextRequest): Response { try { const session = await auth.api.getSession({ headers: await headers(), @@ -27,6 +27,17 @@ export async function POST(): Response { }); } + // Parse the request body to get the meetingId from Stream + const body = await request.json(); + const streamMeetingId = body?.data?.meetingId; + + if (!streamMeetingId) { + return NextResponse.json({ + success: false, + error: "Meeting ID is required", + }); + } + // Get user data from auth table const [dbUser] = await db .select() @@ -35,9 +46,19 @@ export async function POST(): Response { .execute(); if (!dbUser) { + console.error("User not found in database:", session.user.id); return NextResponse.json({ success: false, error: "User not found" }); } + // Ensure userName exists + if (!dbUser.userName) { + console.error("User has no userName:", session.user.id); + return NextResponse.json({ + success: false, + error: "User profile incomplete - missing username", + }); + } + // Get user's workspace membership const [membership] = await db .select() @@ -47,29 +68,76 @@ export async function POST(): Response { .execute(); if (!membership?.workspaceId) { + console.error("User not in any workspace:", session.user.id); return NextResponse.json({ success: false, error: "User not in a workspace", }); } - // Let the database generate the meetingId automatically + console.log("Creating meeting:", { + meetingId: streamMeetingId, + workspaceId: membership.workspaceId, + hostedBy: dbUser.userName, + }); + + // Use the Stream call ID as the meetingId in the database + // Use onConflictDoNothing to handle duplicate meeting IDs gracefully const [meeting] = await db .insert(workspaceMeetingTable) .values({ + meetingId: streamMeetingId, workspaceId: membership.workspaceId, - hostedBy: dbUser.userName || session.user.name, + hostedBy: dbUser.userName, }) + .onConflictDoNothing() .returning(); + // If meeting wasn't created (already exists), try to fetch it + if (!meeting) { + console.log("Meeting already exists, fetching..."); + const [existingMeeting] = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.meetingId, streamMeetingId)) + .execute(); + + if (existingMeeting) { + return NextResponse.json({ + success: true, + user: dbUser, + data: existingMeeting, + }); + } + + return NextResponse.json({ + success: false, + error: "Failed to create or find meeting", + }); + } + + console.log("Meeting created:", meeting); + // Add the meeting host as a participant - await db.insert(meetingParticipantsTable).values({ - meetingId: meeting.meetingId, - memberId: membership.id, - }); + await db + .insert(meetingParticipantsTable) + .values({ + meetingId: meeting.meetingId, + memberId: membership.id, + }) + .onConflictDoNothing(); // Ignore if already a participant - return NextResponse.json({ success: true, user: dbUser, meeting }); + return NextResponse.json({ success: true, user: dbUser, data: meeting }); } catch (error: unknown) { + console.error("Error creating meeting:", error); + + // Log more specific error info + if (error instanceof Error) { + console.error("Error name:", error.name); + console.error("Error message:", error.message); + console.error("Error stack:", error.stack); + } + return NextResponse.json( { success: false, error: (error as Error).message }, { status: 500 } diff --git a/src/app/api/meeting/route.ts b/src/app/api/meeting/route.ts new file mode 100644 index 0000000..1b3c730 --- /dev/null +++ b/src/app/api/meeting/route.ts @@ -0,0 +1,70 @@ +import { db } from "@/db/client"; +import { + meetingParticipantsTable, + workspaceMeetingTable, + workspacesTable, +} from "@/db/schema/schema"; +import { APIResponse, Call } from "@/types"; +import { and, eq } from "drizzle-orm"; +import { NextRequest } from "next/server"; + +export async function GET( + request: NextRequest, + { params }: { params: Promise<{ slug: string }> } +) { + try { + const { slug } = await params; + const searchParams = request.nextUrl.searchParams; + const page = searchParams.get("page"); + + if (!page) { + return new Response( + JSON.stringify({ success: false, error: "page is required" }), + { status: 400 } + ); + } + + if (!slug) { + return new Response( + JSON.stringify({ success: false, error: "slug is required" }), + { status: 400 } + ); + } + + const workspace = await db.query.workspacesTable.findFirst({ + where: (ws) => eq(ws.slug, slug), + }); + + if (!workspace) { + return new Response( + JSON.stringify({ + success: false, + error: `Workspace with slug ${slug} not found`, + }), + { status: 404 } + ); + } + + const meeting = await db + .select() + .from(workspaceMeetingTable) + .where(eq(workspaceMeetingTable.workspaceId, workspace.id)) + .innerJoin( + meetingParticipantsTable, + eq(workspaceMeetingTable.meetingId, meetingParticipantsTable.meetingId) + ) + .execute(); + + return new Response( + JSON.stringify({ + success: true, + data: meeting, + }) + ); + } catch (error: unknown) { + return new Response( + JSON.stringify({ success: false, error: String(error) }), + { status: 500 } + ); + } +} diff --git a/src/components/workspace/meeting/MeetingRoom.tsx b/src/components/workspace/meeting/MeetingRoom.tsx index e547745..46ae4f2 100644 --- a/src/components/workspace/meeting/MeetingRoom.tsx +++ b/src/components/workspace/meeting/MeetingRoom.tsx @@ -5,12 +5,10 @@ import { CallParticipantsList, CallStatsButton, CallingState, - PaginatedGridLayout, - SpeakerLayout, useCallStateHooks, } from "@stream-io/video-react-sdk"; import { useRouter, useSearchParams } from "next/navigation"; -import { Users, LayoutList } from "lucide-react"; +import { CustomGridLayout, CustomSpeakerLayout } from "./participant"; import { DropdownMenu, @@ -22,6 +20,7 @@ import { import Loader from "@/components/Loader"; import EndCallButton from "@/components/workspace/calls/EndCallButton"; import { cn } from "@/lib/utils"; +import { IconLayoutListFilled, IconUsers } from "@tabler/icons-react"; type CallLayoutType = "grid" | "speaker-left" | "speaker-right"; @@ -41,22 +40,22 @@ const MeetingRoom = () => { const CallLayout = () => { switch (layout) { case "grid": - return ; + return ; case "speaker-right": - return ; + return ; default: - return ; + return ; } }; return (
-
-
- -
+
+ {/*
*/} + + {/*
*/}
); }; diff --git a/src/app/api/workspace/[slug]/members/route.ts b/src/app/api/workspace/[slug]/members/route.ts index 86c46b6..dc44b4e 100644 --- a/src/app/api/workspace/[slug]/members/route.ts +++ b/src/app/api/workspace/[slug]/members/route.ts @@ -1,16 +1,16 @@ -import { NextRequest, NextResponse } from "next/server"; +import { NextResponse } from "next/server"; import { db } from "@/db/client"; import { membersTable, usersTable } from "@/db/schema/schema"; -import { account, session } from "@/db/schema/auth-schema"; import { eq } from "drizzle-orm"; import { auth } from "@/lib/auth-config"; import { headers } from "next/headers"; -import { TUser, TWorkspaceMembersTableRow, TWorkspaceUser } from "@/types"; +import { TWorkspaceMembersTableRow } from "@/types"; -export const GET = async ( - request: NextRequest, - { params }: { params: Promise<{ slug: string }> } -) => { +export const GET = async ({ + params, +}: { + params: Promise<{ slug: string }>; +}) => { try { const { slug } = await params; diff --git a/src/components/workspace/calls/EndCallButton.tsx b/src/components/workspace/calls/EndCallButton.tsx index 3614290..88a2c31 100644 --- a/src/components/workspace/calls/EndCallButton.tsx +++ b/src/components/workspace/calls/EndCallButton.tsx @@ -4,9 +4,9 @@ import { useCall, useCallStateHooks } from "@stream-io/video-react-sdk"; import { Button } from "@/components/ui/button"; import { useRouter } from "next/navigation"; import { useState } from "react"; -import { Phone } from "lucide-react"; import { toast } from "sonner"; import { useWorkspaceStore } from "@/store/workspace"; +import { IconPhone } from "@tabler/icons-react"; const EndCallButton = () => { const call = useCall(); @@ -16,7 +16,7 @@ const EndCallButton = () => { if (!call) throw new Error( - "useStreamCall must be used within a StreamCall component.", + "useStreamCall must be used within a StreamCall component." ); // https://getstream.io/video/docs/react/guides/call-and-participant-state/#participant-state-3 @@ -54,7 +54,7 @@ const EndCallButton = () => { router.push(`/workspace/${workspaceId}`); } catch (error: unknown) { toast.error( - `Failed to end call: ${error instanceof Error ? error.message : "Unknown error"}`, + `Failed to end call: ${error instanceof Error ? error.message : "Unknown error"}` ); } finally { setIsEnding(false); @@ -71,7 +71,7 @@ const EndCallButton = () => { `} variant={"destructive"} > - + {isEnding ? "Ending call..." : "End call for everyone"} ); diff --git a/src/components/workspace/meeting/MeetingTypeList.tsx b/src/components/workspace/meeting/MeetingTypeList.tsx index bf507de..4323055 100644 --- a/src/components/workspace/meeting/MeetingTypeList.tsx +++ b/src/components/workspace/meeting/MeetingTypeList.tsx @@ -13,7 +13,6 @@ import ReactDatePicker from "react-datepicker"; import { type Call, useStreamVideoClient } from "@stream-io/video-react-sdk"; import { CalendarExport } from "../../CalendarExport"; import { Label } from "../../ui/label"; -import { session } from "@/db/schema/auth-schema"; const initialValues = { dateTime: new Date(), @@ -21,7 +20,7 @@ const initialValues = { link: "", }; -const MeetingTypeList = () => { +const MeetingTypeList = ({ workspaceSlug }: { workspaceSlug: string }) => { const router = useRouter(); const client = useStreamVideoClient(); const { data: session, isPending } = useSession(); @@ -77,6 +76,8 @@ const MeetingTypeList = () => { values.dateTime.toISOString() || new Date(Date.now()).toISOString(); const description = values.description || "Instant Meeting"; await call.getOrCreate({ + ring: true, + video: true, data: { starts_at: startsAt, custom: { diff --git a/src/components/workspace/meeting/participant/AudioLevelIndicator.tsx b/src/components/workspace/meeting/participant/AudioLevelIndicator.tsx new file mode 100644 index 0000000..49b4841 --- /dev/null +++ b/src/components/workspace/meeting/participant/AudioLevelIndicator.tsx @@ -0,0 +1,79 @@ +import { useEffect, useRef } from "react"; +import { Mic, MicOff } from "lucide-react"; + +interface AudioLevelIndicatorProps { + audioLevel: number; // 0-1 range + isMuted: boolean; + isSpeaking: boolean; +} + +const AudioLevelIndicator = ({ + audioLevel, + isMuted, + isSpeaking, +}: AudioLevelIndicatorProps) => { + const canvasRef = useRef(null); + + useEffect(() => { + const canvas = canvasRef.current; + if (!canvas) return; + + const ctx = canvas.getContext("2d"); + if (!ctx) return; + + // Clear canvas + ctx.clearRect(0, 0, canvas.width, canvas.height); + + if (isMuted || !isSpeaking) { + return; + } + + // Draw audio level bars + const barCount = 3; + const barWidth = 2; + const barGap = 2; + const maxBarHeight = 12; + + for (let i = 0; i < barCount; i++) { + const barHeight = Math.max( + 3, + audioLevel * maxBarHeight * (0.7 + i * 0.15) + ); + const x = i * (barWidth + barGap); + const y = (maxBarHeight - barHeight) / 2; + + // Create gradient + const gradient = ctx.createLinearGradient(x, 0, x, maxBarHeight); + gradient.addColorStop(0, "#10b981"); + gradient.addColorStop(1, "#14b8a6"); + + ctx.fillStyle = gradient; + ctx.fillRect(x, y, barWidth, barHeight); + } + }, [audioLevel, isMuted, isSpeaking]); + + return ( +
+ {isMuted ? ( +
+ +
+ ) : ( +
+ {isSpeaking ? ( + + ) : ( + + )} +
+ )} +
+ ); +}; + +export default AudioLevelIndicator; diff --git a/src/db/schema/schema.ts b/src/db/schema/schema.ts index 7aae3ef..eba8f4d 100644 --- a/src/db/schema/schema.ts +++ b/src/db/schema/schema.ts @@ -18,6 +18,22 @@ import z from "zod"; // Define user_role enum first export const pgUserRole = pgEnum("user_role", ["owner", "admin", "member"]); +export const pgMeetingStatus = pgEnum("meeting_status", [ + "scheduled", + "active", + "completed", +]); + +export const pgParticipantStatus = pgEnum("participant_status", [ + "invited", + "joined", + "left", + "declined", +]); + +export const UserRole = pgUserRole.enumValues; +export const MeetingStatus = pgMeetingStatus.enumValues; +export const ParticipantStatus = pgParticipantStatus.enumValues; // Users table definition export const usersTable = pgTable( @@ -128,6 +144,7 @@ export const workspaceMeetingTable = pgTable( .references(() => usersTable.userName, { onDelete: "set null", }), + status: pgMeetingStatus().notNull().default("scheduled"), description: text("description").default("Instant Meeting"), createdAt: timestamp("created_at").notNull().defaultNow(), endAt: timestamp("end_at"), @@ -138,6 +155,33 @@ export const workspaceMeetingTable = pgTable( ] ); +// Meeting participants table - tracks which members join which meetings +export const meetingParticipantsTable = pgTable( + "meeting_participants", + { + id: uuid("id").defaultRandom().primaryKey(), + meetingId: uuid("meeting_id") + .notNull() + .references(() => workspaceMeetingTable.meetingId, { + onDelete: "cascade", + }), + memberId: uuid("member_id") + .notNull() + .references(() => membersTable.id, { + onDelete: "cascade", + }), + joinedAt: timestamp("joined_at").notNull().defaultNow(), + leftAt: timestamp("left_at"), + status: pgParticipantStatus().notNull().default("invited"), + }, + (table) => [ + t + .uniqueIndex("meeting_participants_meeting_member_unique_idx") + .on(table.meetingId, table.memberId), + t.index("meeting_participants_meeting_id_idx").on(table.meetingId), + ] +); + export const CreateUserSchema = createInsertSchema(usersTable, { password: z.string().min(6), userName: z.string().min(6, "User Name is required"), @@ -183,12 +227,26 @@ export const SelectUserSchema = createSelectSchema(usersTable, { }); export const SelectCallSchema = createSelectSchema(workspaceMeetingTable); + export const SelectCallMember = createSelectSchema(membersTable); +export const UpdateMeetingSchema = createUpdateSchema(workspaceMeetingTable, { + status: z.enum(MeetingStatus), + endAt: z.date(), +}).omit({ + meetingId: true, + workspaceId: true, + hostedBy: true, + createdAt: true, +}); + export type CreateMeetingType = typeof workspaceMeetingTable.$inferInsert; export type CreateWorkspaceType = typeof workspacesTable.$inferInsert; export type CreateMemberType = typeof membersTable.$inferInsert; export type CreateUserType = typeof usersTable.$inferInsert; +export type CreateParticipantType = + typeof meetingParticipantsTable.$inferInsert; + export type SelectMemberType = typeof membersTable.$inferSelect; export type SelectMeetingType = typeof workspaceMeetingTable.$inferSelect; export type SelectWorkspaceType = typeof workspacesTable.$inferSelect; diff --git a/src/hooks/useParticipantRole.ts b/src/hooks/useParticipantRole.ts new file mode 100644 index 0000000..9880670 --- /dev/null +++ b/src/hooks/useParticipantRole.ts @@ -0,0 +1,67 @@ +import { useEffect, useState } from "react"; +import { + getParticipantRole, + type ParticipantRole, +} from "@/action/participant.action"; +import type { StreamVideoParticipant } from "@stream-io/video-react-sdk"; +import { TWorkspaceMember, TWorkspaceUser } from "@/types"; + +interface ParticipantRoleData { + role: ParticipantRole; + userName: string; + name: string; + email: string; + isLoading: boolean; +} +type Data = Pick< + TWorkspaceMember, + "role" | "userName" | "name" | "email" | "userId" +> & { + isLoading: boolean; +}; + +/** + * Custom hook to get participant role with hybrid approach: + * 1. First check if role is in participant custom data (passed during join) + * 2. If not available, fetch from database + * 3. Cache the result to avoid repeated lookups + */ +export const useParticipantRole = ( + participant: StreamVideoParticipant, + workspaceSlug: string +): Data => { + const [roleData, setRoleData] = useState({ + role: "member", + userName: participant.name || participant.userId, + name: participant.name || participant.userId, + userId: participant.userId, + email: "", + isLoading: true, + }); + + useEffect(() => { + const fetchRole = async () => { + // Step 1: Check if role is already in custom data (fastest) + try { + const fetchRole = await getParticipantRole( + participant.userId, + workspaceSlug + ); + } catch (error) { + console.error("Error fetching participant role:", error); + setRoleData({ + role: "member", + userName: participant.name || participant.userId, + name: participant.name || participant.userId, + email: "", + isLoading: false, + userId: participant.userId, + }); + } + }; + + fetchRole(); + }, [participant.userId, participant.custom, participant.name]); + + return roleData; +}; From 286b86cc39c1054fbac45f2a0d7b6e89340c80c6 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Thu, 1 Jan 2026 19:23:52 +0530 Subject: [PATCH 11/13] refactor: Reimplement meeting participant views using Stream SDK layouts with custom UI components and add a new database migration. --- .../workspace/meeting/MeetingRoom.tsx | 47 ++- .../workspace/meeting/MeetingSetup.tsx | 18 +- .../participant/AudioLevelIndicator.tsx | 79 ----- .../meeting/participant/CustomGridLayout.tsx | 113 ------- .../participant/CustomParticipantView.tsx | 315 ++++++++---------- .../participant/CustomSpeakerLayout.tsx | 27 +- .../meeting/participant/ParticipantAvatar.tsx | 26 +- .../workspace/meeting/participant/index.ts | 13 +- 8 files changed, 229 insertions(+), 409 deletions(-) delete mode 100644 src/components/workspace/meeting/participant/AudioLevelIndicator.tsx delete mode 100644 src/components/workspace/meeting/participant/CustomGridLayout.tsx diff --git a/src/components/workspace/meeting/MeetingRoom.tsx b/src/components/workspace/meeting/MeetingRoom.tsx index 46ae4f2..31502bb 100644 --- a/src/components/workspace/meeting/MeetingRoom.tsx +++ b/src/components/workspace/meeting/MeetingRoom.tsx @@ -5,10 +5,17 @@ import { CallParticipantsList, CallStatsButton, CallingState, + PaginatedGridLayout, + SpeakerLayout, useCallStateHooks, } from "@stream-io/video-react-sdk"; import { useRouter, useSearchParams } from "next/navigation"; -import { CustomGridLayout, CustomSpeakerLayout } from "./participant"; +import { + CustomParticipantViewUI, + CustomVideoPlaceholder, + CustomParticipantViewUISpotlight, + CustomParticipantViewUIBar, +} from "./participant/CustomParticipantView"; import { DropdownMenu, @@ -40,22 +47,44 @@ const MeetingRoom = () => { const CallLayout = () => { switch (layout) { case "grid": - return ; + // Use PaginatedGridLayout with custom UI components + return ( + + ); case "speaker-right": - return ; + // Use SpeakerLayout with separate UI for spotlight and bar + return ( + + ); default: - return ; + // speaker-left + return ( + + ); } }; return (
-
- {/*
*/} - - {/*
*/} +
+
+ +
); }; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx deleted file mode 100644 index 414b544..0000000 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/_components/usercall.tsx +++ /dev/null @@ -1,73 +0,0 @@ -"use client"; -import { - Table, - TableBody, - TableCell, - TableHead, - TableHeader, - TableRow, -} from "@/components/ui/table"; -import { useGetCallsByTeam } from "@/hooks/useGetCallsbyTeam"; -import { useWorkspaceStore } from "@/store/workspace"; -import type { Call } from "@stream-io/video-react-sdk"; - -const Usercall = () => { - const { workspaceName } = useWorkspaceStore(); - const { calls: TeamCall, isCallsLoading } = useGetCallsByTeam( - workspaceName as string, - ); - - if (isCallsLoading || !workspaceName) { - return
Loading...
; - } - - const filteredCalls = TeamCall.filter((call: Call) => call.state.endedAt); - - return ( -
-

User Call

-
-

Total call: {TeamCall.length}

- - - - Description - Call ID - Updated At - Ended At - - - - {TeamCall && - TeamCall.length > 0 && - filteredCalls - .sort((a: Call, b: Call) => { - return ( - new Date(b.state.updatedAt!).getTime() - - new Date(a.state.updatedAt!).getTime() - ); - }) - .map((call: Call, index: number) => { - return ( - - - {call.state.custom?.description} - - {call.id} - - {call.state.updatedAt?.toLocaleDateString()} - - - {call.state.endedAt?.toLocaleDateString()} - - - ); - })} - -
-
-
- ); -}; - -export default Usercall; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx deleted file mode 100644 index a25bdcf..0000000 --- a/src/app/(root)/(dashboard)/workspace/[slug]/(members)/user/page.tsx +++ /dev/null @@ -1,37 +0,0 @@ -import React from "react"; -import { headers } from "next/headers"; -import { auth } from "@/lib/auth-config"; -import Image from "next/image"; -import Usercall from "./_components/usercall"; -const User = async () => { - const session = await auth.api.getSession({ - headers: await headers(), - }); - const user = session?.user; - - return ( -
-
- Profile Picture -
-

{user?.name}

-

@{user?.userName || user?.name}

-

{user?.email}

-
-
- -
-
-
-
- ); -}; - -export default User; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx index 1e52f07..41a4689 100644 --- a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx +++ b/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/page.tsx @@ -1,5 +1,5 @@ -import WeeklyMeetingsChart from "./_components/weekly-meeting-chart"; -import { DailyMeetingsChart } from "@/components/workspace/admin/charts/DailyMeetingsChart"; +import WeeklyMeetingsChart from "@/components/workspace/admin/charts/weekly-meeting-chart"; +import DailyMeetingsChart from "@/components/workspace/admin/charts/weekly-meeting-chart"; const AdminDashboardPage = async ({ params, @@ -25,7 +25,7 @@ const AdminDashboardPage = async ({
- +
diff --git a/src/components/auth/SignupForm.tsx b/src/components/auth/SignupForm.tsx deleted file mode 100644 index 931622f..0000000 --- a/src/components/auth/SignupForm.tsx +++ /dev/null @@ -1,72 +0,0 @@ -import { cn } from "@/lib/utils"; -import { Button } from "@/components/ui/button"; -import { - Field, - FieldDescription, - FieldGroup, - FieldLabel, - FieldSeparator, -} from "@/components/ui/field"; -import { Input } from "@/components/ui/input"; - -export function SignupForm({ - className, - ...props -}: React.ComponentProps<"form">) { - return ( - - -
-

Create your account

-

- Fill in the form below to create your account -

-
- - Full Name - - - - Email - - - We'll use this to contact you. We will not share your email - with anyone else. - - - - Password - - - Must be at least 8 characters long. - - - - Confirm Password - - Please confirm your password. - - - - - Or continue with - - - - Already have an account? Sign in - - -
- - ); -} - -export default SignupForm; diff --git a/src/components/auth/index.ts b/src/components/auth/index.ts index e086bf8..632b44a 100644 --- a/src/components/auth/index.ts +++ b/src/components/auth/index.ts @@ -1,2 +1 @@ export * from "./AcceptInviteForm"; -export { default as SignupForm } from "./SignupForm"; diff --git a/src/components/workspace/admin/charts/DailyMeetingsChart.tsx b/src/components/workspace/admin/charts/DailyMeetingsChart.tsx deleted file mode 100644 index bd97923..0000000 --- a/src/components/workspace/admin/charts/DailyMeetingsChart.tsx +++ /dev/null @@ -1,153 +0,0 @@ -"use client"; - -import { - BarChart, - Bar, - XAxis, - YAxis, - CartesianGrid, - Tooltip, - ResponsiveContainer, -} from "recharts"; -import { useCallback, useMemo, Suspense } from "react"; -import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; - -const ChartContent = () => { - const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); - - // Memoize date calculations - const getDaysArray = useCallback(() => { - const days = [...Array(7)] - .map((_, i) => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - i); - return d; - }) - .reverse(); - return days; - }, []); - - // Memoize chart data processing - const { dailyData, maxMeetings } = useMemo(() => { - const days = getDaysArray(); - const dateMap = new Map(); - - // Initialize map with dates - days.forEach((date) => { - dateMap.set(date.toISOString().split("T")[0], 0); - }); - - // Single pass count - TeamCall.forEach((call) => { - const callDate = new Date(call.state.createdAt || 0) - .toISOString() - .split("T")[0]; - if (dateMap.has(callDate)) { - dateMap.set(callDate, dateMap.get(callDate) + 1); - } - }); - - // Transform to chart data - const data = Array.from(dateMap).map(([date, count]) => ({ - date: new Date(date).toLocaleDateString("en-US", { weekday: "short" }), - meetings: count, - fullDate: date, // Keep full date for tooltip - })); - - return { - dailyData: data, - maxMeetings: Math.max(...data.map((d) => d.meetings)), - }; - }, [TeamCall, getDaysArray]); - - // Memoize chart config - const chartConfig = useMemo( - () => ({ - xAxis: { - tickFormatter: (value: string) => value, - style: { fontSize: "18px" }, - interval: 0, - dx: -10, - dy: 10, - }, - yAxis: { - domain: [0, maxMeetings + 1], - allowDecimals: false, - }, - }), - [maxMeetings], - ); - - if (isCallsLoading) { - return ( -
-
Loading meeting statistics...
-
- ); - } - - if (!TeamCall || TeamCall.length === 0) { - return ( -
-

No meeting data available

-
- ); - } - - return ( -
-

- Daily Meeting Count -

- - - - - - `Date: ${label}`} - formatter={(value) => [`${value} meetings`, "Count"]} - itemStyle={{ color: "var(--color-primary)" }} - contentStyle={{ backgroundColor: "var(--color-chart-1)" }} - wrapperStyle={{ borderRadius: "6px" }} - labelStyle={{ color: "var(--chart-1)" }} - /> - - - -
- ); -}; - -export const DailyMeetingsChart = () => { - return ( - -
Loading chart...
-
- } - > - - - ); -}; diff --git a/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx b/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx deleted file mode 100644 index 7d36221..0000000 --- a/src/components/workspace/admin/charts/WeeklyMeetingsChart.tsx +++ /dev/null @@ -1,213 +0,0 @@ -"use client"; - -import { - PieChart, - Pie, - Cell, - ResponsiveContainer, - Legend, - Tooltip, -} from "recharts"; -import React, { useMemo, useState, Suspense } from "react"; -import { useGetCallByTeamandId } from "@/hooks/useGetCallByTeamandId"; -import { - ChartContainer, - ChartTooltipContent, - ChartLegendContent, -} from "@/components/ui/chart"; - -const sanitizeKey = (key: string) => - key.replace(/[^a-zA-Z0-9_-]/g, "-").toLowerCase(); - -const defaultConfig = { - mon: { label: "Mon", color: "#0e0d85" }, - tue: { label: "Tue", color: "#00C49F" }, - wed: { label: "Wed", color: "#FFBB28" }, - thu: { label: "Thu", color: "#FF8042" }, - fri: { label: "Fri", color: "#6F42C1" }, - sat: { label: "Sat", color: "#E11D48" }, - sun: { label: "Sun", color: "#0ea5e9" }, - w1: { label: "Week 1", color: "#0e0d85" }, - w2: { label: "Week 2", color: "#00C49F" }, - w3: { label: "Week 3", color: "#FFBB28" }, - w4: { label: "Week 4", color: "#FF8042" }, - w5: { label: "Week 5", color: "#6F42C1" }, -}; - -function buildWeeklyData(calls: any[]) { - const days = [...Array(7)].map((_, i) => { - const d = new Date(); - d.setHours(0, 0, 0, 0); - d.setDate(d.getDate() - (6 - i)); - const label = d.toLocaleDateString("en-US", { weekday: "short" }); - const key = label.slice(0, 3).toLowerCase(); - return { key, label }; - }); - - const map = new Map(days.map((d) => [d.key, 0])); - - calls.forEach((call) => { - const dt = new Date(call.state?.createdAt || 0); - const key = dt - .toLocaleDateString("en-US", { weekday: "short" }) - .slice(0, 3) - .toLowerCase(); - if (map.has(key)) map.set(key, (map.get(key) || 0) + 1); - }); - - return days.map((d) => ({ - key: sanitizeKey(d.key), - name: d.label, - value: map.get(d.key) || 0, - })); -} - -function buildMonthlyData(calls: any[]) { - // Group last 30 days into weeks (Week 1..Week 5) - const today = new Date(); - const start = new Date(today); - start.setDate(today.getDate() - 29); - start.setHours(0, 0, 0, 0); - - // Create week buckets - const buckets: { - key: string; - label: string; - start: Date; - end: Date; - value: number; - }[] = []; - for (let i = 0; i < 5; i++) { - const s = new Date(start); - s.setDate(start.getDate() + i * 7); - const e = new Date(s); - e.setDate(s.getDate() + 6); - buckets.push({ - key: `w${i + 1}`, - label: `Week ${i + 1}`, - start: s, - end: e, - value: 0, - }); - } - - calls.forEach((call) => { - const dt = new Date(call.state?.createdAt || 0); - if (dt < start) return; - for (const b of buckets) { - if (dt >= b.start && dt <= b.end) { - b.value += 1; - break; - } - } - }); - - return buckets.map((b) => ({ - key: sanitizeKey(b.key), - name: b.label, - value: b.value, - })); -} - -const ChartLoader = ({ - message = "Loading chart...", -}: { - message?: string; -}) => ( -
-
{message}
-
-); - -const ChartEmpty = ({ - message = "No meeting data available", -}: { - message?: string; -}) => ( -
-

{message}

-
-); - -const ChartInner = ({ scope = "weekly" }: { scope?: "weekly" | "monthly" }) => { - const { calls: TeamCall, isCallsLoading } = useGetCallByTeamandId(); - - const data = useMemo(() => { - if (!TeamCall) return []; - return scope === "weekly" - ? buildWeeklyData(TeamCall) - : buildMonthlyData(TeamCall); - }, [TeamCall, scope]); - - const payload = data.map((d) => ({ - value: d.value, - dataKey: d.key, - name: d.name, - color: `var(--color-${d.key})`, - payload: { ...d, strokeDasharray: 0 }, - })); - - if (isCallsLoading) return ; - if (!TeamCall || TeamCall.length === 0) return ; - - return ( -
-

- {scope === "weekly" - ? "Weekly meeting distribution" - : "Monthly meeting distribution"} -

- - - - - `${name}: ${Math.round(percent * 100)}%` - } - > - {data.map((entry) => ( - - ))} - - } /> - } /> - - - -
- ); -}; - -export const WeeklyMeetingsChart = () => { - const [scope, setScope] = useState<"weekly" | "monthly">("weekly"); - - return ( - }> -
-
-
- - -
-
- -
-
- ); -}; diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx b/src/components/workspace/admin/charts/month-meeting-chart.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/month-meeting-chart.tsx rename to src/components/workspace/admin/charts/month-meeting-chart.tsx diff --git a/src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx b/src/components/workspace/admin/charts/weekly-meeting-chart.tsx similarity index 100% rename from src/app/(root)/(dashboard)/workspace/[slug]/admin/dashboard/_components/weekly-meeting-chart.tsx rename to src/components/workspace/admin/charts/weekly-meeting-chart.tsx diff --git a/src/hooks/use-mobile.tsx b/src/hooks/use-mobile.tsx deleted file mode 100644 index a93d583..0000000 --- a/src/hooks/use-mobile.tsx +++ /dev/null @@ -1,21 +0,0 @@ -import * as React from "react"; - -const MOBILE_BREAKPOINT = 768; - -export function useIsMobile() { - const [isMobile, setIsMobile] = React.useState( - undefined, - ); - - React.useEffect(() => { - const mql = window.matchMedia(`(max-width: ${MOBILE_BREAKPOINT - 1}px)`); - const onChange = () => { - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - }; - mql.addEventListener("change", onChange); - setIsMobile(window.innerWidth < MOBILE_BREAKPOINT); - return () => mql.removeEventListener("change", onChange); - }, []); - - return !!isMobile; -} diff --git a/src/hooks/use-query.ts b/src/hooks/use-query.ts deleted file mode 100644 index c0f2625..0000000 --- a/src/hooks/use-query.ts +++ /dev/null @@ -1,23 +0,0 @@ -"use client"; -import { useEffect, useState } from "react"; - -export function useMediaQuery(query: string): boolean { - const [matches, setMatches] = useState(false); - - useEffect(() => { - const mediaQueryList = window.matchMedia(query); - - const handleChange = (e: MediaQueryListEvent) => { - setMatches(e.matches); - }; - - setMatches(mediaQueryList.matches); - mediaQueryList.addEventListener("change", handleChange); - - return () => { - mediaQueryList.removeEventListener("change", handleChange); - }; - }, [query]); - - return matches; -} diff --git a/src/hooks/useAuthData.ts b/src/hooks/useAuthData.ts deleted file mode 100644 index e5a0fe7..0000000 --- a/src/hooks/useAuthData.ts +++ /dev/null @@ -1,49 +0,0 @@ -"use client"; - -import { useUserStore } from "@/store/user"; -import { useWorkspaceStore } from "@/store/workspace"; - -/** - * Custom hook to access user signin and workspace data - * - * Usage: - * const { userInfo, workspaceInfo, isReady } = useAuthData(); - */ -export const useAuthData = () => { - const userStore = useUserStore(); - const workspaceStore = useWorkspaceStore(); - - return { - // User Information - userInfo: { - userId: userStore.userId, - email: userStore.email, - name: userStore.name, - userName: userStore.userName, - role: userStore.role, - }, - - // Workspace Information - workspaceInfo: { - currentWorkspaceId: userStore.currentWorkspaceId, - currentWorkspaceName: userStore.currentWorkspaceName, - // Also available from workspace store - workspaceId: workspaceStore.workspaceId, - workspaceName: workspaceStore.workspaceName, - members: workspaceStore.members, - }, - - // State - isAuthenticated: userStore.isAuthenticated, - isDataLoaded: userStore.isDataLoaded, - isReady: userStore.isAuthenticated && userStore.isDataLoaded, - - // Actions - actions: { - updateWorkspace: userStore.updateWorkspace, - clearUserData: userStore.clearUserData, - setWorkspace: workspaceStore.setWorkspace, - clearWorkspace: workspaceStore.clearWorkspace, - }, - }; -}; diff --git a/src/hooks/useGetCallByTeamandId.ts b/src/hooks/useGetCallByTeamandId.ts deleted file mode 100644 index 424ea68..0000000 --- a/src/hooks/useGetCallByTeamandId.ts +++ /dev/null @@ -1,42 +0,0 @@ -import { useEffect, useState } from "react"; -import { type Call, useStreamVideoClient } from "@stream-io/video-react-sdk"; -import { useSession } from "@/lib/auth-client"; -import { getCookie } from "cookies-next"; - -export const useGetCallByTeamandId = () => { - const [calls, setCalls] = useState([]); - const [isCallsLoading, setIsCallsLoading] = useState(true); - - const client = useStreamVideoClient(); - const { data: session } = useSession(); - - // Get workspace name from cookie (same as before) - const workspaceName = getCookie("workspaceName") as string | undefined; - const id = session?.user?.id; - - useEffect(() => { - if (!client || !id) return; - - const loadCall = async () => { - try { - // https://getstream.io/video/docs/react/guides/querying-calls/#filters - const { calls } = await client.queryCalls({ - filter_conditions: { - team: { $eq: workspaceName }, - members: { $in: [id] }, - }, - }); - - setCalls(calls); - setIsCallsLoading(false); - } catch (error) { - setIsCallsLoading(false); - throw new Error(`Failed to load calls: ${error}`); - } - }; - - loadCall(); - }, [client, id, workspaceName]); - - return { calls, isCallsLoading }; -}; diff --git a/src/hooks/useGetCallsbyTeam.ts b/src/hooks/useGetCallsbyTeam.ts deleted file mode 100644 index a3b8c9e..0000000 --- a/src/hooks/useGetCallsbyTeam.ts +++ /dev/null @@ -1,32 +0,0 @@ -import { useEffect, useState } from "react"; -import { type Call, useStreamVideoClient } from "@stream-io/video-react-sdk"; - -export const useGetCallsByTeam = (team: string | string[]) => { - const [calls, setCalls] = useState([]); - const [isCallsLoading, setIsCallsLoading] = useState(true); - - const client = useStreamVideoClient(); - - useEffect(() => { - if (!client) return; - - const loadCalls = async () => { - try { - // https://getstream.io/video/docs/react/guides/querying-calls/#filters - const { calls } = await client.queryCalls({ - filter_conditions: { team }, - }); - - setCalls(calls); - setIsCallsLoading(false); - } catch (error) { - console.error(error); - setIsCallsLoading(false); - } - }; - - loadCalls(); - }, [client, team]); - - return { calls, isCallsLoading }; -}; From 4fa57d0d161a03db338ae103ef2b4799b3428397 Mon Sep 17 00:00:00 2001 From: Priyanshu Date: Thu, 1 Jan 2026 19:45:03 +0530 Subject: [PATCH 13/13] feat: Add meeting participant and status management, update database seeding, and enhance workspace store with slug. --- .../workspace/calls/IncomingCallBanner.tsx | 101 +- src/components/workspace/org-switcher.tsx | 32 +- src/db/client.ts | 28 +- src/db/migrations/0001_boring_scorpion.sql | 16 + src/db/migrations/meta/0001_snapshot.json | 1011 +++++++++++++++++ src/db/migrations/meta/_journal.json | 7 + src/db/seed.ts | 45 +- src/store/workspace.ts | 56 +- src/types/store.ts | 11 +- 9 files changed, 1248 insertions(+), 59 deletions(-) create mode 100644 src/db/migrations/0001_boring_scorpion.sql create mode 100644 src/db/migrations/meta/0001_snapshot.json diff --git a/src/components/workspace/calls/IncomingCallBanner.tsx b/src/components/workspace/calls/IncomingCallBanner.tsx index 2c00c62..f4d4224 100644 --- a/src/components/workspace/calls/IncomingCallBanner.tsx +++ b/src/components/workspace/calls/IncomingCallBanner.tsx @@ -5,6 +5,8 @@ import { useRouter } from "next/navigation"; import { useToast } from "@/components/ui/use-toast"; import { RingingCall } from "@stream-io/video-react-sdk"; import { Button } from "@/components/ui/button"; +import MeetingModal from "@/components/workspace/meeting/MeetingModal"; +import { Textarea } from "@/components/ui/textarea"; type Props = { meeting: any | null; @@ -15,6 +17,8 @@ export default function IncomingCallBanner({ meeting, workspaceSlug }: Props) { const router = useRouter(); const { toast } = useToast(); const [isEnding, setIsEnding] = useState(false); + const [showConfirm, setShowConfirm] = useState(false); + const [declineReason, setDeclineReason] = useState(""); if (!meeting) return null; @@ -25,20 +29,34 @@ export default function IncomingCallBanner({ meeting, workspaceSlug }: Props) { const dismissMeeting = async () => { setIsEnding(true); try { - const res = await fetch("/api/meeting/end", { - method: "POST", - headers: { "Content-Type": "application/json" }, - body: JSON.stringify({ meetingId: meeting.meetingId }), - }); + // Call the server endpoint that updates the participant response for the current user + const res = await fetch( + `/api/meeting/${meeting.meetingId}/participants/respond`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "declined", + reason: "Dismissed by invitee", + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: err?.error || "Failed to dismiss meeting" }); + setIsEnding(false); + return; + } const data = await res.json(); if (!data || !data.success) { - toast({ title: "Failed to dismiss meeting" }); + toast({ title: data?.error || "Failed to dismiss meeting" }); setIsEnding(false); return; } - toast({ title: "Meeting dismissed" }); + toast({ title: "You declined the invite" }); router.refresh(); } catch (err) { console.error(err); @@ -75,7 +93,7 @@ export default function IncomingCallBanner({ meeting, workspaceSlug }: Props) { Join
+ + setShowConfirm(false)} + title="Decline Meeting?" + buttonText={isEnding ? "Declining…" : "Confirm Decline"} + handleClick={async () => { + setIsEnding(true); + try { + const res = await fetch( + `/api/meeting/${meeting.meetingId}/participants/respond`, + { + method: "PATCH", + headers: { "Content-Type": "application/json" }, + body: JSON.stringify({ + status: "declined", + reason: declineReason || "Dismissed by invitee", + }), + } + ); + + if (!res.ok) { + const err = await res.json().catch(() => ({})); + toast({ title: err?.error || "Failed to decline meeting" }); + setIsEnding(false); + return; + } + + const data = await res.json(); + if (!data || !data.success) { + toast({ title: data?.error || "Failed to decline meeting" }); + setIsEnding(false); + return; + } + + toast({ title: "You declined the invite" }); + setShowConfirm(false); + router.refresh(); + } catch (err) { + console.error(err); + toast({ title: "Failed to decline meeting" }); + } finally { + setIsEnding(false); + } + }} + > +

+ Are you sure you want to decline this meeting? +

+
+