node build fixed

This commit is contained in:
ra_ma
2025-09-20 14:08:38 +01:00
parent c6ebbe069d
commit 3d298fa434
1516 changed files with 535727 additions and 2 deletions

View File

@@ -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>
)
}

View File

@@ -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>&nbsp;-&nbsp;
{!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) : ""))
})
}
}}
/>
)
})

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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"}
/>
)
})

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
</>
)
}

View File

@@ -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>
)
}

View File

@@ -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>
)
}