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:
yusufipk
2026-07-10 20:55:12 +07:00
parent 654d3a6bc7
commit 57c5a127d1
4 changed files with 439 additions and 0 deletions
@@ -19,6 +19,7 @@ import {
Loader2,
Trash2,
ChevronDown,
FolderInput,
} from 'lucide-react';
import { toast } from 'sonner';
import { Button } from '@/components/ui/button';
@@ -42,6 +43,7 @@ import {
} from '@/components/ui/alert-dialog';
import { VideoCard } from '@/components/video-card';
import { VideoDragDropUploader } from '@/components/video-drag-drop-uploader';
import { MoveVideosDialog } from '@/components/move-videos-dialog';
import type { DirectUploadProvider } from '@/components/video-page/types';
import {
runProjectDownloadManifest,
@@ -105,6 +107,7 @@ export function ProjectContentClient({
const [isDownloading, setIsDownloading] = useState(false);
const [isDeletingSelected, setIsDeletingSelected] = useState(false);
const [showDeleteSelectedDialog, setShowDeleteSelectedDialog] = useState(false);
const [showMoveSelectedDialog, setShowMoveSelectedDialog] = useState(false);
const canSelectVideos = canDownloadProject || canEdit;
@@ -138,6 +141,13 @@ export function ProjectContentClient({
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) => {
setSelectedVideoIds((prev) => {
if (selected) {
@@ -447,6 +457,17 @@ export function ProjectContentClient({
</DropdownMenuContent>
</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 && (
<Button
variant="destructive"
@@ -561,6 +582,14 @@ export function ProjectContentClient({
</AlertDialogFooter>
</AlertDialogContent>
</AlertDialog>
<MoveVideosDialog
open={showMoveSelectedDialog}
onOpenChange={setShowMoveSelectedDialog}
projectId={projectId}
videoIds={selectedVideoIds}
onMoved={handleVideosMoved}
/>
</>
);
}