mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
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:
@@ -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>
|
||||
|
||||
@@ -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<token>/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 });
|
||||
}
|
||||
}
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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 } },
|
||||
},
|
||||
},
|
||||
},
|
||||
|
||||
@@ -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>
|
||||
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
@@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
@@ -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
|
||||
|
||||
Reference in New Issue
Block a user