feat: add approvals workflow and unified member invitation management across projects, workspaces, and videos

This commit is contained in:
Yusuf İpek
2026-02-25 16:24:45 +03:00
parent 0ae1becc1b
commit a2b07b3e19
36 changed files with 3122 additions and 982 deletions
+442
View File
@@ -0,0 +1,442 @@
'use client';
import { ReactNode, useCallback, useEffect, useState } from 'react';
import Link from 'next/link';
import { useRouter } from 'next/navigation';
import {
ArrowLeft,
Clock3,
Crown,
Loader2,
MailX,
MessageSquare,
Plus,
Shield,
Trash2,
UserPlus,
} 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';
import { Label } from '@/components/ui/label';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import {
Select,
SelectContent,
SelectItem,
SelectTrigger,
SelectValue,
} from '@/components/ui/select';
interface Member {
id: string;
role: 'ADMIN' | 'COMMENTATOR';
userId: string;
user: {
id: string;
name: string | null;
email: string | null;
image: string | null;
};
}
interface Owner {
id: string;
name: string | null;
email: string | null;
image: string | null;
}
interface PendingInvitation {
id: string;
email: string;
role: 'ADMIN' | 'COMMENTATOR';
createdAt: string;
expiresAt: string;
invitedBy: {
id: string;
name: string | null;
email: string | null;
};
}
interface MembersManagementPageProps {
apiBasePath: string;
backHref: string;
backLabel: string;
title: string;
subtitle: string;
membersDescription: ReactNode;
forbiddenRedirect: string;
}
export function MembersManagementPage({
apiBasePath,
backHref,
backLabel,
title,
subtitle,
membersDescription,
forbiddenRedirect,
}: MembersManagementPageProps) {
const router = useRouter();
const [members, setMembers] = useState<Member[]>([]);
const [owner, setOwner] = useState<Owner | null>(null);
const [pendingInvitations, setPendingInvitations] = useState<PendingInvitation[]>([]);
const [isLoading, setIsLoading] = useState(true);
const [inviteEmail, setInviteEmail] = useState('');
const [inviteRole, setInviteRole] = useState<'ADMIN' | 'COMMENTATOR'>('COMMENTATOR');
const [isInviting, setIsInviting] = useState(false);
const [cancelingInvitationId, setCancelingInvitationId] = useState<string | null>(null);
const [error, setError] = useState('');
const [success, setSuccess] = useState('');
const fetchMembers = useCallback(async () => {
try {
const res = await fetch(`${apiBasePath}/members`, {
cache: 'no-store',
});
if (!res.ok) {
if (res.status === 403) router.push(forbiddenRedirect);
return;
}
const data = await res.json();
setMembers(data.data.members);
setOwner(data.data.owner);
setPendingInvitations(data.data.pendingInvitations || []);
} catch {
setError('Failed to load members');
} finally {
setIsLoading(false);
}
}, [apiBasePath, forbiddenRedirect, router]);
useEffect(() => {
fetchMembers();
}, [fetchMembers]);
useEffect(() => {
const interval = window.setInterval(() => {
void fetchMembers();
}, 5000);
return () => window.clearInterval(interval);
}, [fetchMembers]);
const handleInvite = async (e: React.FormEvent) => {
e.preventDefault();
setIsInviting(true);
setError('');
setSuccess('');
try {
const res = await fetch(`${apiBasePath}/members`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ email: inviteEmail, role: inviteRole }),
});
const data = await res.json();
if (!res.ok) {
setError(data.error || 'Failed to invite member');
return;
}
if (data.user) {
setSuccess(`Invited ${data.user.name || data.user.email || inviteEmail} as ${inviteRole.toLowerCase()}`);
} else {
setSuccess(data.message || `Invitation sent to ${inviteEmail}`);
}
setInviteEmail('');
fetchMembers();
} catch {
setError('Something went wrong');
} finally {
setIsInviting(false);
}
};
const handleRoleChange = async (memberId: string, newRole: string) => {
try {
const res = await fetch(`${apiBasePath}/members/${memberId}`, {
method: 'PATCH',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ role: newRole }),
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to update role');
return;
}
fetchMembers();
} catch {
setError('Failed to update role');
}
};
const handleRemove = async (memberId: string) => {
if (!confirm('Are you sure you want to remove this member?')) return;
try {
const res = await fetch(`${apiBasePath}/members/${memberId}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to remove member');
return;
}
fetchMembers();
} catch {
setError('Failed to remove member');
}
};
const handleCancelInvitation = async (invitationId: string) => {
setCancelingInvitationId(invitationId);
setError('');
setSuccess('');
try {
const res = await fetch(`${apiBasePath}/members/invitations/${invitationId}`, {
method: 'DELETE',
});
if (!res.ok) {
const data = await res.json();
setError(data.error || 'Failed to cancel invitation');
return;
}
setSuccess('Invitation canceled');
fetchMembers();
} catch {
setError('Failed to cancel invitation');
} finally {
setCancelingInvitationId(null);
}
};
if (isLoading) {
return (
<div className="flex items-center justify-center min-h-[50vh]">
<Loader2 className="h-8 w-8 animate-spin text-muted-foreground" />
</div>
);
}
return (
<div className="px-6 lg:px-8 py-8 w-full max-w-4xl mx-auto">
<div className="mb-6">
<Link
href={backHref}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
>
<ArrowLeft className="h-4 w-4 mr-1" />
{backLabel}
</Link>
</div>
<div className="mb-8">
<h1 className="text-3xl font-bold tracking-tight">{title}</h1>
<p className="text-muted-foreground mt-1">{subtitle}</p>
</div>
<Card className="mb-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<UserPlus className="h-5 w-5" />
Invite Member
</CardTitle>
<CardDescription>
Invite someone by email. They must have an account to be added.
</CardDescription>
</CardHeader>
<CardContent>
<form onSubmit={handleInvite} className="flex flex-col sm:flex-row gap-3 sm:items-end">
<div className="w-full sm:flex-1">
<Label htmlFor="email" className="mb-2 block">Email Address</Label>
<Input
id="email"
type="email"
placeholder="[email protected]"
value={inviteEmail}
onChange={(e) => setInviteEmail(e.target.value)}
required
disabled={isInviting}
/>
</div>
<div className="w-full sm:w-40">
<Label className="mb-2 block">Role</Label>
<Select value={inviteRole} onValueChange={(v) => setInviteRole(v as 'ADMIN' | 'COMMENTATOR')}>
<SelectTrigger className="w-full">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">Admin</SelectItem>
<SelectItem value="COMMENTATOR">Commentator</SelectItem>
</SelectContent>
</Select>
</div>
<Button type="submit" disabled={isInviting} className="w-full sm:w-auto">
{isInviting ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<Plus className="h-4 w-4 mr-1" />
Invite
</>
)}
</Button>
</form>
{error && (
<div className="mt-3 rounded-md bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
)}
{success && (
<div className="mt-3 rounded-md bg-green-500/10 p-3 text-sm text-green-700 dark:text-green-400">
{success}
</div>
)}
</CardContent>
</Card>
<Card>
<CardHeader>
<CardTitle>Current Members</CardTitle>
<CardDescription>{membersDescription}</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{owner && (
<div className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg bg-accent/30">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={owner.image ?? undefined} />
<AvatarFallback>{owner.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{owner.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{owner.email}</p>
</div>
</div>
<Badge variant="default" className="flex items-center gap-1">
<Crown className="h-3 w-3" />
Owner
</Badge>
</div>
)}
{members.map((member) => (
<div key={member.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 sm:gap-0 p-3 rounded-lg border">
<div className="flex items-center gap-3">
<Avatar className="h-9 w-9">
<AvatarImage src={member.user.image ?? undefined} />
<AvatarFallback>{member.user.name?.charAt(0).toUpperCase() ?? 'U'}</AvatarFallback>
</Avatar>
<div>
<p className="text-sm font-medium">{member.user.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground">{member.user.email}</p>
</div>
</div>
<div className="flex items-center gap-2 w-full sm:w-auto">
<Select
value={member.role}
onValueChange={(v) => handleRoleChange(member.id, v)}
>
<SelectTrigger className="w-full sm:w-36 h-8">
<SelectValue />
</SelectTrigger>
<SelectContent>
<SelectItem value="ADMIN">
<span className="flex items-center gap-1.5">
<Shield className="h-3.5 w-3.5" />
Admin
</span>
</SelectItem>
<SelectItem value="COMMENTATOR">
<span className="flex items-center gap-1.5">
<MessageSquare className="h-3.5 w-3.5" />
Commentator
</span>
</SelectItem>
</SelectContent>
</Select>
<Button
variant="ghost"
size="icon"
className="h-8 w-8 text-destructive hover:text-destructive"
onClick={() => handleRemove(member.id)}
>
<Trash2 className="h-4 w-4" />
</Button>
</div>
</div>
))}
{members.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No members yet. Invite someone above.
</p>
)}
</CardContent>
</Card>
<Card className="mt-8">
<CardHeader>
<CardTitle className="flex items-center gap-2">
<Clock3 className="h-5 w-5" />
Pending Invitations
</CardTitle>
<CardDescription>
Invitations that were sent but not accepted yet.
</CardDescription>
</CardHeader>
<CardContent className="space-y-3">
{pendingInvitations.map((invitation) => (
<div key={invitation.id} className="flex flex-col sm:flex-row sm:items-center justify-between gap-4 p-3 rounded-lg border">
<div>
<p className="text-sm font-medium">{invitation.email}</p>
<p className="text-xs text-muted-foreground">
{invitation.role === 'ADMIN' ? 'Admin' : 'Commentator'} · Sent by {invitation.invitedBy.name || invitation.invitedBy.email || 'Unknown'}
</p>
<p className="text-xs text-muted-foreground">
Expires {new Date(invitation.expiresAt).toLocaleString()}
</p>
</div>
<Button
variant="outline"
size="sm"
onClick={() => handleCancelInvitation(invitation.id)}
disabled={cancelingInvitationId === invitation.id}
>
{cancelingInvitationId === invitation.id ? (
<Loader2 className="h-4 w-4 animate-spin" />
) : (
<>
<MailX className="h-4 w-4 mr-2" />
Cancel
</>
)}
</Button>
</div>
))}
{pendingInvitations.length === 0 && (
<p className="text-sm text-muted-foreground text-center py-4">
No pending invitations.
</p>
)}
</CardContent>
</Card>
</div>
);
}
+84
View File
@@ -23,6 +23,8 @@ import { useDownloadActions } from '@/components/video-page/hooks/use-download-a
import { useVersionDurationSync } from '@/components/video-page/hooks/use-version-duration-sync';
import { CommentComposer } from '@/components/video-page/comment-composer';
import { CommentsPane } from '@/components/video-page/comments-pane';
import { ApprovalRequestDialog } from '@/components/video-page/approval-request-dialog';
import { ApprovalRequestsPanel } from '@/components/video-page/approval-requests-panel';
import type {
CommentMarker,
PlayerAdapter,
@@ -31,6 +33,7 @@ import type {
VideoPageComposerActions,
VideoPageHeaderActions,
} from '@/components/video-page/types';
import { useApprovals } from '@/components/video-page/hooks/use-approvals';
function formatTime(seconds: number): string {
const totalSeconds = Math.floor(seconds);
@@ -111,6 +114,8 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
// Compare dialog state
const [showCompareDialog, setShowCompareDialog] = useState(false);
const [selectedCompareVersions, setSelectedCompareVersions] = useState<Set<string>>(new Set());
const [showApprovalRequestDialog, setShowApprovalRequestDialog] = useState(false);
const [showApprovalsPanel, setShowApprovalsPanel] = useState(false);
const router = useRouter();
const {
@@ -185,6 +190,29 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
const currentUserId = video?.currentUserId || null;
const currentUserName = video?.currentUserName || null;
const canResolveComments = !!video?.canResolveComments;
const canRequestApproval = !!video?.canRequestApproval;
const {
requests: approvalRequests,
candidates: approvalCandidates,
isLoadingRequests: isLoadingApprovals,
isLoadingCandidates: isLoadingApprovalCandidates,
isSubmittingRequest: isSubmittingApprovalRequest,
isSubmittingDecision: isSubmittingApprovalDecision,
isCancelingRequest: isCancelingApprovalRequest,
activePendingRequest,
error: approvalError,
setError: setApprovalError,
fetchRequests: fetchApprovalRequests,
fetchCandidates: fetchApprovalCandidates,
createRequest: createApprovalRequest,
submitDecision: submitApprovalDecision,
cancelRequest: cancelApprovalRequest,
} = useApprovals({
projectId,
activeVersionId,
currentUserId,
});
// Memoize active version lookup to avoid recalculating on every render
const activeVersion = useMemo(() => {
@@ -324,6 +352,16 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
return qualityOptions.find((option) => option.level === selectedQualityLevel)?.label ?? 'Auto';
}, [qualityOptions, selectedQualityLevel]);
useEffect(() => {
if (!activeVersionId || mode !== 'dashboard') return;
void fetchApprovalRequests();
}, [activeVersionId, fetchApprovalRequests, mode]);
useEffect(() => {
if (!showApprovalRequestDialog || mode !== 'dashboard') return;
void fetchApprovalCandidates();
}, [fetchApprovalCandidates, mode, showApprovalRequestDialog]);
const {
commentText,
setCommentText,
@@ -462,6 +500,17 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
setShowCompareDialog(true);
}, [activeVersionId]);
const handleOpenApprovalRequestDialog = useCallback(() => {
setApprovalError('');
setShowApprovalRequestDialog(true);
}, [setApprovalError]);
const handleOpenApprovalsPanel = useCallback(() => {
setApprovalError('');
setShowApprovalsPanel(true);
void fetchApprovalRequests();
}, [fetchApprovalRequests, setApprovalError]);
const toggleCompareVersion = useCallback((versionId: string) => {
setSelectedCompareVersions((prev) => {
const next = new Set(prev);
@@ -605,6 +654,10 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
isCreatingVersion={isCreatingVersion}
onCreateVersion={headerActions.onCreateVersion}
onOpenCompare={headerActions.onOpenCompare}
canRequestApproval={canRequestApproval}
hasPendingApprovalRequest={!!activePendingRequest}
onOpenApprovalRequest={handleOpenApprovalRequestDialog}
onOpenApprovalsPanel={handleOpenApprovalsPanel}
/>
<PlayerCore
@@ -782,6 +835,37 @@ export function VideoPageContent({ mode, videoId, projectId: propProjectId }: Vi
onToggleVersion={compareActions.onToggleVersion}
onCompare={compareActions.onCompare}
/>
{mode === 'dashboard' ? (
<>
<ApprovalRequestDialog
open={showApprovalRequestDialog}
onOpenChange={setShowApprovalRequestDialog}
candidates={approvalCandidates}
currentUserId={currentUserId}
activePendingRequest={activePendingRequest}
isLoadingCandidates={isLoadingApprovalCandidates}
isSubmittingRequest={isSubmittingApprovalRequest}
error={approvalError}
onRefreshCandidates={fetchApprovalCandidates}
onCreateRequest={createApprovalRequest}
/>
<ApprovalRequestsPanel
open={showApprovalsPanel}
onOpenChange={setShowApprovalsPanel}
requests={approvalRequests}
currentUserId={currentUserId}
canRequestApproval={canRequestApproval}
isLoadingRequests={isLoadingApprovals}
isSubmittingDecision={isSubmittingApprovalDecision}
isCancelingRequest={isCancelingApprovalRequest}
error={approvalError}
onRefresh={fetchApprovalRequests}
onSubmitDecision={submitApprovalDecision}
onCancelRequest={cancelApprovalRequest}
/>
</>
) : null}
</div >
);
}
@@ -0,0 +1,164 @@
'use client';
import { useMemo, useState } from 'react';
import { Check, Loader2 } from 'lucide-react';
import { Avatar, AvatarFallback, AvatarImage } from '@/components/ui/avatar';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
Dialog,
DialogContent,
DialogDescription,
DialogFooter,
DialogHeader,
DialogTitle,
} from '@/components/ui/dialog';
import { Textarea } from '@/components/ui/textarea';
import type { ApprovalRequest } from '@/components/video-page/types';
import type { ApprovalCandidate } from '@/components/video-page/hooks/use-approvals';
interface ApprovalRequestDialogProps {
open: boolean;
onOpenChange: (open: boolean) => void;
candidates: ApprovalCandidate[];
currentUserId: string | null;
activePendingRequest: ApprovalRequest | null;
isLoadingCandidates: boolean;
isSubmittingRequest: boolean;
error: string;
onRefreshCandidates: () => void;
onCreateRequest: (approverIds: string[], message?: string) => Promise<boolean>;
}
export function ApprovalRequestDialog({
open,
onOpenChange,
candidates,
currentUserId,
activePendingRequest,
isLoadingCandidates,
isSubmittingRequest,
error,
onRefreshCandidates,
onCreateRequest,
}: ApprovalRequestDialogProps) {
const [selectedApproverIds, setSelectedApproverIds] = useState<string[]>([]);
const [message, setMessage] = useState('');
const selectableCandidates = useMemo(
() => candidates.filter((candidate) => candidate.id !== currentUserId),
[candidates, currentUserId]
);
const toggleApprover = (userId: string) => {
setSelectedApproverIds((current) => (
current.includes(userId)
? current.filter((id) => id !== userId)
: [...current, userId]
));
};
const handleCreate = async () => {
const success = await onCreateRequest(selectedApproverIds, message.trim() || undefined);
if (success) {
setSelectedApproverIds([]);
setMessage('');
onOpenChange(false);
}
};
const isBlockedByPendingRequest = !!activePendingRequest;
return (
<Dialog open={open} onOpenChange={onOpenChange}>
<DialogContent className="sm:max-w-lg">
<DialogHeader>
<DialogTitle>Request Approval</DialogTitle>
<DialogDescription>
Select one or more approvers for this version.
</DialogDescription>
</DialogHeader>
{isBlockedByPendingRequest ? (
<div className="rounded-md border border-amber-500/40 bg-amber-500/10 p-3 text-sm text-amber-700 dark:text-amber-300">
A pending approval request already exists for this version.
</div>
) : null}
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
) : null}
<div className="space-y-2">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">Approvers ({selectedApproverIds.length} selected)</p>
<Button size="sm" variant="ghost" onClick={onRefreshCandidates} disabled={isLoadingCandidates}>
{isLoadingCandidates ? <Loader2 className="h-4 w-4 animate-spin" /> : 'Refresh'}
</Button>
</div>
<div className="max-h-56 overflow-y-auto rounded-md border p-2 space-y-1">
{selectableCandidates.length === 0 ? (
<p className="text-sm text-muted-foreground px-2 py-3">No eligible approvers found.</p>
) : (
selectableCandidates.map((candidate) => {
const selected = selectedApproverIds.includes(candidate.id);
return (
<button
key={candidate.id}
type="button"
className="w-full rounded-md border p-2 text-left hover:bg-accent/50 transition-colors"
onClick={() => toggleApprover(candidate.id)}
disabled={isBlockedByPendingRequest}
>
<div className="flex items-center justify-between gap-3">
<div className="flex items-center gap-2 min-w-0">
<Avatar className="h-7 w-7">
<AvatarImage src={candidate.image ?? undefined} />
<AvatarFallback>{(candidate.name || candidate.email || 'U').charAt(0).toUpperCase()}</AvatarFallback>
</Avatar>
<div className="min-w-0">
<p className="text-sm font-medium truncate">{candidate.name || 'Unnamed'}</p>
<p className="text-xs text-muted-foreground truncate">{candidate.email || 'No email'}</p>
</div>
</div>
{selected ? (
<Badge variant="default" className="gap-1">
<Check className="h-3 w-3" />
Selected
</Badge>
) : null}
</div>
</button>
);
})
)}
</div>
</div>
<div className="space-y-2">
<p className="text-xs text-muted-foreground">Message (optional)</p>
<Textarea
value={message}
onChange={(event) => setMessage(event.target.value)}
placeholder="Include context for the approvers..."
rows={3}
maxLength={2000}
disabled={isBlockedByPendingRequest}
/>
</div>
<DialogFooter>
<Button
onClick={handleCreate}
disabled={isSubmittingRequest || isBlockedByPendingRequest || selectedApproverIds.length === 0}
>
{isSubmittingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Create Request
</Button>
</DialogFooter>
</DialogContent>
</Dialog>
);
}
@@ -0,0 +1,201 @@
'use client';
import { useMemo, useState } from 'react';
import { CheckCircle2, Clock3, Loader2, RefreshCcw, ShieldX, XCircle } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import { Textarea } from '@/components/ui/textarea';
import {
Sheet,
SheetContent,
SheetDescription,
SheetHeader,
SheetTitle,
} from '@/components/ui/sheet';
import type { ApprovalRequest } from '@/components/video-page/types';
interface ApprovalRequestsPanelProps {
open: boolean;
onOpenChange: (open: boolean) => void;
requests: ApprovalRequest[];
currentUserId: string | null;
canRequestApproval: boolean;
isLoadingRequests: boolean;
isSubmittingDecision: boolean;
isCancelingRequest: boolean;
error: string;
onRefresh: () => void;
onSubmitDecision: (requestId: string, decision: 'APPROVED' | 'REJECTED', note?: string) => Promise<boolean>;
onCancelRequest: (requestId: string) => Promise<boolean>;
}
function statusBadge(status: ApprovalRequest['status']) {
if (status === 'PENDING') {
return <Badge variant="secondary" className="gap-1"><Clock3 className="h-3 w-3" />Pending</Badge>;
}
if (status === 'APPROVED') {
return <Badge className="gap-1 bg-emerald-600 hover:bg-emerald-600"><CheckCircle2 className="h-3 w-3" />Approved</Badge>;
}
if (status === 'REJECTED') {
return <Badge variant="destructive" className="gap-1"><XCircle className="h-3 w-3" />Rejected</Badge>;
}
return <Badge variant="outline" className="gap-1"><ShieldX className="h-3 w-3" />Canceled</Badge>;
}
function decisionLabel(
requestStatus: ApprovalRequest['status'],
decisionStatus: 'PENDING' | 'APPROVED' | 'REJECTED'
) {
if (decisionStatus === 'APPROVED') return 'Approved';
if (decisionStatus === 'REJECTED') return 'Rejected';
if (requestStatus === 'CANCELED') return 'Canceled';
return 'Pending';
}
export function ApprovalRequestsPanel({
open,
onOpenChange,
requests,
currentUserId,
canRequestApproval,
isLoadingRequests,
isSubmittingDecision,
isCancelingRequest,
error,
onRefresh,
onSubmitDecision,
onCancelRequest,
}: ApprovalRequestsPanelProps) {
const [decisionNote, setDecisionNote] = useState('');
const pendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
[requests]
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !pendingRequest) return null;
return pendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
}, [currentUserId, pendingRequest]);
const canCancelPendingRequest = !!pendingRequest
&& !!currentUserId
&& (pendingRequest.requestedById === currentUserId || canRequestApproval);
const handleDecision = async (decision: 'APPROVED' | 'REJECTED') => {
if (!pendingRequest) return;
const success = await onSubmitDecision(pendingRequest.id, decision, decisionNote.trim() || undefined);
if (success) {
setDecisionNote('');
}
};
return (
<Sheet open={open} onOpenChange={onOpenChange}>
<SheetContent side="right" className="w-full sm:max-w-xl p-0">
<SheetHeader>
<SheetTitle>Approvals</SheetTitle>
<SheetDescription>Review request history and respond to pending approvals.</SheetDescription>
</SheetHeader>
<div className="px-4 pb-4 space-y-3 overflow-y-auto">
<div className="flex items-center justify-between">
<p className="text-xs text-muted-foreground">{requests.length} request(s)</p>
<Button size="sm" variant="ghost" onClick={onRefresh} disabled={isLoadingRequests}>
{isLoadingRequests ? <Loader2 className="h-4 w-4 animate-spin" /> : <RefreshCcw className="h-4 w-4" />}
</Button>
</div>
{error ? (
<div className="rounded-md border border-destructive/40 bg-destructive/10 p-3 text-sm text-destructive">
{error}
</div>
) : null}
{pendingRequest && myPendingDecision ? (
<div className="rounded-md border p-3 space-y-2">
<p className="text-sm font-medium">Your response is required</p>
<p className="text-xs text-muted-foreground">
{pendingRequest.requestedBy.name || pendingRequest.requestedBy.email || 'A user'} requested approval.
</p>
<Textarea
value={decisionNote}
onChange={(event) => setDecisionNote(event.target.value)}
placeholder="Optional note"
rows={3}
maxLength={2000}
/>
<div className="flex gap-2">
<Button
size="sm"
onClick={() => handleDecision('APPROVED')}
disabled={isSubmittingDecision}
>
{isSubmittingDecision ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Approve
</Button>
<Button
size="sm"
variant="destructive"
onClick={() => handleDecision('REJECTED')}
disabled={isSubmittingDecision}
>
{isSubmittingDecision ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Reject
</Button>
</div>
</div>
) : null}
{pendingRequest && canCancelPendingRequest ? (
<div className="rounded-md border p-3">
<Button
size="sm"
variant="outline"
onClick={() => onCancelRequest(pendingRequest.id)}
disabled={isCancelingRequest}
>
{isCancelingRequest ? <Loader2 className="h-4 w-4 animate-spin mr-2" /> : null}
Cancel Pending Request
</Button>
</div>
) : null}
<div className="space-y-2">
{requests.length === 0 ? (
<p className="text-sm text-muted-foreground py-4 text-center">No approval requests yet.</p>
) : (
requests.map((request) => (
<div key={request.id} className="rounded-md border p-3 space-y-2">
<div className="flex items-center justify-between gap-2">
<p className="text-sm font-medium">
Requested by {request.requestedBy.name || request.requestedBy.email || 'Unknown'}
</p>
{statusBadge(request.status)}
</div>
{request.message ? (
<p className="text-sm text-muted-foreground whitespace-pre-wrap">{request.message}</p>
) : null}
<p className="text-xs text-muted-foreground">
{new Date(request.createdAt).toLocaleString()}
</p>
<div className="space-y-1">
{request.decisions.map((decision) => (
<div key={decision.id} className="flex items-center justify-between gap-2 text-xs">
<span className="truncate">
{decision.approver.name || decision.approver.email || 'Unknown'}
</span>
<span className="text-muted-foreground">{decisionLabel(request.status, decision.status)}</span>
</div>
))}
</div>
</div>
))
)}
</div>
</div>
</SheetContent>
</Sheet>
);
}
@@ -0,0 +1,172 @@
'use client';
import { useCallback, useMemo, useState } from 'react';
import type { ApprovalRequest } from '@/components/video-page/types';
export interface ApprovalCandidate {
id: string;
name: string | null;
email: string | null;
image: string | null;
}
interface UseApprovalsParams {
projectId?: string;
activeVersionId: string | null;
currentUserId: string | null;
}
export function useApprovals({ projectId, activeVersionId, currentUserId }: UseApprovalsParams) {
const [requests, setRequests] = useState<ApprovalRequest[]>([]);
const [candidates, setCandidates] = useState<ApprovalCandidate[]>([]);
const [isLoadingRequests, setIsLoadingRequests] = useState(false);
const [isLoadingCandidates, setIsLoadingCandidates] = useState(false);
const [isSubmittingRequest, setIsSubmittingRequest] = useState(false);
const [isSubmittingDecision, setIsSubmittingDecision] = useState(false);
const [isCancelingRequest, setIsCancelingRequest] = useState(false);
const [error, setError] = useState('');
const fetchRequests = useCallback(async () => {
if (!activeVersionId) return;
setIsLoadingRequests(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, { cache: 'no-store' });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approval requests');
return;
}
setRequests(payload?.data?.requests || []);
} catch {
setError('Failed to fetch approval requests');
} finally {
setIsLoadingRequests(false);
}
}, [activeVersionId]);
const fetchCandidates = useCallback(async () => {
if (!projectId) return;
setIsLoadingCandidates(true);
setError('');
try {
const res = await fetch(`/api/projects/${projectId}/approval-candidates`, { cache: 'no-store' });
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to fetch approvers');
return;
}
setCandidates(payload?.data?.candidates || []);
} catch {
setError('Failed to fetch approvers');
} finally {
setIsLoadingCandidates(false);
}
}, [projectId]);
const createRequest = useCallback(async (approverIds: string[], message?: string) => {
if (!activeVersionId) return false;
setIsSubmittingRequest(true);
setError('');
try {
const res = await fetch(`/api/versions/${activeVersionId}/approvals`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ approverIds, message: message || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to create approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to create approval request');
return false;
} finally {
setIsSubmittingRequest(false);
}
}, [activeVersionId, fetchRequests]);
const submitDecision = useCallback(async (
requestId: string,
decision: 'APPROVED' | 'REJECTED',
note?: string
) => {
setIsSubmittingDecision(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/decision`, {
method: 'POST',
headers: { 'Content-Type': 'application/json' },
body: JSON.stringify({ decision, note: note || undefined }),
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to submit approval decision');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to submit approval decision');
return false;
} finally {
setIsSubmittingDecision(false);
}
}, [fetchRequests]);
const cancelRequest = useCallback(async (requestId: string) => {
setIsCancelingRequest(true);
setError('');
try {
const res = await fetch(`/api/approvals/${requestId}/cancel`, {
method: 'POST',
});
const payload = await res.json().catch(() => ({}));
if (!res.ok) {
setError(payload?.error || 'Failed to cancel approval request');
return false;
}
await fetchRequests();
return true;
} catch {
setError('Failed to cancel approval request');
return false;
} finally {
setIsCancelingRequest(false);
}
}, [fetchRequests]);
const activePendingRequest = useMemo(
() => requests.find((request) => request.status === 'PENDING') || null,
[requests]
);
const myPendingDecision = useMemo(() => {
if (!currentUserId || !activePendingRequest) return null;
return activePendingRequest.decisions.find(
(decision) => decision.approverId === currentUserId && decision.status === 'PENDING'
) || null;
}, [activePendingRequest, currentUserId]);
return {
requests,
candidates,
isLoadingRequests,
isLoadingCandidates,
isSubmittingRequest,
isSubmittingDecision,
isCancelingRequest,
activePendingRequest,
myPendingDecision,
error,
setError,
fetchRequests,
fetchCandidates,
createRequest,
submitDecision,
cancelRequest,
};
}
+41
View File
@@ -18,6 +18,46 @@ export interface CommentTag {
color: string;
}
export interface ApprovalDecision {
id: string;
approverId: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED';
note: string | null;
respondedAt: string | null;
createdAt: string;
approver: {
id: string;
name: string | null;
email: string | null;
image: string | null;
};
}
export interface ApprovalRequest {
id: string;
status: 'PENDING' | 'APPROVED' | 'REJECTED' | 'CANCELED';
requestedById: string;
message: string | null;
resolvedAt: string | null;
canceledAt: string | null;
canceledById: string | null;
createdAt: string;
updatedAt: string;
requestedBy: {
id: string;
name: string | null;
email: string | null;
image: string | null;
};
canceledBy: {
id: string;
name: string | null;
email: string | null;
image: string | null;
} | null;
decisions: ApprovalDecision[];
}
export interface CommentReply {
id: string;
content: string | null;
@@ -70,6 +110,7 @@ export interface VideoData {
canDownload?: boolean;
canManageTags?: boolean;
canResolveComments?: boolean;
canRequestApproval?: boolean;
}
export interface BunnyQualityOption {
+53 -42
View File
@@ -2,7 +2,7 @@
import { memo } from 'react';
import Link from 'next/link';
import { ArrowLeft, ChevronDown, GitCompareArrows, MoreVertical, Plus, Share2, Trash2 } from 'lucide-react';
import { ArrowLeft, ChevronDown, GitCompareArrows, ListChecks, MoreVertical, Plus, Share2, ShieldCheck, Trash2 } from 'lucide-react';
import { Badge } from '@/components/ui/badge';
import { Button } from '@/components/ui/button';
import {
@@ -14,7 +14,7 @@ import {
} from '@/components/ui/dropdown-menu';
import { Separator } from '@/components/ui/separator';
import { cn } from '@/lib/utils';
import { DownloadControls, DownloadMenuItems } from '@/components/video-page/download-controls';
import { DownloadMenuItems } from '@/components/video-page/download-controls';
import { VersionDeleteDialog } from '@/components/video-page/version-delete-dialog';
import { VersionActionsDialog } from '@/components/video-page/version-actions-dialog';
import type { BunnyDownloadPreference, DownloadTarget, Version } from '@/components/video-page/types';
@@ -60,6 +60,10 @@ interface VideoPageHeaderProps {
isCreatingVersion: boolean;
onCreateVersion: () => void;
onOpenCompare: () => void;
canRequestApproval: boolean;
hasPendingApprovalRequest: boolean;
onOpenApprovalRequest: () => void;
onOpenApprovalsPanel: () => void;
}
export const VideoPageHeader = memo(function VideoPageHeader({
@@ -102,29 +106,34 @@ export const VideoPageHeader = memo(function VideoPageHeader({
isCreatingVersion,
onCreateVersion,
onOpenCompare,
canRequestApproval,
hasPendingApprovalRequest,
onOpenApprovalRequest,
onOpenApprovalsPanel,
}: VideoPageHeaderProps) {
return (
<div className={cn(
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50',
'shrink-0 flex items-center justify-between h-12 px-4 border-b bg-background/50 gap-3',
isFullscreenMode ? 'absolute top-0 left-0 right-0 z-50 transition-opacity duration-300' : '',
isFullscreenMode && cursorIdle && isPlaying && 'opacity-0 pointer-events-none'
)}>
<div className="flex items-center gap-3">
<div className="flex items-center gap-3 min-w-0 flex-1">
<Link
href={backHref}
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors"
className="inline-flex items-center text-sm text-muted-foreground hover:text-foreground transition-colors shrink-0"
>
<ArrowLeft className="h-4 w-4 mr-1" />
Back
</Link>
<Separator orientation="vertical" className="h-5" />
<div className="hidden sm:block min-w-0">
<span className="text-sm font-medium">{title}</span>
<span className="text-xs text-muted-foreground ml-2"> {projectName}</span>
<Separator orientation="vertical" className="h-5 shrink-0" />
<div className="hidden sm:flex min-w-0 items-center gap-2">
<span className="text-sm font-medium truncate">{title}</span>
<span className="text-xs text-muted-foreground shrink-0"></span>
<span className="text-xs text-muted-foreground truncate">{projectName}</span>
</div>
</div>
<div className="flex items-center gap-2">
<div className="flex items-center gap-1.5 shrink-0">
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="sm">
@@ -175,24 +184,29 @@ export const VideoPageHeader = memo(function VideoPageHeader({
onDelete={onDeleteVersion}
/>
<DownloadControls
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
isDownloading={isDownloadingVideo}
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
{mode === 'dashboard' && (
<>
<Button variant="outline" size="sm" asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-1" />
Share Video
</Link>
<Button variant="outline" size="sm" onClick={() => setShowVersionDialog(true)} className="hidden sm:inline-flex">
<Plus className="h-4 w-4 mr-1" />
New Version
</Button>
<div className="hidden sm:flex items-center gap-2">
<Button variant="outline" size="sm" onClick={onOpenApprovalsPanel} className="hidden sm:inline-flex">
<ListChecks className="h-4 w-4 mr-1" />
Approvals
{hasPendingApprovalRequest ? (
<Badge variant="default" className="ml-2 hidden xl:inline-flex">Pending</Badge>
) : null}
</Button>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare} className="hidden sm:inline-flex">
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
)}
<div className="hidden">
<VersionActionsDialog
open={showVersionDialog}
onOpenChange={setShowVersionDialog}
@@ -212,16 +226,9 @@ export const VideoPageHeader = memo(function VideoPageHeader({
versionsCount={versions.length}
onCreateVersion={onCreateVersion}
/>
{versions.length >= 2 && (
<Button variant="outline" size="sm" onClick={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-1" />
Compare
</Button>
)}
</div>
<div className="sm:hidden">
<div>
<DropdownMenu>
<DropdownMenuTrigger asChild>
<Button variant="outline" size="icon" className="h-8 w-8">
@@ -229,6 +236,20 @@ export const VideoPageHeader = memo(function VideoPageHeader({
</Button>
</DropdownMenuTrigger>
<DropdownMenuContent align="end">
<DropdownMenuItem asChild>
<Link href={`/projects/${projectId}/videos/${videoId}/share`}>
<Share2 className="h-4 w-4 mr-2" />
Share Video
</Link>
</DropdownMenuItem>
<DropdownMenuItem
onSelect={onOpenApprovalRequest}
disabled={!canRequestApproval}
>
<ShieldCheck className="h-4 w-4 mr-2" />
Request Approval
</DropdownMenuItem>
<DropdownMenuSeparator />
<DownloadMenuItems
activeVersion={activeVersion}
videoCanDownload={videoCanDownload}
@@ -236,16 +257,6 @@ export const VideoPageHeader = memo(function VideoPageHeader({
activeDownloadTarget={activeDownloadTarget}
onDownload={onDownload}
/>
<DropdownMenuItem onSelect={() => setShowVersionDialog(true)}>
<Plus className="h-4 w-4 mr-2" />
New Version
</DropdownMenuItem>
{versions.length >= 2 && (
<DropdownMenuItem onSelect={onOpenCompare}>
<GitCompareArrows className="h-4 w-4 mr-2" />
Compare
</DropdownMenuItem>
)}
</DropdownMenuContent>
</DropdownMenu>
</div>