diff --git a/src/app/_components/Header.tsx b/src/app/_components/Header.tsx index a5732f4..5e11577 100644 --- a/src/app/_components/Header.tsx +++ b/src/app/_components/Header.tsx @@ -11,7 +11,7 @@ interface NavbarProps { } const Navbar = ({ title }: NavbarProps) => { - const { isLoaded, isSignedIn } = useUser(); + const { isSignedIn } = useUser(); const router = useRouter(); const pathname = usePathname(); @@ -57,13 +57,7 @@ const Navbar = ({ title }: NavbarProps) => { - {!isLoaded ? ( -
- -
- ) : ( - navigationMenu() - )} + {navigationMenu()} diff --git a/src/app/_components/Loading.tsx b/src/app/_components/Loading.tsx new file mode 100644 index 0000000..f15cbac --- /dev/null +++ b/src/app/_components/Loading.tsx @@ -0,0 +1,7 @@ +"use client"; + +const Loading = () => { + return ; +}; + +export default Loading; diff --git a/src/app/_components/RoomList.tsx b/src/app/_components/RoomList.tsx index fdb96d4..980157c 100644 --- a/src/app/_components/RoomList.tsx +++ b/src/app/_components/RoomList.tsx @@ -2,36 +2,37 @@ import Link from "next/link"; import { configureAbly, useChannel } from "@ably-labs/react-hooks"; -import { useState } from "react"; +import { useEffect, useState } from "react"; import { IoEnterOutline, IoTrashBinOutline } from "react-icons/io5"; import { env } from "@/env.mjs"; -import { useUser } from "@clerk/nextjs"; +import { useOrganization } from "@clerk/nextjs"; import { trpc } from "../_trpc/client"; +import Loading from "./Loading"; export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const fetchCache = "force-no-store"; -const RoomList = () => { - const { isSignedIn, user } = useUser(); +const RoomList = ({ userId }: { userId: string }) => { + const { organization } = useOrganization(); configureAbly({ key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY, - clientId: user?.id, + clientId: userId, recover: (_, cb) => { cb(true); }, }); - const [] = useChannel( - `${env.NEXT_PUBLIC_APP_ENV}-${user?.id}`, + useChannel( + `${env.NEXT_PUBLIC_APP_ENV}-${organization ? organization.id : userId}`, () => void refetchRoomsFromDb() ); const [roomName, setRoomName] = useState(""); const { data: roomsFromDb, refetch: refetchRoomsFromDb } = - trpc.room.getAll.useQuery(undefined, { - enabled: isSignedIn, - }); + trpc.room.getAll.useQuery(undefined); const createRoom = trpc.room.create.useMutation({}); @@ -46,11 +47,13 @@ const RoomList = () => { const deleteRoom = trpc.room.delete.useMutation({}); const deleteRoomHandler = (roomId: string) => { - if (isSignedIn) { - deleteRoom.mutate({ id: roomId }); - } + deleteRoom.mutate({ id: roomId }); }; + useEffect(() => { + void refetchRoomsFromDb(); + }, [organization]); + return (
{/* Modal for Adding Rooms */} @@ -98,6 +101,7 @@ const RoomList = () => { {roomsFromDb && roomsFromDb.length > 0 && (
+ {/* head */} @@ -116,7 +120,7 @@ const RoomList = () => { className="m-2 no-underline" href={`/room/${room.id}`} > - +
Room Name
)} -
); }; diff --git a/src/app/_components/VoteUI.tsx b/src/app/_components/VoteUI.tsx new file mode 100644 index 0000000..fe7853f --- /dev/null +++ b/src/app/_components/VoteUI.tsx @@ -0,0 +1,464 @@ +"use client"; + +import Image from "next/image"; +import { useEffect, useState } from "react"; +import { EventTypes } from "@/utils/types"; + +import { useParams } from "next/navigation"; +import { + IoCheckmarkCircleOutline, + IoCopyOutline, + IoDownloadOutline, + IoEyeOffOutline, + IoEyeOutline, + IoHourglassOutline, + IoReloadOutline, + IoSaveOutline, +} from "react-icons/io5"; +import { GiStarFormation } from "react-icons/gi"; +import { configureAbly, useChannel, usePresence } from "@ably-labs/react-hooks"; +import Link from "next/link"; +import { FaShieldAlt } from "react-icons/fa"; +import { RiVipCrownFill } from "react-icons/ri"; +import { env } from "@/env.mjs"; +import { isAdmin, isVIP } from "@/utils/helpers"; +import type { PresenceItem } from "@/utils/types"; +import { trpc } from "@/app/_trpc/client"; +import Loading from "@/app/_components/Loading"; +import { parse } from "json2csv"; +import { User } from "@clerk/nextjs/dist/types/server"; + +export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const fetchCache = "force-no-store"; + +const VoteUI = ({ user }: { user: Partial }) => { + const params = useParams(); + const roomId = params?.id as string; + + const [storyNameText, setStoryNameText] = useState(""); + const [roomScale, setRoomScale] = useState(""); + const [copied, setCopied] = useState(false); + + const { data: roomFromDb, refetch: refetchRoomFromDb } = + trpc.room.get.useQuery({ id: roomId }); + + const { data: votesFromDb, refetch: refetchVotesFromDb } = + trpc.vote.getAllByRoomId.useQuery({ roomId }); + + const setVoteInDb = trpc.vote.set.useMutation({}); + const setRoomInDb = trpc.room.set.useMutation({}); + + configureAbly({ + key: env.NEXT_PUBLIC_ABLY_PUBLIC_KEY, + clientId: user ? user.id : "unknown", + recover: (_, cb) => { + cb(true); + }, + }); + + const [channel] = useChannel( + { + channelName: `${env.NEXT_PUBLIC_APP_ENV}-${roomId}`, + }, + ({ name }) => { + if (name === EventTypes.ROOM_UPDATE) { + void refetchVotesFromDb(); + void refetchRoomFromDb(); + } else if (name === EventTypes.VOTE_UPDATE) { + void refetchVotesFromDb(); + } + } + ); + + const [presenceData] = usePresence( + `${env.NEXT_PUBLIC_APP_ENV}-${roomId}`, + { + name: `${user?.firstName} ${user?.lastName}` || "", + image: user?.imageUrl || "", + client_id: user?.id || "unknown", + isAdmin: isAdmin(user?.publicMetadata), + isVIP: isVIP(user?.publicMetadata), + } + ); + + // Subscribe on mount and unsubscribe on unmount + useEffect(() => { + window.addEventListener("beforeunload", () => channel.presence.leave()); + return () => { + window.removeEventListener("beforeunload", () => + channel.presence.leave() + ); + channel.presence.leave(); + }; + }, [channel.presence, roomId]); + + // Init story name + useEffect(() => { + if (roomFromDb) { + setStoryNameText(roomFromDb.storyName || ""); + setRoomScale(roomFromDb.scale || "ERROR"); + } + }, [roomFromDb, roomId, user]); + + // Helper functions + const getVoteForCurrentUser = () => { + if (roomFromDb) { + return votesFromDb && votesFromDb.find((vote) => vote.userId === user.id); + } else { + return null; + } + }; + + const setVote = (value: string) => { + if (roomFromDb) { + setVoteInDb.mutate({ + roomId: roomFromDb.id, + value: value, + }); + } + }; + + const saveRoom = (visible: boolean, reset = false, log = false) => { + if (roomFromDb) { + setRoomInDb.mutate({ + name: storyNameText, + roomId: roomFromDb.id, + scale: roomScale, + visible: visible, + reset: reset, + log: log, + }); + } + }; + + const downloadLogs = () => { + if (roomFromDb && votesFromDb) { + const jsonObject = roomFromDb?.logs + .map((item) => { + return { + id: item.id, + created_at: item.created_at, + userId: item.userId, + roomId: item.roomId, + roomName: item.roomName, + storyName: item.storyName, + scale: item.scale, + votes: item.votes, + }; + }) + .concat({ + id: "LATEST", + created_at: new Date(), + userId: roomFromDb.userId, + roomId: roomFromDb.id, + roomName: roomFromDb.roomName, + storyName: storyNameText, + scale: roomScale, + votes: votesFromDb.map((vote) => { + return { + value: vote.value, + }; + }), + }); + + const csv = parse(jsonObject); + const blob = new Blob([csv], { type: "text/csv;charset=utf-8;" }); + const url = URL.createObjectURL(blob); + const link = document.createElement("a"); + link.setAttribute("href", url); + link.setAttribute("download", `sp_${roomId}.csv`); + link.style.visibility = "hidden"; + document.body.appendChild(link); + link.click(); + document.body.removeChild(link); + } + }; + + const copyRoomURLHandler = () => { + navigator.clipboard + .writeText(window.location.href) + .then(() => { + console.log(`Copied Room Link to Clipboard!`); + setCopied(true); + setTimeout(() => { + setCopied(false); + }, 2000); + }) + .catch(() => { + console.log(`Error Copying Room Link to Clipboard!`); + }); + }; + + const voteString = ( + visible: boolean, + votes: typeof votesFromDb, + presenceItem: PresenceItem + ) => { + const matchedVote = votes?.find( + (vote) => vote.userId === presenceItem.client_id + ); + + if (visible) { + if (!!matchedVote) { + return
{matchedVote.value}
; + } else { + return ; + } + } else if (!!matchedVote) { + return ( + + ); + } else { + return ( + + ); + } + }; + + // Room is loading + if (roomFromDb === undefined) { + return ; + // Room has been loaded + } else if (roomFromDb) { + return ( + +
{roomFromDb.roomName}
+
+
ID:
+
{roomFromDb.id}
+ + +
+ + {roomFromDb && ( +
+
+

+ Story: {roomFromDb.storyName} +

+ +
    + {presenceData && + presenceData + .filter( + (value, index, self) => + index === + self.findIndex( + (presenceItem) => + presenceItem.clientId === value.clientId + ) + ) + .map((presenceItem) => { + return ( +
  • +
    + {`${presenceItem.data.name}'s +
    + +

    + {presenceItem.data.name}{" "} + {presenceItem.data.isAdmin && ( + + + + )}{" "} + {presenceItem.data.isVIP && ( + + + + )}{" "} + {presenceItem.clientId === roomFromDb.userId && ( + + + + )} + {" : "} +

    + + {roomFromDb && + votesFromDb && + voteString( + roomFromDb.visible, + votesFromDb, + presenceItem.data + )} +
  • + ); + })} +
+ +
+ {roomFromDb.scale.split(",").map((scaleItem, index) => { + return ( + + ); + })} +
+
+
+ )} + + {!!roomFromDb && + (roomFromDb.userId === user.id || isAdmin(user?.publicMetadata)) && ( + <> +
+
+

Room Settings

+ + + + { + setRoomScale(event.target.value); + }} + /> + + + + { + setStoryNameText(event.target.value); + }} + /> + +
+
+ +
+ +
+ +
+ + {votesFromDb && + (roomFromDb.logs.length > 0 || + votesFromDb.length > 0) && ( +
+ +
+ )} +
+
+
+ + )} +
+ ); + // Room does not exist + } else { + return ( + +

4️⃣0️⃣4️⃣

+

+ Oops! This room does not appear to exist, or may have been deleted! 😢 +

+ + Back to Home + +
+ ); + } +}; + +export default VoteUI; diff --git a/src/app/dashboard/page.tsx b/src/app/dashboard/page.tsx index 4c78eb9..1a11ff9 100644 --- a/src/app/dashboard/page.tsx +++ b/src/app/dashboard/page.tsx @@ -1,44 +1,20 @@ -"use client"; - import RoomList from "@/app/_components/RoomList"; - -import Link from "next/link"; -import { useEffect, useState } from "react"; import { FaShieldAlt } from "react-icons/fa"; import { GiStarFormation } from "react-icons/gi"; -import { useUser } from "@clerk/nextjs"; import { isAdmin, isVIP } from "@/utils/helpers"; +import { currentUser } from "@clerk/nextjs"; export const dynamic = "force-dynamic"; +export const revalidate = 0; +export const fetchCache = "force-no-store"; + +export default async function Dashboard() { + const user = await currentUser(); -const Home = () => { return (
- -
- ); -}; - -export default Home; - -const HomePageBody = () => { - const { isLoaded, user } = useUser(); - const [joinRoomTextBox, setJoinRoomTextBox] = useState(""); - const [tabIndex, setTabIndex] = useState(); - - useEffect(() => { - const tabIndexLocal = localStorage.getItem(`dashboardTabIndex`); - setTabIndex(tabIndexLocal !== null ? Number(tabIndexLocal) : 0); - }, [tabIndex, user]); - - return !isLoaded ? ( -
- -
- ) : ( - <>

- Hi, {user?.fullName}!{" "} + Hi, {user?.firstName}!{" "} {isAdmin(user?.publicMetadata) && ( )} @@ -46,52 +22,8 @@ const HomePageBody = () => { )}

- - {tabIndex === 0 && ( - <> - { - console.log(event.target.value); - setJoinRoomTextBox(event.target.value); - }} - /> - 0 ? `/room/${joinRoomTextBox}` : "/"} - className="btn btn-secondary" - > - Join Room - - - )} - - {tabIndex === 1 && } - + {user && } + ); -}; +} diff --git a/src/app/layout.tsx b/src/app/layout.tsx index 774e450..212ded1 100644 --- a/src/app/layout.tsx +++ b/src/app/layout.tsx @@ -1,4 +1,4 @@ -import { ClerkProvider } from "@clerk/nextjs"; +import { ClerkLoaded, ClerkProvider } from "@clerk/nextjs"; import Footer from "@/app/_components/Footer"; import Header from "@/app/_components/Header"; import "@/styles/globals.css"; @@ -18,11 +18,13 @@ export default function RootLayout({ -
-
- {children} -
-