-
{video.processing && (
{t('processingOverlayText')}
@@ -208,6 +219,17 @@ const VideoCard = ({ video, showProgress = true, showMenu = true, showChannel =
{video.type.toUpperCase()}
+ {selectable && (
+
+ onSelectionChange(event.currentTarget.checked)}
+ aria-label={t('selectVideoCheckboxLabel')}
+ size="sm"
+ />
+
+ )}
+
{(showMenu && hasPermission(UserRole.Archiver)) && (
)}
@@ -218,4 +240,4 @@ const VideoCard = ({ video, showProgress = true, showMenu = true, showChannel =
);
}
-export default VideoCard;
\ No newline at end of file
+export default VideoCard;
diff --git a/frontend/app/components/videos/ChannelVideos.tsx b/frontend/app/components/videos/ChannelVideos.tsx
index f37b75a7..a89165c0 100644
--- a/frontend/app/components/videos/ChannelVideos.tsx
+++ b/frontend/app/components/videos/ChannelVideos.tsx
@@ -1,7 +1,12 @@
// ChannelVideos.tsx
import { useState } from "react";
import { Channel } from "@/app/hooks/useChannels";
-import { useFetchVideosFilter, VideoOrder, VideoSortBy, VideoType } from "@/app/hooks/useVideos";
+import {
+ useFetchVideosFilter,
+ VideoOrder,
+ VideoSortBy,
+ VideoType,
+} from "@/app/hooks/useVideos";
import useSettingsStore from "@/app/store/useSettingsStore";
import VideoGrid from "./Grid";
import GanymedeLoadingText from "../utils/GanymedeLoadingText";
@@ -58,4 +63,4 @@ const ChannelVideos = ({ channel }: Props) => {
);
};
-export default ChannelVideos;
\ No newline at end of file
+export default ChannelVideos;
diff --git a/frontend/app/components/videos/Grid.tsx b/frontend/app/components/videos/Grid.tsx
index fd69ca1b..6dd17465 100644
--- a/frontend/app/components/videos/Grid.tsx
+++ b/frontend/app/components/videos/Grid.tsx
@@ -1,11 +1,20 @@
-import { Box, Center, Group, Pagination, SimpleGrid, ActionIcon, NumberInput, MultiSelect, Text, Select, Flex } from "@mantine/core";
-import { IconMinus, IconPlus } from "@tabler/icons-react";
-import { useRef, useState, useEffect } from "react";
+import { Box, Button, Center, Checkbox, Group, Menu, Modal, Pagination, SimpleGrid, ActionIcon, NumberInput, MultiSelect, Text, Select, Flex } from "@mantine/core";
+import { IconHourglassEmpty, IconHourglassHigh, IconLock, IconLockOpen, IconMinus, IconMovie, IconPhoto, IconPlaylistAdd, IconPlus, IconTrash } from "@tabler/icons-react";
+import { useRef, useState, useEffect, useMemo } from "react";
import type { NumberInputHandlers } from "@mantine/core";
import VideoCard from "./Card";
-import { Video, VideoOrder, VideoSortBy, VideoType } from "@/app/hooks/useVideos";
+import { useGenerateSpriteThumbnails, useGenerateStaticThumbnail, useLockVideo, Video, VideoOrder, VideoSortBy, VideoType } from "@/app/hooks/useVideos";
import GanymedeLoadingText from "../utils/GanymedeLoadingText";
import { useTranslations } from "next-intl";
+import { useAxiosPrivate } from "@/app/hooks/useAxios";
+import { useDeletePlayback, useMarkVideoAsWatched } from "@/app/hooks/usePlayback";
+import { showNotification } from "@mantine/notifications";
+import { useDisclosure } from "@mantine/hooks";
+import PlaylistBulkAddModalContent from "../playlist/BulkAddModalContent";
+import MultiDeleteVideoModalContent from "../admin/video/MultiDeleteModalContent";
+import useAuthStore from "@/app/store/useAuthStore";
+import { UserRole } from "@/app/hooks/useAuthentication";
+import { useQueryClient } from "@tanstack/react-query";
export type VideoGridProps = {
videos: T[];
@@ -22,6 +31,7 @@ export type VideoGridProps = {
showChannel?: boolean;
showMenu?: boolean;
showProgress?: boolean;
+ enableSelection?: boolean;
};
const VideoGrid = ({
@@ -39,14 +49,37 @@ const VideoGrid = ({
showChannel = false,
showMenu = true,
showProgress = true,
+ enableSelection = true,
}: VideoGridProps) => {
const t = useTranslations("VideoComponents");
+ const axiosPrivate = useAxiosPrivate();
+ const queryClient = useQueryClient();
+ const { hasPermission } = useAuthStore();
+ const canBulkManage = hasPermission(UserRole.Archiver);
+ const canBulkDelete = hasPermission(UserRole.Admin);
+ const selectionEnabled = enableSelection && canBulkManage;
const handlersRef = useRef(null);
// Local state to handle the input value while typing
const [localLimit, setLocalLimit] = useState(videoLimit);
const [videoTypes, setVideoTypes] = useState([]);
const [sortBy, setSortBy] = useState(VideoSortBy.Date);
const [order, setOrder] = useState(VideoOrder.Desc);
+ const [selectedVideos, setSelectedVideos] = useState>({});
+ const [bulkActionLoading, setBulkActionLoading] = useState(false);
+ const [bulkMenuOpened, setBulkMenuOpened] = useState(false);
+ const [playlistModalOpened, { open: openPlaylistModal, close: closePlaylistModal }] = useDisclosure(false);
+ const [multiDeleteModalOpened, { open: openMultiDeleteModal, close: closeMultiDeleteModal }] = useDisclosure(false);
+ const selectedVideoList = useMemo(() => Object.values(selectedVideos), [selectedVideos]);
+ const selectedVideoIdSet = useMemo(
+ () => new Set(selectedVideoList.map((video) => video.id)),
+ [selectedVideoList]
+ );
+
+ const markAsWatchedMutate = useMarkVideoAsWatched();
+ const deletePlaybackMutate = useDeletePlayback();
+ const lockVideoMutate = useLockVideo();
+ const generateStaticThumbnailMutate = useGenerateStaticThumbnail();
+ const generateSpriteThumbnailsMutate = useGenerateSpriteThumbnails();
useEffect(() => {
setLocalLimit(videoLimit);
@@ -118,10 +151,192 @@ const VideoGrid = ({
onVideoLimitChange(newValue);
};
+ const handleVideoSelectionChange = (video: T, selected: boolean) => {
+ setSelectedVideos((current) => {
+ const next = { ...current };
+ if (selected) {
+ next[video.id] = video;
+ } else {
+ delete next[video.id];
+ }
+ return next;
+ });
+ };
+
+ const handleSelectAllOnPage = (selected: boolean) => {
+ setSelectedVideos((current) => {
+ const next = { ...current };
+ videos.forEach((video) => {
+ if (selected) {
+ next[video.id] = video;
+ } else {
+ delete next[video.id];
+ }
+ });
+ return next;
+ });
+ };
+
+ const runBulkOperation = async (
+ operation: (video: T) => Promise,
+ successMessage: string,
+ onSuccess?: () => Promise | void
+ ) => {
+ if (selectedVideoList.length === 0) return;
+ try {
+ setBulkActionLoading(true);
+ const results = await Promise.allSettled(
+ selectedVideoList.map((video) => Promise.resolve().then(() => operation(video)))
+ );
+ const successCount = results.filter((result) => result.status === "fulfilled").length;
+ const failedResults = results.filter((result) => result.status === "rejected");
+ const failureCount = failedResults.length;
+
+ if (onSuccess && successCount > 0) {
+ await onSuccess();
+ }
+
+ failedResults.forEach((result) => {
+ console.error(result.reason);
+ });
+
+ showNotification({
+ title:
+ failureCount === 0
+ ? successMessage
+ : failureCount === results.length
+ ? t("error")
+ : successMessage,
+ message:
+ failureCount === 0
+ ? `${successMessage} (${successCount}/${results.length})`
+ : `${successMessage} (${successCount}/${results.length}) • ${failureCount} failed`,
+ color:
+ failureCount === 0
+ ? undefined
+ : failureCount === results.length
+ ? "red"
+ : "yellow",
+ });
+ } catch (error) {
+ showNotification({
+ title: t("error"),
+ message: error instanceof Error ? error.message : String(error),
+ color: "red",
+ });
+ console.error(error);
+ } finally {
+ setBulkActionLoading(false);
+ }
+ };
+
+ const handleMarkVideosAsWatched = async () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ await runBulkOperation(
+ (video) =>
+ markAsWatchedMutate.mutateAsync({
+ axiosPrivate,
+ videoId: video.id,
+ invalidatePlaybackQuery: false,
+ }),
+ t("markedVideosAsWatchedNotification"),
+ async () => {
+ await queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ }
+ );
+ };
+
+ const handleMarkVideosAsUnwatched = async () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ await runBulkOperation(
+ (video) =>
+ deletePlaybackMutate.mutateAsync({
+ axiosPrivate,
+ videoId: video.id,
+ invalidatePlaybackQuery: false,
+ }),
+ t("markedVideosAsUnwatchedNotification"),
+ async () => {
+ await queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ }
+ );
+ };
+
+ const handleLockVideos = async (locked: boolean) => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ await runBulkOperation(
+ (video) =>
+ lockVideoMutate.mutateAsync({
+ axiosPrivate,
+ videoId: video.id,
+ locked,
+ invalidateVideoQueries: false,
+ }),
+ t("videosLockedNotification", { status: locked ? t("locked") : t("unlocked") }),
+ async () => {
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["channel_videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["playlist_videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["search"] }),
+ ]);
+ }
+ );
+ };
+
+ const handleGenerateStaticThumbnails = async () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ await runBulkOperation(
+ (video) =>
+ generateStaticThumbnailMutate.mutateAsync({
+ axiosPrivate,
+ videoId: video.id,
+ }),
+ t("bulkGenerateStaticThumbnailsNotification")
+ );
+ };
+
+ const handleGenerateSpriteThumbnails = async () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ await runBulkOperation(
+ (video) =>
+ generateSpriteThumbnailsMutate.mutateAsync({
+ axiosPrivate,
+ videoId: video.id,
+ }),
+ t("bulkGenerateSpriteThumbnailsNotification")
+ );
+ };
+
+ const handleCloseMultiDeleteModal = () => {
+ closeMultiDeleteModal();
+ setSelectedVideos({});
+ };
+
+ const handleOpenPlaylistModal = () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ openPlaylistModal();
+ };
+
+ const handleOpenMultiDeleteModal = () => {
+ if (bulkActionLoading) return;
+ setBulkMenuOpened(false);
+ openMultiDeleteModal();
+ };
+
if (isPending) {
return ;
}
+ const allVisibleSelected = videos.length > 0 && videos.every((video) => selectedVideoIdSet.has(video.id));
+ const someVisibleSelected = videos.some((video) => selectedVideoIdSet.has(video.id)) && !allVisibleSelected;
+
return (
@@ -156,6 +371,101 @@ const VideoGrid = ({
+ {selectionEnabled && (
+
+
+ handleSelectAllOnPage(event.currentTarget.checked)}
+ disabled={videos.length === 0 || bulkActionLoading}
+ />
+ {t("selectedVideosCount", { count: selectedVideoList.length })}
+
+
+
+
+
+
+ )}
+
({
showChannel={showChannel}
showMenu={showMenu}
showProgress={showProgress}
+ selectable={selectionEnabled}
+ selected={selectedVideoIdSet.has(video.id)}
+ onSelectionChange={(selected) => handleVideoSelectionChange(video, selected)}
/>
))}
@@ -216,8 +529,28 @@ const VideoGrid = ({
+
+
+ {selectedVideoList.length > 0 && (
+
+ )}
+
+
+
+ {selectedVideoList.length > 0 && (
+
+ )}
+
);
};
-export default VideoGrid;
\ No newline at end of file
+export default VideoGrid;
diff --git a/frontend/app/hooks/usePlayback.ts b/frontend/app/hooks/usePlayback.ts
index bb9626c7..c8248a1d 100644
--- a/frontend/app/hooks/usePlayback.ts
+++ b/frontend/app/hooks/usePlayback.ts
@@ -198,11 +198,13 @@ const markVideoAsWatched = async (
export interface MarkVideoAsWatchedInput {
axiosPrivate: AxiosInstance;
videoId: string;
+ invalidatePlaybackQuery?: boolean;
}
export interface DeletePlaybackInput {
axiosPrivate: AxiosInstance;
videoId: string;
+ invalidatePlaybackQuery?: boolean;
}
const useMarkVideoAsWatched = () => {
@@ -211,8 +213,10 @@ const useMarkVideoAsWatched = () => {
{
mutationFn: ({ axiosPrivate, videoId }) =>
markVideoAsWatched(axiosPrivate, videoId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ onSuccess: (_, variables) => {
+ if (variables.invalidatePlaybackQuery !== false) {
+ queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ }
},
}
);
@@ -228,8 +232,10 @@ const useDeletePlayback = () => {
return useMutation, Error, DeletePlaybackInput>({
mutationFn: ({ axiosPrivate, videoId }) =>
deletePlayback(axiosPrivate, videoId),
- onSuccess: () => {
- queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ onSuccess: (_, variables) => {
+ if (variables.invalidatePlaybackQuery !== false) {
+ queryClient.invalidateQueries({ queryKey: ["playback-data"] });
+ }
},
});
};
diff --git a/frontend/app/hooks/useVideos.ts b/frontend/app/hooks/useVideos.ts
index 0468f7c2..f94fd01e 100644
--- a/frontend/app/hooks/useVideos.ts
+++ b/frontend/app/hooks/useVideos.ts
@@ -494,13 +494,22 @@ const useLockVideo = () => {
axiosPrivate: AxiosInstance;
videoId: string;
locked: boolean;
+ invalidateVideoQueries?: boolean;
}
>({
mutationFn: ({ axiosPrivate, videoId, locked }) =>
lockVideo(axiosPrivate, videoId, locked),
- onSuccess: () => {
- // @ts-expect-error fine
- queryClient.invalidateQueries(["channel_videos"]);
+ onSuccess: async (_, variables) => {
+ if (variables.invalidateVideoQueries === false) {
+ return;
+ }
+
+ await Promise.all([
+ queryClient.invalidateQueries({ queryKey: ["videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["channel_videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["playlist_videos"] }),
+ queryClient.invalidateQueries({ queryKey: ["search"] }),
+ ]);
},
});
};
diff --git a/frontend/messages/de.json b/frontend/messages/de.json
index d07a1c17..b23de5ab 100644
--- a/frontend/messages/de.json
+++ b/frontend/messages/de.json
@@ -766,6 +766,7 @@
"markedAsWatchedNotification": "Video als angesehen markiert",
"markedAsUnwatchedNotification": "Video als nicht angesehen markiert",
"videoLockedNotification": "Das Video wurde {status}.",
+ "videosLockedNotification": "Videos wurden {status}.",
"locked": "gesperrt",
"unlocked": "entsperren",
"generateStaticThumbnailsNotification": "Aufgabe zum Erstellen von statischen Thumbnails zur Warteschlange hinzugefügt",
@@ -787,6 +788,28 @@
"share": "Teilen",
"delete": "Video löschen"
},
+ "selectForBulkTooltip": "Für Massenaktionen auswählen",
+ "selectVideoCheckboxLabel": "Video auswählen",
+ "selectAllOnPage": "Alle auf Seite auswählen",
+ "selectedVideosCount": "{count} ausgewählt",
+ "clearSelectionButton": "Auswahl aufheben",
+ "bulkActionsButton": "Massenaktionen",
+ "bulkAddToPlaylistModalTitle": "Videos zur Wiedergabeliste hinzufügen",
+ "bulkDeleteVideosModalTitle": "Videos löschen",
+ "markedVideosAsWatchedNotification": "Videos als angesehen markiert",
+ "markedVideosAsUnwatchedNotification": "Videos als nicht angesehen markiert",
+ "bulkGenerateStaticThumbnailsNotification": "Aufgaben zum Erstellen von statischen Thumbnails zur Warteschlange hinzugefügt",
+ "bulkGenerateSpriteThumbnailsNotification": "Aufgaben zum Erstellen von Sprite-Thumbnails zur Warteschlange hinzugefügt",
+ "bulkActionMenu": {
+ "markAsWatched": "Als angesehen markieren",
+ "markAsUnwatched": "Als nicht angesehen markieren",
+ "lock": "Videos sperren",
+ "unlock": "Videos entsperren",
+ "regenerateThumbnails": "Thumbnails neu erstellen",
+ "generateSpriteThumbnails": "Sprite-Thumbnails erstellen",
+ "playlists": "Zur Wiedergabeliste hinzufügen",
+ "delete": "Videos löschen"
+ },
"videoInformationModalTitle": "Video Informationen",
"videoInformationModal": {
"informationTitle": "Video Informationen",
@@ -874,4 +897,4 @@
"MiscComponents": {
"recordsPerPageLabel": "Einträge pro Seite"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/messages/en.json b/frontend/messages/en.json
index c8f4f54d..f75e3b11 100644
--- a/frontend/messages/en.json
+++ b/frontend/messages/en.json
@@ -766,6 +766,7 @@
"markedAsWatchedNotification": "Video marked as watched",
"markedAsUnwatchedNotification": "Video marked as unwatched",
"videoLockedNotification": "Video has been {status}.",
+ "videosLockedNotification": "Videos have been {status}.",
"locked": "locked",
"unlocked": "unlocked",
"generateStaticThumbnailsNotification": "Queued task to generate static thumbnails",
@@ -787,6 +788,28 @@
"share": "Share",
"delete": "Delete Video"
},
+ "selectForBulkTooltip": "Select for bulk actions",
+ "selectVideoCheckboxLabel": "Select video",
+ "selectAllOnPage": "Select all on page",
+ "selectedVideosCount": "{count} selected",
+ "clearSelectionButton": "Clear Selection",
+ "bulkActionsButton": "Bulk Actions",
+ "bulkAddToPlaylistModalTitle": "Add Videos to Playlist",
+ "bulkDeleteVideosModalTitle": "Delete Videos",
+ "markedVideosAsWatchedNotification": "Videos marked as watched",
+ "markedVideosAsUnwatchedNotification": "Videos marked as unwatched",
+ "bulkGenerateStaticThumbnailsNotification": "Queued tasks to generate static thumbnails",
+ "bulkGenerateSpriteThumbnailsNotification": "Queued tasks to generate sprite thumbnails",
+ "bulkActionMenu": {
+ "markAsWatched": "Mark as Watched",
+ "markAsUnwatched": "Mark as Unwatched",
+ "lock": "Lock Videos",
+ "unlock": "Unlock Videos",
+ "regenerateThumbnails": "Regenerate Thumbnails",
+ "generateSpriteThumbnails": "Generate Sprite Thumbnails",
+ "playlists": "Add to Playlist",
+ "delete": "Delete Videos"
+ },
"videoInformationModalTitle": "Video Information",
"videoInformationModal": {
"informationTitle": "Video Information",
@@ -874,4 +897,4 @@
"MiscComponents": {
"recordsPerPageLabel": "Records per page"
}
-}
\ No newline at end of file
+}
diff --git a/frontend/messages/uk.json b/frontend/messages/uk.json
index a92d2e60..5767c532 100644
--- a/frontend/messages/uk.json
+++ b/frontend/messages/uk.json
@@ -764,6 +764,7 @@
"markedAsWatchedNotification": "Відео позначено як переглянуте",
"markedAsUnwatchedNotification": "Відео позначено як непереглянуте",
"videoLockedNotification": "Відео було {status}.",
+ "videosLockedNotification": "Відео було {status}.",
"locked": "захищено",
"unlocked": "незахищено",
"generateStaticThumbnailsNotification": "Завдання на генерацію статичних мініатюр додано в чергу",
@@ -785,6 +786,28 @@
"share": "Поділитися",
"delete": "Видалити відео"
},
+ "selectForBulkTooltip": "Вибрати для масових дій",
+ "selectVideoCheckboxLabel": "Вибрати відео",
+ "selectAllOnPage": "Вибрати все на сторінці",
+ "selectedVideosCount": "{count} вибрано",
+ "clearSelectionButton": "Очистити вибір",
+ "bulkActionsButton": "Масові дії",
+ "bulkAddToPlaylistModalTitle": "Додати відео до плейліста",
+ "bulkDeleteVideosModalTitle": "Видалити відео",
+ "markedVideosAsWatchedNotification": "Відео позначено як переглянуті",
+ "markedVideosAsUnwatchedNotification": "Відео позначено як непереглянуті",
+ "bulkGenerateStaticThumbnailsNotification": "Завдання на генерацію статичних мініатюр додано в чергу",
+ "bulkGenerateSpriteThumbnailsNotification": "Завдання на генерацію спрайт-мініатюр додано в чергу",
+ "bulkActionMenu": {
+ "markAsWatched": "Позначити як переглянуте",
+ "markAsUnwatched": "Позначити як непереглянуте",
+ "lock": "Захистити відео",
+ "unlock": "Забрати захист відео",
+ "regenerateThumbnails": "Перегенерувати мініатюри",
+ "generateSpriteThumbnails": "Згенерувати спрайт-мініатюри",
+ "playlists": "Додати до плейліста",
+ "delete": "Видалити відео"
+ },
"videoInformationModalTitle": "Інформація про відео",
"videoInformationModal": {
"informationTitle": "Інформація про відео",
@@ -872,4 +895,4 @@
"MiscComponents": {
"recordsPerPageLabel": "Записів на сторінку"
}
-}
\ No newline at end of file
+}