mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 09:36:08 +00:00
feat: add video watch progress tracking with resume functionality
Implements a complete watch progress system that allows users to: - Save playback position automatically every 5 seconds while watching - Resume from last position when returning to a video - Save progress on page leave using sendBeacon for reliability Also includes: - Optimized slug generation in projects and workspaces APIs (single query vs loop) - Added isActive filter to version queries across all video endpoints - Added pagination support for video and comment queries - Enhanced database pool management with connection limits and graceful shutdown - New WatchProgress Prisma model with user-version relations
This commit is contained in:
@@ -52,6 +52,11 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
try {
|
try {
|
||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId } = await params;
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
// Parse pagination params
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const limit = Math.min(parseInt(searchParams.get('limit') || '20'), 100);
|
||||||
|
const offset = parseInt(searchParams.get('offset') || '0');
|
||||||
|
|
||||||
const project = await db.project.findUnique({
|
const project = await db.project.findUnique({
|
||||||
where: { id: projectId },
|
where: { id: projectId },
|
||||||
@@ -64,10 +69,20 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
},
|
},
|
||||||
videos: {
|
videos: {
|
||||||
orderBy: { position: 'asc' },
|
orderBy: { position: 'asc' },
|
||||||
|
skip: offset,
|
||||||
|
take: limit,
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
take: 1,
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
},
|
},
|
||||||
_count: { select: { versions: true } },
|
_count: { select: { versions: true } },
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -15,25 +15,37 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { projectId, videoId } = await params;
|
const { projectId, videoId } = await params;
|
||||||
|
|
||||||
|
// Parse query params for pagination and options
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const commentLimit = Math.min(parseInt(searchParams.get('commentLimit') || '50'), 100);
|
||||||
|
const commentOffset = parseInt(searchParams.get('commentOffset') || '0');
|
||||||
|
const includeReplies = searchParams.get('includeReplies') === 'true';
|
||||||
|
|
||||||
const video = await db.video.findFirst({
|
const video = await db.video.findFirst({
|
||||||
where: { id: videoId, projectId },
|
where: { id: videoId, projectId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
|
take: 1,
|
||||||
include: {
|
include: {
|
||||||
comments: {
|
comments: {
|
||||||
orderBy: { timestamp: 'asc' },
|
orderBy: { timestamp: 'asc' },
|
||||||
|
skip: commentOffset,
|
||||||
|
take: commentLimit,
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
...(includeReplies ? {
|
||||||
orderBy: { createdAt: 'asc' },
|
replies: {
|
||||||
include: {
|
orderBy: { createdAt: 'asc' },
|
||||||
author: { select: { id: true, name: true, image: true } },
|
include: {
|
||||||
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 }, // Only top-level comments
|
where: { parentId: null }, // Only top-level comments
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -38,8 +38,14 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
orderBy: { position: 'asc' },
|
orderBy: { position: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
include: {
|
take: 1,
|
||||||
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
_count: { select: { comments: true } },
|
_count: { select: { comments: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -105,14 +105,19 @@ export async function POST(request: NextRequest) {
|
|||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
// Ensure uniqueness by appending random suffix if needed
|
// Find all existing slugs with the same prefix in a single query
|
||||||
|
const existingProjects = await db.project.findMany({
|
||||||
|
where: { slug: { startsWith: baseSlug } },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate unique slug from the results
|
||||||
|
const usedSlugs = new Set(existingProjects.map(p => p.slug));
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let attempts = 0;
|
let counter = 1;
|
||||||
while (attempts < 10) {
|
while (usedSlugs.has(slug)) {
|
||||||
const existing = await db.project.findUnique({ where: { slug } });
|
slug = `${baseSlug}-${counter}`;
|
||||||
if (!existing) break;
|
counter++;
|
||||||
slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
|
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
// Verify user has access to the workspace
|
// Verify user has access to the workspace
|
||||||
|
|||||||
@@ -0,0 +1,138 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { auth } from '@/lib/auth';
|
||||||
|
import { apiErrors, successResponse } from '@/lib/api-response';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ videoId: string }> };
|
||||||
|
|
||||||
|
// GET /api/watch/[videoId]/progress - Get watch progress for the current user
|
||||||
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
|
||||||
|
// Get the video and its active version
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: videoId },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVersion = video.versions[0];
|
||||||
|
if (!activeVersion) {
|
||||||
|
return apiErrors.notFound('Video version');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get watch progress for this user and version
|
||||||
|
const progress = await db.watchProgress.findUnique({
|
||||||
|
where: {
|
||||||
|
userId_versionId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: activeVersion.id,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse({
|
||||||
|
progress: progress ? progress.progress : 0,
|
||||||
|
duration: progress?.duration || activeVersion.duration || 0,
|
||||||
|
percentage: progress?.percentage || 0,
|
||||||
|
updatedAt: progress?.updatedAt || null,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error fetching watch progress:', error);
|
||||||
|
return apiErrors.internalError('Failed to fetch watch progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/watch/[videoId]/progress - Save watch progress for the current user
|
||||||
|
export async function POST(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const session = await auth();
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized('Authentication required');
|
||||||
|
}
|
||||||
|
|
||||||
|
const { videoId } = await params;
|
||||||
|
const body = await request.json();
|
||||||
|
const { progress, duration, versionId } = body;
|
||||||
|
|
||||||
|
if (typeof progress !== 'number' || progress < 0) {
|
||||||
|
return apiErrors.badRequest('Invalid progress value');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Get the video version
|
||||||
|
let targetVersionId = versionId;
|
||||||
|
|
||||||
|
if (!targetVersionId) {
|
||||||
|
const video = await db.video.findUnique({
|
||||||
|
where: { id: videoId },
|
||||||
|
include: {
|
||||||
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
|
take: 1,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
if (!video) {
|
||||||
|
return apiErrors.notFound('Video');
|
||||||
|
}
|
||||||
|
|
||||||
|
const activeVersion = video.versions[0];
|
||||||
|
if (!activeVersion) {
|
||||||
|
return apiErrors.notFound('Video version');
|
||||||
|
}
|
||||||
|
targetVersionId = activeVersion.id;
|
||||||
|
}
|
||||||
|
|
||||||
|
// Calculate percentage
|
||||||
|
const safeDuration = duration || 0;
|
||||||
|
const percentage = safeDuration > 0 ? Math.min(100, (progress / safeDuration) * 100) : 0;
|
||||||
|
|
||||||
|
// Upsert watch progress
|
||||||
|
const watchProgress = await db.watchProgress.upsert({
|
||||||
|
where: {
|
||||||
|
userId_versionId: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: targetVersionId,
|
||||||
|
},
|
||||||
|
},
|
||||||
|
update: {
|
||||||
|
progress,
|
||||||
|
duration: safeDuration,
|
||||||
|
percentage,
|
||||||
|
},
|
||||||
|
create: {
|
||||||
|
userId: session.user.id,
|
||||||
|
versionId: targetVersionId,
|
||||||
|
progress,
|
||||||
|
duration: safeDuration,
|
||||||
|
percentage,
|
||||||
|
},
|
||||||
|
});
|
||||||
|
|
||||||
|
return successResponse({
|
||||||
|
success: true,
|
||||||
|
progress: watchProgress.progress,
|
||||||
|
percentage: watchProgress.percentage,
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
console.error('Error saving watch progress:', error);
|
||||||
|
return apiErrors.internalError('Failed to save watch progress');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -11,30 +11,39 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
|
|||||||
const session = await auth();
|
const session = await auth();
|
||||||
const { videoId } = await params;
|
const { videoId } = await params;
|
||||||
|
|
||||||
|
// Parse query params
|
||||||
|
const searchParams = request.nextUrl.searchParams;
|
||||||
|
const includeComments = searchParams.get('includeComments') === 'true';
|
||||||
|
|
||||||
const video = await db.video.findUnique({
|
const video = await db.video.findUnique({
|
||||||
where: { id: videoId },
|
where: { id: videoId },
|
||||||
include: {
|
include: {
|
||||||
project: true,
|
project: true,
|
||||||
versions: {
|
versions: {
|
||||||
|
where: { isActive: true },
|
||||||
orderBy: { versionNumber: 'desc' },
|
orderBy: { versionNumber: 'desc' },
|
||||||
include: {
|
take: 1,
|
||||||
comments: {
|
...(includeComments ? {
|
||||||
orderBy: { timestamp: 'asc' },
|
include: {
|
||||||
where: { parentId: null },
|
comments: {
|
||||||
include: {
|
orderBy: { timestamp: 'asc' },
|
||||||
author: { select: { id: true, name: true, image: true } },
|
where: { parentId: null },
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
include: {
|
||||||
replies: {
|
author: { select: { id: true, name: true, image: true } },
|
||||||
orderBy: { createdAt: 'asc' },
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
include: {
|
|
||||||
author: { select: { id: true, name: true, image: true } },
|
|
||||||
tag: { select: { id: true, name: true, color: true } },
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
_count: { select: { comments: true } },
|
||||||
},
|
},
|
||||||
_count: { select: { comments: true } },
|
} : {
|
||||||
},
|
select: {
|
||||||
|
id: true,
|
||||||
|
thumbnailUrl: true,
|
||||||
|
duration: true,
|
||||||
|
versionNumber: true,
|
||||||
|
_count: { select: { comments: true } },
|
||||||
|
},
|
||||||
|
}),
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
});
|
});
|
||||||
|
|||||||
@@ -63,13 +63,19 @@ export async function POST(request: NextRequest) {
|
|||||||
.replace(/\s+/g, '-')
|
.replace(/\s+/g, '-')
|
||||||
.replace(/-+/g, '-');
|
.replace(/-+/g, '-');
|
||||||
|
|
||||||
|
// Find all existing slugs with the same prefix in a single query
|
||||||
|
const existingWorkspaces = await db.workspace.findMany({
|
||||||
|
where: { slug: { startsWith: baseSlug } },
|
||||||
|
select: { slug: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
// Generate unique slug from the results
|
||||||
|
const usedSlugs = new Set(existingWorkspaces.map(w => w.slug));
|
||||||
let slug = baseSlug;
|
let slug = baseSlug;
|
||||||
let attempts = 0;
|
let counter = 1;
|
||||||
while (attempts < 10) {
|
while (usedSlugs.has(slug)) {
|
||||||
const existing = await db.workspace.findUnique({ where: { slug } });
|
slug = `${baseSlug}-${counter}`;
|
||||||
if (!existing) break;
|
counter++;
|
||||||
slug = `${baseSlug}-${Math.random().toString(36).substring(2, 6)}`;
|
|
||||||
attempts++;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
const workspace = await db.workspace.create({
|
const workspace = await db.workspace.create({
|
||||||
|
|||||||
@@ -2,6 +2,7 @@
|
|||||||
|
|
||||||
import { useState, useRef, useCallback, useEffect } from 'react';
|
import { useState, useRef, useCallback, useEffect } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
|
import { usePathname } from 'next/navigation';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import {
|
import {
|
||||||
ArrowLeft,
|
ArrowLeft,
|
||||||
@@ -155,6 +156,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const playerRef = useRef<YT.Player | null>(null);
|
const playerRef = useRef<YT.Player | null>(null);
|
||||||
const timelineRef = useRef<HTMLDivElement>(null);
|
const timelineRef = useRef<HTMLDivElement>(null);
|
||||||
const videoContainerRef = useRef<HTMLDivElement>(null);
|
const videoContainerRef = useRef<HTMLDivElement>(null);
|
||||||
|
const pathname = usePathname();
|
||||||
const [video, setVideo] = useState<VideoData | null>(null);
|
const [video, setVideo] = useState<VideoData | null>(null);
|
||||||
const [loading, setLoading] = useState(true);
|
const [loading, setLoading] = useState(true);
|
||||||
const [error, setError] = useState('');
|
const [error, setError] = useState('');
|
||||||
@@ -168,6 +170,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
const [cursorIdle, setCursorIdle] = useState(false);
|
const [cursorIdle, setCursorIdle] = useState(false);
|
||||||
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
const cursorIdleTimerRef = useRef<ReturnType<typeof setTimeout> | null>(null);
|
||||||
|
const lastPathnameRef = useRef<string>(pathname);
|
||||||
|
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
||||||
@@ -188,6 +191,13 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
const [selectedTimestamp, setSelectedTimestamp] = useState<number | null>(null);
|
||||||
const [showResolved, setShowResolved] = useState(false);
|
const [showResolved, setShowResolved] = useState(false);
|
||||||
|
|
||||||
|
// Watch progress state
|
||||||
|
const [savedProgress, setSavedProgress] = useState<number | null>(null);
|
||||||
|
const [showResumePrompt, setShowResumePrompt] = useState(false);
|
||||||
|
const progressSaveTimerRef = useRef<ReturnType<typeof setInterval> | null>(null);
|
||||||
|
const lastSavedProgressRef = useRef<number>(0);
|
||||||
|
const [progressFetchKey, setProgressFetchKey] = useState(0);
|
||||||
|
|
||||||
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
const [replyingTo, setReplyingTo] = useState<string | null>(null);
|
||||||
const [replyText, setReplyText] = useState('');
|
const [replyText, setReplyText] = useState('');
|
||||||
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
const [isSubmittingReply, setIsSubmittingReply] = useState(false);
|
||||||
@@ -392,6 +402,116 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
});
|
});
|
||||||
}, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]);
|
}, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]);
|
||||||
|
|
||||||
|
// Load watch progress when video is loaded (authenticated users only)
|
||||||
|
const loadWatchProgress = useCallback(async (showPrompt = true) => {
|
||||||
|
if (!video?.isAuthenticated || !activeVersionId) return;
|
||||||
|
|
||||||
|
// Reset state
|
||||||
|
setSavedProgress(null);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
|
||||||
|
try {
|
||||||
|
// Use cache: 'no-store' to always fetch fresh data
|
||||||
|
const res = await fetch(`/api/watch/${videoId}/progress`, { cache: 'no-store' });
|
||||||
|
if (res.ok) {
|
||||||
|
const response = await res.json();
|
||||||
|
const progress = response.data?.progress || 0;
|
||||||
|
const percentage = response.data?.percentage || 0;
|
||||||
|
|
||||||
|
// Only show resume prompt if progress is between 5% and 95%
|
||||||
|
if (showPrompt && percentage > 5 && percentage < 95) {
|
||||||
|
setSavedProgress(progress);
|
||||||
|
setShowResumePrompt(true);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
} catch (err) {
|
||||||
|
console.error('Error loading watch progress:', err);
|
||||||
|
}
|
||||||
|
}, [video?.isAuthenticated, activeVersionId, videoId]);
|
||||||
|
|
||||||
|
// Load progress on mount and when dependencies change
|
||||||
|
useEffect(() => {
|
||||||
|
loadWatchProgress();
|
||||||
|
}, [loadWatchProgress, progressFetchKey]);
|
||||||
|
|
||||||
|
// Refetch progress when pathname changes (user navigates back to this page)
|
||||||
|
useEffect(() => {
|
||||||
|
if (lastPathnameRef.current !== pathname) {
|
||||||
|
const previousPath = lastPathnameRef.current;
|
||||||
|
lastPathnameRef.current = pathname;
|
||||||
|
|
||||||
|
// If we navigated away and came back to this video page, refetch progress
|
||||||
|
if (previousPath !== pathname) {
|
||||||
|
setProgressFetchKey(k => k + 1);
|
||||||
|
}
|
||||||
|
}
|
||||||
|
}, [pathname]);
|
||||||
|
|
||||||
|
// Save watch progress periodically while playing (authenticated users only)
|
||||||
|
useEffect(() => {
|
||||||
|
if (!video?.isAuthenticated || !isReady || !activeVersionId) return;
|
||||||
|
|
||||||
|
// Save progress every 5 seconds while playing
|
||||||
|
progressSaveTimerRef.current = setInterval(() => {
|
||||||
|
if (currentTime > 0 && Math.abs(currentTime - lastSavedProgressRef.current) >= 2) {
|
||||||
|
// Save to API
|
||||||
|
fetch(`/api/watch/${videoId}/progress`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({
|
||||||
|
progress: currentTime,
|
||||||
|
duration: videoDuration,
|
||||||
|
versionId: activeVersionId,
|
||||||
|
}),
|
||||||
|
}).catch((err) => console.error('Error saving watch progress:', err));
|
||||||
|
|
||||||
|
lastSavedProgressRef.current = currentTime;
|
||||||
|
}
|
||||||
|
}, 5000);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
if (progressSaveTimerRef.current) {
|
||||||
|
clearInterval(progressSaveTimerRef.current);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
}, [video?.isAuthenticated, isReady, currentTime, videoDuration, activeVersionId, videoId]);
|
||||||
|
|
||||||
|
// Save progress when user leaves the page
|
||||||
|
useEffect(() => {
|
||||||
|
if (!video?.isAuthenticated) return;
|
||||||
|
|
||||||
|
const saveProgressOnLeave = () => {
|
||||||
|
if (currentTime > 0 && navigator.sendBeacon) {
|
||||||
|
// Use sendBeacon for reliable save on page unload
|
||||||
|
const data = new Blob([JSON.stringify({
|
||||||
|
progress: currentTime,
|
||||||
|
duration: videoDuration,
|
||||||
|
versionId: activeVersionId,
|
||||||
|
})], { type: 'application/json' });
|
||||||
|
navigator.sendBeacon(`/api/watch/${videoId}/progress`, data);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
window.addEventListener('beforeunload', saveProgressOnLeave);
|
||||||
|
return () => window.removeEventListener('beforeunload', saveProgressOnLeave);
|
||||||
|
}, [video?.isAuthenticated, currentTime, videoDuration, activeVersionId, videoId]);
|
||||||
|
|
||||||
|
// Resume playback from saved position
|
||||||
|
const handleResumeFromSaved = useCallback(() => {
|
||||||
|
if (savedProgress !== null && playerRef.current?.seekTo) {
|
||||||
|
playerRef.current.seekTo(savedProgress, true);
|
||||||
|
setCurrentTime(savedProgress);
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
setSavedProgress(null);
|
||||||
|
}
|
||||||
|
}, [savedProgress]);
|
||||||
|
|
||||||
|
// Dismiss resume prompt
|
||||||
|
const handleDismissResume = useCallback(() => {
|
||||||
|
setShowResumePrompt(false);
|
||||||
|
setSavedProgress(null);
|
||||||
|
}, []);
|
||||||
|
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!isReady || !playerRef.current) return;
|
if (!isReady || !playerRef.current) return;
|
||||||
|
|
||||||
@@ -1720,6 +1840,44 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
{/* Resume playback prompt */}
|
||||||
|
{showResumePrompt && savedProgress !== null && (
|
||||||
|
<div className="absolute inset-0 flex items-center justify-center bg-black/40 z-10">
|
||||||
|
<div className="bg-background/95 backdrop-blur-sm rounded-lg p-4 shadow-lg max-w-sm mx-4">
|
||||||
|
<div className="flex items-center gap-3 mb-3">
|
||||||
|
<div className="w-10 h-10 rounded-full bg-primary/10 flex items-center justify-center">
|
||||||
|
<Clock className="h-5 w-5 text-primary" />
|
||||||
|
</div>
|
||||||
|
<div>
|
||||||
|
<p className="font-medium text-sm">Continue watching?</p>
|
||||||
|
<p className="text-xs text-muted-foreground">
|
||||||
|
Resume from {formatTime(savedProgress)}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
<div className="flex gap-2">
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="default"
|
||||||
|
onClick={handleResumeFromSaved}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
<Play className="h-4 w-4 mr-1" />
|
||||||
|
Resume
|
||||||
|
</Button>
|
||||||
|
<Button
|
||||||
|
size="sm"
|
||||||
|
variant="ghost"
|
||||||
|
onClick={handleDismissResume}
|
||||||
|
className="flex-1"
|
||||||
|
>
|
||||||
|
Start over
|
||||||
|
</Button>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
|
|||||||
@@ -1,11 +1,52 @@
|
|||||||
import { PrismaClient } from '@prisma/client';
|
import { PrismaClient } from '@prisma/client';
|
||||||
import { PrismaPg } from '@prisma/adapter-pg';
|
import { PrismaPg } from '@prisma/adapter-pg';
|
||||||
import { Pool } from 'pg';
|
import { Pool, PoolConfig } from 'pg';
|
||||||
|
|
||||||
const globalForPrisma = globalThis as unknown as {
|
const globalForPrisma = globalThis as unknown as {
|
||||||
prisma: PrismaClient | undefined;
|
prisma: PrismaClient | undefined;
|
||||||
};
|
};
|
||||||
|
|
||||||
|
const globalForPool = globalThis as unknown as {
|
||||||
|
pgPool: Pool | undefined;
|
||||||
|
};
|
||||||
|
|
||||||
|
function createPool(connectionString: string): Pool {
|
||||||
|
// Prevent multiple pools from being created during development (Next.js hot reload)
|
||||||
|
if (globalForPool.pgPool) {
|
||||||
|
return globalForPool.pgPool;
|
||||||
|
}
|
||||||
|
|
||||||
|
const poolConfig: PoolConfig = {
|
||||||
|
connectionString,
|
||||||
|
max: 20, // Maximum number of connections
|
||||||
|
idleTimeoutMillis: 30000, // Close idle connections after 30 seconds
|
||||||
|
connectionTimeoutMillis: 5000, // Return error after 5 seconds if can't connect
|
||||||
|
};
|
||||||
|
|
||||||
|
const pool = new Pool(poolConfig);
|
||||||
|
|
||||||
|
// Add error handling for pool errors
|
||||||
|
pool.on('error', (err, client) => {
|
||||||
|
console.error('Unexpected database pool error:', err.message);
|
||||||
|
// Don't crash the app on unexpected pool errors
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('connect', () => {
|
||||||
|
console.debug('New database connection established');
|
||||||
|
});
|
||||||
|
|
||||||
|
pool.on('acquire', () => {
|
||||||
|
console.debug('Connection acquired from pool');
|
||||||
|
});
|
||||||
|
|
||||||
|
// Store pool globally to prevent multiple instances during development
|
||||||
|
if (process.env.NODE_ENV !== 'production') {
|
||||||
|
globalForPool.pgPool = pool;
|
||||||
|
}
|
||||||
|
|
||||||
|
return pool;
|
||||||
|
}
|
||||||
|
|
||||||
function createPrismaClient() {
|
function createPrismaClient() {
|
||||||
// In development without a database, we'll create a mock-friendly client
|
// In development without a database, we'll create a mock-friendly client
|
||||||
// For production or when DATABASE_URL is set, use the real adapter
|
// For production or when DATABASE_URL is set, use the real adapter
|
||||||
@@ -14,13 +55,14 @@ function createPrismaClient() {
|
|||||||
if (!connectionString) {
|
if (!connectionString) {
|
||||||
console.warn('DATABASE_URL not set - database features will not work');
|
console.warn('DATABASE_URL not set - database features will not work');
|
||||||
// Return a client that will throw clear errors when used
|
// Return a client that will throw clear errors when used
|
||||||
|
const pool = createPool('postgresql://localhost:5432/dummy');
|
||||||
return new PrismaClient({
|
return new PrismaClient({
|
||||||
// This will fail on actual DB operations but allows imports to work
|
// This will fail on actual DB operations but allows imports to work
|
||||||
adapter: new PrismaPg(new Pool({ connectionString: 'postgresql://localhost:5432/dummy' })),
|
adapter: new PrismaPg(pool),
|
||||||
});
|
});
|
||||||
}
|
}
|
||||||
|
|
||||||
const pool = new Pool({ connectionString });
|
const pool = createPool(connectionString);
|
||||||
const adapter = new PrismaPg(pool);
|
const adapter = new PrismaPg(pool);
|
||||||
|
|
||||||
return new PrismaClient({
|
return new PrismaClient({
|
||||||
@@ -35,4 +77,21 @@ if (process.env.NODE_ENV !== 'production') {
|
|||||||
globalForPrisma.prisma = db;
|
globalForPrisma.prisma = db;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// Graceful shutdown handler
|
||||||
|
async function shutdown() {
|
||||||
|
console.log('Shutting down database connections...');
|
||||||
|
|
||||||
|
if (globalForPool.pgPool) {
|
||||||
|
await globalForPool.pgPool.end();
|
||||||
|
console.log('Database pool closed');
|
||||||
|
}
|
||||||
|
|
||||||
|
await db.$disconnect();
|
||||||
|
console.log('Prisma client disconnected');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Register shutdown handlers
|
||||||
|
process.on('SIGINT', shutdown);
|
||||||
|
process.on('SIGTERM', shutdown);
|
||||||
|
|
||||||
export default db;
|
export default db;
|
||||||
|
|||||||
@@ -31,6 +31,7 @@ model User {
|
|||||||
comments Comment[]
|
comments Comment[]
|
||||||
projectMemberships ProjectMember[]
|
projectMemberships ProjectMember[]
|
||||||
notificationSetting NotificationSetting?
|
notificationSetting NotificationSetting?
|
||||||
|
watchProgress WatchProgress[]
|
||||||
|
|
||||||
@@map("users")
|
@@map("users")
|
||||||
}
|
}
|
||||||
@@ -158,6 +159,7 @@ model Project {
|
|||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@index([workspaceId])
|
@@index([workspaceId])
|
||||||
|
@@index([workspaceId, updatedAt(sort: Desc)])
|
||||||
@@map("projects")
|
@@map("projects")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -239,9 +241,11 @@ model VideoVersion {
|
|||||||
|
|
||||||
// Relations
|
// Relations
|
||||||
comments Comment[]
|
comments Comment[]
|
||||||
|
watchProgress WatchProgress[]
|
||||||
|
|
||||||
@@unique([videoParentId, versionNumber])
|
@@unique([videoParentId, versionNumber])
|
||||||
@@index([videoParentId])
|
@@index([videoParentId])
|
||||||
|
@@index([videoParentId, isActive])
|
||||||
@@map("video_versions")
|
@@map("video_versions")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -293,6 +297,8 @@ model Comment {
|
|||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
@@index([tagId])
|
@@index([tagId])
|
||||||
|
@@index([versionId, isResolved, timestamp])
|
||||||
|
@@index([versionId, parentId, createdAt])
|
||||||
@@map("comments")
|
@@map("comments")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -345,6 +351,7 @@ model ShareLink {
|
|||||||
|
|
||||||
@@index([projectId])
|
@@index([projectId])
|
||||||
@@index([token])
|
@@index([token])
|
||||||
|
@@index([token, expiresAt])
|
||||||
@@map("share_links")
|
@@map("share_links")
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -386,6 +393,37 @@ model NotificationSetting {
|
|||||||
@@map("notification_settings")
|
@@map("notification_settings")
|
||||||
}
|
}
|
||||||
|
|
||||||
|
// ============================================
|
||||||
|
// WATCH PROGRESS
|
||||||
|
// ============================================
|
||||||
|
|
||||||
|
model WatchProgress {
|
||||||
|
id String @id @default(cuid())
|
||||||
|
|
||||||
|
// User relation (optional for guest progress, though typically requires auth)
|
||||||
|
userId String
|
||||||
|
user User @relation(fields: [userId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Video version relation
|
||||||
|
versionId String
|
||||||
|
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
||||||
|
|
||||||
|
// Progress data
|
||||||
|
progress Float // Current playback position in seconds
|
||||||
|
duration Float // Total video duration at time of save
|
||||||
|
percentage Float // Progress as percentage (0-100)
|
||||||
|
|
||||||
|
// Timestamps
|
||||||
|
updatedAt DateTime @updatedAt
|
||||||
|
createdAt DateTime @default(now())
|
||||||
|
|
||||||
|
// One progress record per user per version
|
||||||
|
@@unique([userId, versionId])
|
||||||
|
@@index([userId])
|
||||||
|
@@index([versionId])
|
||||||
|
@@map("watch_progress")
|
||||||
|
}
|
||||||
|
|
||||||
// Rate limiting table (created as UNLOGGED via raw SQL migration)
|
// Rate limiting table (created as UNLOGGED via raw SQL migration)
|
||||||
// Defined here so `prisma db push` doesn't drop it
|
// Defined here so `prisma db push` doesn't drop it
|
||||||
model RateLimit {
|
model RateLimit {
|
||||||
|
|||||||
Reference in New Issue
Block a user