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.
This commit is contained in:
Yusuf İpek
2026-02-07 14:46:17 +03:00
parent 88dcf9514c
commit 5856c42181
12 changed files with 1098 additions and 111 deletions
@@ -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<string>('');
@@ -63,9 +70,19 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
visibility: 'PRIVATE' as Visibility,
});
// Tag management state
const [tags, setTags] = useState<CommentTag[]>([]);
const [newTagName, setNewTagName] = useState('');
const [newTagColor, setNewTagColor] = useState('#3B82F6');
const [isAddingTag, setIsAddingTag] = useState(false);
const [editingTagId, setEditingTagId] = useState<string | null>(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');
@@ -264,6 +344,97 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
</CardContent>
</Card>
{/* Comment Tags */}
<Card id="comment-tags" className="border-border/50 shadow-lg">
<CardHeader className="pb-3">
<CardTitle className="text-lg flex items-center gap-2">
<Tag className="h-5 w-5" />
Comment Tags
</CardTitle>
<CardDescription>
Customize tags for categorizing comments on videos
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
{/* Existing tags */}
<div className="space-y-2">
{tags.map((tag) => (
<div key={tag.id} className="flex items-center gap-2 p-2 rounded-lg border bg-card">
{editingTagId === tag.id ? (
<>
<input
type="color"
value={editTagColor}
onChange={(e) => setEditTagColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border-0"
/>
<Input
value={editTagName}
onChange={(e) => setEditTagName(e.target.value)}
className="flex-1 h-8"
onKeyDown={(e) => e.key === 'Enter' && handleUpdateTag(tag.id)}
/>
<Button size="sm" variant="ghost" onClick={() => handleUpdateTag(tag.id)}>
<Save className="h-4 w-4" />
</Button>
<Button size="sm" variant="ghost" onClick={() => setEditingTagId(null)}>
<X className="h-4 w-4" />
</Button>
</>
) : (
<>
<div
className="w-6 h-6 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
<span className="flex-1 text-sm font-medium">{tag.name}</span>
<Button
size="sm"
variant="ghost"
onClick={() => {
setEditingTagId(tag.id);
setEditTagName(tag.name);
setEditTagColor(tag.color);
}}
>
Edit
</Button>
<Button
size="sm"
variant="ghost"
className="text-destructive hover:text-destructive"
onClick={() => handleDeleteTag(tag.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</>
)}
</div>
))}
</div>
{/* Add new tag */}
<div className="flex items-center gap-2 pt-2 border-t">
<input
type="color"
value={newTagColor}
onChange={(e) => setNewTagColor(e.target.value)}
className="w-8 h-8 rounded cursor-pointer border-0"
/>
<Input
placeholder="New tag name..."
value={newTagName}
onChange={(e) => setNewTagName(e.target.value)}
className="flex-1 h-8"
onKeyDown={(e) => e.key === 'Enter' && handleAddTag()}
/>
<Button size="sm" onClick={handleAddTag} disabled={!newTagName.trim() || isAddingTag}>
{isAddingTag ? <Loader2 className="h-4 w-4 animate-spin" /> : <Plus className="h-4 w-4" />}
</Button>
</div>
</CardContent>
</Card>
{/* Danger Zone */}
<Card className="border-destructive/30 shadow-lg">
<CardHeader className="pb-3">
@@ -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<CommentTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(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 () => {
@@ -1176,21 +1331,24 @@ export default function VideoPage() {
/>
{/* Comment markers */}
{comments.map((comment) => (
{comments.map((comment) => {
const markerColor = comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE');
return (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeekToTimestamp(comment.timestamp);
}}
className={cn(
'absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10',
comment.isResolved ? 'bg-green-500' : 'bg-cyan-400'
)}
style={{ left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)` }}
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
style={{
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
backgroundColor: markerColor,
}}
title={`${formatTime(comment.timestamp)}${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`}
/>
))}
);
})}
</div>
</div>
</div>
@@ -1241,6 +1399,14 @@ export default function VideoPage() {
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{authorName}</span>
{comment.tag && (
<span
className="text-[10px] font-medium px-2 py-0.5 rounded-full text-white"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div>
<div className="flex items-center gap-1">
@@ -1802,6 +1968,45 @@ export default function VideoPage() {
>
<Mic className="h-4 w-4" />
</Button>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={selectedTagId ? {
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
} : undefined}
>
<Tag className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{selectedTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
<Tag className="h-3 w-3" />
Manage Tags
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
-31
View File
@@ -267,37 +267,6 @@ export default function SettingsPage() {
</CardDescription>
</CardHeader>
<CardContent className="space-y-4">
<div className="rounded-lg border bg-muted/50 p-3 text-sm text-muted-foreground space-y-1">
<p className="font-medium text-foreground">Setup instructions:</p>
<ol className="list-decimal list-inside space-y-1 text-xs">
<li>
Open Telegram and message{' '}
<a
href="https://t.me/BotFather"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
@BotFather <ExternalLink className="h-3 w-3" />
</a>
</li>
<li>Send <code className="px-1 py-0.5 bg-background rounded text-xs">/newbot</code> and follow the prompts to create a bot</li>
<li>Copy the <strong>Bot Token</strong> and paste it below</li>
<li>
Send a message to your new bot, then visit{' '}
<a
href="https://api.telegram.org"
target="_blank"
rel="noopener noreferrer"
className="text-primary hover:underline inline-flex items-center gap-0.5"
>
api.telegram.org <ExternalLink className="h-3 w-3" />
</a>{' '}
<code className="px-1 py-0.5 bg-background rounded text-xs">/bot&lt;token&gt;/getUpdates</code> to find your <strong>Chat ID</strong>
</li>
</ol>
</div>
<div className="space-y-3">
<div>
<Label htmlFor="telegram-token">Bot Token</Label>
@@ -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<string, unknown> = {};
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 });
}
}
+144
View File
@@ -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 });
}
}
@@ -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 } },
},
},
},
@@ -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 } },
},
},
},
+2
View File
@@ -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 } },
},
},
},
+228 -9
View File
@@ -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<CommentTag[]>([]);
const [selectedTagId, setSelectedTagId] = useState<string | null>(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) => (
{comments.map((comment) => {
const markerColor = comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE');
return (
<button
key={comment.id}
onClick={(e) => {
e.stopPropagation();
handleSeekToTimestamp(comment.timestamp);
}}
className={cn(
'absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10',
comment.isResolved ? 'bg-green-500' : 'bg-cyan-400'
)}
style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }}
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
style={{
left: `calc(${(comment.timestamp / duration) * 100}% - 6px)`,
backgroundColor: markerColor,
}}
title={`${formatTime(comment.timestamp)}${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`}
/>
))}
);
})}
</div>
</div>
</div>
@@ -1070,6 +1242,14 @@ export default function WatchPage() {
</AvatarFallback>
</Avatar>
<span className="text-sm font-medium">{authorName}</span>
{comment.tag && (
<span
className="text-[10px] font-medium px-1.5 py-0.5 rounded-full text-white"
style={{ backgroundColor: comment.tag.color }}
>
{comment.tag.name}
</span>
)}
</div>
<div className="flex items-center gap-1">
@@ -1631,6 +1811,45 @@ export default function WatchPage() {
>
<Mic className="h-4 w-4" />
</Button>
{availableTags.length > 0 && (
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button
size="icon"
variant={selectedTagId ? 'default' : 'outline'}
title="Select tag"
style={selectedTagId ? {
backgroundColor: availableTags.find(t => t.id === selectedTagId)?.color
} : undefined}
>
<Tag className="h-4 w-4" />
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
{availableTags.map((tag) => (
<DropdownMenuItem
key={tag.id}
onClick={() => setSelectedTagId(tag.id)}
className="gap-2"
>
<span
className="w-3 h-3 rounded-full shrink-0"
style={{ backgroundColor: tag.color }}
/>
{tag.name}
{selectedTagId === tag.id && <span className="ml-auto"></span>}
</DropdownMenuItem>
))}
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href={`/projects/${video?.projectId}/settings#comment-tags`} className="gap-2 text-muted-foreground">
<Tag className="h-3 w-3" />
Manage Tags
</Link>
</DropdownMenuItem>
</DropdownMenuContent>
</DropdownMenu>
)}
</div>
</div>
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
+92
View File
@@ -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 (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-md">
<DialogHeader>
<DialogTitle className="text-base">Keyboard Shortcuts</DialogTitle>
</DialogHeader>
<div className="space-y-5 mt-1">
{shortcutGroups.map((group) => (
<div key={group.title}>
<h4 className="text-xs font-medium text-muted-foreground uppercase tracking-wider mb-2.5">
{group.title}
</h4>
<div className="space-y-1.5">
{group.shortcuts.map((shortcut, i) => (
<div
key={i}
className="flex items-center justify-between py-1.5 px-2 rounded-md hover:bg-muted/50 transition-colors"
>
<span className="text-sm">{shortcut.description}</span>
<div className="flex items-center gap-1">
{shortcut.keys.map((key, j) => (
<kbd
key={j}
className="inline-flex h-6 min-w-6 items-center justify-center rounded border border-border bg-muted px-1.5 font-mono text-xs text-muted-foreground"
>
{key}
</kbd>
))}
</div>
</div>
))}
</div>
</div>
))}
</div>
</DialogContent>
</Dialog>
);
}
+10 -1
View File
@@ -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 (
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
@@ -151,6 +155,10 @@ export function Header({ user }: HeaderProps) {
Settings
</Link>
</DropdownMenuItem>
<DropdownMenuItem onClick={() => setShortcutsOpen(true)}>
<Keyboard className="h-4 w-4 mr-2" />
Shortcuts
</DropdownMenuItem>
<DropdownMenuSeparator />
<DropdownMenuItem asChild>
<Link href="/signout">
@@ -170,6 +178,7 @@ export function Header({ user }: HeaderProps) {
)}
</div>
</div>
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
</header>
);
}
+30
View File
@@ -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