diff --git a/.gitignore b/.gitignore index e870033..3142145 100644 --- a/.gitignore +++ b/.gitignore @@ -44,5 +44,5 @@ next-env.d.ts # Progress (Internal Tracking) PROGRESS.md -Optimization.md +OPTIMIZATION.md .kilocode \ No newline at end of file diff --git a/AGENTS.md b/AGENTS.md index 0ecc2f8..5285a85 100644 --- a/AGENTS.md +++ b/AGENTS.md @@ -8,7 +8,6 @@ ## Validation before finishing - Run `bun run check`. -- Run `bun test ` for changed behavior; run `bun test` when changes are cross-cutting. ## Repo-specific conventions - Use `auth()` from `@/lib/auth` for server-side session reads. diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/page.tsx index a7fc9f8..f307bb4 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/compare/page.tsx @@ -158,7 +158,7 @@ export default function CompareVersionsPage() { useEffect(() => { async function fetchVideo() { try { - const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); + const res = await fetch(`/api/projects/${projectId}/videos/${videoId}?includeComments=false`); if (!res.ok) { setError('Failed to load video'); setLoading(false); diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 8680700..0c8ff86 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -18,6 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { // Parse query params for pagination and options const searchParams = request.nextUrl.searchParams; + const includeComments = searchParams.get('includeComments') !== 'false'; const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100); const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0')); const includeReplies = searchParams.get('includeReplies') === 'true'; @@ -28,64 +29,80 @@ export async function GET(request: NextRequest, { params }: RouteParams) { project: true, versions: { orderBy: { versionNumber: 'desc' }, - include: { - comments: { - orderBy: { timestamp: 'asc' }, - skip: commentOffset, - take: commentLimit, - select: { - id: true, - content: true, - timestamp: true, - timestampEnd: true, - createdAt: true, - updatedAt: true, - isResolved: true, - resolvedAt: true, - voiceUrl: true, - voiceDuration: true, - imageUrl: true, - annotationData: true, - parentId: true, - authorId: true, - tagId: true, - versionId: true, - guestName: true, - // guestEmail excluded for privacy - author: { select: { id: true, name: true, image: true } }, - tag: { select: { id: true, name: true, color: true } }, - ...(includeReplies ? { - replies: { - orderBy: { createdAt: 'asc' }, - select: { - id: true, - content: true, - timestamp: true, - timestampEnd: true, - createdAt: true, - updatedAt: true, - isResolved: true, - resolvedAt: true, - voiceUrl: true, - voiceDuration: true, - imageUrl: true, - annotationData: true, - parentId: true, - authorId: true, - tagId: true, - versionId: true, - guestName: true, - // guestEmail excluded for privacy - author: { select: { id: true, name: true, image: true } }, - tag: { select: { id: true, name: true, color: true } }, + ...(includeComments ? { + include: { + comments: { + orderBy: { timestamp: 'asc' }, + skip: commentOffset, + take: commentLimit, + select: { + id: true, + content: true, + timestamp: true, + timestampEnd: true, + createdAt: true, + updatedAt: true, + isResolved: true, + resolvedAt: true, + voiceUrl: true, + voiceDuration: true, + imageUrl: true, + annotationData: true, + parentId: true, + authorId: true, + tagId: true, + versionId: true, + guestName: true, + // guestEmail excluded for privacy + author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, + ...(includeReplies ? { + replies: { + orderBy: { createdAt: 'asc' }, + select: { + id: true, + content: true, + timestamp: true, + timestampEnd: true, + createdAt: true, + updatedAt: true, + isResolved: true, + resolvedAt: true, + voiceUrl: true, + voiceDuration: true, + imageUrl: true, + annotationData: true, + parentId: true, + authorId: true, + tagId: true, + versionId: true, + guestName: true, + // guestEmail excluded for privacy + author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, + }, }, - }, - } : {}), + } : {}), + }, + where: { parentId: null }, }, - where: { parentId: null }, + _count: { select: { comments: true } }, }, - _count: { select: { comments: true } }, - }, + } : { + select: { + id: true, + thumbnailUrl: true, + duration: true, + versionNumber: true, + versionLabel: true, + providerId: true, + videoId: true, + originalUrl: true, + title: true, + isActive: true, + _count: { select: { comments: true } }, + }, + }), }, }, }); diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 511b483..65ad78f 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -1,4 +1,4 @@ -import { NextRequest } from 'next/server'; +import { NextRequest, NextResponse } from 'next/server'; import { db } from '@/lib/db'; import { auth } from '@/lib/auth'; import { rateLimit } from '@/lib/rate-limit'; @@ -36,6 +36,10 @@ async function isFreshAttachment(url: string, kind: 'audio' | 'image'): Promise< } } +function normalizeEtag(value: string): string { + return value.trim().replace(/^W\//, ''); +} + // GET /api/versions/[versionId]/comments export async function GET(request: NextRequest, { params }: RouteParams) { try { @@ -103,6 +107,30 @@ export async function GET(request: NextRequest, { params }: RouteParams) { const { searchParams } = new URL(request.url); const includeResolved = searchParams.get('includeResolved') !== 'false'; + const commentsRevision = await db.comment.aggregate({ + where: { + versionId, + ...(includeResolved ? {} : { isResolved: false }), + }, + _count: { id: true }, + _max: { updatedAt: true }, + }); + + const etag = `"comments:${versionId}:${includeResolved ? 1 : 0}:${commentsRevision._count.id}:${commentsRevision._max.updatedAt?.getTime() ?? 0}"`; + const ifNoneMatch = request.headers.get('if-none-match'); + if (ifNoneMatch) { + const matches = ifNoneMatch + .split(',') + .map(normalizeEtag) + .includes(normalizeEtag(etag)); + + if (matches) { + const notModified = new NextResponse(null, { status: 304 }); + notModified.headers.set('ETag', etag); + return withCacheControl(notModified, 'private, no-cache'); + } + } + const comments = await db.comment.findMany({ where: { versionId, @@ -158,6 +186,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) { }); const response = successResponse({ comments }); + response.headers.set('ETag', etag); return withCacheControl(response, 'private, no-cache'); } catch (error) { console.error('Error fetching comments:', error); diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index 5af3cd3..d40bc7f 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -90,6 +90,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { thumbnailUrl: true, duration: true, versionNumber: true, + versionLabel: true, + providerId: true, + videoId: true, + originalUrl: true, + title: true, + isActive: true, _count: { select: { comments: true } }, }, }), diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx index 1b5cd46..9740e3a 100644 --- a/components/video-page-content.tsx +++ b/components/video-page-content.tsx @@ -348,6 +348,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [, setDeletingCommentId] = useState(null); const isMutatingRef = useRef(false); + const commentsEtagRef = useRef>(new Map()); const [previewImage, setPreviewImage] = useState(null); // Annotation state @@ -515,8 +516,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const canResolveComments = !!video?.canResolveComments; const apiBasePath = mode === 'dashboard' - ? `/api/projects/${propProjectId}/videos/${videoId}` - : `/api/watch/${videoId}?includeComments=true`; + ? `/api/projects/${propProjectId}/videos/${videoId}?includeComments=false` + : `/api/watch/${videoId}`; + + const fetchVersionComments = useCallback(async (versionId: string, useEtag: boolean) => { + const headers: HeadersInit = {}; + if (useEtag) { + const etag = commentsEtagRef.current.get(versionId); + if (etag) headers['If-None-Match'] = etag; + } + + const res = await fetch(`/api/versions/${versionId}/comments?includeResolved=true`, { + cache: 'no-store', + headers, + }); + + if (res.status === 304) return; + if (!res.ok) return; + + const etag = res.headers.get('etag'); + if (etag) commentsEtagRef.current.set(versionId, etag); + + const payload = await res.json(); + const commentsList = payload?.data?.comments; + if (!Array.isArray(commentsList)) return; + + setVideo((prev) => { + if (!prev) return prev; + const totalComments = commentsList.reduce((sum: number, comment: Comment) => { + return sum + 1 + (comment.replies?.length ?? 0); + }, 0); + + return { + ...prev, + versions: prev.versions.map((version) => ( + version.id === versionId + ? { ...version, comments: commentsList, _count: { comments: totalComments } } + : version + )), + }; + }); + }, []); useEffect(() => { async function fetchVideo() { @@ -532,9 +572,19 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi return; } const response = await res.json(); - const data = response.data; - setVideo(data); - const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0]; + const rawData = response.data as Omit & { + versions?: Array; + }; + const normalizedData: VideoData = { + ...rawData, + versions: (rawData.versions || []).map((version) => ({ + ...version, + comments: Array.isArray(version.comments) ? version.comments : [], + })), + }; + + setVideo(normalizedData); + const active = normalizedData.versions?.find((v) => v.isActive) || normalizedData.versions?.[0]; if (active) setActiveVersionId(active.id); } catch (err) { console.error('Error fetching video:', err); @@ -546,6 +596,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi fetchVideo(); }, [apiBasePath, mode]); + useEffect(() => { + if (!activeVersionId) return; + void fetchVersionComments(activeVersionId, true); + }, [activeVersionId, fetchVersionComments]); + // Memoize active version lookup to avoid recalculating on every render const activeVersion = useMemo(() => { return video?.versions?.find((v) => v.id === activeVersionId) || @@ -2421,7 +2476,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi // Comment polling with Page Visibility API to pause when tab is hidden useEffect(() => { - if (!activeVersion) return; + if (!activeVersionId) return; let intervalId: ReturnType | null = null; let isPageVisible = true; @@ -2429,14 +2484,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi const poll = async () => { try { if (isMutatingRef.current || !isPageVisible) return; - - const res = await fetch(apiBasePath, { cache: 'no-store' }); - if (res.ok) { - const data = await res.json(); - if (!isMutatingRef.current) { - setVideo(data.data); - } - } + await fetchVersionComments(activeVersionId, true); } catch { /* silent */ } }; @@ -2454,7 +2502,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi if (intervalId) clearInterval(intervalId); document.removeEventListener('visibilitychange', handleVisibilityChange); }; - }, [activeVersion, apiBasePath]); + }, [activeVersionId, fetchVersionComments]); const handleNewVersionUrlChange = (url: string) => { setNewVersionUrl(url);