From 5856c42181122903e43b062523af7d6d51bcfd97 Mon Sep 17 00:00:00 2001 From: =?UTF-8?q?Yusuf=20=C4=B0pek?= Date: Sat, 7 Feb 2026 14:46:17 +0300 Subject: [PATCH] feat: implement comment tagging system with CRUD operations - Removed Telegram setup instructions from settings page. - Enhanced video and version comment APIs to include tag information. - Added new CommentTag model in Prisma schema for managing tags. - Created API routes for managing tags (GET, POST, PATCH, DELETE). - Updated WatchPage to support tag selection and display. - Introduced keyboard shortcuts modal for improved user experience. - Added tag selection dropdown in comment input area. --- .../projects/[projectId]/settings/page.tsx | 185 +++++++++- .../[projectId]/videos/[videoId]/page.tsx | 315 +++++++++++++++--- app/(dashboard)/settings/page.tsx | 31 -- .../[projectId]/tags/[tagId]/route.ts | 139 ++++++++ app/api/projects/[projectId]/tags/route.ts | 144 ++++++++ .../[projectId]/videos/[videoId]/route.ts | 2 + .../versions/[versionId]/comments/route.ts | 7 +- app/api/watch/[videoId]/route.ts | 2 + app/watch/[videoId]/page.tsx | 251 +++++++++++++- components/keyboard-shortcuts-modal.tsx | 92 +++++ components/layout/header.tsx | 11 +- prisma/schema.prisma | 30 ++ 12 files changed, 1098 insertions(+), 111 deletions(-) create mode 100644 app/api/projects/[projectId]/tags/[tagId]/route.ts create mode 100644 app/api/projects/[projectId]/tags/route.ts create mode 100644 components/keyboard-shortcuts-modal.tsx diff --git a/app/(dashboard)/projects/[projectId]/settings/page.tsx b/app/(dashboard)/projects/[projectId]/settings/page.tsx index f19d813..4cb2f05 100644 --- a/app/(dashboard)/projects/[projectId]/settings/page.tsx +++ b/app/(dashboard)/projects/[projectId]/settings/page.tsx @@ -3,7 +3,7 @@ import { useState, useEffect } from 'react'; import { useRouter } from 'next/navigation'; import Link from 'next/link'; -import { ArrowLeft, Loader2, Globe, Lock, UserPlus, Trash2, AlertTriangle, Settings, Save } from 'lucide-react'; +import { ArrowLeft, Loader2, Globe, Lock, UserPlus, Trash2, AlertTriangle, Settings, Save, Tag, Plus, 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'; @@ -48,6 +48,13 @@ interface ProjectSettingsPageProps { params: Promise<{ projectId: string }>; } +interface CommentTag { + id: string; + name: string; + color: string; + position: number; +} + export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps) { const router = useRouter(); const [projectId, setProjectId] = useState(''); @@ -63,9 +70,19 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps visibility: 'PRIVATE' as Visibility, }); + // Tag management state + const [tags, setTags] = useState([]); + const [newTagName, setNewTagName] = useState(''); + const [newTagColor, setNewTagColor] = useState('#3B82F6'); + const [isAddingTag, setIsAddingTag] = useState(false); + const [editingTagId, setEditingTagId] = useState(null); + const [editTagName, setEditTagName] = useState(''); + const [editTagColor, setEditTagColor] = useState(''); + useEffect(() => { params.then(({ projectId: id }) => { setProjectId(id); + // Fetch project data fetch(`/api/projects/${id}`) .then((res) => res.json()) .then((data) => { @@ -81,6 +98,16 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps }) .catch(() => setError('Failed to load project')) .finally(() => setIsLoading(false)); + + // Fetch tags + fetch(`/api/projects/${id}/tags`) + .then((res) => res.json()) + .then((data) => { + if (Array.isArray(data)) { + setTags(data); + } + }) + .catch(() => { /* Silent fail - tags are optional */ }); }); }, [params]); @@ -113,6 +140,59 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps } }; + const handleAddTag = async () => { + if (!newTagName.trim()) return; + setIsAddingTag(true); + try { + const res = await fetch(`/api/projects/${projectId}/tags`, { + method: 'POST', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: newTagName.trim(), color: newTagColor }), + }); + if (res.ok) { + const newTag = await res.json(); + setTags([...tags, newTag]); + setNewTagName(''); + setNewTagColor('#3B82F6'); + } + } catch { + // Silent fail + } finally { + setIsAddingTag(false); + } + }; + + const handleUpdateTag = async (tagId: string) => { + if (!editTagName.trim()) return; + try { + const res = await fetch(`/api/projects/${projectId}/tags/${tagId}`, { + method: 'PATCH', + headers: { 'Content-Type': 'application/json' }, + body: JSON.stringify({ name: editTagName.trim(), color: editTagColor }), + }); + if (res.ok) { + const updated = await res.json(); + setTags(tags.map((t) => (t.id === tagId ? updated : t))); + setEditingTagId(null); + } + } catch { + // Silent fail + } + }; + + const handleDeleteTag = async (tagId: string) => { + try { + const res = await fetch(`/api/projects/${projectId}/tags/${tagId}`, { + method: 'DELETE', + }); + if (res.ok) { + setTags(tags.filter((t) => t.id !== tagId)); + } + } catch { + // Silent fail + } + }; + const handleDelete = async () => { if (deleteConfirmation !== formData.name) { setError('Project name does not match'); @@ -214,13 +294,13 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps onClick={() => setFormData(prev => ({ ...prev, visibility: option.value }))} disabled={isSaving} className={`w-full flex items-center gap-4 p-4 rounded-xl border-2 text-left transition-all ${formData.visibility === option.value - ? 'border-primary bg-primary/5 ring-1 ring-primary/20' - : 'border-border hover:border-border/80 hover:bg-accent/50' + ? 'border-primary bg-primary/5 ring-1 ring-primary/20' + : 'border-border hover:border-border/80 hover:bg-accent/50' }`} >
{option.icon}
@@ -231,8 +311,8 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
{formData.visibility === option.value && (
@@ -264,6 +344,97 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps + {/* Comment Tags */} + + + + + Comment Tags + + + Customize tags for categorizing comments on videos + + + + {/* Existing tags */} +
+ {tags.map((tag) => ( +
+ {editingTagId === tag.id ? ( + <> + setEditTagColor(e.target.value)} + className="w-8 h-8 rounded cursor-pointer border-0" + /> + setEditTagName(e.target.value)} + className="flex-1 h-8" + onKeyDown={(e) => e.key === 'Enter' && handleUpdateTag(tag.id)} + /> + + + + ) : ( + <> +
+ {tag.name} + + + + )} +
+ ))} +
+ + {/* Add new tag */} +
+ setNewTagColor(e.target.value)} + className="w-8 h-8 rounded cursor-pointer border-0" + /> + setNewTagName(e.target.value)} + className="flex-1 h-8" + onKeyDown={(e) => e.key === 'Enter' && handleAddTag()} + /> + +
+ + + {/* Danger Zone */} diff --git a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx index 561d466..57e40cb 100644 --- a/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx +++ b/app/(dashboard)/projects/[projectId]/videos/[videoId]/page.tsx @@ -30,6 +30,7 @@ import { Trash2, X, ArrowUpRight, + Tag, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -50,6 +51,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; @@ -69,6 +71,12 @@ interface Version { _count: { comments: number }; } +interface CommentTag { + id: string; + name: string; + color: string; +} + interface Comment { id: string; content: string | null; @@ -79,6 +87,7 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + tag: CommentTag | null; replies: { id: string; content: string | null; @@ -87,6 +96,7 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + tag: CommentTag | null; }[]; } @@ -184,6 +194,10 @@ export default function VideoPage() { const [newVersionUrlError, setNewVersionUrlError] = useState(''); const [isCreatingVersion, setIsCreatingVersion] = useState(false); + // Comment tags state + const [availableTags, setAvailableTags] = useState([]); + const [selectedTagId, setSelectedTagId] = useState(null); + // Fetch video data useEffect(() => { async function fetchVideo() { @@ -212,6 +226,26 @@ export default function VideoPage() { const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const duration = videoDuration || activeVersion?.duration || 0; + // Fetch tags for the project + useEffect(() => { + async function fetchTags() { + try { + const res = await fetch(`/api/projects/${projectId}/tags`); + if (res.ok) { + const tags = await res.json(); + setAvailableTags(tags); + // Auto-select first tag (Feedback) as default + if (tags.length > 0 && !selectedTagId) { + setSelectedTagId(tags[0].id); + } + } + } catch { + // Silent fail - tags are optional + } + } + fetchTags(); + }, [projectId]); + // Load YouTube iframe API script once useEffect(() => { if (window.YT) return; @@ -287,6 +321,125 @@ export default function VideoPage() { return () => clearInterval(interval); }, [isReady, isDragging]); + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in an input/textarea + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + switch (e.code) { + case 'Space': + case 'KeyK': + e.preventDefault(); + if (playerRef.current) { + if (isPlaying) { + playerRef.current.pauseVideo(); + } else { + playerRef.current.playVideo(); + } + } + break; + case 'ArrowLeft': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.max(0, currentTime - 5); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'ArrowRight': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.min(duration, currentTime + 5); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'ArrowUp': + e.preventDefault(); + { + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex < speeds.length - 1) { + const newSpeed = speeds[currentIndex + 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'ArrowDown': + e.preventDefault(); + { + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex > 0) { + const newSpeed = speeds[currentIndex - 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'Comma': // < key (Shift+,) + if (e.shiftKey) { + e.preventDefault(); + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex > 0) { + const newSpeed = speeds[currentIndex - 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'Period': // > key (Shift+.) + if (e.shiftKey) { + e.preventDefault(); + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex < speeds.length - 1) { + const newSpeed = speeds[currentIndex + 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'KeyM': + e.preventDefault(); + if (playerRef.current) { + if (isMuted) { + playerRef.current.unMute(); + } else { + playerRef.current.mute(); + } + setIsMuted(!isMuted); + } + break; + case 'KeyJ': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.max(0, currentTime - 10); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'KeyL': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.min(duration, currentTime + 10); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isPlaying, currentTime, duration, isMuted, playbackSpeed]); + const handlePlayPause = useCallback(() => { if (!playerRef.current) return; if (isPlaying) { @@ -381,6 +534,7 @@ export default function VideoPage() { timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(isGuest && guestName && { guestName }), + ...(selectedTagId && { tagId: selectedTagId }), }), }); @@ -399,13 +553,14 @@ export default function VideoPage() { }); setCommentText(''); setSelectedTimestamp(null); + setSelectedTagId(null); } } catch (err) { console.error('Failed to add comment:', err); } finally { setIsSubmittingComment(false); } - }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName]); + }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]); // Voice recording handlers const startRecording = useCallback(async () => { @@ -610,11 +765,11 @@ export default function VideoPage() { versions: prev.versions.map((v) => v.id === activeVersionId ? { - ...v, - comments: v.comments.map((c) => - c.id === commentId ? { ...c, isResolved: !c.isResolved } : c - ), - } + ...v, + comments: v.comments.map((c) => + c.id === commentId ? { ...c, isResolved: !c.isResolved } : c + ), + } : v ), }; @@ -653,13 +808,13 @@ export default function VideoPage() { versions: prev.versions.map((v) => v.id === activeVersionId ? { - ...v, - comments: v.comments.map((c) => - c.id === parentId - ? { ...c, replies: [...c.replies, newReply] } - : c - ), - } + ...v, + comments: v.comments.map((c) => + c.id === parentId + ? { ...c, replies: [...c.replies, newReply] } + : c + ), + } : v ), }; @@ -761,17 +916,17 @@ export default function VideoPage() { versions: prev.versions.map((v) => v.id === activeVersionId ? { - ...v, - comments: v.comments.map((c) => { - if (c.id === commentId) return { ...c, content: editText.trim() }; - return { - ...c, - replies: c.replies.map((r) => - r.id === commentId ? { ...r, content: editText.trim() } : r - ), - }; - }), - } + ...v, + comments: v.comments.map((c) => { + if (c.id === commentId) return { ...c, content: editText.trim() }; + return { + ...c, + replies: c.replies.map((r) => + r.id === commentId ? { ...r, content: editText.trim() } : r + ), + }; + }), + } : v ), }; @@ -799,14 +954,14 @@ export default function VideoPage() { versions: prev.versions.map((v) => v.id === activeVersionId ? { - ...v, - comments: v.comments - .filter((c) => c.id !== commentId) - .map((c) => ({ - ...c, - replies: c.replies.filter((r) => r.id !== commentId), - })), - } + ...v, + comments: v.comments + .filter((c) => c.id !== commentId) + .map((c) => ({ + ...c, + replies: c.replies.filter((r) => r.id !== commentId), + })), + } : v ), }; @@ -993,14 +1148,14 @@ export default function VideoPage() { - {video.versions.length >= 2 && ( - - )} + {video.versions.length >= 2 && ( + + )} Add New Version @@ -1176,21 +1331,24 @@ export default function VideoPage() { /> {/* Comment markers */} - {comments.map((comment) => ( -
@@ -1241,6 +1399,14 @@ export default function VideoPage() { {authorName} + {comment.tag && ( + + {comment.tag.name} + + )}
@@ -1802,6 +1968,45 @@ export default function VideoPage() { > + {availableTags.length > 0 && ( + + + + + + {availableTags.map((tag) => ( + setSelectedTagId(tag.id)} + className="gap-2" + > + + {tag.name} + {selectedTagId === tag.id && } + + ))} + + + + + Manage Tags + + + + + )}

Cmd+Enter to submit

diff --git a/app/(dashboard)/settings/page.tsx b/app/(dashboard)/settings/page.tsx index c01d987..791df16 100644 --- a/app/(dashboard)/settings/page.tsx +++ b/app/(dashboard)/settings/page.tsx @@ -267,37 +267,6 @@ export default function SettingsPage() { -
-

Setup instructions:

-
    -
  1. - Open Telegram and message{' '} - - @BotFather - -
  2. -
  3. Send /newbot and follow the prompts to create a bot
  4. -
  5. Copy the Bot Token and paste it below
  6. -
  7. - Send a message to your new bot, then visit{' '} - - api.telegram.org - {' '} - /bot<token>/getUpdates to find your Chat ID -
  8. -
-
-
diff --git a/app/api/projects/[projectId]/tags/[tagId]/route.ts b/app/api/projects/[projectId]/tags/[tagId]/route.ts new file mode 100644 index 0000000..c655310 --- /dev/null +++ b/app/api/projects/[projectId]/tags/[tagId]/route.ts @@ -0,0 +1,139 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { rateLimit } from '@/lib/rate-limit'; + +type RouteParams = { params: Promise<{ projectId: string; tagId: string }> }; + +// Helper to check project access +async function checkProjectAccess(projectId: string, userId: string) { + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { members: { where: { userId } } }, + }); + + if (!project) return { project: null, canEdit: false }; + + const isOwner = project.ownerId === userId; + const isAdmin = project.members[0]?.role === 'ADMIN'; + + // Check workspace-level access + let workspaceCanEdit = false; + if (!isOwner && !isAdmin) { + const wsMember = await db.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + workspaceCanEdit = wsOwner?.ownerId === userId || wsMember?.role === 'ADMIN'; + } + + return { + project, + canEdit: isOwner || isAdmin || workspaceCanEdit, + }; +} + +// PATCH /api/projects/[projectId]/tags/[tagId] - Update a tag +export async function PATCH(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + const { projectId, tagId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Verify tag belongs to this project + const existingTag = await db.commentTag.findUnique({ + where: { id: tagId }, + }); + if (!existingTag || existingTag.projectId !== projectId) { + return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + } + + const body = await request.json(); + const { name, color, position } = body; + + const updateData: Record = {}; + if (name !== undefined) { + if (!name.trim()) { + return NextResponse.json({ error: 'Name cannot be empty' }, { status: 400 }); + } + updateData.name = name.trim(); + } + if (color !== undefined) { + if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { + return NextResponse.json({ error: 'Invalid color format' }, { status: 400 }); + } + updateData.color = color.toUpperCase(); + } + if (position !== undefined) { + updateData.position = position; + } + + const tag = await db.commentTag.update({ + where: { id: tagId }, + data: updateData, + }); + + return NextResponse.json(tag); + } catch (error) { + console.error('Error updating tag:', error); + if ((error as { code?: string }).code === 'P2002') { + return NextResponse.json({ error: 'Tag name already exists' }, { status: 409 }); + } + return NextResponse.json({ error: 'Failed to update tag' }, { status: 500 }); + } +} + +// DELETE /api/projects/[projectId]/tags/[tagId] - Delete a tag +export async function DELETE(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + const { projectId, tagId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + // Verify tag belongs to this project + const existingTag = await db.commentTag.findUnique({ + where: { id: tagId }, + }); + if (!existingTag || existingTag.projectId !== projectId) { + return NextResponse.json({ error: 'Tag not found' }, { status: 404 }); + } + + await db.commentTag.delete({ where: { id: tagId } }); + + return NextResponse.json({ success: true }); + } catch (error) { + console.error('Error deleting tag:', error); + return NextResponse.json({ error: 'Failed to delete tag' }, { status: 500 }); + } +} diff --git a/app/api/projects/[projectId]/tags/route.ts b/app/api/projects/[projectId]/tags/route.ts new file mode 100644 index 0000000..d8b765e --- /dev/null +++ b/app/api/projects/[projectId]/tags/route.ts @@ -0,0 +1,144 @@ +import { NextRequest, NextResponse } from 'next/server'; +import { db } from '@/lib/db'; +import { auth } from '@/lib/auth'; +import { rateLimit } from '@/lib/rate-limit'; + +type RouteParams = { params: Promise<{ projectId: string }> }; + +// Default tags to create for new projects +const DEFAULT_TAGS = [ + { name: 'Feedback', color: '#3B82F6', position: 0 }, + { name: 'Technical', color: '#EF4444', position: 1 }, + { name: 'Creative', color: '#8B5CF6', position: 2 }, + { name: 'Approved', color: '#22C55E', position: 3 }, + { name: 'Urgent', color: '#F59E0B', position: 4 }, +]; + +// Helper to check project access +async function checkProjectAccess(projectId: string, userId: string) { + const project = await db.project.findUnique({ + where: { id: projectId }, + include: { members: { where: { userId } } }, + }); + + if (!project) return { project: null, canEdit: false }; + + const isOwner = project.ownerId === userId; + const isAdmin = project.members[0]?.role === 'ADMIN'; + + // Check workspace-level access + let workspaceCanEdit = false; + if (!isOwner && !isAdmin) { + const wsMember = await db.workspaceMember.findUnique({ + where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } }, + }); + const wsOwner = await db.workspace.findUnique({ + where: { id: project.workspaceId }, + select: { ownerId: true }, + }); + workspaceCanEdit = wsOwner?.ownerId === userId || wsMember?.role === 'ADMIN'; + } + + return { + project, + canEdit: isOwner || isAdmin || workspaceCanEdit, + }; +} + +// GET /api/projects/[projectId]/tags - Get all tags for a project +export async function GET(request: NextRequest, { params }: RouteParams) { + try { + const session = await auth(); + const { projectId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { project } = await checkProjectAccess(projectId, session.user.id); + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + + let tags = await db.commentTag.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + }); + + // Auto-create default tags if none exist (idempotent with skipDuplicates + // to handle race conditions from concurrent requests) + if (tags.length === 0) { + await db.commentTag.createMany({ + data: DEFAULT_TAGS.map((tag) => ({ ...tag, projectId })), + skipDuplicates: true, + }); + tags = await db.commentTag.findMany({ + where: { projectId }, + orderBy: { position: 'asc' }, + }); + } + + return NextResponse.json(tags); + } catch (error) { + console.error('Error fetching tags:', error); + return NextResponse.json({ error: 'Failed to fetch tags' }, { status: 500 }); + } +} + +// POST /api/projects/[projectId]/tags - Create a new tag +export async function POST(request: NextRequest, { params }: RouteParams) { + try { + const limited = await rateLimit(request, 'mutate'); + if (limited) return limited; + + const session = await auth(); + const { projectId } = await params; + + if (!session?.user?.id) { + return NextResponse.json({ error: 'Unauthorized' }, { status: 401 }); + } + + const { canEdit, project } = await checkProjectAccess(projectId, session.user.id); + if (!project) { + return NextResponse.json({ error: 'Project not found' }, { status: 404 }); + } + if (!canEdit) { + return NextResponse.json({ error: 'Access denied' }, { status: 403 }); + } + + const body = await request.json(); + const { name, color } = body; + + if (!name?.trim() || !color?.trim()) { + return NextResponse.json({ error: 'Name and color are required' }, { status: 400 }); + } + + // Hex color validation + if (!/^#[0-9A-Fa-f]{6}$/.test(color)) { + return NextResponse.json({ error: 'Invalid color format' }, { status: 400 }); + } + + // Get max position + const maxPos = await db.commentTag.aggregate({ + where: { projectId }, + _max: { position: true }, + }); + + const tag = await db.commentTag.create({ + data: { + name: name.trim(), + color: color.toUpperCase(), + position: (maxPos._max.position ?? -1) + 1, + projectId, + }, + }); + + return NextResponse.json(tag, { status: 201 }); + } catch (error) { + console.error('Error creating tag:', error); + if ((error as { code?: string }).code === 'P2002') { + return NextResponse.json({ error: 'Tag name already exists' }, { status: 409 }); + } + return NextResponse.json({ error: 'Failed to create tag' }, { status: 500 }); + } +} diff --git a/app/api/projects/[projectId]/videos/[videoId]/route.ts b/app/api/projects/[projectId]/videos/[videoId]/route.ts index 6b06661..ec7d5dc 100644 --- a/app/api/projects/[projectId]/videos/[videoId]/route.ts +++ b/app/api/projects/[projectId]/videos/[videoId]/route.ts @@ -26,10 +26,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { orderBy: { timestamp: 'asc' }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, replies: { orderBy: { createdAt: 'asc' }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, }, }, }, diff --git a/app/api/versions/[versionId]/comments/route.ts b/app/api/versions/[versionId]/comments/route.ts index 9282902..a2d25cc 100644 --- a/app/api/versions/[versionId]/comments/route.ts +++ b/app/api/versions/[versionId]/comments/route.ts @@ -54,10 +54,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { orderBy: { timestamp: 'asc' }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, replies: { orderBy: { createdAt: 'asc' }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, }, }, }, @@ -115,7 +117,7 @@ export async function POST(request: NextRequest, { params }: RouteParams) { } const body = await request.json(); - const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail } = body; + const { content, timestamp, timestampEnd, parentId, voiceUrl, voiceDuration, guestName, guestEmail, tagId } = body; // Validate required fields if (timestamp === undefined || timestamp === null) { @@ -173,13 +175,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) { authorId: session?.user?.id || null, guestName: isGuest ? guestName : null, guestEmail: isGuest ? guestEmail : null, + tagId: tagId || null, versionId, }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, replies: { include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, }, }, }, diff --git a/app/api/watch/[videoId]/route.ts b/app/api/watch/[videoId]/route.ts index e53cc73..1c67197 100644 --- a/app/api/watch/[videoId]/route.ts +++ b/app/api/watch/[videoId]/route.ts @@ -26,10 +26,12 @@ export async function GET(request: NextRequest, { params }: RouteParams) { where: { parentId: null }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, replies: { orderBy: { createdAt: 'asc' }, include: { author: { select: { id: true, name: true, image: true } }, + tag: { select: { id: true, name: true, color: true } }, }, }, }, diff --git a/app/watch/[videoId]/page.tsx b/app/watch/[videoId]/page.tsx index 9ee07c9..ab94e66 100644 --- a/app/watch/[videoId]/page.tsx +++ b/app/watch/[videoId]/page.tsx @@ -26,6 +26,7 @@ import { X, ArrowUpRight, User, + Tag, } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { Badge } from '@/components/ui/badge'; @@ -37,6 +38,7 @@ import { DropdownMenu, DropdownMenuContent, DropdownMenuItem, + DropdownMenuSeparator, DropdownMenuTrigger, } from '@/components/ui/dropdown-menu'; import { cn } from '@/lib/utils'; @@ -55,6 +57,12 @@ interface Version { _count: { comments: number }; } +interface CommentTag { + id: string; + name: string; + color: string; +} + interface Comment { id: string; content: string | null; @@ -65,6 +73,7 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + tag: CommentTag | null; replies: { id: string; content: string | null; @@ -73,6 +82,7 @@ interface Comment { createdAt: string; author: { id: string; name: string | null; image: string | null } | null; guestName: string | null; + tag: CommentTag | null; }[]; } @@ -97,6 +107,8 @@ function formatTime(seconds: number): string { return `${mins}:${secs.toString().padStart(2, '0')}`; } +const SPEED_OPTIONS = [0.25, 0.5, 0.75, 1, 1.25, 1.5, 1.75, 2]; + export default function WatchPage() { const params = useParams(); const videoId = params.videoId as string; @@ -115,6 +127,7 @@ export default function WatchPage() { const [currentTime, setCurrentTime] = useState(0); const [isMuted, setIsMuted] = useState(false); const [isDragging, setIsDragging] = useState(false); + const [playbackSpeed, setPlaybackSpeed] = useState(1); const [commentText, setCommentText] = useState(''); const [isSubmittingComment, setIsSubmittingComment] = useState(false); @@ -153,6 +166,10 @@ export default function WatchPage() { const [guestName, setGuestName] = useState(''); const [guestNameConfirmed, setGuestNameConfirmed] = useState(false); + // Tag state + const [availableTags, setAvailableTags] = useState([]); + const [selectedTagId, setSelectedTagId] = useState(null); + // Restore guest name from localStorage useEffect(() => { const saved = localStorage.getItem('openframe_guest_name'); @@ -192,6 +209,156 @@ export default function WatchPage() { const filteredComments = comments.filter((c) => showResolved || !c.isResolved); const duration = activeVersion?.duration || 300; + // Fetch tags for the project + useEffect(() => { + const projectId = video?.projectId; + if (!projectId) return; + async function fetchTags() { + try { + const res = await fetch(`/api/projects/${projectId}/tags`); + if (res.ok) { + const tags = await res.json(); + setAvailableTags(tags); + // Auto-select first tag (Feedback) as default + if (tags.length > 0 && !selectedTagId) { + setSelectedTagId(tags[0].id); + } + } + } catch { + // Silent fail - tags are optional + } + } + fetchTags(); + }, [video?.projectId]); + + // Keyboard shortcuts + useEffect(() => { + const handleKeyDown = (e: KeyboardEvent) => { + // Ignore if user is typing in an input/textarea + const target = e.target as HTMLElement; + if (target.tagName === 'INPUT' || target.tagName === 'TEXTAREA' || target.isContentEditable) { + return; + } + + switch (e.code) { + case 'Space': + e.preventDefault(); + if (playerRef.current) { + if (isPlaying) { + playerRef.current.pauseVideo(); + } else { + playerRef.current.playVideo(); + } + } + break; + case 'ArrowLeft': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.max(0, currentTime - 5); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'ArrowRight': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.min(duration, currentTime + 5); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'ArrowUp': + e.preventDefault(); + { + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex < speeds.length - 1) { + const newSpeed = speeds[currentIndex + 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'ArrowDown': + e.preventDefault(); + { + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex > 0) { + const newSpeed = speeds[currentIndex - 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'Comma': // < key (Shift+,) + if (e.shiftKey) { + e.preventDefault(); + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex > 0) { + const newSpeed = speeds[currentIndex - 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'Period': // > key (Shift+.) + if (e.shiftKey) { + e.preventDefault(); + const speeds = SPEED_OPTIONS; + const currentIndex = speeds.indexOf(playbackSpeed); + if (currentIndex < speeds.length - 1) { + const newSpeed = speeds[currentIndex + 1]; + setPlaybackSpeed(newSpeed); + playerRef.current?.setPlaybackRate(newSpeed); + } + } + break; + case 'KeyM': + e.preventDefault(); + if (playerRef.current) { + if (isMuted) { + playerRef.current.unMute(); + } else { + playerRef.current.mute(); + } + setIsMuted(!isMuted); + } + break; + case 'KeyJ': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.max(0, currentTime - 10); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + case 'KeyK': + e.preventDefault(); + if (playerRef.current) { + if (isPlaying) { + playerRef.current.pauseVideo(); + } else { + playerRef.current.playVideo(); + } + } + break; + case 'KeyL': + e.preventDefault(); + if (playerRef.current?.seekTo) { + const newTime = Math.min(duration, currentTime + 10); + playerRef.current.seekTo(newTime, true); + setCurrentTime(newTime); + } + break; + } + }; + + window.addEventListener('keydown', handleKeyDown); + return () => window.removeEventListener('keydown', handleKeyDown); + }, [isPlaying, currentTime, duration, isMuted, playbackSpeed]); + // Load YouTube iframe API useEffect(() => { if (!activeVersion || activeVersion.providerId !== 'youtube') return; @@ -326,6 +493,7 @@ export default function WatchPage() { timestamp: selectedTimestamp ?? currentTime, ...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }), ...(isGuest && guestName && { guestName }), + ...(selectedTagId && { tagId: selectedTagId }), }), }); @@ -344,13 +512,14 @@ export default function WatchPage() { }); setCommentText(''); setSelectedTimestamp(null); + setSelectedTagId(null); } } catch (err) { console.error('Failed to add comment:', err); } finally { setIsSubmittingComment(false); } - }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName]); + }, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]); // Voice recording handlers const startRecording = useCallback(async () => { @@ -1005,21 +1174,24 @@ export default function WatchPage() { /> {/* Comment markers */} - {comments.map((comment) => ( -
@@ -1070,6 +1242,14 @@ export default function WatchPage() { {authorName} + {comment.tag && ( + + {comment.tag.name} + + )}
@@ -1631,6 +1811,45 @@ export default function WatchPage() { > + {availableTags.length > 0 && ( + + + + + + {availableTags.map((tag) => ( + setSelectedTagId(tag.id)} + className="gap-2" + > + + {tag.name} + {selectedTagId === tag.id && } + + ))} + + + + + Manage Tags + + + + + )}

Cmd+Enter to submit

diff --git a/components/keyboard-shortcuts-modal.tsx b/components/keyboard-shortcuts-modal.tsx new file mode 100644 index 0000000..546faca --- /dev/null +++ b/components/keyboard-shortcuts-modal.tsx @@ -0,0 +1,92 @@ +'use client'; + +import { + Dialog, + DialogContent, + DialogHeader, + DialogTitle, +} from '@/components/ui/dialog'; + +interface ShortcutItem { + keys: string[]; + description: string; +} + +interface ShortcutGroup { + title: string; + shortcuts: ShortcutItem[]; +} + +const shortcutGroups: ShortcutGroup[] = [ + { + title: 'Playback', + shortcuts: [ + { keys: ['Space', 'K'], description: 'Play / Pause' }, + { keys: ['M'], description: 'Mute / Unmute' }, + ], + }, + { + title: 'Seeking', + shortcuts: [ + { keys: ['←'], description: 'Seek back 5s' }, + { keys: ['→'], description: 'Seek forward 5s' }, + { keys: ['J'], description: 'Seek back 10s' }, + { keys: ['L'], description: 'Seek forward 10s' }, + ], + }, + { + title: 'Speed', + shortcuts: [ + { keys: ['↑'], description: 'Increase speed' }, + { keys: ['↓'], description: 'Decrease speed' }, + { keys: ['⇧', '>'], description: 'Increase speed' }, + { keys: ['⇧', '<'], description: 'Decrease speed' }, + ], + }, +]; + +interface KeyboardShortcutsModalProps { + open: boolean; + onOpenChange: (open: boolean) => void; +} + +export function KeyboardShortcutsModal({ open, onOpenChange }: KeyboardShortcutsModalProps) { + return ( + + + + Keyboard Shortcuts + +
+ {shortcutGroups.map((group) => ( +
+

+ {group.title} +

+
+ {group.shortcuts.map((shortcut, i) => ( +
+ {shortcut.description} +
+ {shortcut.keys.map((key, j) => ( + + {key} + + ))} +
+
+ ))} +
+
+ ))} +
+
+
+ ); +} diff --git a/components/layout/header.tsx b/components/layout/header.tsx index d6b335a..dc038eb 100644 --- a/components/layout/header.tsx +++ b/components/layout/header.tsx @@ -1,5 +1,6 @@ 'use client'; +import { useState } from 'react'; import Link from 'next/link'; import { usePathname } from 'next/navigation'; import { @@ -10,7 +11,8 @@ import { Settings, LogOut, User, - Menu + Menu, + Keyboard } from 'lucide-react'; import { Button } from '@/components/ui/button'; import { @@ -23,6 +25,7 @@ import { import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar'; import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet'; import { ThemeToggle } from '@/components/theme-toggle'; +import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal'; import { cn } from '@/lib/utils'; interface NavItem { @@ -46,6 +49,7 @@ interface HeaderProps { export function Header({ user }: HeaderProps) { const pathname = usePathname(); + const [shortcutsOpen, setShortcutsOpen] = useState(false); return (
@@ -151,6 +155,10 @@ export function Header({ user }: HeaderProps) { Settings + setShortcutsOpen(true)}> + + Shortcuts + @@ -170,6 +178,7 @@ export function Header({ user }: HeaderProps) { )} +
); } diff --git a/prisma/schema.prisma b/prisma/schema.prisma index 367447e..4909068 100644 --- a/prisma/schema.prisma +++ b/prisma/schema.prisma @@ -153,6 +153,7 @@ model Project { videos Video[] members ProjectMember[] shareLinks ShareLink[] + commentTags CommentTag[] @@index([ownerId]) @@index([slug]) @@ -279,6 +280,10 @@ model Comment { versionId String version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade) + // Comment tag (colored category) + tagId String? + tag CommentTag? @relation(fields: [tagId], references: [id], onDelete: SetNull) + // Timestamps createdAt DateTime @default(now()) updatedAt DateTime @updatedAt @@ -287,9 +292,34 @@ model Comment { @@index([parentId]) @@index([authorId]) @@index([timestamp]) + @@index([tagId]) @@map("comments") } +model CommentTag { + id String @id @default(cuid()) + name String // e.g., "Feedback", "Technical", "Urgent" + color String // Hex color, e.g., "#3B82F6" + + // Project relation (tags are per-project) + projectId String + project Project @relation(fields: [projectId], references: [id], onDelete: Cascade) + + // Position for ordering in UI + position Int @default(0) + + // Timestamps + createdAt DateTime @default(now()) + updatedAt DateTime @updatedAt + + // Relations + comments Comment[] + + @@unique([projectId, name]) + @@index([projectId]) + @@map("comment_tags") +} + model ShareLink { id String @id @default(cuid()) token String @unique // Random token for URL