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