diff --git a/app/(dashboard)/projects/[projectId]/page.tsx b/app/(dashboard)/projects/[projectId]/page.tsx
index 395a17c..5e01098 100644
--- a/app/(dashboard)/projects/[projectId]/page.tsx
+++ b/app/(dashboard)/projects/[projectId]/page.tsx
@@ -2,26 +2,12 @@ import Link from 'next/link';
import { notFound, redirect } from 'next/navigation';
import {
ArrowLeft,
- Globe,
- Lock,
- UserPlus,
} from 'lucide-react';
import { GuestGate } from '@/components/guest-gate';
import { auth } from '@/lib/auth';
import { db } from '@/lib/db';
import { ProjectContentClient } from './project-content-client';
-function VisibilityIcon({ visibility }: { visibility: string }) {
- switch (visibility) {
- case 'PUBLIC':
- return ;
- case 'INVITE':
- return ;
- default:
- return ;
- }
-}
-
function formatDuration(seconds: number | null): string {
if (!seconds) return '0:00';
const totalSeconds = Math.floor(seconds);
diff --git a/app/(dashboard)/projects/[projectId]/share/page.tsx b/app/(dashboard)/projects/[projectId]/share/page.tsx
index b5a5a40..d704f46 100644
--- a/app/(dashboard)/projects/[projectId]/share/page.tsx
+++ b/app/(dashboard)/projects/[projectId]/share/page.tsx
@@ -2,7 +2,7 @@
import { useState, useEffect } from 'react';
import Link from 'next/link';
-import { ArrowLeft, Copy, Check, Loader2, UserPlus, Trash2, Share2, Globe, Lock, Mail, X } from 'lucide-react';
+import { ArrowLeft, Copy, Check, Loader2, UserPlus, Share2, Globe, Lock, Mail, X } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
import { Input } from '@/components/ui/input';
diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx
index 6e4b920..05ea5e2 100644
--- a/app/(dashboard)/settings/page.tsx
+++ b/app/(dashboard)/settings/page.tsx
@@ -1,7 +1,7 @@
'use client';
import { useState, useEffect, useCallback } from 'react';
-import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, ExternalLink, Globe } from 'lucide-react';
+import { Bell, Send, Mail, CheckCircle2, AlertCircle, Loader2, Globe } from 'lucide-react';
import { Button } from '@/components/ui/button';
import { Input } from '@/components/ui/input';
import { Label } from '@/components/ui/label';
diff --git a/app/api/auth/register/route.ts b/app/api/auth/register/route.ts
index 848f327..ffdcc7f 100644
--- a/app/api/auth/register/route.ts
+++ b/app/api/auth/register/route.ts
@@ -2,7 +2,7 @@ import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import bcrypt from 'bcryptjs';
import { checkRateLimit, getClientIp, rateLimitHeaders, RATE_LIMIT_CONFIGS } from '@/lib/rate-limit';
-import { apiErrors, successResponse, ErrorCode, withCacheControl } from '@/lib/api-response';
+import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
export async function POST(request: NextRequest) {
try {
diff --git a/app/api/comments/[commentId]/route.ts b/app/api/comments/[commentId]/route.ts
index 01e3225..85fa5d3 100644
--- a/app/api/comments/[commentId]/route.ts
+++ b/app/api/comments/[commentId]/route.ts
@@ -107,7 +107,8 @@ export async function GET(request: NextRequest, { params }: RouteParams) {
}
// Strip internal project data from response
- const { version: _version, ...commentData } = comment;
+ const commentData = { ...comment } as Omit & { version?: unknown };
+ delete commentData.version;
const response = successResponse(commentData);
return withCacheControl(response, 'private, no-cache');
} catch (error) {
diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts
index cd65227..6ade886 100644
--- a/app/api/versions/[versionId]/comments/route.ts
+++ b/app/api/versions/[versionId]/comments/route.ts
@@ -1,12 +1,13 @@
import { NextRequest } from 'next/server';
import { db } from '@/lib/db';
import { auth } from '@/lib/auth';
-import { validateOptionalUrl } from '@/lib/validation';
import { rateLimit } from '@/lib/rate-limit';
import { notifyProjectOwner } from '@/lib/notifications';
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
type RouteParams = { params: Promise<{ versionId: string }> };
+const SAFE_IMAGE_PATH = /^\/api\/upload\/image\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
+const SAFE_AUDIO_PATH = /^\/api\/upload\/audio\/[0-9a-f]{8}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{4}-[0-9a-f]{12}\.[a-z0-9]+$/i;
// GET /api/versions/[versionId]/comments
export async function GET(request: NextRequest, { params }: RouteParams) {
@@ -218,19 +219,12 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
return apiErrors.badRequest('Guest name is required for guest comments');
}
- // Validate voice URL uses safe scheme (allow internal /api/ paths)
- if (voiceUrl && !voiceUrl.startsWith('/api/')) {
- const voiceUrlError = validateOptionalUrl(voiceUrl, 'Voice URL');
- if (voiceUrlError) {
- return apiErrors.badRequest(voiceUrlError);
- }
+ if (voiceUrl && !SAFE_AUDIO_PATH.test(voiceUrl)) {
+ return apiErrors.badRequest('Voice URL must reference an uploaded audio file');
}
- if (imageUrl && !imageUrl.startsWith('/api/')) {
- const imageUrlError = validateOptionalUrl(imageUrl, 'Image URL');
- if (imageUrlError) {
- return apiErrors.badRequest(imageUrlError);
- }
+ if (imageUrl && !SAFE_IMAGE_PATH.test(imageUrl)) {
+ return apiErrors.badRequest('Image URL must reference an uploaded image file');
}
const comment = await db.comment.create({
diff --git a/components/annotation-canvas.tsx b/components/annotation-canvas.tsx
index 288d7e8..fdc145c 100644
--- a/components/annotation-canvas.tsx
+++ b/components/annotation-canvas.tsx
@@ -39,6 +39,7 @@ export const AnnotationCanvas = forwardRef ({
@@ -159,11 +160,6 @@ export const AnnotationCanvas = forwardRef {
- if (strokes.length === 0) return;
- onConfirm?.(strokes);
- }, [strokes, onConfirm]);
-
// View mode: click to dismiss
const handleViewClick = useCallback((e: React.MouseEvent) => {
if (mode === 'view') {
diff --git a/components/guest-gate.tsx b/components/guest-gate.tsx
index 1112bcc..a13cbb7 100644
--- a/components/guest-gate.tsx
+++ b/components/guest-gate.tsx
@@ -1,6 +1,6 @@
'use client';
-import { useState, useEffect, type ReactNode } from 'react';
+import { useState, type ReactNode } from 'react';
import Link from 'next/link';
import { User } from 'lucide-react';
import { Button } from '@/components/ui/button';
@@ -12,16 +12,14 @@ import { Input } from '@/components/ui/input';
* Only renders children after the guest confirms their name.
*/
export function GuestGate({ children }: { children: ReactNode }) {
- const [guestName, setGuestName] = useState('');
- const [confirmed, setConfirmed] = useState(false);
-
- useEffect(() => {
- const saved = localStorage.getItem('openframe_guest_name');
- if (saved) {
- setGuestName(saved);
- setConfirmed(true);
- }
- }, []);
+ const [guestName, setGuestName] = useState(() => {
+ if (typeof window === 'undefined') return '';
+ return localStorage.getItem('openframe_guest_name') ?? '';
+ });
+ const [confirmed, setConfirmed] = useState(() => {
+ if (typeof window === 'undefined') return false;
+ return Boolean(localStorage.getItem('openframe_guest_name'));
+ });
if (confirmed) {
return <>{children}>;
diff --git a/components/theme-toggle.tsx b/components/theme-toggle.tsx
index dcc49b1..b43c2c0 100644
--- a/components/theme-toggle.tsx
+++ b/components/theme-toggle.tsx
@@ -12,7 +12,7 @@ import {
} from '@/components/ui/dropdown-menu';
export function ThemeToggle() {
- const { setTheme, theme } = useTheme();
+ const { setTheme } = useTheme();
return (
diff --git a/components/video-page-content.tsx b/components/video-page-content.tsx
index 4a3b8d1..cedaa95 100644
--- a/components/video-page-content.tsx
+++ b/components/video-page-content.tsx
@@ -1,7 +1,6 @@
'use client';
import { useState, useRef, useCallback, useEffect, useMemo } from 'react';
-import { List } from 'react-window';
import Hls, { type Level } from 'hls.js';
import Link from 'next/link';
import { usePathname, useRouter } from 'next/navigation';
@@ -23,7 +22,6 @@ import {
CheckCircle2,
Circle,
ChevronDown,
- ChevronUp,
MoreVertical,
Plus,
Loader2,
@@ -80,7 +78,7 @@ import { cn } from '@/lib/utils';
import { parseVideoUrl, getThumbnailUrl, fetchVideoMetadata, type VideoSource } from '@/lib/video-providers';
import { AnnotationCanvas, type AnnotationStroke, type AnnotationCanvasHandle } from '@/components/annotation-canvas';
import { Linkify } from '@/components/linkify';
-import { Tabs, TabsContent, TabsList, TabsTrigger } from '@/components/ui/tabs';
+import { Tabs, TabsList, TabsTrigger } from '@/components/ui/tabs';
import * as tus from 'tus-js-client';
import { UploadCloud, FileVideo } from 'lucide-react';
@@ -340,7 +338,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const [isEditingAnnotation, setIsEditingAnnotation] = useState(false);
const editAnnotationCanvasRef = useRef(null);
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
- const [deletingCommentId, setDeletingCommentId] = useState(null);
+ const [, setDeletingCommentId] = useState(null);
const isMutatingRef = useRef(false);
const [previewImage, setPreviewImage] = useState(null);
@@ -543,6 +541,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
video?.versions?.find((v) => v.isActive) ||
video?.versions?.[0];
}, [video?.versions, activeVersionId]);
+ const activeProviderId = activeVersion?.providerId;
+ const activeVersionDuration = activeVersion?.duration;
const isDownloadingVideo = activeDownloadTarget !== null;
@@ -679,7 +679,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}
fetchTags();
- }, [projectId]);
+ }, [projectId, selectedTagId]);
// Load YouTube API immediately on component mount (async, non-blocking)
useEffect(() => {
@@ -703,9 +703,9 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}, [isApiLoaded]);
useEffect(() => {
- if (!activeVersion) return;
- const isYoutube = activeVersion.providerId === 'youtube';
- const isBunny = activeVersion.providerId === 'bunny';
+ if (!activeProviderId) return;
+ const isYoutube = activeProviderId === 'youtube';
+ const isBunny = activeProviderId === 'bunny';
if (isYoutube && !isApiLoaded) return;
if (!isYoutube && !isBunny) return;
@@ -1041,16 +1041,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
bunnyRetryTimerRef.current = null;
}
};
- }, [activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
+ }, [activeProviderId, activeVersionId, embedUrl, isApiLoaded, video?.isAuthenticated, videoId]);
// Save detected duration to DB if the version doesn't have one stored
useEffect(() => {
- if (!videoDuration || !activeVersion || !propProjectId) return;
- if (activeVersion.duration && activeVersion.duration > 0) return;
+ if (!videoDuration || !activeVersionId || !propProjectId) return;
+ if (activeVersionDuration && activeVersionDuration > 0) return;
const roundedDuration = Math.round(videoDuration);
// Fire-and-forget PATCH to save duration
- fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersion.id}`, {
+ fetch(`/api/projects/${propProjectId}/videos/${videoId}/versions/${activeVersionId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ duration: roundedDuration }),
@@ -1062,11 +1062,11 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return {
...prev,
versions: prev.versions.map((v) =>
- v.id === activeVersion.id ? { ...v, duration: roundedDuration } : v
+ v.id === activeVersionId ? { ...v, duration: roundedDuration } : v
),
};
});
- }, [videoDuration, activeVersion?.id, activeVersion?.duration, propProjectId, videoId]);
+ }, [videoDuration, activeVersionDuration, activeVersionId, propProjectId, videoId]);
// Load watch progress when video is loaded (authenticated users only)
const loadWatchProgress = useCallback(async (showPrompt = true) => {
@@ -1236,7 +1236,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setShowResumePrompt(false);
setSavedProgress(null);
}
- }, [savedProgress, activeVersion?.providerId]);
+ }, [savedProgress]);
// Dismiss resume prompt
const handleDismissResume = useCallback(() => {
@@ -1510,7 +1510,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
}
}, [isDragging, currentTime, handleSeekToTimestamp]);
- const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }, imageData?: { url: string }) => {
+ const handleAddComment = useCallback(async (voiceData?: { url: string; duration: number }) => {
if (!voiceData && !imageBlob && !commentText.trim() && !annotationStrokes && !isAnnotating) return;
if (!activeVersion) return;
@@ -1625,7 +1625,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to add comment');
}
- } catch (err) {
+ } catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -1643,7 +1643,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingImage(false);
isMutatingRef.current = false;
}
- }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
+ }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, currentUserName, selectedTagId, availableTags, imageBlob, annotationStrokes, isAnnotating]);
const handleImageSelect = useCallback((e: React.ChangeEvent, isReply: boolean = false) => {
const file = e.target.files?.[0];
@@ -1870,7 +1870,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
clearInterval(recordingTimerRef.current);
}
};
- }, []);
+ }, [stopVoiceTracking]);
const submitCommentWithMedia = useCallback(async () => {
if (!activeVersion) return;
@@ -1886,8 +1886,6 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
try {
let voiceData: { url: string; duration: number } | undefined;
- let imageData: { url: string } | undefined;
-
if (audioBlob) {
const formData = new FormData();
formData.append('audio', audioBlob, 'recording.webm');
@@ -1897,7 +1895,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
voiceData = { url: uploadData.data.url, duration: recordingTime };
}
- await handleAddComment(voiceData, imageData); // Image is uploaded inside handleAddComment for both text/image cases
+ await handleAddComment(voiceData); // Image is uploaded inside handleAddComment for both text/image cases
setAudioBlob(null);
setRecordingTime(0);
@@ -1958,7 +1956,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to update comment');
}
- } catch (err) {
+ } catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -2103,7 +2101,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
});
toast.error('Failed to add reply');
}
- } catch (err) {
+ } catch {
setVideo((prev) => {
if (!prev) return prev;
return {
@@ -2128,7 +2126,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setIsUploadingReplyImage(false);
isMutatingRef.current = false;
}
- }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, replyImageBlob]);
+ }, [replyText, activeVersion, activeVersionId, comments, currentTime, isGuest, guestName, currentUserName, replyImageBlob]);
const startReplyRecording = useCallback(async () => {
try {
@@ -4402,6 +4400,7 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onClick={() => setPreviewImage(null)}
>
{previewImage && (
+ // eslint-disable-next-line @next/next/no-img-element
(
return NextResponse.json(body, { status });
}
-export function withCacheControl(response: Response, value: string): Response {
+export function withCacheControl(response: Response, value: string): Response {
response.headers.set('Cache-Control', value);
return response;
}
diff --git a/lib/db.ts b/lib/db.ts
index b76649a..cb1bb3b 100644
--- a/lib/db.ts
+++ b/lib/db.ts
@@ -26,7 +26,7 @@ function createPool(connectionString: string): Pool {
const pool = new Pool(poolConfig);
// Add error handling for pool errors
- pool.on('error', (err, client) => {
+ pool.on('error', (err) => {
console.error('Unexpected database pool error:', err.message);
// Don't crash the app on unexpected pool errors
});
diff --git a/lib/r2.ts b/lib/r2.ts
index 478f66d..11e6a65 100644
--- a/lib/r2.ts
+++ b/lib/r2.ts
@@ -1,4 +1,4 @@
-import { S3Client, PutObjectCommand, GetObjectCommand } from '@aws-sdk/client-s3';
+import { S3Client, PutObjectCommand } from '@aws-sdk/client-s3';
const R2_ACCOUNT_ID = process.env.R2_ACCOUNT_ID!;
const R2_ACCESS_KEY_ID = process.env.R2_ACCESS_KEY_ID!;
diff --git a/lib/video-providers/bunny.ts b/lib/video-providers/bunny.ts
index c2d5b52..e879938 100644
--- a/lib/video-providers/bunny.ts
+++ b/lib/video-providers/bunny.ts
@@ -1,4 +1,4 @@
-import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
+import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
import { getCachedMetadata, setCachedMetadata } from './metadata-cache';
// Bunny Stream URL patterns
@@ -43,8 +43,7 @@ export const bunnyProvider: VideoProvider = {
return `https://iframe.mediadelivery.net/embed/${libraryId}/${videoId}?${params.toString()}`;
},
- getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
- const libraryId = process.env.NEXT_PUBLIC_BUNNY_STREAM_LIBRARY_ID || process.env.BUNNY_STREAM_LIBRARY_ID || '0';
+ getThumbnailUrl(videoId: string): string {
// Bunny stream thumbnails: https://vz-uuid.b-cdn.net/{videoId}/thumbnail.jpg
// Since we don't have the b-cdn pull zone readily available in pure abstract,
// we should rely on fetching metadata for actual thumbnails, OR construct via API
diff --git a/lib/video-providers/direct.ts b/lib/video-providers/direct.ts
index 36706b2..f98cc8c 100644
--- a/lib/video-providers/direct.ts
+++ b/lib/video-providers/direct.ts
@@ -1,4 +1,4 @@
-import type { VideoProvider, VideoMetadata, EmbedOptions, ThumbnailSize } from './types';
+import type { VideoProvider, VideoMetadata, EmbedOptions } from './types';
// Direct video URL patterns (for future self-hosted videos)
const DIRECT_VIDEO_PATTERNS = [
@@ -49,7 +49,8 @@ export const directProvider: VideoProvider = {
return `${videoId}${queryString ? `#t=${options.startTime}` : ''}`;
},
- getThumbnailUrl(videoId: string, size: ThumbnailSize = 'medium'): string {
+ getThumbnailUrl(videoId: string): string {
+ void videoId;
// For direct uploads, thumbnail would be generated server-side
// Return a placeholder for now
return '/placeholder-video-thumbnail.png';
diff --git a/lib/video-providers/youtube.ts b/lib/video-providers/youtube.ts
index a02b989..e716d33 100644
--- a/lib/video-providers/youtube.ts
+++ b/lib/video-providers/youtube.ts
@@ -84,7 +84,7 @@ export const youtubeProvider: VideoProvider = {
setCachedMetadata(cacheKey, metadata);
return metadata;
- } catch (error) {
+ } catch {
// Fallback with minimal data
const fallback: VideoMetadata = {
title: 'YouTube Video',
diff --git a/package.json b/package.json
index 99f5131..f13cc6f 100644
--- a/package.json
+++ b/package.json
@@ -7,8 +7,9 @@
"prebuild": "bun run typecheck",
"build": "next build",
"start": "next start",
- "lint": "eslint",
+ "lint": "eslint --max-warnings=0",
"typecheck": "tsc --noEmit",
+ "check": "bun run lint && bun run typecheck",
"postinstall": "prisma generate",
"db:generate": "prisma generate",
"db:push": "prisma db push",
diff --git a/prisma/seed.ts b/prisma/seed.ts
index 55d587d..5b48174 100644
--- a/prisma/seed.ts
+++ b/prisma/seed.ts
@@ -121,7 +121,7 @@ async function main() {
},
});
- const clientProject = await prisma.project.create({
+ await prisma.project.create({
data: {
name: 'Client: XYZ Corp Promo',
description: 'Promotional video for XYZ Corporation product launch.',
@@ -173,7 +173,7 @@ async function main() {
},
});
- const aiToolsVideo = await prisma.video.create({
+ await prisma.video.create({
data: {
title: 'Best AI Tools for Developers 2025',
description: 'A curated list of AI tools that actually boost productivity.',
@@ -198,7 +198,7 @@ async function main() {
});
// Tutorial video
- const reactVideo = await prisma.video.create({
+ await prisma.video.create({
data: {
title: 'React Server Components Deep Dive',
description: 'Understanding RSC from first principles.',
diff --git a/types/next-auth.d.ts b/types/next-auth.d.ts
index e418902..15f2881 100644
--- a/types/next-auth.d.ts
+++ b/types/next-auth.d.ts
@@ -1,4 +1,4 @@
-import NextAuth, { type DefaultSession } from 'next-auth';
+import { type DefaultSession } from 'next-auth';
declare module 'next-auth' {
interface Session {