node build fixed
This commit is contained in:
@@ -0,0 +1,136 @@
|
||||
import { useLocalFileBulkAction, useRemoveEmptyDirectories } from "@/api/hooks/localfiles.hooks"
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { atom, useAtom } from "jotai"
|
||||
import React from "react"
|
||||
import { BiLockAlt, BiLockOpenAlt } from "react-icons/bi"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __bulkAction_modalAtomIsOpen = atom<boolean>(false)
|
||||
|
||||
export function BulkActionModal() {
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__bulkAction_modalAtomIsOpen)
|
||||
|
||||
const { mutate: performBulkAction, isPending } = useLocalFileBulkAction()
|
||||
|
||||
function handleLockFiles() {
|
||||
performBulkAction({
|
||||
action: "lock",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
toast.success("Files locked")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleUnlockFiles() {
|
||||
performBulkAction({
|
||||
action: "unlock",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
toast.success("Files unlocked")
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const { mutate: removeEmptyDirectories, isPending: isRemoving } = useRemoveEmptyDirectories()
|
||||
|
||||
function handleRemoveEmptyDirectories() {
|
||||
removeEmptyDirectories(undefined, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const confirmRemoveEmptyDirs = useConfirmationDialog({
|
||||
title: "Remove empty directories",
|
||||
description: "This action will remove all empty directories in the library. Are you sure you want to continue?",
|
||||
onConfirm: () => {
|
||||
handleRemoveEmptyDirectories()
|
||||
},
|
||||
})
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
React.useEffect(() => {
|
||||
inject("anime-library-bulk-actions", {
|
||||
priority: 1,
|
||||
items: [
|
||||
{
|
||||
id: "lock-files", value: "lock", heading: "Library",
|
||||
render: () => (
|
||||
<p>Lock all files</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
handleLockFiles()
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "unlock-files", value: "unlock", heading: "Library",
|
||||
render: () => (
|
||||
<p>Unlock all files</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
handleUnlockFiles()
|
||||
},
|
||||
},
|
||||
],
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase())
|
||||
},
|
||||
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
|
||||
showBasedOnInput: "startsWith",
|
||||
})
|
||||
|
||||
return () => remove("anime-library-bulk-actions")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<Modal
|
||||
open={isOpen} onOpenChange={() => setIsOpen(false)} title="Bulk actions"
|
||||
contentClass="space-y-4"
|
||||
>
|
||||
<AppLayoutStack spacing="sm">
|
||||
{/*<p>These actions do not affect ignored files.</p>*/}
|
||||
<div className="flex gap-2 flex-col md:flex-row">
|
||||
<Button
|
||||
leftIcon={<BiLockAlt className="text-2xl" />}
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending || isRemoving}
|
||||
onClick={handleLockFiles}
|
||||
>
|
||||
Lock all files
|
||||
</Button>
|
||||
<Button
|
||||
leftIcon={<BiLockOpenAlt className="text-2xl" />}
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending || isRemoving}
|
||||
onClick={handleUnlockFiles}
|
||||
>
|
||||
Unlock all files
|
||||
</Button>
|
||||
</div>
|
||||
<Button
|
||||
intent="gray-outline"
|
||||
className="w-full"
|
||||
disabled={isPending}
|
||||
loading={isRemoving}
|
||||
onClick={() => confirmRemoveEmptyDirs.open()}
|
||||
>
|
||||
Remove empty directories
|
||||
</Button>
|
||||
</AppLayoutStack>
|
||||
<ConfirmationDialog {...confirmRemoveEmptyDirs} />
|
||||
</Modal>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,272 @@
|
||||
"use client"
|
||||
import { Anime_Episode, Continuity_WatchHistory } from "@/api/generated/types"
|
||||
import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { __libraryHeaderImageAtom } from "@/app/(main)/(library)/_components/library-header"
|
||||
import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms"
|
||||
import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card"
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { episodeCardCarouselItemClass } from "@/components/shared/classnames"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { TextGenerateEffect } from "@/components/shared/text-generate-effect"
|
||||
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { anilist_animeIsMovie } from "@/lib/helpers/media"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useWindowSize } from "@uidotdev/usehooks"
|
||||
import { atom } from "jotai/index"
|
||||
import { useAtom, useSetAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { seaCommand_compareMediaTitles } from "../../_features/sea-command/utils"
|
||||
|
||||
export const __libraryHeaderEpisodeAtom = atom<Anime_Episode | null>(null)
|
||||
|
||||
export function ContinueWatching({ episodes, isLoading, linkTemplate }: {
|
||||
episodes: Anime_Episode[],
|
||||
isLoading: boolean
|
||||
linkTemplate?: string
|
||||
}) {
|
||||
|
||||
const router = useRouter()
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
|
||||
const [headerEpisode, setHeaderEpisode] = useAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
const [episodeRefs, setEpisodeRefs] = React.useState<React.RefObject<any>[]>([])
|
||||
const [inViewEpisodes, setInViewEpisodes] = React.useState<number[]>([])
|
||||
const debouncedInViewEpisodes = useDebounce(inViewEpisodes, 500)
|
||||
|
||||
const { width } = useWindowSize()
|
||||
|
||||
// Create refs for each episode
|
||||
React.useEffect(() => {
|
||||
setEpisodeRefs(episodes.map(() => React.createRef()))
|
||||
}, [episodes])
|
||||
|
||||
// Observe each episode
|
||||
React.useEffect(() => {
|
||||
const observer = new IntersectionObserver((entries) => {
|
||||
entries.forEach((entry) => {
|
||||
const index = episodeRefs.findIndex(ref => ref.current === entry.target)
|
||||
if (index !== -1) {
|
||||
if (entry.isIntersecting) {
|
||||
setInViewEpisodes(prev => prev.includes(index) ? prev : [...prev, index])
|
||||
} else {
|
||||
setInViewEpisodes(prev => prev.filter((idx) => idx !== index))
|
||||
}
|
||||
}
|
||||
})
|
||||
}, { threshold: 0.5 }) // Trigger callback when 50% of the element is visible
|
||||
|
||||
episodeRefs.forEach((ref) => {
|
||||
if (ref.current) {
|
||||
observer.observe(ref.current)
|
||||
}
|
||||
})
|
||||
|
||||
return () => {
|
||||
episodeRefs.forEach((ref) => {
|
||||
if (ref.current) {
|
||||
observer.unobserve(ref.current)
|
||||
}
|
||||
})
|
||||
}
|
||||
}, [episodeRefs, width])
|
||||
|
||||
const prevSelectedEpisodeRef = React.useRef<Anime_Episode | null>(null)
|
||||
|
||||
// Set header image when new episode is in view
|
||||
React.useEffect(() => {
|
||||
if (debouncedInViewEpisodes.length === 0) return
|
||||
|
||||
let candidateIndices = debouncedInViewEpisodes
|
||||
// Exclude previously selected episode if possible
|
||||
if (prevSelectedEpisodeRef.current && debouncedInViewEpisodes.length > 1) {
|
||||
candidateIndices = debouncedInViewEpisodes.filter(idx => episodes[idx]?.baseAnime?.id !== prevSelectedEpisodeRef.current?.baseAnime?.id)
|
||||
}
|
||||
if (candidateIndices.length === 0) candidateIndices = debouncedInViewEpisodes
|
||||
|
||||
const randomCandidateIdx = candidateIndices[Math.floor(Math.random() * candidateIndices.length)]
|
||||
const selectedEpisode = episodes[randomCandidateIdx]
|
||||
|
||||
if (selectedEpisode) {
|
||||
setHeaderImage({
|
||||
bannerImage: selectedEpisode.baseAnime?.bannerImage || null,
|
||||
episodeImage: selectedEpisode.baseAnime?.bannerImage || selectedEpisode.baseAnime?.coverImage?.extraLarge || null,
|
||||
})
|
||||
prevSelectedEpisodeRef.current = selectedEpisode
|
||||
}
|
||||
}, [debouncedInViewEpisodes, episodes, setHeaderImage])
|
||||
|
||||
const { setPlayNext } = usePlayNext()
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
|
||||
React.useEffect(() => {
|
||||
inject("continue-watching", {
|
||||
items: episodes.map(episode => ({
|
||||
data: episode,
|
||||
id: `${episode.localFile?.path || episode.baseAnime?.title?.userPreferred || ""}-${episode.episodeNumber || 1}`,
|
||||
value: `${episode.episodeNumber || 1}`,
|
||||
heading: "Continue Watching",
|
||||
priority: 100,
|
||||
render: () => (
|
||||
<>
|
||||
<div className="w-12 aspect-[6/5] flex-none rounded-[--radius-md] relative overflow-hidden">
|
||||
<Image
|
||||
src={episode.episodeMetadata?.image || ""}
|
||||
alt="episode image"
|
||||
fill
|
||||
className="object-center object-cover"
|
||||
/>
|
||||
</div>
|
||||
<div className="flex gap-1 items-center w-full">
|
||||
<p className="max-w-[70%] truncate">{episode.baseAnime?.title?.userPreferred || ""}</p> -
|
||||
{!anilist_animeIsMovie(episode.baseAnime) ? <>
|
||||
<p className="text-[--muted]">Ep</p><span>{episode.episodeNumber}</span>
|
||||
</> : <>
|
||||
<p className="text-[--muted]">Movie</p>
|
||||
</>}
|
||||
|
||||
</div>
|
||||
</>
|
||||
),
|
||||
onSelect: () => setPlayNext(episode.baseAnime?.id, () => {
|
||||
router.push(`/entry?id=${episode.baseAnime?.id}`)
|
||||
}),
|
||||
})),
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase()) ||
|
||||
seaCommand_compareMediaTitles(item.data.baseAnime?.title, input)
|
||||
},
|
||||
priority: 100,
|
||||
})
|
||||
|
||||
return () => remove("continue-watching")
|
||||
}, [episodes, inject, remove, router, setPlayNext])
|
||||
|
||||
if (episodes.length > 0) return (
|
||||
<PageWrapper className="space-y-3 lg:space-y-6 p-4 relative z-[4]" data-continue-watching-container>
|
||||
<h2 data-continue-watching-title>Continue watching</h2>
|
||||
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerEpisode?.baseAnime) && <TextGenerateEffect
|
||||
data-continue-watching-media-title
|
||||
words={headerEpisode?.baseAnime?.title?.userPreferred || ""}
|
||||
className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1"
|
||||
/>}
|
||||
<Carousel
|
||||
className="w-full max-w-full"
|
||||
gap="md"
|
||||
opts={{
|
||||
align: "start",
|
||||
}}
|
||||
autoScroll
|
||||
autoScrollDelay={8000}
|
||||
>
|
||||
<CarouselDotButtons />
|
||||
<CarouselContent>
|
||||
{episodes.map((episode, idx) => (
|
||||
<CarouselItem
|
||||
key={episode?.localFile?.path || idx}
|
||||
className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)}
|
||||
>
|
||||
<_EpisodeCard
|
||||
key={episode.localFile?.path || ""}
|
||||
episode={episode}
|
||||
mRef={episodeRefs[idx]}
|
||||
overrideLink={linkTemplate}
|
||||
watchHistory={watchHistory}
|
||||
/>
|
||||
</CarouselItem>
|
||||
))}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
</PageWrapper>
|
||||
)
|
||||
return null
|
||||
}
|
||||
|
||||
const _EpisodeCard = React.memo(({ episode, mRef, overrideLink, watchHistory }: {
|
||||
episode: Anime_Episode,
|
||||
mRef: React.RefObject<any>,
|
||||
overrideLink?: string
|
||||
watchHistory: Continuity_WatchHistory | undefined
|
||||
}) => {
|
||||
const serverStatus = useServerStatus()
|
||||
const router = useRouter()
|
||||
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
|
||||
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
React.useEffect(() => {
|
||||
setHeaderImage(prev => {
|
||||
if (prev?.episodeImage === null) {
|
||||
return {
|
||||
bannerImage: episode.baseAnime?.bannerImage || null,
|
||||
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
|
||||
}
|
||||
}
|
||||
return prev
|
||||
})
|
||||
setHeaderEpisode(prev => {
|
||||
if (prev === null) {
|
||||
return episode
|
||||
}
|
||||
return prev
|
||||
})
|
||||
}, [])
|
||||
|
||||
const { setPlayNext } = usePlayNext()
|
||||
|
||||
return (
|
||||
<EpisodeCard
|
||||
key={episode.localFile?.path || ""}
|
||||
episode={episode}
|
||||
image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge}
|
||||
topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred}
|
||||
title={episode.displayTitle}
|
||||
isInvalid={episode.isInvalid}
|
||||
progressTotal={episode.baseAnime?.episodes}
|
||||
progressNumber={episode.progressNumber}
|
||||
episodeNumber={episode.episodeNumber}
|
||||
length={episode.episodeMetadata?.length}
|
||||
hasDiscrepancy={episode.episodeNumber !== episode.progressNumber}
|
||||
percentageComplete={getEpisodePercentageComplete(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
|
||||
minutesRemaining={getEpisodeMinutesRemaining(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
|
||||
anime={{
|
||||
id: episode?.baseAnime?.id || 0,
|
||||
image: episode?.baseAnime?.coverImage?.medium,
|
||||
title: episode?.baseAnime?.title?.userPreferred,
|
||||
}}
|
||||
onMouseEnter={() => {
|
||||
React.startTransition(() => {
|
||||
setHeaderImage({
|
||||
bannerImage: episode.baseAnime?.bannerImage || null,
|
||||
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
|
||||
})
|
||||
})
|
||||
}}
|
||||
mRef={mRef}
|
||||
onClick={() => {
|
||||
if (!overrideLink) {
|
||||
setPlayNext(episode.baseAnime?.id, () => {
|
||||
if (!serverStatus?.isOffline) {
|
||||
router.push(`/entry?id=${episode.baseAnime?.id}`)
|
||||
} else {
|
||||
router.push(`/offline/entry/anime?id=${episode.baseAnime?.id}`)
|
||||
}
|
||||
})
|
||||
} else {
|
||||
setPlayNext(episode.baseAnime?.id, () => {
|
||||
router.push(overrideLink.replace("{id}", episode.baseAnime?.id ? String(episode.baseAnime.id) : ""))
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
@@ -0,0 +1,112 @@
|
||||
"use client"
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getAssetUrl } from "@/lib/server/assets"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { motion } from "motion/react"
|
||||
import React, { useEffect } from "react"
|
||||
import { useWindowScroll } from "react-use"
|
||||
|
||||
type CustomLibraryBannerProps = {
|
||||
discrete?: boolean
|
||||
isLibraryScreen?: boolean // Anime library or manga library
|
||||
}
|
||||
|
||||
export function CustomLibraryBanner(props: CustomLibraryBannerProps) {
|
||||
/**
|
||||
* Library screens: Shows the custom banner IF theme settings are set to use a custom banner
|
||||
* Other pages: Shows the custom banner
|
||||
*/
|
||||
const { discrete, isLibraryScreen } = props
|
||||
const ts = useThemeSettings()
|
||||
const image = React.useMemo(() => ts.libraryScreenCustomBannerImage ? getAssetUrl(ts.libraryScreenCustomBannerImage) : "",
|
||||
[ts.libraryScreenCustomBannerImage])
|
||||
const [dimmed, setDimmed] = React.useState(false)
|
||||
|
||||
const { y } = useWindowScroll()
|
||||
|
||||
useEffect(() => {
|
||||
if (y > 100)
|
||||
setDimmed(true)
|
||||
else
|
||||
setDimmed(false)
|
||||
}, [(y > 100)])
|
||||
|
||||
if (isLibraryScreen && ts.libraryScreenBannerType !== ThemeLibraryScreenBannerType.Custom) return null
|
||||
if (discrete && !!ts.libraryScreenCustomBackgroundImage) return null
|
||||
if (!image) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!discrete && <div
|
||||
data-custom-library-banner-top-spacer
|
||||
className={cn(
|
||||
"py-20",
|
||||
ts.hideTopNavbar && "py-28",
|
||||
)}
|
||||
></div>}
|
||||
<div
|
||||
data-custom-library-banner-container
|
||||
className={cn(
|
||||
"__header h-[30rem] z-[1] top-0 w-full fixed group/library-header transition-opacity duration-1000",
|
||||
discrete && "opacity-20",
|
||||
!!ts.libraryScreenCustomBackgroundImage && "absolute", // If there's a background image, make the banner absolute
|
||||
(!ts.libraryScreenCustomBackgroundImage && dimmed) && "opacity-5", // If the user has scrolled down, dim the banner
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
>
|
||||
{(!ts.disableSidebarTransparency && !discrete) && <div
|
||||
data-custom-library-banner-top-gradient
|
||||
className="hidden lg:block h-full absolute z-[2] w-[20rem] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
|
||||
/>}
|
||||
|
||||
<div
|
||||
data-custom-library-banner-bottom-gradient
|
||||
className="w-full z-[3] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
|
||||
/>
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className={cn(
|
||||
"h-[30rem] z-[0] w-full flex-none absolute top-0 overflow-hidden",
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
data-custom-library-banner-inner-container
|
||||
>
|
||||
<div
|
||||
data-custom-library-banner-top-gradient
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_TOP_FADE w-full absolute z-[2] top-0 h-[5rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via",
|
||||
discrete && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-custom-library-banner-image
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_IMG z-[1] absolute inset-0 w-full h-full bg-cover bg-no-repeat transition-opacity duration-1000",
|
||||
"scroll-locked-offset",
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${image})`,
|
||||
backgroundPosition: ts.libraryScreenCustomBannerPosition || "50% 50%",
|
||||
opacity: (ts.libraryScreenCustomBannerOpacity || 100) / 100,
|
||||
backgroundRepeat: "no-repeat",
|
||||
backgroundSize: "cover",
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
data-custom-library-banner-bottom-gradient
|
||||
className={cn(
|
||||
"CUSTOM_LIB_BANNER_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-opacity-50 via-10% to-transparent via",
|
||||
discrete && "via-50% via-opacity-100 h-[40rem]",
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,135 @@
|
||||
import { Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { upath } from "@/lib/helpers/upath"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { TbFileSad } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __ignoredFileManagerIsOpen = atom(false)
|
||||
|
||||
type IgnoredFileManagerProps = {
|
||||
files: Anime_LocalFile[]
|
||||
}
|
||||
|
||||
export function IgnoredFileManager(props: IgnoredFileManagerProps) {
|
||||
|
||||
const { files } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__ignoredFileManagerIsOpen)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const { mutate: updateLocalFiles, isPending: isUpdating } = useUpdateLocalFiles()
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setSelectedPaths(files?.map(lf => lf.path) ?? [])
|
||||
}, [files])
|
||||
|
||||
function handleUnIgnoreSelected() {
|
||||
if (selectedPaths.length > 0) {
|
||||
updateLocalFiles({
|
||||
paths: selectedPaths,
|
||||
action: "unignore",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Files un-ignored")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
// contentClass="max-w-5xl"
|
||||
size="xl"
|
||||
title="Ignored files"
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
{files.length > 0 && <div className="flex flex-wrap items-center gap-2">
|
||||
<div className="flex flex-1"></div>
|
||||
<Button
|
||||
leftIcon={<TbFileSad className="text-lg" />}
|
||||
intent="white"
|
||||
size="sm"
|
||||
rounded
|
||||
loading={isUpdating}
|
||||
onClick={handleUnIgnoreSelected}
|
||||
>
|
||||
Un-ignore selection
|
||||
</Button>
|
||||
</div>}
|
||||
|
||||
{files.length === 0 && <LuffyError title={null}>
|
||||
No ignored files
|
||||
</LuffyError>}
|
||||
|
||||
{files.length > 0 &&
|
||||
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[85vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
|
||||
<div className="p-2">
|
||||
<Checkbox
|
||||
label={`Select all files`}
|
||||
value={(selectedPaths.length === files?.length) ? true : (selectedPaths.length === 0
|
||||
? false
|
||||
: "indeterminate")}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (draft.length === files?.length) {
|
||||
return []
|
||||
} else {
|
||||
return files?.map(lf => lf.path) ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{files.map((lf, index) => (
|
||||
<div
|
||||
key={`${lf.path}-${index}`}
|
||||
className="p-2 "
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
label={`${upath.basename(lf.path)}`}
|
||||
value={selectedPaths.includes(lf.path)}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (checked) {
|
||||
return [...draft, lf.path]
|
||||
} else {
|
||||
return draft.filter(p => p !== lf.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>}
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,138 @@
|
||||
import { Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { __mainLibrary_paramsAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { getLibraryCollectionTitle } from "@/lib/server/utils"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { LuListFilter } from "react-icons/lu"
|
||||
|
||||
export function LibraryCollectionLists({ collectionList, isLoading, streamingMediaIds }: {
|
||||
collectionList: Anime_LibraryCollectionList[],
|
||||
isLoading: boolean,
|
||||
streamingMediaIds: number[]
|
||||
}) {
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
key="library-collection-lists"
|
||||
className="space-y-8"
|
||||
data-library-collection-lists
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}>
|
||||
{collectionList.map(collection => {
|
||||
if (!collection.entries?.length) return null
|
||||
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</PageWrapper>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export function LibraryCollectionFilteredLists({ collectionList, isLoading, streamingMediaIds }: {
|
||||
collectionList: Anime_LibraryCollectionList[],
|
||||
isLoading: boolean,
|
||||
streamingMediaIds: number[]
|
||||
}) {
|
||||
|
||||
// const params = useAtomValue(__mainLibrary_paramsAtom)
|
||||
|
||||
return (
|
||||
<PageWrapper
|
||||
key="library-filtered-lists"
|
||||
className="space-y-8"
|
||||
data-library-filtered-lists
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}>
|
||||
{/*<h3 className="text-center truncate">*/}
|
||||
{/* {params.genre?.join(", ")}*/}
|
||||
{/*</h3>*/}
|
||||
<MediaCardLazyGrid itemCount={collectionList?.flatMap(n => n.entries)?.length ?? 0}>
|
||||
{collectionList?.flatMap(n => n.entries)?.filter(Boolean)?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</PageWrapper>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
export const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: {
|
||||
list: Anime_LibraryCollectionList,
|
||||
streamingMediaIds: number[]
|
||||
}) => {
|
||||
|
||||
const isCurrentlyWatching = list.type === "CURRENT"
|
||||
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
|
||||
|
||||
return (
|
||||
<React.Fragment key={list.type}>
|
||||
<div className="flex gap-3 items-center" data-library-collection-list-item-header data-list-type={list.type}>
|
||||
<h2 className="p-0 m-0">{getLibraryCollectionTitle(list.type)}</h2>
|
||||
<div className="flex flex-1"></div>
|
||||
{isCurrentlyWatching && <DropdownMenu
|
||||
trigger={<IconButton
|
||||
intent="white-basic"
|
||||
size="xs"
|
||||
className="mt-1"
|
||||
icon={<LuListFilter />}
|
||||
/>}
|
||||
>
|
||||
<DropdownMenuItem
|
||||
onClick={() => {
|
||||
setParams(draft => {
|
||||
draft.continueWatchingOnly = !draft.continueWatchingOnly
|
||||
return
|
||||
})
|
||||
}}
|
||||
>
|
||||
{params.continueWatchingOnly ? "Show all" : "Show unwatched only"}
|
||||
</DropdownMenuItem>
|
||||
</DropdownMenu>}
|
||||
</div>
|
||||
<MediaCardLazyGrid
|
||||
itemCount={list?.entries?.length || 0}
|
||||
data-library-collection-list-item-media-card-lazy-grid
|
||||
data-list-type={list.type}
|
||||
>
|
||||
{list.entries?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
export const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
|
||||
entry: Anime_LibraryCollectionEntry,
|
||||
streamingMediaIds: number[]
|
||||
}) => {
|
||||
return (
|
||||
<MediaEntryCard
|
||||
media={entry.media!}
|
||||
listData={entry.listData}
|
||||
libraryData={entry.libraryData}
|
||||
nakamaLibraryData={entry.nakamaLibraryData}
|
||||
showListDataButton
|
||||
withAudienceScore={false}
|
||||
type="anime"
|
||||
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId) && entry.listData?.status === "CURRENT"}
|
||||
/>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,191 @@
|
||||
"use client"
|
||||
import { Anime_LibraryCollectionList, Anime_LocalFile, Anime_UnknownGroup } from "@/api/generated/types"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { __bulkAction_modalAtomIsOpen } from "@/app/(main)/(library)/_containers/bulk-action-modal"
|
||||
import { __ignoredFileManagerIsOpen } from "@/app/(main)/(library)/_containers/ignored-file-manager"
|
||||
import { PlayRandomEpisodeButton } from "@/app/(main)/(library)/_containers/play-random-episode-button"
|
||||
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
|
||||
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
import { __unknownMedia_drawerIsOpen } from "@/app/(main)/(library)/_containers/unknown-media-manager"
|
||||
import { __unmatchedFileManagerIsOpen } from "@/app/(main)/(library)/_containers/unmatched-file-manager"
|
||||
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useAtom, useSetAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { BiCollection, BiDotsVerticalRounded, BiFolder } from "react-icons/bi"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { IoLibrary, IoLibrarySharp } from "react-icons/io5"
|
||||
import { MdOutlineVideoLibrary } from "react-icons/md"
|
||||
import { PiClockCounterClockwiseFill } from "react-icons/pi"
|
||||
import { TbFileSad, TbReload } from "react-icons/tb"
|
||||
import { PluginAnimeLibraryDropdownItems } from "../../_features/plugin/actions/plugin-actions"
|
||||
|
||||
export type LibraryToolbarProps = {
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
ignoredLocalFiles: Anime_LocalFile[]
|
||||
unmatchedLocalFiles: Anime_LocalFile[]
|
||||
unknownGroups: Anime_UnknownGroup[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
isStreamingOnly: boolean
|
||||
isNakamaLibrary: boolean
|
||||
}
|
||||
|
||||
export function LibraryToolbar(props: LibraryToolbarProps) {
|
||||
|
||||
const {
|
||||
collectionList,
|
||||
ignoredLocalFiles,
|
||||
unmatchedLocalFiles,
|
||||
unknownGroups,
|
||||
hasEntries,
|
||||
isStreamingOnly,
|
||||
isNakamaLibrary,
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const setBulkActionIsOpen = useSetAtom(__bulkAction_modalAtomIsOpen)
|
||||
|
||||
const status = useServerStatus()
|
||||
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
|
||||
const setUnmatchedFileManagerOpen = useSetAtom(__unmatchedFileManagerIsOpen)
|
||||
const setIgnoredFileManagerOpen = useSetAtom(__ignoredFileManagerIsOpen)
|
||||
const setUnknownMediaManagerOpen = useSetAtom(__unknownMedia_drawerIsOpen)
|
||||
const setPlaylistsModalOpen = useSetAtom(__playlists_modalOpenAtom)
|
||||
|
||||
const [libraryView, setLibraryView] = useAtom(__library_viewAtom)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const hasLibraryPath = !!status?.settings?.library?.libraryPath
|
||||
|
||||
return (
|
||||
<>
|
||||
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && hasEntries) && <div
|
||||
className={cn(
|
||||
"h-28",
|
||||
ts.hideTopNavbar && "h-40",
|
||||
)}
|
||||
data-library-toolbar-top-padding
|
||||
></div>}
|
||||
<div className="flex flex-wrap w-full justify-end gap-2 p-4 relative z-[10]" data-library-toolbar-container>
|
||||
<div className="flex flex-1" data-library-toolbar-spacer></div>
|
||||
{(hasEntries) && (
|
||||
<>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-switch-view-button
|
||||
intent={libraryView === "base" ? "white-subtle" : "white"}
|
||||
icon={<IoLibrary className="text-2xl" />}
|
||||
onClick={() => setLibraryView(p => p === "detailed" ? "base" : "detailed")}
|
||||
/>}
|
||||
>
|
||||
Switch view
|
||||
</Tooltip>
|
||||
|
||||
{!(isStreamingOnly || isNakamaLibrary) && <Tooltip
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-playlists-button
|
||||
intent={"white-subtle"}
|
||||
icon={<MdOutlineVideoLibrary className="text-2xl" />}
|
||||
onClick={() => setPlaylistsModalOpen(true)}
|
||||
/>}
|
||||
>Playlists</Tooltip>}
|
||||
|
||||
{!(isStreamingOnly || isNakamaLibrary) && <PlayRandomEpisodeButton />}
|
||||
|
||||
{/*Show up even when there's no local entries*/}
|
||||
{!isNakamaLibrary && hasLibraryPath && <Button
|
||||
data-library-toolbar-scan-button
|
||||
intent={hasEntries ? "primary-subtle" : "primary"}
|
||||
leftIcon={hasEntries ? <TbReload className="text-xl" /> : <FiSearch className="text-xl" />}
|
||||
onClick={() => setScannerModalOpen(true)}
|
||||
hideTextOnSmallScreen
|
||||
>
|
||||
{hasEntries ? "Refresh library" : "Scan your library"}
|
||||
</Button>}
|
||||
</>
|
||||
)}
|
||||
{(unmatchedLocalFiles.length > 0) && <Button
|
||||
data-library-toolbar-unmatched-button
|
||||
intent="alert"
|
||||
leftIcon={<IoLibrarySharp />}
|
||||
className="animate-bounce"
|
||||
onClick={() => setUnmatchedFileManagerOpen(true)}
|
||||
>
|
||||
Resolve unmatched ({unmatchedLocalFiles.length})
|
||||
</Button>}
|
||||
{(unknownGroups.length > 0) && <Button
|
||||
data-library-toolbar-unknown-button
|
||||
intent="warning"
|
||||
leftIcon={<IoLibrarySharp />}
|
||||
className="animate-bounce"
|
||||
onClick={() => setUnknownMediaManagerOpen(true)}
|
||||
>
|
||||
Resolve hidden media ({unknownGroups.length})
|
||||
</Button>}
|
||||
|
||||
{(!isStreamingOnly && !isNakamaLibrary && hasLibraryPath) &&
|
||||
<DropdownMenu
|
||||
trigger={<IconButton
|
||||
data-library-toolbar-dropdown-menu-trigger
|
||||
icon={<BiDotsVerticalRounded />} intent="gray-basic"
|
||||
/>}
|
||||
>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-open-directory-button
|
||||
disabled={!hasLibraryPath}
|
||||
className={cn("cursor-pointer", { "!text-[--muted]": !hasLibraryPath })}
|
||||
onClick={() => {
|
||||
openInExplorer({ path: status?.settings?.library?.libraryPath ?? "" })
|
||||
}}
|
||||
>
|
||||
<BiFolder />
|
||||
<span>Open directory</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-bulk-actions-button
|
||||
onClick={() => setBulkActionIsOpen(true)}
|
||||
disabled={!hasEntries}
|
||||
className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<BiCollection />
|
||||
<span>Bulk actions</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-ignored-files-button
|
||||
onClick={() => setIgnoredFileManagerOpen(true)}
|
||||
// disabled={!hasEntries}
|
||||
className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<TbFileSad />
|
||||
<span>Ignored files</span>
|
||||
</DropdownMenuItem>
|
||||
|
||||
<SeaLink href="/scan-summaries">
|
||||
<DropdownMenuItem
|
||||
data-library-toolbar-scan-summaries-button
|
||||
// className={cn({ "!text-[--muted]": !hasEntries })}
|
||||
>
|
||||
<PiClockCounterClockwiseFill />
|
||||
<span>Scan summaries</span>
|
||||
</DropdownMenuItem>
|
||||
</SeaLink>
|
||||
|
||||
<PluginAnimeLibraryDropdownItems />
|
||||
</DropdownMenu>}
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,33 @@
|
||||
import { usePlaybackPlayRandomVideo } from "@/api/hooks/playback_manager.hooks"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import React from "react"
|
||||
import { LiaRandomSolid } from "react-icons/lia"
|
||||
|
||||
type PlayRandomEpisodeButtonProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function PlayRandomEpisodeButton(props: PlayRandomEpisodeButtonProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { mutate: playRandom, isPending } = usePlaybackPlayRandomVideo()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
data-play-random-episode-button
|
||||
intent={"white-subtle"}
|
||||
icon={<LiaRandomSolid className="text-2xl" />}
|
||||
loading={isPending}
|
||||
onClick={() => playRandom()}
|
||||
/>}
|
||||
>Play random anime</Tooltip>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,358 @@
|
||||
import { AL_BaseAnime, Anime_LibraryCollectionEntry, Anime_LocalFile } from "@/api/generated/types"
|
||||
import { useGetLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { useGetPlaylistEpisodes } from "@/api/hooks/playlist.hooks"
|
||||
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { DndContext, DragEndEvent } from "@dnd-kit/core"
|
||||
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
|
||||
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
|
||||
import { CSS } from "@dnd-kit/utilities"
|
||||
import { useAtomValue } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiPlus, BiTrash } from "react-icons/bi"
|
||||
import { toast } from "sonner"
|
||||
|
||||
|
||||
type PlaylistManagerProps = {
|
||||
paths: string[]
|
||||
setPaths: (paths: string[]) => void
|
||||
}
|
||||
|
||||
export function PlaylistManager(props: PlaylistManagerProps) {
|
||||
|
||||
const {
|
||||
paths: controlledPaths,
|
||||
setPaths: onChange,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const libraryCollection = useAtomValue(animeLibraryCollectionAtom)
|
||||
|
||||
const { data: localFiles } = useGetLocalFiles()
|
||||
|
||||
const firstRender = React.useRef(true)
|
||||
|
||||
const [paths, setPaths] = React.useState(controlledPaths)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (firstRender.current) {
|
||||
firstRender.current = false
|
||||
return
|
||||
}
|
||||
setPaths(controlledPaths)
|
||||
}, [controlledPaths])
|
||||
|
||||
React.useEffect(() => {
|
||||
onChange(paths)
|
||||
}, [paths])
|
||||
|
||||
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
|
||||
const { active, over } = event
|
||||
|
||||
if (active.id !== over?.id) {
|
||||
setPaths((items) => {
|
||||
const oldIndex = items.indexOf(active.id as any)
|
||||
const newIndex = items.indexOf(over?.id as any)
|
||||
|
||||
return arrayMove(items, oldIndex, newIndex)
|
||||
})
|
||||
}
|
||||
}, [])
|
||||
|
||||
const [selectedCategory, setSelectedCategory] = React.useState("CURRENT")
|
||||
const [searchInput, setSearchInput] = React.useState("")
|
||||
const debouncedSearchInput = useDebounce(searchInput, 500)
|
||||
|
||||
const entries = React.useMemo(() => {
|
||||
if (debouncedSearchInput.length !== 0) return (libraryCollection?.lists
|
||||
?.filter(n => n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT")
|
||||
?.flatMap(n => n.entries)
|
||||
?.filter(Boolean) ?? []).filter(n => n?.media?.title?.english?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()) ||
|
||||
n?.media?.title?.romaji?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()))
|
||||
|
||||
return libraryCollection?.lists?.filter(n => {
|
||||
if (selectedCategory === "-") return n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT"
|
||||
return n.type === selectedCategory
|
||||
})
|
||||
?.flatMap(n => n.entries)
|
||||
?.filter(n => !!n?.libraryData)
|
||||
?.filter(Boolean) ?? []
|
||||
}, [libraryCollection, debouncedSearchInput, selectedCategory])
|
||||
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-2">
|
||||
<Modal
|
||||
title="Select an anime"
|
||||
contentClass="max-w-4xl"
|
||||
trigger={<Button
|
||||
leftIcon={<BiPlus className="text-2xl" />}
|
||||
intent="white"
|
||||
className="rounded-full"
|
||||
disabled={paths.length >= 10}
|
||||
>Add an episode</Button>}
|
||||
>
|
||||
|
||||
<div className="grid grid-cols-[150px,1fr] gap-2">
|
||||
<Select
|
||||
value={selectedCategory}
|
||||
onValueChange={v => setSelectedCategory(v)}
|
||||
options={[
|
||||
{ label: "Current", value: "CURRENT" },
|
||||
{ label: "Paused", value: "PAUSED" },
|
||||
{ label: "Planning", value: "PLANNING" },
|
||||
{ label: "All", value: "-" },
|
||||
]}
|
||||
disabled={searchInput.length !== 0}
|
||||
/>
|
||||
|
||||
<TextInput
|
||||
placeholder="Search"
|
||||
value={searchInput}
|
||||
onChange={e => setSearchInput(e.target.value)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
|
||||
{entries?.map(entry => {
|
||||
return (
|
||||
<PlaylistMediaEntry key={entry.mediaId} entry={entry} paths={paths} setPaths={setPaths} />
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
<DndContext
|
||||
modifiers={[restrictToVerticalAxis]}
|
||||
onDragEnd={handleDragEnd}
|
||||
>
|
||||
<SortableContext
|
||||
strategy={verticalListSortingStrategy}
|
||||
items={paths}
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<ul className="space-y-2">
|
||||
{paths.map(path => localFiles?.find(n => n.path === path))?.filter(Boolean).map((lf, index) => (
|
||||
<SortableItem
|
||||
key={lf.path}
|
||||
id={lf.path}
|
||||
localFile={lf}
|
||||
media={libraryCollection?.lists?.flatMap(n => n.entries)
|
||||
?.filter(Boolean)
|
||||
?.find(n => lf?.mediaId === n.mediaId)?.media}
|
||||
setPaths={setPaths}
|
||||
/>
|
||||
))}
|
||||
</ul>
|
||||
</div>
|
||||
</SortableContext>
|
||||
</DndContext>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
type PlaylistMediaEntryProps = {
|
||||
entry: Anime_LibraryCollectionEntry
|
||||
paths: string[]
|
||||
setPaths: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
function PlaylistMediaEntry(props: PlaylistMediaEntryProps) {
|
||||
const { entry, paths, setPaths } = props
|
||||
return <Modal
|
||||
title={entry.media?.title?.userPreferred || entry.media?.title?.romaji || ""}
|
||||
trigger={(
|
||||
<div
|
||||
key={entry.mediaId}
|
||||
className="col-span-1 aspect-[6/7] rounded-[--radius-md] border overflow-hidden relative transition cursor-pointer bg-[var(--background)] md:opacity-60 md:hover:opacity-100"
|
||||
>
|
||||
<Image
|
||||
src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover"
|
||||
/>
|
||||
<p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]">
|
||||
{entry.media?.title?.userPreferred || entry.media?.title?.romaji}
|
||||
</p>
|
||||
<div
|
||||
className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent"
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
>
|
||||
<EntryEpisodeList
|
||||
selectedPaths={paths}
|
||||
setSelectedPaths={setPaths}
|
||||
entry={entry}
|
||||
/>
|
||||
</Modal>
|
||||
}
|
||||
|
||||
function SortableItem({ localFile, id, media, setPaths }: {
|
||||
id: string,
|
||||
localFile: Anime_LocalFile | undefined,
|
||||
media: AL_BaseAnime | undefined,
|
||||
setPaths: any
|
||||
}) {
|
||||
const {
|
||||
attributes,
|
||||
listeners,
|
||||
setNodeRef,
|
||||
transform,
|
||||
transition,
|
||||
} = useSortable({ id: id })
|
||||
|
||||
const style = {
|
||||
transform: CSS.Transform.toString(transform),
|
||||
transition,
|
||||
}
|
||||
|
||||
if (!localFile) return null
|
||||
|
||||
if (!media) return (
|
||||
<li ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className="px-2.5 py-2 bg-[var(--background)] border-[--red] rounded-[--radius-md] border flex gap-3 relative"
|
||||
|
||||
>
|
||||
<IconButton
|
||||
className="absolute top-2 right-2 rounded-full"
|
||||
icon={<BiTrash />}
|
||||
intent="alert-subtle"
|
||||
size="sm"
|
||||
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
|
||||
|
||||
/>
|
||||
<div
|
||||
|
||||
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
|
||||
{...attributes} {...listeners}
|
||||
/>
|
||||
<div>
|
||||
<p className="text-lg text-white font-semibold">
|
||||
<span>
|
||||
???
|
||||
</span>
|
||||
<span className="text-gray-400 font-medium max-w-lg truncate">
|
||||
</span>
|
||||
</p>
|
||||
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
|
||||
return (
|
||||
<li ref={setNodeRef} style={style}>
|
||||
<div
|
||||
className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"
|
||||
|
||||
>
|
||||
<IconButton
|
||||
className="absolute top-2 right-2 rounded-full"
|
||||
icon={<BiTrash />}
|
||||
intent="alert-subtle"
|
||||
size="sm"
|
||||
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
|
||||
|
||||
/>
|
||||
<div
|
||||
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
|
||||
{...attributes} {...listeners}
|
||||
/>
|
||||
<div
|
||||
|
||||
className="w-16 aspect-square rounded-[--radius-md] border overflow-hidden relative transition bg-[var(--background)]"
|
||||
>
|
||||
<Image
|
||||
src={media?.coverImage?.large || media?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover"
|
||||
|
||||
/>
|
||||
</div>
|
||||
<div>
|
||||
<div className="text-lg text-white font-semibold flex gap-1">
|
||||
{localFile.metadata && <p>
|
||||
{media?.format !== "MOVIE" ? `Episode ${localFile.metadata?.episode}` : "Movie"}
|
||||
</p>}
|
||||
<p className="max-w-full truncate text-gray-400 font-medium max-w-lg truncate">
|
||||
{" - "}{media?.title?.userPreferred || media?.title?.romaji}
|
||||
</p>
|
||||
</div>
|
||||
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
|
||||
</div>
|
||||
</div>
|
||||
</li>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type EntryEpisodeListProps = {
|
||||
entry: Anime_LibraryCollectionEntry
|
||||
selectedPaths: string[]
|
||||
setSelectedPaths: React.Dispatch<React.SetStateAction<string[]>>
|
||||
}
|
||||
|
||||
function EntryEpisodeList(props: EntryEpisodeListProps) {
|
||||
|
||||
const {
|
||||
entry,
|
||||
selectedPaths,
|
||||
setSelectedPaths,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { data } = useGetPlaylistEpisodes(entry.mediaId, entry.listData?.progress || 0)
|
||||
|
||||
const handleSelect = (value: string) => {
|
||||
if (selectedPaths.length <= 10) {
|
||||
setSelectedPaths(prev => {
|
||||
if (prev.includes(value)) {
|
||||
return prev.filter(n => n !== value)
|
||||
}
|
||||
return [...prev, value]
|
||||
})
|
||||
} else {
|
||||
toast.error("You can't add more than 10 episodes to a playlist")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="flex flex-col gap-2 overflow-auto p-1">
|
||||
{data?.filter(n => !!n.metadata)?.sort((a, b) => a.metadata!.episode - b.metadata!.episode)?.map(lf => {
|
||||
return (
|
||||
<div
|
||||
key={lf.path}
|
||||
className={cn(
|
||||
"px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border cursor-pointer opacity-80 max-w-full",
|
||||
selectedPaths.includes(lf.path) ? "bg-gray-800 opacity-100 text-white ring-1 ring-[--zinc]" : "hover:bg-[--subtle]",
|
||||
"transition",
|
||||
)}
|
||||
onClick={() => handleSelect(lf.path)}
|
||||
>
|
||||
<p className="">{entry.media?.format !== "MOVIE" ? `Episode ${lf.metadata!.episode}` : "Movie"}</p>
|
||||
<p className="text-sm text-[--muted] font-normal italic max-w-lg line-clamp-1">{lf.name}</p>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,109 @@
|
||||
import { Anime_Playlist } from "@/api/generated/types"
|
||||
import { useCreatePlaylist, useDeletePlaylist, useUpdatePlaylist } from "@/api/hooks/playlist.hooks"
|
||||
import { PlaylistManager } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-manager"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { DangerZone } from "@/components/ui/form"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type PlaylistModalProps = {
|
||||
playlist?: Anime_Playlist
|
||||
trigger: React.ReactElement
|
||||
}
|
||||
|
||||
export function PlaylistModal(props: PlaylistModalProps) {
|
||||
|
||||
const {
|
||||
playlist,
|
||||
trigger,
|
||||
} = props
|
||||
|
||||
const [isOpen, setIsOpen] = React.useState(false)
|
||||
const [name, setName] = React.useState(playlist?.name ?? "")
|
||||
const [paths, setPaths] = React.useState<string[]>(playlist?.localFiles?.map(l => l.path) ?? [])
|
||||
|
||||
const isUpdate = !!playlist
|
||||
|
||||
const { mutate: createPlaylist, isPending: isCreating } = useCreatePlaylist()
|
||||
|
||||
const { mutate: deletePlaylist, isPending: isDeleting } = useDeletePlaylist()
|
||||
|
||||
const { mutate: updatePlaylist, isPending: isUpdating } = useUpdatePlaylist()
|
||||
|
||||
function reset() {
|
||||
setName("")
|
||||
setPaths([])
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isUpdate && !!playlist && !!playlist.localFiles) {
|
||||
setName(playlist.name)
|
||||
setPaths(playlist.localFiles.map(l => l.path))
|
||||
}
|
||||
}, [playlist, isOpen])
|
||||
|
||||
function handleSubmit() {
|
||||
if (name.length === 0) {
|
||||
toast.error("Please enter a name for the playlist")
|
||||
return
|
||||
}
|
||||
if (isUpdate && !!playlist) {
|
||||
updatePlaylist({ dbId: playlist.dbId, name, paths })
|
||||
} else {
|
||||
setIsOpen(false)
|
||||
createPlaylist({ name, paths }, {
|
||||
onSuccess: () => {
|
||||
reset()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title={isUpdate ? "Edit playlist" : "Create a playlist"}
|
||||
trigger={trigger}
|
||||
open={isOpen}
|
||||
onOpenChange={v => setIsOpen(v)}
|
||||
contentClass="max-w-4xl"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="space-y-4">
|
||||
<TextInput
|
||||
label="Name"
|
||||
value={name}
|
||||
onChange={e => setName(e.target.value)}
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<PlaylistManager
|
||||
paths={paths}
|
||||
setPaths={setPaths}
|
||||
/>
|
||||
<div className="">
|
||||
<Button disabled={paths.length === 0} onClick={handleSubmit} loading={isCreating || isDeleting || isUpdating}>
|
||||
{isUpdate ? "Update" : "Create"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{isUpdate && <DangerZone
|
||||
actionText="Delete playlist" onDelete={() => {
|
||||
if (isUpdate && !!playlist) {
|
||||
deletePlaylist({ dbId: playlist.dbId }, {
|
||||
onSuccess: () => {
|
||||
setIsOpen(false)
|
||||
},
|
||||
})
|
||||
}
|
||||
}}
|
||||
/>}
|
||||
</div>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,123 @@
|
||||
import { useGetPlaylists } from "@/api/hooks/playlist.hooks"
|
||||
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
|
||||
import { StartPlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal"
|
||||
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
|
||||
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
|
||||
import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { useAtomValue, useSetAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiEditAlt } from "react-icons/bi"
|
||||
import { FaCirclePlay } from "react-icons/fa6"
|
||||
import { MdOutlineVideoLibrary } from "react-icons/md"
|
||||
|
||||
type PlaylistsListProps = {}
|
||||
|
||||
export function PlaylistsList(props: PlaylistsListProps) {
|
||||
|
||||
const {} = props
|
||||
|
||||
const { data: playlists, isLoading } = useGetPlaylists()
|
||||
const userMedia = useAtomValue(__anilist_userAnimeMediaAtom)
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const setOpen = useSetAtom(__playlists_modalOpenAtom)
|
||||
|
||||
const handlePlaylistLoaded = React.useCallback(() => {
|
||||
setOpen(false)
|
||||
}, [])
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
|
||||
if (!playlists?.length) {
|
||||
return (
|
||||
<div className="text-center text-[--muted] space-y-1">
|
||||
<MdOutlineVideoLibrary className="mx-auto text-5xl text-[--muted]" />
|
||||
<div>
|
||||
No playlists
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
// <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
|
||||
<Carousel
|
||||
className="w-full max-w-full"
|
||||
gap="none"
|
||||
opts={{
|
||||
align: "start",
|
||||
}}
|
||||
>
|
||||
<CarouselDotButtons />
|
||||
<CarouselContent>
|
||||
{playlists.map(p => {
|
||||
|
||||
const mainMedia = userMedia?.find(m => m.id === p.localFiles?.[0]?.mediaId)
|
||||
|
||||
return (
|
||||
<CarouselItem
|
||||
key={p.dbId}
|
||||
className={cn(
|
||||
"md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 min-[2000px]:basis-1/6",
|
||||
"aspect-[7/6] p-2",
|
||||
)}
|
||||
// onClick={() => handleSelect(lf.path)}
|
||||
>
|
||||
<div className="group/playlist-item flex gap-3 h-full justify-between items-center bg-gray-950 rounded-[--radius-md] transition relative overflow-hidden">
|
||||
{(mainMedia?.coverImage?.large || mainMedia?.bannerImage) && <Image
|
||||
src={mainMedia?.coverImage?.extraLarge || mainMedia?.bannerImage || ""}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
fill
|
||||
alt=""
|
||||
className="object-center object-cover z-[1]"
|
||||
/>}
|
||||
|
||||
<div className="absolute inset-0 z-[2] bg-gray-900 opacity-50 hover:opacity-70 transition-opacity flex items-center justify-center" />
|
||||
<div className="absolute inset-0 z-[6] flex items-center justify-center">
|
||||
<StartPlaylistModal
|
||||
canStart={serverStatus?.settings?.library?.autoUpdateProgress}
|
||||
playlist={p}
|
||||
onPlaylistLoaded={handlePlaylistLoaded}
|
||||
trigger={
|
||||
<FaCirclePlay className="block text-5xl cursor-pointer opacity-50 hover:opacity-100 transition-opacity" />}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute top-2 right-2 z-[6] flex items-center justify-center">
|
||||
<PlaylistModal
|
||||
trigger={<Button
|
||||
className="w-full flex-none rounded-full"
|
||||
leftIcon={<BiEditAlt />}
|
||||
intent="white-subtle"
|
||||
size="sm"
|
||||
|
||||
>Edit</Button>} playlist={p}
|
||||
/>
|
||||
</div>
|
||||
<div className="absolute w-full bottom-0 h-fit z-[6]">
|
||||
<div className="space-y-0 pb-3 items-center">
|
||||
<p className="text-md font-bold text-white max-w-lg truncate text-center">{p.name}</p>
|
||||
{p.localFiles &&
|
||||
<p className="text-sm text-[--muted] font-normal line-clamp-1 text-center">{p.localFiles.length} episode{p.localFiles.length > 1
|
||||
? `s`
|
||||
: ""}</p>}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<MediaCardBodyBottomGradient />
|
||||
</div>
|
||||
</CarouselItem>
|
||||
)
|
||||
})}
|
||||
</CarouselContent>
|
||||
</Carousel>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,60 @@
|
||||
import { Anime_Playlist } from "@/api/generated/types"
|
||||
import { usePlaybackStartPlaylist } from "@/api/hooks/playback_manager.hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import React from "react"
|
||||
import { FaPlay } from "react-icons/fa"
|
||||
|
||||
type StartPlaylistModalProps = {
|
||||
trigger?: React.ReactElement
|
||||
playlist: Anime_Playlist
|
||||
canStart?: boolean
|
||||
onPlaylistLoaded: () => void
|
||||
}
|
||||
|
||||
export function StartPlaylistModal(props: StartPlaylistModalProps) {
|
||||
|
||||
const {
|
||||
trigger,
|
||||
playlist,
|
||||
canStart,
|
||||
onPlaylistLoaded,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { mutate: startPlaylist, isPending } = usePlaybackStartPlaylist({
|
||||
onSuccess: onPlaylistLoaded,
|
||||
})
|
||||
|
||||
if (!playlist?.localFiles?.length) return null
|
||||
|
||||
return (
|
||||
<Modal
|
||||
title="Start playlist"
|
||||
titleClass="text-center"
|
||||
trigger={trigger}
|
||||
>
|
||||
<p className="text-center">
|
||||
You are about to start the playlist <strong>"{playlist.name}"</strong>,
|
||||
which contains {playlist.localFiles.length} episode{playlist.localFiles.length > 1 ? "s" : ""}.
|
||||
</p>
|
||||
<p className="text-[--muted] text-center">
|
||||
Reminder: The playlist will be deleted once you start it, whether you finish it or not.
|
||||
</p>
|
||||
{!canStart && (
|
||||
<p className="text-orange-300 text-center">
|
||||
Please enable "Automatically update progress" to start
|
||||
</p>
|
||||
)}
|
||||
<Button
|
||||
className="w-full flex-none"
|
||||
leftIcon={<FaPlay />}
|
||||
intent="primary"
|
||||
size="lg"
|
||||
loading={isPending}
|
||||
disabled={!canStart}
|
||||
onClick={() => startPlaylist({ dbId: playlist.dbId })}
|
||||
>Start</Button>
|
||||
</Modal>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,75 @@
|
||||
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
|
||||
import { PlaylistsList } from "@/app/(main)/(library)/_containers/playlists/_components/playlists-list"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
|
||||
type PlaylistsModalProps = {}
|
||||
|
||||
export const __playlists_modalOpenAtom = atom(false)
|
||||
|
||||
export function PlaylistsModal(props: PlaylistsModalProps) {
|
||||
|
||||
const {} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const [isOpen, setIsOpen] = useAtom(__playlists_modalOpenAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={v => setIsOpen(v)}
|
||||
size="lg"
|
||||
side="bottom"
|
||||
contentClass=""
|
||||
>
|
||||
{/*<div*/}
|
||||
{/* className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover"*/}
|
||||
{/*>*/}
|
||||
{/* <div*/}
|
||||
{/* className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/}
|
||||
{/* />*/}
|
||||
{/*</div>*/}
|
||||
|
||||
<div className="space-y-6">
|
||||
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
|
||||
<div>
|
||||
<h4>Playlists</h4>
|
||||
<p className="text-[--muted] text-sm">
|
||||
Playlists only work with system media players and local files.
|
||||
</p>
|
||||
</div>
|
||||
<div className="flex gap-2 items-center md:pr-8">
|
||||
<PlaylistModal
|
||||
trigger={
|
||||
<Button intent="white" className="rounded-full">
|
||||
Add a playlist
|
||||
</Button>
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{!serverStatus?.settings?.library?.autoUpdateProgress && <Alert
|
||||
className="max-w-2xl mx-auto"
|
||||
intent="warning"
|
||||
description={<>
|
||||
<p>
|
||||
You need to enable "Automatically update progress" to use playlists.
|
||||
</p>
|
||||
</>}
|
||||
/>}
|
||||
|
||||
<div className="">
|
||||
<PlaylistsList />
|
||||
</div>
|
||||
</div>
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,70 @@
|
||||
"use client"
|
||||
import { __scanner_isScanningAtom } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { Card, CardDescription, CardHeader } from "@/components/ui/card"
|
||||
import { Spinner } from "@/components/ui/loading-spinner"
|
||||
import { ProgressBar } from "@/components/ui/progress-bar"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React, { useState } from "react"
|
||||
|
||||
export function ScanProgressBar() {
|
||||
|
||||
const [isScanning] = useAtom(__scanner_isScanningAtom)
|
||||
|
||||
const [progress, setProgress] = useState(0)
|
||||
const [status, setStatus] = useState("Scanning...")
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isScanning) {
|
||||
setProgress(0)
|
||||
setStatus("Scanning...")
|
||||
}
|
||||
}, [isScanning])
|
||||
|
||||
useWebsocketMessageListener<number>({
|
||||
type: WSEvents.SCAN_PROGRESS,
|
||||
onMessage: data => {
|
||||
console.log("Scan progress", data)
|
||||
setProgress(data)
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.SCAN_STATUS,
|
||||
onMessage: data => {
|
||||
console.log("Scan status", data)
|
||||
setStatus(data)
|
||||
},
|
||||
})
|
||||
|
||||
if (!isScanning) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]" data-scan-progress-bar-container>
|
||||
<ProgressBar size="xs" value={progress} />
|
||||
</div>
|
||||
{/*<div className="fixed left-0 top-8 w-full flex justify-center z-[100]">*/}
|
||||
{/* <div className="bg-gray-900 rounded-full border h-14 px-6 flex gap-2 items-center">*/}
|
||||
{/* <Spinner className="w-4 h-4" />*/}
|
||||
{/* <p>{progress}% - {status}</p>*/}
|
||||
{/* </div>*/}
|
||||
{/*</div>*/}
|
||||
<div className="z-50 fixed bottom-4 right-4" data-scan-progress-bar-card-container>
|
||||
<PageWrapper>
|
||||
<Card className="w-fit max-w-[400px] relative" data-scan-progress-bar-card>
|
||||
<CardHeader>
|
||||
<CardDescription className="flex items-center gap-2 text-base text-[--foregorund]">
|
||||
<Spinner className="size-6" /> {progress}% - {status}
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,181 @@
|
||||
import { useScanLocalFiles } from "@/api/hooks/scan.hooks"
|
||||
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
|
||||
|
||||
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { GlowingEffect } from "@/components/shared/glowing-effect"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { useBoolean } from "@/hooks/use-disclosure"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
|
||||
export const __scanner_modalIsOpen = atom(false)
|
||||
export const __scanner_isScanningAtom = atom(false)
|
||||
|
||||
|
||||
export function ScannerModal() {
|
||||
const serverStatus = useServerStatus()
|
||||
const [isOpen, setOpen] = useAtom(__scanner_modalIsOpen)
|
||||
const [, setScannerIsScanning] = useAtom(__scanner_isScanningAtom)
|
||||
const [userMedia] = useAtom(__anilist_userAnimeMediaAtom)
|
||||
const anilistDataOnly = useBoolean(true)
|
||||
const skipLockedFiles = useBoolean(true)
|
||||
const skipIgnoredFiles = useBoolean(true)
|
||||
|
||||
const { mutate: scanLibrary, isPending: isScanning } = useScanLocalFiles(() => {
|
||||
setOpen(false)
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!userMedia?.length) anilistDataOnly.off()
|
||||
else anilistDataOnly.on()
|
||||
}, [userMedia])
|
||||
|
||||
React.useEffect(() => {
|
||||
setScannerIsScanning(isScanning)
|
||||
}, [isScanning])
|
||||
|
||||
function handleScan() {
|
||||
scanLibrary({
|
||||
enhanced: !anilistDataOnly.active,
|
||||
skipLockedFiles: skipLockedFiles.active,
|
||||
skipIgnoredFiles: skipIgnoredFiles.active,
|
||||
})
|
||||
setOpen(false)
|
||||
}
|
||||
|
||||
const { inject, remove } = useSeaCommandInject()
|
||||
React.useEffect(() => {
|
||||
inject("scanner-controls", {
|
||||
priority: 1,
|
||||
items: [{
|
||||
id: "refresh",
|
||||
value: "refresh",
|
||||
heading: "Library",
|
||||
render: () => (
|
||||
<p>Refresh library</p>
|
||||
),
|
||||
onSelect: ({ ctx }) => {
|
||||
ctx.close()
|
||||
setTimeout(() => {
|
||||
handleScan()
|
||||
}, 500)
|
||||
},
|
||||
showBasedOnInput: "startsWith",
|
||||
}],
|
||||
filter: ({ item, input }) => {
|
||||
if (!input) return true
|
||||
return item.value.toLowerCase().includes(input.toLowerCase())
|
||||
},
|
||||
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
|
||||
showBasedOnInput: "startsWith",
|
||||
})
|
||||
|
||||
return () => remove("scanner-controls")
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
data-scanner-modal
|
||||
open={isOpen}
|
||||
onOpenChange={o => {
|
||||
// if (!isScanning) {
|
||||
// setOpen(o)
|
||||
// }
|
||||
setOpen(o)
|
||||
}}
|
||||
// title="Library scanner"
|
||||
titleClass="text-center"
|
||||
contentClass="space-y-4 max-w-2xl bg-gray-950 bg-opacity-70 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none rounded-xl"
|
||||
overlayClass="bg-gray-950/70 backdrop-blur-sm"
|
||||
>
|
||||
<GlowingEffect
|
||||
spread={50}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
// movementDuration={4}
|
||||
className="!mt-0 opacity-30"
|
||||
/>
|
||||
|
||||
{/* <div
|
||||
data-scanner-modal-top-pattern
|
||||
className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-40 top-0 left-0 bg-no-repeat bg-right bg-cover"
|
||||
>
|
||||
<div
|
||||
className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"
|
||||
/>
|
||||
</div> */}
|
||||
|
||||
{serverStatus?.user?.isSimulated && <div className="border border-dashed rounded-md py-2 px-4 !mt-5">
|
||||
Using this feature without an AniList account is not recommended if you have a large library, as it may lead to rate limits and
|
||||
slower scanning. Please consider using an account for a better experience.
|
||||
</div>}
|
||||
|
||||
<div className="space-y-4" data-scanner-modal-content>
|
||||
|
||||
<AppLayoutStack className="space-y-2">
|
||||
<h5 className="text-[--muted]">Local files</h5>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Skip locked files"
|
||||
value={skipLockedFiles.active}
|
||||
onValueChange={v => skipLockedFiles.set(v as boolean)}
|
||||
// size="lg"
|
||||
/>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Skip ignored files"
|
||||
value={skipIgnoredFiles.active}
|
||||
onValueChange={v => skipIgnoredFiles.set(v as boolean)}
|
||||
// size="lg"
|
||||
/>
|
||||
|
||||
<Separator />
|
||||
|
||||
<AppLayoutStack className="space-y-2">
|
||||
<h5 className="text-[--muted]">Matching data</h5>
|
||||
<Switch
|
||||
side="right"
|
||||
label="Use my AniList lists only"
|
||||
moreHelp="Disabling this will cause Seanime to send more API requests which may lead to rate limits and slower scanning"
|
||||
// label="Enhanced scanning"
|
||||
value={anilistDataOnly.active}
|
||||
onValueChange={v => anilistDataOnly.set(v as boolean)}
|
||||
// className="data-[state=checked]:bg-amber-700 dark:data-[state=checked]:bg-amber-700"
|
||||
// size="lg"
|
||||
help={!anilistDataOnly.active
|
||||
? <span><span className="text-[--orange]">Slower for large libraries</span>. For faster scanning, add the anime
|
||||
entries present in your library to your
|
||||
lists and re-enable this before
|
||||
scanning.</span>
|
||||
: ""}
|
||||
disabled={!userMedia?.length}
|
||||
/>
|
||||
</AppLayoutStack>
|
||||
|
||||
</AppLayoutStack>
|
||||
</div>
|
||||
<Button
|
||||
onClick={handleScan}
|
||||
intent="primary"
|
||||
leftIcon={<FiSearch />}
|
||||
loading={isScanning}
|
||||
className="w-full"
|
||||
disabled={!serverStatus?.settings?.library?.libraryPath}
|
||||
>
|
||||
Scan
|
||||
</Button>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { Anime_UnknownGroup } from "@/api/generated/types"
|
||||
import { useAddUnknownMedia } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React, { useCallback } from "react"
|
||||
import { BiLinkExternal, BiPlus } from "react-icons/bi"
|
||||
import { TbDatabasePlus } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __unknownMedia_drawerIsOpen = atom(false)
|
||||
|
||||
type UnknownMediaManagerProps = {
|
||||
unknownGroups: Anime_UnknownGroup[]
|
||||
}
|
||||
|
||||
export function UnknownMediaManager(props: UnknownMediaManagerProps) {
|
||||
|
||||
const { unknownGroups } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__unknownMedia_drawerIsOpen)
|
||||
|
||||
const { mutate: addUnknownMedia, isPending: isAdding } = useAddUnknownMedia()
|
||||
const { mutate: performBulkAction, isPending: isUnmatching } = useAnimeEntryBulkAction()
|
||||
|
||||
/**
|
||||
* Add all unknown media to AniList
|
||||
*/
|
||||
const handleAddUnknownMedia = useCallback(() => {
|
||||
addUnknownMedia({ mediaIds: unknownGroups.map(n => n.mediaId) })
|
||||
}, [unknownGroups])
|
||||
|
||||
/**
|
||||
* Close the drawer if there are no unknown groups
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (unknownGroups.length === 0) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [unknownGroups])
|
||||
|
||||
/**
|
||||
* Unmatch all files for a media
|
||||
*/
|
||||
const handleUnmatchMedia = useCallback((mediaId: number) => {
|
||||
performBulkAction({
|
||||
mediaId,
|
||||
action: "unmatch",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Media unmatched")
|
||||
},
|
||||
})
|
||||
}, [])
|
||||
|
||||
if (unknownGroups.length === 0) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
data-unknown-media-manager-drawer
|
||||
open={isOpen}
|
||||
onOpenChange={o => {
|
||||
if (!isAdding) {
|
||||
setIsOpen(o)
|
||||
}
|
||||
}}
|
||||
size="xl"
|
||||
title="Resolve hidden media"
|
||||
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
<p className="">
|
||||
Seanime matched {unknownGroups.length} group{unknownGroups.length === 1 ? "" : "s"} to media that {unknownGroups.length === 1
|
||||
? "is"
|
||||
: "are"} absent from your
|
||||
AniList collection.<br />
|
||||
Add the media to be able to see the entry in your library or unmatch them if incorrect.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
leftIcon={<TbDatabasePlus />}
|
||||
onClick={handleAddUnknownMedia}
|
||||
loading={isAdding}
|
||||
disabled={isUnmatching}
|
||||
>
|
||||
Add all to AniList
|
||||
</Button>
|
||||
|
||||
<div className="divide divide-y divide-[--border] space-y-4">
|
||||
|
||||
{unknownGroups.map(group => {
|
||||
return (
|
||||
<div key={group.mediaId} className="pt-4 space-y-2">
|
||||
<div className="flex items-center w-full justify-between">
|
||||
<h4 className="font-semibold flex gap-2 items-center">
|
||||
<span>Anilist ID:{" "}</span>
|
||||
<SeaLink
|
||||
href={`https://anilist.co/anime/${group.mediaId}`}
|
||||
target="_blank"
|
||||
className="underline text-brand-200 flex gap-1.5 items-center"
|
||||
>
|
||||
{group.mediaId} <BiLinkExternal />
|
||||
</SeaLink>
|
||||
</h4>
|
||||
<div className="flex gap-2 items-center">
|
||||
<Button
|
||||
size="sm"
|
||||
intent="primary-subtle"
|
||||
disabled={isAdding}
|
||||
onClick={() => addUnknownMedia({ mediaIds: [group.mediaId] })}
|
||||
leftIcon={<BiPlus />}
|
||||
>
|
||||
Add to AniList
|
||||
</Button>
|
||||
<Button
|
||||
size="sm"
|
||||
intent="warning-subtle"
|
||||
disabled={isUnmatching}
|
||||
onClick={() => handleUnmatchMedia(group.mediaId)}
|
||||
>
|
||||
Unmatch
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
<div className="bg-gray-900 border p-2 px-2 rounded-[--radius-md] space-y-1 max-h-40 max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
{group.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
|
||||
.map(lf => {
|
||||
return <p key={lf.path} className="text-[--muted] line-clamp-1 tracking-wide">
|
||||
{lf.parsedInfo?.original || lf.path}
|
||||
</p>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
})}
|
||||
</div>
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,374 @@
|
||||
import { Anime_UnmatchedGroup } from "@/api/generated/types"
|
||||
import { useAnimeEntryManualMatch, useFetchAnimeEntrySuggestions } from "@/api/hooks/anime_entries.hooks"
|
||||
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
|
||||
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { NumberInput } from "@/components/ui/number-input"
|
||||
import { RadioGroup } from "@/components/ui/radio-group"
|
||||
import { upath } from "@/lib/helpers/upath"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { FaArrowLeft, FaArrowRight } from "react-icons/fa"
|
||||
import { FcFolder } from "react-icons/fc"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { TbFileSad } from "react-icons/tb"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __unmatchedFileManagerIsOpen = atom(false)
|
||||
|
||||
type UnmatchedFileManagerProps = {
|
||||
unmatchedGroups: Anime_UnmatchedGroup[]
|
||||
}
|
||||
|
||||
export function UnmatchedFileManager(props: UnmatchedFileManagerProps) {
|
||||
|
||||
const { unmatchedGroups } = props
|
||||
|
||||
const [isOpen, setIsOpen] = useAtom(__unmatchedFileManagerIsOpen)
|
||||
const [page, setPage] = React.useState(0)
|
||||
const maxPage = unmatchedGroups.length - 1
|
||||
const [currentGroup, setCurrentGroup] = React.useState(unmatchedGroups?.[0])
|
||||
|
||||
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
|
||||
|
||||
const [anilistId, setAnilistId] = React.useState(0)
|
||||
|
||||
const { mutate: openInExplorer } = useOpenInExplorer()
|
||||
|
||||
const {
|
||||
data: suggestions,
|
||||
mutate: fetchSuggestions,
|
||||
isPending: suggestionsLoading,
|
||||
reset: resetSuggestions,
|
||||
} = useFetchAnimeEntrySuggestions()
|
||||
|
||||
const { mutate: updateLocalFiles, isPending: isUpdatingFile } = useUpdateLocalFiles()
|
||||
|
||||
const { mutate: manualMatch, isPending: isMatching } = useAnimeEntryManualMatch()
|
||||
|
||||
const isUpdating = isUpdatingFile || isMatching
|
||||
|
||||
const [_r, setR] = React.useState(0)
|
||||
|
||||
const handleSelectAnime = React.useCallback((value: string | null) => {
|
||||
if (value && !isNaN(Number(value))) {
|
||||
setAnilistId(Number(value))
|
||||
setR(r => r + 1)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Reset the selected paths when the current group changes
|
||||
React.useLayoutEffect(() => {
|
||||
setSelectedPaths(currentGroup?.localFiles?.map(lf => lf.path) ?? [])
|
||||
}, [currentGroup])
|
||||
|
||||
// Reset the current group and page when the drawer is opened
|
||||
React.useEffect(() => {
|
||||
setPage(0)
|
||||
setCurrentGroup(unmatchedGroups[0])
|
||||
}, [isOpen, unmatchedGroups])
|
||||
|
||||
// Set the current group when the page changes
|
||||
React.useEffect(() => {
|
||||
setCurrentGroup(unmatchedGroups[page])
|
||||
setAnilistId(0)
|
||||
resetSuggestions()
|
||||
}, [page, unmatchedGroups])
|
||||
|
||||
const AnilistIdInput = React.useCallback(() => {
|
||||
return <NumberInput
|
||||
value={anilistId}
|
||||
onValueChange={v => setAnilistId(v)}
|
||||
formatOptions={{
|
||||
useGrouping: false,
|
||||
}}
|
||||
/>
|
||||
}, [currentGroup?.dir, _r])
|
||||
|
||||
function onActionSuccess() {
|
||||
if (page === 0 && unmatchedGroups.length === 1) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
setAnilistId(0)
|
||||
resetSuggestions()
|
||||
setPage(0)
|
||||
setCurrentGroup(unmatchedGroups[0])
|
||||
}
|
||||
|
||||
/**
|
||||
* Manually match the current group with the specified Anilist ID.
|
||||
* If the current group is the last group and there are no more unmatched groups, close the drawer.
|
||||
*/
|
||||
function handleMatchSelected() {
|
||||
if (!!currentGroup && anilistId > 0 && selectedPaths.length > 0) {
|
||||
manualMatch({
|
||||
paths: selectedPaths,
|
||||
mediaId: anilistId,
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onActionSuccess()
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const handleFetchSuggestions = React.useCallback(() => {
|
||||
fetchSuggestions({
|
||||
dir: currentGroup.dir,
|
||||
})
|
||||
}, [currentGroup?.dir, fetchSuggestions])
|
||||
|
||||
function handleIgnoreSelected() {
|
||||
if (selectedPaths.length > 0) {
|
||||
updateLocalFiles({
|
||||
paths: selectedPaths,
|
||||
action: "ignore",
|
||||
}, {
|
||||
onSuccess: () => {
|
||||
onActionSuccess()
|
||||
toast.success("Files ignored")
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!currentGroup) {
|
||||
setIsOpen(false)
|
||||
}
|
||||
}, [currentGroup])
|
||||
|
||||
if (!currentGroup) return null
|
||||
|
||||
return (
|
||||
<Drawer
|
||||
data-unmatched-file-manager-drawer
|
||||
open={isOpen}
|
||||
onOpenChange={() => setIsOpen(false)}
|
||||
// contentClass="max-w-5xl"
|
||||
size="xl"
|
||||
title="Unmatched files"
|
||||
>
|
||||
<AppLayoutStack className="mt-4">
|
||||
|
||||
<div className={cn("flex w-full justify-between", { "hidden": unmatchedGroups.length <= 1 })}>
|
||||
<Button
|
||||
intent="gray-subtle"
|
||||
leftIcon={<FaArrowLeft />}
|
||||
disabled={page === 0}
|
||||
onClick={() => {
|
||||
setPage(p => p - 1)
|
||||
}}
|
||||
className={cn("transition-opacity", { "opacity-0": page === 0 })}
|
||||
>Previous</Button>
|
||||
|
||||
<p>
|
||||
{page + 1} / {maxPage + 1}
|
||||
</p>
|
||||
|
||||
<Button
|
||||
intent="gray-subtle"
|
||||
rightIcon={<FaArrowRight />}
|
||||
disabled={page >= maxPage}
|
||||
onClick={() => {
|
||||
setPage(p => p + 1)
|
||||
}}
|
||||
className={cn("transition-opacity", { "opacity-0": page >= maxPage })}
|
||||
>Next</Button>
|
||||
</div>
|
||||
|
||||
<div
|
||||
className="bg-gray-900 border p-2 px-4 rounded-[--radius-md] line-clamp-1 flex gap-2 items-center cursor-pointer transition hover:bg-opacity-80"
|
||||
onClick={() => openInExplorer({
|
||||
path: currentGroup.dir,
|
||||
})}
|
||||
>
|
||||
<FcFolder className="text-2xl" />
|
||||
{currentGroup.dir}
|
||||
</div>
|
||||
|
||||
<div className="flex items-center flex-wrap gap-2">
|
||||
<div className="flex gap-2 items-center w-full">
|
||||
<p className="flex-none text-lg mr-2 font-semibold">Anilist ID</p>
|
||||
<AnilistIdInput />
|
||||
<Button
|
||||
intent="white"
|
||||
onClick={handleMatchSelected}
|
||||
disabled={isUpdating}
|
||||
>Match selection</Button>
|
||||
</div>
|
||||
|
||||
{/*<div className="flex flex-1">*/}
|
||||
{/*</div>*/}
|
||||
</div>
|
||||
|
||||
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[50vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
|
||||
|
||||
<div className="p-2">
|
||||
<Checkbox
|
||||
label={`Select all files`}
|
||||
value={(selectedPaths.length === currentGroup?.localFiles?.length) ? true : (selectedPaths.length === 0
|
||||
? false
|
||||
: "indeterminate")}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (draft.length === currentGroup?.localFiles?.length) {
|
||||
return []
|
||||
} else {
|
||||
return currentGroup?.localFiles?.map(lf => lf.path) ?? []
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
{currentGroup.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
|
||||
.map((lf, index) => (
|
||||
<div
|
||||
key={`${lf.path}-${index}`}
|
||||
className="p-2 "
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
label={`${upath.basename(lf.path)}`}
|
||||
value={selectedPaths.includes(lf.path)}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
setSelectedPaths(draft => {
|
||||
if (checked) {
|
||||
return [...draft, lf.path]
|
||||
} else {
|
||||
return draft.filter(p => p !== lf.path)
|
||||
}
|
||||
})
|
||||
}
|
||||
}}
|
||||
labelClass="text-sm tracking-wide data-[checked=false]:opacity-50"
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
|
||||
{/*<Separator />*/}
|
||||
|
||||
{/*<Separator />*/}
|
||||
|
||||
|
||||
<div className="flex flex-wrap items-center gap-1">
|
||||
<Button
|
||||
leftIcon={<FiSearch />}
|
||||
intent="primary-subtle"
|
||||
onClick={handleFetchSuggestions}
|
||||
>
|
||||
Fetch suggestions
|
||||
</Button>
|
||||
|
||||
<SeaLink
|
||||
target="_blank"
|
||||
href={`https://anilist.co/search/anime?search=${encodeURIComponent(currentGroup?.localFiles?.[0]?.parsedInfo?.title || currentGroup?.localFiles?.[0]?.parsedFolderInfo?.[0]?.title || "")}`}
|
||||
>
|
||||
<Button
|
||||
intent="white-link"
|
||||
>
|
||||
Search on AniList
|
||||
</Button>
|
||||
</SeaLink>
|
||||
|
||||
<div className="flex flex-1"></div>
|
||||
|
||||
<Button
|
||||
leftIcon={<TbFileSad className="text-lg" />}
|
||||
intent="warning-subtle"
|
||||
size="sm"
|
||||
rounded
|
||||
disabled={isUpdating}
|
||||
onClick={handleIgnoreSelected}
|
||||
>
|
||||
Ignore selection
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
{suggestionsLoading && <LoadingSpinner />}
|
||||
|
||||
{(!suggestionsLoading && !!suggestions?.length) && <RadioGroup
|
||||
defaultValue="1"
|
||||
fieldClass="w-full"
|
||||
fieldLabelClass="text-md"
|
||||
label="Select Anime"
|
||||
value={String(anilistId)}
|
||||
onValueChange={handleSelectAnime}
|
||||
options={suggestions?.map((media) => (
|
||||
{
|
||||
label: <div>
|
||||
<p className="text-base md:text-md font-medium !-mt-1.5 line-clamp-1">{media.title?.userPreferred || media.title?.english || media.title?.romaji || "N/A"}</p>
|
||||
<div className="mt-2 flex w-full gap-4">
|
||||
{media.coverImage?.medium && <div
|
||||
className="h-28 w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src={media.coverImage.medium}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="10rem"
|
||||
className="object-cover object-center"
|
||||
/>
|
||||
</div>}
|
||||
<div className="text-[--muted]">
|
||||
<p>Type: <span
|
||||
className="text-gray-200 font-semibold"
|
||||
>{media.format}</span>
|
||||
</p>
|
||||
<p>Aired: {media.startDate?.year ? new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
}).format(new Date(media.startDate?.year || 0, media.startDate?.month || 0)) : "-"}</p>
|
||||
<p>Status: {media.status}</p>
|
||||
<SeaLink href={`https://anilist.co/anime/${media.id}`} target="_blank">
|
||||
<Button
|
||||
intent="primary-link"
|
||||
size="sm"
|
||||
className="px-0"
|
||||
>Open on AniList</Button>
|
||||
</SeaLink>
|
||||
</div>
|
||||
</div>
|
||||
</div>,
|
||||
value: String(media.id) || "",
|
||||
}
|
||||
))}
|
||||
stackClass="grid grid-cols-1 md:grid-cols-2 gap-2 space-y-0"
|
||||
itemContainerClass={cn(
|
||||
"items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full",
|
||||
"bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900",
|
||||
"data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950",
|
||||
"focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition",
|
||||
"border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0",
|
||||
)}
|
||||
itemClass={cn(
|
||||
"border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent",
|
||||
"data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent",
|
||||
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent",
|
||||
)}
|
||||
itemIndicatorClass="hidden"
|
||||
itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer"
|
||||
/>}
|
||||
|
||||
</AppLayoutStack>
|
||||
</Drawer>
|
||||
)
|
||||
|
||||
}
|
||||
Reference in New Issue
Block a user