mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
chore: clean up lint/type issues and tighten comment media URL validation
This commit is contained in:
@@ -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 <Globe className="h-3.5 w-3.5" />;
|
||||
case 'INVITE':
|
||||
return <UserPlus className="h-3.5 w-3.5" />;
|
||||
default:
|
||||
return <Lock className="h-3.5 w-3.5" />;
|
||||
}
|
||||
}
|
||||
|
||||
function formatDuration(seconds: number | null): string {
|
||||
if (!seconds) return '0:00';
|
||||
const totalSeconds = Math.floor(seconds);
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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 {
|
||||
|
||||
@@ -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<typeof comment, 'version'> & { version?: unknown };
|
||||
delete commentData.version;
|
||||
const response = successResponse(commentData);
|
||||
return withCacheControl(response, 'private, no-cache');
|
||||
} catch (error) {
|
||||
|
||||
@@ -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({
|
||||
|
||||
@@ -39,6 +39,7 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
|
||||
const [color, setColor] = useState(DEFAULT_COLOR);
|
||||
const [width, setWidth] = useState(DEFAULT_WIDTH);
|
||||
const isDrawingRef = useRef(false);
|
||||
void onConfirm;
|
||||
|
||||
// Expose getStrokes so parent can grab current drawing without confirm
|
||||
useImperativeHandle(ref, () => ({
|
||||
@@ -159,11 +160,6 @@ export const AnnotationCanvas = forwardRef<AnnotationCanvasHandle, AnnotationCan
|
||||
setStrokes([]);
|
||||
}, []);
|
||||
|
||||
const handleConfirm = useCallback(() => {
|
||||
if (strokes.length === 0) return;
|
||||
onConfirm?.(strokes);
|
||||
}, [strokes, onConfirm]);
|
||||
|
||||
// View mode: click to dismiss
|
||||
const handleViewClick = useCallback((e: React.MouseEvent) => {
|
||||
if (mode === 'view') {
|
||||
|
||||
@@ -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<string>(() => {
|
||||
if (typeof window === 'undefined') return '';
|
||||
return localStorage.getItem('openframe_guest_name') ?? '';
|
||||
});
|
||||
const [confirmed, setConfirmed] = useState<boolean>(() => {
|
||||
if (typeof window === 'undefined') return false;
|
||||
return Boolean(localStorage.getItem('openframe_guest_name'));
|
||||
});
|
||||
|
||||
if (confirmed) {
|
||||
return <>{children}</>;
|
||||
|
||||
@@ -12,7 +12,7 @@ import {
|
||||
} from '@/components/ui/dropdown-menu';
|
||||
|
||||
export function ThemeToggle() {
|
||||
const { setTheme, theme } = useTheme();
|
||||
const { setTheme } = useTheme();
|
||||
|
||||
return (
|
||||
<DropdownMenu>
|
||||
|
||||
@@ -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<AnnotationCanvasHandle>(null);
|
||||
const [isSubmittingEdit, setIsSubmittingEdit] = useState(false);
|
||||
const [deletingCommentId, setDeletingCommentId] = useState<string | null>(null);
|
||||
const [, setDeletingCommentId] = useState<string | null>(null);
|
||||
const isMutatingRef = useRef(false);
|
||||
const [previewImage, setPreviewImage] = useState<string | null>(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<HTMLInputElement>, 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
|
||||
<img
|
||||
src={previewImage}
|
||||
alt="Preview"
|
||||
|
||||
+1
-1
@@ -126,7 +126,7 @@ export function successResponse<T>(
|
||||
return NextResponse.json(body, { status });
|
||||
}
|
||||
|
||||
export function withCacheControl<T>(response: Response, value: string): Response {
|
||||
export function withCacheControl(response: Response, value: string): Response {
|
||||
response.headers.set('Cache-Control', value);
|
||||
return response;
|
||||
}
|
||||
|
||||
@@ -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
|
||||
});
|
||||
|
||||
@@ -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!;
|
||||
|
||||
@@ -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
|
||||
|
||||
@@ -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';
|
||||
|
||||
@@ -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',
|
||||
|
||||
+2
-1
@@ -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",
|
||||
|
||||
+3
-3
@@ -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.',
|
||||
|
||||
Vendored
+1
-1
@@ -1,4 +1,4 @@
|
||||
import NextAuth, { type DefaultSession } from 'next-auth';
|
||||
import { type DefaultSession } from 'next-auth';
|
||||
|
||||
declare module 'next-auth' {
|
||||
interface Session {
|
||||
|
||||
Reference in New Issue
Block a user