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 { useState, useEffect } from 'react';
|
||||||
import { useRouter } from 'next/navigation';
|
import { useRouter } from 'next/navigation';
|
||||||
import Link from 'next/link';
|
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 { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
import { Card, CardContent, CardDescription, CardHeader, CardTitle } from '@/components/ui/card';
|
||||||
import { Input } from '@/components/ui/input';
|
import { Input } from '@/components/ui/input';
|
||||||
@@ -48,6 +48,13 @@ interface ProjectSettingsPageProps {
|
|||||||
params: Promise<{ projectId: string }>;
|
params: Promise<{ projectId: string }>;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentTag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
position: number;
|
||||||
|
}
|
||||||
|
|
||||||
export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
|
export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps) {
|
||||||
const router = useRouter();
|
const router = useRouter();
|
||||||
const [projectId, setProjectId] = useState<string>('');
|
const [projectId, setProjectId] = useState<string>('');
|
||||||
@@ -63,9 +70,19 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
|
|||||||
visibility: 'PRIVATE' as Visibility,
|
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(() => {
|
useEffect(() => {
|
||||||
params.then(({ projectId: id }) => {
|
params.then(({ projectId: id }) => {
|
||||||
setProjectId(id);
|
setProjectId(id);
|
||||||
|
// Fetch project data
|
||||||
fetch(`/api/projects/${id}`)
|
fetch(`/api/projects/${id}`)
|
||||||
.then((res) => res.json())
|
.then((res) => res.json())
|
||||||
.then((data) => {
|
.then((data) => {
|
||||||
@@ -81,6 +98,16 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
|
|||||||
})
|
})
|
||||||
.catch(() => setError('Failed to load project'))
|
.catch(() => setError('Failed to load project'))
|
||||||
.finally(() => setIsLoading(false));
|
.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]);
|
}, [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 () => {
|
const handleDelete = async () => {
|
||||||
if (deleteConfirmation !== formData.name) {
|
if (deleteConfirmation !== formData.name) {
|
||||||
setError('Project name does not match');
|
setError('Project name does not match');
|
||||||
@@ -264,6 +344,97 @@ export default function ProjectSettingsPage({ params }: ProjectSettingsPageProps
|
|||||||
</CardContent>
|
</CardContent>
|
||||||
</Card>
|
</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 */}
|
{/* Danger Zone */}
|
||||||
<Card className="border-destructive/30 shadow-lg">
|
<Card className="border-destructive/30 shadow-lg">
|
||||||
<CardHeader className="pb-3">
|
<CardHeader className="pb-3">
|
||||||
|
|||||||
@@ -30,6 +30,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
X,
|
X,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -50,6 +51,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -69,6 +71,12 @@ interface Version {
|
|||||||
_count: { comments: number };
|
_count: { comments: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentTag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -79,6 +87,7 @@ interface Comment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
guestName: string | null;
|
guestName: string | null;
|
||||||
|
tag: CommentTag | null;
|
||||||
replies: {
|
replies: {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -87,6 +96,7 @@ interface Comment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
guestName: string | null;
|
guestName: string | null;
|
||||||
|
tag: CommentTag | null;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -184,6 +194,10 @@ export default function VideoPage() {
|
|||||||
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
const [newVersionUrlError, setNewVersionUrlError] = useState('');
|
||||||
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
const [isCreatingVersion, setIsCreatingVersion] = useState(false);
|
||||||
|
|
||||||
|
// Comment tags state
|
||||||
|
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
||||||
|
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Fetch video data
|
// Fetch video data
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
async function fetchVideo() {
|
async function fetchVideo() {
|
||||||
@@ -212,6 +226,26 @@ export default function VideoPage() {
|
|||||||
const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
||||||
const duration = videoDuration || activeVersion?.duration || 0;
|
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
|
// Load YouTube iframe API script once
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (window.YT) return;
|
if (window.YT) return;
|
||||||
@@ -287,6 +321,125 @@ export default function VideoPage() {
|
|||||||
return () => clearInterval(interval);
|
return () => clearInterval(interval);
|
||||||
}, [isReady, isDragging]);
|
}, [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(() => {
|
const handlePlayPause = useCallback(() => {
|
||||||
if (!playerRef.current) return;
|
if (!playerRef.current) return;
|
||||||
if (isPlaying) {
|
if (isPlaying) {
|
||||||
@@ -381,6 +534,7 @@ export default function VideoPage() {
|
|||||||
timestamp: selectedTimestamp ?? currentTime,
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||||
...(isGuest && guestName && { guestName }),
|
...(isGuest && guestName && { guestName }),
|
||||||
|
...(selectedTagId && { tagId: selectedTagId }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -399,13 +553,14 @@ export default function VideoPage() {
|
|||||||
});
|
});
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setSelectedTimestamp(null);
|
setSelectedTimestamp(null);
|
||||||
|
setSelectedTagId(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add comment:', err);
|
console.error('Failed to add comment:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingComment(false);
|
setIsSubmittingComment(false);
|
||||||
}
|
}
|
||||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName]);
|
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]);
|
||||||
|
|
||||||
// Voice recording handlers
|
// Voice recording handlers
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
@@ -1176,21 +1331,24 @@ export default function VideoPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Comment markers */}
|
{/* Comment markers */}
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => {
|
||||||
|
const markerColor = comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE');
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSeekToTimestamp(comment.timestamp);
|
handleSeekToTimestamp(comment.timestamp);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
|
||||||
'absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10',
|
style={{
|
||||||
comment.isResolved ? 'bg-green-500' : 'bg-cyan-400'
|
left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)`,
|
||||||
)}
|
backgroundColor: markerColor,
|
||||||
style={{ left: `calc(${duration > 0 ? (comment.timestamp / duration) * 100 : 0}% - 6px)` }}
|
}}
|
||||||
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
|
title={`${formatTime(comment.timestamp)}${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1241,6 +1399,14 @@ export default function VideoPage() {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">{authorName}</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -1802,6 +1968,45 @@ export default function VideoPage() {
|
|||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
||||||
|
|||||||
@@ -267,37 +267,6 @@ export default function SettingsPage() {
|
|||||||
</CardDescription>
|
</CardDescription>
|
||||||
</CardHeader>
|
</CardHeader>
|
||||||
<CardContent className="space-y-4">
|
<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 className="space-y-3">
|
||||||
<div>
|
<div>
|
||||||
<Label htmlFor="telegram-token">Bot Token</Label>
|
<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' },
|
orderBy: { timestamp: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
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' },
|
orderBy: { timestamp: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
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 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
|
// Validate required fields
|
||||||
if (timestamp === undefined || timestamp === null) {
|
if (timestamp === undefined || timestamp === null) {
|
||||||
@@ -173,13 +175,16 @@ export async function POST(request: NextRequest, { params }: RouteParams) {
|
|||||||
authorId: session?.user?.id || null,
|
authorId: session?.user?.id || null,
|
||||||
guestName: isGuest ? guestName : null,
|
guestName: isGuest ? guestName : null,
|
||||||
guestEmail: isGuest ? guestEmail : null,
|
guestEmail: isGuest ? guestEmail : null,
|
||||||
|
tagId: tagId || null,
|
||||||
versionId,
|
versionId,
|
||||||
},
|
},
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
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 },
|
where: { parentId: null },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
replies: {
|
replies: {
|
||||||
orderBy: { createdAt: 'asc' },
|
orderBy: { createdAt: 'asc' },
|
||||||
include: {
|
include: {
|
||||||
author: { select: { id: true, name: true, image: true } },
|
author: { select: { id: true, name: true, image: true } },
|
||||||
|
tag: { select: { id: true, name: true, color: true } },
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|||||||
@@ -26,6 +26,7 @@ import {
|
|||||||
X,
|
X,
|
||||||
ArrowUpRight,
|
ArrowUpRight,
|
||||||
User,
|
User,
|
||||||
|
Tag,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Badge } from '@/components/ui/badge';
|
import { Badge } from '@/components/ui/badge';
|
||||||
@@ -37,6 +38,7 @@ import {
|
|||||||
DropdownMenu,
|
DropdownMenu,
|
||||||
DropdownMenuContent,
|
DropdownMenuContent,
|
||||||
DropdownMenuItem,
|
DropdownMenuItem,
|
||||||
|
DropdownMenuSeparator,
|
||||||
DropdownMenuTrigger,
|
DropdownMenuTrigger,
|
||||||
} from '@/components/ui/dropdown-menu';
|
} from '@/components/ui/dropdown-menu';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
@@ -55,6 +57,12 @@ interface Version {
|
|||||||
_count: { comments: number };
|
_count: { comments: number };
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface CommentTag {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
color: string;
|
||||||
|
}
|
||||||
|
|
||||||
interface Comment {
|
interface Comment {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -65,6 +73,7 @@ interface Comment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
guestName: string | null;
|
guestName: string | null;
|
||||||
|
tag: CommentTag | null;
|
||||||
replies: {
|
replies: {
|
||||||
id: string;
|
id: string;
|
||||||
content: string | null;
|
content: string | null;
|
||||||
@@ -73,6 +82,7 @@ interface Comment {
|
|||||||
createdAt: string;
|
createdAt: string;
|
||||||
author: { id: string; name: string | null; image: string | null } | null;
|
author: { id: string; name: string | null; image: string | null } | null;
|
||||||
guestName: string | null;
|
guestName: string | null;
|
||||||
|
tag: CommentTag | null;
|
||||||
}[];
|
}[];
|
||||||
}
|
}
|
||||||
|
|
||||||
@@ -97,6 +107,8 @@ function formatTime(seconds: number): string {
|
|||||||
return `${mins}:${secs.toString().padStart(2, '0')}`;
|
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() {
|
export default function WatchPage() {
|
||||||
const params = useParams();
|
const params = useParams();
|
||||||
const videoId = params.videoId as string;
|
const videoId = params.videoId as string;
|
||||||
@@ -115,6 +127,7 @@ export default function WatchPage() {
|
|||||||
const [currentTime, setCurrentTime] = useState(0);
|
const [currentTime, setCurrentTime] = useState(0);
|
||||||
const [isMuted, setIsMuted] = useState(false);
|
const [isMuted, setIsMuted] = useState(false);
|
||||||
const [isDragging, setIsDragging] = useState(false);
|
const [isDragging, setIsDragging] = useState(false);
|
||||||
|
const [playbackSpeed, setPlaybackSpeed] = useState(1);
|
||||||
|
|
||||||
const [commentText, setCommentText] = useState('');
|
const [commentText, setCommentText] = useState('');
|
||||||
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
const [isSubmittingComment, setIsSubmittingComment] = useState(false);
|
||||||
@@ -153,6 +166,10 @@ export default function WatchPage() {
|
|||||||
const [guestName, setGuestName] = useState('');
|
const [guestName, setGuestName] = useState('');
|
||||||
const [guestNameConfirmed, setGuestNameConfirmed] = useState(false);
|
const [guestNameConfirmed, setGuestNameConfirmed] = useState(false);
|
||||||
|
|
||||||
|
// Tag state
|
||||||
|
const [availableTags, setAvailableTags] = useState<CommentTag[]>([]);
|
||||||
|
const [selectedTagId, setSelectedTagId] = useState<string | null>(null);
|
||||||
|
|
||||||
// Restore guest name from localStorage
|
// Restore guest name from localStorage
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
const saved = localStorage.getItem('openframe_guest_name');
|
const saved = localStorage.getItem('openframe_guest_name');
|
||||||
@@ -192,6 +209,156 @@ export default function WatchPage() {
|
|||||||
const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
const filteredComments = comments.filter((c) => showResolved || !c.isResolved);
|
||||||
const duration = activeVersion?.duration || 300;
|
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
|
// Load YouTube iframe API
|
||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
if (!activeVersion || activeVersion.providerId !== 'youtube') return;
|
if (!activeVersion || activeVersion.providerId !== 'youtube') return;
|
||||||
@@ -326,6 +493,7 @@ export default function WatchPage() {
|
|||||||
timestamp: selectedTimestamp ?? currentTime,
|
timestamp: selectedTimestamp ?? currentTime,
|
||||||
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
...(voiceData && { voiceUrl: voiceData.url, voiceDuration: voiceData.duration }),
|
||||||
...(isGuest && guestName && { guestName }),
|
...(isGuest && guestName && { guestName }),
|
||||||
|
...(selectedTagId && { tagId: selectedTagId }),
|
||||||
}),
|
}),
|
||||||
});
|
});
|
||||||
|
|
||||||
@@ -344,13 +512,14 @@ export default function WatchPage() {
|
|||||||
});
|
});
|
||||||
setCommentText('');
|
setCommentText('');
|
||||||
setSelectedTimestamp(null);
|
setSelectedTimestamp(null);
|
||||||
|
setSelectedTagId(null);
|
||||||
}
|
}
|
||||||
} catch (err) {
|
} catch (err) {
|
||||||
console.error('Failed to add comment:', err);
|
console.error('Failed to add comment:', err);
|
||||||
} finally {
|
} finally {
|
||||||
setIsSubmittingComment(false);
|
setIsSubmittingComment(false);
|
||||||
}
|
}
|
||||||
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName]);
|
}, [commentText, currentTime, selectedTimestamp, activeVersion, activeVersionId, isGuest, guestName, selectedTagId]);
|
||||||
|
|
||||||
// Voice recording handlers
|
// Voice recording handlers
|
||||||
const startRecording = useCallback(async () => {
|
const startRecording = useCallback(async () => {
|
||||||
@@ -1005,21 +1174,24 @@ export default function WatchPage() {
|
|||||||
/>
|
/>
|
||||||
|
|
||||||
{/* Comment markers */}
|
{/* Comment markers */}
|
||||||
{comments.map((comment) => (
|
{comments.map((comment) => {
|
||||||
|
const markerColor = comment.tag?.color || (comment.isResolved ? '#22C55E' : '#22D3EE');
|
||||||
|
return (
|
||||||
<button
|
<button
|
||||||
key={comment.id}
|
key={comment.id}
|
||||||
onClick={(e) => {
|
onClick={(e) => {
|
||||||
e.stopPropagation();
|
e.stopPropagation();
|
||||||
handleSeekToTimestamp(comment.timestamp);
|
handleSeekToTimestamp(comment.timestamp);
|
||||||
}}
|
}}
|
||||||
className={cn(
|
className="absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10"
|
||||||
'absolute top-1/2 -translate-y-1/2 w-3 h-3 rounded-full transition-transform hover:scale-150 z-10',
|
style={{
|
||||||
comment.isResolved ? 'bg-green-500' : 'bg-cyan-400'
|
left: `calc(${(comment.timestamp / duration) * 100}% - 6px)`,
|
||||||
)}
|
backgroundColor: markerColor,
|
||||||
style={{ left: `calc(${(comment.timestamp / duration) * 100}% - 6px)` }}
|
}}
|
||||||
title={`${formatTime(comment.timestamp)} - ${comment.content?.substring(0, 30)}...`}
|
title={`${formatTime(comment.timestamp)}${comment.tag ? ` [${comment.tag.name}]` : ''} - ${comment.content?.substring(0, 30) || '(voice note)'}...`}
|
||||||
/>
|
/>
|
||||||
))}
|
);
|
||||||
|
})}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
@@ -1070,6 +1242,14 @@ export default function WatchPage() {
|
|||||||
</AvatarFallback>
|
</AvatarFallback>
|
||||||
</Avatar>
|
</Avatar>
|
||||||
<span className="text-sm font-medium">{authorName}</span>
|
<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>
|
||||||
|
|
||||||
<div className="flex items-center gap-1">
|
<div className="flex items-center gap-1">
|
||||||
@@ -1631,6 +1811,45 @@ export default function WatchPage() {
|
|||||||
>
|
>
|
||||||
<Mic className="h-4 w-4" />
|
<Mic className="h-4 w-4" />
|
||||||
</Button>
|
</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>
|
||||||
</div>
|
</div>
|
||||||
<p className="text-xs text-muted-foreground mt-1">Cmd+Enter to submit</p>
|
<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';
|
'use client';
|
||||||
|
|
||||||
|
import { useState } from 'react';
|
||||||
import Link from 'next/link';
|
import Link from 'next/link';
|
||||||
import { usePathname } from 'next/navigation';
|
import { usePathname } from 'next/navigation';
|
||||||
import {
|
import {
|
||||||
@@ -10,7 +11,8 @@ import {
|
|||||||
Settings,
|
Settings,
|
||||||
LogOut,
|
LogOut,
|
||||||
User,
|
User,
|
||||||
Menu
|
Menu,
|
||||||
|
Keyboard
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import {
|
import {
|
||||||
@@ -23,6 +25,7 @@ import {
|
|||||||
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
|
||||||
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
import { Sheet, SheetContent, SheetTrigger } from '@/components/ui/sheet';
|
||||||
import { ThemeToggle } from '@/components/theme-toggle';
|
import { ThemeToggle } from '@/components/theme-toggle';
|
||||||
|
import { KeyboardShortcutsModal } from '@/components/keyboard-shortcuts-modal';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface NavItem {
|
interface NavItem {
|
||||||
@@ -46,6 +49,7 @@ interface HeaderProps {
|
|||||||
|
|
||||||
export function Header({ user }: HeaderProps) {
|
export function Header({ user }: HeaderProps) {
|
||||||
const pathname = usePathname();
|
const pathname = usePathname();
|
||||||
|
const [shortcutsOpen, setShortcutsOpen] = useState(false);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<header className="sticky top-0 z-50 w-full border-b bg-background/95 backdrop-blur supports-[backdrop-filter]:bg-background/60">
|
<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
|
Settings
|
||||||
</Link>
|
</Link>
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onClick={() => setShortcutsOpen(true)}>
|
||||||
|
<Keyboard className="h-4 w-4 mr-2" />
|
||||||
|
Shortcuts
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuSeparator />
|
<DropdownMenuSeparator />
|
||||||
<DropdownMenuItem asChild>
|
<DropdownMenuItem asChild>
|
||||||
<Link href="/signout">
|
<Link href="/signout">
|
||||||
@@ -170,6 +178,7 @@ export function Header({ user }: HeaderProps) {
|
|||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<KeyboardShortcutsModal open={shortcutsOpen} onOpenChange={setShortcutsOpen} />
|
||||||
</header>
|
</header>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -153,6 +153,7 @@ model Project {
|
|||||||
videos Video[]
|
videos Video[]
|
||||||
members ProjectMember[]
|
members ProjectMember[]
|
||||||
shareLinks ShareLink[]
|
shareLinks ShareLink[]
|
||||||
|
commentTags CommentTag[]
|
||||||
|
|
||||||
@@index([ownerId])
|
@@index([ownerId])
|
||||||
@@index([slug])
|
@@index([slug])
|
||||||
@@ -279,6 +280,10 @@ model Comment {
|
|||||||
versionId String
|
versionId String
|
||||||
version VideoVersion @relation(fields: [versionId], references: [id], onDelete: Cascade)
|
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
|
// Timestamps
|
||||||
createdAt DateTime @default(now())
|
createdAt DateTime @default(now())
|
||||||
updatedAt DateTime @updatedAt
|
updatedAt DateTime @updatedAt
|
||||||
@@ -287,9 +292,34 @@ model Comment {
|
|||||||
@@index([parentId])
|
@@index([parentId])
|
||||||
@@index([authorId])
|
@@index([authorId])
|
||||||
@@index([timestamp])
|
@@index([timestamp])
|
||||||
|
@@index([tagId])
|
||||||
@@map("comments")
|
@@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 {
|
model ShareLink {
|
||||||
id String @id @default(cuid())
|
id String @id @default(cuid())
|
||||||
token String @unique // Random token for URL
|
token String @unique // Random token for URL
|
||||||
|
|||||||
Reference in New Issue
Block a user