feat(video): lazy-load version comments and add ETag-based comment caching

This commit is contained in:
Yusuf İpek
2026-02-24 16:34:16 +03:00
parent a9041ebca5
commit ffa55d7dcc
7 changed files with 173 additions and 74 deletions
+1 -1
View File
@@ -44,5 +44,5 @@ next-env.d.ts
# Progress (Internal Tracking) # Progress (Internal Tracking)
PROGRESS.md PROGRESS.md
Optimization.md OPTIMIZATION.md
.kilocode .kilocode
-1
View File
@@ -8,7 +8,6 @@
## Validation before finishing ## Validation before finishing
- Run `bun run check`. - Run `bun run check`.
- Run `bun test <path>` for changed behavior; run `bun test` when changes are cross-cutting.
## Repo-specific conventions ## Repo-specific conventions
- Use `auth()` from `@/lib/auth` for server-side session reads. - Use `auth()` from `@/lib/auth` for server-side session reads.
@@ -158,7 +158,7 @@ export default function CompareVersionsPage() {
useEffect(() => { useEffect(() => {
async function fetchVideo() { async function fetchVideo() {
try { try {
const res = await fetch(`/api/projects/${projectId}/videos/${videoId}`); const res = await fetch(`/api/projects/${projectId}/videos/${videoId}?includeComments=false`);
if (!res.ok) { if (!res.ok) {
setError('Failed to load video'); setError('Failed to load video');
setLoading(false); setLoading(false);
@@ -18,6 +18,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
// Parse query params for pagination and options // Parse query params for pagination and options
const searchParams = request.nextUrl.searchParams; const searchParams = request.nextUrl.searchParams;
const includeComments = searchParams.get('includeComments') !== 'false';
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100); const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0')); const commentOffset = Math.max(0, parseInt(searchParams.get('commentOffset') || '0'));
const includeReplies = searchParams.get('includeReplies') === 'true'; const includeReplies = searchParams.get('includeReplies') === 'true';
@@ -28,64 +29,80 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
project: true, project: true,
versions: { versions: {
orderBy: { versionNumber: 'desc' }, orderBy: { versionNumber: 'desc' },
include: { ...(includeComments ? {
comments: { include: {
orderBy: { timestamp: 'asc' }, comments: {
skip: commentOffset, orderBy: { timestamp: 'asc' },
take: commentLimit, skip: commentOffset,
select: { take: commentLimit,
id: true, select: {
content: true, id: true,
timestamp: true, content: true,
timestampEnd: true, timestamp: true,
createdAt: true, timestampEnd: true,
updatedAt: true, createdAt: true,
isResolved: true, updatedAt: true,
resolvedAt: true, isResolved: true,
voiceUrl: true, resolvedAt: true,
voiceDuration: true, voiceUrl: true,
imageUrl: true, voiceDuration: true,
annotationData: true, imageUrl: true,
parentId: true, annotationData: true,
authorId: true, parentId: true,
tagId: true, authorId: true,
versionId: true, tagId: true,
guestName: true, versionId: true,
// guestEmail excluded for privacy guestName: true,
author: { select: { id: true, name: true, image: true } }, // guestEmail excluded for privacy
tag: { select: { id: true, name: true, color: true } }, author: { select: { id: true, name: true, image: true } },
...(includeReplies ? { tag: { select: { id: true, name: true, color: true } },
replies: { ...(includeReplies ? {
orderBy: { createdAt: 'asc' }, replies: {
select: { orderBy: { createdAt: 'asc' },
id: true, select: {
content: true, id: true,
timestamp: true, content: true,
timestampEnd: true, timestamp: true,
createdAt: true, timestampEnd: true,
updatedAt: true, createdAt: true,
isResolved: true, updatedAt: true,
resolvedAt: true, isResolved: true,
voiceUrl: true, resolvedAt: true,
voiceDuration: true, voiceUrl: true,
imageUrl: true, voiceDuration: true,
annotationData: true, imageUrl: true,
parentId: true, annotationData: true,
authorId: true, parentId: true,
tagId: true, authorId: true,
versionId: true, tagId: true,
guestName: true, versionId: true,
// guestEmail excluded for privacy guestName: true,
author: { select: { id: true, name: true, image: true } }, // guestEmail excluded for privacy
tag: { select: { id: true, name: true, color: true } }, 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 } },
},
}),
}, },
}, },
}); });
+30 -1
View File
@@ -1,4 +1,4 @@
import { NextRequest } from 'next/server'; import { NextRequest, NextResponse } from 'next/server';
import { db } from '@/lib/db'; import { db } from '@/lib/db';
import { auth } from '@/lib/auth'; import { auth } from '@/lib/auth';
import { rateLimit } from '@/lib/rate-limit'; 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 // GET /api/versions/[versionId]/comments
export async function GET(request: NextRequest, { params }: RouteParams) { export async function GET(request: NextRequest, { params }: RouteParams) {
try { try {
@@ -103,6 +107,30 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
const { searchParams } = new URL(request.url); const { searchParams } = new URL(request.url);
const includeResolved = searchParams.get('includeResolved') !== 'false'; 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({ const comments = await db.comment.findMany({
where: { where: {
versionId, versionId,
@@ -158,6 +186,7 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}); });
const response = successResponse({ comments }); const response = successResponse({ comments });
response.headers.set('ETag', etag);
return withCacheControl(response, 'private, no-cache'); return withCacheControl(response, 'private, no-cache');
} catch (error) { } catch (error) {
console.error('Error fetching comments:', error); console.error('Error fetching comments:', error);
+6
View File
@@ -90,6 +90,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
thumbnailUrl: true, thumbnailUrl: true,
duration: true, duration: true,
versionNumber: true, versionNumber: true,
versionLabel: true,
providerId: true,
videoId: true,
originalUrl: true,
title: true,
isActive: true,
_count: { select: { comments: true } }, _count: { select: { comments: true } },
}, },
}), }),
+63 -15
View File
@@ -348,6 +348,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false); const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
const [, setDeletingCommentId] = useState<string | null>(null); const [, setDeletingCommentId] = useState<string | null>(null);
const isMutatingRef = useRef(false); const isMutatingRef = useRef(false);
const commentsEtagRef = useRef<Map<string, string>>(new Map());
const [previewImage, setPreviewImage] = useState<string | null>(null); const [previewImage, setPreviewImage] = useState<string | null>(null);
// Annotation state // Annotation state
@@ -515,8 +516,47 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const canResolveComments = !!video?.canResolveComments; const canResolveComments = !!video?.canResolveComments;
const apiBasePath = mode === 'dashboard' const apiBasePath = mode === 'dashboard'
? `/api/projects/${propProjectId}/videos/${videoId}` ? `/api/projects/${propProjectId}/videos/${videoId}?includeComments=false`
: `/api/watch/${videoId}?includeComments=true`; : `/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(() => { useEffect(() => {
async function fetchVideo() { async function fetchVideo() {
@@ -532,9 +572,19 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return; return;
} }
const response = await res.json(); const response = await res.json();
const data = response.data; const rawData = response.data as Omit<VideoData, 'versions'> & {
setVideo(data); versions?: Array<Version & { comments?: Comment[] }>;
const active = data.versions?.find((v: Version) => v.isActive) || data.versions?.[0]; };
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); if (active) setActiveVersionId(active.id);
} catch (err) { } catch (err) {
console.error('Error fetching video:', err); console.error('Error fetching video:', err);
@@ -546,6 +596,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
fetchVideo(); fetchVideo();
}, [apiBasePath, mode]); }, [apiBasePath, mode]);
useEffect(() => {
if (!activeVersionId) return;
void fetchVersionComments(activeVersionId, true);
}, [activeVersionId, fetchVersionComments]);
// Memoize active version lookup to avoid recalculating on every render // Memoize active version lookup to avoid recalculating on every render
const activeVersion = useMemo(() => { const activeVersion = useMemo(() => {
return video?.versions?.find((v) => v.id === activeVersionId) || 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 // Comment polling with Page Visibility API to pause when tab is hidden
useEffect(() => { useEffect(() => {
if (!activeVersion) return; if (!activeVersionId) return;
let intervalId: ReturnType<typeof setInterval> | null = null; let intervalId: ReturnType<typeof setInterval> | null = null;
let isPageVisible = true; let isPageVisible = true;
@@ -2429,14 +2484,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const poll = async () => { const poll = async () => {
try { try {
if (isMutatingRef.current || !isPageVisible) return; if (isMutatingRef.current || !isPageVisible) return;
await fetchVersionComments(activeVersionId, true);
const res = await fetch(apiBasePath, { cache: 'no-store' });
if (res.ok) {
const data = await res.json();
if (!isMutatingRef.current) {
setVideo(data.data);
}
}
} catch { /* silent */ } } catch { /* silent */ }
}; };
@@ -2454,7 +2502,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
if (intervalId) clearInterval(intervalId); if (intervalId) clearInterval(intervalId);
document.removeEventListener('visibilitychange', handleVisibilityChange); document.removeEventListener('visibilitychange', handleVisibilityChange);
}; };
}, [activeVersion, apiBasePath]); }, [activeVersionId, fetchVersionComments]);
const handleNewVersionUrlChange = (url: string) => { const handleNewVersionUrlChange = (url: string) => {
setNewVersionUrl(url); setNewVersionUrl(url);