mirror of
https://github.com/yusufipk/OpenFrame.git
synced 2026-09-11 17:46:06 +00:00
feat: move videos to another project (single + bulk)
Add a "Move to project" action in the video card dropdown and the
selection-mode toolbar. Videos (with their versions, comments, assets and
video-scoped share links) can be moved into another project in the same
workspace.
- New GET/POST /api/projects/[projectId]/videos/move: GET lists manageable
destination projects in the workspace; POST performs the move.
- Requires canEdit on both source and destination; same-workspace only.
- Move runs in an interactive transaction that re-asserts source ownership
atomically (updateMany guarded by projectId) to avoid a TOCTOU race, and
returns 409 on conflict. GET is rate-limited ('api').
This commit is contained in:
@@ -19,6 +19,7 @@ import {
|
|||||||
Loader2,
|
Loader2,
|
||||||
Trash2,
|
Trash2,
|
||||||
ChevronDown,
|
ChevronDown,
|
||||||
|
FolderInput,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { toast } from 'sonner';
|
import { toast } from 'sonner';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
@@ -42,6 +43,7 @@ import {
|
|||||||
} from '@/components/ui/alert-dialog';
|
} from '@/components/ui/alert-dialog';
|
||||||
import { VideoCard } from '@/components/video-card';
|
import { VideoCard } from '@/components/video-card';
|
||||||
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
|
||||||
|
import { MoveVideosDialog } from '@/components/move-videos-dialog';
|
||||||
import type { DirectUploadProvider } from '@/components/video-page/types';
|
import type { DirectUploadProvider } from '@/components/video-page/types';
|
||||||
import {
|
import {
|
||||||
runProjectDownloadManifest,
|
runProjectDownloadManifest,
|
||||||
@@ -105,6 +107,7 @@ export function ProjectContentClient({
|
|||||||
const [isDownloading, setIsDownloading] = useState(false);
|
const [isDownloading, setIsDownloading] = useState(false);
|
||||||
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
|
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
|
||||||
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
|
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
|
||||||
|
const [showMoveSelectedDialog, setShowMoveSelectedDialog] = useState(false);
|
||||||
|
|
||||||
const canSelectVideos = canDownloadProject || canEdit;
|
const canSelectVideos = canDownloadProject || canEdit;
|
||||||
|
|
||||||
@@ -138,6 +141,13 @@ export function ProjectContentClient({
|
|||||||
setSelectedVideoIds((prev) => prev.filter((id) => id !== videoId));
|
setSelectedVideoIds((prev) => prev.filter((id) => id !== videoId));
|
||||||
}, []);
|
}, []);
|
||||||
|
|
||||||
|
const handleVideosMoved = useCallback((movedIds: string[]) => {
|
||||||
|
const moved = new Set(movedIds);
|
||||||
|
setLocalVideos((prev) => prev.filter((video) => !moved.has(video.id)));
|
||||||
|
setSelectedVideoIds([]);
|
||||||
|
setSelectionMode(false);
|
||||||
|
}, []);
|
||||||
|
|
||||||
const toggleVideoSelection = useCallback((videoId: string, selected: boolean) => {
|
const toggleVideoSelection = useCallback((videoId: string, selected: boolean) => {
|
||||||
setSelectedVideoIds((prev) => {
|
setSelectedVideoIds((prev) => {
|
||||||
if (selected) {
|
if (selected) {
|
||||||
@@ -447,6 +457,17 @@ export function ProjectContentClient({
|
|||||||
</DropdownMenuContent>
|
</DropdownMenuContent>
|
||||||
</DropdownMenu>
|
</DropdownMenu>
|
||||||
)}
|
)}
|
||||||
|
{canEdit && (
|
||||||
|
<Button
|
||||||
|
variant="outline"
|
||||||
|
size="sm"
|
||||||
|
onClick={() => setShowMoveSelectedDialog(true)}
|
||||||
|
disabled={selectedCount === 0 || isDeletingSelected}
|
||||||
|
>
|
||||||
|
<FolderInput className="h-4 w-4 mr-2" />
|
||||||
|
Move to project
|
||||||
|
</Button>
|
||||||
|
)}
|
||||||
{canEdit && (
|
{canEdit && (
|
||||||
<Button
|
<Button
|
||||||
variant="destructive"
|
variant="destructive"
|
||||||
@@ -561,6 +582,14 @@ export function ProjectContentClient({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
<MoveVideosDialog
|
||||||
|
open={showMoveSelectedDialog}
|
||||||
|
onOpenChange={setShowMoveSelectedDialog}
|
||||||
|
projectId={projectId}
|
||||||
|
videoIds={selectedVideoIds}
|
||||||
|
onMoved={handleVideosMoved}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
@@ -0,0 +1,221 @@
|
|||||||
|
import { NextRequest } from 'next/server';
|
||||||
|
import { revalidatePath } from 'next/cache';
|
||||||
|
import { auth, checkProjectAccess } from '@/lib/auth';
|
||||||
|
import { apiErrors, successResponse, withCacheControl } from '@/lib/api-response';
|
||||||
|
import { db } from '@/lib/db';
|
||||||
|
import { logError } from '@/lib/logger';
|
||||||
|
import { rateLimit } from '@/lib/rate-limit';
|
||||||
|
|
||||||
|
type RouteParams = { params: Promise<{ projectId: string }> };
|
||||||
|
|
||||||
|
const MAX_BULK_MOVE = 50;
|
||||||
|
|
||||||
|
// Thrown inside the move transaction when the atomic source-ownership re-check
|
||||||
|
// fails (a concurrent request relocated a video between check and commit).
|
||||||
|
class VideoMoveConflictError extends Error {}
|
||||||
|
|
||||||
|
// GET /api/projects/[projectId]/videos/move
|
||||||
|
// Lists destination projects (same workspace, manageable by the user) the
|
||||||
|
// current project's videos can be moved into.
|
||||||
|
export async function GET(request: NextRequest, { params }: RouteParams) {
|
||||||
|
try {
|
||||||
|
const limited = await rateLimit(request, 'api');
|
||||||
|
if (limited) return limited;
|
||||||
|
|
||||||
|
const session = await auth();
|
||||||
|
const { projectId } = await params;
|
||||||
|
|
||||||
|
if (!session?.user?.id) {
|
||||||
|
return apiErrors.unauthorized();
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const project = await db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
});
|
||||||
|
if (!project) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
|
||||||
|
const access = await checkProjectAccess(project, userId, { intent: 'manage' });
|
||||||
|
if (!access.canEdit) {
|
||||||
|
return apiErrors.forbidden('Access denied');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Workspace owners/admins can manage every project in the workspace; everyone
|
||||||
|
// else can only move into projects they own or are an admin member of.
|
||||||
|
const [workspace, workspaceMember] = await Promise.all([
|
||||||
|
db.workspace.findUnique({
|
||||||
|
where: { id: project.workspaceId },
|
||||||
|
select: { ownerId: true },
|
||||||
|
}),
|
||||||
|
db.workspaceMember.findUnique({
|
||||||
|
where: { workspaceId_userId: { workspaceId: project.workspaceId, userId } },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
const isWorkspaceManager = workspace?.ownerId === userId || workspaceMember?.role === 'ADMIN';
|
||||||
|
|
||||||
|
const targets = await db.project.findMany({
|
||||||
|
where: {
|
||||||
|
workspaceId: project.workspaceId,
|
||||||
|
id: { not: projectId },
|
||||||
|
...(isWorkspaceManager
|
||||||
|
? {}
|
||||||
|
: {
|
||||||
|
OR: [{ ownerId: userId }, { members: { some: { userId, role: 'ADMIN' } } }],
|
||||||
|
}),
|
||||||
|
},
|
||||||
|
orderBy: { name: 'asc' },
|
||||||
|
select: { id: true, name: true },
|
||||||
|
});
|
||||||
|
|
||||||
|
const response = successResponse({ projects: targets });
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error listing video move targets:', error);
|
||||||
|
return apiErrors.internalError('Failed to load destination projects');
|
||||||
|
}
|
||||||
|
}
|
||||||
|
|
||||||
|
// POST /api/projects/[projectId]/videos/move
|
||||||
|
// Moves one or more videos from this project into another project in the same
|
||||||
|
// workspace. Versions, comments and assets follow the video automatically.
|
||||||
|
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 apiErrors.unauthorized();
|
||||||
|
}
|
||||||
|
const userId = session.user.id;
|
||||||
|
|
||||||
|
const body = await request.json();
|
||||||
|
const { videoIds, targetProjectId } = body as {
|
||||||
|
videoIds?: unknown;
|
||||||
|
targetProjectId?: unknown;
|
||||||
|
};
|
||||||
|
|
||||||
|
if (!Array.isArray(videoIds) || videoIds.length === 0) {
|
||||||
|
return apiErrors.badRequest('videoIds must be a non-empty array');
|
||||||
|
}
|
||||||
|
if (videoIds.length > MAX_BULK_MOVE) {
|
||||||
|
return apiErrors.badRequest(`You can move at most ${MAX_BULK_MOVE} videos at once`);
|
||||||
|
}
|
||||||
|
if (!videoIds.every((id) => typeof id === 'string' && id.trim().length > 0)) {
|
||||||
|
return apiErrors.badRequest('Each video id must be a non-empty string');
|
||||||
|
}
|
||||||
|
if (typeof targetProjectId !== 'string' || targetProjectId.trim().length === 0) {
|
||||||
|
return apiErrors.badRequest('targetProjectId must be a non-empty string');
|
||||||
|
}
|
||||||
|
|
||||||
|
const normalizedIds = [...new Set(videoIds.map((id) => id.trim()))];
|
||||||
|
const targetId = targetProjectId.trim();
|
||||||
|
|
||||||
|
if (targetId === projectId) {
|
||||||
|
return apiErrors.badRequest('Source and destination projects are the same');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sourceProject, targetProject] = await Promise.all([
|
||||||
|
db.project.findUnique({
|
||||||
|
where: { id: projectId },
|
||||||
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
}),
|
||||||
|
db.project.findUnique({
|
||||||
|
where: { id: targetId },
|
||||||
|
select: { id: true, ownerId: true, workspaceId: true, visibility: true },
|
||||||
|
}),
|
||||||
|
]);
|
||||||
|
if (!sourceProject) {
|
||||||
|
return apiErrors.notFound('Project');
|
||||||
|
}
|
||||||
|
if (!targetProject) {
|
||||||
|
return apiErrors.badRequest('Destination project not found');
|
||||||
|
}
|
||||||
|
if (sourceProject.workspaceId !== targetProject.workspaceId) {
|
||||||
|
return apiErrors.badRequest('Videos can only be moved within the same workspace');
|
||||||
|
}
|
||||||
|
|
||||||
|
const [sourceAccess, targetAccess] = await Promise.all([
|
||||||
|
checkProjectAccess(sourceProject, userId, { intent: 'manage' }),
|
||||||
|
checkProjectAccess(targetProject, userId, { intent: 'manage' }),
|
||||||
|
]);
|
||||||
|
if (!sourceAccess.canEdit) {
|
||||||
|
return apiErrors.forbidden('You cannot move videos out of this project');
|
||||||
|
}
|
||||||
|
if (!targetAccess.canEdit) {
|
||||||
|
return apiErrors.forbidden('You cannot move videos into the selected project');
|
||||||
|
}
|
||||||
|
|
||||||
|
// Fast, friendly pre-check for the common case (stale UI). The authoritative
|
||||||
|
// ownership guard is re-asserted atomically inside the transaction below.
|
||||||
|
const videos = await db.video.findMany({
|
||||||
|
where: { id: { in: normalizedIds }, projectId },
|
||||||
|
select: { id: true },
|
||||||
|
});
|
||||||
|
if (videos.length !== normalizedIds.length) {
|
||||||
|
return apiErrors.badRequest('One or more selected videos do not belong to this project');
|
||||||
|
}
|
||||||
|
|
||||||
|
try {
|
||||||
|
await db.$transaction(async (tx) => {
|
||||||
|
// Append moved videos after the destination's existing videos so ordering
|
||||||
|
// stays stable instead of colliding with the source positions. Read this
|
||||||
|
// before the move so the videos being moved aren't counted yet.
|
||||||
|
const maxPosition = await tx.video.aggregate({
|
||||||
|
where: { projectId: targetId },
|
||||||
|
_max: { position: true },
|
||||||
|
});
|
||||||
|
const basePosition = (maxPosition._max.position ?? -1) + 1;
|
||||||
|
|
||||||
|
// Re-assert source ownership as part of the write itself: a concurrent
|
||||||
|
// move can't slip a video out from under us between check and commit,
|
||||||
|
// and the row locks serialize competing moves of the same videos.
|
||||||
|
const moved = await tx.video.updateMany({
|
||||||
|
where: { id: { in: normalizedIds }, projectId },
|
||||||
|
data: { projectId: targetId },
|
||||||
|
});
|
||||||
|
if (moved.count !== normalizedIds.length) {
|
||||||
|
throw new VideoMoveConflictError();
|
||||||
|
}
|
||||||
|
|
||||||
|
// Apply per-video ordering now that the videos live in the destination.
|
||||||
|
await Promise.all(
|
||||||
|
normalizedIds.map((id, index) =>
|
||||||
|
tx.video.update({ where: { id }, data: { position: basePosition + index } })
|
||||||
|
)
|
||||||
|
);
|
||||||
|
|
||||||
|
// Keep video-scoped share links pointing at the video's new project.
|
||||||
|
await tx.shareLink.updateMany({
|
||||||
|
where: { videoId: { in: normalizedIds } },
|
||||||
|
data: { projectId: targetId },
|
||||||
|
});
|
||||||
|
});
|
||||||
|
} catch (error) {
|
||||||
|
if (error instanceof VideoMoveConflictError) {
|
||||||
|
return apiErrors.conflict(
|
||||||
|
'One or more selected videos changed while moving. Please refresh and try again.'
|
||||||
|
);
|
||||||
|
}
|
||||||
|
throw error;
|
||||||
|
}
|
||||||
|
|
||||||
|
revalidatePath(`/projects/${projectId}`);
|
||||||
|
revalidatePath(`/projects/${targetId}`);
|
||||||
|
|
||||||
|
const response = successResponse({
|
||||||
|
message: `${normalizedIds.length} video${normalizedIds.length === 1 ? '' : 's'} moved`,
|
||||||
|
movedCount: normalizedIds.length,
|
||||||
|
targetProjectId: targetId,
|
||||||
|
});
|
||||||
|
return withCacheControl(response, 'private, no-store');
|
||||||
|
} catch (error) {
|
||||||
|
logError('Error moving videos:', error);
|
||||||
|
return apiErrors.internalError('Failed to move videos');
|
||||||
|
}
|
||||||
|
}
|
||||||
@@ -0,0 +1,171 @@
|
|||||||
|
'use client';
|
||||||
|
|
||||||
|
import { useEffect, useState } from 'react';
|
||||||
|
import { useRouter } from 'next/navigation';
|
||||||
|
import { toast } from 'sonner';
|
||||||
|
import { FolderInput, Loader2 } from 'lucide-react';
|
||||||
|
import { Button } from '@/components/ui/button';
|
||||||
|
import {
|
||||||
|
Dialog,
|
||||||
|
DialogContent,
|
||||||
|
DialogDescription,
|
||||||
|
DialogFooter,
|
||||||
|
DialogHeader,
|
||||||
|
DialogTitle,
|
||||||
|
} from '@/components/ui/dialog';
|
||||||
|
import {
|
||||||
|
Select,
|
||||||
|
SelectContent,
|
||||||
|
SelectItem,
|
||||||
|
SelectTrigger,
|
||||||
|
SelectValue,
|
||||||
|
} from '@/components/ui/select';
|
||||||
|
|
||||||
|
interface MoveTarget {
|
||||||
|
id: string;
|
||||||
|
name: string;
|
||||||
|
}
|
||||||
|
|
||||||
|
interface MoveVideosDialogProps {
|
||||||
|
open: boolean;
|
||||||
|
onOpenChange: (open: boolean) => void;
|
||||||
|
/** Source project the videos currently belong to. */
|
||||||
|
projectId: string;
|
||||||
|
/** Videos to move. */
|
||||||
|
videoIds: string[];
|
||||||
|
/** Called after a successful move with the ids that were moved. */
|
||||||
|
onMoved?: (movedIds: string[]) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function MoveVideosDialog({
|
||||||
|
open,
|
||||||
|
onOpenChange,
|
||||||
|
projectId,
|
||||||
|
videoIds,
|
||||||
|
onMoved,
|
||||||
|
}: MoveVideosDialogProps) {
|
||||||
|
const router = useRouter();
|
||||||
|
const [targets, setTargets] = useState<MoveTarget[] | null>(null);
|
||||||
|
const [isLoading, setIsLoading] = useState(false);
|
||||||
|
const [loadError, setLoadError] = useState('');
|
||||||
|
const [selectedId, setSelectedId] = useState('');
|
||||||
|
const [isMoving, setIsMoving] = useState(false);
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (!open) return;
|
||||||
|
|
||||||
|
let cancelled = false;
|
||||||
|
setIsLoading(true);
|
||||||
|
setLoadError('');
|
||||||
|
setTargets(null);
|
||||||
|
setSelectedId('');
|
||||||
|
|
||||||
|
fetch(`/api/projects/${projectId}/videos/move`, { cache: 'no-store' })
|
||||||
|
.then(async (res) => {
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
throw new Error(typeof body?.error === 'string' ? body.error : 'Failed to load projects');
|
||||||
|
}
|
||||||
|
if (cancelled) return;
|
||||||
|
setTargets((body?.data?.projects as MoveTarget[] | undefined) ?? []);
|
||||||
|
})
|
||||||
|
.catch((err) => {
|
||||||
|
if (!cancelled) {
|
||||||
|
setLoadError(err instanceof Error ? err.message : 'Failed to load projects');
|
||||||
|
}
|
||||||
|
})
|
||||||
|
.finally(() => {
|
||||||
|
if (!cancelled) setIsLoading(false);
|
||||||
|
});
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
cancelled = true;
|
||||||
|
};
|
||||||
|
}, [open, projectId]);
|
||||||
|
|
||||||
|
const count = videoIds.length;
|
||||||
|
const noun = count === 1 ? 'video' : 'videos';
|
||||||
|
|
||||||
|
const handleMove = async () => {
|
||||||
|
if (!selectedId || isMoving) return;
|
||||||
|
setIsMoving(true);
|
||||||
|
try {
|
||||||
|
const res = await fetch(`/api/projects/${projectId}/videos/move`, {
|
||||||
|
method: 'POST',
|
||||||
|
headers: { 'Content-Type': 'application/json' },
|
||||||
|
body: JSON.stringify({ videoIds, targetProjectId: selectedId }),
|
||||||
|
});
|
||||||
|
const body = await res.json().catch(() => null);
|
||||||
|
if (!res.ok) {
|
||||||
|
toast.error(typeof body?.error === 'string' ? body.error : 'Failed to move videos');
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
toast.success(typeof body?.data?.message === 'string' ? body.data.message : 'Videos moved');
|
||||||
|
onOpenChange(false);
|
||||||
|
onMoved?.(videoIds);
|
||||||
|
router.refresh();
|
||||||
|
} catch {
|
||||||
|
toast.error('Failed to move videos');
|
||||||
|
} finally {
|
||||||
|
setIsMoving(false);
|
||||||
|
}
|
||||||
|
};
|
||||||
|
|
||||||
|
return (
|
||||||
|
<Dialog open={open} onOpenChange={onOpenChange}>
|
||||||
|
<DialogContent>
|
||||||
|
<DialogHeader>
|
||||||
|
<DialogTitle>
|
||||||
|
Move {count === 1 ? 'video' : `${count} videos`} to another project
|
||||||
|
</DialogTitle>
|
||||||
|
<DialogDescription>
|
||||||
|
Choose a destination project in this workspace. Versions, comments and assets move with
|
||||||
|
the {noun}.
|
||||||
|
</DialogDescription>
|
||||||
|
</DialogHeader>
|
||||||
|
|
||||||
|
<div className="py-2">
|
||||||
|
{isLoading ? (
|
||||||
|
<div className="flex items-center gap-2 text-sm text-muted-foreground">
|
||||||
|
<Loader2 className="h-4 w-4 animate-spin" />
|
||||||
|
Loading projects…
|
||||||
|
</div>
|
||||||
|
) : loadError ? (
|
||||||
|
<p className="text-sm text-destructive">{loadError}</p>
|
||||||
|
) : targets && targets.length > 0 ? (
|
||||||
|
<Select value={selectedId} onValueChange={setSelectedId} disabled={isMoving}>
|
||||||
|
<SelectTrigger>
|
||||||
|
<SelectValue placeholder="Select a project" />
|
||||||
|
</SelectTrigger>
|
||||||
|
<SelectContent>
|
||||||
|
{targets.map((target) => (
|
||||||
|
<SelectItem key={target.id} value={target.id}>
|
||||||
|
{target.name}
|
||||||
|
</SelectItem>
|
||||||
|
))}
|
||||||
|
</SelectContent>
|
||||||
|
</Select>
|
||||||
|
) : (
|
||||||
|
<p className="text-sm text-muted-foreground">
|
||||||
|
No other projects in this workspace are available to move to.
|
||||||
|
</p>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
|
||||||
|
<DialogFooter>
|
||||||
|
<Button variant="ghost" onClick={() => onOpenChange(false)} disabled={isMoving}>
|
||||||
|
Cancel
|
||||||
|
</Button>
|
||||||
|
<Button onClick={handleMove} disabled={!selectedId || isMoving}>
|
||||||
|
{isMoving ? (
|
||||||
|
<Loader2 className="h-4 w-4 mr-2 animate-spin" />
|
||||||
|
) : (
|
||||||
|
<FolderInput className="h-4 w-4 mr-2" />
|
||||||
|
)}
|
||||||
|
Move
|
||||||
|
</Button>
|
||||||
|
</DialogFooter>
|
||||||
|
</DialogContent>
|
||||||
|
</Dialog>
|
||||||
|
);
|
||||||
|
}
|
||||||
@@ -18,6 +18,7 @@ import {
|
|||||||
Trash2,
|
Trash2,
|
||||||
CheckSquare,
|
CheckSquare,
|
||||||
Check,
|
Check,
|
||||||
|
FolderInput,
|
||||||
} from 'lucide-react';
|
} from 'lucide-react';
|
||||||
import { Button } from '@/components/ui/button';
|
import { Button } from '@/components/ui/button';
|
||||||
import { Card, CardContent } from '@/components/ui/card';
|
import { Card, CardContent } from '@/components/ui/card';
|
||||||
@@ -55,6 +56,7 @@ import {
|
|||||||
type VideoSource,
|
type VideoSource,
|
||||||
} from '@/lib/video-providers';
|
} from '@/lib/video-providers';
|
||||||
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
import { resolvePublicBunnyCdnHostname } from '@/lib/bunny-cdn';
|
||||||
|
import { MoveVideosDialog } from '@/components/move-videos-dialog';
|
||||||
import { cn } from '@/lib/utils';
|
import { cn } from '@/lib/utils';
|
||||||
|
|
||||||
interface VideoCardProps {
|
interface VideoCardProps {
|
||||||
@@ -110,6 +112,9 @@ export function VideoCard({
|
|||||||
// Delete dialog
|
// Delete dialog
|
||||||
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
const [showDeleteDialog, setShowDeleteDialog] = useState(false);
|
||||||
const [isDeleting, setIsDeleting] = useState(false);
|
const [isDeleting, setIsDeleting] = useState(false);
|
||||||
|
|
||||||
|
// Move dialog
|
||||||
|
const [showMoveDialog, setShowMoveDialog] = useState(false);
|
||||||
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
const bunnyCdnHostname = useMemo(() => resolvePublicBunnyCdnHostname(), []);
|
||||||
const resolvedThumbnailUrl = useMemo(() => {
|
const resolvedThumbnailUrl = useMemo(() => {
|
||||||
if (!video.thumbnailUrl) return '';
|
if (!video.thumbnailUrl) return '';
|
||||||
@@ -409,6 +414,10 @@ export function VideoCard({
|
|||||||
<Plus className="mr-2 h-4 w-4" />
|
<Plus className="mr-2 h-4 w-4" />
|
||||||
Add Version
|
Add Version
|
||||||
</DropdownMenuItem>
|
</DropdownMenuItem>
|
||||||
|
<DropdownMenuItem onSelect={() => setShowMoveDialog(true)}>
|
||||||
|
<FolderInput className="mr-2 h-4 w-4" />
|
||||||
|
Move to project
|
||||||
|
</DropdownMenuItem>
|
||||||
<DropdownMenuItem
|
<DropdownMenuItem
|
||||||
className="text-destructive"
|
className="text-destructive"
|
||||||
onSelect={() => setShowDeleteDialog(true)}
|
onSelect={() => setShowDeleteDialog(true)}
|
||||||
@@ -576,6 +585,15 @@ export function VideoCard({
|
|||||||
</AlertDialogFooter>
|
</AlertDialogFooter>
|
||||||
</AlertDialogContent>
|
</AlertDialogContent>
|
||||||
</AlertDialog>
|
</AlertDialog>
|
||||||
|
|
||||||
|
{/* Move to another project */}
|
||||||
|
<MoveVideosDialog
|
||||||
|
open={showMoveDialog}
|
||||||
|
onOpenChange={setShowMoveDialog}
|
||||||
|
projectId={projectId}
|
||||||
|
videoIds={[video.id]}
|
||||||
|
onMoved={() => onDeleted?.(video.id)}
|
||||||
|
/>
|
||||||
</>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|||||||
Reference in New Issue
Block a user