diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 4e24e90..0d616b3 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -70,6 +70,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { ...video, isAuthenticated: !!session?.user?.id, currentUserId: session?.user?.id || null, + currentUserName: session?.user?.name || null, }); return withCacheControl(response, 'private, no-cache'); diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index e94f238..6f13ebe 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -76,6 +76,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }, isAuthenticated: !!session?.user?.id, currentUserId: session?.user?.id || null, + currentUserName: session?.user?.name || null, canComment: access.hasAccess, }); diff --git a/bun.lock b/bun.lock index 59ed60c..c605971 100644 --- a/bun.lock +++ b/bun.lock @@ -10,6 +10,7 @@ "@base-ui/react": "^1.1.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "@types/react-window": "^2.0.0", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -25,7 +26,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", - "shadcn": "^3.8.3", + "react-window": "^2.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0", @@ -41,6 +42,7 @@ "@types/react-dom": "^19", "eslint": "^9", "eslint-config-next": "16.1.6", + "shadcn": "^3.8.3", "tailwindcss": "^4", "typescript": "^5", }, @@ -697,6 +699,8 @@ "@types/react-dom": ["@types/react-dom@19.2.3", "", { "peerDependencies": { "@types/react": "^19.2.0" } }, "sha512-jp2L/eY6fn+KgVVQAOqYItbF0VY/YApe5Mz2F0aykSO8gx31bYCZyvSeYxCHKvzHG5eZjc+zyaS5BrBWya2+kQ=="], + "@types/react-window": ["@types/react-window@2.0.0", "", { "dependencies": { "react-window": "*" } }, "sha512-E8hMDtImEpMk1SjswSvqoSmYvk7GEtyVaTa/GJV++FdDNuMVVEzpAClyJ0nqeKYBrMkGiyH6M1+rPLM0Nu1exQ=="], + "@types/statuses": ["@types/statuses@2.0.6", "", {}, "sha512-xMAgYwceFhRA2zY+XbEA7mxYbA093wdiW8Vu6gZPGWy9cmOyU9XesH1tNcEWsKFd5Vzrqx5T3D38PWx1FIIXkA=="], "@types/validate-npm-package-name": ["@types/validate-npm-package-name@4.0.2", "", {}, "sha512-lrpDziQipxCEeK5kWxvljWYhUvOiB2A9izZd9B2AFarYAkqZshb4lPbRs7zKEic6eGtH8V/2qJW+dPp9OtF6bw=="], @@ -1163,7 +1167,7 @@ "hermes-parser": ["hermes-parser@0.25.1", "", { "dependencies": { "hermes-estree": "0.25.1" } }, "sha512-6pEjquH3rqaI6cYAXYPcz9MS4rY6R4ngRgrgfDshRptUZIc3lw0MCIJIGDj9++mfySOuPTHB4nrSW99BCvOPIA=="], - "hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], "http-errors": ["http-errors@2.0.1", "", { "dependencies": { "depd": "~2.0.0", "inherits": "~2.0.4", "setprototypeof": "~1.2.0", "statuses": "~2.0.2", "toidentifier": "~1.0.1" } }, "sha512-4FbRdAX+bSdmo4AUFuS0WNiPz8NgFt+r8ThgNWmlrjQjt1Q7ZR9+zTlce2859x4KSXrwIsaeTqDoKQmtP8pLmQ=="], @@ -1577,6 +1581,8 @@ "react-style-singleton": ["react-style-singleton@2.2.3", "", { "dependencies": { "get-nonce": "^1.0.0", "tslib": "^2.0.0" }, "peerDependencies": { "@types/react": "*", "react": "^16.8.0 || ^17.0.0 || ^18.0.0 || ^19.0.0 || ^19.0.0-rc" }, "optionalPeers": ["@types/react"] }, "sha512-b6jSvxvVnyptAiLjbkWLE/lOnR4lfTtDAl+eUC7RZy+QQWc6wRzIV2CE6xBuMmDxc2qIihtDCZD5NPOFl7fRBQ=="], + "react-window": ["react-window@2.2.7", "", { "peerDependencies": { "react": "^18.0.0 || ^19.0.0", "react-dom": "^18.0.0 || ^19.0.0" } }, "sha512-SH5nvfUQwGHYyriDUAOt7wfPsfG9Qxd6OdzQxl5oQ4dsSsUicqQvjV7dR+NqZ4coY0fUn3w1jnC5PwzIUWEg5w=="], + "readdirp": ["readdirp@4.1.2", "", {}, "sha512-GDhwkLfywWL2s6vEjyhri+eXmfH6j1L7JE27WhqLeYzoh/A3DBaYGEj2H/HFZCn/kMfim73FXxEJTw06WtxQwg=="], "recast": ["recast@0.23.11", "", { "dependencies": { "ast-types": "^0.16.1", "esprima": "~4.0.0", "source-map": "~0.6.1", "tiny-invariant": "^1.3.3", "tslib": "^2.0.1" } }, "sha512-YTUo+Flmw4ZXiWfQKGcwwc11KnoRAYgzAE2E7mXKCjSviTKShtxBsN6YUUBB2gtaBzKzeKunxhUwNHQuRryhWA=="], @@ -1863,10 +1869,10 @@ "@modelcontextprotocol/sdk/ajv": ["ajv@8.17.1", "", { "dependencies": { "fast-deep-equal": "^3.1.3", "fast-uri": "^3.0.1", "json-schema-traverse": "^1.0.0", "require-from-string": "^2.0.2" } }, "sha512-B/gBuNg5SiMTrPkC+A2+cW0RszwxYmn6VYxB/inlBStS5nx6xHIt/ehKRhIMhqusl7a8LjQoZnjCs5vhwxOQ1g=="], - "@modelcontextprotocol/sdk/hono": ["hono@4.11.7", "", {}, "sha512-l7qMiNee7t82bH3SeyUCt9UF15EVmaBvsppY2zQtrbIhl/yzBTny+YUxsVjSjQ6gaqaeVtZmGocom8TzBlA4Yw=="], - "@next/eslint-plugin-next/fast-glob": ["fast-glob@3.3.1", "", { "dependencies": { "@nodelib/fs.stat": "^2.0.2", "@nodelib/fs.walk": "^1.2.3", "glob-parent": "^5.1.2", "merge2": "^1.3.0", "micromatch": "^4.0.4" } }, "sha512-kNFPyjhh5cKjrUltxs+wFx+ZkbRaxxmZ+X0ZU31SOsxCEtP9VPgtq2teZw1DebupL5GmDaNQ6yKMMVcM41iqDg=="], + "@prisma/dev/hono": ["hono@4.11.4", "", {}, "sha512-U7tt8JsyrxSRKspfhtLET79pU8K+tInj5QZXs1jSugO1Vq5dFj3kmZsRldo29mTBfcjDRVRXrEZ6LS63Cog9ZA=="], + "@prisma/engines/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="], "@prisma/fetch-engine/@prisma/get-platform": ["@prisma/get-platform@7.3.0", "", { "dependencies": { "@prisma/debug": "7.3.0" } }, "sha512-N7c6m4/I0Q6JYmWKP2RCD/sM9eWiyCPY98g5c0uEktObNSZnugW2U/PO+pwL0UaqzxqTXt7gTsYsb0FnMnJNbg=="], diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index ddb22a3..457292a 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -1,6 +1,7 @@ 'use client'; -import { useState, useRef, useCallback, useEffect } from 'react'; +import { useState, useRef, useCallback, useEffect, useMemo } from 'react'; +import { List } from 'react-window'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { toast } from 'sonner'; @@ -127,6 +128,7 @@ interface VideoData { versions: (Version & { comments: Comment[] })[]; isAuthenticated: boolean; currentUserId: string | null; + currentUserName: string | null; canComment?: boolean; } @@ -241,6 +243,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const projectId = propProjectId || video?.projectId; // Cursor idle detection: hide overlay when cursor idle for 3s while playing + // Memoize version selection handler to prevent recreating on each render + const handleVersionSelect = useCallback((versionId: string) => { + setActiveVersionId(versionId); + }, []); + + // Memoize toggle show resolved handler + const handleToggleShowResolved = useCallback(() => { + setShowResolved(prev => !prev); + }, []); + const handleVideoMouseMove = useCallback(() => { setCursorIdle(false); if (cursorIdleTimerRef.current) clearTimeout(cursorIdleTimerRef.current); @@ -260,8 +272,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi }; }, []); - // Determine current user ID for permission checks + // Determine current user info for permission checks and comment display const currentUserId = video?.currentUserId || null; + const currentUserName = video?.currentUserName || null; const apiBasePath = mode === 'dashboard' ? `/api/projects/${propProjectId}/videos/${videoId}` @@ -295,12 +308,52 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi fetchVideo(); }, [apiBasePath, mode]); - const activeVersion = video?.versions?.find((v) => v.id === activeVersionId) || - video?.versions?.find((v) => v.isActive) || - video?.versions?.[0]; - const comments = activeVersion?.comments || []; - const filteredComments = comments.filter((c) => showResolved || !c.isResolved); - const duration = videoDuration || activeVersion?.duration || 0; + // Memoize active version lookup to avoid recalculating on every render + const activeVersion = useMemo(() => { + return video?.versions?.find((v) => v.id === activeVersionId) || + video?.versions?.find((v) => v.isActive) || + video?.versions?.[0]; + }, [video?.versions, activeVersionId]); + + // Memoize comments array + const comments = useMemo(() => { + return activeVersion?.comments || []; + }, [activeVersion]); + + // Memoize filtered comments to avoid filtering on every render + const filteredComments = useMemo(() => { + return comments.filter((c) => showResolved || !c.isResolved); + }, [comments, showResolved]); + + // Memoize sorted comments to avoid sorting on every render + const sortedComments = useMemo(() => { + return [...filteredComments].sort((a, b) => a.timestamp - b.timestamp); + }, [filteredComments]); + + // Memoize duration computation + const duration = useMemo(() => { + return videoDuration || activeVersion?.duration || 0; + }, [videoDuration, activeVersion?.duration]); + + // Memoize embed URL calculation to avoid recalculating on every render + const embedUrl = useMemo(() => { + if (!activeVersion) return ''; + if (activeVersion.providerId === 'youtube') { + return `https://www.youtube.com/embed/${activeVersion.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; + } + if (activeVersion.providerId === 'vimeo') { + return `https://player.vimeo.com/video/${activeVersion.videoId}`; + } + try { + const url = new URL(activeVersion.originalUrl); + if (url.protocol !== 'http:' && url.protocol !== 'https:') { + return ''; + } + return activeVersion.originalUrl; + } catch { + return ''; + } + }, [activeVersion]); useEffect(() => { if (!projectId) return; @@ -734,7 +787,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi voiceDuration: voiceData?.duration ?? null, isResolved: false, createdAt: new Date().toISOString(), - author: isGuest ? null : { id: 'current-user', name: null, image: null }, + author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, tag: availableTags.find(t => t.id === selectedTagId) || null, replies: [], @@ -1085,7 +1138,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi voiceUrl: voiceData?.url ?? null, voiceDuration: voiceData?.duration ?? null, createdAt: new Date().toISOString(), - author: isGuest ? null : { id: 'current-user', name: null, image: null }, + author: isGuest ? null : { id: 'current-user', name: currentUserName, image: null }, guestName: isGuest ? guestName : null, tag: null, }; @@ -1352,11 +1405,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }, [activeVersionId, video]); + // Comment polling with Page Visibility API to pause when tab is hidden useEffect(() => { if (!activeVersion) return; - const interval = setInterval(async () => { + + let intervalId: ReturnType | null = null; + let isPageVisible = true; + + const poll = async () => { try { - if (isMutatingRef.current) return; + if (isMutatingRef.current || !isPageVisible) return; const res = await fetch(apiBasePath, { cache: 'no-store' }); if (res.ok) { @@ -1366,8 +1424,22 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } } } catch { /* silent */ } - }, 10000); - return () => clearInterval(interval); + }; + + // Start polling + intervalId = setInterval(poll, 10000); + + // Handle page visibility change + const handleVisibilityChange = () => { + isPageVisible = document.visibilityState === 'visible'; + }; + + document.addEventListener('visibilitychange', handleVisibilityChange); + + return () => { + if (intervalId) clearInterval(intervalId); + document.removeEventListener('visibilitychange', handleVisibilityChange); + }; }, [activeVersion, apiBasePath]); const handleNewVersionUrlChange = (url: string) => { @@ -1472,23 +1544,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi } }; - const getEmbedUrl = (version: Version) => { - if (version.providerId === 'youtube') { - return `https://www.youtube.com/embed/${version.videoId}?enablejsapi=1&rel=0&modestbranding=1&controls=0&showinfo=0&iv_load_policy=3&disablekb=1`; - } - if (version.providerId === 'vimeo') { - return `https://player.vimeo.com/video/${version.videoId}`; - } - try { - const url = new URL(version.originalUrl); - if (url.protocol !== 'http:' && url.protocol !== 'https:') { - return ''; - } - return version.originalUrl; - } catch { - return ''; - } - }; const containerHeight = 'h-screen'; const backHref = mode === 'dashboard' @@ -1630,8 +1685,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi ); } - const embedUrl = getEmbedUrl(activeVersion); - return (
( setActiveVersionId(version.id)} + onClick={() => handleVersionSelect(version.id)} > Comments {comments.length}
- @@ -2001,9 +2054,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi

