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
@@ -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 } },
},
},
},