diff --git a/app/layout.tsx b/app/layout.tsx index 702197b..92db6b9 100644 --- a/app/layout.tsx +++ b/app/layout.tsx @@ -6,6 +6,7 @@ import { ThemeProvider } from '@/components/theme-provider'; import { Toaster } from '@/components/ui/toaster'; import { TooltipProvider } from '@/components/ui/tooltip'; import { cn } from '@/lib/utils'; +import StoreProvider from '@/stores/store-provider'; const inter = Inter({ subsets: ['latin'] }); @@ -29,9 +30,11 @@ export default function RootLayout({ enableSystem disableTransitionOnChange > - - - {children} + + + + {children} + diff --git a/components/livestream-metadata-card.tsx b/components/livestream-metadata-card.tsx index 935928e..8b789ce 100644 --- a/components/livestream-metadata-card.tsx +++ b/components/livestream-metadata-card.tsx @@ -1,27 +1,21 @@ import { Card, CardContent } from '@/components/ui/card'; import { Label } from '@/components/ui/label'; -import { LiveMetadata } from '@/types/liveChat'; - +import { usePollAppStore } from '@/stores/store'; import Image from 'next/image'; -interface LiveStreamMetadataCardProps { - liveStreamMetaData: LiveMetadata; -} - -const LiveStreamMetadataCard = ({ - liveStreamMetaData, -}: LiveStreamMetadataCardProps) => { +const LiveStreamMetadataCard = () => { + const { liveMetadata } = usePollAppStore(); return ( metadata-yt-thumbnail diff --git a/components/poll-app-core.tsx b/components/poll-app-core.tsx new file mode 100644 index 0000000..0581028 --- /dev/null +++ b/components/poll-app-core.tsx @@ -0,0 +1,100 @@ +import { useCallback, useState } from 'react'; +import { vidParser } from '@/lib/vid-parser'; +import { useLiveChat } from '@/hooks/use-livechat'; +import { useToast } from '@/components/ui/use-toast'; +import LiveStreamMetadataCard from '@/components/livestream-metadata-card'; +import UrlInput from '@/components/url-input'; +import { usePollAppStore } from '@/stores/store'; +import PrepareSection from '@/components/prepare-section'; +import PollProcessResultSection from '@/components/poll-process-result-section'; + +const PollAppCore = () => { + const { toast } = useToast(); + const { + isLoading, + setIsLoading, + isReady, + setIsReady, + setLiveChatId, + setLiveMetaData, + changePollAppState, + currentPassphrase, + } = usePollAppStore(); + + const [urlInputValue, setUrlInputValue] = useState(''); + + const { fetchLiveStreamingDetails } = useLiveChat(currentPassphrase); + + const handleTerminateProcess = useCallback(async () => { + setIsReady(false); + changePollAppState('prepare'); + }, [changePollAppState, setIsReady]); + + const handleUrlSubmit = useCallback(async () => { + // start / stop + if (isReady) { + await handleTerminateProcess(); + return; + } + setIsLoading(true); + // check live url vid + const vid = vidParser(urlInputValue); + if (vid == null || vid.length === 0) { + toast({ + title: '🚨 Oops...', + description: 'Invalid youtube live url format', + }); + setIsLoading(false); + return; + } + + // check vid is correct + const result = await fetchLiveStreamingDetails(vid); + if (!result.success) { + setIsLoading(false); + toast({ title: '🚨 Oops...', description: result.message }); + return; + } + + setLiveChatId(result.activeLiveChatId); + setLiveMetaData({ title: result.title, thumbnail: result.thumbnail }); + + // all green, reset any error flag + setIsReady(true); + setIsLoading(false); + }, [ + fetchLiveStreamingDetails, + handleTerminateProcess, + isReady, + setIsLoading, + setIsReady, + setLiveChatId, + setLiveMetaData, + toast, + urlInputValue, + ]); + + return ( +
+ { + setUrlInputValue(event.target.value); + }} + /> + {isReady && ( + <> +
+ + +
+ + + )} +
+ ); +}; + +export default PollAppCore; diff --git a/components/poll-app.tsx b/components/poll-app.tsx index f2362bc..a6b868a 100644 --- a/components/poll-app.tsx +++ b/components/poll-app.tsx @@ -1,14 +1,15 @@ 'use client'; -import { useCallback, useRef, useState } from 'react'; +import { useCallback, useState } from 'react'; import { useToast } from '@/components/ui/use-toast'; import { AuthForm } from './auth-form'; -import PollCardGroup from './poll-cardgroup'; +import PollAppCore from './poll-app-core'; +import { usePollAppStore } from '@/stores/store'; const PollApp = () => { const [isAuth, setIsAuth] = useState(false); const { toast } = useToast(); - const [currentPassphrase, setCurrentPassphrase] = useState(''); + const { setCurrentPassphrase } = usePollAppStore(); const handleAuth = useCallback( async (passphrase: string) => { const result = await fetch('/api/auth', { @@ -24,16 +25,12 @@ const PollApp = () => { setIsAuth(false); } }, - [toast] + [setCurrentPassphrase, toast] ); return (
- {!isAuth ? ( - - ) : ( - - )} + {!isAuth ? : }
); }; diff --git a/components/poll-cardgroup.tsx b/components/poll-cardgroup.tsx deleted file mode 100644 index d5cfafe..0000000 --- a/components/poll-cardgroup.tsx +++ /dev/null @@ -1,436 +0,0 @@ -import { useCallback, useEffect, useRef, useState } from 'react'; -import { vidParser } from '@/lib/vid-parser'; -import { LiveMetadata, MessageData, PollUserData } from '@/types/liveChat'; -import { useLiveChat } from '@/hooks/use-livechat'; -import { useToast } from '@/components/ui/use-toast'; -import { Label } from '@/components/ui/label'; -import { Card, CardContent, CardHeader, CardTitle } from './ui/card'; -import { Input } from '@/components/ui/input'; -import { Button } from '@/components/ui/button'; -import { PlayIcon, StopCircleIcon } from 'lucide-react'; -import { Bar } from 'react-chartjs-2'; -import { - Chart as ChartJS, - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend, -} from 'chart.js'; -import LiveStreamMetadataCard from '@/components/livestream-metadata-card'; -import Spinner from '@/components/spinner'; -import { defaultBaseInterval, isNumeric } from '@/lib/utils'; -import dayjs from 'dayjs'; -import { Checkbox } from '@/components/ui/checkbox'; -import { useChartConfig } from '@/hooks/use-chart-config'; -import NewPollConfirmDialog from '@/components/new-poll-confirm-dialog'; -import PollSummarySubCard from '@/components/poll-summary-subcard'; -import UrlInput from '@/components/url-input'; - -interface PollCardGroupProps { - currentPassphrase: string; -} - -type PollStatusType = 'prepare' | 'start' | 'stop'; - -ChartJS.register( - CategoryScale, - LinearScale, - BarElement, - Title, - Tooltip, - Legend -); - -const PollCardGroup = ({ currentPassphrase }: PollCardGroupProps) => { - const { toast } = useToast(); - const [isLoading, setIsLoading] = useState(false); - const [isReady, setIsReady] = useState(false); - const readyRef = useRef(isReady); - readyRef.current = isReady; - const [urlInputValue, setUrlInputValue] = useState(''); - const [activeChatMessageId, setActiveChatMessageId] = useState< - string | undefined - >(); - - const [liveMetadata, setLiveMetadata] = useState(); - const [pollStatus, setPollStatus] = useState('prepare'); - const pollStatusRef = useRef(pollStatus); - pollStatusRef.current = pollStatus; - const barChartRef = useRef>(null); - const inputRef = useRef(null); - const [numOfOptions, setNumOfOptions] = useState(0); - const [barColor, setBarColor] = useState<{ - bar: string[]; - border: string[]; - }>({ bar: [], border: [] }); - - const handleTerminateProcess = useCallback(async () => { - setIsReady(false); - setPollStatus('prepare'); - }, []); - - const [pollData, setPollData] = useState({}); - const [pollStartDate, setPollStartDate] = useState(); - const [pollSummary, setPollSummary] = useState([]); - const [allowUpdatePollOptions, setAllowUpdatePollOptions] = useState(true); - - const { fetchLiveChatMessage, fetchLiveStreamingDetails, extractMessage } = - useLiveChat(currentPassphrase); - - const handleUrlSubmit = useCallback(async () => { - // start / stop - if (isReady) { - await handleTerminateProcess(); - return; - } - setIsLoading(true); - // check live url vid - const vid = vidParser(urlInputValue); - if (vid == null || vid.length === 0) { - toast({ - title: '🚨 Oops...', - description: 'Invalid youtube live url format', - }); - setIsLoading(false); - return; - } - - // check vid is correct - const result = await fetchLiveStreamingDetails(vid); - if (!result.success) { - setIsLoading(false); - toast({ title: '🚨 Oops...', description: result.message }); - return; - } - - setActiveChatMessageId(result.activeLiveChatId); - setLiveMetadata({ title: result.title, thumbnail: result.thumbnail }); - - // all green, reset any error flag - setIsReady(true); - setIsLoading(false); - }, [ - fetchLiveStreamingDetails, - handleTerminateProcess, - isReady, - toast, - urlInputValue, - ]); - - const intervalLiveChatMessage = useCallback( - async (chatId: string, nextToken?: string) => { - if (!readyRef.current || pollStatusRef.current !== 'start') { - Promise.resolve(); - return; - } - const d = await fetchLiveChatMessage(chatId, nextToken); - if (!d.success) { - setIsLoading(false); - toast({ title: '🚨 Oops...', description: d.message }); - return; - } - - const pollingMs = d.pollingIntervalMillis + defaultBaseInterval; - const nextPageToken = d.nextPageToken; - - const newData: MessageData[] = d.items.map((it: any) => ({ - key: it.id, - uid: it.authorDetails.channelId, - name: it.authorDetails.displayName, - pic: it.authorDetails.profileImageUrl, - message: extractMessage(it), - type: it.snippet.type, - time: it.snippet.publishedAt, - isChatOwner: it.authorDetails.isChatOwner, - isChatSponsor: it.authorDetails.isChatSponsor, - isChatModerator: it.authorDetails.isChatModerator, - })); - - const existedPollData = pollData; - // filter out old message - const latestData = newData.filter((it) => - dayjs(it.time).isAfter(pollStartDate) - ); - - latestData.map((it) => { - if (isNumeric(it.message)) { - // within valid range? - const value = +it.message; - if (value > 0 && value <= numOfOptions) { - if (allowUpdatePollOptions) { - existedPollData[it.uid] = value; - } else { - if ( - existedPollData[it.uid] == null || - existedPollData[it.uid] == 0 - ) { - existedPollData[it.uid] = value; - } - } - } - } - }); - - const data = new Array(numOfOptions).fill(0); - Object.values(existedPollData).forEach((v) => { - if (v > 0 && v <= numOfOptions) { - data[v - 1]++; - } - }); - if (barChartRef.current) { - barChartRef.current.data.datasets[0].data = data; - barChartRef.current.update(); - } - - setPollData(existedPollData); - - setTimeout(async () => { - await intervalLiveChatMessage(chatId, nextPageToken); - }, pollingMs); - }, - [ - allowUpdatePollOptions, - extractMessage, - fetchLiveChatMessage, - numOfOptions, - pollData, - pollStartDate, - toast, - ] - ); - - // NOTE: start fetching message when poll start - useEffect(() => { - if (!isReady || activeChatMessageId == null || pollStatus !== 'start') - return; - (async () => { - await intervalLiveChatMessage(activeChatMessageId); - })(); - }, [ - activeChatMessageId, - extractMessage, - fetchLiveChatMessage, - intervalLiveChatMessage, - isReady, - pollStatus, - ]); - - const sortChartResult = useCallback(() => { - const data = new Array(numOfOptions).fill(0); - - Object.values(pollData).forEach((v) => { - if (v > 0 && v <= numOfOptions) { - data[v - 1]++; - } - }); - - const arrayOfObj = Array.from(Array(numOfOptions).keys()) - .map((i) => i + 1) - .map((value, index) => { - return { - label: value, - data: data[index] || 0, - borderColor: barColor.border[index], - backgroundColor: barColor.bar[index], - }; - }); - - const pollSummary = arrayOfObj.map((it) => it.data); - setPollSummary(pollSummary); - const sortedArrayOfObj = arrayOfObj.sort((a, b) => - a.data === b.data ? 0 : a.data > b.data ? -1 : 1 - ); - - const newArrayLabel: string[] = []; - const newArrayData: number[] = []; - const newBarColor: string[] = []; - const newBorderColor: string[] = []; - sortedArrayOfObj.forEach(function (d, index) { - if (index === 0) { - const highestVoteLabel = `👑${d.label}`; - newArrayLabel.push(highestVoteLabel); - } else { - newArrayLabel.push(`${d.label}`); - } - newArrayData.push(d.data); - newBarColor.push(d.backgroundColor); - newBorderColor.push(d.borderColor); - }); - - if (barChartRef.current) { - barChartRef.current.data.datasets[0].data = newArrayData; - barChartRef.current.data.datasets[0].backgroundColor = newBarColor; - barChartRef.current.data.datasets[0].borderColor = newBorderColor; - barChartRef.current.data.labels = newArrayLabel; - barChartRef.current.update(); - } - }, [barColor?.bar, barColor?.border, numOfOptions, pollData]); - - const { chartOptions, chartInitData, chartColor } = - useChartConfig(numOfOptions); - - useEffect(() => { - // memories chartColor scheme - setBarColor(chartColor); - }, [chartColor]); - - const handleProceedNewPoll = useCallback(() => { - setPollStatus('prepare'); - setPollData({}); - setNumOfOptions(0); - setPollSummary([]); - if (inputRef.current) { - inputRef.current.value = ''; - } - }, []); - - return ( -
- { - setUrlInputValue(event.target.value); - }} - /> - {isReady && ( - <> -
- {liveMetadata && ( - - )} - - - - 1️⃣ Prepare - - - - - { - if (!/[0-9]/.test(e.key)) { - e.preventDefault(); - } - }} - onPaste={(e) => { - e.preventDefault(); - return false; - }} - onChange={(event) => { - // cap max options = 100 - if (Math.abs(+event.target.value) >= 101) { - setNumOfOptions(0); - return; - } - setNumOfOptions(Math.abs(+event.target.value)); - }} - disabled={pollStatus !== 'prepare'} - /> -
- - setAllowUpdatePollOptions( - checked === 'indeterminate' ? true : checked - ) - } - /> - -
-
-                  {'For example:\n'}
-                  {'userA: 2\n'}
-                  {'userB: 1\n'}
-                  {'userA: 3\n'}
-                  {'...\n'}
-                  {'userA is updated his choice from "2" to "3".\n'}
-                
- -
-
-
- {pollStatus !== 'prepare' && ( - - - {pollStatus === 'start' && ( - - 2️⃣ Retrieving response - - )} - {pollStatus === 'stop' && ( - - 3️⃣ Result - - )} - - - - {pollStatus === 'stop' && ( - - )} - {pollStatus === 'start' && ( - - )} - {pollStatus === 'stop' && ( - - )} - - - )} - - )} -
- ); -}; - -export default PollCardGroup; diff --git a/components/poll-process-result-section.tsx b/components/poll-process-result-section.tsx new file mode 100644 index 0000000..9b81b6d --- /dev/null +++ b/components/poll-process-result-section.tsx @@ -0,0 +1,112 @@ +import { usePollAppStore } from '@/stores/store'; +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Button } from '@/components/ui/button'; +import { StopCircleIcon } from 'lucide-react'; +import { Bar } from 'react-chartjs-2'; +import Spinner from '@/components/spinner'; +import NewPollConfirmDialog from '@/components/new-poll-confirm-dialog'; +import PollSummarySubCard from '@/components/poll-summary-subcard'; +import { useChartConfig } from '@/hooks/use-chart-config'; +import { updateChartResultParam } from '@/hooks/use-chart-config'; +import { useFetchLiveChat } from '@/hooks/use-fetch-livechat'; +import { useCallback, useRef } from 'react'; +import { + Chart as ChartJS, + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend, +} from 'chart.js'; + +ChartJS.register( + CategoryScale, + LinearScale, + BarElement, + Title, + Tooltip, + Legend +); + +const PollProcessResultSection = () => { + const { pollAppState, pollResultSummary, changePollAppState, newPollReset } = + usePollAppStore(); + const { chartOptions, chartInitData, sortChartResult } = useChartConfig(); + const barChartRef = useRef>(null); + + const updateChartInPolling = useCallback((data: number[]) => { + if (barChartRef.current) { + barChartRef.current.data.datasets[0].data = data; + barChartRef.current.update(); + } + }, []); + + const updateChartPollResult = useCallback((param: updateChartResultParam) => { + if (barChartRef.current) { + barChartRef.current.data.datasets[0].data = param.newArrayData; + barChartRef.current.data.datasets[0].backgroundColor = param.newBarColor; + barChartRef.current.data.datasets[0].borderColor = param.newBorderColor; + barChartRef.current.data.labels = param.newArrayLabel; + barChartRef.current.update(); + } + }, []); + + useFetchLiveChat({ updateChart: updateChartInPolling }); + + const handleProceedNewPoll = useCallback(() => { + newPollReset(); + }, [newPollReset]); + + return ( + <> + {pollAppState !== 'prepare' && ( + + + {pollAppState === 'start' && ( + + 2️⃣ Retrieving response + + )} + {pollAppState === 'stop' && ( + + 3️⃣ Result + + )} + + + + {pollAppState === 'stop' && ( + + )} + {pollAppState === 'start' && ( + + )} + {pollAppState === 'stop' && ( + + )} + + + )} + + ); +}; + +export default PollProcessResultSection; diff --git a/components/prepare-section.tsx b/components/prepare-section.tsx new file mode 100644 index 0000000..14e1b26 --- /dev/null +++ b/components/prepare-section.tsx @@ -0,0 +1,107 @@ +import { Card, CardContent, CardHeader, CardTitle } from '@/components/ui/card'; +import { Label } from '@/components/ui/label'; +import { Input } from '@/components/ui/input'; +import { Checkbox } from '@/components/ui/checkbox'; +import { Button } from '@/components/ui/button'; +import { useRef } from 'react'; +import { usePollAppStore } from '@/stores/store'; +import { PlayIcon } from 'lucide-react'; +import dayjs from 'dayjs'; +import { useToast } from '@/components/ui/use-toast'; + +const PrepareSection = () => { + const inputRef = useRef(null); + const { + pollAppState, + changePollAppState, + numOfOptions, + setNumOfOptions, + setPollStartDateTime, + allowUpdatePollChoice, + setAllowUpdatePollChoice, + } = usePollAppStore(); + const { toast } = useToast(); + + return ( + + + + 1️⃣ Prepare + + + + + { + if (!/[0-9]/.test(e.key)) { + e.preventDefault(); + } + }} + onPaste={(e) => { + e.preventDefault(); + return false; + }} + onChange={(event) => { + // cap max options = 100 + if (Math.abs(+event.target.value) >= 101) { + setNumOfOptions(0); + return; + } + setNumOfOptions(Math.abs(+event.target.value)); + }} + disabled={pollAppState !== 'prepare'} + /> +
+ + setAllowUpdatePollChoice( + checked === 'indeterminate' ? true : checked + ) + } + /> + +
+
+          {'For example:\n'}
+          {'userA: 2\n'}
+          {'userB: 1\n'}
+          {'userA: 3\n'}
+          {'...\n'}
+          {'userA is updated his choice from "2" to "3".\n'}
+        
+ +
+
+ ); +}; + +export default PrepareSection; diff --git a/hooks/use-chart-config.ts b/hooks/use-chart-config.ts index 8b0c712..c118ec2 100644 --- a/hooks/use-chart-config.ts +++ b/hooks/use-chart-config.ts @@ -1,7 +1,17 @@ import { randomRGBAColor } from '@/lib/random-rgba-color'; -import { useMemo } from 'react'; +import { usePollAppStore } from '@/stores/store'; +import { useCallback, useMemo } from 'react'; + +export type updateChartResultParam = { + newArrayData: number[]; + newBarColor: string[]; + newBorderColor: string[]; + newArrayLabel: string[]; +}; + +export const useChartConfig = () => { + const { numOfOptions, pollData, setPollResultSummary } = usePollAppStore(); -export const useChartConfig = (numOfOptions: number) => { const chartOptions = useMemo(() => { return { indexAxis: 'y' as const, @@ -76,5 +86,58 @@ export const useChartConfig = (numOfOptions: number) => { }; }, [chartColor.bar, chartColor.border, numOfOptions]); - return { chartOptions, chartInitData, chartColor }; + const sortChartResult = useCallback( + (updateChartResult: (param: updateChartResultParam) => void) => { + const data = new Array(numOfOptions).fill(0); + + Object.values(pollData).forEach((v) => { + if (v > 0 && v <= numOfOptions) { + data[v - 1]++; + } + }); + + const arrayOfObj = Array.from(Array(numOfOptions).keys()) + .map((i) => i + 1) + .map((value, index) => { + return { + label: value, + data: data[index] || 0, + borderColor: chartColor.border[index], + backgroundColor: chartColor.bar[index], + }; + }); + + const pollSummary = arrayOfObj.map((it) => it.data); + setPollResultSummary(pollSummary); + const sortedArrayOfObj = arrayOfObj.sort((a, b) => + a.data === b.data ? 0 : a.data > b.data ? -1 : 1 + ); + + const newArrayLabel: string[] = []; + const newArrayData: number[] = []; + const newBarColor: string[] = []; + const newBorderColor: string[] = []; + sortedArrayOfObj.forEach(function (d, index) { + if (index === 0) { + const highestVoteLabel = `👑${d.label}`; + newArrayLabel.push(highestVoteLabel); + } else { + newArrayLabel.push(`${d.label}`); + } + newArrayData.push(d.data); + newBarColor.push(d.backgroundColor); + newBorderColor.push(d.borderColor); + }); + + updateChartResult({ + newArrayData, + newBarColor, + newBorderColor, + newArrayLabel, + }); + }, + [chartColor, numOfOptions, pollData, setPollResultSummary] + ); + + return { chartOptions, chartInitData, sortChartResult }; }; diff --git a/hooks/use-fetch-livechat.ts b/hooks/use-fetch-livechat.ts new file mode 100644 index 0000000..a1ad61c --- /dev/null +++ b/hooks/use-fetch-livechat.ts @@ -0,0 +1,139 @@ +import { useCallback, useEffect, useRef } from 'react'; +import { useLiveChat } from './use-livechat'; +import { useToast } from '@/components/ui/use-toast'; +import { usePollAppStore } from '@/stores/store'; +import { defaultBaseInterval, isNumeric } from '@/lib/utils'; +import { MessageData } from '@/types/live-chat'; +import dayjs from 'dayjs'; + +interface useFetchLiveChatProps { + updateChart: (data: number[]) => void; +} + +export const useFetchLiveChat = ({ updateChart }: useFetchLiveChatProps) => { + const { + setIsLoading, + pollData, + setPollData, + liveChatId, + pollStartDateTime, + numOfOptions, + allowUpdatePollChoice, + isReady, + pollAppState, + currentPassphrase, + } = usePollAppStore(); + + const { fetchLiveChatMessage, extractMessage } = + useLiveChat(currentPassphrase); + const { toast } = useToast(); + + // use ref, prevent callback update during process + const ready = useRef(isReady); + ready.current = isReady; + const currentAppState = useRef(pollAppState); + currentAppState.current = pollAppState; + + const intervalLiveChatMessage = useCallback( + async (chatId: string, nextToken?: string) => { + if (!ready.current || currentAppState.current !== 'start') { + Promise.resolve(); + return; + } + const d = await fetchLiveChatMessage(chatId, nextToken); + if (!d.success) { + setIsLoading(false); + toast({ title: '🚨 Oops...', description: d.message }); + return; + } + + const pollingMs = d.pollingIntervalMillis + defaultBaseInterval; + const nextPageToken = d.nextPageToken; + + const newData: MessageData[] = d.items.map((it: any) => ({ + key: it.id, + uid: it.authorDetails.channelId, + name: it.authorDetails.displayName, + pic: it.authorDetails.profileImageUrl, + message: extractMessage(it), + type: it.snippet.type, + time: it.snippet.publishedAt, + isChatOwner: it.authorDetails.isChatOwner, + isChatSponsor: it.authorDetails.isChatSponsor, + isChatModerator: it.authorDetails.isChatModerator, + })); + + const existedPollData = pollData; + // filter out old message + const latestData = newData.filter((it) => { + return dayjs(it.time).isAfter(pollStartDateTime); + }); + + latestData.map((it) => { + if (isNumeric(it.message)) { + // within valid range? + const value = +it.message; + if (value > 0 && value <= numOfOptions) { + if (allowUpdatePollChoice) { + existedPollData[it.uid] = value; + } else { + if ( + existedPollData[it.uid] == null || + existedPollData[it.uid] == 0 + ) { + existedPollData[it.uid] = value; + } + } + } + } + }); + + const data = new Array(numOfOptions).fill(0); + Object.values(existedPollData).forEach((v) => { + if (v > 0 && v <= numOfOptions) { + data[v - 1]++; + } + }); + + updateChart(data); + + setPollData(existedPollData); + + setTimeout(async () => { + await intervalLiveChatMessage(chatId, nextPageToken); + }, pollingMs); + }, + [ + allowUpdatePollChoice, + extractMessage, + fetchLiveChatMessage, + numOfOptions, + pollData, + pollStartDateTime, + setIsLoading, + setPollData, + toast, + updateChart, + ] + ); + + // NOTE: start fetching message when poll start + useEffect(() => { + if ( + !ready.current || + liveChatId == null || + currentAppState.current !== 'start' + ) + return; + (async () => { + await intervalLiveChatMessage(liveChatId); + })(); + }, [ + liveChatId, + extractMessage, + fetchLiveChatMessage, + intervalLiveChatMessage, + isReady, + pollAppState, + ]); +}; diff --git a/lib/utils.ts b/lib/utils.ts index ca72f51..d49143e 100644 --- a/lib/utils.ts +++ b/lib/utils.ts @@ -1,4 +1,3 @@ -import { PollUserData } from '@/types/liveChat'; import { type ClassValue, clsx } from 'clsx'; import { twMerge } from 'tailwind-merge'; diff --git a/package-lock.json b/package-lock.json index a3bdaf2..937dd2c 100644 --- a/package-lock.json +++ b/package-lock.json @@ -29,7 +29,8 @@ "react-device-detect": "^2.2.3", "react-dom": "^18", "tailwind-merge": "^2.2.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.5.1" }, "devDependencies": { "@types/node": "^20", @@ -5620,6 +5621,14 @@ } } }, + "node_modules/use-sync-external-store": { + "version": "1.2.0", + "resolved": "https://registry.npmjs.org/use-sync-external-store/-/use-sync-external-store-1.2.0.tgz", + "integrity": "sha512-eEgnFxGQ1Ife9bzYs6VLi8/4X6CObHMw9Qr9tPY43iKwsPw8xE8+EFsf/2cFZ5S3esXgpWgtSCtLNS41F+sKPA==", + "peerDependencies": { + "react": "^16.8.0 || ^17.0.0 || ^18.0.0" + } + }, "node_modules/util-deprecate": { "version": "1.0.2", "resolved": "https://registry.npmjs.org/util-deprecate/-/util-deprecate-1.0.2.tgz", @@ -5836,6 +5845,33 @@ "funding": { "url": "https://github.com/sponsors/sindresorhus" } + }, + "node_modules/zustand": { + "version": "4.5.1", + "resolved": "https://registry.npmjs.org/zustand/-/zustand-4.5.1.tgz", + "integrity": "sha512-XlauQmH64xXSC1qGYNv00ODaQ3B+tNPoy22jv2diYiP4eoDKr9LA+Bh5Bc3gplTrFdb6JVI+N4kc1DZ/tbtfPg==", + "dependencies": { + "use-sync-external-store": "1.2.0" + }, + "engines": { + "node": ">=12.7.0" + }, + "peerDependencies": { + "@types/react": ">=16.8", + "immer": ">=9.0.6", + "react": ">=16.8" + }, + "peerDependenciesMeta": { + "@types/react": { + "optional": true + }, + "immer": { + "optional": true + }, + "react": { + "optional": true + } + } } } } diff --git a/package.json b/package.json index 1276970..f080b72 100644 --- a/package.json +++ b/package.json @@ -32,7 +32,8 @@ "react-device-detect": "^2.2.3", "react-dom": "^18", "tailwind-merge": "^2.2.1", - "tailwindcss-animate": "^1.0.7" + "tailwindcss-animate": "^1.0.7", + "zustand": "^4.5.1" }, "devDependencies": { "@types/node": "^20", diff --git a/stores/store-provider.tsx b/stores/store-provider.tsx new file mode 100644 index 0000000..360f2fa --- /dev/null +++ b/stores/store-provider.tsx @@ -0,0 +1,19 @@ +'use client'; +import { type PropsWithChildren, useRef } from 'react'; +import { initStore, StoreContextProvider } from './store'; +import { StoreApi } from 'zustand'; +import { PollAppFullInterface } from '@/types/poll-app'; + +export default function StoreProvider({ children }: PropsWithChildren) { + const storeRef = useRef>(); + + if (!storeRef.current) { + storeRef.current = initStore(); + } + + return ( + + {children} + + ); +} diff --git a/stores/store.ts b/stores/store.ts new file mode 100644 index 0000000..791f65a --- /dev/null +++ b/stores/store.ts @@ -0,0 +1,141 @@ +import { LiveMetadata } from '@/types/live-chat'; +import { + PollAppFullInterface, + PollAppInterface, + PollAppStatusType, + PollUserData, +} from '@/types/poll-app'; +import dayjs from 'dayjs'; +import { createContext, useContext } from 'react'; +import { createStore, useStore as useZustandStore } from 'zustand'; +import { useShallow } from 'zustand/react/shallow'; + +const getDefaultInitialState: () => PollAppInterface = () => ({ + isLoading: false, + isReady: false, + liveChatId: undefined, + liveMetadata: undefined, + pollAppState: 'prepare', + numOfOptions: 0, + allowUpdatePollChoice: true, + pollData: {}, + pollStartDateTime: dayjs(), + pollResultSummary: [] as number[], + currentPassphrase: '', +}); + +const storeContext = createContext | null>(null); +export const StoreContextProvider = storeContext.Provider; + +function useStore(selector: (state: PollAppFullInterface) => T) { + const store = useContext(storeContext); + if (!store) throw new Error('Store is missing the provider'); + return useZustandStore(store, selector); +} + +export const usePollAppStore = () => { + return useStore( + useShallow((store) => ({ + isLoading: store.isLoading, + isReady: store.isReady, + liveChatId: store.liveChatId, + liveMetadata: store.liveMetadata, + pollAppState: store.pollAppState, + numOfOptions: store.numOfOptions, + allowUpdatePollChoice: store.allowUpdatePollChoice, + pollData: store.pollData, + pollStartDateTime: store.pollStartDateTime, + pollResultSummary: store.pollResultSummary, + currentPassphrase: store.currentPassphrase, + setIsLoading: store.setIsLoading, + setIsReady: store.setIsReady, + setLiveChatId: store.setLiveChatId, + setLiveMetaData: store.setLiveMetaData, + changePollAppState: store.changePollAppState, + setNumOfOptions: store.setNumOfOptions, + setAllowUpdatePollChoice: store.setAllowUpdatePollChoice, + setPollData: store.setPollData, + setPollStartDateTime: store.setPollStartDateTime, + setPollResultSummary: store.setPollResultSummary, + setCurrentPassphrase: store.setCurrentPassphrase, + newPollReset: store.newPollReset, + })) + ); +}; + +export const initStore = () => { + return createStore((set, _get) => ({ + ...getDefaultInitialState(), + setIsLoading: (isLoading: boolean) => { + set((_state) => { + return { isLoading }; + }); + }, + setIsReady: (isReady: boolean) => { + set((_state) => { + return { isReady }; + }); + }, + setLiveChatId: (liveChatId: string) => { + set((_state) => { + return { liveChatId }; + }); + }, + setLiveMetaData: (data: LiveMetadata) => { + set((_state) => { + return { liveMetadata: data }; + }); + }, + changePollAppState: (state: PollAppStatusType) => { + set((_state) => { + return { pollAppState: state }; + }); + }, + setNumOfOptions: (numOfOptions: number) => { + set((_state) => { + return { numOfOptions }; + }); + }, + setAllowUpdatePollChoice: (enable: boolean) => { + set((_state) => { + return { allowUpdatePollChoice: enable }; + }); + }, + setPollData: (data: PollUserData) => { + set((_state) => { + return { pollData: data }; + }); + }, + setPollStartDateTime: (dateTime: dayjs.Dayjs) => { + set((_state) => { + return { pollStartDateTime: dateTime }; + }); + }, + setPollResultSummary: (resultSummary: number[]) => { + set((_state) => { + return { pollResultSummary: resultSummary }; + }); + }, + setCurrentPassphrase: (currentPassphrase: string) => { + set((_state) => { + return { currentPassphrase }; + }); + }, + newPollReset: () => { + const { + isLoading, + pollAppState, + pollData, + pollStartDateTime, + pollResultSummary, + } = getDefaultInitialState(); + set({ + isLoading, + pollAppState, + pollData, + pollStartDateTime, + pollResultSummary, + }); + }, + })); +}; diff --git a/types/liveChat.ts b/types/live-chat.ts similarity index 91% rename from types/liveChat.ts rename to types/live-chat.ts index 3b4de3a..a202e04 100644 --- a/types/liveChat.ts +++ b/types/live-chat.ts @@ -23,5 +23,3 @@ export interface MessageData { isChatSponsor: boolean; // channel membership isChatModerator: boolean; // channel mod } - -export type PollUserData = Record; diff --git a/types/poll-app.ts b/types/poll-app.ts new file mode 100644 index 0000000..bd76c5c --- /dev/null +++ b/types/poll-app.ts @@ -0,0 +1,42 @@ +import dayjs from 'dayjs'; +import { LiveMetadata } from './live-chat'; + +export type PollUserData = Record; + +export type PollAppStatusType = 'prepare' | 'start' | 'stop'; + +export type BarColor = { + bar: string[]; + border: string[]; +}; + +export interface PollAppInterface { + isLoading: boolean; // application busy state + isReady: boolean; // application init done, ready for use + liveChatId: string | undefined; // youtube liveChatId + liveMetadata: LiveMetadata | undefined; // youtube live metadata + pollAppState: PollAppStatusType; + numOfOptions: number; // app param: Number of poll options + allowUpdatePollChoice: boolean; // app param: Allow audience to update his choice using latest comments + pollData: PollUserData; + pollStartDateTime: dayjs.Dayjs; + pollResultSummary: number[]; + currentPassphrase: string; +} + +export interface PollAppActionsInterface { + setIsLoading: (state: boolean) => void; + setIsReady: (state: boolean) => void; + setLiveChatId: (liveChatId: string) => void; + setLiveMetaData: (data: LiveMetadata) => void; + changePollAppState: (state: PollAppStatusType) => void; + setNumOfOptions: (numOfOptions: number) => void; + setAllowUpdatePollChoice: (enable: boolean) => void; + setPollData: (data: PollUserData) => void; + setPollStartDateTime: (dateTime: dayjs.Dayjs) => void; + setPollResultSummary: (resultSummary: number[]) => void; + newPollReset: () => void; + setCurrentPassphrase: (currentPassphrase: string) => void; +} + +export type PollAppFullInterface = PollAppInterface & PollAppActionsInterface;