Be the first to leave feedback!

) : ( - filteredComments - .sort((a, b) => a.timestamp - b.timestamp) - .map((comment) => { + sortedComments.map((comment) => { const authorName = comment.author?.name || comment.guestName || 'Anonymous'; const isEditing = editingCommentId === comment.id; diff --git a/lib/auth.ts b/lib/auth.ts index 8e8afa4..ed7e232 100644 --- a/lib/auth.ts +++ b/lib/auth.ts @@ -62,12 +62,14 @@ export const { handlers, signIn, signOut, auth } = NextAuth({ async session({ session, token }) { if (token.sub && session.user) { session.user.id = token.sub; + session.user.name = token.name || null; } return session; }, async jwt({ token, user }) { if (user) { token.sub = user.id; + token.name = user.name; } return token; }, diff --git a/package.json b/package.json index 5fa4f77..4ad52a5 100644 --- a/package.json +++ b/package.json @@ -23,6 +23,7 @@ "@base-ui/react": "^1.1.0", "@prisma/adapter-pg": "^7.3.0", "@prisma/client": "^7.3.0", + "@types/react-window": "^2.0.0", "bcryptjs": "^3.0.3", "class-variance-authority": "^0.7.1", "clsx": "^2.1.1", @@ -38,6 +39,7 @@ "radix-ui": "^1.4.3", "react": "19.2.3", "react-dom": "19.2.3", + "react-window": "^2.2.7", "sonner": "^2.0.7", "tailwind-merge": "^3.4.0", "tw-animate-css": "^1.4.0",