mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat(video): lazy-load version comments and add ETag-based comment caching
This commit is contained in:
+1
-1
@@ -44,5 +44,5 @@ next-env.d.ts
|
||||
|
||||
# Progress (Internal Tracking)
|
||||
PROGRESS.md
|
||||
Optimization.md
|
||||
OPTIMIZATION.md
|
||||
.kilocode
|
||||
@@ -8,7 +8,6 @@
|
||||
|
||||
## Validation before finishing
|
||||
- Run `bun run check`.
|
||||
- Run `bun test <path>` for changed behavior; run `bun test` when changes are cross-cutting.
|
||||
|
||||
## Repo-specific conventions
|
||||
- Use `auth()` from `@/lib/auth` for server-side session reads.
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
}),
|
||||
},
|
||||
},
|
||||
});
|
||||
|
||||
@@ -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);
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
}),
|
||||
|
||||
@@ -348,6 +348,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||
const [, setDeletingCommentId] = useState<string | null>(null);
|
||||
const isMutatingRef = useRef(false);
|
||||
const commentsEtagRef = useRef<Map<string, string>>(new Map());
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(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<VideoData, 'versions'> & {
|
||||
versions?: Array<Version & { comments?: Comment[] }>;
|
||||
};
|
||||
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<typeof setInterval> | 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);
|
||||
|
||||
Reference in New Issue
Block a user