diff --git a/frontend/app/components/videos/Card.module.css b/frontend/app/components/videos/Card.module.css index 9482e508..0b905f2a 100644 --- a/frontend/app/components/videos/Card.module.css +++ b/frontend/app/components/videos/Card.module.css @@ -21,6 +21,17 @@ position: relative; } +.selectionCheckboxInline { + display: flex; + align-items: center; + margin-left: var(--mantine-spacing-xs); + opacity: 0.75; +} + +.selectionCheckboxInline:hover { + opacity: 1; +} + .videoImage { pointer-events: none; cursor: default; diff --git a/frontend/app/components/videos/Card.tsx b/frontend/app/components/videos/Card.tsx index fb95de10..f7619d67 100644 --- a/frontend/app/components/videos/Card.tsx +++ b/frontend/app/components/videos/Card.tsx @@ -1,5 +1,5 @@ import { Video } from "@/app/hooks/useVideos"; -import { Badge, Card, Image, Progress, Tooltip, Text, Title, Group, Center, Avatar, Flex, ThemeIcon, LoadingOverlay, Loader, Box } from "@mantine/core"; +import { Badge, Card, Image, Progress, Tooltip, Text, Title, Group, Center, Avatar, Flex, ThemeIcon, LoadingOverlay, Loader, Box, Checkbox } from "@mantine/core"; import Link from "next/link"; import { useEffect, useState } from "react"; import dayjs from "dayjs"; @@ -26,9 +26,21 @@ type Props = { showMenu: boolean; showChannel: boolean; showViewCount?: boolean; + selectable?: boolean; + selected?: boolean; + onSelectionChange?: (selected: boolean) => void; } -const VideoCard = ({ video, showProgress = true, showMenu = true, showChannel = true, showViewCount = true }: Props) => { +const VideoCard = ({ + video, + showProgress = true, + showMenu = true, + showChannel = true, + showViewCount = true, + selectable = false, + selected = false, + onSelectionChange = () => { }, +}: Props) => { const t = useTranslations('VideoComponents') const { isLoggedIn, hasPermission } = useAuthStore() const [thumbnailError, setThumbnailError] = useState(false); @@ -62,7 +74,6 @@ const VideoCard = ({ video, showProgress = true, showMenu = true, showChannel =
- {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 })} + + + + + + + + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.markAsWatched")} + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.markAsUnwatched")} + + handleLockVideos(true)} + leftSection={} + disabled={bulkActionLoading} + > + {t("bulkActionMenu.lock")} + + handleLockVideos(false)} + leftSection={} + disabled={bulkActionLoading} + > + {t("bulkActionMenu.unlock")} + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.regenerateThumbnails")} + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.generateSpriteThumbnails")} + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.playlists")} + + {canBulkDelete && ( + <> + + } + disabled={bulkActionLoading} + > + {t("bulkActionMenu.delete")} + + + )} + + + + + )} + ({ 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 +}