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 (Internal Tracking)
|
||||||
PROGRESS.md
|
PROGRESS.md
|
||||||
Optimization.md
|
OPTIMIZATION.md
|
||||||
.kilocode
|
.kilocode
|
||||||
@@ -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 } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
@@ -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 } },
|
||||||
},
|
},
|
||||||
}),
|
}),
|
||||||
|
|||||||
@@ -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);
|
||||||
|
|||||||
Reference in New Issue
Block a user