node build fixed
This commit is contained in:
@@ -0,0 +1,187 @@
|
||||
"use client"
|
||||
import { Anime_Episode } from "@/api/generated/types"
|
||||
import { __libraryHeaderEpisodeAtom } from "@/app/(main)/(library)/_containers/continue-watching"
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { ThemeMediaPageBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atom, useAtomValue } from "jotai"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import Image from "next/image"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { useWindowScroll } from "react-use"
|
||||
|
||||
export const __libraryHeaderImageAtom = atom<{ bannerImage?: string | null, episodeImage?: string | null } | null>({
|
||||
bannerImage: null,
|
||||
episodeImage: null,
|
||||
})
|
||||
|
||||
const MotionImage = motion.create(Image)
|
||||
|
||||
export function LibraryHeader({ list }: { list: Anime_Episode[] }) {
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const image = useAtomValue(__libraryHeaderImageAtom)
|
||||
const [actualImage, setActualImage] = useState<string | null>(null)
|
||||
const [prevImage, setPrevImage] = useState<string | null>(null)
|
||||
const [dimmed, setDimmed] = useState(false)
|
||||
|
||||
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
|
||||
|
||||
const bannerImage = image?.bannerImage || image?.episodeImage || ""
|
||||
const shouldHideBanner = (
|
||||
(ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !image?.bannerImage)
|
||||
|| ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide
|
||||
)
|
||||
const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !image?.bannerImage) ||
|
||||
ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur
|
||||
|
||||
useEffect(() => {
|
||||
if (image != actualImage) {
|
||||
if (actualImage === null) {
|
||||
setActualImage(bannerImage)
|
||||
} else {
|
||||
setActualImage(null)
|
||||
}
|
||||
}
|
||||
}, [image])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
const t = setTimeout(() => {
|
||||
if (image != actualImage) {
|
||||
setActualImage(bannerImage)
|
||||
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === image?.episodeImage || ep.baseAnime?.coverImage?.extraLarge === image?.episodeImage || ep.episodeMetadata?.image === image?.episodeImage) || null)
|
||||
}
|
||||
}, 600)
|
||||
|
||||
return () => {
|
||||
clearTimeout(t)
|
||||
}
|
||||
}, [image])
|
||||
|
||||
useEffect(() => {
|
||||
if (actualImage) {
|
||||
setPrevImage(actualImage)
|
||||
setHeaderEpisode(list.find(ep => ep.baseAnime?.bannerImage === actualImage || ep.baseAnime?.coverImage?.extraLarge === actualImage || ep.episodeMetadata?.image === actualImage) || null)
|
||||
}
|
||||
}, [actualImage])
|
||||
|
||||
const { y } = useWindowScroll()
|
||||
|
||||
useEffect(() => {
|
||||
if (y > 100)
|
||||
setDimmed(true)
|
||||
else
|
||||
setDimmed(false)
|
||||
}, [(y > 100)])
|
||||
|
||||
if (!image) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-library-header-container
|
||||
className={cn(
|
||||
"LIB_HEADER_CONTAINER __header h-[25rem] z-[1] top-0 w-full absolute group/library-header pointer-events-none",
|
||||
// Make it not fixed when the user scrolls down if a background image is set
|
||||
!ts.libraryScreenCustomBackgroundImage && "fixed",
|
||||
)}
|
||||
>
|
||||
|
||||
<div
|
||||
data-library-header-banner-top-gradient
|
||||
className={cn(
|
||||
"w-full z-[3] absolute bottom-[-10rem] h-[10rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
data-library-header-inner-container
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, delay: 0.2 }}
|
||||
className={cn(
|
||||
"LIB_HEADER_INNER_CONTAINER h-full z-[0] w-full flex-none object-cover object-center absolute top-0 overflow-hidden bg-[--background]",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
>
|
||||
|
||||
{!ts.disableSidebarTransparency && <div
|
||||
data-library-header-banner-inner-container-top-gradient
|
||||
className="hidden lg:block h-full absolute z-[2] w-[20%] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
|
||||
/>}
|
||||
|
||||
<div
|
||||
data-library-header-banner-inner-container-bottom-gradient
|
||||
className="w-full z-[3] opacity-50 absolute top-0 h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
|
||||
/>
|
||||
|
||||
{/*<div*/}
|
||||
{/* className="LIB_HEADER_TOP_FADE w-full absolute z-[2] top-0 h-[10rem] opacity-20 bg-gradient-to-b from-[var(--background)] to-transparent via"*/}
|
||||
{/*/>*/}
|
||||
<AnimatePresence>
|
||||
{!!actualImage && (
|
||||
<motion.div
|
||||
key="library-header-banner-image-container"
|
||||
data-library-header-image-container
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0.4 }}
|
||||
transition={{ duration: 0.5 }}
|
||||
>
|
||||
<MotionImage
|
||||
data-library-header-banner-image
|
||||
src={getImageUrl(actualImage || prevImage!)}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center z-[1] opacity-100 transition-opacity duration-700 scroll-locked-offset",
|
||||
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
|
||||
{ "opacity-5": dimmed },
|
||||
)}
|
||||
initial={{ scale: 1.01, y: 0 }}
|
||||
animate={{
|
||||
scale: Math.min(1 + y * 0.0002, 1.03),
|
||||
// y: Math.max(y * -0.9, -10)
|
||||
}}
|
||||
exit={{ scale: 1.01, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
{/* {prevImage && <MotionImage
|
||||
data-library-header-banner-previous-image
|
||||
src={getImageUrl(actualImage || prevImage!)}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center z-[1] opacity-50 transition-opacity scroll-locked-offset",
|
||||
(shouldHideBanner || shouldBlurBanner) && "opacity-15",
|
||||
{ "opacity-5": dimmed },
|
||||
)}
|
||||
initial={{ scale: 1, y: 0 }}
|
||||
animate={{ scale: Math.min(1 + y * 0.0002, 1.03), y: Math.max(y * -0.9, -10) }}
|
||||
exit={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
/>} */}
|
||||
<div
|
||||
data-library-header-banner-bottom-gradient
|
||||
className="LIB_HEADER_IMG_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] lg:h-[15rem] bg-gradient-to-t from-[--background] lg:via-opacity-50 lg:via-10% to-transparent"
|
||||
/>
|
||||
</motion.div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -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>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,121 @@
|
||||
import { Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { CollectionParams, DEFAULT_ANIME_COLLECTION_PARAMS, filterAnimeCollectionEntries, filterEntriesByTitle } from "@/lib/helpers/filtering"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
import { useAtom, useAtomValue } from "jotai/index"
|
||||
import React from "react"
|
||||
|
||||
export const DETAILED_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
sorting: "TITLE",
|
||||
}
|
||||
|
||||
// export const __library_paramsAtom = atomWithStorage("sea-library-sorting-params", DETAILED_LIBRARY_DEFAULT_PARAMS, undefined, { getOnInit: true })
|
||||
export const __library_paramsAtom = atomWithImmer(DETAILED_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export const __library_selectedListAtom = atomWithImmer<string>("-")
|
||||
|
||||
export const __library_debouncedSearchInputAtom = atomWithImmer<string>("")
|
||||
|
||||
export function useHandleDetailedLibraryCollection() {
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const { animeLibraryCollectionDefaultSorting } = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
/**
|
||||
* Fetch the library collection data
|
||||
*/
|
||||
const { data, isLoading } = useGetLibraryCollection()
|
||||
|
||||
const [paramsToDebounce, setParamsToDebounce] = useAtom(__library_paramsAtom)
|
||||
const debouncedParams = useDebounce(paramsToDebounce, 500)
|
||||
|
||||
const debouncedSearchInput = useAtomValue(__library_debouncedSearchInputAtom)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
let _params = { ...paramsToDebounce }
|
||||
_params.sorting = animeLibraryCollectionDefaultSorting as any
|
||||
setParamsToDebounce(_params)
|
||||
}, [data, animeLibraryCollectionDefaultSorting])
|
||||
|
||||
|
||||
/**
|
||||
* Sort and filter the collection data
|
||||
*/
|
||||
const _filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
const arr = filterAnimeCollectionEntries(obj.entries,
|
||||
paramsToDebounce,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
data.continueWatchingList,
|
||||
watchHistory)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, debouncedParams, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
|
||||
|
||||
const filteredCollection: Anime_LibraryCollectionList[] = React.useMemo(() => {
|
||||
return _filteredCollection.map(obj => {
|
||||
if (!obj) return obj
|
||||
const arr = filterEntriesByTitle(obj.entries, debouncedSearchInput)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
}).filter(Boolean)
|
||||
}, [_filteredCollection, debouncedSearchInput])
|
||||
|
||||
const continueWatchingList = React.useMemo(() => {
|
||||
if (!data?.continueWatchingList) return []
|
||||
|
||||
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
|
||||
return data.continueWatchingList.filter(entry => entry.baseAnime?.isAdult === false)
|
||||
}
|
||||
|
||||
return data.continueWatchingList
|
||||
}, [
|
||||
data?.continueWatchingList,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
serverStatus?.settings?.anilist?.blurAdultContent,
|
||||
])
|
||||
|
||||
const libraryGenres = React.useMemo(() => {
|
||||
const allGenres = filteredCollection?.flatMap(l => {
|
||||
return l.entries?.flatMap(e => e.media?.genres) ?? []
|
||||
})
|
||||
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
|
||||
}, [filteredCollection])
|
||||
|
||||
return {
|
||||
isLoading: isLoading,
|
||||
stats: data?.stats,
|
||||
libraryCollectionList: filteredCollection,
|
||||
libraryGenres: libraryGenres,
|
||||
continueWatchingList: continueWatchingList,
|
||||
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
|
||||
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
|
||||
unmatchedGroups: data?.unmatchedGroups ?? [],
|
||||
unknownGroups: data?.unknownGroups ?? [],
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,232 @@
|
||||
import { useGetLibraryCollection } from "@/api/hooks/anime_collection.hooks"
|
||||
import { useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
|
||||
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import {
|
||||
CollectionParams,
|
||||
DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
filterAnimeCollectionEntries,
|
||||
sortContinueWatchingEntries,
|
||||
} from "@/lib/helpers/filtering"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
import { useAtomValue, useSetAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
|
||||
export const MAIN_LIBRARY_DEFAULT_PARAMS: CollectionParams<"anime"> = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
sorting: "TITLE", // Will be set to default sorting on mount
|
||||
continueWatchingOnly: false,
|
||||
}
|
||||
|
||||
export const __mainLibrary_paramsAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export const __mainLibrary_paramsInputAtom = atomWithImmer<CollectionParams<"anime">>(MAIN_LIBRARY_DEFAULT_PARAMS)
|
||||
|
||||
export function useHandleLibraryCollection() {
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const atom_setLibraryCollection = useSetAtom(animeLibraryCollectionAtom)
|
||||
|
||||
const { animeLibraryCollectionDefaultSorting, continueWatchingDefaultSorting } = useThemeSettings()
|
||||
|
||||
const { data: watchHistory } = useGetContinuityWatchHistory()
|
||||
|
||||
/**
|
||||
* Fetch the anime library collection
|
||||
*/
|
||||
const { data, isLoading } = useGetLibraryCollection()
|
||||
|
||||
/**
|
||||
* Store the received data in `libraryCollectionAtom`
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!!data) {
|
||||
atom_setLibraryCollection(data)
|
||||
}
|
||||
}, [data])
|
||||
|
||||
/**
|
||||
* Get the current params
|
||||
*/
|
||||
const params = useAtomValue(__mainLibrary_paramsAtom)
|
||||
|
||||
/**
|
||||
* Sort the collection
|
||||
* - This is displayed when there's no filters applied
|
||||
*/
|
||||
const sortedCollection = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
// Stream
|
||||
if (data.stream) {
|
||||
// Add to current list
|
||||
let currentList = data.lists.find(n => n.type === "CURRENT")
|
||||
if (currentList) {
|
||||
let entries = [...(currentList.entries ?? [])]
|
||||
for (let anime of (data.stream.anime ?? [])) {
|
||||
if (!entries.some(e => e.mediaId === anime.id)) {
|
||||
entries.push({
|
||||
media: anime,
|
||||
mediaId: anime.id,
|
||||
listData: data.stream.listData?.[anime.id],
|
||||
libraryData: undefined,
|
||||
})
|
||||
}
|
||||
}
|
||||
data.lists.find(n => n.type === "CURRENT")!.entries = entries
|
||||
}
|
||||
}
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
|
||||
//
|
||||
let sortingParams = {
|
||||
...DEFAULT_ANIME_COLLECTION_PARAMS,
|
||||
continueWatchingOnly: params.continueWatchingOnly,
|
||||
sorting: animeLibraryCollectionDefaultSorting as any,
|
||||
} as CollectionParams<"anime">
|
||||
|
||||
let continueWatchingList = [...(data.continueWatchingList ?? [])]
|
||||
|
||||
if (data.stream) {
|
||||
for (let entry of (data.stream?.continueWatchingList ?? [])) {
|
||||
continueWatchingList = [...continueWatchingList, entry]
|
||||
}
|
||||
}
|
||||
let arr = filterAnimeCollectionEntries(
|
||||
obj.entries,
|
||||
sortingParams,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
continueWatchingList,
|
||||
watchHistory,
|
||||
)
|
||||
|
||||
// Reset `continueWatchingOnly` to false if it's about to make the list disappear
|
||||
if (arr.length === 0 && sortingParams.continueWatchingOnly) {
|
||||
|
||||
// TODO: Add a toast to notify the user that the list is empty
|
||||
sortingParams = {
|
||||
...sortingParams,
|
||||
continueWatchingOnly: false, // Override
|
||||
}
|
||||
|
||||
arr = filterAnimeCollectionEntries(
|
||||
obj.entries,
|
||||
sortingParams,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
continueWatchingList,
|
||||
watchHistory,
|
||||
)
|
||||
}
|
||||
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, params, animeLibraryCollectionDefaultSorting, serverStatus?.settings?.anilist?.enableAdultContent])
|
||||
|
||||
/**
|
||||
* Filter the collection
|
||||
* - This is displayed when there's filters applied
|
||||
*/
|
||||
const filteredCollection = React.useMemo(() => {
|
||||
if (!data || !data.lists) return []
|
||||
|
||||
let _lists = data.lists.map(obj => {
|
||||
if (!obj) return obj
|
||||
const paramsToApply = {
|
||||
...params,
|
||||
sorting: animeLibraryCollectionDefaultSorting,
|
||||
} as CollectionParams<"anime">
|
||||
const arr = filterAnimeCollectionEntries(obj.entries,
|
||||
paramsToApply,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
data.continueWatchingList,
|
||||
watchHistory)
|
||||
return {
|
||||
type: obj.type,
|
||||
status: obj.status,
|
||||
entries: arr,
|
||||
}
|
||||
})
|
||||
return [
|
||||
_lists.find(n => n.type === "CURRENT"),
|
||||
_lists.find(n => n.type === "PAUSED"),
|
||||
_lists.find(n => n.type === "PLANNING"),
|
||||
_lists.find(n => n.type === "COMPLETED"),
|
||||
_lists.find(n => n.type === "DROPPED"),
|
||||
].filter(Boolean)
|
||||
}, [data, params, serverStatus?.settings?.anilist?.enableAdultContent, watchHistory])
|
||||
|
||||
/**
|
||||
* Sort the continue watching list
|
||||
*/
|
||||
const continueWatchingList = React.useMemo(() => {
|
||||
if (!data?.continueWatchingList) return []
|
||||
|
||||
let list = [...data.continueWatchingList]
|
||||
|
||||
|
||||
if (data.stream) {
|
||||
for (let entry of (data.stream.continueWatchingList ?? [])) {
|
||||
list = [...list, entry]
|
||||
}
|
||||
}
|
||||
|
||||
const entries = sortedCollection.flatMap(n => n.entries)
|
||||
|
||||
list = sortContinueWatchingEntries(list, continueWatchingDefaultSorting as any, entries, watchHistory)
|
||||
|
||||
if (!serverStatus?.settings?.anilist?.enableAdultContent || serverStatus?.settings?.anilist?.blurAdultContent) {
|
||||
return list.filter(entry => entry.baseAnime?.isAdult === false)
|
||||
}
|
||||
|
||||
return list
|
||||
}, [
|
||||
data?.stream,
|
||||
sortedCollection,
|
||||
data?.continueWatchingList,
|
||||
continueWatchingDefaultSorting,
|
||||
serverStatus?.settings?.anilist?.enableAdultContent,
|
||||
serverStatus?.settings?.anilist?.blurAdultContent,
|
||||
watchHistory,
|
||||
])
|
||||
|
||||
/**
|
||||
* Get the genres from all media in the library
|
||||
*/
|
||||
const libraryGenres = React.useMemo(() => {
|
||||
const allGenres = filteredCollection?.flatMap(l => {
|
||||
return l.entries?.flatMap(e => e.media?.genres) ?? []
|
||||
})
|
||||
return [...new Set(allGenres)].filter(Boolean)?.sort((a, b) => a.localeCompare(b))
|
||||
}, [filteredCollection])
|
||||
|
||||
return {
|
||||
libraryGenres,
|
||||
isLoading: isLoading,
|
||||
libraryCollectionList: sortedCollection,
|
||||
filteredLibraryCollectionList: filteredCollection,
|
||||
continueWatchingList: continueWatchingList,
|
||||
unmatchedLocalFiles: data?.unmatchedLocalFiles ?? [],
|
||||
ignoredLocalFiles: data?.ignoredLocalFiles ?? [],
|
||||
unmatchedGroups: data?.unmatchedGroups ?? [],
|
||||
unknownGroups: data?.unknownGroups ?? [],
|
||||
streamingMediaIds: data?.stream?.anime?.map(n => n.id) ?? [],
|
||||
hasEntries: sortedCollection.some(n => n.entries?.length > 0),
|
||||
isStreamingOnly: sortedCollection.every(n => n.entries?.every(e => !e.libraryData)),
|
||||
isNakamaLibrary: React.useMemo(() => data?.lists?.some(l => l.entries?.some(e => !!e.nakamaLibraryData)) ?? false, [data?.lists]),
|
||||
}
|
||||
|
||||
}
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atom } from "jotai"
|
||||
|
||||
export const __library_viewAtom = atom<"base" | "detailed">("base")
|
||||
@@ -0,0 +1,366 @@
|
||||
import { Anime_Episode, Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import {
|
||||
__library_debouncedSearchInputAtom,
|
||||
__library_paramsAtom,
|
||||
__library_selectedListAtom,
|
||||
DETAILED_LIBRARY_DEFAULT_PARAMS,
|
||||
useHandleDetailedLibraryCollection,
|
||||
} from "@/app/(main)/(library)/_lib/handle-detailed-library-collection"
|
||||
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
|
||||
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
|
||||
import { useNakamaStatus } from "@/app/(main)/_features/nakama/nakama-manager"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { ADVANCED_SEARCH_FORMATS, ADVANCED_SEARCH_SEASONS, ADVANCED_SEARCH_STATUS } from "@/app/(main)/search/_lib/advanced-search-constants"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { AppLayoutStack } from "@/components/ui/app-layout"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { Switch } from "@/components/ui/switch"
|
||||
import { StaticTabs } from "@/components/ui/tabs"
|
||||
import { TextInput } from "@/components/ui/text-input"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { ANIME_COLLECTION_SORTING_OPTIONS } from "@/lib/helpers/filtering"
|
||||
import { getLibraryCollectionTitle } from "@/lib/server/utils"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { getYear } from "date-fns"
|
||||
import { useAtomValue, useSetAtom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { AiOutlineArrowLeft } from "react-icons/ai"
|
||||
import { BiTrash } from "react-icons/bi"
|
||||
import { FaSortAmountDown } from "react-icons/fa"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { LuCalendar, LuLeaf } from "react-icons/lu"
|
||||
import { MdPersonalVideo } from "react-icons/md"
|
||||
import { RiSignalTowerLine } from "react-icons/ri"
|
||||
|
||||
type LibraryViewProps = {
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
continueWatchingList: Anime_Episode[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
streamingMediaIds: number[]
|
||||
isNakamaLibrary: boolean
|
||||
}
|
||||
|
||||
export function DetailedLibraryView(props: LibraryViewProps) {
|
||||
|
||||
const {
|
||||
// collectionList: _collectionList,
|
||||
continueWatchingList,
|
||||
isLoading,
|
||||
hasEntries,
|
||||
streamingMediaIds,
|
||||
isNakamaLibrary,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const setView = useSetAtom(__library_viewAtom)
|
||||
const nakamaStatus = useNakamaStatus()
|
||||
|
||||
const {
|
||||
stats,
|
||||
libraryCollectionList,
|
||||
libraryGenres,
|
||||
} = useHandleDetailedLibraryCollection()
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
|
||||
if (!hasEntries) return null
|
||||
|
||||
return (
|
||||
<PageWrapper className="p-4 space-y-8 relative z-[4]" data-detailed-library-view-container>
|
||||
|
||||
{/* <div
|
||||
className={cn(
|
||||
"absolute top-[-20rem] left-0 w-full h-[30rem] bg-gradient-to-t from-[--background] to-transparent z-[-1]",
|
||||
TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
/> */}
|
||||
|
||||
<div className="flex flex-col md:flex-row gap-4 justify-between" data-detailed-library-view-header-container>
|
||||
<div className="flex gap-4 items-center relative w-fit">
|
||||
<IconButton
|
||||
icon={<AiOutlineArrowLeft />}
|
||||
rounded
|
||||
intent="white-outline"
|
||||
size="sm"
|
||||
onClick={() => setView("base")}
|
||||
/>
|
||||
{!isNakamaLibrary && <h3 className="text-ellipsis truncate">Library</h3>}
|
||||
{isNakamaLibrary &&
|
||||
<h3 className="text-ellipsis truncate">{nakamaStatus?.hostConnectionStatus?.username || "Host"}'s Library</h3>}
|
||||
</div>
|
||||
|
||||
<SearchInput />
|
||||
</div>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"grid grid-cols-3 lg:grid-cols-6 gap-4 [&>div]:text-center [&>div>p]:text-[--muted]",
|
||||
isNakamaLibrary && "lg:grid-cols-5",
|
||||
)}
|
||||
data-detailed-library-view-stats-container
|
||||
>
|
||||
{!isNakamaLibrary && <div>
|
||||
<h3>{stats?.totalSize}</h3>
|
||||
<p>Library</p>
|
||||
</div>}
|
||||
<div>
|
||||
<h3>{stats?.totalFiles}</h3>
|
||||
<p>Files</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalEntries}</h3>
|
||||
<p>Entries</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalShows}</h3>
|
||||
<p>TV Shows</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalMovies}</h3>
|
||||
<p>Movies</p>
|
||||
</div>
|
||||
<div>
|
||||
<h3>{stats?.totalSpecials}</h3>
|
||||
<p>Specials</p>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<SearchOptions />
|
||||
|
||||
<GenreSelector genres={libraryGenres} />
|
||||
|
||||
{libraryCollectionList.map(collection => {
|
||||
if (!collection.entries?.length) return null
|
||||
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
|
||||
const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: { list: Anime_LibraryCollectionList, streamingMediaIds: number[] }) => {
|
||||
|
||||
const selectedList = useAtomValue(__library_selectedListAtom)
|
||||
|
||||
if (selectedList !== "-" && selectedList !== list.type) return null
|
||||
|
||||
return (
|
||||
<React.Fragment key={list.type}>
|
||||
<h2>{getLibraryCollectionTitle(list.type)} <span className="text-[--muted] font-medium ml-3">{list?.entries?.length ?? 0}</span></h2>
|
||||
<MediaCardLazyGrid itemCount={list?.entries?.length || 0}>
|
||||
{list.entries?.map(entry => {
|
||||
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
|
||||
})}
|
||||
</MediaCardLazyGrid>
|
||||
</React.Fragment>
|
||||
)
|
||||
})
|
||||
|
||||
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)}
|
||||
/>
|
||||
)
|
||||
})
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const SearchInput = () => {
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const setDebouncedInput = useSetAtom(__library_debouncedSearchInputAtom)
|
||||
const debouncedInput = useDebounce(inputValue, 500)
|
||||
|
||||
React.useEffect(() => {
|
||||
setDebouncedInput(inputValue)
|
||||
}, [debouncedInput])
|
||||
|
||||
|
||||
return (
|
||||
<div className="w-full md:w-[300px]">
|
||||
<TextInput
|
||||
leftIcon={<FiSearch />}
|
||||
value={inputValue}
|
||||
onValueChange={v => {
|
||||
setInputValue(v)
|
||||
}}
|
||||
className="rounded-full bg-gray-900/50"
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
export function SearchOptions() {
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const [params, setParams] = useAtom(__library_paramsAtom)
|
||||
const [selectedIndex, setSelectedIndex] = useAtom(__library_selectedListAtom)
|
||||
|
||||
return (
|
||||
<AppLayoutStack className="px-4 xl:px-0" data-detailed-library-view-search-options-container>
|
||||
<div className="flex w-full justify-center">
|
||||
<StaticTabs
|
||||
className="h-10 w-fit pb-6"
|
||||
triggerClass="px-4 py-1"
|
||||
items={[
|
||||
{ name: "All", isCurrent: selectedIndex === "-", onClick: () => setSelectedIndex("-") },
|
||||
{ name: "Watching", isCurrent: selectedIndex === "CURRENT", onClick: () => setSelectedIndex("CURRENT") },
|
||||
{ name: "Planning", isCurrent: selectedIndex === "PLANNING", onClick: () => setSelectedIndex("PLANNING") },
|
||||
{ name: "Paused", isCurrent: selectedIndex === "PAUSED", onClick: () => setSelectedIndex("PAUSED") },
|
||||
{ name: "Completed", isCurrent: selectedIndex === "COMPLETED", onClick: () => setSelectedIndex("COMPLETED") },
|
||||
{ name: "Dropped", isCurrent: selectedIndex === "DROPPED", onClick: () => setSelectedIndex("DROPPED") },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div
|
||||
className="grid grid-cols-2 md:grid-cols-3 2xl:grid-cols-[1fr_1fr_1fr_1fr_1fr_auto_auto] gap-4"
|
||||
data-detailed-library-view-search-options-grid
|
||||
>
|
||||
<Select
|
||||
label="Sorting"
|
||||
leftAddon={<FaSortAmountDown className={cn(params.sorting !== "TITLE" && "text-indigo-300 font-bold text-xl")} />}
|
||||
className="w-full"
|
||||
fieldClass="flex items-center"
|
||||
inputContainerClass="w-full"
|
||||
options={ANIME_COLLECTION_SORTING_OPTIONS}
|
||||
value={params.sorting || "TITLE"}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.sorting = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
// disabled={!!params.title && params.title.length > 0}
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<MdPersonalVideo className={cn((params.format as any) !== null && (params.format as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Format" placeholder="All formats"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={ADVANCED_SEARCH_FORMATS}
|
||||
value={params.format || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.format = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<RiSignalTowerLine className={cn((params.status as any) !== null && (params.status as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Status" placeholder="All statuses"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={[
|
||||
...ADVANCED_SEARCH_STATUS,
|
||||
]}
|
||||
value={params.status || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.status = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={
|
||||
<LuLeaf className={cn((params.season as any) !== null && (params.season as any) !== "" && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Season"
|
||||
placeholder="All seasons"
|
||||
className="w-full"
|
||||
fieldClass="w-full flex items-center"
|
||||
inputContainerClass="w-full"
|
||||
options={ADVANCED_SEARCH_SEASONS.map(season => ({ value: season.toUpperCase(), label: season }))}
|
||||
value={params.season || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.season = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<Select
|
||||
leftAddon={<LuCalendar className={cn((params.year !== null && params.year !== "") && "text-indigo-300 font-bold text-xl")} />}
|
||||
label="Year" placeholder="Timeless"
|
||||
className="w-full"
|
||||
fieldClass="w-full"
|
||||
options={[...Array(70)].map((v, idx) => getYear(new Date()) - idx).map(year => ({
|
||||
value: String(year),
|
||||
label: String(year),
|
||||
}))}
|
||||
value={params.year || ""}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.year = v as any
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
<div className="flex gap-4 items-center w-full">
|
||||
<IconButton
|
||||
icon={<BiTrash />} intent="alert-subtle" className="flex-none" onClick={() => {
|
||||
setParams(DETAILED_LIBRARY_DEFAULT_PARAMS)
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
{serverStatus?.settings?.anilist?.enableAdultContent && <div className="flex h-full items-center">
|
||||
<Switch
|
||||
label="Adult"
|
||||
value={params.isAdult}
|
||||
onValueChange={v => setParams(draft => {
|
||||
draft.isAdult = v
|
||||
return
|
||||
})}
|
||||
fieldLabelClass="hidden"
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
</AppLayoutStack>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({ genres }: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__library_paramsAtom)
|
||||
return (
|
||||
<MediaGenreSelector
|
||||
items={[
|
||||
{
|
||||
name: "All",
|
||||
isCurrent: !params!.genre?.length,
|
||||
onClick: () => setParams(draft => {
|
||||
draft.genre = []
|
||||
return
|
||||
}),
|
||||
},
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,148 @@
|
||||
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { DiscoverPageHeader } from "@/app/(main)/discover/_components/discover-page-header"
|
||||
import { DiscoverTrending } from "@/app/(main)/discover/_containers/discover-trending"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll"
|
||||
import { StaticTabs } from "@/components/ui/tabs"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useSetAtom } from "jotai/index"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { LuCog } from "react-icons/lu"
|
||||
|
||||
type EmptyLibraryViewProps = {
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
}
|
||||
|
||||
export function EmptyLibraryView(props: EmptyLibraryViewProps) {
|
||||
|
||||
const {
|
||||
isLoading,
|
||||
hasEntries,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
|
||||
|
||||
if (hasEntries || isLoading) return null
|
||||
|
||||
/**
|
||||
* Show empty library message and trending if library is empty
|
||||
*/
|
||||
return (
|
||||
<>
|
||||
<DiscoverPageHeader />
|
||||
<PageWrapper className="p-4 sm:p-8 pt-0 space-y-8 relative z-[4]" data-empty-library-view-container>
|
||||
<div className="text-center space-y-4">
|
||||
<div className="w-fit mx-auto space-y-4">
|
||||
{!!serverStatus?.settings?.library?.libraryPath ? <>
|
||||
<h2>Empty library</h2>
|
||||
<Button
|
||||
intent="primary-outline"
|
||||
leftIcon={<FiSearch />}
|
||||
size="xl"
|
||||
rounded
|
||||
onClick={() => setScannerModalOpen(true)}
|
||||
>
|
||||
Scan your library
|
||||
</Button>
|
||||
</> : (
|
||||
<LuffyError
|
||||
title="Your library is empty"
|
||||
className=""
|
||||
>
|
||||
<div className="text-center space-y-4">
|
||||
<SeaLink href="/settings?tab=library">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Set the path to your local library and scan it
|
||||
</Button>
|
||||
</SeaLink>
|
||||
{serverStatus?.settings?.library?.enableOnlinestream && <p>
|
||||
<SeaLink href="/settings?tab=onlinestream">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include online streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
{serverStatus?.torrentstreamSettings?.enabled && <p>
|
||||
<SeaLink href="/settings?tab=torrentstream">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include torrent streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
{serverStatus?.debridSettings?.enabled && <p>
|
||||
<SeaLink href="/settings?tab=debrid">
|
||||
<Button intent="primary-subtle" leftIcon={<LuCog className="text-xl" />}>
|
||||
Include debrid streaming in your library
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</p>}
|
||||
</div>
|
||||
</LuffyError>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
<div className="">
|
||||
<h3>Trending this season</h3>
|
||||
<DiscoverTrending />
|
||||
</div>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({
|
||||
genres,
|
||||
}: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
|
||||
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
|
||||
const debouncedParams = useDebounce(params, 500)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActualParams(params)
|
||||
}, [debouncedParams])
|
||||
|
||||
if (!genres.length) return null
|
||||
|
||||
return (
|
||||
<HorizontalDraggableScroll className="scroll-pb-1 pt-4 flex">
|
||||
<div className="flex flex-1"></div>
|
||||
<StaticTabs
|
||||
className="px-2 overflow-visible gap-2 py-4 w-fit"
|
||||
triggerClass="text-base rounded-[--radius-md] ring-2 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300"
|
||||
items={[
|
||||
// {
|
||||
// name: "All",
|
||||
// isCurrent: !params!.genre?.length,
|
||||
// onClick: () => setParams(draft => {
|
||||
// draft.genre = []
|
||||
// return
|
||||
// }),
|
||||
// },
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
<div className="flex flex-1"></div>
|
||||
</HorizontalDraggableScroll>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,136 @@
|
||||
import { Anime_Episode, Anime_LibraryCollectionList } from "@/api/generated/types"
|
||||
import { ContinueWatching } from "@/app/(main)/(library)/_containers/continue-watching"
|
||||
import { LibraryCollectionFilteredLists, LibraryCollectionLists } from "@/app/(main)/(library)/_containers/library-collection"
|
||||
import { __mainLibrary_paramsAtom, __mainLibrary_paramsInputAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { MediaGenreSelector } from "@/app/(main)/_features/media/_components/media-genre-selector"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useSetAtom } from "jotai/index"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import React from "react"
|
||||
|
||||
|
||||
type LibraryViewProps = {
|
||||
genres: string[]
|
||||
collectionList: Anime_LibraryCollectionList[]
|
||||
filteredCollectionList: Anime_LibraryCollectionList[]
|
||||
continueWatchingList: Anime_Episode[]
|
||||
isLoading: boolean
|
||||
hasEntries: boolean
|
||||
streamingMediaIds: number[]
|
||||
}
|
||||
|
||||
export function LibraryView(props: LibraryViewProps) {
|
||||
|
||||
const {
|
||||
genres,
|
||||
collectionList,
|
||||
continueWatchingList,
|
||||
filteredCollectionList,
|
||||
isLoading,
|
||||
hasEntries,
|
||||
streamingMediaIds,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
|
||||
|
||||
if (isLoading) return <React.Fragment>
|
||||
<div className="p-4 space-y-4 relative z-[4]">
|
||||
<Skeleton className="h-12 w-full max-w-lg relative" />
|
||||
<div
|
||||
className={cn(
|
||||
"grid h-[22rem] min-[2000px]:h-[24rem] grid-cols-2 md:grid-cols-3 lg:grid-cols-4 xl:grid-cols-5 2xl:grid-cols-7 min-[2000px]:grid-cols-8 gap-4",
|
||||
)}
|
||||
>
|
||||
{[1, 2, 3, 4, 5, 6, 7, 8]?.map((_, idx) => {
|
||||
return <Skeleton
|
||||
key={idx} className={cn(
|
||||
"h-[22rem] min-[2000px]:h-[24rem] col-span-1 aspect-[6/7] flex-none rounded-[--radius-md] relative overflow-hidden",
|
||||
"[&:nth-child(8)]:hidden min-[2000px]:[&:nth-child(8)]:block",
|
||||
"[&:nth-child(7)]:hidden 2xl:[&:nth-child(7)]:block",
|
||||
"[&:nth-child(6)]:hidden xl:[&:nth-child(6)]:block",
|
||||
"[&:nth-child(5)]:hidden xl:[&:nth-child(5)]:block",
|
||||
"[&:nth-child(4)]:hidden lg:[&:nth-child(4)]:block",
|
||||
"[&:nth-child(3)]:hidden md:[&:nth-child(3)]:block",
|
||||
)}
|
||||
/>
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
</React.Fragment>
|
||||
|
||||
return (
|
||||
<>
|
||||
<ContinueWatching
|
||||
episodes={continueWatchingList}
|
||||
isLoading={isLoading}
|
||||
/>
|
||||
|
||||
{(
|
||||
!ts.disableLibraryScreenGenreSelector &&
|
||||
collectionList.flatMap(n => n.entries)?.length > 2
|
||||
) && <GenreSelector genres={genres} />}
|
||||
|
||||
<PageWrapper key="library-collection-lists" className="p-4 space-y-8 relative z-[4]" data-library-collection-lists-container>
|
||||
<AnimatePresence mode="wait" initial={false}>
|
||||
{!params.genre?.length ?
|
||||
<LibraryCollectionLists
|
||||
key="library-collection-lists"
|
||||
collectionList={collectionList}
|
||||
isLoading={isLoading}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
: <LibraryCollectionFilteredLists
|
||||
key="library-filtered-lists"
|
||||
collectionList={filteredCollectionList}
|
||||
isLoading={isLoading}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
}
|
||||
</AnimatePresence>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function GenreSelector({
|
||||
genres,
|
||||
}: { genres: string[] }) {
|
||||
const [params, setParams] = useAtom(__mainLibrary_paramsInputAtom)
|
||||
const setActualParams = useSetAtom(__mainLibrary_paramsAtom)
|
||||
const debouncedParams = useDebounce(params, 200)
|
||||
|
||||
React.useEffect(() => {
|
||||
setActualParams(params)
|
||||
}, [debouncedParams])
|
||||
|
||||
if (!genres.length) return null
|
||||
|
||||
return (
|
||||
<PageWrapper className="space-y-3 lg:space-y-6 relative z-[4]" data-library-genre-selector-container>
|
||||
<MediaGenreSelector
|
||||
items={[
|
||||
...genres.map(genre => ({
|
||||
name: genre,
|
||||
isCurrent: params!.genre?.includes(genre) ?? false,
|
||||
onClick: () => setParams(draft => {
|
||||
if (draft.genre?.includes(genre)) {
|
||||
draft.genre = draft.genre?.filter(g => g !== genre)
|
||||
} else {
|
||||
draft.genre = [...(draft.genre || []), genre]
|
||||
}
|
||||
return
|
||||
}),
|
||||
})),
|
||||
]}
|
||||
/>
|
||||
</PageWrapper>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image"
|
||||
import React from "react"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*[CUSTOM UI]*/}
|
||||
<CustomBackgroundImage />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const dynamic = "force-static"
|
||||
120
seanime-2.9.10/seanime-web/src/app/(main)/(library)/page.tsx
Normal file
120
seanime-2.9.10/seanime-web/src/app/(main)/(library)/page.tsx
Normal file
@@ -0,0 +1,120 @@
|
||||
"use client"
|
||||
import { LibraryHeader } from "@/app/(main)/(library)/_components/library-header"
|
||||
import { BulkActionModal } from "@/app/(main)/(library)/_containers/bulk-action-modal"
|
||||
import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner"
|
||||
import { IgnoredFileManager } from "@/app/(main)/(library)/_containers/ignored-file-manager"
|
||||
import { LibraryToolbar } from "@/app/(main)/(library)/_containers/library-toolbar"
|
||||
import { UnknownMediaManager } from "@/app/(main)/(library)/_containers/unknown-media-manager"
|
||||
import { UnmatchedFileManager } from "@/app/(main)/(library)/_containers/unmatched-file-manager"
|
||||
import { useHandleLibraryCollection } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
|
||||
import { DetailedLibraryView } from "@/app/(main)/(library)/_screens/detailed-library-view"
|
||||
import { EmptyLibraryView } from "@/app/(main)/(library)/_screens/empty-library-view"
|
||||
import { LibraryView } from "@/app/(main)/(library)/_screens/library-view"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { AnimatePresence } from "motion/react"
|
||||
import React from "react"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function Library() {
|
||||
|
||||
const {
|
||||
libraryGenres,
|
||||
libraryCollectionList,
|
||||
filteredLibraryCollectionList,
|
||||
isLoading,
|
||||
continueWatchingList,
|
||||
unmatchedLocalFiles,
|
||||
ignoredLocalFiles,
|
||||
unmatchedGroups,
|
||||
unknownGroups,
|
||||
streamingMediaIds,
|
||||
hasEntries,
|
||||
isStreamingOnly,
|
||||
isNakamaLibrary,
|
||||
} = useHandleLibraryCollection()
|
||||
|
||||
const [view, setView] = useAtom(__library_viewAtom)
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<div data-library-page-container>
|
||||
|
||||
{hasEntries && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />}
|
||||
{hasEntries && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && <LibraryHeader list={continueWatchingList} />}
|
||||
<LibraryToolbar
|
||||
collectionList={libraryCollectionList}
|
||||
unmatchedLocalFiles={unmatchedLocalFiles}
|
||||
ignoredLocalFiles={ignoredLocalFiles}
|
||||
unknownGroups={unknownGroups}
|
||||
isLoading={isLoading}
|
||||
hasEntries={hasEntries}
|
||||
isStreamingOnly={isStreamingOnly}
|
||||
isNakamaLibrary={isNakamaLibrary}
|
||||
/>
|
||||
|
||||
<EmptyLibraryView isLoading={isLoading} hasEntries={hasEntries} />
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{view === "base" && <PageWrapper
|
||||
key="base"
|
||||
className="relative 2xl:order-first pb-10 pt-4"
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<LibraryView
|
||||
genres={libraryGenres}
|
||||
collectionList={libraryCollectionList}
|
||||
filteredCollectionList={filteredLibraryCollectionList}
|
||||
continueWatchingList={continueWatchingList}
|
||||
isLoading={isLoading}
|
||||
hasEntries={hasEntries}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
</PageWrapper>}
|
||||
{view === "detailed" && <PageWrapper
|
||||
key="detailed"
|
||||
className="relative 2xl:order-first pb-10 pt-4"
|
||||
{...{
|
||||
initial: { opacity: 0, y: 60 },
|
||||
animate: { opacity: 1, y: 0 },
|
||||
exit: { opacity: 0, scale: 0.99 },
|
||||
transition: {
|
||||
duration: 0.25,
|
||||
},
|
||||
}}
|
||||
>
|
||||
<DetailedLibraryView
|
||||
collectionList={libraryCollectionList}
|
||||
continueWatchingList={continueWatchingList}
|
||||
isLoading={isLoading}
|
||||
hasEntries={hasEntries}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
isNakamaLibrary={isNakamaLibrary}
|
||||
/>
|
||||
</PageWrapper>}
|
||||
</AnimatePresence>
|
||||
|
||||
<UnmatchedFileManager
|
||||
unmatchedGroups={unmatchedGroups}
|
||||
/>
|
||||
<UnknownMediaManager
|
||||
unknownGroups={unknownGroups}
|
||||
/>
|
||||
<IgnoredFileManager
|
||||
files={ignoredLocalFiles}
|
||||
/>
|
||||
<BulkActionModal />
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { LibraryHeader } from "@/app/(main)/(library)/_components/library-header"
|
||||
import { useHandleLibraryCollection } from "@/app/(main)/(library)/_lib/handle-library-collection"
|
||||
import { LibraryView } from "@/app/(main)/(library)/_screens/library-view"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export function OfflineAnimeLists() {
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const {
|
||||
libraryGenres,
|
||||
libraryCollectionList,
|
||||
filteredLibraryCollectionList,
|
||||
isLoading,
|
||||
continueWatchingList,
|
||||
streamingMediaIds,
|
||||
} = useHandleLibraryCollection()
|
||||
|
||||
return (
|
||||
<>
|
||||
{ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && <>
|
||||
<LibraryHeader list={continueWatchingList} />
|
||||
<div
|
||||
className={cn(
|
||||
"h-28",
|
||||
ts.hideTopNavbar && "h-40",
|
||||
)}
|
||||
></div>
|
||||
</>}
|
||||
<PageWrapper
|
||||
className="pt-4 relative space-y-8"
|
||||
>
|
||||
<LibraryView
|
||||
genres={libraryGenres}
|
||||
collectionList={libraryCollectionList}
|
||||
filteredCollectionList={filteredLibraryCollectionList}
|
||||
continueWatchingList={continueWatchingList}
|
||||
isLoading={isLoading}
|
||||
hasEntries={true}
|
||||
streamingMediaIds={streamingMediaIds}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,57 @@
|
||||
import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner"
|
||||
import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display"
|
||||
import { LibraryHeader } from "@/app/(main)/manga/_components/library-header"
|
||||
import { useHandleMangaCollection } from "@/app/(main)/manga/_lib/handle-manga-collection"
|
||||
import { MangaLibraryView } from "@/app/(main)/manga/_screens/manga-library-view"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export function OfflineMangaLists() {
|
||||
const {
|
||||
mangaCollection,
|
||||
filteredMangaCollection,
|
||||
genres,
|
||||
mangaCollectionLoading,
|
||||
storedProviders,
|
||||
hasManga,
|
||||
} = useHandleMangaCollection()
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
if (!mangaCollection || mangaCollectionLoading) return <MediaEntryPageLoadingDisplay />
|
||||
|
||||
return (
|
||||
<div>
|
||||
{(
|
||||
(!!ts.libraryScreenCustomBannerImage && ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom)
|
||||
) && (
|
||||
<>
|
||||
<CustomLibraryBanner isLibraryScreen />
|
||||
<div
|
||||
className={cn("h-14")}
|
||||
></div>
|
||||
</>
|
||||
)}
|
||||
{ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && (
|
||||
<>
|
||||
<LibraryHeader manga={mangaCollection?.lists?.flatMap(l => l.entries)?.flatMap(e => e?.media)?.filter(Boolean) || []} />
|
||||
<div
|
||||
className={cn(
|
||||
"h-28",
|
||||
ts.hideTopNavbar && "h-40",
|
||||
)}
|
||||
></div>
|
||||
</>
|
||||
)}
|
||||
|
||||
<MangaLibraryView
|
||||
genres={genres}
|
||||
collection={mangaCollection}
|
||||
filteredCollection={filteredMangaCollection}
|
||||
storedProviders={storedProviders}
|
||||
hasManga={hasManga}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,45 @@
|
||||
"use client"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { NavigationMenu, NavigationMenuProps } from "@/components/ui/navigation-menu"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface OfflineTopMenuProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const OfflineTopMenu: React.FC<OfflineTopMenuProps> = (props) => {
|
||||
|
||||
const { children, ...rest } = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
const navigationItems = useMemo<NavigationMenuProps["items"]>(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
href: "/offline",
|
||||
// icon: IoLibrary,
|
||||
isCurrent: pathname === "/offline",
|
||||
name: "My library",
|
||||
},
|
||||
...[serverStatus?.settings?.library?.enableManga && {
|
||||
href: "/offline/manga",
|
||||
icon: null,
|
||||
isCurrent: pathname.includes("/offline/manga"),
|
||||
name: "Manga",
|
||||
}].filter(Boolean) as NavigationMenuProps["items"],
|
||||
].filter(Boolean)
|
||||
}, [pathname, serverStatus?.settings?.library?.enableManga])
|
||||
|
||||
return (
|
||||
<NavigationMenu
|
||||
className="p-0 hidden lg:inline-block"
|
||||
itemClass="text-xl"
|
||||
items={navigationItems}
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
"use client"
|
||||
import { AL_BaseAnime, AL_BaseManga, Anime_Entry, Manga_Entry } from "@/api/generated/types"
|
||||
import { MediaEntryAudienceScore } from "@/app/(main)/_features/media/_components/media-entry-metadata-components"
|
||||
import {
|
||||
MediaPageHeader,
|
||||
MediaPageHeaderDetailsContainer,
|
||||
MediaPageHeaderEntryDetails,
|
||||
} from "@/app/(main)/_features/media/_components/media-page-header-components"
|
||||
import React from "react"
|
||||
|
||||
type OfflineMetaSectionProps<T extends "anime" | "manga"> = {
|
||||
type: T,
|
||||
entry: T extends "anime" ? Anime_Entry : Manga_Entry
|
||||
}
|
||||
|
||||
export function OfflineMetaSection<T extends "anime" | "manga">(props: OfflineMetaSectionProps<T>) {
|
||||
|
||||
const { type, entry } = props
|
||||
|
||||
if (!entry?.media) return null
|
||||
|
||||
return (
|
||||
<MediaPageHeader
|
||||
backgroundImage={entry.media?.bannerImage}
|
||||
coverImage={entry.media?.coverImage?.extraLarge}
|
||||
>
|
||||
|
||||
<MediaPageHeaderDetailsContainer>
|
||||
|
||||
<MediaPageHeaderEntryDetails
|
||||
coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large}
|
||||
title={entry.media?.title?.userPreferred}
|
||||
color={entry.media?.coverImage?.color}
|
||||
englishTitle={entry.media?.title?.english}
|
||||
romajiTitle={entry.media?.title?.romaji}
|
||||
startDate={entry.media?.startDate}
|
||||
season={entry.media?.season}
|
||||
progressTotal={type === "anime" ? (entry.media as AL_BaseAnime)?.episodes : (entry.media as AL_BaseManga)?.chapters}
|
||||
status={entry.media?.status}
|
||||
description={entry.media?.description}
|
||||
listData={entry.listData}
|
||||
media={entry.media}
|
||||
type="anime"
|
||||
/>
|
||||
|
||||
|
||||
<div className="flex gap-2 items-center">
|
||||
<MediaEntryAudienceScore meanScore={entry.media?.meanScore} badgeClass="bg-transparent" />
|
||||
</div>
|
||||
</MediaPageHeaderDetailsContainer>
|
||||
</MediaPageHeader>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,43 @@
|
||||
"use client"
|
||||
|
||||
import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks"
|
||||
import { OfflineMetaSection } from "@/app/(main)/(offline)/offline/entry/_components/offline-meta-section"
|
||||
import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display"
|
||||
import { EpisodeSection } from "@/app/(main)/entry/_containers/episode-list/episode-section"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import React from "react"
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter()
|
||||
const mediaId = useSearchParams().get("id")
|
||||
|
||||
const { data: animeEntry, isLoading: animeEntryLoading } = useGetAnimeEntry(mediaId)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mediaId || (!animeEntryLoading && !animeEntry)) {
|
||||
router.push("/offline")
|
||||
}
|
||||
}, [animeEntry, animeEntryLoading])
|
||||
|
||||
if (animeEntryLoading) return <MediaEntryPageLoadingDisplay />
|
||||
if (!animeEntry) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<OfflineMetaSection type="anime" entry={animeEntry} />
|
||||
<PageWrapper
|
||||
className="p-4 relative"
|
||||
data-media={JSON.stringify(animeEntry.media)}
|
||||
data-anime-entry-list-data={JSON.stringify(animeEntry.listData)}
|
||||
>
|
||||
<EpisodeSection
|
||||
entry={animeEntry}
|
||||
details={undefined}
|
||||
bottomSection={<></>}
|
||||
/>
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,15 @@
|
||||
"use client"
|
||||
import React from "react"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const dynamic = "force-static"
|
||||
@@ -0,0 +1,173 @@
|
||||
import { HibikeManga_ChapterDetails, Manga_ChapterContainer, Manga_Entry } from "@/api/generated/types"
|
||||
import { useGetMangaEntryDownloadedChapters } from "@/api/hooks/manga.hooks"
|
||||
import { ChapterReaderDrawer } from "@/app/(main)/manga/_containers/chapter-reader/chapter-reader-drawer"
|
||||
import { __manga_selectedChapterAtom } from "@/app/(main)/manga/_lib/handle-chapter-reader"
|
||||
import { useHandleMangaDownloadData } from "@/app/(main)/manga/_lib/handle-manga-downloads"
|
||||
import { getChapterNumberFromChapter } from "@/app/(main)/manga/_lib/handle-manga-utils"
|
||||
import { primaryPillCheckboxClasses } from "@/components/shared/classnames"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { DataGrid, defineDataGridColumns } from "@/components/ui/datagrid"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { useSetAtom } from "jotai"
|
||||
import React from "react"
|
||||
import { GiOpenBook } from "react-icons/gi"
|
||||
|
||||
type OfflineChapterListProps = {
|
||||
entry: Manga_Entry | undefined
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function OfflineChapterList(props: OfflineChapterListProps) {
|
||||
|
||||
const {
|
||||
entry,
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { data: chapterContainers, isLoading } = useGetMangaEntryDownloadedChapters(entry?.mediaId)
|
||||
|
||||
/**
|
||||
* Set selected chapter
|
||||
*/
|
||||
const setSelectedChapter = useSetAtom(__manga_selectedChapterAtom)
|
||||
|
||||
// Load download data
|
||||
useHandleMangaDownloadData(entry?.mediaId)
|
||||
|
||||
const chapters = React.useMemo(() => {
|
||||
return chapterContainers?.flatMap(n => n.chapters)?.filter(Boolean) ?? []
|
||||
}, [chapterContainers])
|
||||
|
||||
|
||||
const chapterNumbersMap = React.useMemo(() => {
|
||||
const map = new Map<string, number>()
|
||||
|
||||
for (const chapter of chapters) {
|
||||
map.set(chapter.id, getChapterNumberFromChapter(chapter.chapter))
|
||||
}
|
||||
|
||||
return map
|
||||
}, [chapterContainers])
|
||||
|
||||
const [selectedChapterContainer, setSelectedChapterContainer] = React.useState<Manga_ChapterContainer | undefined>(undefined)
|
||||
|
||||
const columns = React.useMemo(() => defineDataGridColumns<HibikeManga_ChapterDetails>(() => [
|
||||
{
|
||||
accessorKey: "title",
|
||||
header: "Name",
|
||||
size: 90,
|
||||
},
|
||||
{
|
||||
accessorKey: "provider",
|
||||
header: "Provider",
|
||||
size: 10,
|
||||
enableSorting: true,
|
||||
},
|
||||
{
|
||||
id: "number",
|
||||
header: "Number",
|
||||
size: 10,
|
||||
enableSorting: true,
|
||||
accessorFn: (row) => {
|
||||
return chapterNumbersMap.get(row.id)
|
||||
},
|
||||
},
|
||||
{
|
||||
id: "_actions",
|
||||
size: 5,
|
||||
enableSorting: false,
|
||||
enableGlobalFilter: false,
|
||||
cell: ({ row }) => {
|
||||
return (
|
||||
<div className="flex justify-end gap-2 items-center w-full">
|
||||
<IconButton
|
||||
intent="gray-subtle"
|
||||
size="sm"
|
||||
onClick={() => {
|
||||
// setProvider(row.original.provider)
|
||||
setSelectedChapterContainer(chapterContainers?.find(c => c.provider === row.original.provider))
|
||||
React.startTransition(() => {
|
||||
setSelectedChapter({
|
||||
chapterId: row.original.id,
|
||||
chapterNumber: row.original.chapter,
|
||||
provider: row.original.provider,
|
||||
mediaId: Number(entry?.mediaId),
|
||||
})
|
||||
})
|
||||
}}
|
||||
icon={<GiOpenBook />}
|
||||
/>
|
||||
</div>
|
||||
)
|
||||
},
|
||||
},
|
||||
]), [entry, chapterNumbersMap])
|
||||
|
||||
const [showUnreadChapter, setShowUnreadChapter] = React.useState(false)
|
||||
|
||||
const retainUnreadChapters = React.useCallback((chapter: HibikeManga_ChapterDetails) => {
|
||||
if (!entry?.listData || !chapterNumbersMap.has(chapter.id) || !entry?.listData?.progress) return true
|
||||
|
||||
const chapterNumber = chapterNumbersMap.get(chapter.id)
|
||||
return !!chapterNumber && chapterNumber > entry.listData?.progress
|
||||
}, [chapterNumbersMap, chapterContainers, entry])
|
||||
|
||||
const unreadChapters = React.useMemo(() => chapters.filter(ch => retainUnreadChapters(ch)) ?? [],
|
||||
[chapters, entry])
|
||||
|
||||
React.useEffect(() => {
|
||||
setShowUnreadChapter(!!unreadChapters.length)
|
||||
}, [unreadChapters])
|
||||
|
||||
const tableChapters = React.useMemo(() => {
|
||||
return showUnreadChapter ? unreadChapters : chapters
|
||||
}, [showUnreadChapter, chapters, unreadChapters])
|
||||
|
||||
if (!entry || isLoading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="space-y-4 border rounded-[--radius-md] bg-[--paper] p-4">
|
||||
|
||||
<div className="flex flex-wrap items-center gap-4">
|
||||
<Checkbox
|
||||
label="Show unread"
|
||||
value={showUnreadChapter}
|
||||
onValueChange={v => setShowUnreadChapter(v as boolean)}
|
||||
fieldClass="w-fit"
|
||||
{...primaryPillCheckboxClasses}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<DataGrid<HibikeManga_ChapterDetails>
|
||||
columns={columns}
|
||||
data={tableChapters}
|
||||
rowCount={tableChapters?.length || 0}
|
||||
isLoading={!tableChapters}
|
||||
rowSelectionPrimaryKey="id"
|
||||
initialState={{
|
||||
pagination: {
|
||||
pageIndex: 0,
|
||||
pageSize: 10,
|
||||
},
|
||||
sorting: [
|
||||
{
|
||||
id: "number",
|
||||
desc: false,
|
||||
},
|
||||
],
|
||||
}}
|
||||
className=""
|
||||
/>
|
||||
|
||||
{(!!selectedChapterContainer) && <ChapterReaderDrawer
|
||||
entry={entry}
|
||||
chapterIdToNumbersMap={chapterNumbersMap}
|
||||
chapterContainer={selectedChapterContainer}
|
||||
/>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,38 @@
|
||||
"use client"
|
||||
|
||||
import { useGetMangaEntry } from "@/api/hooks/manga.hooks"
|
||||
import { OfflineMetaSection } from "@/app/(main)/(offline)/offline/entry/_components/offline-meta-section"
|
||||
import { OfflineChapterList } from "@/app/(main)/(offline)/offline/entry/manga/_components/offline-chapter-list"
|
||||
import { MediaEntryPageLoadingDisplay } from "@/app/(main)/_features/media/_components/media-entry-page-loading-display"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { useRouter, useSearchParams } from "next/navigation"
|
||||
import React from "react"
|
||||
|
||||
export default function Page() {
|
||||
const router = useRouter()
|
||||
const mediaId = useSearchParams().get("id")
|
||||
|
||||
const { data: mangaEntry, isLoading: mangaEntryLoading } = useGetMangaEntry(mediaId)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!mediaId || (!mangaEntryLoading && !mangaEntry)) {
|
||||
router.push("/offline")
|
||||
}
|
||||
}, [mangaEntry, mangaEntryLoading])
|
||||
|
||||
if (mangaEntryLoading) return <MediaEntryPageLoadingDisplay />
|
||||
if (!mangaEntry) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<OfflineMetaSection type="manga" entry={mangaEntry} />
|
||||
<PageWrapper className="p-4 space-y-6">
|
||||
|
||||
<h2>Chapters</h2>
|
||||
|
||||
<OfflineChapterList entry={mangaEntry} />
|
||||
</PageWrapper>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { CustomBackgroundImage } from "@/app/(main)/_features/custom-ui/custom-background-image"
|
||||
import React from "react"
|
||||
|
||||
export default function Layout({ children }: { children: React.ReactNode }) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*[CUSTOM UI]*/}
|
||||
<CustomBackgroundImage />
|
||||
{children}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
|
||||
|
||||
export const dynamic = "force-static"
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner"
|
||||
import { OfflineMangaLists } from "@/app/(main)/(offline)/offline/_components/offline-manga-lists"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function Page() {
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
{ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />}
|
||||
<OfflineMangaLists />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
"use client"
|
||||
import { CustomLibraryBanner } from "@/app/(main)/(library)/_containers/custom-library-banner"
|
||||
import { OfflineAnimeLists } from "@/app/(main)/(offline)/offline/_components/offline-anime-lists"
|
||||
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export const dynamic = "force-static"
|
||||
|
||||
export default function Page() {
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
{ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Custom && <CustomLibraryBanner isLibraryScreen />}
|
||||
<OfflineAnimeLists />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,9 @@
|
||||
import { AL_BaseAnime, Anime_EntryListData, Manga_EntryListData } from "@/api/generated/types"
|
||||
import { atom } from "jotai/index"
|
||||
|
||||
export const __anilist_userAnimeMediaAtom = atom<AL_BaseAnime[] | undefined>(undefined)
|
||||
|
||||
// e.g. { "123": { ... } }
|
||||
export const __anilist_userAnimeListDataAtom = atom<Record<string, Anime_EntryListData>>({})
|
||||
|
||||
export const __anilist_userMangaListDataAtom = atom<Record<string, Manga_EntryListData>>({})
|
||||
@@ -0,0 +1,27 @@
|
||||
import { Anime_LibraryCollection } from "@/api/generated/types"
|
||||
import { derive } from "jotai-derive"
|
||||
import { atom } from "jotai/index"
|
||||
|
||||
export const animeLibraryCollectionAtom = atom<Anime_LibraryCollection | undefined>(undefined)
|
||||
export const animeLibraryCollectionWithoutStreamsAtom = derive([animeLibraryCollectionAtom], (animeLibraryCollection) => {
|
||||
if (!animeLibraryCollection) {
|
||||
return undefined
|
||||
}
|
||||
return {
|
||||
...animeLibraryCollection,
|
||||
lists: animeLibraryCollection.lists?.map(list => ({
|
||||
...list,
|
||||
entries: list.entries?.filter(n => !!n.libraryData),
|
||||
})),
|
||||
} as Anime_LibraryCollection
|
||||
})
|
||||
|
||||
export const getAtomicLibraryEntryAtom = atom(get => get(animeLibraryCollectionAtom)?.lists?.length,
|
||||
(get, set, payload: number) => {
|
||||
const lists = get(animeLibraryCollectionAtom)?.lists
|
||||
if (!lists) {
|
||||
return undefined
|
||||
}
|
||||
return lists.flatMap(n => n.entries)?.filter(Boolean).find(n => n.mediaId === payload)
|
||||
},
|
||||
)
|
||||
@@ -0,0 +1,5 @@
|
||||
import { Models_AutoDownloaderItem } from "@/api/generated/types"
|
||||
import { atom } from "jotai/index"
|
||||
|
||||
export const autoDownloaderItemsAtom = atom<Models_AutoDownloaderItem[]>([])
|
||||
export const autoDownloaderItemCountAtom = atom(get => get(autoDownloaderItemsAtom).length)
|
||||
@@ -0,0 +1,3 @@
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const __mediaplayer_discreteControlsAtom = atomWithStorage("sea-mediaplayer-discrete-controls", false)
|
||||
@@ -0,0 +1,9 @@
|
||||
import { Anime_Episode } from "@/api/generated/types"
|
||||
import { atom } from "jotai"
|
||||
|
||||
export const missingEpisodesAtom = atom<Anime_Episode[]>([])
|
||||
|
||||
export const missingSilencedEpisodesAtom = atom<Anime_Episode[]>([])
|
||||
|
||||
export const missingEpisodeCountAtom = atom(get => get(missingEpisodesAtom).length)
|
||||
|
||||
@@ -0,0 +1,117 @@
|
||||
import { Nullish } from "@/api/generated/types"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import { FaShareFromSquare } from "react-icons/fa6"
|
||||
import { PiVideoFill } from "react-icons/pi"
|
||||
|
||||
export const enum ElectronPlaybackMethod {
|
||||
NativePlayer = "nativePlayer", // Desktop media player or Integrated player (media streaming)
|
||||
Default = "default", // Desktop media player, media streaming or external player link
|
||||
}
|
||||
|
||||
export const __playback_electronPlaybackMethodAtom = atomWithStorage<string>("sea-playback-electron-playback-method",
|
||||
ElectronPlaybackMethod.NativePlayer)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const enum PlaybackDownloadedMedia {
|
||||
Default = "default", // Desktop media player or Integrated player (media streaming)
|
||||
ExternalPlayerLink = "externalPlayerLink", // External player link
|
||||
}
|
||||
|
||||
|
||||
export const playbackDownloadedMediaOptions = [
|
||||
{
|
||||
label: <div className="flex items-center gap-4 md:gap-2 w-full">
|
||||
<PiVideoFill className="text-2xl flex-none" />
|
||||
<p className="max-w-[90%]">Desktop media player or Transcoding / Direct Play</p>
|
||||
</div>, value: PlaybackDownloadedMedia.Default,
|
||||
},
|
||||
{
|
||||
label: <div className="flex items-center gap-4 md:gap-2 w-full">
|
||||
<FaShareFromSquare className="text-2xl flex-none" />
|
||||
<p className="max-w-[90%]">External player link</p>
|
||||
</div>, value: PlaybackDownloadedMedia.ExternalPlayerLink,
|
||||
},
|
||||
]
|
||||
|
||||
export const __playback_downloadedMediaAtom = atomWithStorage<string>("sea-playback-downloaded-media", PlaybackDownloadedMedia.Default)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const enum PlaybackTorrentStreaming {
|
||||
Default = "default", // Desktop media player
|
||||
ExternalPlayerLink = "externalPlayerLink",
|
||||
}
|
||||
|
||||
export const playbackTorrentStreamingOptions = [
|
||||
{
|
||||
label: <div className="flex items-center gap-4 md:gap-2 w-full">
|
||||
<PiVideoFill className="text-2xl flex-none" />
|
||||
<p className="max-w-[90%]">Desktop media player</p>
|
||||
</div>, value: PlaybackTorrentStreaming.Default,
|
||||
},
|
||||
{
|
||||
label: <div className="flex items-center gap-4 md:gap-2 w-full">
|
||||
<FaShareFromSquare className="text-2xl flex-none" />
|
||||
<p className="max-w-[90%]">External player link</p>
|
||||
</div>, value: PlaybackTorrentStreaming.ExternalPlayerLink,
|
||||
},
|
||||
]
|
||||
|
||||
|
||||
export const __playback_torrentStreamingAtom = atomWithStorage<string>("sea-playback-torrentstream", PlaybackTorrentStreaming.Default)
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function useCurrentDevicePlaybackSettings() {
|
||||
|
||||
const [downloadedMediaPlayback, setDownloadedMediaPlayback] = useAtom(__playback_downloadedMediaAtom)
|
||||
const [torrentStreamingPlayback, setTorrentStreamingPlayback] = useAtom(__playback_torrentStreamingAtom)
|
||||
const [electronPlaybackMethod, setElectronPlaybackMethod] = useAtom(__playback_electronPlaybackMethodAtom)
|
||||
return {
|
||||
downloadedMediaPlayback,
|
||||
setDownloadedMediaPlayback,
|
||||
torrentStreamingPlayback,
|
||||
setTorrentStreamingPlayback,
|
||||
electronPlaybackMethod,
|
||||
setElectronPlaybackMethod,
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const __playback_externalPlayerLink = atomWithStorage<string>("sea-playback-external-player-link", "")
|
||||
export const __playback_externalPlayerLink_encodePath = atomWithStorage<boolean>("sea-playback-external-player-link-encode-path", false)
|
||||
|
||||
export function useExternalPlayerLink() {
|
||||
const [externalPlayerLink, setExternalPlayerLink] = useAtom(__playback_externalPlayerLink)
|
||||
const [encodePath, setEncodePath] = useAtom(__playback_externalPlayerLink_encodePath)
|
||||
return {
|
||||
externalPlayerLink,
|
||||
setExternalPlayerLink,
|
||||
encodePath,
|
||||
setEncodePath,
|
||||
}
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
const __playback_playNext = atom<number | null>(null)
|
||||
|
||||
export function usePlayNext() {
|
||||
const [playNext, _setPlayNext] = useAtom(__playback_playNext)
|
||||
|
||||
function setPlayNext(ep: Nullish<number>, callback: () => void) {
|
||||
if (!ep) return
|
||||
_setPlayNext(ep)
|
||||
callback()
|
||||
}
|
||||
|
||||
return {
|
||||
playNext,
|
||||
setPlayNext,
|
||||
resetPlayNext: () => _setPlayNext(null),
|
||||
}
|
||||
}
|
||||
@@ -0,0 +1,10 @@
|
||||
import { Status } from "@/api/generated/types"
|
||||
import { atom } from "jotai"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
|
||||
export const serverStatusAtom = atomWithImmer<Status | undefined>(undefined)
|
||||
|
||||
export const isLoginModalOpenAtom = atom(false)
|
||||
|
||||
export const serverAuthTokenAtom = atomWithStorage<string | undefined>("sea-server-auth-token", undefined, undefined, { getOnInit: true })
|
||||
@@ -0,0 +1,9 @@
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
|
||||
const syncIsActiveAtom = atom(false)
|
||||
|
||||
export function useSyncIsActive() {
|
||||
const [syncIsActive, setSyncIsActive] = useAtom(syncIsActiveAtom)
|
||||
return { syncIsActive, setSyncIsActive }
|
||||
}
|
||||
@@ -0,0 +1,7 @@
|
||||
import { atom } from "jotai"
|
||||
import { createContext } from "react"
|
||||
|
||||
export const WebSocketContext = createContext<WebSocket | null>(null)
|
||||
|
||||
export const websocketAtom = atom<WebSocket | null>(null)
|
||||
|
||||
@@ -0,0 +1,26 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
export function ElectronCrashScreenError() {
|
||||
const [msg, setMsg] = React.useState("")
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
if (window.electron) {
|
||||
const u = window.electron.on("crash", (msg: string) => {
|
||||
console.log("Received crash event", msg)
|
||||
setMsg(msg)
|
||||
})
|
||||
return () => {
|
||||
u?.()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<p className="px-4">
|
||||
{msg || "An error occurred"}
|
||||
</p>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,19 @@
|
||||
"use client"
|
||||
|
||||
import React from "react"
|
||||
|
||||
type ElectronManagerProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
// This is only rendered on the Electron Desktop client
|
||||
export function ElectronManager(props: ElectronManagerProps) {
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
// No-op
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,11 @@
|
||||
import React from "react"
|
||||
|
||||
export function ElectronSidebarPaddingMacOS() {
|
||||
if (window.electron?.platform !== "darwin") return null
|
||||
|
||||
return (
|
||||
<div className="h-4">
|
||||
{/* Extra padding for macOS */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { isUpdateInstalledAtom, isUpdatingAtom } from "@/app/(main)/_tauri/tauri-update-modal"
|
||||
import { websocketConnectedAtom, websocketConnectionErrorCountAtom } from "@/app/websocket-provider"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { LoadingOverlay } from "@/components/ui/loading-spinner"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { useAtom, useAtomValue } from "jotai/react"
|
||||
import React from "react"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export function ElectronRestartServerPrompt() {
|
||||
|
||||
const [hasRendered, setHasRendered] = React.useState(false)
|
||||
|
||||
const [isConnected, setIsConnected] = useAtom(websocketConnectedAtom)
|
||||
const connectionErrorCount = useAtomValue(websocketConnectionErrorCountAtom)
|
||||
const [hasClickedRestarted, setHasClickedRestarted] = React.useState(false)
|
||||
const isUpdatedInstalled = useAtomValue(isUpdateInstalledAtom)
|
||||
const isUpdating = useAtomValue(isUpdatingAtom)
|
||||
|
||||
React.useEffect(() => {
|
||||
(async () => {
|
||||
if (window.electron) {
|
||||
await window.electron.window.getCurrentWindow() // TODO: Isn't called
|
||||
setHasRendered(true)
|
||||
}
|
||||
})()
|
||||
}, [])
|
||||
|
||||
const handleRestart = async () => {
|
||||
setHasClickedRestarted(true)
|
||||
toast.info("Restarting server...")
|
||||
if (window.electron) {
|
||||
window.electron.emit("restart-server")
|
||||
React.startTransition(() => {
|
||||
setTimeout(() => {
|
||||
setHasClickedRestarted(false)
|
||||
}, 5000)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Try to reconnect automatically
|
||||
const tryAutoReconnectRef = React.useRef(true)
|
||||
React.useEffect(() => {
|
||||
if (!isConnected && connectionErrorCount >= 10 && tryAutoReconnectRef.current && !isUpdatedInstalled) {
|
||||
tryAutoReconnectRef.current = false
|
||||
console.log("Connection error count reached 10, restarting server automatically")
|
||||
handleRestart()
|
||||
}
|
||||
}, [connectionErrorCount])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isConnected) {
|
||||
setHasClickedRestarted(false)
|
||||
tryAutoReconnectRef.current = true
|
||||
}
|
||||
}, [isConnected])
|
||||
|
||||
if (!hasRendered) return null
|
||||
|
||||
// Not connected for 10 seconds
|
||||
return (
|
||||
<>
|
||||
{(!isConnected && connectionErrorCount < 10 && !isUpdating && !isUpdatedInstalled) && (
|
||||
<LoadingOverlay className="fixed left-0 top-0 z-[9999]">
|
||||
<p>
|
||||
The server connection has been lost. Please wait while we attempt to reconnect.
|
||||
</p>
|
||||
</LoadingOverlay>
|
||||
)}
|
||||
|
||||
<Modal
|
||||
open={!isConnected && connectionErrorCount >= 10 && !isUpdatedInstalled}
|
||||
onOpenChange={() => {}}
|
||||
hideCloseButton
|
||||
contentClass="max-w-2xl"
|
||||
>
|
||||
<LuffyError>
|
||||
<div className="space-y-4 flex flex-col items-center">
|
||||
<p className="text-lg max-w-sm">
|
||||
The background server process has stopped responding. Please restart it to continue.
|
||||
</p>
|
||||
|
||||
<Button
|
||||
onClick={handleRestart}
|
||||
loading={hasClickedRestarted}
|
||||
intent="white-outline"
|
||||
size="lg"
|
||||
className="rounded-full"
|
||||
>
|
||||
Restart server
|
||||
</Button>
|
||||
<p className="text-[--muted] text-sm max-w-xl">
|
||||
If this message persists after multiple tries, please relaunch the application.
|
||||
</p>
|
||||
</div>
|
||||
</LuffyError>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,205 @@
|
||||
"use client"
|
||||
import { useGetLatestUpdate } from "@/api/hooks/releases.hooks"
|
||||
import { UpdateChangelogBody } from "@/app/(main)/_features/update/update-helper"
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { VerticalMenu } from "@/components/ui/vertical-menu"
|
||||
import { logger } from "@/lib/helpers/debug"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { AiFillExclamationCircle } from "react-icons/ai"
|
||||
import { BiLinkExternal } from "react-icons/bi"
|
||||
import { FiArrowRight } from "react-icons/fi"
|
||||
import { GrInstall } from "react-icons/gr"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type UpdateModalProps = {
|
||||
collapsed?: boolean
|
||||
}
|
||||
|
||||
const updateModalOpenAtom = atom<boolean>(false)
|
||||
export const isUpdateInstalledAtom = atom<boolean>(false)
|
||||
export const isUpdatingAtom = atom<boolean>(false)
|
||||
|
||||
export function ElectronUpdateModal(props: UpdateModalProps) {
|
||||
const serverStatus = useServerStatus()
|
||||
const [updateModalOpen, setUpdateModalOpen] = useAtom(updateModalOpenAtom)
|
||||
const [isUpdating, setIsUpdating] = useAtom(isUpdatingAtom)
|
||||
|
||||
const { data: updateData, isLoading, refetch } = useGetLatestUpdate(!!serverStatus && !serverStatus?.settings?.library?.disableUpdateCheck)
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.CHECK_FOR_UPDATES,
|
||||
onMessage: () => {
|
||||
refetch().then(() => checkElectronUpdate())
|
||||
},
|
||||
})
|
||||
|
||||
const [updateLoading, setUpdateLoading] = React.useState(true)
|
||||
const [electronUpdate, setUpdate] = React.useState<boolean>(false)
|
||||
const [updateError, setUpdateError] = React.useState("")
|
||||
const [isInstalled, setIsInstalled] = useAtom(isUpdateInstalledAtom)
|
||||
|
||||
const checkElectronUpdate = React.useCallback(() => {
|
||||
try {
|
||||
if (window.electron) {
|
||||
// Check if the update is available
|
||||
setUpdateLoading(true);
|
||||
window.electron.checkForUpdates()
|
||||
.then((updateAvailable: boolean) => {
|
||||
setUpdate(updateAvailable)
|
||||
setUpdateLoading(false)
|
||||
})
|
||||
.catch((error: any) => {
|
||||
logger("ELECTRON").error("Failed to check for updates", error)
|
||||
setUpdateError(JSON.stringify(error))
|
||||
setUpdateLoading(false)
|
||||
})
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logger("ELECTRON").error("Failed to check for updates", e)
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
checkElectronUpdate()
|
||||
|
||||
// Listen for update events from Electron
|
||||
if (window.electron) {
|
||||
// Register listeners for update events
|
||||
const removeUpdateDownloaded = window.electron.on("update-downloaded", () => {
|
||||
toast.info("Update downloaded and ready to install")
|
||||
})
|
||||
|
||||
const removeUpdateError = window.electron.on("update-error", (error: string) => {
|
||||
logger("ELECTRON").error("Update error", error)
|
||||
toast.error(`Update error: ${error}`)
|
||||
setIsUpdating(false)
|
||||
})
|
||||
|
||||
return () => {
|
||||
// Clean up listeners
|
||||
removeUpdateDownloaded?.()
|
||||
removeUpdateError?.()
|
||||
}
|
||||
}
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (updateData && updateData.release) {
|
||||
setUpdateModalOpen(true)
|
||||
}
|
||||
}, [updateData])
|
||||
|
||||
async function handleInstallUpdate() {
|
||||
if (!electronUpdate || isUpdating) return
|
||||
|
||||
try {
|
||||
setIsUpdating(true)
|
||||
|
||||
// Tell Electron to download and install the update
|
||||
if (window.electron) {
|
||||
toast.info("Downloading update...")
|
||||
|
||||
// Kill the currently running server before installing update
|
||||
try {
|
||||
toast.info("Shutting down server...")
|
||||
await window.electron.killServer()
|
||||
}
|
||||
catch (e) {
|
||||
logger("ELECTRON").error("Failed to kill server", e)
|
||||
}
|
||||
|
||||
// Install update
|
||||
toast.info("Installing update...")
|
||||
await window.electron.installUpdate()
|
||||
setIsInstalled(true)
|
||||
|
||||
// Electron will automatically restart the app
|
||||
toast.info("Update installed. Restarting app...")
|
||||
}
|
||||
}
|
||||
catch (e) {
|
||||
logger("ELECTRON").error("Failed to download update", e)
|
||||
toast.error(`Failed to download update: ${JSON.stringify(e)}`)
|
||||
setIsUpdating(false)
|
||||
}
|
||||
}
|
||||
|
||||
if (serverStatus?.settings?.library?.disableUpdateCheck) return null
|
||||
|
||||
if (isLoading || updateLoading || !updateData || !updateData.release) return null
|
||||
|
||||
if (isInstalled) return (
|
||||
<div className="fixed top-0 left-0 w-full h-full bg-[--background] flex items-center z-[9999]">
|
||||
<div className="container max-w-4xl py-10">
|
||||
<div className="mb-4 flex justify-center w-full">
|
||||
<img src="/logo_2.png" alt="logo" className="w-36 h-auto" />
|
||||
</div>
|
||||
<p className="text-center text-lg">
|
||||
Update installed. The app will restart automatically.
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<>
|
||||
<VerticalMenu
|
||||
collapsed={props.collapsed}
|
||||
items={[
|
||||
{
|
||||
iconType: AiFillExclamationCircle,
|
||||
name: "Update available",
|
||||
onClick: () => setUpdateModalOpen(true),
|
||||
},
|
||||
]}
|
||||
itemIconClass="text-brand-300"
|
||||
/>
|
||||
<Modal
|
||||
open={updateModalOpen}
|
||||
onOpenChange={v => !isUpdating && setUpdateModalOpen(v)}
|
||||
contentClass="max-w-3xl"
|
||||
>
|
||||
<div className="space-y-2">
|
||||
<h3 className="text-center">A new update is available!</h3>
|
||||
<h4 className="font-bold flex gap-2 text-center items-center justify-center">
|
||||
<span className="text-[--muted]">{updateData.current_version}</span> <FiArrowRight />
|
||||
<span className="text-indigo-200">{updateData.release.version}</span></h4>
|
||||
|
||||
{!electronUpdate && (
|
||||
<Alert intent="warning">
|
||||
This update is not yet available for desktop clients.
|
||||
Wait a few minutes or check the GitHub page for more information.
|
||||
</Alert>
|
||||
)}
|
||||
|
||||
<UpdateChangelogBody updateData={updateData} />
|
||||
|
||||
<div className="flex gap-2 w-full !mt-4">
|
||||
{!!electronUpdate && <Button
|
||||
leftIcon={<GrInstall className="text-2xl" />}
|
||||
onClick={handleInstallUpdate}
|
||||
loading={isUpdating}
|
||||
disabled={isLoading}
|
||||
>
|
||||
Update now
|
||||
</Button>}
|
||||
<div className="flex flex-1" />
|
||||
<SeaLink href={updateData?.release?.html_url || ""} target="_blank">
|
||||
<Button intent="white-subtle" rightIcon={<BiLinkExternal />}>See on GitHub</Button>
|
||||
</SeaLink>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,132 @@
|
||||
"use client"
|
||||
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import React from "react"
|
||||
import { VscChromeClose, VscChromeMaximize, VscChromeMinimize, VscChromeRestore } from "react-icons/vsc"
|
||||
|
||||
type ElectronWindowTitleBarProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function ElectronWindowTitleBar(props: ElectronWindowTitleBarProps) {
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [showControls, setShowControls] = React.useState(true)
|
||||
const [displayDragRegion, setDisplayDragRegion] = React.useState(true)
|
||||
const [maximized, setMaximized] = React.useState(false)
|
||||
const [currentPlatform, setCurrentPlatform] = React.useState("")
|
||||
|
||||
// Handle window control actions
|
||||
function handleMinimize() {
|
||||
if (window.electron?.window) {
|
||||
window.electron.window.minimize()
|
||||
}
|
||||
}
|
||||
|
||||
function toggleMaximized() {
|
||||
if (window.electron?.window) {
|
||||
window.electron.window.toggleMaximize()
|
||||
}
|
||||
}
|
||||
|
||||
function handleClose() {
|
||||
if (window.electron?.window) {
|
||||
window.electron.window.close()
|
||||
}
|
||||
}
|
||||
|
||||
// Check fullscreen state
|
||||
function onFullscreenChange() {
|
||||
if (window.electron?.window) {
|
||||
window.electron.window.isFullscreen().then((fullscreen: boolean) => {
|
||||
setShowControls(!fullscreen)
|
||||
setDisplayDragRegion(!fullscreen)
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
// Get platform
|
||||
if (window.electron) {
|
||||
setCurrentPlatform(window.electron.platform)
|
||||
}
|
||||
|
||||
// Setup window event listeners
|
||||
const removeMaximizedListener = window.electron?.on("window:maximized", () => {
|
||||
setMaximized(true)
|
||||
})
|
||||
|
||||
const removeUnmaximizedListener = window.electron?.on("window:unmaximized", () => {
|
||||
setMaximized(false)
|
||||
})
|
||||
|
||||
const removeFullscreenListener = window.electron?.on("window:fullscreen", (isFullscreen: boolean) => {
|
||||
setShowControls(!isFullscreen)
|
||||
setDisplayDragRegion(!isFullscreen)
|
||||
})
|
||||
|
||||
// Check window capabilities
|
||||
// if (window.electron?.window) {
|
||||
// Promise.all([
|
||||
// window.electron.window.isMinimizable(),
|
||||
// window.electron.window.isMaximizable(),
|
||||
// window.electron.window.isClosable(),
|
||||
// window.electron.window.isMaximized()
|
||||
// ]).then(([minimizable, maximizable, closable, isMaximized]) => {
|
||||
// setMaximized(isMaximized)
|
||||
// setShowControls(minimizable || maximizable || closable)
|
||||
// })
|
||||
// }
|
||||
|
||||
document.addEventListener("fullscreenchange", onFullscreenChange)
|
||||
|
||||
// Cleanup
|
||||
return () => {
|
||||
if (removeMaximizedListener) removeMaximizedListener()
|
||||
if (removeUnmaximizedListener) removeUnmaximizedListener()
|
||||
if (removeFullscreenListener) removeFullscreenListener()
|
||||
document.removeEventListener("fullscreenchange", onFullscreenChange)
|
||||
}
|
||||
}, [])
|
||||
|
||||
// Only show on Windows and macOS
|
||||
if (!(currentPlatform === "win32" || currentPlatform === "darwin")) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className="__electron-window-title-bar scroll-locked-offset bg-transparent fixed top-0 left-0 h-10 z-[999] w-full bg-opacity-90 flex pointer-events-[all]"
|
||||
style={{
|
||||
pointerEvents: "all",
|
||||
}}
|
||||
>
|
||||
{displayDragRegion &&
|
||||
<div className="flex flex-1 cursor-grab active:cursor-grabbing" style={{ WebkitAppRegion: "drag" } as any}></div>}
|
||||
{(currentPlatform === "win32" && showControls) &&
|
||||
<div className="flex h-10 items-center justify-center gap-1 mr-2 !cursor-default">
|
||||
<IconButton
|
||||
className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]"
|
||||
icon={<VscChromeMinimize className="text-[0.95rem]" />}
|
||||
onClick={handleMinimize}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<IconButton
|
||||
className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-[rgba(255,255,255,0.05)] active:text-white active:bg-[rgba(255,255,255,0.1)]"
|
||||
icon={maximized ? <VscChromeRestore className="text-[0.95rem]" /> : <VscChromeMaximize className="text-[0.95rem]" />}
|
||||
onClick={toggleMaximized}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
<IconButton
|
||||
className="outline-none w-11 size-8 rounded-lg duration-0 shadow-none text-white hover:text-white bg-transparent hover:bg-red-500 active:bg-red-600 active:text-white"
|
||||
icon={<VscChromeClose className="text-[0.95rem]" />}
|
||||
onClick={handleClose}
|
||||
tabIndex={-1}
|
||||
/>
|
||||
</div>}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,52 @@
|
||||
"use client"
|
||||
import { useRefreshAnimeCollection } from "@/api/hooks/anilist.hooks"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import React from "react"
|
||||
import { IoReload } from "react-icons/io5"
|
||||
|
||||
interface RefreshAnilistButtonProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const RefreshAnilistButton: React.FC<RefreshAnilistButtonProps> = (props) => {
|
||||
|
||||
const { children, ...rest } = props
|
||||
|
||||
/**
|
||||
* @description
|
||||
* - Asks the server to fetch an up-to-date version of the user's AniList collection.
|
||||
*/
|
||||
const { mutate, isPending } = useRefreshAnimeCollection()
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
data-refresh-anilist-button-tooltip
|
||||
trigger={
|
||||
<Button
|
||||
data-refresh-anilist-button
|
||||
onClick={() => mutate()}
|
||||
intent="warning-subtle"
|
||||
size="sm"
|
||||
rightIcon={<IoReload />}
|
||||
loading={isPending}
|
||||
leftIcon={<svg
|
||||
xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24"
|
||||
viewBox="0 0 24 24" role="img"
|
||||
>
|
||||
<path
|
||||
d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z"
|
||||
/>
|
||||
</svg>}
|
||||
className={""}
|
||||
>
|
||||
</Button>
|
||||
}
|
||||
>
|
||||
Refresh AniList
|
||||
</Tooltip>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { AL_AnimeCollection_MediaListCollection_Lists } from "@/api/generated/types"
|
||||
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import React from "react"
|
||||
|
||||
|
||||
type AnilistAnimeEntryListProps = {
|
||||
list: AL_AnimeCollection_MediaListCollection_Lists | undefined
|
||||
type: "anime" | "manga"
|
||||
}
|
||||
|
||||
/**
|
||||
* Displays a list of media entry card from an Anilist media list collection.
|
||||
*/
|
||||
export function AnilistAnimeEntryList(props: AnilistAnimeEntryListProps) {
|
||||
|
||||
const {
|
||||
list,
|
||||
type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<MediaCardLazyGrid itemCount={list?.entries?.filter(Boolean)?.length || 0} data-anilist-anime-entry-list>
|
||||
{list?.entries?.filter(Boolean)?.map((entry) => (
|
||||
<MediaEntryCard
|
||||
key={`${entry.media?.id}`}
|
||||
listData={{
|
||||
progress: entry.progress!,
|
||||
score: entry.score!,
|
||||
status: entry.status!,
|
||||
startedAt: entry.startedAt?.year ? new Date(entry.startedAt.year,
|
||||
(entry.startedAt.month || 1) - 1,
|
||||
entry.startedAt.day || 1).toISOString() : undefined,
|
||||
completedAt: entry.completedAt?.year ? new Date(entry.completedAt.year,
|
||||
(entry.completedAt.month || 1) - 1,
|
||||
entry.completedAt.day || 1).toISOString() : undefined,
|
||||
}}
|
||||
showLibraryBadge
|
||||
media={entry.media!}
|
||||
showListDataButton
|
||||
type={type}
|
||||
/>
|
||||
))}
|
||||
</MediaCardLazyGrid>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,240 @@
|
||||
import { Anime_Episode } from "@/api/generated/types"
|
||||
import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu"
|
||||
import { EpisodeItemBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
|
||||
import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { ProgressBar } from "@/components/ui/progress-bar"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import Image from "next/image"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { AiFillPlayCircle } from "react-icons/ai"
|
||||
import { PluginEpisodeCardContextMenuItems } from "../../plugin/actions/plugin-actions"
|
||||
|
||||
type EpisodeCardProps = {
|
||||
title: React.ReactNode
|
||||
actionIcon?: React.ReactElement | null
|
||||
image?: string
|
||||
onClick?: () => void
|
||||
topTitle?: string
|
||||
meta?: string
|
||||
type?: "carousel" | "grid"
|
||||
isInvalid?: boolean
|
||||
containerClass?: string
|
||||
episodeNumber?: number
|
||||
progressNumber?: number
|
||||
progressTotal?: number
|
||||
mRef?: React.RefObject<HTMLDivElement>
|
||||
hasDiscrepancy?: boolean
|
||||
length?: string | number | null
|
||||
imageClass?: string
|
||||
badge?: React.ReactNode
|
||||
percentageComplete?: number
|
||||
minutesRemaining?: number
|
||||
anime?: {
|
||||
id?: number
|
||||
image?: string
|
||||
title?: string
|
||||
}
|
||||
episode?: Anime_Episode // Optional, used for plugin actions
|
||||
} & Omit<React.ComponentPropsWithoutRef<"div">, "title">
|
||||
|
||||
export function EpisodeCard(props: EpisodeCardProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
actionIcon = props.actionIcon !== null ? <AiFillPlayCircle className="opacity-50" /> : undefined,
|
||||
image,
|
||||
onClick,
|
||||
topTitle,
|
||||
meta,
|
||||
title,
|
||||
type = "carousel",
|
||||
isInvalid,
|
||||
className,
|
||||
containerClass,
|
||||
mRef,
|
||||
episodeNumber,
|
||||
progressTotal,
|
||||
progressNumber,
|
||||
hasDiscrepancy,
|
||||
length,
|
||||
imageClass,
|
||||
badge,
|
||||
percentageComplete,
|
||||
minutesRemaining,
|
||||
anime,
|
||||
episode,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const serverStatus = useServerStatus()
|
||||
const ts = useThemeSettings()
|
||||
const { setPreviewModalMediaId } = useMediaPreviewModal()
|
||||
|
||||
const showAnimeInfo = ts.showEpisodeCardAnimeInfo && !!anime
|
||||
const showTotalEpisodes = React.useMemo(() => !!progressTotal && progressTotal > 1, [progressTotal])
|
||||
const offset = React.useMemo(() => hasDiscrepancy ? 1 : 0, [hasDiscrepancy])
|
||||
|
||||
const Meta = () => (
|
||||
<div data-episode-card-info-container className="relative z-[3] w-full space-y-0">
|
||||
<p
|
||||
data-episode-card-title
|
||||
className="w-[80%] line-clamp-1 text-md md:text-lg transition-colors duration-200 text-[--foreground] font-semibold"
|
||||
>
|
||||
{topTitle?.replaceAll("`", "'")}
|
||||
</p>
|
||||
<div data-episode-card-info-content className="w-full justify-between flex flex-none items-center">
|
||||
<p data-episode-card-subtitle className="line-clamp-1 flex items-center">
|
||||
<span className="flex-none text-base md:text-xl font-medium">{title}{showTotalEpisodes ?
|
||||
<span className="opacity-40">{` / `}{progressTotal! - offset}</span>
|
||||
: ``}</span>
|
||||
<span className="text-[--muted] text-base md:text-xl ml-2 font-normal line-clamp-1">{showAnimeInfo
|
||||
? "- " + anime.title
|
||||
: ""}</span>
|
||||
</p>
|
||||
{(!!meta || !!length) && (
|
||||
<p data-episode-card-meta-text className="text-[--muted] flex-none ml-2 text-sm md:text-base line-clamp-2 text-right">
|
||||
{meta}{!!meta && !!length && ` • `}{length ? `${length}m` : ""}
|
||||
</p>)}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
|
||||
return (
|
||||
<SeaContextMenu
|
||||
hideMenuIf={!anime?.id}
|
||||
content={
|
||||
<ContextMenuGroup>
|
||||
<ContextMenuLabel className="text-[--muted] line-clamp-1 py-0 my-2">
|
||||
{anime?.title}
|
||||
</ContextMenuLabel>
|
||||
|
||||
{pathname !== "/entry" && <>
|
||||
<ContextMenuItem
|
||||
onClick={() => {
|
||||
if (!serverStatus?.isOffline) {
|
||||
router.push(`/entry?id=${anime?.id}`)
|
||||
} else {
|
||||
router.push(`/offline/entry/anime?id=${anime?.id}`)
|
||||
}
|
||||
}}
|
||||
>
|
||||
Open page
|
||||
</ContextMenuItem>
|
||||
{!serverStatus?.isOffline && <ContextMenuItem
|
||||
onClick={() => {
|
||||
setPreviewModalMediaId(anime?.id || 0, "anime")
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</ContextMenuItem>}
|
||||
|
||||
</>}
|
||||
|
||||
<PluginEpisodeCardContextMenuItems episode={props.episode} />
|
||||
|
||||
</ContextMenuGroup>
|
||||
}
|
||||
>
|
||||
<ContextMenuTrigger>
|
||||
<div
|
||||
ref={mRef}
|
||||
className={cn(
|
||||
"rounded-lg overflow-hidden space-y-2 flex-none group/episode-card cursor-pointer",
|
||||
"select-none",
|
||||
type === "carousel" && "w-full",
|
||||
type === "grid" && "aspect-[4/2] w-72 lg:w-[26rem]",
|
||||
className,
|
||||
containerClass,
|
||||
)}
|
||||
onClick={onClick}
|
||||
data-episode-card
|
||||
data-episode-number={episodeNumber}
|
||||
data-media-id={anime?.id}
|
||||
data-progress-total={progressTotal}
|
||||
data-progress-number={progressNumber}
|
||||
{...rest}
|
||||
>
|
||||
<div data-episode-card-image-container className="w-full h-full rounded-lg overflow-hidden z-[1] aspect-[4/2] relative">
|
||||
{!!image ? <Image
|
||||
data-episode-card-image
|
||||
src={getImageUrl(image)}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="20rem"
|
||||
className={cn(
|
||||
"object-cover rounded-lg object-center transition lg:group-hover/episode-card:scale-105 duration-200",
|
||||
imageClass,
|
||||
)}
|
||||
/> : <div
|
||||
data-episode-card-image-bottom-gradient
|
||||
className="h-full block rounded-lg absolute w-full bg-gradient-to-t from-gray-800 to-transparent z-[2]"
|
||||
></div>}
|
||||
{/*[CUSTOM UI] BOTTOM GRADIENT*/}
|
||||
<EpisodeItemBottomGradient />
|
||||
|
||||
{(serverStatus?.settings?.library?.enableWatchContinuity && !!percentageComplete) &&
|
||||
<div
|
||||
data-episode-card-progress-bar-container
|
||||
className="absolute bottom-0 left-0 w-full z-[3]"
|
||||
data-episode-number={episodeNumber}
|
||||
data-media-id={anime?.id}
|
||||
data-progress-total={progressTotal}
|
||||
data-progress-number={progressNumber}
|
||||
>
|
||||
<ProgressBar value={percentageComplete} size="xs" />
|
||||
{minutesRemaining && <div className="absolute bottom-2 right-2">
|
||||
<p className="text-[--muted] text-sm">{minutesRemaining}m left</p>
|
||||
</div>}
|
||||
</div>}
|
||||
|
||||
<div
|
||||
data-episode-card-action-icon
|
||||
className={cn(
|
||||
"group-hover/episode-card:opacity-100 text-6xl text-gray-200",
|
||||
"cursor-pointer opacity-0 transition-opacity bg-gray-950 bg-opacity-60 z-[2] absolute w-[105%] h-[105%] items-center justify-center",
|
||||
"hidden md:flex",
|
||||
)}
|
||||
>
|
||||
{actionIcon && actionIcon}
|
||||
</div>
|
||||
|
||||
{isInvalid &&
|
||||
<p data-episode-card-invalid-metadata className="text-red-300 opacity-50 absolute left-2 bottom-2 z-[2]">No metadata
|
||||
found</p>}
|
||||
</div>
|
||||
{(showAnimeInfo) ? <div data-episode-card-anime-info-container className="flex gap-3 items-center">
|
||||
<div
|
||||
data-episode-card-anime-image-container
|
||||
className="flex-none w-12 aspect-[5/6] rounded-lg overflow-hidden z-[1] relative"
|
||||
>
|
||||
{!!anime?.image && <Image
|
||||
data-episode-card-anime-image
|
||||
src={getImageUrl(anime.image)}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="20rem"
|
||||
className={cn(
|
||||
"object-cover rounded-lg object-center transition lg:group-hover/episode-card:scale-105 duration-200",
|
||||
imageClass,
|
||||
)}
|
||||
/>}
|
||||
</div>
|
||||
<Meta />
|
||||
</div> : <Meta />}
|
||||
</div>
|
||||
</ContextMenuTrigger>
|
||||
</SeaContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,218 @@
|
||||
import { AL_BaseAnime } from "@/api/generated/types"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { ProgressBar } from "@/components/ui/progress-bar"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { AiFillPlayCircle, AiFillWarning } from "react-icons/ai"
|
||||
|
||||
type EpisodeGridItemProps = {
|
||||
media: AL_BaseAnime,
|
||||
children?: React.ReactNode
|
||||
action?: React.ReactNode
|
||||
image?: string | null
|
||||
onClick?: () => void
|
||||
title: string,
|
||||
episodeTitle?: string | null
|
||||
description?: string | null
|
||||
fileName?: string
|
||||
isWatched?: boolean
|
||||
isSelected?: boolean
|
||||
unoptimizedImage?: boolean
|
||||
isInvalid?: boolean
|
||||
className?: string
|
||||
disabled?: boolean
|
||||
actionIcon?: React.ReactElement | null
|
||||
isFiller?: boolean
|
||||
length?: string | number | null
|
||||
imageClassName?: string
|
||||
imageContainerClassName?: string
|
||||
episodeTitleClassName?: string
|
||||
percentageComplete?: number
|
||||
minutesRemaining?: number
|
||||
episodeNumber?: number
|
||||
progressNumber?: number
|
||||
}
|
||||
|
||||
export const EpisodeGridItem: React.FC<EpisodeGridItemProps & React.ComponentPropsWithoutRef<"div">> = (props) => {
|
||||
|
||||
const {
|
||||
children,
|
||||
action,
|
||||
image,
|
||||
onClick,
|
||||
episodeTitle,
|
||||
description,
|
||||
title,
|
||||
fileName,
|
||||
media,
|
||||
isWatched,
|
||||
isSelected,
|
||||
unoptimizedImage,
|
||||
isInvalid,
|
||||
imageClassName,
|
||||
imageContainerClassName,
|
||||
className,
|
||||
disabled,
|
||||
isFiller,
|
||||
length,
|
||||
actionIcon = props.actionIcon !== null ? <AiFillPlayCircle data-episode-grid-item-action-icon className="opacity-70 text-4xl" /> : undefined,
|
||||
episodeTitleClassName,
|
||||
percentageComplete,
|
||||
minutesRemaining,
|
||||
episodeNumber,
|
||||
progressNumber,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return <>
|
||||
<div
|
||||
data-episode-grid-item
|
||||
data-media-id={media.id}
|
||||
data-media-type={media.type}
|
||||
data-filename={fileName}
|
||||
data-episode-number={episodeNumber}
|
||||
data-progress-number={progressNumber}
|
||||
data-is-watched={isWatched}
|
||||
data-description={description}
|
||||
data-episode-title={episodeTitle}
|
||||
data-title={title}
|
||||
data-file-name={fileName}
|
||||
data-is-invalid={isInvalid}
|
||||
data-is-filler={isFiller}
|
||||
className={cn(
|
||||
"max-w-full",
|
||||
"rounded-lg relative transition group/episode-list-item select-none",
|
||||
!!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5 ? "bg-[--background] p-3" : "py-3",
|
||||
"pr-12",
|
||||
disabled && "cursor-not-allowed opacity-50 pointer-events-none",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
{isFiller && (
|
||||
<Badge
|
||||
data-episode-grid-item-filler-badge
|
||||
className={cn(
|
||||
"font-semibold absolute top-3 left-0 z-[5] text-white bg-orange-800 !bg-opacity-100 rounded-[--radius-md] text-base rounded-bl-none rounded-tr-none",
|
||||
!!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5 && "top-3 left-3",
|
||||
)}
|
||||
intent="gray"
|
||||
size="lg"
|
||||
>Filler</Badge>
|
||||
)}
|
||||
|
||||
<div
|
||||
data-episode-grid-item-container
|
||||
className={cn(
|
||||
"flex gap-4 relative",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-episode-grid-item-image-container
|
||||
className={cn(
|
||||
"w-36 h-28 lg:w-44 lg:h-32",
|
||||
(ts.hideEpisodeCardDescription) && "w-36 h-28 lg:w-40 lg:h-28",
|
||||
"flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden",
|
||||
"group/ep-item-img-container",
|
||||
onClick && "cursor-pointer",
|
||||
{
|
||||
"border-2 border-red-700": isInvalid,
|
||||
"border-2 border-yellow-900": isFiller,
|
||||
"border-2 border-[--brand]": isSelected,
|
||||
},
|
||||
|
||||
imageContainerClassName,
|
||||
)}
|
||||
onClick={onClick}
|
||||
>
|
||||
<div data-episode-grid-item-image-overlay className="absolute z-[1] rounded-[--radius-md] w-full h-full"></div>
|
||||
<div
|
||||
data-episode-grid-item-image-background
|
||||
className="bg-[--background] absolute z-[0] rounded-[--radius-md] w-full h-full"
|
||||
></div>
|
||||
{!!onClick && <div
|
||||
data-episode-grid-item-action-overlay
|
||||
className={cn(
|
||||
"absolute inset-0 bg-gray-950 bg-opacity-60 z-[1] flex items-center justify-center",
|
||||
"transition-opacity opacity-0 group-hover/ep-item-img-container:opacity-100",
|
||||
)}
|
||||
>
|
||||
{actionIcon && actionIcon}
|
||||
</div>}
|
||||
{(image || media.coverImage?.medium) && <Image
|
||||
data-episode-grid-item-image
|
||||
src={getImageUrl(image || media.coverImage?.medium || "")}
|
||||
alt="episode image"
|
||||
fill
|
||||
quality={60}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
className={cn("object-cover object-center transition select-none", {
|
||||
"opacity-25 lg:group-hover/episode-list-item:opacity-100": isWatched && !isSelected,
|
||||
}, imageClassName)}
|
||||
data-src={image}
|
||||
/>}
|
||||
|
||||
{(serverStatus?.settings?.library?.enableWatchContinuity && !!percentageComplete && !isWatched) &&
|
||||
<div data-episode-grid-item-progress-bar-container className="absolute bottom-0 left-0 w-full z-[3]">
|
||||
<ProgressBar value={percentageComplete} size="xs" />
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
<div data-episode-grid-item-content className="relative overflow-hidden">
|
||||
{isInvalid && <p data-episode-grid-item-invalid-metadata className="flex gap-2 text-red-300 items-center"><AiFillWarning
|
||||
className="text-lg text-red-500"
|
||||
/> Unidentified</p>}
|
||||
{isInvalid &&
|
||||
<p data-episode-grid-item-invalid-metadata className="flex gap-2 text-red-200 text-sm items-center">No metadata found</p>}
|
||||
|
||||
<p
|
||||
className={cn(
|
||||
!episodeTitle && "text-lg font-semibold",
|
||||
!!episodeTitle && "transition line-clamp-2 text-base text-[--muted]",
|
||||
// { "opacity-50 group-hover/episode-list-item:opacity-100": isWatched },
|
||||
)}
|
||||
data-episode-grid-item-title
|
||||
>
|
||||
<span
|
||||
className={cn(
|
||||
"font-medium text-[--foreground]",
|
||||
isSelected && "text-[--brand]",
|
||||
)}
|
||||
>
|
||||
{title?.replaceAll("`", "'")}</span>{(!!episodeTitle && !!length) &&
|
||||
<span className="ml-4">{length}m</span>}
|
||||
</p>
|
||||
|
||||
{!!episodeTitle &&
|
||||
<p
|
||||
data-episode-grid-item-episode-title
|
||||
className={cn("text-md font-medium lg:text-lg text-gray-300 line-clamp-2 lg:!leading-6",
|
||||
episodeTitleClassName)}
|
||||
>{episodeTitle?.replaceAll("`", "'")}</p>}
|
||||
|
||||
|
||||
{!!description && !ts.hideEpisodeCardDescription &&
|
||||
<p data-episode-grid-item-episode-description className="text-sm text-[--muted] line-clamp-2">{description.replaceAll("`",
|
||||
"'")}</p>}
|
||||
{!!fileName && !ts.hideDownloadedEpisodeCardFilename && <p data-episode-grid-item-filename className="text-xs tracking-wider opacity-75 line-clamp-1 mt-1">{fileName}</p>}
|
||||
{children && children}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{action && <div data-episode-grid-item-action className="absolute right-1 top-1 flex flex-col items-center">
|
||||
{action}
|
||||
</div>}
|
||||
</div>
|
||||
</>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,102 @@
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import React from "react"
|
||||
|
||||
type PlaylistsModalProps = {
|
||||
trigger?: React.ReactElement
|
||||
trailerId?: string | null
|
||||
isOpen?: boolean
|
||||
setIsOpen?: (v: boolean) => void
|
||||
}
|
||||
|
||||
export function TrailerModal(props: PlaylistsModalProps) {
|
||||
|
||||
const {
|
||||
trigger,
|
||||
trailerId,
|
||||
isOpen,
|
||||
setIsOpen,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
open={isOpen}
|
||||
onOpenChange={v => setIsOpen?.(v)}
|
||||
trigger={trigger}
|
||||
size="xl"
|
||||
side="right"
|
||||
contentClass="flex items-center justify-center"
|
||||
>
|
||||
<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>
|
||||
|
||||
<Content trailerId={trailerId} />
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
trailerId?: string | null
|
||||
}
|
||||
|
||||
export function Content(props: ContentProps) {
|
||||
|
||||
const {
|
||||
trailerId,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [loaded, setLoaded] = React.useState(true)
|
||||
const [muted, setMuted] = React.useState(true)
|
||||
|
||||
if (!trailerId) return <LuffyError title="No trailer found" />
|
||||
|
||||
return (
|
||||
<>
|
||||
{!loaded && <LoadingSpinner className="" />}
|
||||
<div
|
||||
className={cn(
|
||||
"relative aspect-video h-[85dvh] flex items-center overflow-hidden rounded-xl",
|
||||
!loaded && "hidden",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
src={`https://www.youtube.com/embed/${trailerId}`}
|
||||
title="YouTube Video"
|
||||
className="w-full aspect-video rounded-xl"
|
||||
allowFullScreen
|
||||
loading="lazy" // Lazy load the iframe
|
||||
/>
|
||||
{/*<video*/}
|
||||
{/* src={`https://yewtu.be/latest_version?id=${animeDetails?.trailer?.id}&itag=18`}*/}
|
||||
{/* className={cn(*/}
|
||||
{/* "w-full h-full absolute left-0",*/}
|
||||
{/* )}*/}
|
||||
{/* playsInline*/}
|
||||
{/* preload="none"*/}
|
||||
{/* loop*/}
|
||||
{/* autoPlay*/}
|
||||
{/* muted={muted}*/}
|
||||
{/* onLoadedData={() => setLoaded(true)}*/}
|
||||
{/*/>*/}
|
||||
{/*{<IconButton*/}
|
||||
{/* intent="white-basic"*/}
|
||||
{/* className="absolute bottom-4 left-4"*/}
|
||||
{/* icon={muted ? <FaVolumeMute /> : <FaVolumeHigh />}*/}
|
||||
{/* onClick={() => setMuted(p => !p)}*/}
|
||||
{/*/>}*/}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
@@ -0,0 +1,124 @@
|
||||
import { Anime_AutoDownloaderRule, Anime_Entry } from "@/api/generated/types"
|
||||
import { useGetAutoDownloaderRulesByAnime } from "@/api/hooks/auto_downloader.hooks"
|
||||
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { AutoDownloaderRuleItem } from "@/app/(main)/auto-downloader/_components/autodownloader-rule-item"
|
||||
import { AutoDownloaderRuleForm } from "@/app/(main)/auto-downloader/_containers/autodownloader-rule-form"
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { useBoolean } from "@/hooks/use-disclosure"
|
||||
import { useAtomValue } from "jotai/react"
|
||||
import React from "react"
|
||||
import { BiPlus } from "react-icons/bi"
|
||||
import { TbWorldDownload } from "react-icons/tb"
|
||||
|
||||
type AnimeAutoDownloaderButtonProps = {
|
||||
entry: Anime_Entry
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export function AnimeAutoDownloaderButton(props: AnimeAutoDownloaderButtonProps) {
|
||||
|
||||
const {
|
||||
entry,
|
||||
size,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const { data: rules, isLoading } = useGetAutoDownloaderRulesByAnime(entry.mediaId, !!serverStatus?.settings?.autoDownloader?.enabled)
|
||||
|
||||
const [isModalOpen, setIsModalOpen] = React.useState(false)
|
||||
|
||||
if (
|
||||
isLoading
|
||||
|| !serverStatus?.settings?.autoDownloader?.enabled
|
||||
|| !entry.listData
|
||||
) return null
|
||||
|
||||
const isTracked = !!rules?.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
title="Auto Downloader"
|
||||
contentClass="max-w-3xl"
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
trigger={<IconButton
|
||||
icon={isTracked ? <TbWorldDownload /> : <TbWorldDownload />}
|
||||
loading={isLoading}
|
||||
intent={isTracked ? "primary-subtle" : "gray-subtle"}
|
||||
size={size}
|
||||
{...rest}
|
||||
/>}
|
||||
>
|
||||
<Content entry={entry} rules={rules} />
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type ContentProps = {
|
||||
entry: Anime_Entry
|
||||
rules: Anime_AutoDownloaderRule[] | undefined
|
||||
}
|
||||
|
||||
export function Content(props: ContentProps) {
|
||||
|
||||
const {
|
||||
entry,
|
||||
rules,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const userMedia = useAtomValue(__anilist_userAnimeMediaAtom)
|
||||
const createRuleModal = useBoolean(false)
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
|
||||
<div className="flex w-full">
|
||||
<div className="flex-1"></div>
|
||||
<Modal
|
||||
open={createRuleModal.active}
|
||||
onOpenChange={createRuleModal.set}
|
||||
title="Create a new rule"
|
||||
contentClass="max-w-3xl"
|
||||
trigger={<Button
|
||||
className="rounded-full"
|
||||
intent="success-subtle"
|
||||
leftIcon={<BiPlus />}
|
||||
onClick={() => {
|
||||
createRuleModal.on()
|
||||
}}
|
||||
>
|
||||
New Rule
|
||||
</Button>}
|
||||
>
|
||||
<AutoDownloaderRuleForm
|
||||
mediaId={entry.mediaId}
|
||||
type="create"
|
||||
onRuleCreatedOrDeleted={() => createRuleModal.off()}
|
||||
/>
|
||||
</Modal>
|
||||
</div>
|
||||
|
||||
{!rules?.length && (
|
||||
<LuffyError title={null}>
|
||||
No rules found for this anime.
|
||||
</LuffyError>
|
||||
)}
|
||||
|
||||
{rules?.map(rule => (
|
||||
<AutoDownloaderRuleItem
|
||||
key={rule.dbId}
|
||||
rule={rule}
|
||||
userMedia={userMedia}
|
||||
/>
|
||||
))}
|
||||
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,61 @@
|
||||
import { AL_BaseAnime, Anime_EntryLibraryData, Anime_NakamaEntryLibraryData, Nullish } from "@/api/generated/types"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { anilist_getCurrentEpisodes } from "@/lib/helpers/media"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
import { MdOutlinePlayCircleOutline } from "react-icons/md"
|
||||
|
||||
type AnimeEntryCardUnwatchedBadgeProps = {
|
||||
progress: number
|
||||
media: Nullish<AL_BaseAnime>
|
||||
libraryData: Nullish<Anime_EntryLibraryData>
|
||||
nakamaLibraryData: Nullish<Anime_NakamaEntryLibraryData>
|
||||
}
|
||||
|
||||
export function AnimeEntryCardUnwatchedBadge(props: AnimeEntryCardUnwatchedBadgeProps) {
|
||||
|
||||
const {
|
||||
media,
|
||||
libraryData,
|
||||
progress,
|
||||
nakamaLibraryData,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { showAnimeUnwatchedCount } = useThemeSettings()
|
||||
|
||||
if (!showAnimeUnwatchedCount) return null
|
||||
|
||||
const progressTotal = anilist_getCurrentEpisodes(media)
|
||||
const unwatched = progressTotal - (progress ?? 0)
|
||||
|
||||
const unwatchedFromLibrary = nakamaLibraryData?.unwatchedCount ?? libraryData?.unwatchedCount ?? 0
|
||||
const isInLibrary = !!nakamaLibraryData?.mainFileCount || !!libraryData?.mainFileCount
|
||||
|
||||
const unwatchedCount = isInLibrary ? unwatchedFromLibrary : unwatched
|
||||
|
||||
if (unwatchedCount <= 0) return null
|
||||
|
||||
return (
|
||||
<div
|
||||
data-anime-entry-card-unwatched-badge-container
|
||||
className={cn(
|
||||
"flex w-full z-[5]",
|
||||
)}
|
||||
>
|
||||
<Badge
|
||||
intent="unstyled"
|
||||
size="lg"
|
||||
className="text-sm tracking-wide flex gap-1 items-center rounded-[--radius-md] border-0 bg-transparent px-1.5"
|
||||
data-anime-entry-card-unwatched-badge
|
||||
>
|
||||
<MdOutlinePlayCircleOutline className="text-lg" /><span className="text-[--foreground] font-normal">{unwatchedCount}</span>
|
||||
</Badge>
|
||||
</div>
|
||||
)
|
||||
|
||||
// return (
|
||||
// <MediaEntryProgressBadge progress={progress} progressTotal={progressTotal} {...rest} />
|
||||
// )
|
||||
}
|
||||
@@ -0,0 +1,47 @@
|
||||
import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks"
|
||||
import { IconButton, IconButtonProps } from "@/components/ui/button"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import React, { memo } from "react"
|
||||
import { BiLockOpenAlt } from "react-icons/bi"
|
||||
import { VscVerified } from "react-icons/vsc"
|
||||
|
||||
type ToggleLockFilesButtonProps = {
|
||||
mediaId: number
|
||||
allFilesLocked: boolean
|
||||
size?: IconButtonProps["size"]
|
||||
}
|
||||
|
||||
export const ToggleLockFilesButton = memo((props: ToggleLockFilesButtonProps) => {
|
||||
const { mediaId, allFilesLocked, size = "sm" } = props
|
||||
const [isLocked, setIsLocked] = React.useState(allFilesLocked)
|
||||
const { mutate: performBulkAction, isPending } = useAnimeEntryBulkAction(mediaId)
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setIsLocked(allFilesLocked)
|
||||
}, [allFilesLocked])
|
||||
|
||||
const handleToggle = React.useCallback(() => {
|
||||
performBulkAction({
|
||||
mediaId,
|
||||
action: "toggle-lock",
|
||||
})
|
||||
setIsLocked(p => !p)
|
||||
}, [mediaId])
|
||||
|
||||
return (
|
||||
<Tooltip
|
||||
trigger={
|
||||
<IconButton
|
||||
icon={isLocked ? <VscVerified /> : <BiLockOpenAlt />}
|
||||
intent={isLocked ? "success-subtle" : "warning-subtle"}
|
||||
size={size}
|
||||
className="hover:opacity-60"
|
||||
loading={isPending}
|
||||
onClick={handleToggle}
|
||||
/>
|
||||
}
|
||||
>
|
||||
{isLocked ? "Unlock all files" : "Lock all files"}
|
||||
</Tooltip>
|
||||
)
|
||||
})
|
||||
@@ -0,0 +1,265 @@
|
||||
"use client"
|
||||
|
||||
import { Updater_Announcement, Updater_AnnouncementAction, Updater_AnnouncementSeverity } from "@/api/generated/types"
|
||||
import { useGetAnnouncements } from "@/api/hooks/status.hooks"
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { useUpdateEffect } from "@/components/ui/core/hooks"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { logger } from "@/lib/helpers/debug"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants"
|
||||
import { useAtom } from "jotai"
|
||||
import { atomWithStorage } from "jotai/utils"
|
||||
import React from "react"
|
||||
import { FiAlertTriangle, FiInfo } from "react-icons/fi"
|
||||
import { useEffectOnce } from "react-use"
|
||||
import { toast } from "sonner"
|
||||
|
||||
const dismissedAnnouncementsAtom = atomWithStorage<string[]>("sea-dismissed-announcements", [])
|
||||
|
||||
export function Announcements() {
|
||||
const { data: announcements, mutate: getAnnouncements } = useGetAnnouncements()
|
||||
const [dismissedAnnouncements, setDismissedAnnouncements] = useAtom(dismissedAnnouncementsAtom)
|
||||
const [hasShownToasts, setHasShownToasts] = React.useState<string[]>([])
|
||||
|
||||
function handleCheckForAnnouncements() {
|
||||
getAnnouncements({
|
||||
platform: __isElectronDesktop__ ? "denshi" : __isTauriDesktop__ ? "tauri" : "web",
|
||||
})
|
||||
}
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.CHECK_FOR_ANNOUNCEMENTS,
|
||||
onMessage: () => {
|
||||
handleCheckForAnnouncements()
|
||||
},
|
||||
})
|
||||
|
||||
useEffectOnce(() => {
|
||||
handleCheckForAnnouncements()
|
||||
})
|
||||
|
||||
useUpdateEffect(() => {
|
||||
if (announcements) {
|
||||
logger("Announcement").info("Fetched announcements", announcements)
|
||||
// Clean up dismissed announcements that are no longer in the announcements
|
||||
setDismissedAnnouncements(prev => prev.filter(id => announcements.some(a => a.id === id)))
|
||||
}
|
||||
}, [announcements])
|
||||
|
||||
const filteredAnnouncements = React.useMemo(() => {
|
||||
if (!announcements) return []
|
||||
|
||||
return announcements
|
||||
.filter(announcement => {
|
||||
if (announcement.notDismissible) return true
|
||||
if (dismissedAnnouncements.includes(announcement.id)) return false
|
||||
return true
|
||||
})
|
||||
.sort((a, b) => b.priority - a.priority)
|
||||
}, [announcements, dismissedAnnouncements])
|
||||
|
||||
const bannerAnnouncements = filteredAnnouncements.filter(a => a.type === "banner")
|
||||
const dialogAnnouncements = filteredAnnouncements.filter(a => a.type === "dialog")
|
||||
const toastAnnouncements = filteredAnnouncements.filter(a => a.type === "toast")
|
||||
|
||||
const dismissAnnouncement = (id: string) => {
|
||||
setDismissedAnnouncements(prev => [...prev, id])
|
||||
}
|
||||
|
||||
const getSeverityIcon = (severity: Updater_AnnouncementSeverity) => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
return <FiInfo className="size-5 mt-1" />
|
||||
case "warning":
|
||||
return <FiAlertTriangle className="size-5 mt-1" />
|
||||
case "error":
|
||||
return <FiAlertTriangle className="size-5 mt-1" />
|
||||
case "critical":
|
||||
return <FiAlertTriangle className="size-5 mt-1" />
|
||||
default:
|
||||
return <FiInfo className="size-5 mt-1" />
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityIntent = (severity: Updater_AnnouncementSeverity) => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
return "info-basic"
|
||||
case "warning":
|
||||
return "warning-basic"
|
||||
case "error":
|
||||
return "alert-basic"
|
||||
case "critical":
|
||||
return "alert-basic"
|
||||
default:
|
||||
return "info-basic"
|
||||
}
|
||||
}
|
||||
|
||||
const getSeverityBadgeIntent = (severity: Updater_AnnouncementSeverity) => {
|
||||
switch (severity) {
|
||||
case "info":
|
||||
return "blue"
|
||||
case "warning":
|
||||
return "warning"
|
||||
case "error":
|
||||
return "alert"
|
||||
case "critical":
|
||||
return "alert-solid"
|
||||
default:
|
||||
return "blue"
|
||||
}
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
toastAnnouncements.forEach(announcement => {
|
||||
if (!hasShownToasts.includes(announcement.id)) {
|
||||
const toastFunction = announcement.severity === "error" || announcement.severity === "critical"
|
||||
? toast.error
|
||||
: announcement.severity === "warning"
|
||||
? toast.warning
|
||||
: toast.info
|
||||
|
||||
toastFunction(announcement.message, {
|
||||
position: "top-right",
|
||||
id: announcement.id,
|
||||
duration: Infinity,
|
||||
action: !announcement.notDismissible ? {
|
||||
label: "OK",
|
||||
onClick: () => dismissAnnouncement(announcement.id),
|
||||
} : undefined,
|
||||
onDismiss: !announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined,
|
||||
onAutoClose: !announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined,
|
||||
})
|
||||
|
||||
setHasShownToasts(prev => [...prev, announcement.id])
|
||||
}
|
||||
})
|
||||
}, [toastAnnouncements, hasShownToasts])
|
||||
|
||||
const handleDialogClose = (announcement: Updater_Announcement) => {
|
||||
if (!announcement.notDismissible) {
|
||||
dismissAnnouncement(announcement.id)
|
||||
}
|
||||
}
|
||||
|
||||
const handleActionClick = (action: Updater_AnnouncementAction) => {
|
||||
if (action.type === "link" && action.url) {
|
||||
window.open(action.url, "_blank")
|
||||
}
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
{bannerAnnouncements.map((announcement, index) => (
|
||||
<div
|
||||
key={announcement.id + "" + String(index)} className={cn(
|
||||
"fixed bottom-0 left-0 right-0 z-[999] bg-[--background] border-b border-[--border] shadow-lg bg-gradient-to-br",
|
||||
)}
|
||||
>
|
||||
<Alert
|
||||
intent={getSeverityIntent(announcement.severity) as any}
|
||||
title={announcement.title}
|
||||
|
||||
description={<div className="space-y-2">
|
||||
<p>
|
||||
{announcement.message}
|
||||
</p>
|
||||
{announcement.actions && announcement.actions.length > 0 && (
|
||||
<div className="flex gap-2">
|
||||
{announcement.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
size="sm"
|
||||
intent="white-outline"
|
||||
onClick={() => handleActionClick(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
</div>}
|
||||
icon={getSeverityIcon(announcement.severity)}
|
||||
onClose={!announcement.notDismissible ? () => dismissAnnouncement(announcement.id) : undefined}
|
||||
className={cn(
|
||||
"rounded-none border-0 border-t shadow-[0_0_10px_0_rgba(0,0,0,0.05)] bg-gradient-to-br",
|
||||
announcement.severity === "critical" && "from-red-950/95 to-red-900/60 dark:text-red-100",
|
||||
announcement.severity === "error" && "from-red-950/95 to-red-900/60 dark:text-red-100",
|
||||
announcement.severity === "warning" && "from-amber-950/95 to-amber-900/60 dark:text-amber-100",
|
||||
announcement.severity === "info" && "from-blue-950/95 to-blue-900/60 dark:text-blue-100",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
||||
{dialogAnnouncements.map((announcement, index) => (
|
||||
<Modal
|
||||
key={announcement.id + "" + String(index)}
|
||||
open={true}
|
||||
onOpenChange={(open) => {
|
||||
if (!open) {
|
||||
handleDialogClose(announcement)
|
||||
}
|
||||
}}
|
||||
hideCloseButton={announcement.notDismissible}
|
||||
title={
|
||||
<div className="flex items-center gap-2">
|
||||
<span
|
||||
className={cn(
|
||||
announcement.severity === "info" && "text-blue-300",
|
||||
announcement.severity === "warning" && "text-amber-300",
|
||||
announcement.severity === "error" && "text-red-300",
|
||||
announcement.severity === "critical" && "text-red-300",
|
||||
)}
|
||||
>
|
||||
{getSeverityIcon(announcement.severity)}
|
||||
</span>
|
||||
{announcement.title || "Announcement"}
|
||||
{/* <Badge
|
||||
intent={getSeverityBadgeIntent(announcement.severity) as any}
|
||||
size="sm"
|
||||
>
|
||||
{announcement.severity.toUpperCase()}
|
||||
</Badge> */}
|
||||
</div>
|
||||
}
|
||||
overlayClass="bg-gray-950/10"
|
||||
>
|
||||
<div className="space-y-4">
|
||||
<p className="text-[--muted]">
|
||||
{announcement.message}
|
||||
</p>
|
||||
|
||||
<div className="flex gap-2 pt-2">
|
||||
{announcement.actions && announcement.actions.length > 0 && (
|
||||
<div className="flex gap-2 flex-wrap">
|
||||
{announcement.actions.map((action, index) => (
|
||||
<Button
|
||||
key={index}
|
||||
intent="gray-outline"
|
||||
onClick={() => handleActionClick(action)}
|
||||
>
|
||||
{action.label}
|
||||
</Button>
|
||||
))}
|
||||
</div>
|
||||
)}
|
||||
<div className="flex-1" />
|
||||
<Button
|
||||
intent="white"
|
||||
onClick={() => handleDialogClose(announcement)}
|
||||
>
|
||||
OK
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
</Modal>
|
||||
))}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,34 @@
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { ContextMenuContent } from "@/components/ui/context-menu"
|
||||
import { ContextMenu } from "@radix-ui/react-context-menu"
|
||||
|
||||
export type SeaContextMenuProps = {
|
||||
content: React.ReactNode
|
||||
children?: React.ReactNode
|
||||
availableWhenOffline?: boolean
|
||||
hideMenuIf?: boolean
|
||||
}
|
||||
|
||||
export function SeaContextMenu(props: SeaContextMenuProps) {
|
||||
|
||||
const {
|
||||
content,
|
||||
children,
|
||||
availableWhenOffline = true,
|
||||
hideMenuIf,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
return (
|
||||
<ContextMenu data-sea-context-menu {...rest}>
|
||||
{children}
|
||||
|
||||
{(((serverStatus?.isOffline && availableWhenOffline) || !serverStatus?.isOffline) && !hideMenuIf) &&
|
||||
<ContextMenuContent className="max-w-xs" data-sea-context-menu-content>
|
||||
{content}
|
||||
</ContextMenuContent>}
|
||||
</ContextMenu>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,51 @@
|
||||
"use client"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getAssetUrl } from "@/lib/server/assets"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { motion } from "motion/react"
|
||||
import React from "react"
|
||||
|
||||
type CustomBackgroundImageProps = React.ComponentPropsWithoutRef<"div"> & {}
|
||||
|
||||
export function CustomBackgroundImage(props: CustomBackgroundImageProps) {
|
||||
|
||||
const {
|
||||
className,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!ts.libraryScreenCustomBackgroundImage && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, delay: 0.1 }}
|
||||
className="fixed w-full h-full inset-0"
|
||||
>
|
||||
|
||||
{ts.libraryScreenCustomBackgroundBlur !== "" && <div
|
||||
className="fixed w-full h-full inset-0 z-[0]"
|
||||
style={{ backdropFilter: `blur(${ts.libraryScreenCustomBackgroundBlur})` }}
|
||||
>
|
||||
</div>}
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"fixed w-full h-full inset-0 z-[-1] bg-no-repeat bg-cover bg-center transition-opacity duration-1000",
|
||||
className,
|
||||
)}
|
||||
style={{
|
||||
backgroundImage: `url(${getAssetUrl(ts.libraryScreenCustomBackgroundImage)})`,
|
||||
opacity: ts.libraryScreenCustomBackgroundOpacity / 100,
|
||||
}}
|
||||
{...rest}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,54 @@
|
||||
/**
|
||||
* This file contains bottom gradients for items
|
||||
* They change responsively based on the UI settings
|
||||
*/
|
||||
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export function MediaCardBodyBottomGradient() {
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
if (!!ts.libraryScreenCustomBackgroundImage || ts.hasCustomBackgroundColor) {
|
||||
return (
|
||||
<div
|
||||
data-media-card-body-bottom-gradient
|
||||
className="z-[5] absolute inset-x-0 bottom-0 w-full h-[40%] opacity-80 bg-gradient-to-t from-[#0c0c0c] to-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-card-body-bottom-gradient
|
||||
className="z-[5] absolute inset-x-0 bottom-0 w-full opacity-90 to-40% h-[50%] bg-gradient-to-t from-[#0c0c0c] to-transparent"
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
export function EpisodeItemBottomGradient() {
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
// if (!!ts.libraryScreenCustomBackgroundImage || ts.hasCustomBackgroundColor) {
|
||||
// return (
|
||||
// <div
|
||||
// className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-80 md:h-[60%] bg-gradient-to-t from-[--background] to-transparent"
|
||||
// />
|
||||
// )
|
||||
// }
|
||||
|
||||
if (ts.useLegacyEpisodeCard) {
|
||||
return <div
|
||||
data-episode-item-bottom-gradient
|
||||
className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-90 md:h-[80%] bg-gradient-to-t from-[#0c0c0c] to-transparent"
|
||||
/>
|
||||
}
|
||||
|
||||
return <div
|
||||
data-episode-item-bottom-gradient
|
||||
className="z-[1] absolute inset-x-0 bottom-0 w-full h-full opacity-50 md:h-[70%] bg-gradient-to-t from-[#0c0c0c] to-transparent"
|
||||
/>
|
||||
}
|
||||
@@ -0,0 +1,5 @@
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
|
||||
export const TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE = cn(
|
||||
"lg:w-[calc(100%_+_5rem)] lg:left-[-5rem]",
|
||||
)
|
||||
@@ -0,0 +1,50 @@
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
|
||||
const __errorExplainer_overlayOpenAtom = atom(false)
|
||||
const __errorExplainer_errorAtom = atom<string | null>(null)
|
||||
|
||||
export function ErrorExplainer() {
|
||||
const [open, setOpen] = useAtom(__errorExplainer_overlayOpenAtom)
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <div
|
||||
className={cn(
|
||||
"error-explainer-ui",
|
||||
"fixed z-[100] bottom-8 w-fit left-20 h-fit flex",
|
||||
"transition-all duration-300 select-none",
|
||||
// !isRecording && "hover:translate-y-[-2px]",
|
||||
// isRecording && "justify-end",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 bg-gray-900 border text-white rounded-xl",
|
||||
"transition-colors duration-300",
|
||||
// isRecording && "p-0 border-transparent bg-transparent",
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
export function useErrorExplainer() {
|
||||
const [error, setError] = useAtom(__errorExplainer_errorAtom)
|
||||
|
||||
const explaination = React.useMemo(() => {
|
||||
if (!error) return null
|
||||
|
||||
if (error.includes("could not open and play video")) {
|
||||
|
||||
}
|
||||
|
||||
return ""
|
||||
}, [error])
|
||||
|
||||
return { error, setError }
|
||||
}
|
||||
@@ -0,0 +1,42 @@
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
|
||||
type ExternalPlayerLinkButtonProps = {}
|
||||
|
||||
export const __externalPlayerLinkButton_linkAtom = atom<string | null>(null)
|
||||
|
||||
export function ExternalPlayerLinkButton(props: ExternalPlayerLinkButtonProps) {
|
||||
|
||||
const {} = props
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const [link, setLink] = useAtom(__externalPlayerLinkButton_linkAtom)
|
||||
|
||||
if (!link) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="fixed bottom-2 right-2 z-50">
|
||||
<SeaLink href={link} target="_blank" prefetch={false}>
|
||||
<Button
|
||||
rounded
|
||||
size="lg"
|
||||
className="animate-bounce"
|
||||
onClick={() => {
|
||||
React.startTransition(() => {
|
||||
setLink(null)
|
||||
})
|
||||
}}
|
||||
>
|
||||
Open media in external player
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,880 @@
|
||||
import { Status } from "@/api/generated/types"
|
||||
import { useGettingStarted } from "@/api/hooks/settings.hooks"
|
||||
import { useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { GlowingEffect } from "@/components/shared/glowing-effect"
|
||||
import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo"
|
||||
import { Alert } from "@/components/ui/alert"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { Card, CardProps } from "@/components/ui/card"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Field, Form } from "@/components/ui/form"
|
||||
import {
|
||||
DEFAULT_TORRENT_PROVIDER,
|
||||
getDefaultIinaSocket,
|
||||
getDefaultMpvSocket,
|
||||
getDefaultSettings,
|
||||
gettingStartedSchema,
|
||||
TORRENT_PROVIDER,
|
||||
useDefaultSettingsPaths,
|
||||
} from "@/lib/server/settings"
|
||||
import { AnimatePresence, motion } from "motion/react"
|
||||
import { useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { useFormContext, useWatch } from "react-hook-form"
|
||||
import { BiChevronLeft, BiChevronRight, BiCloud, BiCog, BiDownload, BiFolder, BiPlay, BiRocket } from "react-icons/bi"
|
||||
import { FaBook, FaDiscord } from "react-icons/fa"
|
||||
import { HiOutlineDesktopComputer } from "react-icons/hi"
|
||||
import { HiEye, HiGlobeAlt } from "react-icons/hi2"
|
||||
import { ImDownload } from "react-icons/im"
|
||||
import { IoPlayForwardCircleSharp } from "react-icons/io5"
|
||||
import { MdOutlineBroadcastOnHome } from "react-icons/md"
|
||||
import { RiFolderDownloadFill } from "react-icons/ri"
|
||||
import { SiMpv, SiQbittorrent, SiTransmission, SiVlcmediaplayer } from "react-icons/si"
|
||||
|
||||
const containerVariants = {
|
||||
hidden: { opacity: 0 },
|
||||
visible: {
|
||||
opacity: 1,
|
||||
transition: {
|
||||
staggerChildren: 0.05,
|
||||
delayChildren: 0.1,
|
||||
},
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
transition: {
|
||||
staggerChildren: 0.03,
|
||||
staggerDirection: -1,
|
||||
},
|
||||
},
|
||||
}
|
||||
|
||||
const itemVariants = {
|
||||
hidden: {
|
||||
opacity: 0,
|
||||
y: 10,
|
||||
},
|
||||
visible: {
|
||||
opacity: 1,
|
||||
y: 0,
|
||||
},
|
||||
exit: {
|
||||
opacity: 0,
|
||||
y: -10,
|
||||
},
|
||||
}
|
||||
|
||||
const stepVariants = {
|
||||
enter: (direction: number) => ({
|
||||
x: direction > 0 ? 40 : -40,
|
||||
opacity: 0,
|
||||
}),
|
||||
center: {
|
||||
zIndex: 1,
|
||||
x: 0,
|
||||
opacity: 1,
|
||||
},
|
||||
exit: (direction: number) => ({
|
||||
zIndex: 0,
|
||||
x: direction < 0 ? 40 : -40,
|
||||
opacity: 0,
|
||||
}),
|
||||
}
|
||||
|
||||
const STEPS = [
|
||||
{
|
||||
id: "library",
|
||||
title: "Anime Library",
|
||||
description: "Choose your anime collection folder",
|
||||
icon: BiFolder,
|
||||
gradient: "from-blue-500 to-cyan-500",
|
||||
},
|
||||
{
|
||||
id: "player",
|
||||
title: "Media Player",
|
||||
description: "Configure your video player",
|
||||
icon: BiPlay,
|
||||
gradient: "from-green-500 to-emerald-500",
|
||||
},
|
||||
{
|
||||
id: "torrents",
|
||||
title: "Torrent Setup",
|
||||
description: "Set up downloading and providers",
|
||||
icon: BiDownload,
|
||||
gradient: "from-orange-500 to-red-500",
|
||||
},
|
||||
{
|
||||
id: "debrid",
|
||||
title: "Debrid Service",
|
||||
description: "Optional premium streaming",
|
||||
icon: BiCloud,
|
||||
gradient: "from-indigo-500 to-purple-500",
|
||||
},
|
||||
{
|
||||
id: "features",
|
||||
title: "Features",
|
||||
description: "Enable additional features",
|
||||
icon: BiCog,
|
||||
gradient: "from-teal-500 to-blue-500",
|
||||
},
|
||||
]
|
||||
|
||||
function StepIndicator({ currentStep, totalSteps, onStepClick }: { currentStep: number; totalSteps: number; onStepClick: (step: number) => void }) {
|
||||
return (
|
||||
<div className="mb-12">
|
||||
<div className="flex items-center justify-center mb-6">
|
||||
<div className="relative mx-auto w-24 h-24">
|
||||
<motion.img
|
||||
src="/logo_2.png"
|
||||
alt="Seanime Logo"
|
||||
className="w-full h-full object-contain"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ duration: 0.3 }}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
<div className="text-center mb-8">
|
||||
<p className="text-[--muted] text-sm ">
|
||||
These settings can be changed later
|
||||
</p>
|
||||
</div>
|
||||
|
||||
<div className="flex items-start justify-between max-w-4xl mx-auto px-4 border p-4 rounded-lg relative bg-gray-900/50 backdrop-blur-sm">
|
||||
<GlowingEffect
|
||||
spread={40}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
// movementDuration={4}
|
||||
className="opacity-30"
|
||||
/>
|
||||
|
||||
{STEPS.map((step, i) => (
|
||||
<div
|
||||
key={step.id}
|
||||
onClick={(e) => {
|
||||
onStepClick(i)
|
||||
}}
|
||||
className={cn("flex flex-col items-center relative group transition-all duration-200 focus:outline-none rounded-lg p-2 w-36",
|
||||
"cursor-pointer")}
|
||||
>
|
||||
<motion.div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-full flex items-center justify-center mb-3 transition-all duration-200",
|
||||
// i <= currentStep
|
||||
// ? `bg-gradient-to-r ${step.gradient} text-white`
|
||||
// : "bg-gray-700 text-gray-500",
|
||||
i <= currentStep
|
||||
? "bg-gradient-to-br from-brand-500/20 to-purple-500/20 border border-brand-500/20"
|
||||
: "bg-[--subtle] text-[--muted]",
|
||||
i <= currentStep && "group-hover:shadow-md",
|
||||
)}
|
||||
initial={{ scale: 0.9 }}
|
||||
animate={{
|
||||
scale: i === currentStep ? 1.05 : 1,
|
||||
}}
|
||||
transition={{ duration: 0.2 }}
|
||||
>
|
||||
<step.icon className="w-6 h-6" />
|
||||
</motion.div>
|
||||
|
||||
<div className="text-center">
|
||||
<h3
|
||||
className={cn(
|
||||
"text-sm font-medium transition-colors duration-200 tracking-wide",
|
||||
i <= currentStep ? "text-white" : "text-[--muted]",
|
||||
"group-hover:text-[--brand]",
|
||||
)}
|
||||
>
|
||||
{step.title}
|
||||
</h3>
|
||||
{/* <p className="text-xs text-gray-500 mt-1 max-w-20">
|
||||
{step.description}
|
||||
</p> */}
|
||||
</div>
|
||||
|
||||
{/* {i < STEPS.length - 1 && (
|
||||
<div className="absolute top-8 left-full w-[40%] h-0.5 -translate-y-0 hidden md:block">
|
||||
<div className={cn(
|
||||
"h-full transition-all duration-300",
|
||||
i < currentStep
|
||||
? "bg-[--subtle]"
|
||||
: "bg-gray-600"
|
||||
)} />
|
||||
</div>
|
||||
)} */}
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
function StepCard({ children, className, ...props }: CardProps) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={itemVariants}
|
||||
className={cn(
|
||||
"relative rounded-xl bg-gray-900/50 backdrop-blur-sm border",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<GlowingEffect
|
||||
spread={40}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
// movementDuration={4}
|
||||
className="opacity-30"
|
||||
/>
|
||||
<Card className="bg-transparent border-none shadow-none p-6">
|
||||
{children}
|
||||
</Card>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
function LibraryStep({ form }: { form: any }) {
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold">Anime Library</h2>
|
||||
<p className="text-[--muted] text-sm max-w-lg mx-auto">
|
||||
Choose the folder where your anime files are stored. This is where Seanime will scan for your collection.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<StepCard className="max-w-2xl mx-auto">
|
||||
<motion.div variants={itemVariants}>
|
||||
<Field.DirectorySelector
|
||||
name="libraryPath"
|
||||
label="Anime Library Path"
|
||||
leftIcon={<BiFolder className="text-blue-500" />}
|
||||
shouldExist
|
||||
help="Select the main folder containing your anime collection. You can add more folders later."
|
||||
className="w-full"
|
||||
/>
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function PlayerStep({ form, status }: { form: any, status: Status }) {
|
||||
const { watch } = useFormContext()
|
||||
const defaultPlayer = useWatch({ name: "defaultPlayer" })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold">Media Player</h2>
|
||||
<p className="text-[--muted] text-sm max-w-lg mx-auto">
|
||||
Configure your preferred media player for watching anime and tracking progress automatically.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<StepCard className="max-w-2xl mx-auto">
|
||||
<motion.div variants={itemVariants} className="space-y-6">
|
||||
<Field.Select
|
||||
name="defaultPlayer"
|
||||
label="Media Player"
|
||||
help={status?.os !== "darwin"
|
||||
? "MPV is recommended for better subtitle rendering, torrent streaming."
|
||||
: "Both MPV and IINA are recommended for macOS."}
|
||||
required
|
||||
leftIcon={<BiPlay className="text-green-500" />}
|
||||
options={[
|
||||
{ label: "MPV (Recommended)", value: "mpv" },
|
||||
{ label: "VLC", value: "vlc" },
|
||||
...(status?.os === "windows" ? [{ label: "MPC-HC", value: "mpc-hc" }] : []),
|
||||
...(status?.os === "darwin" ? [{ label: "IINA", value: "iina" }] : []),
|
||||
]}
|
||||
/>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{defaultPlayer === "mpv" && (
|
||||
<>
|
||||
<p>
|
||||
On Windows, install MPV easily using Scoop or Chocolatey. On macOS, install MPV using Homebrew.
|
||||
</p>
|
||||
<motion.div
|
||||
key="mpv"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 p-4 rounded-lg bg-gray-800/30"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<SiMpv className="w-6 h-6 text-purple-400" />
|
||||
<h4 className="font-semibold">MPV Configuration</h4>
|
||||
</div>
|
||||
<Field.Text
|
||||
name="mpvSocket"
|
||||
label="Socket / Pipe Path"
|
||||
help="Path for MPV IPC communication"
|
||||
/>
|
||||
</motion.div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{defaultPlayer === "iina" && (
|
||||
<motion.div
|
||||
key="iina"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 p-4 rounded-lg bg-gray-800/30"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<IoPlayForwardCircleSharp className="w-6 h-6 text-blue-400" />
|
||||
<h4 className="font-semibold">IINA Configuration</h4>
|
||||
</div>
|
||||
<Field.Text
|
||||
name="iinaSocket"
|
||||
label="Socket / Pipe Path"
|
||||
help="Path for IINA IPC communication"
|
||||
/>
|
||||
|
||||
<Alert
|
||||
intent="info-basic"
|
||||
description={<p>For IINA to work correctly with Seanime, make sure <strong>Quit after all windows are
|
||||
closed</strong> is <span
|
||||
className="underline"
|
||||
>checked</span> and <strong>Keep window open after playback
|
||||
finishes</strong> is <span className="underline">unchecked</span> in
|
||||
your IINA general settings.</p>}
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{defaultPlayer === "vlc" && (
|
||||
<motion.div
|
||||
key="vlc"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 p-4 rounded-lg bg-gray-800/30"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<SiVlcmediaplayer className="w-6 h-6 text-orange-500" />
|
||||
<h4 className="font-semibold">VLC Configuration</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field.Text name="mediaPlayerHost" label="Host" />
|
||||
<Field.Number name="vlcPort" label="Port" formatOptions={{ useGrouping: false }} />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field.Text name="vlcUsername" label="Username" />
|
||||
<Field.Text name="vlcPassword" label="Password" />
|
||||
</div>
|
||||
<Field.Text name="vlcPath" label="VLC Executable Path" />
|
||||
</motion.div>
|
||||
)}
|
||||
|
||||
{defaultPlayer === "mpc-hc" && (
|
||||
<motion.div
|
||||
key="mpc-hc"
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 p-4 rounded-lg bg-gray-800/30"
|
||||
>
|
||||
<div className="flex items-center space-x-3">
|
||||
<HiOutlineDesktopComputer className="w-6 h-6 text-blue-500" />
|
||||
<h4 className="font-semibold">MPC-HC Configuration</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4">
|
||||
<Field.Text name="mediaPlayerHost" label="Host" />
|
||||
<Field.Number name="mpcPort" label="Port" formatOptions={{ useGrouping: false }} />
|
||||
</div>
|
||||
<Field.Text name="mpcPath" label="MPC-HC Executable Path" />
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function TorrentStep({ form }: { form: any }) {
|
||||
const { watch } = useFormContext()
|
||||
const defaultTorrentClient = useWatch({ name: "defaultTorrentClient" })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold">Torrent Setup</h2>
|
||||
<p className="text-[--muted] text-sm max-w-lg mx-auto">
|
||||
Configure your default torrent provider and client.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 lg:grid-cols-2 gap-6 max-w-6xl mx-auto">
|
||||
<StepCard>
|
||||
<motion.div variants={itemVariants} className="space-y-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<RiFolderDownloadFill className="w-6 h-6 text-orange-500" />
|
||||
<h3 className="text-xl font-semibold">Torrent Provider</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[--muted]">
|
||||
Extension for finding anime torrents
|
||||
</p>
|
||||
<Field.Select
|
||||
name="torrentProvider"
|
||||
label="Provider"
|
||||
required
|
||||
options={[
|
||||
{ label: "AnimeTosho (Recommended)", value: TORRENT_PROVIDER.ANIMETOSHO },
|
||||
{ label: "Nyaa", value: TORRENT_PROVIDER.NYAA },
|
||||
{ label: "Nyaa (Non-English)", value: TORRENT_PROVIDER.NYAA_NON_ENG },
|
||||
]}
|
||||
help="AnimeTosho search results are more precise in most cases."
|
||||
/>
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
|
||||
<StepCard>
|
||||
<motion.div variants={itemVariants} className="space-y-4">
|
||||
<div className="flex items-center space-x-3 mb-4">
|
||||
<ImDownload className="w-6 h-6 text-blue-500" />
|
||||
<h3 className="text-xl font-semibold">Torrent Client</h3>
|
||||
</div>
|
||||
<p className="text-sm text-[--muted]">
|
||||
Client used to download anime torrents
|
||||
</p>
|
||||
<Field.Select
|
||||
name="defaultTorrentClient"
|
||||
label="Client"
|
||||
options={[
|
||||
{ label: "qBittorrent", value: "qbittorrent" },
|
||||
{ label: "Transmission", value: "transmission" },
|
||||
{ label: "None", value: "none" },
|
||||
]}
|
||||
/>
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
</div>
|
||||
|
||||
<AnimatePresence mode="wait">
|
||||
{(defaultTorrentClient === "qbittorrent" || defaultTorrentClient === "transmission") && (
|
||||
<StepCard className="max-w-4xl mx-auto">
|
||||
<motion.div
|
||||
key={defaultTorrentClient}
|
||||
initial={{ opacity: 0, scale: 0.95 }}
|
||||
animate={{ opacity: 1, scale: 1 }}
|
||||
exit={{ opacity: 0, scale: 0.95 }}
|
||||
className="space-y-6"
|
||||
>
|
||||
{defaultTorrentClient === "qbittorrent" && (
|
||||
<>
|
||||
<div className="flex items-center space-x-3">
|
||||
<SiQbittorrent className="w-8 h-8 text-blue-600" />
|
||||
<h4 className="text-xl font-semibold">qBittorrent Settings</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field.Text name="qbittorrentHost" label="Host" />
|
||||
<Field.Text name="qbittorrentUsername" label="Username" />
|
||||
<Field.Text name="qbittorrentPassword" label="Password" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-[200px_1fr]">
|
||||
<Field.Number name="qbittorrentPort" label="Port" formatOptions={{ useGrouping: false }} />
|
||||
<Field.Text name="qbittorrentPath" label="Executable Path" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{defaultTorrentClient === "transmission" && (
|
||||
<>
|
||||
<div className="flex items-center space-x-3">
|
||||
<SiTransmission className="w-8 h-8 text-red-600" />
|
||||
<h4 className="text-xl font-semibold">Transmission Settings</h4>
|
||||
</div>
|
||||
<div className="grid grid-cols-1 md:grid-cols-3 gap-4">
|
||||
<Field.Text name="transmissionHost" label="Host" />
|
||||
<Field.Text name="transmissionUsername" label="Username" />
|
||||
<Field.Text name="transmissionPassword" label="Password" />
|
||||
</div>
|
||||
<div className="grid grid-cols-2 gap-4 lg:grid-cols-[200px_1fr]">
|
||||
<Field.Number name="transmissionPort" label="Port" formatOptions={{ useGrouping: false }} />
|
||||
<Field.Text name="transmissionPath" label="Executable Path" />
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function DebridStep({ form }: { form: any }) {
|
||||
const debridProvider = useWatch({ name: "debridProvider" })
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold">Debrid Service</h2>
|
||||
<p className="text-[--muted] text-sm max-w-lg mx-auto">
|
||||
Debrid services offer faster downloads and instant streaming from the cloud.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<StepCard className="max-w-2xl mx-auto">
|
||||
<motion.div variants={itemVariants} className="space-y-6">
|
||||
<Field.Select
|
||||
name="debridProvider"
|
||||
label="Debrid Service"
|
||||
leftIcon={<BiCloud className="text-[--purple]" />}
|
||||
options={[
|
||||
{ label: "None", value: "none" },
|
||||
{ label: "TorBox", value: "torbox" },
|
||||
{ label: "Real-Debrid", value: "realdebrid" },
|
||||
]}
|
||||
/>
|
||||
|
||||
<AnimatePresence>
|
||||
{debridProvider !== "none" && debridProvider !== "" && (
|
||||
<motion.div
|
||||
initial={{ opacity: 0, height: 0 }}
|
||||
animate={{ opacity: 1, height: "auto" }}
|
||||
exit={{ opacity: 0, height: 0 }}
|
||||
className="space-y-4 p-4 rounded-lg bg-gray-800/30"
|
||||
>
|
||||
<Field.Text
|
||||
name="debridApiKey"
|
||||
label="API Key"
|
||||
help="The API key provided by the debrid service."
|
||||
/>
|
||||
</motion.div>
|
||||
)}
|
||||
</AnimatePresence>
|
||||
</motion.div>
|
||||
</StepCard>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
function FeaturesStep({ form }: { form: any }) {
|
||||
const features = [
|
||||
{
|
||||
name: "enableManga",
|
||||
icon: FaBook,
|
||||
title: "Manga",
|
||||
description: "Read and download manga chapters",
|
||||
gradient: "from-orange-500 to-yellow-700",
|
||||
},
|
||||
{
|
||||
name: "enableTorrentStreaming",
|
||||
icon: BiDownload,
|
||||
title: "Torrent Streaming",
|
||||
description: "Stream torrents without waiting for download",
|
||||
gradient: "from-cyan-500 to-teal-500",
|
||||
},
|
||||
{
|
||||
name: "enableAdultContent",
|
||||
icon: HiEye,
|
||||
title: "NSFW Content",
|
||||
description: "Show adult content in library and search",
|
||||
gradient: "from-red-500 to-pink-500",
|
||||
},
|
||||
{
|
||||
name: "enableOnlinestream",
|
||||
icon: HiGlobeAlt,
|
||||
title: "Online Streaming",
|
||||
description: "Watch anime from online sources",
|
||||
gradient: "from-purple-500 to-violet-500",
|
||||
},
|
||||
{
|
||||
name: "enableRichPresence",
|
||||
icon: FaDiscord,
|
||||
title: "Discord Rich Presence",
|
||||
description: "Show what you're watching on Discord",
|
||||
gradient: "from-indigo-500 to-blue-500",
|
||||
},
|
||||
{
|
||||
name: "enableTranscode",
|
||||
icon: MdOutlineBroadcastOnHome,
|
||||
title: "Transcoding / Direct Play",
|
||||
description: "Stream downloaded files on other devices",
|
||||
gradient: "from-cyan-500 to-indigo-500",
|
||||
},
|
||||
]
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
variants={containerVariants}
|
||||
initial="hidden"
|
||||
animate="visible"
|
||||
exit="exit"
|
||||
className="space-y-8"
|
||||
>
|
||||
<motion.div variants={itemVariants} className="text-center space-y-4">
|
||||
<h2 className="text-3xl font-bold">Additional Features</h2>
|
||||
<p className="text-[--muted] text-sm max-w-lg mx-auto">
|
||||
Choose which additional features you'd like to enable. You can enable or disable these later in settings.
|
||||
</p>
|
||||
</motion.div>
|
||||
|
||||
<div className="grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 gap-3 max-w-6xl mx-auto">
|
||||
{features.map((feature, index) => (
|
||||
<motion.div
|
||||
key={feature.name}
|
||||
variants={itemVariants}
|
||||
custom={index}
|
||||
>
|
||||
<Field.Checkbox
|
||||
name={feature.name}
|
||||
label={
|
||||
<div className="flex items-start space-x-4 p-4">
|
||||
<div
|
||||
className={cn(
|
||||
"w-12 h-12 rounded-lg flex items-center justify-center",
|
||||
`bg-gradient-to-br ${feature.gradient}`,
|
||||
)}
|
||||
>
|
||||
<feature.icon className="w-6 h-6 text-white" />
|
||||
</div>
|
||||
<div className="flex-1 min-w-0">
|
||||
<h3 className="font-semibold text-sm">{feature.title}</h3>
|
||||
<p className="text-xs text-gray-400 mt-1 leading-relaxed">
|
||||
{feature.description}
|
||||
</p>
|
||||
</div>
|
||||
</div>
|
||||
}
|
||||
size="lg"
|
||||
labelClass={cn(
|
||||
"block cursor-pointer transition-all duration-200 overflow-hidden w-full rounded-xl",
|
||||
"bg-gray-900/50 hover:bg-gray-800/80",
|
||||
"border border-gray-700/50",
|
||||
"hover:border-gray-600",
|
||||
// "hover:shadow-lg hover:scale-[1.02]",
|
||||
"data-[checked=true]:bg-gradient-to-br data-[checked=true]:from-gray-900 data-[checked=true]:to-gray-900",
|
||||
"data-[checked=true]:border-brand-600",
|
||||
// "data-[checked=true]:shadow-lg data-[checked=true]:scale-[1.02]"
|
||||
)}
|
||||
containerClass="flex items-center justify-between h-full"
|
||||
className="absolute top-2 right-2 z-10"
|
||||
fieldClass="relative"
|
||||
/>
|
||||
</motion.div>
|
||||
))}
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
export function GettingStartedPage({ status }: { status: Status }) {
|
||||
const router = useRouter()
|
||||
const { getDefaultVlcPath, getDefaultQBittorrentPath, getDefaultTransmissionPath } = useDefaultSettingsPaths()
|
||||
const setServerStatus = useSetServerStatus()
|
||||
|
||||
const { mutate, data, isPending, isSuccess } = useGettingStarted()
|
||||
|
||||
const [currentStep, setCurrentStep] = React.useState(0)
|
||||
const [direction, setDirection] = React.useState(0)
|
||||
|
||||
/**
|
||||
* If the settings are returned, redirect to the home page
|
||||
*/
|
||||
React.useEffect(() => {
|
||||
if (!isPending && !!data?.settings) {
|
||||
setServerStatus(data)
|
||||
router.push("/")
|
||||
}
|
||||
}, [data, isPending])
|
||||
|
||||
const vlcDefaultPath = React.useMemo(() => getDefaultVlcPath(status.os), [status.os])
|
||||
const qbittorrentDefaultPath = React.useMemo(() => getDefaultQBittorrentPath(status.os), [status.os])
|
||||
const transmissionDefaultPath = React.useMemo(() => getDefaultTransmissionPath(status.os), [status.os])
|
||||
const mpvSocketPath = React.useMemo(() => getDefaultMpvSocket(status.os), [status.os])
|
||||
const iinaSocketPath = React.useMemo(() => getDefaultIinaSocket(status.os), [status.os])
|
||||
|
||||
const nextStep = () => {
|
||||
if (currentStep < STEPS.length - 1) {
|
||||
setDirection(1)
|
||||
setCurrentStep(currentStep + 1)
|
||||
}
|
||||
}
|
||||
|
||||
const prevStep = () => {
|
||||
if (currentStep > 0) {
|
||||
setDirection(-1)
|
||||
setCurrentStep(currentStep - 1)
|
||||
}
|
||||
}
|
||||
|
||||
const goToStep = (step: number) => {
|
||||
if (step >= 0 && step < STEPS.length) {
|
||||
setDirection(step > currentStep ? 1 : -1)
|
||||
setCurrentStep(step)
|
||||
}
|
||||
}
|
||||
|
||||
if (isPending) return <LoadingOverlayWithLogo />
|
||||
|
||||
if (!data) return (
|
||||
<div className="min-h-screen bg-gradient-to-br from-[--background] via-[--background] to-purple-950/10">
|
||||
<div className="absolute inset-0 overflow-hidden pointer-events-none">
|
||||
{/* <div className="absolute top-1/4 left-1/4 w-96 h-96 bg-blue-500/10 rounded-full blur-3xl" /> */}
|
||||
{/* <div className="absolute bottom-1/4 right-1/4 w-96 h-96 bg-purple-500/10 rounded-full blur-3xl" /> */}
|
||||
{/* <div className="absolute top-1/2 left-1/2 transform -translate-x-1/2 -translate-y-1/2 w-96 h-96 bg-pink-500/5 rounded-full blur-3xl" /> */}
|
||||
</div>
|
||||
|
||||
<div className="container max-w-6xl mx-auto px-4 py-8 relative z-10">
|
||||
<Form
|
||||
schema={gettingStartedSchema}
|
||||
onSubmit={data => {
|
||||
if (currentStep === STEPS.length - 1) {
|
||||
mutate(getDefaultSettings(data))
|
||||
} else {
|
||||
nextStep()
|
||||
}
|
||||
}}
|
||||
defaultValues={{
|
||||
mediaPlayerHost: "127.0.0.1",
|
||||
vlcPort: 8080,
|
||||
mpcPort: 13579,
|
||||
defaultPlayer: "mpv",
|
||||
vlcPath: vlcDefaultPath,
|
||||
qbittorrentPath: qbittorrentDefaultPath,
|
||||
qbittorrentHost: "127.0.0.1",
|
||||
qbittorrentPort: 8081,
|
||||
transmissionPath: transmissionDefaultPath,
|
||||
transmissionHost: "127.0.0.1",
|
||||
transmissionPort: 9091,
|
||||
mpcPath: "C:/Program Files/MPC-HC/mpc-hc64.exe",
|
||||
torrentProvider: DEFAULT_TORRENT_PROVIDER,
|
||||
mpvSocket: mpvSocketPath,
|
||||
iinaSocket: iinaSocketPath,
|
||||
enableRichPresence: false,
|
||||
autoScan: false,
|
||||
enableManga: true,
|
||||
enableOnlinestream: false,
|
||||
enableAdultContent: true,
|
||||
enableTorrentStreaming: true,
|
||||
enableTranscode: false,
|
||||
debridProvider: "none",
|
||||
debridApiKey: "",
|
||||
nakamaUsername: "",
|
||||
enableWatchContinuity: true,
|
||||
}}
|
||||
>
|
||||
{(f) => (
|
||||
<div className="space-y-8">
|
||||
<StepIndicator currentStep={currentStep} totalSteps={STEPS.length} onStepClick={goToStep} />
|
||||
|
||||
<AnimatePresence mode="wait" custom={direction}>
|
||||
<motion.div
|
||||
key={currentStep}
|
||||
custom={direction}
|
||||
variants={stepVariants}
|
||||
initial="enter"
|
||||
animate="center"
|
||||
exit="exit"
|
||||
transition={{
|
||||
x: { duration: 0.3, ease: "easeInOut" },
|
||||
opacity: { duration: 0.2 },
|
||||
}}
|
||||
className=""
|
||||
>
|
||||
{currentStep === 0 && <LibraryStep form={f} />}
|
||||
{currentStep === 1 && <PlayerStep form={f} status={status} />}
|
||||
{currentStep === 2 && <TorrentStep form={f} />}
|
||||
{currentStep === 3 && <DebridStep form={f} />}
|
||||
{currentStep === 4 && <FeaturesStep form={f} />}
|
||||
</motion.div>
|
||||
</AnimatePresence>
|
||||
|
||||
<motion.div
|
||||
className="flex justify-between items-center max-w-2xl mx-auto pt-8"
|
||||
initial={{ opacity: 0, y: 20 }}
|
||||
animate={{ opacity: 1, y: 0 }}
|
||||
transition={{ delay: 0.5 }}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
intent="gray-outline"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
prevStep()
|
||||
}}
|
||||
disabled={currentStep === 0}
|
||||
className="flex items-center space-x-2"
|
||||
leftIcon={<BiChevronLeft />}
|
||||
>
|
||||
Previous
|
||||
</Button>
|
||||
|
||||
{currentStep === STEPS.length - 1 ? (
|
||||
<Button
|
||||
type="submit"
|
||||
className="flex items-center bg-gradient-to-r from-brand-600 to-indigo-600 hover:ring-2 ring-brand-600"
|
||||
loading={isPending}
|
||||
rightIcon={<BiRocket className="size-6" />}
|
||||
>
|
||||
<span>Launch Seanime</span>
|
||||
</Button>
|
||||
) : (
|
||||
<Button
|
||||
type="button"
|
||||
intent="primary-subtle"
|
||||
onClick={e => {
|
||||
e.preventDefault()
|
||||
nextStep()
|
||||
}}
|
||||
className="flex items-center space-x-2"
|
||||
rightIcon={<BiChevronRight />}
|
||||
>
|
||||
Next
|
||||
</Button>
|
||||
)}
|
||||
</motion.div>
|
||||
</div>
|
||||
)}
|
||||
</Form>
|
||||
|
||||
<motion.p
|
||||
className="text-center text-[--muted] mt-12"
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
transition={{ delay: 1 }}
|
||||
>
|
||||
Made by 5rahim
|
||||
</motion.p>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,279 @@
|
||||
"use client"
|
||||
import { useAnilistListAnime } from "@/api/hooks/anilist.hooks"
|
||||
import { useAnilistListManga } from "@/api/hooks/manga.hooks"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { Select } from "@/components/ui/select"
|
||||
import { useDebounce } from "@/hooks/use-debounce"
|
||||
import { Combobox, Dialog, Transition } from "@headlessui/react"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import Image from "next/image"
|
||||
import { useRouter } from "next/navigation"
|
||||
import React, { Fragment, useEffect, useRef } from "react"
|
||||
import { BiChevronRight } from "react-icons/bi"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
|
||||
export const __globalSearch_isOpenAtom = atom(false)
|
||||
|
||||
export function GlobalSearch() {
|
||||
|
||||
const [inputValue, setInputValue] = React.useState("")
|
||||
const debouncedQuery = useDebounce(inputValue, 500)
|
||||
const inputRef = useRef<HTMLInputElement>(null)
|
||||
|
||||
const [type, setType] = React.useState<string>("anime")
|
||||
|
||||
const router = useRouter()
|
||||
|
||||
const [open, setOpen] = useAtom(__globalSearch_isOpenAtom)
|
||||
|
||||
useEffect(() => {
|
||||
if(open) {
|
||||
setTimeout(() => {
|
||||
console.log("open", open, inputRef.current)
|
||||
console.log("focusing")
|
||||
inputRef.current?.focus()
|
||||
}, 300)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
const { data: animeData, isLoading: animeIsLoading, isFetching: animeIsFetching } = useAnilistListAnime({
|
||||
search: debouncedQuery,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"],
|
||||
sort: ["SEARCH_MATCH"],
|
||||
}, debouncedQuery.length > 0 && type === "anime")
|
||||
|
||||
const { data: mangaData, isLoading: mangaIsLoading, isFetching: mangaIsFetching } = useAnilistListManga({
|
||||
search: debouncedQuery,
|
||||
page: 1,
|
||||
perPage: 10,
|
||||
status: ["FINISHED", "CANCELLED", "NOT_YET_RELEASED", "RELEASING"],
|
||||
sort: ["SEARCH_MATCH"],
|
||||
}, debouncedQuery.length > 0 && type === "manga")
|
||||
|
||||
const isLoading = type === "anime" ? animeIsLoading : mangaIsLoading
|
||||
const isFetching = type === "anime" ? animeIsFetching : mangaIsFetching
|
||||
|
||||
const media = React.useMemo(() => type === "anime" ? animeData?.Page?.media?.filter(Boolean) : mangaData?.Page?.media?.filter(Boolean),
|
||||
[animeData, mangaData, type])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Transition.Root show={open} as={Fragment} afterLeave={() => setInputValue("")} appear>
|
||||
<Dialog as="div" className="relative z-50" onClose={setOpen}>
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0"
|
||||
enterTo="opacity-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100"
|
||||
leaveTo="opacity-0"
|
||||
>
|
||||
<div className="fixed inset-0 bg-black bg-opacity-70 transition-opacity backdrop-blur-sm" />
|
||||
</Transition.Child>
|
||||
|
||||
<div className="fixed inset-0 z-50 overflow-y-auto p-4 sm:p-6 md:p-20">
|
||||
<Transition.Child
|
||||
as={Fragment}
|
||||
enter="ease-out duration-300"
|
||||
enterFrom="opacity-0 scale-95"
|
||||
enterTo="opacity-100 scale-100"
|
||||
leave="ease-in duration-200"
|
||||
leaveFrom="opacity-100 scale-100"
|
||||
leaveTo="opacity-0 scale-95"
|
||||
>
|
||||
<Dialog.Panel
|
||||
className="mx-auto max-w-3xl transform space-y-4 transition-all"
|
||||
>
|
||||
<div className="absolute right-2 -top-7 z-10">
|
||||
<SeaLink
|
||||
href="/search"
|
||||
className="text-[--muted] hover:text-[--foreground] font-bold"
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
Advanced search →
|
||||
</SeaLink>
|
||||
</div>
|
||||
<Combobox>
|
||||
{({ activeOption }: any) => (
|
||||
<>
|
||||
<div
|
||||
className="relative border bg-gray-950 shadow-2xl ring-1 ring-black ring-opacity-5 w-full rounded-lg "
|
||||
>
|
||||
<FiSearch
|
||||
className="pointer-events-none absolute top-4 left-4 h-6 w-6 text-[--muted]"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
<Combobox.Input
|
||||
ref={inputRef}
|
||||
className="h-14 w-full border-0 bg-transparent pl-14 pr-4 text-white placeholder-[--muted] focus:ring-0 sm:text-md"
|
||||
placeholder="Search..."
|
||||
onChange={(event) => setInputValue(event.target.value)}
|
||||
/>
|
||||
<div className="block fixed lg:absolute top-2 right-2 z-1">
|
||||
<Select
|
||||
fieldClass="w-fit"
|
||||
value={type}
|
||||
onValueChange={(value) => setType(value)}
|
||||
options={[
|
||||
{ value: "anime", label: "Anime" },
|
||||
{ value: "manga", label: "Manga" },
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{(!!media && media.length > 0) && (
|
||||
<Combobox.Options
|
||||
as="div" static hold
|
||||
className="flex divide-[--border] bg-gray-950 shadow-2xl ring-1 ring-black ring-opacity-5 rounded-lg border "
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"max-h-96 min-w-0 flex-auto scroll-py-2 overflow-y-auto px-6 py-2 my-2",
|
||||
{ "sm:h-96": activeOption },
|
||||
)}
|
||||
>
|
||||
<div className="-mx-2 text-sm text-[--foreground]">
|
||||
{(media).map((item: any) => (
|
||||
<Combobox.Option
|
||||
as="div"
|
||||
key={item.id}
|
||||
value={item}
|
||||
onClick={() => {
|
||||
if (type === "anime") {
|
||||
router.push(`/entry?id=${item.id}`)
|
||||
} else {
|
||||
router.push(`/manga/entry?id=${item.id}`)
|
||||
}
|
||||
setOpen(false)
|
||||
}}
|
||||
className={({ active }) =>
|
||||
cn(
|
||||
"flex select-none items-center rounded-[--radius-md] p-2 text-[--muted] cursor-pointer",
|
||||
active && "bg-gray-800 text-white",
|
||||
)
|
||||
}
|
||||
>
|
||||
{({ active }) => (
|
||||
<>
|
||||
<div
|
||||
className="h-10 w-10 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
|
||||
>
|
||||
{item.coverImage?.medium && <Image
|
||||
src={item.coverImage?.medium}
|
||||
alt={""}
|
||||
fill
|
||||
quality={50}
|
||||
priority
|
||||
sizes="10rem"
|
||||
className="object-cover object-center"
|
||||
/>}
|
||||
</div>
|
||||
<span
|
||||
className="ml-3 flex-auto truncate"
|
||||
>{item.title?.userPreferred}</span>
|
||||
{active && (
|
||||
<BiChevronRight
|
||||
className="ml-3 h-7 w-7 flex-none text-gray-400"
|
||||
aria-hidden="true"
|
||||
/>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox.Option>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{activeOption && (
|
||||
<div
|
||||
className="hidden min-h-96 w-1/2 flex-none flex-col overflow-y-auto sm:flex p-4"
|
||||
>
|
||||
<div className="flex-none p-6 text-center">
|
||||
<div
|
||||
className="h-40 w-32 mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
|
||||
>
|
||||
{activeOption.coverImage?.large && <Image
|
||||
src={activeOption.coverImage?.large}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="10rem"
|
||||
className="object-cover object-center"
|
||||
/>}
|
||||
</div>
|
||||
<h4 className="mt-3 font-semibold text-[--foreground] line-clamp-3">{activeOption.title?.userPreferred}</h4>
|
||||
<p className="text-sm leading-6 text-[--muted]">
|
||||
{activeOption.format}{activeOption.season
|
||||
? ` - ${capitalize(activeOption.season)} `
|
||||
: " - "}{activeOption.seasonYear
|
||||
? activeOption.seasonYear
|
||||
: "-"}
|
||||
</p>
|
||||
</div>
|
||||
<SeaLink
|
||||
href={type === "anime"
|
||||
? `/entry?id=${activeOption.id}`
|
||||
: `/manga/entry?id=${activeOption.id}`}
|
||||
onClick={() => setOpen(false)}
|
||||
>
|
||||
<Button
|
||||
type="button"
|
||||
className="w-full"
|
||||
intent="gray-subtle"
|
||||
>
|
||||
Open
|
||||
</Button>
|
||||
</SeaLink>
|
||||
</div>
|
||||
)}
|
||||
</Combobox.Options>
|
||||
)}
|
||||
|
||||
{(debouncedQuery !== "" && (!media || media.length === 0) && (isLoading || isFetching)) && (
|
||||
<LoadingSpinner />
|
||||
)}
|
||||
|
||||
{debouncedQuery !== "" && !isLoading && !isFetching && (!media || media.length === 0) && (
|
||||
<div className="py-14 px-6 text-center text-sm sm:px-14">
|
||||
{<div
|
||||
className="h-[10rem] w-[10rem] mx-auto flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
src="/luffy-01.png"
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="10rem"
|
||||
className="object-contain object-top"
|
||||
/>
|
||||
</div>}
|
||||
<h5 className="mt-4 font-semibold text-[--foreground]">Nothing
|
||||
found</h5>
|
||||
<p className="mt-2 text-[--muted]">
|
||||
We couldn't find anything with that name. Please try again.
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</>
|
||||
)}
|
||||
</Combobox>
|
||||
</Dialog.Panel>
|
||||
</Transition.Child>
|
||||
</div>
|
||||
</Dialog>
|
||||
</Transition.Root>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,339 @@
|
||||
import { getServerBaseUrl } from "@/api/client/server-url"
|
||||
import { Report_ClickLog, Report_ConsoleLog, Report_NetworkLog, Report_ReactQueryLog } from "@/api/generated/types"
|
||||
import { useSaveIssueReport } from "@/api/hooks/report.hooks"
|
||||
import { useServerHMACAuth } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { openTab } from "@/lib/helpers/browser"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { atom } from "jotai"
|
||||
import { useAtom } from "jotai/react"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { BiX } from "react-icons/bi"
|
||||
import { PiRecordFill, PiStopCircleFill } from "react-icons/pi"
|
||||
import { VscDebugAlt } from "react-icons/vsc"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const __issueReport_overlayOpenAtom = atom<boolean>(false)
|
||||
export const __issueReport_recordingAtom = atom<boolean>(false)
|
||||
export const __issueReport_streamAtom = atom<any>()
|
||||
|
||||
export const __issueReport_clickLogsAtom = atom<Report_ClickLog[]>([])
|
||||
|
||||
export const __issueReport_consoleAtom = atom<Report_ConsoleLog[]>([])
|
||||
|
||||
export const __issueReport_networkAtom = atom<Report_NetworkLog[]>([])
|
||||
|
||||
export const __issueReport_reactQueryAtom = atom<Report_ReactQueryLog[]>([])
|
||||
|
||||
export function IssueReport() {
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const queryClient = useQueryClient()
|
||||
|
||||
const [open, setOpen] = useAtom(__issueReport_overlayOpenAtom)
|
||||
const [isRecording, setRecording] = useAtom(__issueReport_recordingAtom)
|
||||
const [consoleLogs, setConsoleLogs] = useAtom(__issueReport_consoleAtom)
|
||||
const [clickLogs, setClickLogs] = useAtom(__issueReport_clickLogsAtom)
|
||||
const [networkLogs, setNetworkLogs] = useAtom(__issueReport_networkAtom)
|
||||
const [reactQueryLogs, setReactQueryLogs] = useAtom(__issueReport_reactQueryAtom)
|
||||
const [recordLocalFiles, setRecordLocalFiles] = React.useState(false)
|
||||
|
||||
const { mutate, isPending } = useSaveIssueReport()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!open) {
|
||||
setRecording(false)
|
||||
}
|
||||
}, [open])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRecording) {
|
||||
setConsoleLogs([])
|
||||
setClickLogs([])
|
||||
setNetworkLogs([])
|
||||
setReactQueryLogs([])
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
const captureClick = (e: MouseEvent) => {
|
||||
const element = e.target as HTMLElement
|
||||
setClickLogs(prev => [...prev, {
|
||||
pageUrl: window.location.href.replace(window.location.host, "{client}"),
|
||||
timestamp: new Date().toISOString(),
|
||||
element: element.tagName,
|
||||
className: JSON.stringify(element.className?.length && element.className.length > 50
|
||||
? element.className.slice(0, 100) + "..."
|
||||
: element.className),
|
||||
text: element.innerText,
|
||||
}])
|
||||
}
|
||||
|
||||
window.addEventListener("click", captureClick)
|
||||
|
||||
return () => {
|
||||
window.removeEventListener("click", captureClick)
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
const originalConsole = {
|
||||
log: console.log,
|
||||
error: console.error,
|
||||
warn: console.warn,
|
||||
}
|
||||
|
||||
const logInterceptor = (type: Report_ConsoleLog["type"]) => (...args: any[]) => {
|
||||
(originalConsole as any)[type](...args)
|
||||
try {
|
||||
setConsoleLogs(prev => [...prev, {
|
||||
type,
|
||||
pageUrl: window.location.href.replace(window.location.host, "{client}"),
|
||||
content: args.map(arg =>
|
||||
typeof arg === "object" ? JSON.stringify(arg) : String(arg),
|
||||
).join(" "),
|
||||
timestamp: new Date().toISOString(),
|
||||
}])
|
||||
}
|
||||
catch (e) {
|
||||
// console.error("Error capturing console logs", e)
|
||||
}
|
||||
}
|
||||
|
||||
console.log = logInterceptor("log")
|
||||
console.error = logInterceptor("error")
|
||||
console.warn = logInterceptor("warn")
|
||||
|
||||
return () => {
|
||||
Object.assign(console, originalConsole)
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
const queryUnsubscribe = queryClient.getQueryCache().subscribe(listener => {
|
||||
if (listener.query.state.status === "pending") return
|
||||
setReactQueryLogs(prev => [...prev, {
|
||||
type: "query",
|
||||
pageUrl: window.location.href.replace(window.location.host, "{client}"),
|
||||
status: listener.query.state.status,
|
||||
hash: listener.query.queryHash,
|
||||
error: listener.query.state.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
dataPreview: typeof listener.query.state.data === "object"
|
||||
? JSON.stringify(listener.query.state.data).slice(0, 200)
|
||||
: "",
|
||||
dataType: typeof listener.query.state.data,
|
||||
}])
|
||||
})
|
||||
|
||||
const mutationUnsubscribe = queryClient.getMutationCache().subscribe(listener => {
|
||||
if (!listener.mutation) return
|
||||
if (listener.mutation.state.status === "pending" || listener.mutation.state.status === "idle") return
|
||||
|
||||
// Don't log the save issue report mutation to prevent feedback loop
|
||||
const mutationKey = listener.mutation.options.mutationKey
|
||||
if (Array.isArray(mutationKey) && mutationKey.includes("REPORT-save-issue-report")) return
|
||||
|
||||
setReactQueryLogs(prev => [...prev, {
|
||||
type: "mutation",
|
||||
pageUrl: window.location.href.replace(window.location.host, "{client}"),
|
||||
status: listener.mutation!.state.status,
|
||||
hash: JSON.stringify(listener.mutation!.options.mutationKey),
|
||||
error: listener.mutation!.state.error,
|
||||
timestamp: new Date().toISOString(),
|
||||
dataPreview: typeof listener.mutation!.state.data === "object" ? JSON.stringify(listener.mutation!.state.data)
|
||||
.slice(0, 200) : "",
|
||||
dataType: typeof listener.mutation!.state.data,
|
||||
}])
|
||||
})
|
||||
|
||||
return () => {
|
||||
queryUnsubscribe()
|
||||
mutationUnsubscribe()
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isRecording) return
|
||||
|
||||
const originalXhrOpen = XMLHttpRequest.prototype.open
|
||||
const originalXhrSend = XMLHttpRequest.prototype.send
|
||||
|
||||
XMLHttpRequest.prototype.open = function (method, url) {
|
||||
// @ts-ignore
|
||||
this._url = url
|
||||
// @ts-ignore
|
||||
this._method = method
|
||||
// @ts-ignore
|
||||
originalXhrOpen.apply(this, arguments)
|
||||
}
|
||||
|
||||
XMLHttpRequest.prototype.send = function (body) {
|
||||
const startTime = Date.now()
|
||||
// @ts-ignore
|
||||
const url = this._url
|
||||
// @ts-ignore
|
||||
const method = this._method
|
||||
|
||||
const _url = new URL(url)
|
||||
// remove host and port
|
||||
_url.host = "{server}"
|
||||
_url.port = ""
|
||||
|
||||
this.addEventListener("load", () => {
|
||||
const duration = Date.now() - startTime
|
||||
setNetworkLogs(prev => [...prev, {
|
||||
type: "xhr",
|
||||
method,
|
||||
url: _url.href,
|
||||
pageUrl: window.location.href.replace(window.location.host, "{client}"),
|
||||
status: this.status,
|
||||
duration,
|
||||
dataPreview: this.responseText.slice(0, 200),
|
||||
timestamp: new Date().toISOString(),
|
||||
body: JSON.stringify(body),
|
||||
}])
|
||||
})
|
||||
|
||||
// @ts-ignore
|
||||
originalXhrSend.apply(this, arguments)
|
||||
}
|
||||
|
||||
return () => {
|
||||
XMLHttpRequest.prototype.open = originalXhrOpen
|
||||
XMLHttpRequest.prototype.send = originalXhrSend
|
||||
}
|
||||
}, [isRecording])
|
||||
|
||||
function handleStartRecording() {
|
||||
setRecording(true)
|
||||
}
|
||||
|
||||
const { getHMACTokenQueryParam } = useServerHMACAuth()
|
||||
|
||||
async function handleStopRecording() {
|
||||
const logsToSave = {
|
||||
clickLogs,
|
||||
consoleLogs,
|
||||
networkLogs,
|
||||
reactQueryLogs,
|
||||
}
|
||||
|
||||
setRecording(false)
|
||||
|
||||
mutate({
|
||||
...logsToSave,
|
||||
isAnimeLibraryIssue: recordLocalFiles,
|
||||
}, {
|
||||
onSuccess: async () => {
|
||||
toast.success("Issue report saved successfully")
|
||||
|
||||
setTimeout(async () => {
|
||||
try {
|
||||
const endpoint = "/api/v1/report/issue/download"
|
||||
const tokenQuery = await getHMACTokenQueryParam(endpoint)
|
||||
openTab(`${getServerBaseUrl()}${endpoint}${tokenQuery}`)
|
||||
}
|
||||
catch (error) {
|
||||
toast.error("Failed to generate download token")
|
||||
}
|
||||
}, 1000)
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<>
|
||||
{open && <div
|
||||
className={cn(
|
||||
"issue-reporter-ui",
|
||||
"fixed z-[100] bottom-8 w-fit left-20 h-fit flex",
|
||||
"transition-all duration-300 select-none",
|
||||
!isRecording && "hover:translate-y-[-2px]",
|
||||
isRecording && "justify-end",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"p-4 bg-gray-900 border text-white rounded-xl",
|
||||
"transition-colors duration-300",
|
||||
isRecording && "p-0 border-transparent bg-transparent",
|
||||
)}
|
||||
>
|
||||
{!isRecording ? <div className="space-y-2">
|
||||
<div className="flex items-center justify-center gap-4">
|
||||
<VscDebugAlt className="text-2xl text-[--brand]" />
|
||||
<div className="">
|
||||
<p>
|
||||
Issue recorder
|
||||
</p>
|
||||
<p className="text-[--muted] text-sm text-center">
|
||||
Record your issue and generate a report
|
||||
</p>
|
||||
<div className="pt-2">
|
||||
<Checkbox
|
||||
label="Anime library issue"
|
||||
value={recordLocalFiles}
|
||||
onValueChange={v => typeof v === "boolean" && setRecordLocalFiles(v)}
|
||||
size="sm"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
<div className="flex items-center gap-0">
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
intent="gray-basic"
|
||||
icon={<PiRecordFill className="text-red-500" />}
|
||||
onClick={handleStartRecording}
|
||||
/>}
|
||||
>
|
||||
Start recording
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
intent="gray-basic"
|
||||
icon={<BiX />}
|
||||
onClick={() => setOpen(false)}
|
||||
/>}
|
||||
>
|
||||
Close
|
||||
</Tooltip>
|
||||
</div>
|
||||
</div>
|
||||
</div> : <div className="flex items-center justify-center gap-0">
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
intent="alert"
|
||||
icon={<PiStopCircleFill className="text-white animate-pulse" />}
|
||||
onClick={handleStopRecording}
|
||||
/>}
|
||||
>
|
||||
Stop recording
|
||||
</Tooltip>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
intent="white"
|
||||
size="xs"
|
||||
icon={<BiX />}
|
||||
onClick={() => setRecording(false)}
|
||||
/>}
|
||||
>
|
||||
Cancel
|
||||
</Tooltip>
|
||||
</div>}
|
||||
</div>
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,36 @@
|
||||
"use client"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React from "react"
|
||||
import { SiAnilist } from "react-icons/si"
|
||||
|
||||
export function LayoutHeaderBackground() {
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
return (
|
||||
<>
|
||||
{!pathname.startsWith("/entry") && <>
|
||||
<div
|
||||
data-layout-header-background
|
||||
className={cn(
|
||||
// "bg-[url(/pattern-3.svg)] bg-[#000] opacity-50 bg-contain bg-center bg-repeat z-[-2] w-full h-[20rem] absolute bottom-0",
|
||||
"bg-[#000] opacity-50 bg-contain bg-center bg-repeat z-[-2] w-full h-[20rem] absolute bottom-0",
|
||||
)}
|
||||
>
|
||||
</div>
|
||||
{pathname.startsWith("/anilist") &&
|
||||
<div
|
||||
data-layout-header-background-anilist-icon-container
|
||||
className="w-full flex items-center justify-center absolute bottom-0 h-[5rem] lg:hidden 2xl:flex"
|
||||
>
|
||||
<SiAnilist className="text-5xl text-white relative z-[2] opacity-40" />
|
||||
</div>}
|
||||
<div
|
||||
data-layout-header-background-gradient
|
||||
className="w-full absolute bottom-0 h-[8rem] bg-gradient-to-t from-[--background] to-transparent z-[-2]"
|
||||
/>
|
||||
</>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,114 @@
|
||||
"use client"
|
||||
import { PlaylistsModal } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
|
||||
import { ScanProgressBar } from "@/app/(main)/(library)/_containers/scan-progress-bar"
|
||||
import { ScannerModal } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
import { ErrorExplainer } from "@/app/(main)/_features/error-explainer/error-explainer"
|
||||
import { GlobalSearch } from "@/app/(main)/_features/global-search/global-search"
|
||||
import { IssueReport } from "@/app/(main)/_features/issue-report/issue-report"
|
||||
import { LibraryWatcher } from "@/app/(main)/_features/library-watcher/library-watcher"
|
||||
import { MediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal"
|
||||
import { MainSidebar } from "@/app/(main)/_features/navigation/main-sidebar"
|
||||
import { PluginManager } from "@/app/(main)/_features/plugin/plugin-manager"
|
||||
import { ManualProgressTracking } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking"
|
||||
import { PlaybackManagerProgressTracking } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking"
|
||||
import { SeaCommand } from "@/app/(main)/_features/sea-command/sea-command"
|
||||
import { VideoCoreProvider } from "@/app/(main)/_features/video-core/video-core"
|
||||
import { useAnimeCollectionLoader } from "@/app/(main)/_hooks/anilist-collection-loader"
|
||||
import { useAnimeLibraryCollectionLoader } from "@/app/(main)/_hooks/anime-library-collection-loader"
|
||||
import { useMissingEpisodesLoader } from "@/app/(main)/_hooks/missing-episodes-loader"
|
||||
import { useAnimeCollectionListener } from "@/app/(main)/_listeners/anilist-collection.listeners"
|
||||
import { useAutoDownloaderItemListener } from "@/app/(main)/_listeners/autodownloader.listeners"
|
||||
import { useExtensionListener } from "@/app/(main)/_listeners/extensions.listeners"
|
||||
import { useExternalPlayerLinkListener } from "@/app/(main)/_listeners/external-player-link.listeners"
|
||||
import { useMangaListener } from "@/app/(main)/_listeners/manga.listeners"
|
||||
import { useMiscEventListeners } from "@/app/(main)/_listeners/misc-events.listeners"
|
||||
import { useSyncListener } from "@/app/(main)/_listeners/sync.listeners"
|
||||
import { DebridStreamOverlay } from "@/app/(main)/entry/_containers/debrid-stream/debrid-stream-overlay"
|
||||
import { TorrentStreamOverlay } from "@/app/(main)/entry/_containers/torrent-stream/torrent-stream-overlay"
|
||||
import { ChapterDownloadsDrawer } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer"
|
||||
import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo"
|
||||
import { AppLayout, AppLayoutContent, AppLayoutSidebar, AppSidebarProvider } from "@/components/ui/app-layout"
|
||||
import { __isElectronDesktop__ } from "@/types/constants"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { useServerStatus } from "../../_hooks/use-server-status"
|
||||
import { useInvalidateQueriesListener } from "../../_listeners/invalidate-queries.listeners"
|
||||
import { Announcements } from "../announcements"
|
||||
import { NakamaManager } from "../nakama/nakama-manager"
|
||||
import { NativePlayer } from "../native-player/native-player"
|
||||
import { TopIndefiniteLoader } from "../top-indefinite-loader"
|
||||
|
||||
export const MainLayout = ({ children }: { children: React.ReactNode }) => {
|
||||
|
||||
/**
|
||||
* Data loaders
|
||||
*/
|
||||
useAnimeLibraryCollectionLoader()
|
||||
useAnimeCollectionLoader()
|
||||
useMissingEpisodesLoader()
|
||||
|
||||
/**
|
||||
* Websocket listeners
|
||||
*/
|
||||
useAutoDownloaderItemListener()
|
||||
useAnimeCollectionListener()
|
||||
useMiscEventListeners()
|
||||
useExtensionListener()
|
||||
useMangaListener()
|
||||
useExternalPlayerLinkListener()
|
||||
useSyncListener()
|
||||
useInvalidateQueriesListener()
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!serverStatus?.isOffline && pathname.startsWith("/offline")) {
|
||||
router.push("/")
|
||||
}
|
||||
}, [serverStatus?.isOffline, pathname])
|
||||
|
||||
if (serverStatus?.isOffline) {
|
||||
return <LoadingOverlayWithLogo />
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<GlobalSearch />
|
||||
<ScanProgressBar />
|
||||
<LibraryWatcher />
|
||||
<ScannerModal />
|
||||
<PlaylistsModal />
|
||||
<ChapterDownloadsDrawer />
|
||||
<TorrentStreamOverlay />
|
||||
<DebridStreamOverlay />
|
||||
<MediaPreviewModal />
|
||||
<PlaybackManagerProgressTracking />
|
||||
<ManualProgressTracking />
|
||||
<IssueReport />
|
||||
<ErrorExplainer />
|
||||
<SeaCommand />
|
||||
<PluginManager />
|
||||
{__isElectronDesktop__ && <VideoCoreProvider>
|
||||
<NativePlayer />
|
||||
</VideoCoreProvider>}
|
||||
<NakamaManager />
|
||||
<TopIndefiniteLoader />
|
||||
<Announcements />
|
||||
|
||||
<AppSidebarProvider>
|
||||
<AppLayout withSidebar sidebarSize="slim">
|
||||
<AppLayoutSidebar>
|
||||
<MainSidebar />
|
||||
</AppLayoutSidebar>
|
||||
<AppLayout>
|
||||
<AppLayoutContent>
|
||||
{children}
|
||||
</AppLayoutContent>
|
||||
</AppLayout>
|
||||
</AppLayout>
|
||||
</AppSidebarProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,83 @@
|
||||
import { ErrorExplainer } from "@/app/(main)/_features/error-explainer/error-explainer"
|
||||
import { IssueReport } from "@/app/(main)/_features/issue-report/issue-report"
|
||||
import { OfflineSidebar } from "@/app/(main)/_features/navigation/offline-sidebar"
|
||||
import { PluginManager } from "@/app/(main)/_features/plugin/plugin-manager"
|
||||
import { ManualProgressTracking } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking"
|
||||
import { PlaybackManagerProgressTracking } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking"
|
||||
import { VideoCoreProvider } from "@/app/(main)/_features/video-core/video-core"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useInvalidateQueriesListener } from "@/app/(main)/_listeners/invalidate-queries.listeners"
|
||||
import { LoadingOverlayWithLogo } from "@/components/shared/loading-overlay-with-logo"
|
||||
import { AppLayout, AppLayoutContent, AppLayoutSidebar, AppSidebarProvider } from "@/components/ui/app-layout"
|
||||
import { __isElectronDesktop__ } from "@/types/constants"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { NativePlayer } from "../native-player/native-player"
|
||||
import { SeaCommand } from "../sea-command/sea-command"
|
||||
import { TopIndefiniteLoader } from "../top-indefinite-loader"
|
||||
|
||||
type OfflineLayoutProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function OfflineLayout(props: OfflineLayoutProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const pathname = usePathname()
|
||||
const router = useRouter()
|
||||
|
||||
useInvalidateQueriesListener()
|
||||
|
||||
const [cont, setContinue] = React.useState(false)
|
||||
|
||||
React.useEffect(() => {
|
||||
|
||||
if (
|
||||
pathname.startsWith("/offline") ||
|
||||
pathname.startsWith("/settings") ||
|
||||
pathname.startsWith("/mediastream") ||
|
||||
pathname.startsWith("/medialinks")
|
||||
) {
|
||||
setContinue(true)
|
||||
return
|
||||
}
|
||||
|
||||
router.push("/offline")
|
||||
}, [pathname, serverStatus?.isOffline])
|
||||
|
||||
if (!cont) return <LoadingOverlayWithLogo />
|
||||
|
||||
return (
|
||||
<>
|
||||
<PlaybackManagerProgressTracking />
|
||||
<ManualProgressTracking />
|
||||
<IssueReport />
|
||||
<ErrorExplainer />
|
||||
<SeaCommand />
|
||||
<PluginManager />
|
||||
{__isElectronDesktop__ && <VideoCoreProvider>
|
||||
<NativePlayer />
|
||||
</VideoCoreProvider>}
|
||||
<TopIndefiniteLoader />
|
||||
|
||||
<AppSidebarProvider>
|
||||
<AppLayout withSidebar sidebarSize="slim">
|
||||
<AppLayoutSidebar>
|
||||
<OfflineSidebar />
|
||||
</AppLayoutSidebar>
|
||||
<AppLayout>
|
||||
<AppLayoutContent>
|
||||
{children}
|
||||
</AppLayoutContent>
|
||||
</AppLayout>
|
||||
</AppLayout>
|
||||
</AppSidebarProvider>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,137 @@
|
||||
import { useRefreshAnimeCollection } from "@/api/hooks/anilist.hooks"
|
||||
import { OfflineTopMenu } from "@/app/(main)/(offline)/offline/_components/offline-top-menu"
|
||||
import { RefreshAnilistButton } from "@/app/(main)/_features/anilist/refresh-anilist-button"
|
||||
import { LayoutHeaderBackground } from "@/app/(main)/_features/layout/_components/layout-header-background"
|
||||
import { TopMenu } from "@/app/(main)/_features/navigation/top-menu"
|
||||
import { ManualProgressTrackingButton } from "@/app/(main)/_features/progress-tracking/manual-progress-tracking"
|
||||
import { PlaybackManagerProgressTrackingButton } from "@/app/(main)/_features/progress-tracking/playback-manager-progress-tracking"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { ChapterDownloadsButton } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-button"
|
||||
import { __manga_chapterDownloadsDrawerIsOpenAtom } from "@/app/(main)/manga/_containers/chapter-downloads/chapter-downloads-drawer"
|
||||
import { AppSidebarTrigger } from "@/components/ui/app-layout"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Separator } from "@/components/ui/separator/separator"
|
||||
import { VerticalMenu } from "@/components/ui/vertical-menu"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { __isDesktop__ } from "@/types/constants"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React from "react"
|
||||
import { FaDownload } from "react-icons/fa"
|
||||
import { IoReload } from "react-icons/io5"
|
||||
import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray"
|
||||
|
||||
type TopNavbarProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function TopNavbar(props: TopNavbarProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const isOffline = serverStatus?.isOffline
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-top-navbar
|
||||
className={cn(
|
||||
"w-full h-[5rem] relative overflow-hidden flex items-center",
|
||||
(ts.hideTopNavbar || __isDesktop__) && "lg:hidden",
|
||||
)}
|
||||
>
|
||||
<div data-top-navbar-content-container className="relative z-10 px-4 w-full flex flex-row md:items-center overflow-x-auto">
|
||||
<div data-top-navbar-content className="flex items-center w-full gap-3">
|
||||
<AppSidebarTrigger />
|
||||
{!isOffline ? <TopMenu /> : <OfflineTopMenu />}
|
||||
<PlaybackManagerProgressTrackingButton />
|
||||
<ManualProgressTrackingButton />
|
||||
<div data-top-navbar-content-separator className="flex flex-1"></div>
|
||||
<PluginSidebarTray place="top" />
|
||||
{!isOffline && <ChapterDownloadsButton />}
|
||||
{!isOffline && <RefreshAnilistButton />}
|
||||
</div>
|
||||
</div>
|
||||
<LayoutHeaderBackground />
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
type SidebarNavbarProps = {
|
||||
isCollapsed: boolean
|
||||
handleExpandSidebar: () => void
|
||||
handleUnexpandedSidebar: () => void
|
||||
}
|
||||
|
||||
export function SidebarNavbar(props: SidebarNavbarProps) {
|
||||
|
||||
const {
|
||||
isCollapsed,
|
||||
handleExpandSidebar,
|
||||
handleUnexpandedSidebar,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
const ts = useThemeSettings()
|
||||
const pathname = usePathname()
|
||||
|
||||
const openDownloadQueue = useSetAtom(__manga_chapterDownloadsDrawerIsOpenAtom)
|
||||
const isMangaPage = pathname.startsWith("/manga")
|
||||
|
||||
/**
|
||||
* @description
|
||||
* - Asks the server to fetch an up-to-date version of the user's AniList collection.
|
||||
*/
|
||||
const { mutate: refreshAC, isPending: isRefreshingAC } = useRefreshAnimeCollection()
|
||||
|
||||
if (!ts.hideTopNavbar && process.env.NEXT_PUBLIC_PLATFORM !== "desktop") return null
|
||||
|
||||
return (
|
||||
<div data-sidebar-navbar className="flex flex-col gap-1">
|
||||
<div data-sidebar-navbar-spacer className="px-4 lg:py-1">
|
||||
<Separator className="px-4" />
|
||||
</div>
|
||||
{!serverStatus?.isOffline && <VerticalMenu
|
||||
data-sidebar-navbar-vertical-menu
|
||||
className="px-4"
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
onMouseEnter={handleExpandSidebar}
|
||||
onMouseLeave={handleUnexpandedSidebar}
|
||||
items={[
|
||||
{
|
||||
iconType: IoReload,
|
||||
name: "Refresh AniList",
|
||||
onClick: () => {
|
||||
if (isRefreshingAC) return
|
||||
refreshAC()
|
||||
},
|
||||
},
|
||||
...(isMangaPage ? [
|
||||
{
|
||||
iconType: FaDownload,
|
||||
name: "Manga downloads",
|
||||
onClick: () => {
|
||||
openDownloadQueue(true)
|
||||
},
|
||||
},
|
||||
] : []),
|
||||
]}
|
||||
/>}
|
||||
<div data-sidebar-navbar-playback-manager-progress-tracking-button className="flex justify-center">
|
||||
<PlaybackManagerProgressTrackingButton asSidebarButton />
|
||||
</div>
|
||||
<div data-sidebar-navbar-manual-progress-tracking-button className="flex justify-center">
|
||||
<ManualProgressTrackingButton asSidebarButton />
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,152 @@
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
|
||||
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { PageWrapper } from "@/components/shared/page-wrapper"
|
||||
import { Button, CloseButton } from "@/components/ui/button"
|
||||
import { Card, CardDescription, CardFooter, CardHeader, CardTitle } from "@/components/ui/card"
|
||||
import { Spinner } from "@/components/ui/loading-spinner"
|
||||
import { useBoolean } from "@/hooks/use-disclosure"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import React, { useState } from "react"
|
||||
import { BiSolidBinoculars } from "react-icons/bi"
|
||||
import { FiSearch } from "react-icons/fi"
|
||||
import { toast } from "sonner"
|
||||
|
||||
type LibraryWatcherProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function LibraryWatcher(props: LibraryWatcherProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const qc = useQueryClient()
|
||||
const serverStatus = useServerStatus()
|
||||
const [fileEvent, setFileEvent] = useState<string | null>(null)
|
||||
const fileAdded = useBoolean(false)
|
||||
const fileRemoved = useBoolean(false)
|
||||
const autoScanning = useBoolean(false)
|
||||
const [progress, setProgress] = useState(0)
|
||||
|
||||
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
|
||||
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.LIBRARY_WATCHER_FILE_ADDED,
|
||||
onMessage: data => {
|
||||
console.log("Library watcher", data)
|
||||
if (!serverStatus?.settings?.library?.autoScan) { // Only show the notification if auto scan is disabled
|
||||
fileAdded.on()
|
||||
setFileEvent(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.LIBRARY_WATCHER_FILE_REMOVED,
|
||||
onMessage: data => {
|
||||
console.log("Library watcher", data)
|
||||
if (!serverStatus?.settings?.library?.autoScan) { // Only show the notification if auto scan is disabled
|
||||
fileRemoved.on()
|
||||
setFileEvent(data)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Scan progress event
|
||||
useWebsocketMessageListener<number>({
|
||||
type: WSEvents.SCAN_PROGRESS,
|
||||
onMessage: data => {
|
||||
// Remove notification of file added or removed
|
||||
setFileEvent(null)
|
||||
fileAdded.off()
|
||||
fileRemoved.off()
|
||||
setProgress(data)
|
||||
// reset progress
|
||||
if (data === 100) {
|
||||
setTimeout(() => {
|
||||
setProgress(0)
|
||||
}, 2000)
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
// Auto scan event started
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.AUTO_SCAN_STARTED,
|
||||
onMessage: _ => {
|
||||
autoScanning.on()
|
||||
},
|
||||
})
|
||||
// Auto scan event completed
|
||||
useWebsocketMessageListener<string>({
|
||||
type: WSEvents.AUTO_SCAN_COMPLETED,
|
||||
onMessage: _ => {
|
||||
autoScanning.off()
|
||||
toast.success("Library scanned")
|
||||
qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_COLLECTION.GetLibraryCollection.key] })
|
||||
qc.invalidateQueries({ queryKey: [API_ENDPOINTS.ANIME_ENTRIES.GetMissingEpisodes.key] })
|
||||
qc.invalidateQueries({ queryKey: [API_ENDPOINTS.AUTO_DOWNLOADER.GetAutoDownloaderItems.key] })
|
||||
},
|
||||
})
|
||||
|
||||
function handleCancel() {
|
||||
setFileEvent(null)
|
||||
fileAdded.off()
|
||||
fileRemoved.off()
|
||||
}
|
||||
|
||||
if (autoScanning.active && progress > 0) {
|
||||
return (
|
||||
<div className="z-50 fixed bottom-4 right-4">
|
||||
<PageWrapper>
|
||||
<Card className="w-fit max-w-[400px]">
|
||||
<CardHeader>
|
||||
<CardDescription className="flex items-center gap-2 text-base">
|
||||
<Spinner className="size-6" /> {progress}% Refreshing your library...
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
)
|
||||
} else if (!!fileEvent) {
|
||||
return (
|
||||
<div className="z-50 fixed bottom-4 right-4">
|
||||
<PageWrapper>
|
||||
<Card className="w-full max-w-[400px] min-h-[150px] relative">
|
||||
<CardHeader>
|
||||
<CardTitle className="text-lg flex items-center gap-2">
|
||||
<BiSolidBinoculars className="text-brand-400" />
|
||||
Library watcher
|
||||
</CardTitle>
|
||||
<CardDescription className="flex items-center gap-2 text-base">
|
||||
A change has been detected in your library, refresh your entries.
|
||||
</CardDescription>
|
||||
</CardHeader>
|
||||
<CardFooter>
|
||||
<Button
|
||||
intent="primary-outline"
|
||||
leftIcon={<FiSearch />}
|
||||
size="sm"
|
||||
onClick={() => setScannerModalOpen(true)}
|
||||
className="rounded-full"
|
||||
>
|
||||
Scan your library
|
||||
</Button>
|
||||
</CardFooter>
|
||||
<CloseButton className="absolute top-2 right-2" onClick={handleCancel} />
|
||||
</Card>
|
||||
</PageWrapper>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
return null
|
||||
}
|
||||
@@ -0,0 +1,89 @@
|
||||
import { useGetAnilistStudioDetails } from "@/api/hooks/anilist.hooks"
|
||||
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Drawer } from "@/components/ui/drawer"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import React from "react"
|
||||
|
||||
type AnimeEntryStudioProps = {
|
||||
studios?: { nodes?: Array<{ name: string, id: number } | null> | null } | null | undefined
|
||||
}
|
||||
|
||||
export function AnimeEntryStudio(props: AnimeEntryStudioProps) {
|
||||
|
||||
const {
|
||||
studios,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (!studios?.nodes) return null
|
||||
|
||||
return (
|
||||
<AnimeEntryStudioDetailsModal studios={studios}>
|
||||
<Badge
|
||||
size="lg"
|
||||
intent="gray"
|
||||
className="rounded-full px-0 border-transparent bg-transparent cursor-pointer transition-all hover:bg-transparent hover:text-white hover:-translate-y-0.5"
|
||||
data-anime-entry-studio-badge
|
||||
>
|
||||
{studios?.nodes?.[0]?.name}
|
||||
</Badge>
|
||||
</AnimeEntryStudioDetailsModal>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimeEntryStudioDetailsModal(props: AnimeEntryStudioProps & { children: React.ReactElement }) {
|
||||
|
||||
const {
|
||||
studios,
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const studio = studios?.nodes?.[0]
|
||||
|
||||
if (!studio?.name) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<Drawer
|
||||
trigger={children}
|
||||
size="xl"
|
||||
title={studio.name}
|
||||
data-anime-entry-studio-details-modal
|
||||
>
|
||||
<div data-anime-entry-studio-details-modal-top-padding className="py-4"></div>
|
||||
<AnimeEntryStudioDetailsModalContent studios={studios} />
|
||||
</Drawer>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function AnimeEntryStudioDetailsModalContent(props: AnimeEntryStudioProps) {
|
||||
|
||||
const {
|
||||
studios,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { data, isLoading } = useGetAnilistStudioDetails(studios?.nodes?.[0]?.id!)
|
||||
|
||||
if (isLoading) return <LoadingSpinner />
|
||||
|
||||
return (
|
||||
<div
|
||||
data-anime-entry-studio-details-modal-content
|
||||
className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-4 xl:grid-cols-4 2xl:grid-cols-4 gap-4"
|
||||
>
|
||||
{data?.Studio?.media?.nodes?.map(media => {
|
||||
return <div key={media?.id!} className="col-span-1" data-anime-entry-studio-details-modal-content-media-entry-card>
|
||||
<MediaEntryCard
|
||||
media={media}
|
||||
type="anime"
|
||||
showLibraryBadge
|
||||
/>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,82 @@
|
||||
import { Checkbox } from "@/components/ui/checkbox"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import { upath } from "@/lib/helpers/upath"
|
||||
import React from "react"
|
||||
|
||||
type FilepathSelectorProps = {
|
||||
filepaths: string[]
|
||||
allFilepaths: string[]
|
||||
onFilepathSelected: React.Dispatch<React.SetStateAction<string[]>>
|
||||
showFullPath?: boolean
|
||||
} & React.ComponentPropsWithoutRef<"div">
|
||||
|
||||
export function FilepathSelector(props: FilepathSelectorProps) {
|
||||
|
||||
const {
|
||||
filepaths,
|
||||
allFilepaths,
|
||||
onFilepathSelected,
|
||||
showFullPath,
|
||||
className,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const allFilesChecked = filepaths.length === allFilepaths.length
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
className={cn(
|
||||
"overflow-y-auto px-2 space-y-1",
|
||||
className,
|
||||
)} {...rest}>
|
||||
|
||||
<div className="">
|
||||
<Checkbox
|
||||
label="Select all files"
|
||||
value={allFilesChecked ? true : filepaths.length === 0 ? false : "indeterminate"}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
onFilepathSelected(checked ? allFilepaths : [])
|
||||
}
|
||||
}}
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
|
||||
<Separator />
|
||||
|
||||
<div className="divide-[--border] divide-y">
|
||||
{allFilepaths?.toSorted((a, b) => a.localeCompare(b)).map((path, index) => (
|
||||
<div
|
||||
key={`${path}-${index}`}
|
||||
className="py-2"
|
||||
>
|
||||
<div className="flex items-center">
|
||||
<Checkbox
|
||||
label={<span className={cn("", showFullPath && "text-[--muted]")}>
|
||||
{showFullPath ? path.replace(upath.basename(path), "") : upath.basename(path)}{showFullPath &&
|
||||
<span className="text-[--foreground]">{upath.basename(path)}</span>}
|
||||
</span>}
|
||||
value={filepaths.includes(path)}
|
||||
onValueChange={checked => {
|
||||
if (typeof checked === "boolean") {
|
||||
onFilepathSelected(prev => checked
|
||||
? [...prev, path]
|
||||
: prev.filter(p => p !== path),
|
||||
)
|
||||
}
|
||||
}}
|
||||
labelClass="break-all tracking-wide text-sm"
|
||||
fieldLabelClass="break-all"
|
||||
fieldClass="w-[fit-content]"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,322 @@
|
||||
import { LuffyError } from "@/components/shared/luffy-error"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import React from "react"
|
||||
|
||||
const gridClass = cn(
|
||||
"grid grid-cols-2 min-[768px]:grid-cols-3 min-[1080px]:grid-cols-4 min-[1320px]:grid-cols-5 min-[1750px]:grid-cols-6 min-[1850px]:grid-cols-7 min-[2000px]:grid-cols-8 gap-4",
|
||||
)
|
||||
|
||||
type MediaCardGridProps = {
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function MediaCardGrid(props: MediaCardGridProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (React.Children.toArray(children).length === 0) {
|
||||
return <LuffyError title={null}>
|
||||
<p>Nothing to see</p>
|
||||
</LuffyError>
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-media-card-grid
|
||||
className={cn(gridClass)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type MediaCardLazyGridProps = {
|
||||
children: React.ReactNode
|
||||
itemCount: number
|
||||
containerRef?: React.RefObject<HTMLElement>
|
||||
} & React.HTMLAttributes<HTMLDivElement>;
|
||||
|
||||
export function MediaCardLazyGrid({
|
||||
children,
|
||||
itemCount,
|
||||
...rest
|
||||
}: MediaCardLazyGridProps) {
|
||||
if (itemCount === 0) {
|
||||
return <LuffyError title={null}>
|
||||
<p>Nothing to see</p>
|
||||
</LuffyError>
|
||||
}
|
||||
|
||||
if (itemCount <= 48) {
|
||||
return (
|
||||
<MediaCardGrid {...rest}>
|
||||
{children}
|
||||
</MediaCardGrid>
|
||||
)
|
||||
}
|
||||
|
||||
return (
|
||||
<MediaCardLazyGridRenderer itemCount={itemCount} {...rest}>
|
||||
{children}
|
||||
</MediaCardLazyGridRenderer>
|
||||
)
|
||||
}
|
||||
|
||||
const colClasses = [
|
||||
{ min: 0, cols: 2 },
|
||||
{ min: 768, cols: 3 },
|
||||
{ min: 1080, cols: 4 },
|
||||
{ min: 1320, cols: 5 },
|
||||
{ min: 1750, cols: 6 },
|
||||
{ min: 1850, cols: 7 },
|
||||
{ min: 2000, cols: 8 },
|
||||
]
|
||||
|
||||
export function MediaCardLazyGridRenderer({
|
||||
children,
|
||||
itemCount,
|
||||
...rest
|
||||
}: MediaCardLazyGridProps) {
|
||||
const [visibleIndices, setVisibleIndices] = React.useState<Set<number>>(new Set())
|
||||
const [itemHeights, setItemHeights] = React.useState<Map<number, number>>(new Map())
|
||||
const gridRef = React.useRef<HTMLDivElement>(null)
|
||||
const itemRefs = React.useRef<(HTMLDivElement | null)[]>([])
|
||||
const observerRef = React.useRef<IntersectionObserver | null>(null)
|
||||
|
||||
// Determine initial columns based on window width
|
||||
const initialColumns = React.useMemo(() =>
|
||||
colClasses.find(c => window.innerWidth >= c.min)?.cols ?? 8,
|
||||
[],
|
||||
)
|
||||
|
||||
// Initialize visible indices with first row
|
||||
React.useEffect(() => {
|
||||
const initialVisibleIndices = new Set(
|
||||
Array.from(Array(Math.min(initialColumns, itemCount)).keys()),
|
||||
)
|
||||
setVisibleIndices(initialVisibleIndices)
|
||||
|
||||
// Clear heights when component unmounts
|
||||
return () => {
|
||||
setItemHeights(new Map())
|
||||
}
|
||||
}, [initialColumns, itemCount])
|
||||
|
||||
// Intersection Observer to track which items become visible
|
||||
React.useEffect(() => {
|
||||
if (!gridRef.current) return
|
||||
|
||||
const observerOptions = {
|
||||
root: null,
|
||||
rootMargin: "200px 0px",
|
||||
threshold: 0,
|
||||
}
|
||||
|
||||
observerRef.current = new IntersectionObserver((entries) => {
|
||||
entries.forEach(entry => {
|
||||
const index = parseInt(entry.target.getAttribute("data-index") ?? "-1")
|
||||
|
||||
if (entry.isIntersecting) {
|
||||
// Add to visible indices
|
||||
setVisibleIndices(prev => {
|
||||
const updated = new Set(prev)
|
||||
updated.add(index)
|
||||
return updated
|
||||
})
|
||||
} else {
|
||||
// Remove from visible indices when scrolled out
|
||||
setVisibleIndices(prev => {
|
||||
const updated = new Set(prev)
|
||||
// Keep initial row always visible
|
||||
if (index >= initialColumns) {
|
||||
updated.delete(index)
|
||||
}
|
||||
return updated
|
||||
})
|
||||
}
|
||||
})
|
||||
}, observerOptions)
|
||||
|
||||
// Observe all items
|
||||
itemRefs.current.forEach(ref => {
|
||||
if (ref) observerRef.current?.observe(ref)
|
||||
})
|
||||
|
||||
return () => {
|
||||
observerRef.current?.disconnect()
|
||||
}
|
||||
}, [itemCount, initialColumns])
|
||||
|
||||
// Function to update item heights
|
||||
const updateItemHeight = React.useCallback((index: number, height: number) => {
|
||||
setItemHeights(prev => {
|
||||
const updated = new Map(prev)
|
||||
updated.set(index, height)
|
||||
return updated
|
||||
})
|
||||
}, [])
|
||||
|
||||
return (
|
||||
<div data-media-card-lazy-grid-renderer {...rest}>
|
||||
<div data-media-card-lazy-grid className={cn(gridClass)} ref={gridRef}>
|
||||
{React.Children.map(children, (child, index) => {
|
||||
const isVisible = visibleIndices.has(index)
|
||||
const storedHeight = itemHeights.get(index)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-card-lazy-grid-item
|
||||
ref={el => { itemRefs.current[index] = el }}
|
||||
data-index={index}
|
||||
key={!!(child as React.ReactElement)?.key ? (child as React.ReactElement)?.key : index}
|
||||
className="transition-all duration-300 ease-in-out"
|
||||
>
|
||||
{isVisible ? (
|
||||
<div
|
||||
data-media-card-lazy-grid-item-content
|
||||
ref={(el) => {
|
||||
// Measure and store height when first rendered
|
||||
if (el && !storedHeight) {
|
||||
updateItemHeight(index, el.offsetHeight)
|
||||
}
|
||||
}}
|
||||
>
|
||||
{child}
|
||||
</div>
|
||||
) : (
|
||||
<Skeleton
|
||||
data-media-card-lazy-grid-item-skeleton
|
||||
className="w-full"
|
||||
style={{
|
||||
height: storedHeight || "300px",
|
||||
}}
|
||||
></Skeleton>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
// type MediaCardLazyGridProps = {
|
||||
// children: React.ReactNode
|
||||
// itemCount: number
|
||||
// } & React.HTMLAttributes<HTMLDivElement>;
|
||||
//
|
||||
// export function MediaCardLazyGrid({
|
||||
// children,
|
||||
// itemCount,
|
||||
// ...rest
|
||||
// }: MediaCardLazyGridProps) {
|
||||
//
|
||||
// if (itemCount === 0) {
|
||||
// return <LuffyError title={null}>
|
||||
// <p>Nothing to see</p>
|
||||
// </LuffyError>
|
||||
// }
|
||||
//
|
||||
// if (itemCount <= 48) {
|
||||
// return (
|
||||
// <MediaCardGrid {...rest}>
|
||||
// {children}
|
||||
// </MediaCardGrid>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// return (
|
||||
// <MediaCardLazyGridRenderer itemCount={itemCount} {...rest}>
|
||||
// {children}
|
||||
// </MediaCardLazyGridRenderer>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// const colClasses = [
|
||||
// { min: 0, cols: 2 },
|
||||
// { min: 768, cols: 3 },
|
||||
// { min: 1080, cols: 4 },
|
||||
// { min: 1320, cols: 5 },
|
||||
// { min: 1750, cols: 6 },
|
||||
// { min: 1850, cols: 7 },
|
||||
// { min: 2000, cols: 8 },
|
||||
// ]
|
||||
//
|
||||
// export function MediaCardLazyGridRenderer({
|
||||
// children,
|
||||
// itemCount,
|
||||
// ...rest
|
||||
// }: MediaCardLazyGridProps) {
|
||||
//
|
||||
// const itemRef = React.useRef<HTMLDivElement | null>(null)
|
||||
// const [itemHeight, setItemHeight] = React.useState<number | null>(null)
|
||||
//
|
||||
// const [initialRenderArr] = React.useState(Array.from(Array(colClasses.find(c => window.innerWidth >= c.min)?.cols ?? 8).keys()))
|
||||
//
|
||||
// // Render the first row of items
|
||||
// const [indicesToRender, setIndicesToRender] = React.useState<number[]>(initialRenderArr)
|
||||
//
|
||||
// React.useLayoutEffect(() => {
|
||||
// if (itemRef.current) {
|
||||
// const itemRect = itemRef.current.getBoundingClientRect()
|
||||
// const itemHeight = itemRect.height
|
||||
// setItemHeight(itemHeight)
|
||||
// setIndicesToRender(Array.from(Array(itemCount).keys()))
|
||||
// }
|
||||
// }, [itemRef.current])
|
||||
//
|
||||
// const visibleChildren = indicesToRender.map((index) => (children as any)[index])
|
||||
//
|
||||
// return (
|
||||
// <div {...rest}>
|
||||
// <div
|
||||
// className={cn(gridClass)}
|
||||
// >
|
||||
// {visibleChildren.map((child, index) => (
|
||||
// <MediaCardLazyGridItem
|
||||
// key={!!(child as React.ReactElement)?.key ? (child as React.ReactElement)?.key : index}
|
||||
// ref={index === 0 ? itemRef : null}
|
||||
// itemHeight={itemHeight}
|
||||
// initialRenderCount={initialRenderArr.length}
|
||||
// index={index}
|
||||
// >
|
||||
// {child}
|
||||
// </MediaCardLazyGridItem>
|
||||
// ))}
|
||||
// </div>
|
||||
// </div>
|
||||
// )
|
||||
// }
|
||||
//
|
||||
// const MediaCardLazyGridItem = React.forwardRef<HTMLDivElement, React.HTMLAttributes<HTMLDivElement> & {
|
||||
// itemHeight: number | null,
|
||||
// index: number,
|
||||
// initialRenderCount: number
|
||||
// }>(({
|
||||
// children,
|
||||
// itemHeight,
|
||||
// initialRenderCount,
|
||||
// index,
|
||||
// ...rest
|
||||
// }, mRef) => {
|
||||
// const ref = React.useRef<HTMLDivElement | null>(null)
|
||||
// const isInView = useInView(ref as any, {
|
||||
// margin: "200px",
|
||||
// once: true,
|
||||
// })
|
||||
//
|
||||
// return (
|
||||
// <div ref={mergeRefs([mRef, ref])} {...rest}>
|
||||
// {(index < initialRenderCount || isInView) ? children : <div className="w-full" style={{ height: itemHeight || 0 }}></div>}
|
||||
// </div>
|
||||
//
|
||||
// )
|
||||
// })
|
||||
@@ -0,0 +1,578 @@
|
||||
import { AL_BaseAnime_NextAiringEpisode, AL_MediaListStatus, AL_MediaStatus } from "@/api/generated/types"
|
||||
import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
|
||||
import { MediaEntryProgressBadge } from "@/app/(main)/_features/media/_components/media-entry-progress-badge"
|
||||
import { __ui_fixBorderRenderingArtifacts } from "@/app/(main)/settings/_containers/ui-settings"
|
||||
import { GlowingEffect } from "@/components/shared/glowing-effect"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { addSeconds, formatDistanceToNow } from "date-fns"
|
||||
import { atom, useAtom } from "jotai/index"
|
||||
import { useAtomValue } from "jotai/react"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import startCase from "lodash/startCase"
|
||||
import Image from "next/image"
|
||||
import React, { memo } from "react"
|
||||
import { BiCalendarAlt } from "react-icons/bi"
|
||||
import { IoLibrarySharp } from "react-icons/io5"
|
||||
import { RiSignalTowerLine } from "react-icons/ri"
|
||||
|
||||
type MediaEntryCardContainerProps = {
|
||||
children?: React.ReactNode
|
||||
mRef?: React.RefObject<HTMLDivElement>
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function MediaEntryCardContainer(props: MediaEntryCardContainerProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
mRef,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-entry-card-container
|
||||
ref={mRef}
|
||||
className={cn(
|
||||
"h-full col-span-1 group/media-entry-card relative flex flex-col place-content-stretch focus-visible:outline-0 flex-none",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardOverlayProps = {
|
||||
overlay?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MediaEntryCardOverlay(props: MediaEntryCardOverlayProps) {
|
||||
|
||||
const {
|
||||
overlay,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-entry-card-overlay
|
||||
className={cn(
|
||||
"absolute z-[14] top-0 left-0 w-full",
|
||||
)}
|
||||
>{overlay}</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardHoverPopupProps = {
|
||||
children?: React.ReactNode
|
||||
coverImage?: string
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function MediaEntryCardHoverPopup(props: MediaEntryCardHoverPopupProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
coverImage,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const markBorderRenderingArtifacts = useAtomValue(__ui_fixBorderRenderingArtifacts)
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-entry-card-hover-popup
|
||||
className={cn(
|
||||
!ts.enableMediaCardBlurredBackground ? "bg-[--media-card-popup-background]" : "bg-[--background]",
|
||||
"absolute z-[15] opacity-0 scale-100 border border-[rgb(255_255_255_/_5%)] duration-150",
|
||||
"group-hover/media-entry-card:opacity-100 group-hover/media-entry-card:scale-100",
|
||||
"group-focus-visible/media-entry-card:opacity-100 group-focus-visible/media-entry-card:scale-100",
|
||||
"focus-visible:opacity-100 focus-visible:scale-100",
|
||||
"h-[105%] w-[100%] -top-[5%] rounded-[0.7rem] transition ease-in-out",
|
||||
"focus-visible:ring-2 ring-brand-400 focus-visible:outline-0",
|
||||
"hidden lg:block", // Hide on small screens
|
||||
markBorderRenderingArtifacts && "w-[101%] -left-[0.5%]",
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
<GlowingEffect
|
||||
spread={50}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={100}
|
||||
inactiveZone={0.01}
|
||||
// movementDuration={4}
|
||||
className="opacity-15"
|
||||
/>
|
||||
{(ts.enableMediaCardBlurredBackground && !!coverImage) &&
|
||||
<div
|
||||
data-media-entry-card-hover-popup-image-container
|
||||
className="absolute top-0 left-0 w-full h-full rounded-[--radius] overflow-hidden"
|
||||
>
|
||||
<Image
|
||||
data-media-entry-card-hover-popup-image
|
||||
src={getImageUrl(coverImage || "")}
|
||||
alt={"cover image"}
|
||||
fill
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
quality={100}
|
||||
sizes="20rem"
|
||||
className="object-cover object-center transition opacity-20"
|
||||
/>
|
||||
|
||||
<div
|
||||
data-media-entry-card-hover-popup-image-blur-overlay
|
||||
className="absolute top-0 w-full h-full backdrop-blur-xl z-[0]"
|
||||
></div>
|
||||
</div>}
|
||||
|
||||
{ts.enableMediaCardBlurredBackground && <div
|
||||
data-media-entry-card-hover-popup-image-blur-gradient
|
||||
className="w-full absolute top-0 h-full opacity-60 bg-gradient-to-b from-70% from-[--background] to-transparent z-[2] rounded-[--radius]"
|
||||
/>}
|
||||
|
||||
<div data-media-entry-card-hover-popup-content className="p-2 h-full w-full flex flex-col justify-between relative z-[2]">
|
||||
{children}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardHoverPopupBodyProps = {
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function MediaEntryCardHoverPopupBody(props: MediaEntryCardHoverPopupBodyProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-entry-card-hover-popup-body
|
||||
className={cn(
|
||||
"space-y-1 select-none",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardHoverPopupFooterProps = {
|
||||
children?: React.ReactNode
|
||||
} & React.HTMLAttributes<HTMLDivElement>
|
||||
|
||||
export function MediaEntryCardHoverPopupFooter(props: MediaEntryCardHoverPopupFooterProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
className,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div
|
||||
data-media-entry-card-hover-popup-footer
|
||||
className={cn(
|
||||
"flex gap-2 items-center",
|
||||
className,
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
{children}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardHoverPopupTitleSectionProps = {
|
||||
link: string
|
||||
title: string
|
||||
season?: string
|
||||
year?: number
|
||||
format?: string
|
||||
}
|
||||
|
||||
export function MediaEntryCardHoverPopupTitleSection(props: MediaEntryCardHoverPopupTitleSectionProps) {
|
||||
|
||||
const {
|
||||
link,
|
||||
title,
|
||||
season,
|
||||
year,
|
||||
format,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-media-entry-card-hover-popup-title className="select-none">
|
||||
<SeaLink
|
||||
href={link}
|
||||
className="text-center text-pretty font-medium text-sm lg:text-base px-4 leading-0 line-clamp-2 hover:text-brand-100"
|
||||
>
|
||||
{title}
|
||||
</SeaLink>
|
||||
</div>
|
||||
{!!year && <div>
|
||||
<p
|
||||
data-media-entry-card-hover-popup-title-section-year-season
|
||||
className="justify-center text-sm text-[--muted] flex w-full gap-1 items-center"
|
||||
>
|
||||
{startCase(format || "")} - <BiCalendarAlt /> {capitalize(season ?? "")} {year}
|
||||
</p>
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type AnimeEntryCardNextAiringProps = {
|
||||
nextAiring: AL_BaseAnime_NextAiringEpisode | undefined
|
||||
}
|
||||
|
||||
export function AnimeEntryCardNextAiring(props: AnimeEntryCardNextAiringProps) {
|
||||
|
||||
const {
|
||||
nextAiring,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (!nextAiring) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<div data-anime-entry-card-next-airing-container className="flex gap-1 items-center justify-center">
|
||||
{/*<p className="text-xs min-[2000px]:text-md">Next episode:</p>*/}
|
||||
<p data-anime-entry-card-next-airing className="text-justify font-normal text-xs min-[2000px]:text-md">
|
||||
Episode <span className="font-semibold">{nextAiring?.episode}</span> {formatDistanceToNow(addSeconds(new Date(),
|
||||
nextAiring?.timeUntilAiring), { addSuffix: true })}
|
||||
{/*<Badge*/}
|
||||
{/* size="sm"*/}
|
||||
{/* className="bg-transparent rounded-[--radius]"*/}
|
||||
{/*>{nextAiring?.episode}</Badge>*/}
|
||||
</p>
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardBodyProps = {
|
||||
link: string
|
||||
type: "anime" | "manga"
|
||||
title: string
|
||||
season?: string
|
||||
listStatus?: AL_MediaListStatus
|
||||
status?: AL_MediaStatus
|
||||
showProgressBar?: boolean
|
||||
progress?: number
|
||||
progressTotal?: number
|
||||
startDate?: { year?: number, month?: number, day?: number }
|
||||
bannerImage?: string
|
||||
isAdult?: boolean
|
||||
showLibraryBadge?: boolean
|
||||
children?: React.ReactNode
|
||||
blurAdultContent?: boolean
|
||||
}
|
||||
|
||||
export function MediaEntryCardBody(props: MediaEntryCardBodyProps) {
|
||||
|
||||
const {
|
||||
link,
|
||||
type,
|
||||
title,
|
||||
season,
|
||||
listStatus,
|
||||
status,
|
||||
showProgressBar,
|
||||
progress,
|
||||
progressTotal,
|
||||
startDate,
|
||||
bannerImage,
|
||||
isAdult,
|
||||
showLibraryBadge,
|
||||
children,
|
||||
blurAdultContent,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<SeaLink
|
||||
href={link}
|
||||
className="w-full relative focus-visible:ring-2 ring-[--brand]"
|
||||
data-media-entry-card-body-link
|
||||
>
|
||||
<div
|
||||
data-media-entry-card-body
|
||||
className={cn(
|
||||
"media-entry-card__body aspect-[6/8] flex-none rounded-[--radius] object-cover object-center relative overflow-hidden select-none",
|
||||
)}
|
||||
>
|
||||
|
||||
{/*[CUSTOM UI] BOTTOM GRADIENT*/}
|
||||
<MediaCardBodyBottomGradient />
|
||||
|
||||
{(showProgressBar && progress && progressTotal) && (
|
||||
<div
|
||||
data-media-entry-card-body-progress-bar-container
|
||||
className={cn(
|
||||
"absolute top-0 w-full h-1 z-[2] bg-gray-700 left-0",
|
||||
listStatus === "COMPLETED" && "hidden",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-media-entry-card-body-progress-bar
|
||||
className={cn(
|
||||
"h-1 absolute z-[2] left-0 bg-gray-200 transition-all",
|
||||
(listStatus === "CURRENT") ? "bg-brand-400" : "bg-gray-400",
|
||||
)}
|
||||
style={{
|
||||
width: `${String(Math.ceil((progress / progressTotal) * 100))}%`,
|
||||
}}
|
||||
></div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{(showLibraryBadge) &&
|
||||
<div data-media-entry-card-body-library-badge className="absolute z-[1] left-0 top-0">
|
||||
<Badge
|
||||
size="xl" intent="warning-solid"
|
||||
className="rounded-[--radius] rounded-bl-none rounded-tr-none text-orange-900"
|
||||
><IoLibrarySharp /></Badge>
|
||||
</div>}
|
||||
|
||||
{/*RELEASING BADGE*/}
|
||||
{(status === "RELEASING" || status === "NOT_YET_RELEASED") &&
|
||||
<div data-media-entry-card-body-releasing-badge-container className="absolute z-[10] right-1 top-2">
|
||||
<Badge intent={status === "RELEASING" ? "primary-solid" : "zinc-solid"} size="lg"><RiSignalTowerLine /></Badge>
|
||||
</div>}
|
||||
|
||||
|
||||
{children}
|
||||
|
||||
<Image
|
||||
data-media-entry-card-body-image
|
||||
src={getImageUrl(bannerImage || "")}
|
||||
alt={""}
|
||||
fill
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
quality={100}
|
||||
sizes="20rem"
|
||||
className={cn(
|
||||
"object-cover object-center transition-transform",
|
||||
"group-hover/media-entry-card:scale-110",
|
||||
(blurAdultContent && isAdult) && "opacity-80",
|
||||
)}
|
||||
/>
|
||||
|
||||
{(blurAdultContent && isAdult) && <div
|
||||
data-media-entry-card-body-blur-adult-content-overlay
|
||||
className="absolute top-0 w-[125%] h-[125%] -translate-x-[10%] -translate-y-[10%] backdrop-blur-xl z-[3] rounded-[--radius]"
|
||||
></div>}
|
||||
</div>
|
||||
</SeaLink>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaEntryCardTitleSectionProps = {
|
||||
title: string
|
||||
season?: string
|
||||
year?: number
|
||||
format?: string
|
||||
}
|
||||
|
||||
export function MediaEntryCardTitleSection(props: MediaEntryCardTitleSectionProps) {
|
||||
|
||||
const {
|
||||
title,
|
||||
season,
|
||||
year,
|
||||
format,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<div data-media-entry-card-title-section className="pt-2 space-y-1 flex flex-col justify-between h-full select-none">
|
||||
<div>
|
||||
<p
|
||||
data-media-entry-card-title-section-title
|
||||
className="text-pretty font-medium min-[2000px]:font-semibold text-sm lg:text-[1rem] min-[2000px]:text-lg line-clamp-2"
|
||||
>{title}</p>
|
||||
</div>
|
||||
{(!!season || !!year) && <div>
|
||||
<p data-media-entry-card-title-section-year-season className="text-sm text-[--muted] inline-flex gap-1 items-center">
|
||||
{capitalize(season ?? "")} {year}
|
||||
</p>
|
||||
</div>}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export const __mediaEntryCard_hoveredPopupId = atom<number | undefined>(undefined)
|
||||
|
||||
export const MediaEntryCardHoverPopupBanner = memo(({
|
||||
trailerId,
|
||||
showProgressBar,
|
||||
mediaId,
|
||||
progress,
|
||||
progressTotal,
|
||||
showTrailer,
|
||||
disableAnimeCardTrailers,
|
||||
bannerImage,
|
||||
isAdult,
|
||||
blurAdultContent,
|
||||
link,
|
||||
listStatus,
|
||||
status,
|
||||
}: {
|
||||
mediaId: number
|
||||
trailerId?: string
|
||||
progress?: number
|
||||
progressTotal?: number
|
||||
bannerImage?: string
|
||||
showProgressBar: boolean
|
||||
showTrailer?: boolean
|
||||
link: string
|
||||
disableAnimeCardTrailers?: boolean
|
||||
blurAdultContent?: boolean
|
||||
isAdult?: boolean
|
||||
listStatus?: AL_MediaListStatus
|
||||
status?: AL_MediaStatus
|
||||
}) => {
|
||||
|
||||
const [trailerLoaded, setTrailerLoaded] = React.useState(false)
|
||||
const [actionPopupHoverId] = useAtom(__mediaEntryCard_hoveredPopupId)
|
||||
const actionPopupHover = actionPopupHoverId === mediaId
|
||||
const [trailerEnabled, setTrailerEnabled] = React.useState(!!trailerId && !disableAnimeCardTrailers && showTrailer)
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
React.useEffect(() => {
|
||||
setTrailerEnabled(!!trailerId && !disableAnimeCardTrailers && showTrailer)
|
||||
}, [!!trailerId, !disableAnimeCardTrailers, showTrailer])
|
||||
|
||||
return <SeaLink tabIndex={-1} href={link} data-media-entry-card-hover-popup-banner-link>
|
||||
<div data-media-entry-card-hover-popup-banner-container className="aspect-[4/2] relative rounded-[--radius] mb-2 cursor-pointer">
|
||||
{(showProgressBar && progress && listStatus && progressTotal && progress !== progressTotal) &&
|
||||
<div
|
||||
data-media-entry-card-hover-popup-banner-progress-bar-container
|
||||
className="absolute rounded-[--radius] overflow-hidden top-0 w-full h-1 z-[2] bg-gray-700 left-0"
|
||||
>
|
||||
<div
|
||||
data-media-entry-card-hover-popup-banner-progress-bar
|
||||
className={cn(
|
||||
"h-1 absolute z-[2] left-0 bg-gray-200 transition-all",
|
||||
(listStatus === "CURRENT" || listStatus === "COMPLETED") ? "bg-brand-400" : "bg-gray-400",
|
||||
)}
|
||||
style={{ width: `${String(Math.ceil((progress / progressTotal) * 100))}%` }}
|
||||
></div>
|
||||
</div>}
|
||||
|
||||
{(status === "RELEASING" || status === "NOT_YET_RELEASED") &&
|
||||
<div data-media-entry-card-hover-popup-banner-releasing-badge-container className="absolute z-[10] right-1 top-2">
|
||||
<Tooltip
|
||||
trigger={<Badge intent={status === "RELEASING" ? "primary-solid" : "zinc-solid"} size="lg"><RiSignalTowerLine /></Badge>}
|
||||
>
|
||||
{status === "RELEASING" ? "Releasing" : "Not yet released"}
|
||||
</Tooltip>
|
||||
</div>}
|
||||
|
||||
{(!!bannerImage) ? <div className="absolute object-cover top-0 object-center w-full h-full rounded-[--radius] overflow-hidden"><Image
|
||||
data-media-entry-card-hover-popup-banner-image
|
||||
src={getImageUrl(bannerImage || "")}
|
||||
alt={"banner"}
|
||||
fill
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
quality={100}
|
||||
sizes="20rem"
|
||||
className={cn(
|
||||
"object-cover top-0 object-center rounded-[--radius] transition scale-[1.04] duration-200",
|
||||
"group-hover/media-entry-card:scale-100",
|
||||
trailerLoaded && "hidden",
|
||||
)}
|
||||
/></div> : <div
|
||||
data-media-entry-card-hover-popup-banner-image-gradient
|
||||
className="h-full block absolute w-full bg-gradient-to-t from-gray-800 to-transparent"
|
||||
></div>}
|
||||
|
||||
{(blurAdultContent && isAdult) && <div
|
||||
data-media-entry-card-hover-popup-banner-blur-adult-content-overlay
|
||||
className="absolute top-0 w-full h-full backdrop-blur-xl z-[3] rounded-[--radius]"
|
||||
></div>}
|
||||
|
||||
<div data-media-entry-card-hover-popup-banner-progress-badge-container className="absolute z-[4] left-0 bottom-0">
|
||||
<MediaEntryProgressBadge
|
||||
progress={progress}
|
||||
progressTotal={progressTotal}
|
||||
forceShowTotal
|
||||
/>
|
||||
</div>
|
||||
|
||||
{(trailerEnabled && actionPopupHover) && <div
|
||||
data-media-entry-card-hover-popup-banner-trailer-container
|
||||
className={cn(
|
||||
"absolute w-full h-full overflow-hidden rounded-[--radius]",
|
||||
!trailerLoaded && "hidden",
|
||||
)}
|
||||
>
|
||||
<iframe
|
||||
data-media-entry-card-hover-popup-banner-trailer
|
||||
src={`https://www.youtube-nocookie.com/embed/${trailerId}?autoplay=1&controls=0&mute=1&disablekb=1&loop=1&vq=medium&playlist=${trailerId}&cc_lang_pref=ja`}
|
||||
className={cn(
|
||||
"aspect-video w-full absolute left-0",
|
||||
)}
|
||||
// playsInline
|
||||
// preload="none"
|
||||
// loop
|
||||
allow="autoplay"
|
||||
// muted
|
||||
onLoad={() => setTrailerLoaded(true)}
|
||||
onError={() => setTrailerEnabled(false)}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
{<div
|
||||
data-media-entry-card-hover-popup-banner-gradient
|
||||
className={cn(
|
||||
"w-full absolute -bottom-1 h-[80%] from-10% bg-gradient-to-t from-[--media-card-popup-background] to-transparent z-[2]",
|
||||
ts.enableMediaCardBlurredBackground && "from-[--background] from-0% bottom-0 rounded-[--radius] opacity-80",
|
||||
)}
|
||||
/>}
|
||||
</div>
|
||||
</SeaLink>
|
||||
})
|
||||
@@ -0,0 +1,13 @@
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import React from "react"
|
||||
|
||||
export const MediaEntryCardSkeleton = () => {
|
||||
return (
|
||||
<>
|
||||
<Skeleton
|
||||
data-media-entry-card-skeleton
|
||||
className="min-w-[250px] basis-[250px] max-w-[250px] h-[350px] bg-gray-900 rounded-[--radius-md] mt-8 mx-2"
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,362 @@
|
||||
import {
|
||||
AL_BaseAnime,
|
||||
AL_BaseManga,
|
||||
Anime_EntryLibraryData,
|
||||
Anime_EntryListData,
|
||||
Anime_NakamaEntryLibraryData,
|
||||
Manga_EntryListData,
|
||||
} from "@/api/generated/types"
|
||||
import { getAtomicLibraryEntryAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
|
||||
import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms"
|
||||
import { AnimeEntryCardUnwatchedBadge } from "@/app/(main)/_features/anime/_containers/anime-entry-card-unwatched-badge"
|
||||
import { ToggleLockFilesButton } from "@/app/(main)/_features/anime/_containers/toggle-lock-files-button"
|
||||
import { SeaContextMenu } from "@/app/(main)/_features/context-menu/sea-context-menu"
|
||||
import {
|
||||
__mediaEntryCard_hoveredPopupId,
|
||||
AnimeEntryCardNextAiring,
|
||||
MediaEntryCardBody,
|
||||
MediaEntryCardContainer,
|
||||
MediaEntryCardHoverPopup,
|
||||
MediaEntryCardHoverPopupBanner,
|
||||
MediaEntryCardHoverPopupBody,
|
||||
MediaEntryCardHoverPopupFooter,
|
||||
MediaEntryCardHoverPopupTitleSection,
|
||||
MediaEntryCardOverlay,
|
||||
MediaEntryCardTitleSection,
|
||||
} from "@/app/(main)/_features/media/_components/media-entry-card-components"
|
||||
import { MediaEntryAudienceScore } from "@/app/(main)/_features/media/_components/media-entry-metadata-components"
|
||||
import { MediaEntryProgressBadge } from "@/app/(main)/_features/media/_components/media-entry-progress-badge"
|
||||
import { MediaEntryScoreBadge } from "@/app/(main)/_features/media/_components/media-entry-score-badge"
|
||||
import { AnilistMediaEntryModal } from "@/app/(main)/_features/media/_containers/anilist-media-entry-modal"
|
||||
import { useMediaPreviewModal } from "@/app/(main)/_features/media/_containers/media-preview-modal"
|
||||
import { useAnilistUserAnimeListData } from "@/app/(main)/_hooks/anilist-collection-loader"
|
||||
import { useMissingEpisodes } from "@/app/(main)/_hooks/missing-episodes-loader"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { MangaEntryCardUnreadBadge } from "@/app/(main)/manga/_containers/manga-entry-card-unread-badge"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button } from "@/components/ui/button"
|
||||
import { ContextMenuGroup, ContextMenuItem, ContextMenuLabel, ContextMenuTrigger } from "@/components/ui/context-menu"
|
||||
import { useAtom } from "jotai"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React, { useState } from "react"
|
||||
import { BiPlay } from "react-icons/bi"
|
||||
import { IoLibrarySharp } from "react-icons/io5"
|
||||
import { RiCalendarLine } from "react-icons/ri"
|
||||
import { PluginMediaCardContextMenuItems } from "../../plugin/actions/plugin-actions"
|
||||
|
||||
type MediaEntryCardBaseProps = {
|
||||
overlay?: React.ReactNode
|
||||
withAudienceScore?: boolean
|
||||
containerClassName?: string
|
||||
showListDataButton?: boolean
|
||||
}
|
||||
|
||||
type MediaEntryCardProps<T extends "anime" | "manga"> = {
|
||||
type: T
|
||||
media: T extends "anime" ? AL_BaseAnime : T extends "manga" ? AL_BaseManga : never
|
||||
// Anime-only
|
||||
listData?: T extends "anime" ? Anime_EntryListData : T extends "manga" ? Manga_EntryListData : never
|
||||
showLibraryBadge?: T extends "anime" ? boolean : never
|
||||
showTrailer?: T extends "anime" ? boolean : never
|
||||
libraryData?: T extends "anime" ? Anime_EntryLibraryData : never
|
||||
nakamaLibraryData?: T extends "anime" ? Anime_NakamaEntryLibraryData : never
|
||||
hideUnseenCountBadge?: boolean
|
||||
hideAnilistEntryEditButton?: boolean
|
||||
} & MediaEntryCardBaseProps
|
||||
|
||||
export function MediaEntryCard<T extends "anime" | "manga">(props: MediaEntryCardProps<T>) {
|
||||
|
||||
const {
|
||||
media,
|
||||
listData: _listData,
|
||||
libraryData: _libraryData,
|
||||
nakamaLibraryData,
|
||||
overlay,
|
||||
showListDataButton,
|
||||
showTrailer: _showTrailer,
|
||||
type,
|
||||
withAudienceScore = true,
|
||||
hideUnseenCountBadge = false,
|
||||
hideAnilistEntryEditButton = false,
|
||||
} = props
|
||||
|
||||
const router = useRouter()
|
||||
const serverStatus = useServerStatus()
|
||||
const missingEpisodes = useMissingEpisodes()
|
||||
const [listData, setListData] = useState<Anime_EntryListData | undefined>(_listData)
|
||||
const [libraryData, setLibraryData] = useState<Anime_EntryLibraryData | undefined>(_libraryData)
|
||||
const setActionPopupHover = useSetAtom(__mediaEntryCard_hoveredPopupId)
|
||||
|
||||
const ref = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
const [__atomicLibraryCollection, getAtomicLibraryEntry] = useAtom(getAtomicLibraryEntryAtom)
|
||||
|
||||
const showLibraryBadge = !!libraryData && !!props.showLibraryBadge
|
||||
|
||||
const showProgressBar = React.useMemo(() => {
|
||||
return !!listData?.progress
|
||||
&& type === "anime" ? !!(media as AL_BaseAnime)?.episodes : !!(media as AL_BaseManga)?.chapters
|
||||
&& listData?.status !== "COMPLETED"
|
||||
}, [listData?.progress, media, listData?.status])
|
||||
|
||||
const showTrailer = React.useMemo(() => _showTrailer && !libraryData && !media?.isAdult, [_showTrailer, libraryData, media])
|
||||
|
||||
const MANGA_LINK = serverStatus?.isOffline ? `/offline/entry/manga?id=${media.id}` : `/manga/entry?id=${media.id}`
|
||||
const ANIME_LINK = serverStatus?.isOffline ? `/offline/entry/anime?id=${media.id}` : `/entry?id=${media.id}`
|
||||
|
||||
const link = React.useMemo(() => {
|
||||
return type === "anime" ? ANIME_LINK : MANGA_LINK
|
||||
}, [serverStatus?.isOffline, type])
|
||||
|
||||
const progressTotal = type === "anime" ? (media as AL_BaseAnime)?.episodes : (media as AL_BaseManga)?.chapters
|
||||
|
||||
const pathname = usePathname()
|
||||
//
|
||||
// // Dynamically refresh data when LibraryCollection is updated
|
||||
React.useEffect(() => {
|
||||
if (pathname !== "/") {
|
||||
const entry = getAtomicLibraryEntry(media.id)
|
||||
if (!_listData) {
|
||||
setListData(entry?.listData)
|
||||
}
|
||||
if (!_libraryData) {
|
||||
setLibraryData(entry?.libraryData)
|
||||
}
|
||||
}
|
||||
}, [pathname, __atomicLibraryCollection])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setListData(_listData)
|
||||
}, [_listData])
|
||||
|
||||
React.useLayoutEffect(() => {
|
||||
setLibraryData(_libraryData)
|
||||
}, [_libraryData])
|
||||
|
||||
const listDataFromCollection = useAnilistUserAnimeListData(media.id)
|
||||
|
||||
React.useEffect(() => {
|
||||
if (listDataFromCollection && !_listData) {
|
||||
setListData(listDataFromCollection)
|
||||
}
|
||||
}, [listDataFromCollection, _listData])
|
||||
|
||||
const { setPlayNext } = usePlayNext()
|
||||
const handleWatchButtonClicked = React.useCallback(() => {
|
||||
if ((!!listData?.progress && (listData?.status !== "COMPLETED"))) {
|
||||
setPlayNext(media.id, () => {
|
||||
router.push(ANIME_LINK)
|
||||
})
|
||||
} else {
|
||||
router.push(ANIME_LINK)
|
||||
}
|
||||
}, [listData?.progress, listData?.status, media.id])
|
||||
|
||||
const onPopupMouseEnter = React.useCallback(() => {
|
||||
setActionPopupHover(media.id)
|
||||
}, [media.id])
|
||||
|
||||
const onPopupMouseLeave = React.useCallback(() => {
|
||||
setActionPopupHover(undefined)
|
||||
}, [media.id])
|
||||
|
||||
const { setPreviewModalMediaId } = useMediaPreviewModal()
|
||||
|
||||
if (!media) return null
|
||||
|
||||
return (
|
||||
<MediaEntryCardContainer
|
||||
data-media-id={media.id}
|
||||
data-media-mal-id={media.idMal}
|
||||
data-media-type={type}
|
||||
mRef={ref}
|
||||
className={props.containerClassName}
|
||||
data-list-data={JSON.stringify(listData)}
|
||||
>
|
||||
|
||||
<MediaEntryCardOverlay overlay={overlay} />
|
||||
|
||||
<SeaContextMenu
|
||||
content={<ContextMenuGroup>
|
||||
<ContextMenuLabel className="text-[--muted] line-clamp-1 py-0 my-2">
|
||||
{media.title?.userPreferred}
|
||||
</ContextMenuLabel>
|
||||
{!serverStatus?.isOffline && <ContextMenuItem
|
||||
onClick={() => {
|
||||
setPreviewModalMediaId(media.id!, type)
|
||||
}}
|
||||
>
|
||||
Preview
|
||||
</ContextMenuItem>}
|
||||
|
||||
<PluginMediaCardContextMenuItems for={type} media={media} />
|
||||
</ContextMenuGroup>}
|
||||
>
|
||||
<ContextMenuTrigger>
|
||||
|
||||
{/*ACTION POPUP*/}
|
||||
<MediaEntryCardHoverPopup
|
||||
onMouseEnter={onPopupMouseEnter}
|
||||
onMouseLeave={onPopupMouseLeave}
|
||||
coverImage={media.bannerImage || media.coverImage?.extraLarge || ""}
|
||||
>
|
||||
|
||||
{/*METADATA SECTION*/}
|
||||
<MediaEntryCardHoverPopupBody>
|
||||
|
||||
<MediaEntryCardHoverPopupBanner
|
||||
trailerId={(media as any)?.trailer?.id}
|
||||
showProgressBar={showProgressBar}
|
||||
mediaId={media.id}
|
||||
progress={listData?.progress}
|
||||
progressTotal={progressTotal}
|
||||
showTrailer={showTrailer}
|
||||
disableAnimeCardTrailers={serverStatus?.settings?.library?.disableAnimeCardTrailers}
|
||||
bannerImage={media.bannerImage || media.coverImage?.extraLarge}
|
||||
isAdult={media.isAdult}
|
||||
blurAdultContent={serverStatus?.settings?.anilist?.blurAdultContent}
|
||||
link={link}
|
||||
listStatus={listData?.status}
|
||||
status={media.status}
|
||||
/>
|
||||
|
||||
<MediaEntryCardHoverPopupTitleSection
|
||||
title={media.title?.userPreferred || ""}
|
||||
year={(media as AL_BaseAnime).seasonYear ?? media.startDate?.year}
|
||||
season={media.season}
|
||||
format={media.format}
|
||||
link={link}
|
||||
/>
|
||||
|
||||
{type === "anime" && (
|
||||
<AnimeEntryCardNextAiring nextAiring={(media as AL_BaseAnime).nextAiringEpisode} />
|
||||
)}
|
||||
|
||||
{type === "anime" && <div className="py-1">
|
||||
<Button
|
||||
leftIcon={<BiPlay className="text-2xl" />}
|
||||
intent="gray-subtle"
|
||||
size="sm"
|
||||
className="w-full text-sm"
|
||||
tabIndex={-1}
|
||||
onClick={handleWatchButtonClicked}
|
||||
>
|
||||
{!!listData?.progress && (listData?.status === "CURRENT" || listData?.status === "PAUSED")
|
||||
? "Continue watching"
|
||||
: "Watch"}
|
||||
</Button>
|
||||
</div>}
|
||||
|
||||
{type === "manga" && <SeaLink
|
||||
href={MANGA_LINK}
|
||||
>
|
||||
<Button
|
||||
leftIcon={<IoLibrarySharp />}
|
||||
intent="gray-subtle"
|
||||
size="sm"
|
||||
className="w-full text-sm mt-2"
|
||||
tabIndex={-1}
|
||||
>
|
||||
Read
|
||||
</Button>
|
||||
</SeaLink>}
|
||||
|
||||
{(listData?.status) &&
|
||||
<p className="text-center text-sm text-[--muted]">
|
||||
{listData?.status === "CURRENT" ? type === "anime" ? "Watching" : "Reading"
|
||||
: capitalize(listData?.status ?? "")}
|
||||
</p>}
|
||||
|
||||
</MediaEntryCardHoverPopupBody>
|
||||
|
||||
<MediaEntryCardHoverPopupFooter>
|
||||
|
||||
{(type === "anime" && !!libraryData) &&
|
||||
<ToggleLockFilesButton mediaId={media.id} allFilesLocked={libraryData.allFilesLocked} />}
|
||||
|
||||
{!hideAnilistEntryEditButton && <AnilistMediaEntryModal listData={listData} media={media} type={type} forceModal />}
|
||||
|
||||
{withAudienceScore &&
|
||||
<MediaEntryAudienceScore
|
||||
meanScore={media.meanScore}
|
||||
/>}
|
||||
|
||||
</MediaEntryCardHoverPopupFooter>
|
||||
</MediaEntryCardHoverPopup>
|
||||
</ContextMenuTrigger>
|
||||
</SeaContextMenu>
|
||||
|
||||
|
||||
<MediaEntryCardBody
|
||||
link={link}
|
||||
type={type}
|
||||
title={media.title?.userPreferred || ""}
|
||||
season={media.season}
|
||||
listStatus={listData?.status}
|
||||
status={media.status}
|
||||
showProgressBar={showProgressBar}
|
||||
progress={listData?.progress}
|
||||
progressTotal={progressTotal}
|
||||
startDate={media.startDate}
|
||||
bannerImage={media.coverImage?.extraLarge || ""}
|
||||
isAdult={media.isAdult}
|
||||
showLibraryBadge={showLibraryBadge}
|
||||
blurAdultContent={serverStatus?.settings?.anilist?.blurAdultContent}
|
||||
>
|
||||
<div data-media-entry-card-body-progress-badge-container className="absolute z-[10] left-0 bottom-0 flex items-end">
|
||||
<MediaEntryProgressBadge
|
||||
progress={listData?.progress}
|
||||
progressTotal={progressTotal}
|
||||
forceShowTotal={type === "manga"}
|
||||
// forceShowProgress={listData?.status === "CURRENT"}
|
||||
top={!hideUnseenCountBadge ? <>
|
||||
|
||||
{(type === "anime" && (listData?.status === "CURRENT" || listData?.status === "REPEATING")) && (
|
||||
<AnimeEntryCardUnwatchedBadge
|
||||
progress={listData?.progress || 0}
|
||||
media={media}
|
||||
libraryData={libraryData}
|
||||
nakamaLibraryData={nakamaLibraryData}
|
||||
/>
|
||||
)}
|
||||
{type === "manga" &&
|
||||
<MangaEntryCardUnreadBadge mediaId={media.id} progress={listData?.progress} progressTotal={progressTotal} />}
|
||||
</> : null}
|
||||
/>
|
||||
</div>
|
||||
<div data-media-entry-card-body-score-badge-container className="absolute z-[10] right-0 bottom-0">
|
||||
<MediaEntryScoreBadge
|
||||
isMediaCard
|
||||
score={listData?.score}
|
||||
/>
|
||||
</div>
|
||||
{(type === "anime" && !!libraryData && missingEpisodes.find(n => n.baseAnime?.id === media.id)) && (
|
||||
<div
|
||||
data-media-entry-card-body-missing-episodes-badge-container
|
||||
className="absolute z-[10] w-full flex justify-center left-1 bottom-0"
|
||||
>
|
||||
<Badge
|
||||
className="font-semibold animate-pulse text-white bg-gray-950 !bg-opacity-90 rounded-[--radius-md] text-base rounded-bl-none rounded-br-none"
|
||||
intent="gray-solid"
|
||||
size="xl"
|
||||
><RiCalendarLine /></Badge>
|
||||
</div>
|
||||
)}
|
||||
|
||||
</MediaEntryCardBody>
|
||||
|
||||
<MediaEntryCardTitleSection
|
||||
title={media.title?.userPreferred || ""}
|
||||
year={(media as AL_BaseAnime).seasonYear ?? media.startDate?.year}
|
||||
season={media.season}
|
||||
format={media.format}
|
||||
/>
|
||||
|
||||
</MediaEntryCardContainer>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
@@ -0,0 +1,116 @@
|
||||
import { AL_AnimeDetailsById_Media, AL_MangaDetailsById_Media } from "@/api/generated/types"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiSolidHeart } from "react-icons/bi"
|
||||
|
||||
type RelationsRecommendationsSectionProps = {
|
||||
details: AL_AnimeDetailsById_Media | AL_MangaDetailsById_Media | undefined
|
||||
isMangaPage?: boolean
|
||||
}
|
||||
|
||||
export function MediaEntryCharactersSection(props: RelationsRecommendationsSectionProps) {
|
||||
|
||||
const {
|
||||
details,
|
||||
isMangaPage,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const characters = React.useMemo(() => {
|
||||
return details?.characters?.edges?.filter(n => n.role === "MAIN" || n.role === "SUPPORTING") || []
|
||||
}, [details?.characters?.edges])
|
||||
|
||||
if (characters.length === 0) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{/*{!isMangaPage && <Separator />}*/}
|
||||
|
||||
<h2 data-media-entry-characters-section-title>Characters</h2>
|
||||
|
||||
<div
|
||||
data-media-entry-characters-section-grid
|
||||
className={cn(
|
||||
"grid grid-cols-1 md:grid-cols-2 lg:grid-cols-3 xl:grid-cols-4 2xl:grid-cols-5 gap-4",
|
||||
isMangaPage && "grid-cols-1 md:grid-col-2 lg:grid-cols-3 xl:grid-cols-2 2xl:grid-cols-2",
|
||||
)}
|
||||
>
|
||||
{characters?.slice(0, 10).map(edge => {
|
||||
return <div key={edge?.node?.id} className="col-span-1" data-media-entry-characters-section-grid-item>
|
||||
<div
|
||||
data-media-entry-characters-section-grid-item-container
|
||||
className={cn(
|
||||
"max-w-full flex gap-4",
|
||||
"rounded-lg relative transition group/episode-list-item select-none",
|
||||
!!ts.libraryScreenCustomBackgroundImage && ts.libraryScreenCustomBackgroundOpacity > 5
|
||||
? "bg-[--background] p-3"
|
||||
: "py-3",
|
||||
"pr-12",
|
||||
)}
|
||||
{...rest}
|
||||
>
|
||||
|
||||
<div
|
||||
data-media-entry-characters-section-grid-item-image-container
|
||||
className={cn(
|
||||
"size-20 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden",
|
||||
"group/ep-item-img-container",
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-media-entry-characters-section-grid-item-image-overlay
|
||||
className="absolute z-[1] rounded-[--radius-md] w-full h-full"
|
||||
></div>
|
||||
<div
|
||||
data-media-entry-characters-section-grid-item-image-background
|
||||
className="bg-[--background] absolute z-[0] rounded-[--radius-md] w-full h-full"
|
||||
></div>
|
||||
{(edge?.node?.image?.large) && <Image
|
||||
data-media-entry-characters-section-grid-item-image
|
||||
src={edge?.node?.image?.large || ""}
|
||||
alt="episode image"
|
||||
fill
|
||||
quality={60}
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
sizes="10rem"
|
||||
className={cn("object-cover object-center transition select-none")}
|
||||
data-src={edge?.node?.image?.large}
|
||||
/>}
|
||||
</div>
|
||||
|
||||
<div data-media-entry-characters-section-grid-item-content>
|
||||
<SeaLink href={edge?.node?.siteUrl || "#"} target="_blank" data-media-entry-characters-section-grid-item-content-link>
|
||||
<p
|
||||
className={cn(
|
||||
"text-lg font-semibold transition line-clamp-2 leading-5 hover:text-brand-100",
|
||||
)}
|
||||
>
|
||||
{edge?.node?.name?.full}
|
||||
</p>
|
||||
</SeaLink>
|
||||
|
||||
{edge?.node?.age && <p data-media-entry-characters-section-grid-item-content-age className="text-sm">
|
||||
{edge?.node?.age} years old
|
||||
</p>}
|
||||
|
||||
<p data-media-entry-characters-section-grid-item-content-role className="text-[--muted] text-xs">
|
||||
{edge?.role}
|
||||
</p>
|
||||
|
||||
{edge?.node?.isFavourite && <div data-media-entry-characters-section-grid-item-content-favourite>
|
||||
<BiSolidHeart className="text-pink-600 text-lg block" />
|
||||
</div>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,225 @@
|
||||
import { AL_AnimeDetailsById_Media_Rankings, AL_MangaDetailsById_Media_Rankings } from "@/api/generated/types"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Disclosure, DisclosureContent, DisclosureItem, DisclosureTrigger } from "@/components/ui/disclosure"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { getScoreColor } from "@/lib/helpers/score"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import React from "react"
|
||||
import { AiFillStar, AiOutlineHeart, AiOutlineStar } from "react-icons/ai"
|
||||
import { BiHeart, BiHide } from "react-icons/bi"
|
||||
|
||||
type MediaEntryGenresListProps = {
|
||||
genres?: Array<string | null> | null | undefined
|
||||
className?: string
|
||||
type?: "anime" | "manga"
|
||||
}
|
||||
|
||||
export function MediaEntryGenresList(props: MediaEntryGenresListProps) {
|
||||
|
||||
const {
|
||||
genres,
|
||||
className,
|
||||
type,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
if (!genres) return null
|
||||
|
||||
if (serverStatus?.isOffline) {
|
||||
return (
|
||||
<>
|
||||
<div data-media-entry-genres-list-container className={cn("items-center flex flex-wrap gap-3", className)}>
|
||||
{genres?.map(genre => {
|
||||
return <Badge
|
||||
key={genre!}
|
||||
className={cn(
|
||||
"opacity-75 hover:opacity-100 transition-all px-0 border-transparent bg-transparent hover:bg-transparent hover:text-white")}
|
||||
size="lg"
|
||||
data-media-entry-genres-list-item
|
||||
>
|
||||
{genre}
|
||||
</Badge>
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
} else {
|
||||
return (
|
||||
<>
|
||||
<div data-media-entry-genres-list className={cn("items-center flex flex-wrap gap-3", className)}>
|
||||
{genres?.map(genre => {
|
||||
return <SeaLink href={`/search?genre=${genre}&sorting=TRENDING_DESC${type === "manga" ? "&format=MANGA" : ""}`} key={genre!}>
|
||||
<Badge
|
||||
className={cn(
|
||||
"opacity-75 hover:opacity-100 transition-all px-0 border-transparent bg-transparent hover:bg-transparent hover:text-white")}
|
||||
size="lg"
|
||||
data-media-entry-genres-list-item
|
||||
>
|
||||
{genre}
|
||||
</Badge>
|
||||
</SeaLink>
|
||||
})}
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
}
|
||||
|
||||
type MediaEntryAudienceScoreProps = {
|
||||
meanScore?: number | null
|
||||
badgeClass?: string
|
||||
}
|
||||
|
||||
export function MediaEntryAudienceScore(props: MediaEntryAudienceScoreProps) {
|
||||
|
||||
const {
|
||||
meanScore,
|
||||
badgeClass,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const status = useServerStatus()
|
||||
const hideAudienceScore = React.useMemo(() => status?.settings?.anilist?.hideAudienceScore ?? false,
|
||||
[status?.settings?.anilist?.hideAudienceScore])
|
||||
|
||||
if (!meanScore) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{hideAudienceScore ? <Disclosure type="single" collapsible data-media-entry-audience-score-disclosure>
|
||||
<DisclosureItem value="item-1" className="flex items-center gap-0">
|
||||
<Tooltip
|
||||
side="right"
|
||||
trigger={<DisclosureTrigger asChild>
|
||||
<IconButton
|
||||
data-media-entry-audience-score-disclosure-trigger
|
||||
intent="gray-basic"
|
||||
icon={<BiHide className="text-sm" />}
|
||||
rounded
|
||||
size="sm"
|
||||
/>
|
||||
</DisclosureTrigger>}
|
||||
>Show audience score</Tooltip>
|
||||
<DisclosureContent>
|
||||
<Badge
|
||||
data-media-entry-audience-score
|
||||
intent="unstyled"
|
||||
size="lg"
|
||||
className={cn(getScoreColor(meanScore, "audience"), badgeClass)}
|
||||
leftIcon={<BiHeart className="text-xs" />}
|
||||
>{meanScore / 10}</Badge>
|
||||
</DisclosureContent>
|
||||
</DisclosureItem>
|
||||
</Disclosure> : <Badge
|
||||
data-media-entry-audience-score
|
||||
intent="unstyled"
|
||||
size="lg"
|
||||
className={cn(getScoreColor(meanScore, "audience"), badgeClass)}
|
||||
leftIcon={<BiHeart className="text-xs" />}
|
||||
>{meanScore / 10}</Badge>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
type AnimeEntryRankingsProps = {
|
||||
rankings?: AL_AnimeDetailsById_Media_Rankings[] | AL_MangaDetailsById_Media_Rankings[]
|
||||
}
|
||||
|
||||
export function AnimeEntryRankings(props: AnimeEntryRankingsProps) {
|
||||
|
||||
const {
|
||||
rankings,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const seasonMostPopular = rankings?.find(r => (!!r?.season || !!r?.year) && r?.type === "POPULAR" && r.rank <= 10)
|
||||
const allTimeHighestRated = rankings?.find(r => !!r?.allTime && r?.type === "RATED" && r.rank <= 100)
|
||||
const seasonHighestRated = rankings?.find(r => (!!r?.season || !!r?.year) && r?.type === "RATED" && r.rank <= 5)
|
||||
const allTimeMostPopular = rankings?.find(r => !!r?.allTime && r?.type === "POPULAR" && r.rank <= 100)
|
||||
|
||||
const formatFormat = React.useCallback((format: string) => {
|
||||
if (format === "MANGA") return ""
|
||||
return (format === "TV" ? "" : format).replace("_", " ")
|
||||
}, [])
|
||||
|
||||
const Link = React.useCallback((props: { children: React.ReactNode, href: string }) => {
|
||||
if (serverStatus?.isOffline) {
|
||||
return <>{props.children}</>
|
||||
}
|
||||
|
||||
return <SeaLink href={props.href}>{props.children}</SeaLink>
|
||||
}, [serverStatus])
|
||||
|
||||
if (!rankings) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{(!!allTimeHighestRated || !!seasonMostPopular) &&
|
||||
<div className="Sea-AnimeEntryRankings__container flex-wrap gap-2 hidden md:flex" data-anime-entry-rankings>
|
||||
{allTimeHighestRated && <Link
|
||||
href={`/search?sorting=SCORE_DESC${allTimeHighestRated.format ? `&format=${allTimeHighestRated.format}` : ""}`}
|
||||
data-anime-entry-rankings-item
|
||||
data-anime-entry-rankings-item-all-time-highest-rated
|
||||
>
|
||||
<Badge
|
||||
size="lg"
|
||||
intent="gray"
|
||||
leftIcon={<AiFillStar className="text-lg" />}
|
||||
iconClass="text-yellow-500"
|
||||
className="opacity-75 transition-all hover:opacity-100 rounded-full bg-transparent border-transparent px-0 hover:bg-transparent hover:text-white"
|
||||
>
|
||||
#{String(allTimeHighestRated.rank)} Highest
|
||||
Rated {formatFormat(allTimeHighestRated.format)} of All
|
||||
Time
|
||||
</Badge>
|
||||
</Link>}
|
||||
{seasonHighestRated && <Link
|
||||
href={`/search?sorting=SCORE_DESC${seasonHighestRated.format
|
||||
? `&format=${seasonHighestRated.format}`
|
||||
: ""}${seasonHighestRated.season ? `&season=${seasonHighestRated.season}` : ""}&year=${seasonHighestRated.year}`}
|
||||
data-anime-entry-rankings-item
|
||||
data-anime-entry-rankings-item-season-highest-rated
|
||||
>
|
||||
<Badge
|
||||
size="lg"
|
||||
intent="gray"
|
||||
leftIcon={<AiOutlineStar />}
|
||||
iconClass="text-yellow-500"
|
||||
className="opacity-75 transition-all hover:opacity-100 rounded-full border-transparent bg-transparent px-0 hover:bg-transparent hover:text-white"
|
||||
>
|
||||
#{String(seasonHighestRated.rank)} Highest
|
||||
Rated {formatFormat(seasonHighestRated.format)} of {capitalize(seasonHighestRated.season!)} {seasonHighestRated.year}
|
||||
</Badge>
|
||||
</Link>}
|
||||
{seasonMostPopular && <Link
|
||||
href={`/search?sorting=POPULARITY_DESC${seasonMostPopular.format
|
||||
? `&format=${seasonMostPopular.format}`
|
||||
: ""}${seasonMostPopular.year ? `&year=${seasonMostPopular.year}` : ""}${seasonMostPopular.season
|
||||
? `&season=${seasonMostPopular.season}`
|
||||
: ""}`}
|
||||
data-anime-entry-rankings-item
|
||||
data-anime-entry-rankings-item-season-most-popular
|
||||
>
|
||||
<Badge
|
||||
size="lg"
|
||||
intent="gray"
|
||||
leftIcon={<AiOutlineHeart />}
|
||||
iconClass="text-pink-500"
|
||||
className="opacity-75 transition-all hover:opacity-100 rounded-full border-transparent bg-transparent px-0 hover:bg-transparent hover:text-white"
|
||||
>
|
||||
#{(String(seasonMostPopular.rank))} Most
|
||||
Popular {formatFormat(seasonMostPopular.format)} of {capitalize(seasonMostPopular.season!)} {seasonMostPopular.year}
|
||||
</Badge>
|
||||
</Link>}
|
||||
</div>}
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,32 @@
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import React from "react"
|
||||
|
||||
export function MediaEntryPageLoadingDisplay() {
|
||||
const ts = useThemeSettings()
|
||||
|
||||
if (!!ts.libraryScreenCustomBackgroundImage) {
|
||||
return null
|
||||
}
|
||||
|
||||
return (
|
||||
<div data-media-entry-page-loading-display className="__header h-[30rem] fixed left-0 top-0 w-full">
|
||||
<div
|
||||
className={cn(
|
||||
"h-[30rem] w-full flex-none object-cover object-center absolute top-0 overflow-hidden",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
className="w-full absolute z-[1] top-0 h-[15rem] bg-gradient-to-b from-[--background] to-transparent via"
|
||||
/>
|
||||
<Skeleton className="h-full absolute w-full" />
|
||||
<div
|
||||
className="w-full absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-transparent to-transparent"
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,55 @@
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { __isDesktop__ } from "@/types/constants"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
|
||||
type MediaEntryPageSmallBannerProps = {
|
||||
bannerImage?: string
|
||||
}
|
||||
|
||||
export function MediaEntryPageSmallBanner(props: MediaEntryPageSmallBannerProps) {
|
||||
|
||||
const {
|
||||
bannerImage,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div
|
||||
data-media-entry-page-small-banner
|
||||
className={cn(
|
||||
"h-[30rem] w-full flex-none object-cover object-center absolute -top-[5rem] overflow-hidden bg-[--background]",
|
||||
(ts.hideTopNavbar || __isDesktop__) && "h-[27rem]",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
)}
|
||||
>
|
||||
<div
|
||||
data-media-entry-page-small-banner-gradient
|
||||
className="w-full absolute z-[2] top-0 h-[8rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via"
|
||||
/>
|
||||
<div data-media-entry-page-small-banner-image-container className="absolute w-full h-full">
|
||||
{(!!bannerImage) && <Image
|
||||
data-media-entry-page-small-banner-image
|
||||
src={bannerImage || ""}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className="object-cover object-center z-[1]"
|
||||
/>}
|
||||
</div>
|
||||
<div
|
||||
data-media-entry-page-small-banner-bottom-gradient
|
||||
className="w-full z-[3] absolute bottom-0 h-[32rem] bg-gradient-to-t from-[--background] via-[--background] via-50% to-transparent"
|
||||
/>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,44 @@
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import React from "react"
|
||||
|
||||
type MediaEntryProgressBadgeProps = {
|
||||
progress?: number
|
||||
progressTotal?: number
|
||||
forceShowTotal?: boolean
|
||||
forceShowProgress?: boolean
|
||||
top?: React.ReactNode
|
||||
}
|
||||
|
||||
export const MediaEntryProgressBadge = (props: MediaEntryProgressBadgeProps) => {
|
||||
const { progress, progressTotal, forceShowTotal, forceShowProgress, top } = props
|
||||
|
||||
// if (!progress) return null
|
||||
|
||||
return (
|
||||
<Badge
|
||||
intent="unstyled"
|
||||
size="lg"
|
||||
className="font-semibold tracking-wide flex-col rounded-[--radius-md] rounded-tl-none rounded-br-none border-0 bg-zinc-950/40 px-1.5 py-0.5 gap-0 !h-auto"
|
||||
data-media-entry-progress-badge
|
||||
>
|
||||
{top && <span data-media-entry-progress-badge-top className="block">
|
||||
{top}
|
||||
</span>}
|
||||
{(!!progress || forceShowProgress) && <span
|
||||
data-media-entry-progress-badge-progress
|
||||
className="block"
|
||||
data-progress={progress}
|
||||
data-progress-total={progressTotal}
|
||||
data-force-show-total={forceShowTotal}
|
||||
>
|
||||
{progress || 0}{(!!progressTotal || forceShowTotal) && <span
|
||||
data-media-entry-progress-badge-progress-total
|
||||
className={cn(
|
||||
"text-[--muted]",
|
||||
)}
|
||||
>/{(!!progressTotal) ? progressTotal : "-"}</span>}
|
||||
</span>}
|
||||
</Badge>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,27 @@
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { getScoreColor } from "@/lib/helpers/score"
|
||||
import React from "react"
|
||||
import { BiSolidStar, BiStar } from "react-icons/bi"
|
||||
|
||||
type MediaEntryScoreBadgeProps = {
|
||||
isMediaCard?: boolean
|
||||
score?: number // 0-100
|
||||
}
|
||||
|
||||
export const MediaEntryScoreBadge = (props: MediaEntryScoreBadgeProps) => {
|
||||
const { score, isMediaCard } = props
|
||||
|
||||
if (!score) return null
|
||||
return (
|
||||
<div
|
||||
data-media-entry-score-badge
|
||||
className={cn(
|
||||
"backdrop-blur-lg inline-flex items-center justify-center border gap-1 w-14 h-7 rounded-full font-bold bg-opacity-70 drop-shadow-sm shadow-lg",
|
||||
isMediaCard && "rounded-none rounded-tl-lg border-none",
|
||||
getScoreColor(score, "user"),
|
||||
)}
|
||||
>
|
||||
{score >= 90 ? <BiSolidStar className="text-sm" /> : <BiStar className="text-sm" />} {(score === 0) ? "-" : score / 10}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,120 @@
|
||||
import { Nullish } from "@/api/generated/types"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Modal, ModalProps } from "@/components/ui/modal"
|
||||
import { Popover, PopoverProps } from "@/components/ui/popover"
|
||||
import { Separator } from "@/components/ui/separator"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { AiFillWarning } from "react-icons/ai"
|
||||
import { MdInfo } from "react-icons/md"
|
||||
import { useWindowSize } from "react-use"
|
||||
|
||||
type MediaEpisodeInfoModalProps = {
|
||||
title?: Nullish<string>
|
||||
image?: Nullish<string>
|
||||
episodeTitle?: Nullish<string>
|
||||
airDate?: Nullish<string>
|
||||
length?: Nullish<number | string>
|
||||
summary?: Nullish<string>
|
||||
isInvalid?: Nullish<boolean>
|
||||
filename?: Nullish<string>
|
||||
}
|
||||
|
||||
function IsomorphicPopover(props: PopoverProps & ModalProps) {
|
||||
const { title, children, ...rest } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
if (width && width > 1024) {
|
||||
return <Popover
|
||||
{...rest}
|
||||
className="max-w-xl !w-full overflow-hidden min-w-md"
|
||||
>
|
||||
{children}
|
||||
</Popover>
|
||||
}
|
||||
|
||||
return <Modal
|
||||
{...rest}
|
||||
title={title}
|
||||
titleClass="text-xl"
|
||||
contentClass="max-w-2xl overflow-hidden"
|
||||
>
|
||||
{children}
|
||||
</Modal>
|
||||
}
|
||||
|
||||
export function MediaEpisodeInfoModal(props: MediaEpisodeInfoModalProps) {
|
||||
|
||||
const {
|
||||
title,
|
||||
image,
|
||||
episodeTitle,
|
||||
airDate,
|
||||
length,
|
||||
summary,
|
||||
isInvalid,
|
||||
filename,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
if (!episodeTitle && !filename && !summary) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
<IsomorphicPopover
|
||||
data-media-episode-info-modal
|
||||
trigger={<IconButton
|
||||
icon={<MdInfo />}
|
||||
className="opacity-30 hover:opacity-100 transform-opacity"
|
||||
intent="gray-basic"
|
||||
size="xs"
|
||||
/>}
|
||||
title={title || "Episode"}
|
||||
// contentClass="max-w-2xl"
|
||||
// titleClass="text-xl"
|
||||
>
|
||||
|
||||
{image && <div
|
||||
data-media-episode-info-modal-image-container
|
||||
className="h-[8rem] rounded-t-md w-full flex-none object-cover object-center overflow-hidden absolute left-0 top-0 z-[0]"
|
||||
>
|
||||
<Image
|
||||
data-media-episode-info-modal-image
|
||||
src={image}
|
||||
alt="banner"
|
||||
fill
|
||||
quality={80}
|
||||
priority
|
||||
sizes="20rem"
|
||||
className="object-cover object-center opacity-30"
|
||||
/>
|
||||
<div
|
||||
data-media-episode-info-modal-image-gradient
|
||||
className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent"
|
||||
/>
|
||||
</div>}
|
||||
|
||||
<div data-media-episode-info-modal-content className="space-y-4 z-[5] relative">
|
||||
<p data-media-episode-info-modal-content-title className="text-lg line-clamp-2 font-semibold">
|
||||
{episodeTitle?.replaceAll("`", "'")}
|
||||
{isInvalid && <AiFillWarning />}
|
||||
</p>
|
||||
{!(!airDate && !length) && <p className="text-[--muted]">
|
||||
{airDate || "Unknown airing date"} - {length || "N/A"} minutes
|
||||
</p>}
|
||||
<p className="text-gray-300">
|
||||
{summary?.replaceAll("`", "'") || "No summary"}
|
||||
</p>
|
||||
|
||||
{filename && <>
|
||||
<Separator />
|
||||
<p className="text-[--muted] line-clamp-2">
|
||||
{filename}
|
||||
</p>
|
||||
</>}
|
||||
</div>
|
||||
|
||||
</IsomorphicPopover>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,48 @@
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { HorizontalDraggableScroll } from "@/components/ui/horizontal-draggable-scroll"
|
||||
import { StaticTabs, StaticTabsItem } from "@/components/ui/tabs"
|
||||
import React from "react"
|
||||
|
||||
type MediaGenreSelectorProps = {
|
||||
items: StaticTabsItem[]
|
||||
className?: string
|
||||
staticTabsClass?: string,
|
||||
staticTabsTriggerClass?: string
|
||||
}
|
||||
|
||||
export function MediaGenreSelector(props: MediaGenreSelectorProps) {
|
||||
|
||||
const {
|
||||
items,
|
||||
className,
|
||||
staticTabsClass,
|
||||
staticTabsTriggerClass,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
return (
|
||||
<>
|
||||
<HorizontalDraggableScroll
|
||||
data-media-genre-selector
|
||||
className={cn(
|
||||
"scroll-pb-1 flex",
|
||||
className,
|
||||
)}
|
||||
>
|
||||
<div data-media-genre-selector-scroll-container className="flex flex-1"></div>
|
||||
<StaticTabs
|
||||
className={cn(
|
||||
"px-2 overflow-visible gap-2 py-4 w-fit",
|
||||
staticTabsClass,
|
||||
)}
|
||||
triggerClass={cn(
|
||||
"text-base rounded-[--radius-md] ring-1 ring-transparent data-[current=true]:ring-brand-500 data-[current=true]:text-brand-300",
|
||||
staticTabsTriggerClass,
|
||||
)}
|
||||
items={items}
|
||||
/>
|
||||
<div data-media-genre-selector-scroll-container-end className="flex flex-1"></div>
|
||||
</HorizontalDraggableScroll>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,542 @@
|
||||
import { AL_BaseAnime, AL_BaseManga, AL_MediaStatus, Anime_EntryListData, Manga_EntryListData, Nullish } from "@/api/generated/types"
|
||||
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
|
||||
import { AnilistMediaEntryModal } from "@/app/(main)/_features/media/_containers/anilist-media-entry-modal"
|
||||
import { imageShimmer } from "@/components/shared/image-helpers"
|
||||
import { TextGenerateEffect } from "@/components/shared/text-generate-effect"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Popover } from "@/components/ui/popover"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { getScoreColor } from "@/lib/helpers/score"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { ThemeMediaPageBannerSize, ThemeMediaPageBannerType, ThemeMediaPageInfoBoxSize, useIsMobile, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import capitalize from "lodash/capitalize"
|
||||
import { motion } from "motion/react"
|
||||
import Image from "next/image"
|
||||
import React from "react"
|
||||
import { BiCalendarAlt, BiSolidStar, BiStar } from "react-icons/bi"
|
||||
import { MdOutlineSegment } from "react-icons/md"
|
||||
import { RiSignalTowerFill } from "react-icons/ri"
|
||||
import { useWindowScroll, useWindowSize } from "react-use"
|
||||
|
||||
const MotionImage = motion.create(Image)
|
||||
|
||||
type MediaPageHeaderProps = {
|
||||
children?: React.ReactNode
|
||||
backgroundImage?: string
|
||||
coverImage?: string
|
||||
}
|
||||
|
||||
export function MediaPageHeader(props: MediaPageHeaderProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
backgroundImage,
|
||||
coverImage,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const { y } = useWindowScroll()
|
||||
const { isMobile } = useIsMobile()
|
||||
|
||||
const bannerImage = backgroundImage || coverImage
|
||||
const shouldHideBanner = (
|
||||
(ts.mediaPageBannerType === ThemeMediaPageBannerType.HideWhenUnavailable && !backgroundImage)
|
||||
|| ts.mediaPageBannerType === ThemeMediaPageBannerType.Hide
|
||||
)
|
||||
const shouldBlurBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.BlurWhenUnavailable && !backgroundImage) ||
|
||||
ts.mediaPageBannerType === ThemeMediaPageBannerType.Blur
|
||||
|
||||
const shouldDimBanner = (ts.mediaPageBannerType === ThemeMediaPageBannerType.DimWhenUnavailable && !backgroundImage) ||
|
||||
ts.mediaPageBannerType === ThemeMediaPageBannerType.Dim
|
||||
|
||||
const shouldShowBlurredBackground = ts.enableMediaPageBlurredBackground && (
|
||||
y > 100
|
||||
|| (shouldHideBanner && !ts.libraryScreenCustomBackgroundImage)
|
||||
)
|
||||
|
||||
|
||||
return (
|
||||
<motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{ opacity: 1 }}
|
||||
exit={{ opacity: 0 }}
|
||||
transition={{ duration: 1, ease: "easeOut" }}
|
||||
className="__meta-page-header relative group/media-page-header"
|
||||
data-media-page-header
|
||||
>
|
||||
|
||||
{/*<div*/}
|
||||
{/* className={cn(MediaPageHeaderAnatomy.fadeBg({ size }))}*/}
|
||||
{/*/>*/}
|
||||
|
||||
{(ts.enableMediaPageBlurredBackground) && <div
|
||||
data-media-page-header-blurred-background
|
||||
className={cn(
|
||||
"fixed opacity-0 transition-opacity duration-1000 top-0 left-0 w-full h-full z-[4] bg-[--background] rounded-xl",
|
||||
shouldShowBlurredBackground && "opacity-100",
|
||||
)}
|
||||
>
|
||||
<Image
|
||||
data-media-page-header-blurred-background-image
|
||||
src={getImageUrl(bannerImage || "")}
|
||||
alt={""}
|
||||
fill
|
||||
quality={100}
|
||||
sizes="20rem"
|
||||
className={cn(
|
||||
"object-cover object-bottom transition opacity-10",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "object-left",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-media-page-header-blurred-background-blur
|
||||
className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2]"
|
||||
></div>
|
||||
</div>}
|
||||
|
||||
{children}
|
||||
|
||||
<div
|
||||
data-media-page-header-banner
|
||||
className={cn(
|
||||
"w-full scroll-locked-offset flex-none object-cover object-center z-[3] bg-[--background] h-[20rem]",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[28rem]" : "h-[20rem] lg:h-[32rem] 2xl:h-[36.5rem]",
|
||||
ts.libraryScreenCustomBackgroundImage ? "absolute -top-[5rem]" : "fixed transition-opacity top-0 duration-1000",
|
||||
!ts.libraryScreenCustomBackgroundImage && y > 100 && (ts.enableMediaPageBlurredBackground ? "opacity-0" : shouldDimBanner
|
||||
? "opacity-15"
|
||||
: (y > 300 ? "opacity-5" : "opacity-15")),
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
shouldHideBanner && "bg-transparent",
|
||||
)}
|
||||
// style={{
|
||||
// opacity: !ts.libraryScreenCustomBackgroundImage && y > 100 ? (ts.enableMediaPageBlurredBackground ? 0 : shouldDimBanner ? 0.15
|
||||
// : 1 - Math.min(y * 0.005, 0.9) ) : 1, }}
|
||||
>
|
||||
{/*TOP FADE*/}
|
||||
<div
|
||||
data-media-page-header-banner-top-gradient
|
||||
className={cn(
|
||||
"w-full absolute z-[2] top-0 h-[8rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via",
|
||||
)}
|
||||
/>
|
||||
|
||||
{/*BOTTOM OVERFLOW FADE*/}
|
||||
<div
|
||||
data-media-page-header-banner-bottom-gradient
|
||||
className={cn(
|
||||
"w-full z-[2] absolute scroll-locked-offset bottom-[-5rem] h-[5em] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent",
|
||||
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
|
||||
shouldHideBanner && "hidden",
|
||||
)}
|
||||
/>
|
||||
|
||||
<motion.div
|
||||
data-media-page-header-banner-image-container
|
||||
className={cn(
|
||||
"absolute top-0 left-0 scroll-locked-offset w-full h-full overflow-hidden",
|
||||
// shouldBlurBanner && "blur-xl",
|
||||
shouldHideBanner && "hidden",
|
||||
)}
|
||||
initial={{ scale: 1, y: 0 }}
|
||||
animate={{
|
||||
scale: !ts.libraryScreenCustomBackgroundImage ? Math.min(1 + y * 0.0002, 1.03) : 1,
|
||||
y: isMobile ? 0 : Math.max(y * -0.9, -10),
|
||||
}}
|
||||
exit={{ scale: 1, y: 0 }}
|
||||
transition={{ duration: 0.6, ease: "easeOut" }}
|
||||
>
|
||||
{(!!bannerImage) && <MotionImage
|
||||
data-media-page-header-banner-image
|
||||
src={getImageUrl(bannerImage || "")}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center scroll-locked-offset z-[1]",
|
||||
// shouldDimBanner && "!opacity-30",
|
||||
)}
|
||||
initial={{ scale: 1.05, x: 0, y: -10, opacity: 0 }}
|
||||
animate={{ scale: 1, x: 0, y: 1, opacity: shouldDimBanner ? 0.3 : 1 }}
|
||||
transition={{ duration: 0.6, delay: 0.2, ease: "easeOut" }}
|
||||
/>}
|
||||
|
||||
{shouldBlurBanner && <div
|
||||
data-media-page-header-banner-blur
|
||||
className="absolute top-0 w-full h-full backdrop-blur-xl z-[2] "
|
||||
></div>}
|
||||
|
||||
{/*LEFT MASK*/}
|
||||
<div
|
||||
data-media-page-header-banner-left-gradient
|
||||
className={cn(
|
||||
"hidden lg:block max-w-[60rem] xl:max-w-[100rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] transition-opacity to-transparent",
|
||||
"opacity-85 duration-1000",
|
||||
// y > 300 && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
data-media-page-header-banner-right-gradient
|
||||
className={cn(
|
||||
"hidden lg:block max-w-[60rem] xl:max-w-[80rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] from-25% transition-opacity to-transparent",
|
||||
"opacity-50 duration-500",
|
||||
)}
|
||||
/>
|
||||
</motion.div>
|
||||
|
||||
{/*BOTTOM FADE*/}
|
||||
<div
|
||||
data-media-page-header-banner-bottom-gradient
|
||||
className={cn(
|
||||
"w-full z-[3] absolute bottom-0 h-[50%] bg-gradient-to-t from-[--background] via-transparent via-100% to-transparent",
|
||||
shouldHideBanner && "hidden",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
data-media-page-header-banner-dim
|
||||
className={cn(
|
||||
"absolute h-full w-full block lg:hidden bg-[--background] opacity-70 z-[2]",
|
||||
shouldHideBanner && "hidden",
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type MediaPageHeaderDetailsContainerProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MediaPageHeaderDetailsContainer(props: MediaPageHeaderDetailsContainerProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const { y } = useWindowScroll()
|
||||
const { width } = useWindowSize()
|
||||
|
||||
return (
|
||||
<>
|
||||
<motion.div
|
||||
initial={{ opacity: 1, y: 0 }}
|
||||
animate={{
|
||||
opacity: (width >= 1024 && y > 400) ? Math.max(1 - y * 0.006, 0.1) : 1,
|
||||
y: (width >= 1024 && y > 200) ? Math.max(y * -0.05, -40) : 0,
|
||||
}}
|
||||
transition={{ duration: 0.4, ease: "easeOut" }}
|
||||
className="relative z-[4]"
|
||||
>
|
||||
<motion.div
|
||||
initial={{ opacity: 0, x: -20 }}
|
||||
animate={{ opacity: 1, x: 0 }}
|
||||
exit={{ opacity: 0, x: -20 }}
|
||||
transition={{ duration: 0.7, delay: 0.4 }}
|
||||
className="relative z-[4]"
|
||||
data-media-page-header-details-container
|
||||
>
|
||||
<div
|
||||
data-media-page-header-details-inner-container
|
||||
className={cn(
|
||||
"space-y-8 p-6 sm:p-8 relative",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "p-6 sm:py-4 sm:px-8",
|
||||
ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid
|
||||
? "w-full"
|
||||
: "lg:max-w-[100%] xl:max-w-[80%] 2xl:max-w-[65rem] 5xl:max-w-[80rem]",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
{...{
|
||||
initial: { opacity: 0 },
|
||||
animate: { opacity: 1 },
|
||||
exit: { opacity: 0 },
|
||||
transition: {
|
||||
type: "spring",
|
||||
damping: 20,
|
||||
stiffness: 100,
|
||||
delay: 0.1,
|
||||
},
|
||||
}}
|
||||
className="space-y-4"
|
||||
data-media-page-header-details-motion-container
|
||||
>
|
||||
|
||||
{children}
|
||||
|
||||
</motion.div>
|
||||
|
||||
</div>
|
||||
</motion.div>
|
||||
</motion.div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
|
||||
type MediaPageHeaderEntryDetailsProps = {
|
||||
children?: React.ReactNode
|
||||
coverImage?: string
|
||||
color?: string
|
||||
title?: string
|
||||
englishTitle?: string
|
||||
romajiTitle?: string
|
||||
startDate?: { year?: number, month?: number }
|
||||
season?: string
|
||||
progressTotal?: number
|
||||
status?: AL_MediaStatus
|
||||
description?: string
|
||||
smallerTitle?: boolean
|
||||
|
||||
listData?: Anime_EntryListData | Manga_EntryListData
|
||||
media: AL_BaseAnime | AL_BaseManga
|
||||
type: "anime" | "manga"
|
||||
offlineAnilistAnimeEntryModal?: React.ReactNode
|
||||
}
|
||||
|
||||
export function MediaPageHeaderEntryDetails(props: MediaPageHeaderEntryDetailsProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
coverImage,
|
||||
title,
|
||||
englishTitle,
|
||||
romajiTitle,
|
||||
startDate,
|
||||
season,
|
||||
progressTotal,
|
||||
status,
|
||||
description,
|
||||
color,
|
||||
smallerTitle,
|
||||
|
||||
listData,
|
||||
media,
|
||||
type,
|
||||
offlineAnilistAnimeEntryModal,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const { y } = useWindowScroll()
|
||||
|
||||
return (
|
||||
<>
|
||||
<div className="flex flex-col lg:flex-row gap-8" data-media-page-header-entry-details>
|
||||
|
||||
{!!coverImage && <motion.div
|
||||
initial={{ opacity: 0 }}
|
||||
animate={{
|
||||
opacity: 1,
|
||||
// scale: Math.max(1 - y * 0.0002, 0.96),
|
||||
// y: Math.max(y * -0.1, -10)
|
||||
}}
|
||||
transition={{ duration: 0.3 }}
|
||||
data-media-page-header-entry-details-cover-image-container
|
||||
className={cn(
|
||||
"flex-none aspect-[6/8] max-w-[150px] mx-auto lg:m-0 h-auto sm:max-w-[200px] lg:max-w-[230px] w-full relative rounded-[--radius-md] overflow-hidden bg-[--background] shadow-md block",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && "max-w-[150px] lg:m-0 h-auto sm:max-w-[195px] lg:max-w-[210px] -top-1",
|
||||
ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "lg:max-w-[270px]",
|
||||
(ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid) && "lg:max-w-[220px]",
|
||||
)}
|
||||
>
|
||||
<motion.div
|
||||
initial={{ scale: 1.1, x: -10 }}
|
||||
animate={{ scale: 1, x: 0 }}
|
||||
transition={{ duration: 0.6, delay: 0.3, ease: "easeOut" }}
|
||||
className="w-full h-full"
|
||||
>
|
||||
<MotionImage
|
||||
data-media-page-header-entry-details-cover-image
|
||||
src={getImageUrl(coverImage)}
|
||||
alt="cover image"
|
||||
fill
|
||||
priority
|
||||
placeholder={imageShimmer(700, 475)}
|
||||
className="object-cover object-center"
|
||||
initial={{ scale: 1.1, x: 0 }}
|
||||
animate={{ scale: Math.min(1 + y * 0.0002, 1.05), x: 0 }}
|
||||
transition={{ duration: 0.3, ease: "easeOut" }}
|
||||
/>
|
||||
</motion.div>
|
||||
</motion.div>}
|
||||
|
||||
|
||||
<div
|
||||
data-media-page-header-entry-details-content
|
||||
className={cn(
|
||||
"space-y-2 lg:space-y-4",
|
||||
(ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small || ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid) && "lg:space-y-3",
|
||||
)}
|
||||
>
|
||||
{/*TITLE*/}
|
||||
<div className="space-y-2" data-media-page-header-entry-details-title-container>
|
||||
<TextGenerateEffect
|
||||
className={cn(
|
||||
"[text-shadow:_0_1px_10px_rgb(0_0_0_/_20%)] text-white line-clamp-2 pb-1 text-center lg:text-left text-pretty text-3xl 2xl:text-5xl xl:max-w-[50vw]",
|
||||
smallerTitle && "text-3xl 2xl:text-3xl",
|
||||
)}
|
||||
words={title || ""}
|
||||
/>
|
||||
{(!!englishTitle && title?.toLowerCase() !== englishTitle?.toLowerCase()) &&
|
||||
<h4 className="text-gray-200 line-clamp-1 text-center lg:text-left xl:max-w-[50vw]">{englishTitle}</h4>}
|
||||
{(!!romajiTitle && title?.toLowerCase() !== romajiTitle?.toLowerCase()) &&
|
||||
<h4 className="text-gray-200 line-clamp-1 text-center lg:text-left xl:max-w-[50vw]">{romajiTitle}</h4>}
|
||||
</div>
|
||||
|
||||
{/*DATE*/}
|
||||
{!!startDate?.year && (
|
||||
<div
|
||||
className="flex gap-4 items-center flex-wrap justify-center lg:justify-start"
|
||||
data-media-page-header-entry-details-date-container
|
||||
>
|
||||
<p className="text-lg text-white flex gap-1 items-center">
|
||||
<BiCalendarAlt /> {new Intl.DateTimeFormat("en-US", {
|
||||
year: "numeric",
|
||||
month: "short",
|
||||
}).format(new Date(startDate?.year || 0, startDate?.month ? startDate?.month - 1 : 0))}{!!season
|
||||
? ` - ${capitalize(season)}`
|
||||
: ""}
|
||||
</p>
|
||||
|
||||
{((status !== "FINISHED" && type === "anime") || type === "manga") && <Badge
|
||||
size="lg"
|
||||
intent={status === "RELEASING" ? "primary" : "gray"}
|
||||
className="bg-transparent border-transparent dark:text-brand-200 px-0 rounded-none"
|
||||
leftIcon={<RiSignalTowerFill />}
|
||||
data-media-page-header-entry-details-date-badge
|
||||
>
|
||||
{capitalize(status || "")?.replaceAll("_", " ")}
|
||||
</Badge>}
|
||||
|
||||
{ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small && <Popover
|
||||
trigger={
|
||||
<IconButton
|
||||
intent="white-subtle"
|
||||
className="rounded-full"
|
||||
size="sm"
|
||||
icon={<MdOutlineSegment />}
|
||||
/>
|
||||
}
|
||||
className="max-w-[40rem] bg-[--background] p-4 w-[20rem] lg:w-[40rem] text-md"
|
||||
>
|
||||
<span className="transition-colors">{description?.replace(/(<([^>]+)>)/ig, "")}</span>
|
||||
</Popover>}
|
||||
</div>
|
||||
)}
|
||||
|
||||
|
||||
{/*LIST*/}
|
||||
<div className="flex gap-2 md:gap-4 items-center justify-center lg:justify-start" data-media-page-header-entry-details-more-info>
|
||||
|
||||
<MediaPageHeaderScoreAndProgress
|
||||
score={listData?.score}
|
||||
progress={listData?.progress}
|
||||
episodes={progressTotal}
|
||||
/>
|
||||
|
||||
<AnilistMediaEntryModal listData={listData} media={media} type={type} />
|
||||
|
||||
{(listData?.status || listData?.repeat) &&
|
||||
<div
|
||||
data-media-page-header-entry-details-status
|
||||
className="text-base text-white md:text-lg flex items-center"
|
||||
>{capitalize(listData?.status === "CURRENT"
|
||||
? type === "anime" ? "watching" : "reading"
|
||||
: listData?.status)}
|
||||
{listData?.repeat && <Tooltip
|
||||
trigger={<Badge
|
||||
size="md"
|
||||
intent="gray"
|
||||
className="ml-3"
|
||||
data-media-page-header-entry-details-repeating-badge
|
||||
>
|
||||
{listData?.repeat}
|
||||
|
||||
</Badge>}
|
||||
>
|
||||
{listData?.repeat} {type === "anime" ? "rewatch" : "reread"}{listData?.repeat > 1
|
||||
? type === "anime" ? "es" : "s"
|
||||
: ""}
|
||||
</Tooltip>}
|
||||
</div>}
|
||||
|
||||
</div>
|
||||
|
||||
{ts.mediaPageBannerSize !== ThemeMediaPageBannerSize.Small && <Popover
|
||||
trigger={<div
|
||||
className={cn(
|
||||
"cursor-pointer max-h-16 line-clamp-3 col-span-2 left-[-.5rem] text-[--muted] 2xl:max-w-[50vw] hover:text-white transition-colors duration-500 text-sm pr-2",
|
||||
"bg-transparent rounded-[--radius-md] text-center lg:text-left",
|
||||
)}
|
||||
data-media-page-header-details-description-trigger
|
||||
>
|
||||
{description?.replace(/(<([^>]+)>)/ig, "")}
|
||||
</div>}
|
||||
className="max-w-[40rem] bg-[--background] p-4 w-[20rem] lg:w-[40rem] text-md"
|
||||
data-media-page-header-details-description-popover
|
||||
>
|
||||
<span className="transition-colors">{description?.replace(/(<([^>]+)>)/ig, "")}</span>
|
||||
</Popover>}
|
||||
|
||||
{children}
|
||||
|
||||
</div>
|
||||
|
||||
</div>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
//////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
export function MediaPageHeaderScoreAndProgress({ score, progress, episodes }: {
|
||||
score: Nullish<number>,
|
||||
progress: Nullish<number>,
|
||||
episodes: Nullish<number>,
|
||||
}) {
|
||||
|
||||
return (
|
||||
<>
|
||||
{!!score && <Badge
|
||||
leftIcon={score >= 90 ? <BiSolidStar className="text-sm" /> : <BiStar className="text-sm" />}
|
||||
size="xl"
|
||||
intent="unstyled"
|
||||
className={getScoreColor(score, "user")}
|
||||
data-media-page-header-score-badge
|
||||
>
|
||||
{score / 10}
|
||||
</Badge>}
|
||||
<Badge
|
||||
size="xl"
|
||||
intent="basic"
|
||||
className="!text-xl font-bold !text-white px-0 gap-0 rounded-none"
|
||||
data-media-page-header-progress-badge
|
||||
>
|
||||
<span data-media-page-header-progress-badge-progress>{`${progress ?? 0}`}</span><span
|
||||
data-media-page-header-progress-total
|
||||
className={cn(
|
||||
(!progress || progress !== episodes) && "opacity-60",
|
||||
)}
|
||||
>/{episodes || "-"}</span>
|
||||
</Badge>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,327 @@
|
||||
"use client"
|
||||
import { AL_BaseAnime, AL_BaseManga, AL_MediaListStatus, Anime_EntryListData, Manga_EntryListData } from "@/api/generated/types"
|
||||
import { useDeleteAnilistListEntry, useEditAnilistListEntry } from "@/api/hooks/anilist.hooks"
|
||||
import { useUpdateAnimeEntryRepeat } from "@/api/hooks/anime_entries.hooks"
|
||||
import { useCurrentUser } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { Disclosure, DisclosureContent, DisclosureItem, DisclosureTrigger } from "@/components/ui/disclosure"
|
||||
import { defineSchema, Field, Form } from "@/components/ui/form"
|
||||
import { Modal, ModalProps } from "@/components/ui/modal"
|
||||
import { NumberInput } from "@/components/ui/number-input"
|
||||
import { Popover, PopoverProps } from "@/components/ui/popover"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { normalizeDate } from "@/lib/helpers/date"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { useWindowSize } from "@uidotdev/usehooks"
|
||||
import Image from "next/image"
|
||||
import React, { Fragment } from "react"
|
||||
import { AiFillEdit } from "react-icons/ai"
|
||||
import { BiListPlus, BiPlus, BiStar, BiTrash } from "react-icons/bi"
|
||||
import { useToggle } from "react-use"
|
||||
|
||||
type AnilistMediaEntryModalProps = {
|
||||
children?: React.ReactNode
|
||||
listData?: Anime_EntryListData | Manga_EntryListData
|
||||
media?: AL_BaseAnime | AL_BaseManga
|
||||
hideButton?: boolean
|
||||
type?: "anime" | "manga"
|
||||
forceModal?: boolean
|
||||
}
|
||||
|
||||
export const mediaListDataSchema = defineSchema(({ z, presets }) => z.object({
|
||||
status: z.custom<AL_MediaListStatus>().nullish(),
|
||||
score: z.number().min(0).max(100).nullish(),
|
||||
progress: z.number().min(0).nullish(),
|
||||
startedAt: presets.datePicker.nullish(),
|
||||
completedAt: presets.datePicker.nullish(),
|
||||
}))
|
||||
|
||||
function IsomorphicPopover(props: PopoverProps & ModalProps & { media?: AL_BaseAnime | AL_BaseManga, forceModal?: boolean }) {
|
||||
const { title, children, media, forceModal, ...rest } = props
|
||||
const { width } = useWindowSize()
|
||||
|
||||
if ((width && width > 1024) && !forceModal) {
|
||||
return <Popover
|
||||
{...rest}
|
||||
className="max-w-5xl !w-full overflow-hidden bg-gray-950/95 backdrop-blur-sm rounded-xl"
|
||||
>
|
||||
<p className="mb-4 font-semibold text-center px-6 line-clamp-1">
|
||||
{media?.title?.userPreferred}
|
||||
</p>
|
||||
{children}
|
||||
</Popover>
|
||||
}
|
||||
|
||||
return <Modal
|
||||
{...rest}
|
||||
title={title}
|
||||
titleClass="text-xl"
|
||||
contentClass="max-w-3xl overflow-hidden"
|
||||
>
|
||||
{media?.bannerImage && <div
|
||||
data-anilist-media-entry-modal-banner-image-container
|
||||
className="h-24 w-full flex-none object-cover object-center overflow-hidden absolute left-0 top-0 z-[0]"
|
||||
>
|
||||
<Image
|
||||
data-anilist-media-entry-modal-banner-image
|
||||
src={getImageUrl(media?.bannerImage!)}
|
||||
alt="banner"
|
||||
fill
|
||||
quality={80}
|
||||
priority
|
||||
sizes="20rem"
|
||||
className="object-cover object-center opacity-5 z-[1]"
|
||||
/>
|
||||
<div
|
||||
data-anilist-media-entry-modal-banner-image-bottom-gradient
|
||||
className="z-[5] absolute bottom-0 w-full h-[60%] bg-gradient-to-t from-[--background] to-transparent"
|
||||
/>
|
||||
</div>}
|
||||
{children}
|
||||
</Modal>
|
||||
}
|
||||
|
||||
|
||||
export const AnilistMediaEntryModal = (props: AnilistMediaEntryModalProps) => {
|
||||
const [open, toggle] = useToggle(false)
|
||||
|
||||
const { children, media, listData, hideButton, type = "anime", forceModal, ...rest } = props
|
||||
|
||||
const user = useCurrentUser()
|
||||
|
||||
const { mutate, isPending: _isPending1, isSuccess } = useEditAnilistListEntry(media?.id, type)
|
||||
const { mutate: mutateRepeat, isPending: _isPending2 } = useUpdateAnimeEntryRepeat(media?.id)
|
||||
const isPending = _isPending1
|
||||
const { mutate: deleteEntry, isPending: isDeleting } = useDeleteAnilistListEntry(media?.id, type, () => {
|
||||
toggle(false)
|
||||
})
|
||||
|
||||
const [repeat, setRepeat] = React.useState(0)
|
||||
|
||||
React.useEffect(() => {
|
||||
setRepeat(listData?.repeat || 0)
|
||||
}, [listData])
|
||||
|
||||
if (!user) return null
|
||||
|
||||
return (
|
||||
<>
|
||||
{!hideButton && <>
|
||||
{(!listData) && <Tooltip
|
||||
trigger={<IconButton
|
||||
data-anilist-media-entry-modal-add-button
|
||||
intent="primary-subtle"
|
||||
icon={<BiPlus />}
|
||||
rounded
|
||||
size="sm"
|
||||
loading={isPending || isDeleting}
|
||||
className={cn({ "hidden": isSuccess })} // Hide button when mutation is successful
|
||||
onClick={() => mutate({
|
||||
mediaId: media?.id || 0,
|
||||
status: "PLANNING",
|
||||
score: 0,
|
||||
progress: 0,
|
||||
startedAt: undefined,
|
||||
completedAt: undefined,
|
||||
type: type,
|
||||
})}
|
||||
/>}
|
||||
>
|
||||
Add to list
|
||||
</Tooltip>}
|
||||
</>}
|
||||
|
||||
{!!listData && <IsomorphicPopover
|
||||
forceModal={forceModal}
|
||||
open={open}
|
||||
onOpenChange={o => toggle(o)}
|
||||
title={media?.title?.userPreferred ?? undefined}
|
||||
trigger={<span>
|
||||
{!hideButton && <>
|
||||
{!!listData && <IconButton
|
||||
data-anilist-media-entry-modal-edit-button
|
||||
intent="white-subtle"
|
||||
icon={<AiFillEdit />}
|
||||
rounded
|
||||
size="sm"
|
||||
loading={isPending || isDeleting}
|
||||
onClick={toggle}
|
||||
/>}
|
||||
</>}
|
||||
</span>}
|
||||
media={media}
|
||||
>
|
||||
|
||||
{(!!listData) && <Form
|
||||
data-anilist-media-entry-modal-form
|
||||
schema={mediaListDataSchema}
|
||||
onSubmit={data => {
|
||||
if (repeat !== (listData?.repeat ?? 0)) {
|
||||
// Update repeat count
|
||||
mutateRepeat({
|
||||
mediaId: media?.id || 0,
|
||||
repeat: repeat,
|
||||
})
|
||||
}
|
||||
mutate({
|
||||
mediaId: media?.id || 0,
|
||||
status: data.status || "PLANNING",
|
||||
score: data.score ? data.score * 10 : 0, // should be 0-100
|
||||
progress: data.progress || 0,
|
||||
startedAt: data.startedAt ? {
|
||||
// @ts-ignore
|
||||
day: data.startedAt.getDate(),
|
||||
month: data.startedAt.getMonth() + 1,
|
||||
year: data.startedAt.getFullYear(),
|
||||
} : undefined,
|
||||
completedAt: data.completedAt ? {
|
||||
// @ts-ignore
|
||||
day: data.completedAt.getDate(),
|
||||
month: data.completedAt.getMonth() + 1,
|
||||
year: data.completedAt.getFullYear(),
|
||||
} : undefined,
|
||||
type: type,
|
||||
})
|
||||
}}
|
||||
className={cn(
|
||||
// {
|
||||
// "mt-8": !!media?.bannerImage,
|
||||
// },
|
||||
)}
|
||||
onError={console.log}
|
||||
defaultValues={{
|
||||
status: listData?.status,
|
||||
score: listData?.score ? listData?.score / 10 : undefined, // Returned score is 0-100
|
||||
progress: listData?.progress,
|
||||
startedAt: listData?.startedAt ? (normalizeDate(listData?.startedAt)) : undefined,
|
||||
completedAt: listData?.completedAt ? (normalizeDate(listData?.completedAt)) : undefined,
|
||||
}}
|
||||
>
|
||||
<div className="flex flex-col sm:flex-row gap-4">
|
||||
<Field.Select
|
||||
label="Status"
|
||||
name="status"
|
||||
options={[
|
||||
media?.status !== "NOT_YET_RELEASED" ? {
|
||||
value: "CURRENT",
|
||||
label: type === "anime" ? "Watching" : "Reading",
|
||||
} : undefined,
|
||||
{ value: "PLANNING", label: "Planning" },
|
||||
media?.status !== "NOT_YET_RELEASED" ? {
|
||||
value: "PAUSED",
|
||||
label: "Paused",
|
||||
} : undefined,
|
||||
media?.status !== "NOT_YET_RELEASED" ? {
|
||||
value: "COMPLETED",
|
||||
label: "Completed",
|
||||
} : undefined,
|
||||
media?.status !== "NOT_YET_RELEASED" ? {
|
||||
value: "DROPPED",
|
||||
label: "Dropped",
|
||||
} : undefined,
|
||||
media?.status !== "NOT_YET_RELEASED" ? {
|
||||
value: "REPEATING",
|
||||
label: "Repeating",
|
||||
} : undefined,
|
||||
].filter(Boolean)}
|
||||
/>
|
||||
{media?.status !== "NOT_YET_RELEASED" && <>
|
||||
<Field.Number
|
||||
label="Score"
|
||||
name="score"
|
||||
min={0}
|
||||
max={10}
|
||||
formatOptions={{
|
||||
maximumFractionDigits: 1,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
}}
|
||||
rightIcon={<BiStar />}
|
||||
/>
|
||||
<Field.Number
|
||||
label="Progress"
|
||||
name="progress"
|
||||
min={0}
|
||||
max={type === "anime" ? (!!(media as AL_BaseAnime)?.nextAiringEpisode?.episode
|
||||
? (media as AL_BaseAnime)?.nextAiringEpisode?.episode! - 1
|
||||
: ((media as AL_BaseAnime)?.episodes
|
||||
? (media as AL_BaseAnime).episodes
|
||||
: undefined)) : (media as AL_BaseManga)?.chapters}
|
||||
formatOptions={{
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
}}
|
||||
rightIcon={<BiListPlus />}
|
||||
/>
|
||||
</>}
|
||||
</div>
|
||||
{media?.status !== "NOT_YET_RELEASED" && <div className="flex flex-col sm:flex-row gap-4">
|
||||
<Field.DatePicker
|
||||
label="Start date"
|
||||
name="startedAt"
|
||||
// defaultValue={(state.startedAt && state.startedAt.year) ? parseAbsoluteToLocal(new Date(state.startedAt.year,
|
||||
// (state.startedAt.month || 1)-1, state.startedAt.day || 1).toISOString()) : undefined}
|
||||
/>
|
||||
<Field.DatePicker
|
||||
label="Completion date"
|
||||
name="completedAt"
|
||||
// defaultValue={(state.completedAt && state.completedAt.year) ? parseAbsoluteToLocal(new Date(state.completedAt.year,
|
||||
// (state.completedAt.month || 1)-1, state.completedAt.day || 1).toISOString()) : undefined}
|
||||
/>
|
||||
|
||||
<NumberInput
|
||||
name="repeat"
|
||||
label={type === "anime" ? "Total rewatches" : "Total rereads"}
|
||||
min={0}
|
||||
max={1000}
|
||||
value={repeat}
|
||||
onValueChange={setRepeat}
|
||||
formatOptions={{
|
||||
maximumFractionDigits: 0,
|
||||
minimumFractionDigits: 0,
|
||||
useGrouping: false,
|
||||
}}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
<div className="flex w-full items-center justify-between mt-4">
|
||||
<div>
|
||||
<Disclosure type="multiple" defaultValue={["item-2"]}>
|
||||
<DisclosureItem value="item-1" className="flex items-center gap-1">
|
||||
<DisclosureTrigger>
|
||||
<IconButton
|
||||
intent="alert-subtle"
|
||||
icon={<BiTrash />}
|
||||
rounded
|
||||
size="md"
|
||||
/>
|
||||
</DisclosureTrigger>
|
||||
<DisclosureContent>
|
||||
<Button
|
||||
intent="alert-basic"
|
||||
rounded
|
||||
size="md"
|
||||
loading={isDeleting}
|
||||
onClick={() => deleteEntry({
|
||||
mediaId: media?.id!,
|
||||
type: type,
|
||||
})}
|
||||
>Confirm</Button>
|
||||
</DisclosureContent>
|
||||
</DisclosureItem>
|
||||
</Disclosure>
|
||||
</div>
|
||||
|
||||
<Field.Submit role="save" disableIfInvalid={true} loading={isPending} disabled={isDeleting}>
|
||||
Save
|
||||
</Field.Submit>
|
||||
</div>
|
||||
</Form>}
|
||||
|
||||
</IsomorphicPopover>}
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,319 @@
|
||||
import { AL_AnimeDetailsById_Media, AL_BaseAnime, AL_MangaDetailsById_Media, Anime_Entry, Manga_Entry, Nullish } from "@/api/generated/types"
|
||||
import { useGetAnilistAnimeDetails } from "@/api/hooks/anilist.hooks"
|
||||
import { useGetAnimeEntry } from "@/api/hooks/anime_entries.hooks"
|
||||
import { useGetMangaEntry, useGetMangaEntryDetails } from "@/api/hooks/manga.hooks"
|
||||
import { TrailerModal } from "@/app/(main)/_features/anime/_components/trailer-modal"
|
||||
import { AnimeEntryStudio } from "@/app/(main)/_features/media/_components/anime-entry-studio"
|
||||
import {
|
||||
AnimeEntryRankings,
|
||||
MediaEntryAudienceScore,
|
||||
MediaEntryGenresList,
|
||||
} from "@/app/(main)/_features/media/_components/media-entry-metadata-components"
|
||||
import { MediaPageHeaderEntryDetails } from "@/app/(main)/_features/media/_components/media-page-header-components"
|
||||
import { useHasDebridService, useHasTorrentProvider, useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { RelationsRecommendationsSection } from "@/app/(main)/entry/_components/relations-recommendations-section"
|
||||
import { TorrentSearchButton } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-button"
|
||||
import { __torrentSearch_selectedTorrentsAtom } from "@/app/(main)/entry/_containers/torrent-search/torrent-search-container"
|
||||
import {
|
||||
__torrentSearch_selectionAtom,
|
||||
__torrentSearch_selectionEpisodeAtom,
|
||||
TorrentSearchDrawer,
|
||||
} from "@/app/(main)/entry/_containers/torrent-search/torrent-search-drawer"
|
||||
import { MangaRecommendations } from "@/app/(main)/manga/_components/manga-recommendations"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Skeleton } from "@/components/ui/skeleton"
|
||||
import { getImageUrl } from "@/lib/server/assets"
|
||||
import { TORRENT_CLIENT } from "@/lib/server/settings"
|
||||
import { ThemeMediaPageBannerSize, ThemeMediaPageInfoBoxSize, useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { usePrevious } from "@uidotdev/usehooks"
|
||||
import { atom } from "jotai"
|
||||
import { ScopeProvider } from "jotai-scope"
|
||||
import { useAtom, useSetAtom } from "jotai/react"
|
||||
import Image from "next/image"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React from "react"
|
||||
import { BiX } from "react-icons/bi"
|
||||
import { GoArrowLeft } from "react-icons/go"
|
||||
import { SiAnilist } from "react-icons/si"
|
||||
|
||||
|
||||
// unused
|
||||
|
||||
type AnimePreviewModalProps = {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
const __mediaPreview_mediaIdAtom = atom<{ mediaId: number, type: "anime" | "manga" } | undefined>(undefined)
|
||||
|
||||
export function useMediaPreviewModal() {
|
||||
const setInfo = useSetAtom(__mediaPreview_mediaIdAtom)
|
||||
return {
|
||||
setPreviewModalMediaId: (mediaId: number, type: "anime" | "manga") => {
|
||||
setInfo({ mediaId, type })
|
||||
},
|
||||
}
|
||||
}
|
||||
|
||||
export function MediaPreviewModal(props: AnimePreviewModalProps) {
|
||||
|
||||
const {
|
||||
children,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const [info, setInfo] = useAtom(__mediaPreview_mediaIdAtom)
|
||||
const previousInfo = usePrevious(info)
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
React.useEffect(() => {
|
||||
setInfo(undefined)
|
||||
}, [pathname])
|
||||
|
||||
return (
|
||||
<>
|
||||
<Modal
|
||||
open={!!info}
|
||||
onOpenChange={v => setInfo(prev => v ? prev : undefined)}
|
||||
contentClass="max-w-7xl relative"
|
||||
hideCloseButton
|
||||
{...rest}
|
||||
>
|
||||
|
||||
{info && <div className="z-[12] absolute right-2 top-2 flex gap-2 items-center">
|
||||
{(!!previousInfo && previousInfo.mediaId !== info.mediaId) && <IconButton
|
||||
intent="white-subtle" size="sm" className="rounded-full" icon={<GoArrowLeft />}
|
||||
onClick={() => {
|
||||
setInfo(previousInfo)
|
||||
}}
|
||||
/>}
|
||||
<IconButton
|
||||
intent="alert" size="sm" className="rounded-full" icon={<BiX />}
|
||||
onClick={() => {
|
||||
setInfo(undefined)
|
||||
}}
|
||||
/>
|
||||
</div>}
|
||||
|
||||
{info?.type === "anime" && <Anime mediaId={info.mediaId} />}
|
||||
{info?.type === "manga" && <Manga mediaId={info.mediaId} />}
|
||||
|
||||
|
||||
</Modal>
|
||||
</>
|
||||
)
|
||||
}
|
||||
|
||||
function Anime({ mediaId }: { mediaId: number }) {
|
||||
const { data: entry, isLoading: entryLoading } = useGetAnimeEntry(mediaId)
|
||||
const { data: details, isLoading: detailsLoading } = useGetAnilistAnimeDetails(mediaId)
|
||||
|
||||
return <Content entry={entry} details={details} entryLoading={entryLoading} detailsLoading={detailsLoading} type="anime" />
|
||||
}
|
||||
|
||||
function Manga({ mediaId }: { mediaId: number }) {
|
||||
const { data: entry, isLoading: entryLoading } = useGetMangaEntry(mediaId)
|
||||
const { data: details, isLoading: detailsLoading } = useGetMangaEntryDetails(mediaId)
|
||||
|
||||
return <Content entry={entry} details={details} entryLoading={entryLoading} detailsLoading={detailsLoading} type="manga" />
|
||||
}
|
||||
|
||||
function Content({ entry, entryLoading, detailsLoading, details, type }: {
|
||||
entry: Nullish<Anime_Entry | Manga_Entry>,
|
||||
entryLoading: boolean,
|
||||
detailsLoading: boolean,
|
||||
details: Nullish<AL_AnimeDetailsById_Media | AL_MangaDetailsById_Media>
|
||||
type: "anime" | "manga"
|
||||
}) {
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const ts = useThemeSettings()
|
||||
const media = entry?.media
|
||||
const bannerImage = media?.bannerImage || media?.coverImage?.extraLarge
|
||||
|
||||
const { hasTorrentProvider } = useHasTorrentProvider()
|
||||
const { hasDebridService } = useHasDebridService()
|
||||
|
||||
return (
|
||||
<ScopeProvider atoms={[__torrentSearch_selectionAtom, __torrentSearch_selectionEpisodeAtom, __torrentSearch_selectedTorrentsAtom]}>
|
||||
<div
|
||||
className={cn(
|
||||
"absolute z-[0] opacity-30 w-full rounded-t-[--radius] overflow-hidden",
|
||||
"w-full flex-none object-cover object-center z-[3] bg-[--background] h-[12rem]",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[23rem]" : "h-[12rem] lg:h-[22rem] 2xl:h-[30rem]",
|
||||
)}
|
||||
>
|
||||
|
||||
{/*BOTTOM OVERFLOW FADE*/}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full z-[2] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute top-0 left-0 w-full h-full",
|
||||
)}
|
||||
>
|
||||
{(!!bannerImage) && <Image
|
||||
src={getImageUrl(bannerImage || "")}
|
||||
alt="banner image"
|
||||
fill
|
||||
quality={100}
|
||||
priority
|
||||
sizes="100vw"
|
||||
className={cn(
|
||||
"object-cover object-center z-[1]",
|
||||
)}
|
||||
/>}
|
||||
|
||||
{/*LEFT MASK*/}
|
||||
<div
|
||||
className={cn(
|
||||
"hidden lg:block max-w-[60rem] xl:max-w-[100rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] transition-opacity to-transparent",
|
||||
"opacity-85 duration-1000",
|
||||
// y > 300 && "opacity-70",
|
||||
)}
|
||||
/>
|
||||
<div
|
||||
className={cn(
|
||||
"hidden lg:block max-w-[60rem] xl:max-w-[80rem] w-full z-[2] h-full absolute left-0 bg-gradient-to-r from-[--background] from-25% transition-opacity to-transparent",
|
||||
"opacity-50 duration-500",
|
||||
)}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/*BOTTOM FADE*/}
|
||||
<div
|
||||
className={cn(
|
||||
"w-full z-[3] absolute bottom-0 h-[50%] bg-gradient-to-t from-[--background] via-transparent via-100% to-transparent",
|
||||
)}
|
||||
/>
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
"absolute h-full w-full block lg:hidden bg-[--background] opacity-70 z-[2]",
|
||||
)}
|
||||
/>
|
||||
|
||||
</div>
|
||||
|
||||
{entryLoading && <div className="space-y-4 relative z-[5]">
|
||||
<Skeleton
|
||||
className={cn(
|
||||
"h-[12rem]",
|
||||
ts.mediaPageBannerSize === ThemeMediaPageBannerSize.Small ? "lg:h-[23rem]" : "h-[12rem] lg:h-[22rem] 2xl:h-[30rem]",
|
||||
)}
|
||||
/>
|
||||
{/*<LoadingSpinner />*/}
|
||||
</div>}
|
||||
|
||||
{(!entryLoading && entry) && <>
|
||||
|
||||
<div className="z-[5] relative">
|
||||
<MediaPageHeaderEntryDetails
|
||||
coverImage={entry.media?.coverImage?.extraLarge || entry.media?.coverImage?.large}
|
||||
title={entry.media?.title?.userPreferred}
|
||||
color={entry.media?.coverImage?.color}
|
||||
englishTitle={entry.media?.title?.english}
|
||||
romajiTitle={entry.media?.title?.romaji}
|
||||
startDate={entry.media?.startDate}
|
||||
season={entry.media?.season}
|
||||
progressTotal={(entry.media as AL_BaseAnime)?.episodes}
|
||||
status={entry.media?.status}
|
||||
description={entry.media?.description}
|
||||
listData={entry.listData}
|
||||
media={entry.media!}
|
||||
smallerTitle
|
||||
type="anime"
|
||||
>
|
||||
<div
|
||||
className={cn(
|
||||
"flex gap-3 flex-wrap items-center relative z-[10]",
|
||||
ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid && "justify-center lg:justify-start lg:max-w-[65vw]",
|
||||
)}
|
||||
>
|
||||
<MediaEntryAudienceScore meanScore={entry?.media?.meanScore} badgeClass="bg-transparent" />
|
||||
|
||||
{(details as AL_AnimeDetailsById_Media)?.studios &&
|
||||
<AnimeEntryStudio studios={(details as AL_AnimeDetailsById_Media)?.studios} />}
|
||||
|
||||
<MediaEntryGenresList genres={details?.genres} />
|
||||
|
||||
<div
|
||||
className={cn(
|
||||
ts.mediaPageBannerInfoBoxSize === ThemeMediaPageInfoBoxSize.Fluid ? "w-full" : "contents",
|
||||
)}
|
||||
>
|
||||
<AnimeEntryRankings rankings={details?.rankings} />
|
||||
</div>
|
||||
</div>
|
||||
</MediaPageHeaderEntryDetails>
|
||||
|
||||
<div className="mt-6 flex gap-3 items-center">
|
||||
|
||||
<SeaLink href={type === "anime" ? `/entry?id=${media?.id}` : `/manga/entry?id=${media?.id}`}>
|
||||
<Button className="px-0" intent="gray-link">
|
||||
Open page
|
||||
</Button>
|
||||
</SeaLink>
|
||||
|
||||
{type === "anime" && !!(entry?.media as AL_BaseAnime)?.trailer?.id && <TrailerModal
|
||||
trailerId={(entry?.media as AL_BaseAnime)?.trailer?.id} trigger={
|
||||
<Button intent="gray-link" className="px-0">
|
||||
Trailer
|
||||
</Button>}
|
||||
/>}
|
||||
|
||||
<SeaLink href={`https://anilist.co/${type}/${entry.mediaId}`} target="_blank">
|
||||
<IconButton intent="gray-link" className="px-0" icon={<SiAnilist className="text-lg" />} />
|
||||
</SeaLink>
|
||||
|
||||
{(
|
||||
type === "anime" &&
|
||||
entry?.media?.status !== "NOT_YET_RELEASED"
|
||||
&& hasTorrentProvider
|
||||
&& (
|
||||
serverStatus?.settings?.torrent?.defaultTorrentClient !== TORRENT_CLIENT.NONE
|
||||
|| hasDebridService
|
||||
)
|
||||
) && (
|
||||
<TorrentSearchButton
|
||||
entry={entry as Anime_Entry}
|
||||
/>
|
||||
)}
|
||||
</div>
|
||||
|
||||
{detailsLoading ? <LoadingSpinner /> : <div className="space-y-6 pt-6">
|
||||
{type === "anime" && <RelationsRecommendationsSection entry={entry as Anime_Entry} details={details} />}
|
||||
{type === "manga" && <MangaRecommendations entry={entry as Manga_Entry} details={details} />}
|
||||
</div>}
|
||||
</div>
|
||||
|
||||
{/*<div className="absolute top-0 left-0 w-full h-full z-[0] bg-[--background] rounded-xl">*/}
|
||||
{/* <Image*/}
|
||||
{/* src={media?.bannerImage || ""}*/}
|
||||
{/* alt={""}*/}
|
||||
{/* fill*/}
|
||||
{/* quality={100}*/}
|
||||
{/* sizes="20rem"*/}
|
||||
{/* className="object-cover object-center transition opacity-15"*/}
|
||||
{/* />*/}
|
||||
|
||||
{/* <div*/}
|
||||
{/* className="absolute top-0 w-full h-full backdrop-blur-2xl z-[2] "*/}
|
||||
{/* ></div>*/}
|
||||
{/*</div>*/}
|
||||
|
||||
</>}
|
||||
|
||||
{(type === "anime" && !!entry) && <TorrentSearchDrawer entry={entry as Anime_Entry} />}
|
||||
</ScopeProvider>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,66 @@
|
||||
import { useLocalAddTrackedMedia, useLocalGetIsMediaTracked, useLocalRemoveTrackedMedia } from "@/api/hooks/local.hooks"
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
|
||||
import { IconButton } from "@/components/ui/button"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import React from "react"
|
||||
import { MdOutlineDownloadForOffline, MdOutlineOfflinePin } from "react-icons/md"
|
||||
|
||||
type MediaSyncTrackButtonProps = {
|
||||
mediaId: number
|
||||
type: "anime" | "manga"
|
||||
size?: "sm" | "md" | "lg"
|
||||
}
|
||||
|
||||
export function MediaSyncTrackButton(props: MediaSyncTrackButtonProps) {
|
||||
|
||||
const {
|
||||
mediaId,
|
||||
type,
|
||||
size,
|
||||
...rest
|
||||
} = props
|
||||
|
||||
const { data: isTracked, isLoading } = useLocalGetIsMediaTracked(mediaId, type)
|
||||
const { mutate: addMedia, isPending: isAdding } = useLocalAddTrackedMedia()
|
||||
const { mutate: removeMedia, isPending: isRemoving } = useLocalRemoveTrackedMedia()
|
||||
|
||||
function handleToggle() {
|
||||
if (isTracked) {
|
||||
removeMedia({ mediaId, type })
|
||||
} else {
|
||||
addMedia({
|
||||
media: [{
|
||||
mediaId: mediaId,
|
||||
type: type,
|
||||
}],
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
const confirmUntrack = useConfirmationDialog({
|
||||
title: "Remove offline data",
|
||||
description: "This action will remove the offline data for this media entry. Are you sure you want to proceed?",
|
||||
onConfirm: () => {
|
||||
handleToggle()
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
icon={isTracked ? <MdOutlineOfflinePin /> : <MdOutlineDownloadForOffline />}
|
||||
onClick={() => isTracked ? confirmUntrack.open() : handleToggle()}
|
||||
loading={isLoading || isAdding || isRemoving}
|
||||
intent={isTracked ? "primary-subtle" : "gray-subtle"}
|
||||
size={size}
|
||||
{...rest}
|
||||
/>}
|
||||
>
|
||||
{isTracked ? `Remove offline data` : `Save locally`}
|
||||
</Tooltip>
|
||||
|
||||
<ConfirmationDialog {...confirmUntrack} />
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,655 @@
|
||||
import { Nakama_NakamaStatus, Nakama_WatchPartySession, Nakama_WatchPartySessionSettings } from "@/api/generated/types"
|
||||
import {
|
||||
useNakamaCreateWatchParty,
|
||||
useNakamaJoinWatchParty,
|
||||
useNakamaLeaveWatchParty,
|
||||
useNakamaReconnectToHost,
|
||||
useNakamaRemoveStaleConnections,
|
||||
} from "@/api/hooks/nakama.hooks"
|
||||
import { useWebsocketMessageListener, useWebsocketSender } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { useNakamaOnlineStreamWatchParty } from "@/app/(main)/onlinestream/_lib/handle-onlinestream"
|
||||
import { AlphaBadge } from "@/components/shared/beta-badge"
|
||||
import { GlowingEffect } from "@/components/shared/glowing-effect"
|
||||
import { SeaLink } from "@/components/shared/sea-link"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { LoadingSpinner } from "@/components/ui/loading-spinner"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { Tooltip } from "@/components/ui/tooltip"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { atom, useAtom, useAtomValue } from "jotai"
|
||||
import React from "react"
|
||||
import { BiCog } from "react-icons/bi"
|
||||
import { FaBroadcastTower } from "react-icons/fa"
|
||||
import { HiOutlinePlay } from "react-icons/hi2"
|
||||
import { LuPopcorn } from "react-icons/lu"
|
||||
import { MdAdd, MdCleaningServices, MdOutlineConnectWithoutContact, MdPlayArrow, MdRefresh } from "react-icons/md"
|
||||
import { toast } from "sonner"
|
||||
|
||||
export const nakamaModalOpenAtom = atom(false)
|
||||
export const nakamaStatusAtom = atom<Nakama_NakamaStatus | null | undefined>(undefined)
|
||||
|
||||
export const watchPartySessionAtom = atom<Nakama_WatchPartySession | null | undefined>(undefined)
|
||||
|
||||
export function useNakamaStatus() {
|
||||
return useAtomValue(nakamaStatusAtom)
|
||||
}
|
||||
|
||||
export function useWatchPartySession() {
|
||||
return useAtomValue(watchPartySessionAtom)
|
||||
}
|
||||
|
||||
export function NakamaManager() {
|
||||
const { sendMessage } = useWebsocketSender()
|
||||
const [isModalOpen, setIsModalOpen] = useAtom(nakamaModalOpenAtom)
|
||||
const [nakamaStatus, setNakamaStatus] = useAtom(nakamaStatusAtom)
|
||||
const [watchPartySession, setWatchPartySession] = useAtom(watchPartySessionAtom)
|
||||
|
||||
// const { data: status, refetch: refetchStatus, isLoading } = useGetNakamaStatus()
|
||||
const { mutate: reconnectToHost, isPending: isReconnecting } = useNakamaReconnectToHost()
|
||||
const { mutate: removeStaleConnections, isPending: isCleaningUp } = useNakamaRemoveStaleConnections()
|
||||
const { mutate: createWatchParty, isPending: isCreatingWatchParty } = useNakamaCreateWatchParty()
|
||||
const { mutate: joinWatchParty, isPending: isJoiningWatchParty } = useNakamaJoinWatchParty()
|
||||
const { mutate: leaveWatchParty, isPending: isLeavingWatchParty } = useNakamaLeaveWatchParty()
|
||||
|
||||
// Watch party settings for creating a new session
|
||||
const [watchPartySettings, setWatchPartySettings] = React.useState<Nakama_WatchPartySessionSettings>({
|
||||
syncThreshold: 3.0,
|
||||
maxBufferWaitTime: 10,
|
||||
})
|
||||
|
||||
function refetchStatus() {
|
||||
sendMessage({
|
||||
type: WSEvents.NAKAMA_STATUS_REQUESTED,
|
||||
payload: null,
|
||||
})
|
||||
}
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_STATUS,
|
||||
onMessage: (data: Nakama_NakamaStatus | null) => {
|
||||
setNakamaStatus(data ?? null)
|
||||
},
|
||||
})
|
||||
|
||||
// NAKAMA_WATCH_PARTY_STATE tells the client to refetch the status
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_WATCH_PARTY_STATE,
|
||||
onMessage: (data: any) => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
React.useEffect(() => {
|
||||
if (nakamaStatus?.currentWatchPartySession) {
|
||||
setWatchPartySession(nakamaStatus.currentWatchPartySession)
|
||||
} else {
|
||||
setWatchPartySession(null)
|
||||
}
|
||||
}, [nakamaStatus])
|
||||
|
||||
React.useEffect(() => {
|
||||
refetchStatus()
|
||||
}, [])
|
||||
|
||||
React.useEffect(() => {
|
||||
if (isModalOpen) {
|
||||
refetchStatus()
|
||||
}
|
||||
}, [isModalOpen])
|
||||
|
||||
const handleReconnect = React.useCallback(() => {
|
||||
reconnectToHost({}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Reconnection initiated")
|
||||
refetchStatus()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to reconnect: ${error.message}`)
|
||||
},
|
||||
})
|
||||
}, [reconnectToHost, refetchStatus])
|
||||
|
||||
const handleCleanupStaleConnections = React.useCallback(() => {
|
||||
removeStaleConnections({}, {
|
||||
onSuccess: () => {
|
||||
toast.success("Stale connections cleaned up")
|
||||
refetchStatus()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to cleanup: ${error.message}`)
|
||||
},
|
||||
})
|
||||
}, [removeStaleConnections, refetchStatus])
|
||||
|
||||
const handleCreateWatchParty = React.useCallback(() => {
|
||||
createWatchParty({ settings: watchPartySettings }, {
|
||||
onSuccess: () => {
|
||||
toast.success("Watch party created")
|
||||
refetchStatus()
|
||||
},
|
||||
onError: (error) => {
|
||||
toast.error(`Failed to create watch party: ${error.message}`)
|
||||
},
|
||||
})
|
||||
}, [createWatchParty, watchPartySettings, refetchStatus])
|
||||
|
||||
const handleJoinWatchParty = React.useCallback(() => {
|
||||
joinWatchParty(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.info("Joining watch party")
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
}, [joinWatchParty, refetchStatus])
|
||||
|
||||
const handleLeaveWatchParty = React.useCallback(() => {
|
||||
leaveWatchParty(undefined, {
|
||||
onSuccess: () => {
|
||||
toast.info("Leaving watch party")
|
||||
setWatchPartySession(null)
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
}, [leaveWatchParty, setWatchPartySession, refetchStatus])
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_HOST_STARTED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_HOST_STOPPED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_PEER_CONNECTED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_PEER_DISCONNECTED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_HOST_CONNECTED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_HOST_DISCONNECTED,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_ERROR,
|
||||
onMessage: () => {
|
||||
refetchStatus()
|
||||
},
|
||||
})
|
||||
|
||||
/////// Online stream
|
||||
|
||||
const { startOnlineStream } = useNakamaOnlineStreamWatchParty()
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NAKAMA_ONLINE_STREAM_EVENT,
|
||||
onMessage: (_data: { type: string, payload: { type: string, payload: any } }) => {
|
||||
console.log(_data)
|
||||
switch (_data.type) {
|
||||
case "online-stream-playback-status":
|
||||
const data = _data.payload
|
||||
switch (data.type) {
|
||||
case "start":
|
||||
startOnlineStream(data.payload)
|
||||
break
|
||||
}
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
return <>
|
||||
<Modal
|
||||
open={isModalOpen}
|
||||
onOpenChange={setIsModalOpen}
|
||||
title={<div className="flex items-center gap-2 w-full justify-center">
|
||||
<MdOutlineConnectWithoutContact className="size-8" />
|
||||
Nakama
|
||||
<AlphaBadge className="border-transparent" />
|
||||
</div>}
|
||||
contentClass="max-w-3xl bg-gray-950 bg-opacity-60 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none sm:rounded-3xl"
|
||||
overlayClass="bg-gray-950/70 backdrop-blur-sm"
|
||||
// allowOutsideInteraction
|
||||
>
|
||||
|
||||
<GlowingEffect
|
||||
variant="classic"
|
||||
spread={40}
|
||||
glow={true}
|
||||
disabled={false}
|
||||
proximity={64}
|
||||
inactiveZone={0.01}
|
||||
className="opacity-50"
|
||||
/>
|
||||
|
||||
<div className="absolute top-4 right-14">
|
||||
<SeaLink href="/settings?tab=nakama" onClick={() => setIsModalOpen(false)}>
|
||||
<IconButton intent="gray-basic" size="sm" icon={<BiCog />} />
|
||||
</SeaLink>
|
||||
</div>
|
||||
|
||||
{nakamaStatus === undefined && <LoadingSpinner />}
|
||||
|
||||
{!nakamaStatus?.isHost && (
|
||||
<div className="flex items-center justify-between">
|
||||
<div></div>
|
||||
<Button
|
||||
onClick={handleReconnect}
|
||||
disabled={isReconnecting}
|
||||
size="sm"
|
||||
intent="gray-basic"
|
||||
leftIcon={<MdRefresh />}
|
||||
>
|
||||
{isReconnecting ? "Reconnecting..." : "Reconnect"}
|
||||
</Button>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{nakamaStatus !== undefined && (nakamaStatus?.isHost || nakamaStatus?.isConnectedToHost) && (
|
||||
<>
|
||||
|
||||
{nakamaStatus?.isHost && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<Badge intent="success-solid" className="px-0 text-indigo-300 bg-transparent">Currently hosting</Badge>
|
||||
<Button
|
||||
onClick={handleCleanupStaleConnections}
|
||||
disabled={isCleaningUp}
|
||||
size="sm"
|
||||
intent="gray-basic"
|
||||
leftIcon={<MdCleaningServices />}
|
||||
>
|
||||
{isCleaningUp ? "Cleaning up..." : "Remove stale connections"}
|
||||
</Button>
|
||||
</div>
|
||||
<h4>Connected peers ({nakamaStatus?.connectedPeers?.length ?? 0})</h4>
|
||||
<div className="p-4 border rounded-lg bg-gray-950">
|
||||
{!nakamaStatus?.connectedPeers?.length &&
|
||||
<p className="text-center text-sm text-[--muted]">No connected peers</p>}
|
||||
{nakamaStatus?.connectedPeers?.map((peer, index) => (
|
||||
<div key={index} className="flex items-center justify-between py-1">
|
||||
<span className="font-medium">{peer}</span>
|
||||
</div>
|
||||
))}
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{nakamaStatus?.isConnectedToHost && (
|
||||
<>
|
||||
|
||||
<h4>Host connection</h4>
|
||||
<div className="p-4 border rounded-lg bg-gray-950">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Host</span>
|
||||
<span className="font-medium text-sm tracking-wide">
|
||||
{nakamaStatus?.hostConnectionStatus?.username || "Unknown"}
|
||||
</span>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
|
||||
{/* Watch Party Content */}
|
||||
{(() => {
|
||||
const isHost = nakamaStatus?.isHost || false
|
||||
const isConnectedToHost = nakamaStatus?.isConnectedToHost || false
|
||||
const currentPeerID = nakamaStatus?.hostConnectionStatus?.peerId
|
||||
|
||||
// Check if user is in the participant list by comparing peer ID
|
||||
const isUserInSession = watchPartySession && (
|
||||
isHost ||
|
||||
(currentPeerID && watchPartySession.participants && currentPeerID in watchPartySession.participants)
|
||||
)
|
||||
|
||||
// Show session view if there's a session AND user is in it
|
||||
if (watchPartySession && isUserInSession) {
|
||||
return (
|
||||
<WatchPartySessionView
|
||||
session={watchPartySession}
|
||||
isHost={isHost}
|
||||
onLeave={handleLeaveWatchParty}
|
||||
isLeaving={isLeavingWatchParty}
|
||||
/>
|
||||
)
|
||||
}
|
||||
|
||||
// Otherwise show creation/join options
|
||||
return (
|
||||
<WatchPartyCreation
|
||||
isHost={isHost}
|
||||
isConnectedToHost={isConnectedToHost}
|
||||
hasActiveSession={!!watchPartySession}
|
||||
settings={watchPartySettings}
|
||||
onSettingsChange={setWatchPartySettings}
|
||||
onCreateWatchParty={handleCreateWatchParty}
|
||||
onJoinWatchParty={handleJoinWatchParty}
|
||||
isCreating={isCreatingWatchParty}
|
||||
isJoining={isJoiningWatchParty}
|
||||
/>
|
||||
)
|
||||
})()}
|
||||
</>
|
||||
)}
|
||||
|
||||
{!nakamaStatus?.isHost && !nakamaStatus?.isConnectedToHost && nakamaStatus !== undefined && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[--muted]">Nakama is not active</p>
|
||||
<p className="text-sm text-[--muted] mt-2">
|
||||
Configure Nakama in settings to connect to a host or start hosting
|
||||
</p>
|
||||
</div>
|
||||
)}
|
||||
</Modal>
|
||||
</>
|
||||
}
|
||||
|
||||
interface WatchPartyCreationProps {
|
||||
isHost: boolean
|
||||
isConnectedToHost: boolean
|
||||
hasActiveSession: boolean
|
||||
settings: Nakama_WatchPartySessionSettings
|
||||
onSettingsChange: (settings: Nakama_WatchPartySessionSettings) => void
|
||||
onCreateWatchParty: () => void
|
||||
onJoinWatchParty: () => void
|
||||
isCreating: boolean
|
||||
isJoining: boolean
|
||||
}
|
||||
|
||||
function WatchPartyCreation({
|
||||
isHost,
|
||||
isConnectedToHost,
|
||||
hasActiveSession,
|
||||
settings,
|
||||
onSettingsChange,
|
||||
onCreateWatchParty,
|
||||
onJoinWatchParty,
|
||||
isCreating,
|
||||
isJoining,
|
||||
}: WatchPartyCreationProps) {
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<h4 className="flex items-center gap-2"><LuPopcorn className="size-6" /> Watch Party</h4>
|
||||
{isHost && (
|
||||
<div className="p-4 border rounded-lg bg-gray-950">
|
||||
<div className="space-y-4">
|
||||
{/* <div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<label className="text-sm font-medium">Allow participant control</label>
|
||||
<Switch
|
||||
value={settings.allowParticipantControl}
|
||||
onValueChange={(checked: boolean) =>
|
||||
onSettingsChange({ ...settings, allowParticipantControl: checked })
|
||||
}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Sync threshold (seconds)</label>
|
||||
<NumberInput
|
||||
value={settings.syncThreshold}
|
||||
onValueChange={(value) =>
|
||||
onSettingsChange({ ...settings, syncThreshold: value || 3.0 })
|
||||
}
|
||||
min={1}
|
||||
max={10}
|
||||
step={0.5}
|
||||
/>
|
||||
<p className="text-xs text-[--muted]">How far out of sync before forcing synchronization</p>
|
||||
</div>
|
||||
|
||||
<div className="space-y-2">
|
||||
<label className="text-sm font-medium">Max buffer wait time (seconds)</label>
|
||||
<NumberInput
|
||||
value={settings.maxBufferWaitTime}
|
||||
onValueChange={(value) =>
|
||||
onSettingsChange({ ...settings, maxBufferWaitTime: value || 10 })
|
||||
}
|
||||
min={5}
|
||||
max={60}
|
||||
/>
|
||||
<p className="text-xs text-[--muted]">Maximum time to wait for peers to buffer</p>
|
||||
</div>
|
||||
</div> */}
|
||||
|
||||
<Button
|
||||
onClick={onCreateWatchParty}
|
||||
disabled={isCreating}
|
||||
className="w-full"
|
||||
intent="primary"
|
||||
leftIcon={<MdAdd />}
|
||||
>
|
||||
{isCreating ? "Creating..." : "Create Watch Party"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{isConnectedToHost && !isHost && hasActiveSession && (
|
||||
<div className="p-4 border rounded-lg bg-gray-950">
|
||||
<div className="space-y-4">
|
||||
<p className="text-sm text-[--muted]">
|
||||
There's an active watch party! Join to watch content together in sync.
|
||||
</p>
|
||||
<Button
|
||||
onClick={onJoinWatchParty}
|
||||
disabled={isJoining}
|
||||
className="w-full"
|
||||
intent="primary"
|
||||
leftIcon={<MdPlayArrow />}
|
||||
>
|
||||
{isJoining ? "Joining..." : "Join Watch Party"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isHost && !isConnectedToHost && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[--muted]">Connect to a host to join a watch party</p>
|
||||
</div>
|
||||
)}
|
||||
|
||||
{!isHost && isConnectedToHost && !hasActiveSession && (
|
||||
<div className="text-center py-8">
|
||||
<p className="text-[--muted]">No active watch party</p>
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
|
||||
interface WatchPartySessionViewProps {
|
||||
session: Nakama_WatchPartySession
|
||||
isHost: boolean
|
||||
onLeave: () => void
|
||||
isLeaving: boolean
|
||||
}
|
||||
|
||||
function WatchPartySessionView({ session, isHost, onLeave, isLeaving }: WatchPartySessionViewProps) {
|
||||
const { sendMessage } = useWebsocketSender()
|
||||
const nakamaStatus = useNakamaStatus()
|
||||
const participants = Object.values(session.participants || {})
|
||||
const participantCount = participants.length
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const [enablingRelayMode, setEnablingRelayMode] = React.useState(false)
|
||||
|
||||
// Identify current user - either "host" if hosting, or the peer ID if connected as peer
|
||||
const currentUserId = isHost ? "host" : nakamaStatus?.hostConnectionStatus?.peerId
|
||||
|
||||
function handleEnableRelayMode(peerId: string) {
|
||||
sendMessage({
|
||||
type: WSEvents.NAKAMA_WATCH_PARTY_ENABLE_RELAY_MODE,
|
||||
payload: { peerId },
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<div className="space-y-4">
|
||||
<div className="flex items-center justify-between">
|
||||
<h4 className="flex items-center gap-2"><LuPopcorn className="size-6" /> Watch Party</h4>
|
||||
<div className="flex items-center gap-2">
|
||||
{/*Enable relay mode*/}
|
||||
{isHost && !session.isRelayMode && (
|
||||
<Tooltip
|
||||
trigger={<IconButton
|
||||
size="sm"
|
||||
intent={!enablingRelayMode ? "primary-subtle" : "primary"}
|
||||
icon={<FaBroadcastTower />}
|
||||
onClick={() => setEnablingRelayMode(p => !p)}
|
||||
className={cn(enablingRelayMode && "animate-pulse")}
|
||||
/>}
|
||||
>
|
||||
Enable relay mode
|
||||
</Tooltip>
|
||||
)}
|
||||
<Button
|
||||
onClick={onLeave}
|
||||
disabled={isLeaving}
|
||||
size="sm"
|
||||
intent="alert-basic"
|
||||
// leftIcon={isHost ? <MdStop /> : <MdExitToApp />}
|
||||
>
|
||||
{isLeaving ? "Leaving..." : isHost ? "Stop" : "Leave"}
|
||||
</Button>
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <SettingsCard title="Session Details">
|
||||
<div className="space-y-3">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Session ID:</span>
|
||||
<span className="font-mono text-xs">{session.id}</span>
|
||||
</div>
|
||||
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Created:</span>
|
||||
<span className="text-sm">{session.createdAt ? new Date(session.createdAt).toLocaleString() : "Unknown"}</span>
|
||||
</div>
|
||||
|
||||
{session.currentMediaInfo && (
|
||||
<>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Current Media:</span>
|
||||
<span className="text-sm">Episode {session.currentMediaInfo.episodeNumber}</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Stream Type:</span>
|
||||
<Badge className="">
|
||||
{session.currentMediaInfo.streamType}
|
||||
</Badge>
|
||||
</div>
|
||||
</>
|
||||
)}
|
||||
</div>
|
||||
</SettingsCard> */}
|
||||
|
||||
<h5>Participants ({participantCount})</h5>
|
||||
<div className="p-4 border rounded-lg bg-gray-950">
|
||||
<div className="space-y-0">
|
||||
{participants.map((participant) => {
|
||||
const isCurrentUser = participant.id === currentUserId
|
||||
return (
|
||||
<div key={participant.id} className="flex items-center justify-between py-1">
|
||||
<div className="flex items-center gap-2">
|
||||
<span className="font-medium text-sm tracking-wide">
|
||||
{participant.username}
|
||||
{isCurrentUser && <span className="text-[--muted] font-normal"> (me)</span>}
|
||||
</span>
|
||||
{session.isRelayMode && participant.isHost && (
|
||||
<Badge intent="unstyled" className="text-xs" leftIcon={<FaBroadcastTower />}>Relay</Badge>
|
||||
)}
|
||||
{participant.isHost && (
|
||||
<Badge className="text-xs">Host</Badge>
|
||||
)}
|
||||
{participant.isRelayOrigin && (
|
||||
<Badge intent="warning" className="text-xs">Origin</Badge>
|
||||
)}
|
||||
{enablingRelayMode && !participant.isHost && !participant.isRelayOrigin && !session.isRelayMode && (
|
||||
<Button
|
||||
size="sm" intent="white" leftIcon={<HiOutlinePlay />}
|
||||
onClick={() => handleEnableRelayMode(participant.id)}
|
||||
>Promote to origin</Button>
|
||||
)}
|
||||
</div>
|
||||
<div className="flex items-center gap-2 text-xs text-[--muted]">
|
||||
{!participant.isHost && participant.bufferHealth !== undefined && (
|
||||
<Tooltip
|
||||
trigger={<div className="flex items-center gap-1">
|
||||
<span className="text-xs">Buffer</span>
|
||||
<div className="w-8 h-1 bg-gray-300 rounded-full overflow-hidden">
|
||||
<div
|
||||
className="h-full bg-green-500 transition-all duration-300"
|
||||
style={{ width: `${Math.max(0, Math.min(100, participant.bufferHealth * 100))}%` }}
|
||||
/>
|
||||
</div>
|
||||
<span className="text-xs">{Math.round(participant.bufferHealth * 100)}%</span>
|
||||
</div>}
|
||||
>
|
||||
Synchronization buffer health
|
||||
</Tooltip>
|
||||
)}
|
||||
{participant.latency > 0 && (
|
||||
<span>{participant.latency}ms</span>
|
||||
)}
|
||||
{participant.isBuffering ? (
|
||||
<Badge intent="alert-solid" className="text-xs">
|
||||
Buffering
|
||||
</Badge>
|
||||
) : null}
|
||||
</div>
|
||||
</div>
|
||||
)
|
||||
})}
|
||||
</div>
|
||||
</div>
|
||||
|
||||
{/* <SettingsCard title="Settings">
|
||||
<div className="space-y-2">
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Participant Control:</span>
|
||||
<span className="text-sm">
|
||||
{session.settings?.allowParticipantControl ? "Enabled" : "Disabled"}
|
||||
</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Sync Threshold:</span>
|
||||
<span className="text-sm">{session.settings?.syncThreshold}s</span>
|
||||
</div>
|
||||
<div className="flex items-center justify-between">
|
||||
<span className="text-sm text-[--muted]">Max Buffer Wait:</span>
|
||||
<span className="text-sm">{session.settings?.maxBufferWaitTime}s</span>
|
||||
</div>
|
||||
</div>
|
||||
</SettingsCard> */}
|
||||
</div>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,18 @@
|
||||
import { NativePlayer_PlaybackInfo } from "@/api/generated/types"
|
||||
import { atomWithImmer } from "jotai-immer"
|
||||
|
||||
export type NativePlayerState = {
|
||||
active: boolean
|
||||
playbackInfo: NativePlayer_PlaybackInfo | null
|
||||
playbackError: string | null
|
||||
loadingState: string | null
|
||||
}
|
||||
|
||||
export const nativePlayer_initialState: NativePlayerState = {
|
||||
active: false,
|
||||
playbackInfo: null,
|
||||
playbackError: null,
|
||||
loadingState: null,
|
||||
}
|
||||
|
||||
export const nativePlayer_stateAtom = atomWithImmer<NativePlayerState>(nativePlayer_initialState)
|
||||
@@ -0,0 +1,407 @@
|
||||
import { API_ENDPOINTS } from "@/api/generated/endpoints"
|
||||
import { MKVParser_SubtitleEvent, MKVParser_TrackInfo, NativePlayer_PlaybackInfo, NativePlayer_ServerEvent } from "@/api/generated/types"
|
||||
import { useUpdateAnimeEntryProgress } from "@/api/hooks/anime_entries.hooks"
|
||||
import { useHandleCurrentMediaContinuity } from "@/api/hooks/continuity.hooks"
|
||||
import { __seaMediaPlayer_autoNextAtom } from "@/app/(main)/_features/sea-media-player/sea-media-player.atoms"
|
||||
import { vc_dispatchAction, vc_miniPlayer, vc_subtitleManager, vc_videoElement, VideoCore } from "@/app/(main)/_features/video-core/video-core"
|
||||
import { clientIdAtom } from "@/app/websocket-provider"
|
||||
import { logger } from "@/lib/helpers/debug"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { useQueryClient } from "@tanstack/react-query"
|
||||
import { useAtom, useAtomValue } from "jotai"
|
||||
import { useSetAtom } from "jotai/react"
|
||||
import React from "react"
|
||||
import { toast } from "sonner"
|
||||
import { useWebsocketMessageListener, useWebsocketSender } from "../../_hooks/handle-websockets"
|
||||
import { useServerStatus } from "../../_hooks/use-server-status"
|
||||
import { useSkipData } from "../sea-media-player/aniskip"
|
||||
import { nativePlayer_stateAtom } from "./native-player.atoms"
|
||||
|
||||
const enum VideoPlayerEvents {
|
||||
LOADED_METADATA = "loaded-metadata",
|
||||
VIDEO_SEEKED = "video-seeked",
|
||||
SUBTITLE_FILE_UPLOADED = "subtitle-file-uploaded",
|
||||
VIDEO_PAUSED = "video-paused",
|
||||
VIDEO_RESUMED = "video-resumed",
|
||||
VIDEO_ENDED = "video-ended",
|
||||
VIDEO_ERROR = "video-error",
|
||||
VIDEO_CAN_PLAY = "video-can-play",
|
||||
VIDEO_STARTED = "video-started",
|
||||
VIDEO_COMPLETED = "video-completed",
|
||||
VIDEO_TERMINATED = "video-terminated",
|
||||
VIDEO_TIME_UPDATE = "video-time-update",
|
||||
}
|
||||
|
||||
const log = logger("NATIVE PLAYER")
|
||||
|
||||
export function NativePlayer() {
|
||||
const serverStatus = useServerStatus()
|
||||
const clientId = useAtomValue(clientIdAtom)
|
||||
const { sendMessage } = useWebsocketSender()
|
||||
|
||||
const autoPlayNext = useAtomValue(__seaMediaPlayer_autoNextAtom)
|
||||
const videoElement = useAtomValue(vc_videoElement)
|
||||
const [state, setState] = useAtom(nativePlayer_stateAtom)
|
||||
const [miniPlayer, setMiniPlayer] = useAtom(vc_miniPlayer)
|
||||
const subtitleManager = useAtomValue(vc_subtitleManager)
|
||||
const dispatchEvent = useSetAtom(vc_dispatchAction)
|
||||
|
||||
// Continuity
|
||||
const { watchHistory, waitForWatchHistory, getEpisodeContinuitySeekTo } = useHandleCurrentMediaContinuity(state?.playbackInfo?.media?.id)
|
||||
|
||||
// AniSkip
|
||||
const { data: aniSkipData } = useSkipData(state?.playbackInfo?.media?.idMal, state?.playbackInfo?.episode?.progressNumber ?? -1)
|
||||
|
||||
//
|
||||
// Start
|
||||
//
|
||||
|
||||
const qc = useQueryClient()
|
||||
|
||||
React.useEffect(() => {
|
||||
qc.invalidateQueries({ queryKey: [API_ENDPOINTS.CONTINUITY.GetContinuityWatchHistoryItem.key] })
|
||||
}, [state])
|
||||
|
||||
|
||||
// Update progress
|
||||
const { mutate: updateProgress, isPending: isUpdatingProgress, isSuccess: isProgressUpdateSuccess } = useUpdateAnimeEntryProgress(
|
||||
state.playbackInfo?.media?.id,
|
||||
state.playbackInfo?.episode?.progressNumber ?? 0,
|
||||
)
|
||||
|
||||
const handleTimeInterval = () => {
|
||||
if (videoElement) {
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_TIME_UPDATE,
|
||||
payload: {
|
||||
currentTime: videoElement.currentTime,
|
||||
duration: videoElement.duration,
|
||||
paused: videoElement.paused,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
}
|
||||
|
||||
// Time update interval
|
||||
React.useEffect(() => {
|
||||
const interval = setInterval(handleTimeInterval, 2000)
|
||||
return () => clearInterval(interval)
|
||||
}, [videoElement])
|
||||
|
||||
//
|
||||
// Event Handlers
|
||||
//
|
||||
|
||||
const handleCompleted = () => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_COMPLETED,
|
||||
payload: {
|
||||
currentTime: v.currentTime,
|
||||
duration: v.duration,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleTimeUpdate = () => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
}
|
||||
|
||||
const handleEnded = () => {
|
||||
log.info("Ended")
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_ENDED,
|
||||
payload: {
|
||||
autoNext: autoPlayNext,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
}
|
||||
|
||||
const handleError = (value: string) => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
const error = value || v.error
|
||||
let errorMessage = value || "Unknown error"
|
||||
let detailedInfo = ""
|
||||
|
||||
if (error instanceof MediaError) {
|
||||
switch (error.code) {
|
||||
case MediaError.MEDIA_ERR_ABORTED:
|
||||
errorMessage = "Media playback aborted"
|
||||
break
|
||||
case MediaError.MEDIA_ERR_NETWORK:
|
||||
errorMessage = "Network error occurred"
|
||||
break
|
||||
case MediaError.MEDIA_ERR_DECODE:
|
||||
errorMessage = "Media decode error - codec not supported or corrupted file"
|
||||
detailedInfo = "This is likely a codec compatibility issue."
|
||||
break
|
||||
case MediaError.MEDIA_ERR_SRC_NOT_SUPPORTED:
|
||||
errorMessage = "Media format not supported"
|
||||
detailedInfo = "The video codec/container format is not supported."
|
||||
break
|
||||
default:
|
||||
errorMessage = error.message || "Unknown media error"
|
||||
}
|
||||
log.error("Media error", {
|
||||
code: error?.code,
|
||||
message: error?.message,
|
||||
src: v.src,
|
||||
networkState: v.networkState,
|
||||
readyState: v.readyState,
|
||||
})
|
||||
}
|
||||
|
||||
|
||||
const fullErrorMessage = detailedInfo ? `${errorMessage}\n\n${detailedInfo}` : errorMessage
|
||||
|
||||
log.error("Media error", fullErrorMessage)
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_ERROR,
|
||||
payload: { error: fullErrorMessage },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handleSeeked = (currentTime: number) => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
log.info("Video seeked to", currentTime)
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_SEEKED,
|
||||
payload: { currentTime: currentTime, duration: v.duration },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
/**
|
||||
* Metadata is loaded
|
||||
* - Handle captions
|
||||
* - Initialize the subtitle manager if the stream is MKV
|
||||
* - Initialize the audio manager if the stream is MKV
|
||||
* - Initialize the thumbnailer if the stream is local file
|
||||
*/
|
||||
const handleLoadedMetadata = () => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.LOADED_METADATA,
|
||||
payload: {
|
||||
currentTime: v.currentTime,
|
||||
duration: v.duration,
|
||||
},
|
||||
},
|
||||
})
|
||||
|
||||
if (state.playbackInfo?.episode?.progressNumber && watchHistory?.found && watchHistory.item?.episodeNumber === state.playbackInfo?.episode?.progressNumber) {
|
||||
const lastWatchedTime = getEpisodeContinuitySeekTo(state.playbackInfo?.episode?.progressNumber,
|
||||
videoElement?.currentTime,
|
||||
videoElement?.duration)
|
||||
logger("MEDIA PLAYER").info("Watch continuity: Seeking to last watched time", { lastWatchedTime })
|
||||
if (lastWatchedTime > 0) {
|
||||
logger("MEDIA PLAYER").info("Watch continuity: Seeking to", lastWatchedTime)
|
||||
dispatchEvent({ type: "restoreProgress", payload: { time: lastWatchedTime } })
|
||||
// const isPaused = videoElement?.paused
|
||||
// videoElement?.play?.()
|
||||
// setTimeout(() => {
|
||||
//
|
||||
// if (isPaused) {
|
||||
// setTimeout(() => {
|
||||
// videoElement?.pause?.()
|
||||
// }, 200)
|
||||
// }
|
||||
// }, 1000)
|
||||
}
|
||||
}
|
||||
}
|
||||
|
||||
const handlePause = () => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_PAUSED,
|
||||
payload: {
|
||||
currentTime: v.currentTime,
|
||||
duration: v.duration,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
const handlePlay = () => {
|
||||
const v = videoElement
|
||||
if (!v) return
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_RESUMED,
|
||||
payload: {
|
||||
currentTime: v.currentTime,
|
||||
duration: v.duration,
|
||||
},
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
function handleFileUploaded(data: { name: string, content: string }) {
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.SUBTITLE_FILE_UPLOADED,
|
||||
payload: { filename: data.name, content: data.content },
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
//
|
||||
// Server events
|
||||
//
|
||||
|
||||
useWebsocketMessageListener({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
onMessage: ({ type, payload }: { type: NativePlayer_ServerEvent, payload: unknown }) => {
|
||||
switch (type) {
|
||||
// 1. Open and await
|
||||
// The server is loading the stream
|
||||
case "open-and-await":
|
||||
log.info("Open and await event received", { payload })
|
||||
setState(draft => {
|
||||
draft.active = true
|
||||
draft.loadingState = payload as string
|
||||
draft.playbackInfo = null
|
||||
draft.playbackError = null
|
||||
return
|
||||
})
|
||||
setMiniPlayer(false)
|
||||
|
||||
break
|
||||
// 2. Watch
|
||||
// We received the playback info
|
||||
case "watch":
|
||||
log.info("Watch event received", { payload })
|
||||
setState(draft => {
|
||||
draft.playbackInfo = payload as NativePlayer_PlaybackInfo
|
||||
draft.loadingState = null
|
||||
draft.playbackError = null
|
||||
return
|
||||
})
|
||||
setMiniPlayer(false)
|
||||
break
|
||||
// 3. Subtitle event (MKV)
|
||||
// We receive the subtitle events after the server received the loaded-metadata event
|
||||
case "subtitle-event":
|
||||
subtitleManager?.onSubtitleEvent(payload as MKVParser_SubtitleEvent)
|
||||
break
|
||||
case "add-subtitle-track":
|
||||
subtitleManager?.onTrackAdded(payload as MKVParser_TrackInfo)
|
||||
break
|
||||
case "terminate":
|
||||
log.info("Terminate event received")
|
||||
handleTerminateStream()
|
||||
break
|
||||
case "error":
|
||||
log.error("Error event received", payload)
|
||||
toast.error("An error occurred while playing the stream. " + ((payload as { error: string }).error))
|
||||
setState(draft => {
|
||||
draft.playbackError = (payload as { error: string }).error
|
||||
return
|
||||
})
|
||||
break
|
||||
}
|
||||
},
|
||||
})
|
||||
|
||||
//
|
||||
// Handlers
|
||||
//
|
||||
|
||||
function handleTerminateStream() {
|
||||
// Clean up player first
|
||||
if (videoElement) {
|
||||
log.info("Cleaning up media")
|
||||
videoElement.pause()
|
||||
}
|
||||
|
||||
setMiniPlayer(true)
|
||||
setState(draft => {
|
||||
draft.playbackInfo = null
|
||||
draft.playbackError = null
|
||||
draft.loadingState = "Ending stream..."
|
||||
return
|
||||
})
|
||||
|
||||
setTimeout(() => {
|
||||
setState(draft => {
|
||||
draft.active = false
|
||||
return
|
||||
})
|
||||
}, 700)
|
||||
|
||||
sendMessage({
|
||||
type: WSEvents.NATIVE_PLAYER,
|
||||
payload: {
|
||||
clientId: clientId,
|
||||
type: VideoPlayerEvents.VIDEO_TERMINATED,
|
||||
},
|
||||
})
|
||||
}
|
||||
|
||||
return (
|
||||
<>
|
||||
|
||||
<VideoCore
|
||||
state={state}
|
||||
aniSkipData={aniSkipData}
|
||||
onTerminateStream={handleTerminateStream}
|
||||
onLoadedMetadata={handleLoadedMetadata}
|
||||
onTimeUpdate={handleTimeUpdate}
|
||||
onEnded={handleEnded}
|
||||
onSeeked={handleSeeked}
|
||||
onCompleted={handleCompleted}
|
||||
onError={handleError}
|
||||
onPlay={handlePlay}
|
||||
onPause={handlePause}
|
||||
onFileUploaded={handleFileUploaded}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
}
|
||||
@@ -0,0 +1,491 @@
|
||||
"use client"
|
||||
import { useLogout } from "@/api/hooks/auth.hooks"
|
||||
import { useGetExtensionUpdateData as useGetExtensionUpdateData } from "@/api/hooks/extensions.hooks"
|
||||
import { isLoginModalOpenAtom } from "@/app/(main)/_atoms/server-status.atoms"
|
||||
import { useSyncIsActive } from "@/app/(main)/_atoms/sync.atoms"
|
||||
import { ElectronUpdateModal } from "@/app/(main)/_electron/electron-update-modal"
|
||||
import { __globalSearch_isOpenAtom } from "@/app/(main)/_features/global-search/global-search"
|
||||
import { SidebarNavbar } from "@/app/(main)/_features/layout/top-navbar"
|
||||
import { useOpenSeaCommand } from "@/app/(main)/_features/sea-command/sea-command"
|
||||
import { UpdateModal } from "@/app/(main)/_features/update/update-modal"
|
||||
import { useAutoDownloaderQueueCount } from "@/app/(main)/_hooks/autodownloader-queue-count"
|
||||
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
|
||||
import { useMissingEpisodeCount } from "@/app/(main)/_hooks/missing-episodes-loader"
|
||||
import { useCurrentUser, useServerStatus, useSetServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { TauriUpdateModal } from "@/app/(main)/_tauri/tauri-update-modal"
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
|
||||
import { AppSidebar, useAppSidebarContext } from "@/components/ui/app-layout"
|
||||
import { Avatar } from "@/components/ui/avatar"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { Button, IconButton } from "@/components/ui/button"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
|
||||
import { defineSchema, Field, Form } from "@/components/ui/form"
|
||||
import { HoverCard } from "@/components/ui/hover-card"
|
||||
import { Modal } from "@/components/ui/modal"
|
||||
import { VerticalMenu, VerticalMenuItem } from "@/components/ui/vertical-menu"
|
||||
import { openTab } from "@/lib/helpers/browser"
|
||||
import { ANILIST_OAUTH_URL, ANILIST_PIN_URL } from "@/lib/server/config"
|
||||
import { TORRENT_CLIENT, TORRENT_PROVIDER } from "@/lib/server/settings"
|
||||
import { WSEvents } from "@/lib/server/ws-events"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { __isDesktop__, __isElectronDesktop__, __isTauriDesktop__ } from "@/types/constants"
|
||||
import { useAtom, useSetAtom } from "jotai"
|
||||
import Link from "next/link"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { BiCalendarAlt, BiChevronRight, BiDownload, BiExtension, BiLogIn, BiLogOut, BiNews } from "react-icons/bi"
|
||||
import { FaBookReader } from "react-icons/fa"
|
||||
import { FiLogIn, FiSearch, FiSettings } from "react-icons/fi"
|
||||
import { GrTest } from "react-icons/gr"
|
||||
import { HiOutlineServerStack } from "react-icons/hi2"
|
||||
import { IoCloudOfflineOutline, IoLibrary } from "react-icons/io5"
|
||||
import { MdOutlineConnectWithoutContact } from "react-icons/md"
|
||||
import { PiArrowCircleLeftDuotone, PiArrowCircleRightDuotone, PiClockCounterClockwiseFill, PiListChecksFill } from "react-icons/pi"
|
||||
import { SiAnilist } from "react-icons/si"
|
||||
import { TbWorldDownload } from "react-icons/tb"
|
||||
import { nakamaModalOpenAtom, useNakamaStatus } from "../nakama/nakama-manager"
|
||||
import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray"
|
||||
|
||||
/**
|
||||
* @description
|
||||
* - Displays navigation items
|
||||
* - Button to logout
|
||||
* - Shows count of missing episodes and auto downloader queue
|
||||
*/
|
||||
export function MainSidebar() {
|
||||
|
||||
const ctx = useAppSidebarContext()
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const [expandedSidebar, setExpandSidebar] = React.useState(false)
|
||||
const [dropdownOpen, setDropdownOpen] = React.useState(false)
|
||||
// const isCollapsed = !ctx.isBelowBreakpoint && !expandedSidebar
|
||||
const isCollapsed = ts.expandSidebarOnHover ? (!ctx.isBelowBreakpoint && !expandedSidebar) : !ctx.isBelowBreakpoint
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
const serverStatus = useServerStatus()
|
||||
const setServerStatus = useSetServerStatus()
|
||||
const user = useCurrentUser()
|
||||
|
||||
const { setSeaCommandOpen } = useOpenSeaCommand()
|
||||
|
||||
const missingEpisodeCount = useMissingEpisodeCount()
|
||||
const autoDownloaderQueueCount = useAutoDownloaderQueueCount()
|
||||
|
||||
// Logout
|
||||
const { mutate: logout, data, isPending } = useLogout()
|
||||
|
||||
React.useEffect(() => {
|
||||
if (!isPending) {
|
||||
setServerStatus(data)
|
||||
}
|
||||
}, [isPending, data])
|
||||
|
||||
const setGlobalSearchIsOpen = useSetAtom(__globalSearch_isOpenAtom)
|
||||
const [loginModal, setLoginModal] = useAtom(isLoginModalOpenAtom)
|
||||
const [nakamaModalOpen, setNakamaModalOpen] = useAtom(nakamaModalOpenAtom)
|
||||
const nakamaStatus = useNakamaStatus()
|
||||
|
||||
const handleExpandSidebar = () => {
|
||||
if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) {
|
||||
setExpandSidebar(true)
|
||||
}
|
||||
}
|
||||
const handleUnexpandedSidebar = () => {
|
||||
if (expandedSidebar && ts.expandSidebarOnHover) {
|
||||
setExpandSidebar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmSignOut = useConfirmationDialog({
|
||||
title: "Sign out",
|
||||
description: "Are you sure you want to sign out?",
|
||||
onConfirm: () => {
|
||||
logout()
|
||||
},
|
||||
})
|
||||
|
||||
const [activeTorrentCount, setActiveTorrentCount] = React.useState({ downloading: 0, paused: 0, seeding: 0 })
|
||||
useWebsocketMessageListener<{ downloading: number, paused: number, seeding: number }>({
|
||||
type: WSEvents.ACTIVE_TORRENT_COUNT_UPDATED,
|
||||
onMessage: data => {
|
||||
setActiveTorrentCount(data)
|
||||
},
|
||||
})
|
||||
|
||||
const { syncIsActive } = useSyncIsActive()
|
||||
|
||||
const { data: updateData } = useGetExtensionUpdateData()
|
||||
|
||||
const [loggingIn, setLoggingIn] = React.useState(false)
|
||||
|
||||
const items = [
|
||||
{
|
||||
id: "library",
|
||||
iconType: IoLibrary,
|
||||
name: "Library",
|
||||
href: "/",
|
||||
isCurrent: pathname === "/",
|
||||
},
|
||||
...(process.env.NODE_ENV === "development" ? [{
|
||||
id: "test",
|
||||
iconType: GrTest,
|
||||
name: "Test",
|
||||
href: "/test",
|
||||
isCurrent: pathname === "/test",
|
||||
}] : []),
|
||||
{
|
||||
id: "schedule",
|
||||
iconType: BiCalendarAlt,
|
||||
name: "Schedule",
|
||||
href: "/schedule",
|
||||
isCurrent: pathname === "/schedule",
|
||||
addon: missingEpisodeCount > 0 ? <Badge
|
||||
className="absolute right-0 top-0" size="sm"
|
||||
intent="alert-solid"
|
||||
>{missingEpisodeCount}</Badge> : undefined,
|
||||
},
|
||||
...serverStatus?.settings?.library?.enableManga ? [{
|
||||
id: "manga",
|
||||
iconType: FaBookReader,
|
||||
name: "Manga",
|
||||
href: "/manga",
|
||||
isCurrent: pathname.startsWith("/manga"),
|
||||
}] : [],
|
||||
{
|
||||
id: "discover",
|
||||
iconType: BiNews,
|
||||
name: "Discover",
|
||||
href: "/discover",
|
||||
isCurrent: pathname === "/discover",
|
||||
},
|
||||
{
|
||||
id: "anilist",
|
||||
iconType: user?.isSimulated ? PiListChecksFill : SiAnilist,
|
||||
name: user?.isSimulated ? "My lists" : "AniList",
|
||||
href: "/anilist",
|
||||
isCurrent: pathname === "/anilist",
|
||||
},
|
||||
...serverStatus?.settings?.nakama?.enabled ? [{
|
||||
id: "nakama",
|
||||
iconType: MdOutlineConnectWithoutContact,
|
||||
iconClass: "size-6",
|
||||
name: "Nakama",
|
||||
isCurrent: nakamaModalOpen,
|
||||
onClick: () => setNakamaModalOpen(true),
|
||||
addon: <>
|
||||
{nakamaStatus?.isHost && !!nakamaStatus?.connectedPeers?.length && <Badge
|
||||
className="absolute right-0 top-0" size="sm"
|
||||
intent="info"
|
||||
>{nakamaStatus?.connectedPeers?.length}</Badge>}
|
||||
|
||||
{nakamaStatus?.isConnectedToHost && <div
|
||||
className="absolute right-2 top-2 animate-pulse size-2 bg-green-500 rounded-full"
|
||||
></div>}
|
||||
</>,
|
||||
}] : [],
|
||||
...serverStatus?.settings?.library?.torrentProvider !== TORRENT_PROVIDER.NONE ? [{
|
||||
id: "auto-downloader",
|
||||
iconType: TbWorldDownload,
|
||||
name: "Auto Downloader",
|
||||
href: "/auto-downloader",
|
||||
isCurrent: pathname === "/auto-downloader",
|
||||
addon: autoDownloaderQueueCount > 0 ? <Badge
|
||||
className="absolute right-0 top-0" size="sm"
|
||||
intent="alert-solid"
|
||||
>{autoDownloaderQueueCount}</Badge> : undefined,
|
||||
}] : [],
|
||||
...(
|
||||
serverStatus?.settings?.library?.torrentProvider !== TORRENT_PROVIDER.NONE
|
||||
&& !serverStatus?.settings?.torrent?.hideTorrentList
|
||||
&& serverStatus?.settings?.torrent?.defaultTorrentClient !== TORRENT_CLIENT.NONE)
|
||||
? [{
|
||||
id: "torrent-list",
|
||||
iconType: BiDownload,
|
||||
name: (activeTorrentCount.seeding === 0 || !serverStatus?.settings?.torrent?.showActiveTorrentCount)
|
||||
? "Torrent list"
|
||||
: `Torrent list (${activeTorrentCount.seeding} seeding)`,
|
||||
href: "/torrent-list",
|
||||
isCurrent: pathname === "/torrent-list",
|
||||
addon: ((activeTorrentCount.downloading + activeTorrentCount.paused) > 0 && serverStatus?.settings?.torrent?.showActiveTorrentCount)
|
||||
? <Badge
|
||||
className="absolute right-0 top-0 bg-green-500" size="sm"
|
||||
intent="alert-solid"
|
||||
>{activeTorrentCount.downloading + activeTorrentCount.paused}</Badge>
|
||||
: undefined,
|
||||
}] : [],
|
||||
...(serverStatus?.debridSettings?.enabled && !!serverStatus?.debridSettings?.provider) ? [{
|
||||
id: "debrid",
|
||||
iconType: HiOutlineServerStack,
|
||||
name: "Debrid",
|
||||
href: "/debrid",
|
||||
isCurrent: pathname === "/debrid",
|
||||
}] : [],
|
||||
{
|
||||
id: "scan-summaries",
|
||||
iconType: PiClockCounterClockwiseFill,
|
||||
name: "Scan summaries",
|
||||
href: "/scan-summaries",
|
||||
isCurrent: pathname === "/scan-summaries",
|
||||
},
|
||||
{
|
||||
id: "search",
|
||||
iconType: FiSearch,
|
||||
name: "Search",
|
||||
onClick: () => {
|
||||
ctx.setOpen(false)
|
||||
setGlobalSearchIsOpen(true)
|
||||
},
|
||||
},
|
||||
]
|
||||
|
||||
const pinnedMenuItems = React.useMemo(() => {
|
||||
return items.filter(item => !ts.unpinnedMenuItems?.includes(item.id))
|
||||
}, [items, ts.unpinnedMenuItems])
|
||||
|
||||
const unpinnedMenuItems = React.useMemo(() => {
|
||||
if (ts.unpinnedMenuItems?.length === 0 || items.length === 0) return []
|
||||
return [
|
||||
{
|
||||
iconType: BiChevronRight,
|
||||
name: "More",
|
||||
subContent: <VerticalMenu
|
||||
items={items.filter(item => ts.unpinnedMenuItems?.includes(item.id))}
|
||||
/>,
|
||||
} as VerticalMenuItem,
|
||||
]
|
||||
}, [items, ts.unpinnedMenuItems, ts.hideTopNavbar])
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSidebar
|
||||
className={cn(
|
||||
"group/main-sidebar h-full flex flex-col justify-between transition-gpu w-full transition-[width] duration-300",
|
||||
(!ctx.isBelowBreakpoint && expandedSidebar) && "w-[260px]",
|
||||
(!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency) && "bg-transparent",
|
||||
(!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency && ts.expandSidebarOnHover) && "hover:bg-[--background]",
|
||||
)}
|
||||
onMouseEnter={handleExpandSidebar}
|
||||
onMouseLeave={handleUnexpandedSidebar}
|
||||
>
|
||||
{(!ctx.isBelowBreakpoint && ts.expandSidebarOnHover && ts.disableSidebarTransparency) && <div
|
||||
className={cn(
|
||||
"fixed h-full translate-x-0 w-[50px] bg-gradient bg-gradient-to-r via-[--background] from-[--background] to-transparent",
|
||||
"group-hover/main-sidebar:translate-x-[250px] transition opacity-0 duration-300 group-hover/main-sidebar:opacity-100",
|
||||
)}
|
||||
></div>}
|
||||
|
||||
<div>
|
||||
<div className="mb-4 p-4 pb-0 flex justify-center w-full">
|
||||
<img src="/logo.png" alt="logo" className="w-15 h-10" />
|
||||
</div>
|
||||
<VerticalMenu
|
||||
className="px-4"
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
itemChevronClass="hidden"
|
||||
itemIconClass="transition-transform group-data-[state=open]/verticalMenu_parentItem:rotate-90"
|
||||
items={[
|
||||
...pinnedMenuItems,
|
||||
...unpinnedMenuItems,
|
||||
]}
|
||||
subContentClass={cn((ts.hideTopNavbar || __isDesktop__) && "border-transparent !border-b-0")}
|
||||
onLinkItemClick={() => ctx.setOpen(false)}
|
||||
/>
|
||||
|
||||
<SidebarNavbar
|
||||
isCollapsed={isCollapsed}
|
||||
handleExpandSidebar={() => { }}
|
||||
handleUnexpandedSidebar={() => { }}
|
||||
/>
|
||||
{__isDesktop__ && <div className="w-full flex justify-center px-4">
|
||||
<HoverCard
|
||||
side="right"
|
||||
sideOffset={-8}
|
||||
className="bg-transparent border-none"
|
||||
trigger={<IconButton
|
||||
intent="gray-basic"
|
||||
className="!text-[--muted] hover:!text-[--foreground]"
|
||||
icon={<PiArrowCircleLeftDuotone />}
|
||||
onClick={() => {
|
||||
router.back()
|
||||
}}
|
||||
/>}
|
||||
>
|
||||
<IconButton
|
||||
icon={<PiArrowCircleRightDuotone />}
|
||||
intent="gray-subtle"
|
||||
className="opacity-50 hover:opacity-100"
|
||||
onClick={() => {
|
||||
router.forward()
|
||||
}}
|
||||
/>
|
||||
</HoverCard>
|
||||
</div>}
|
||||
|
||||
<PluginSidebarTray place="sidebar" />
|
||||
|
||||
</div>
|
||||
<div className="flex w-full gap-2 flex-col px-4">
|
||||
{!__isDesktop__ ? <UpdateModal collapsed={isCollapsed} /> :
|
||||
__isTauriDesktop__ ? <TauriUpdateModal collapsed={isCollapsed} /> :
|
||||
__isElectronDesktop__ ? <ElectronUpdateModal collapsed={isCollapsed} /> :
|
||||
null}
|
||||
<div>
|
||||
<VerticalMenu
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
onMouseEnter={() => { }}
|
||||
onMouseLeave={() => { }}
|
||||
onLinkItemClick={() => ctx.setOpen(false)}
|
||||
items={[
|
||||
// {
|
||||
// iconType: RiSlashCommands2,
|
||||
// name: "Command palette",
|
||||
// onClick: () => {
|
||||
// setSeaCommandOpen(true)
|
||||
// }
|
||||
// },
|
||||
{
|
||||
iconType: BiExtension,
|
||||
name: "Extensions",
|
||||
href: "/extensions",
|
||||
isCurrent: pathname.includes("/extensions"),
|
||||
addon: !!updateData?.length
|
||||
? <Badge
|
||||
className="absolute right-0 top-0 bg-red-500 animate-pulse" size="sm"
|
||||
intent="alert-solid"
|
||||
>
|
||||
{updateData?.length || 1}
|
||||
</Badge>
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
iconType: IoCloudOfflineOutline,
|
||||
name: "Offline",
|
||||
href: "/sync",
|
||||
isCurrent: pathname.includes("/sync"),
|
||||
addon: (syncIsActive)
|
||||
? <Badge
|
||||
className="absolute right-0 top-0 bg-blue-500" size="sm"
|
||||
intent="alert-solid"
|
||||
>
|
||||
1
|
||||
</Badge>
|
||||
: undefined,
|
||||
},
|
||||
{
|
||||
iconType: FiSettings,
|
||||
name: "Settings",
|
||||
href: "/settings",
|
||||
isCurrent: pathname === ("/settings"),
|
||||
},
|
||||
...(ctx.isBelowBreakpoint ? [
|
||||
{
|
||||
iconType: user?.isSimulated ? FiLogIn : BiLogOut,
|
||||
name: user?.isSimulated ? "Sign in" : "Sign out",
|
||||
onClick: user?.isSimulated ? () => setLoginModal(true) : confirmSignOut.open,
|
||||
},
|
||||
] : []),
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
{!user && (
|
||||
<div>
|
||||
<VerticalMenu
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
onMouseEnter={handleExpandSidebar}
|
||||
onMouseLeave={handleUnexpandedSidebar}
|
||||
onLinkItemClick={() => ctx.setOpen(false)}
|
||||
items={[
|
||||
{
|
||||
iconType: FiLogIn,
|
||||
name: "Login",
|
||||
onClick: () => openTab(ANILIST_OAUTH_URL),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
)}
|
||||
{!!user && <div className="flex w-full gap-2 flex-col">
|
||||
<DropdownMenu
|
||||
trigger={<div
|
||||
className={cn(
|
||||
"w-full flex p-2.5 pt-1 items-center space-x-2",
|
||||
{ "hidden": ctx.isBelowBreakpoint },
|
||||
)}
|
||||
>
|
||||
<Avatar size="sm" className="cursor-pointer" src={user?.viewer?.avatar?.medium || undefined} />
|
||||
{expandedSidebar && <p className="truncate">{user?.viewer?.name}</p>}
|
||||
</div>}
|
||||
open={dropdownOpen}
|
||||
onOpenChange={setDropdownOpen}
|
||||
>
|
||||
{!user.isSimulated ? <DropdownMenuItem onClick={confirmSignOut.open}>
|
||||
<BiLogOut /> Sign out
|
||||
</DropdownMenuItem> : <DropdownMenuItem onClick={() => setLoginModal(true)}>
|
||||
<BiLogIn /> Log in with AniList
|
||||
</DropdownMenuItem>}
|
||||
</DropdownMenu>
|
||||
</div>}
|
||||
</div>
|
||||
</AppSidebar>
|
||||
|
||||
<Modal
|
||||
title="Log in with AniList"
|
||||
description="Using an AniList account is recommended."
|
||||
open={loginModal && user?.isSimulated}
|
||||
onOpenChange={(v) => setLoginModal(v)}
|
||||
overlayClass="bg-opacity-95 bg-gray-950"
|
||||
contentClass="border"
|
||||
>
|
||||
<div className="mt-5 text-center space-y-4">
|
||||
|
||||
<Link
|
||||
href={ANILIST_PIN_URL}
|
||||
target="_blank"
|
||||
>
|
||||
<Button
|
||||
leftIcon={<svg
|
||||
xmlns="http://www.w3.org/2000/svg" fill="currentColor" width="24" height="24"
|
||||
viewBox="0 0 24 24" role="img"
|
||||
>
|
||||
<path
|
||||
d="M6.361 2.943 0 21.056h4.942l1.077-3.133H11.4l1.052 3.133H22.9c.71 0 1.1-.392 1.1-1.101V17.53c0-.71-.39-1.101-1.1-1.101h-6.483V4.045c0-.71-.392-1.102-1.101-1.102h-2.422c-.71 0-1.101.392-1.101 1.102v1.064l-.758-2.166zm2.324 5.948 1.688 5.018H7.144z"
|
||||
/>
|
||||
</svg>}
|
||||
intent="white"
|
||||
size="md"
|
||||
>Get AniList token</Button>
|
||||
</Link>
|
||||
|
||||
<Form
|
||||
schema={defineSchema(({ z }) => z.object({
|
||||
token: z.string().min(1, "Token is required"),
|
||||
}))}
|
||||
onSubmit={data => {
|
||||
setLoggingIn(true)
|
||||
router.push("/auth/callback#access_token=" + data.token.trim())
|
||||
setLoginModal(false)
|
||||
setLoggingIn(false)
|
||||
}}
|
||||
>
|
||||
<Field.Textarea
|
||||
name="token"
|
||||
label="Enter the token"
|
||||
fieldClass="px-4"
|
||||
/>
|
||||
<Field.Submit showLoadingOverlayOnSuccess loading={loggingIn}>Continue</Field.Submit>
|
||||
</Form>
|
||||
|
||||
</div>
|
||||
</Modal>
|
||||
|
||||
<ConfirmationDialog {...confirmSignOut} />
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,150 @@
|
||||
"use client"
|
||||
import { useSetOfflineMode } from "@/api/hooks/local.hooks"
|
||||
import { SidebarNavbar } from "@/app/(main)/_features/layout/top-navbar"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
|
||||
import { AppSidebar, useAppSidebarContext } from "@/components/ui/app-layout"
|
||||
import { Avatar } from "@/components/ui/avatar"
|
||||
import { cn } from "@/components/ui/core/styling"
|
||||
import { VerticalMenu } from "@/components/ui/vertical-menu"
|
||||
import { useThemeSettings } from "@/lib/theme/hooks"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React from "react"
|
||||
import { FaBookReader } from "react-icons/fa"
|
||||
import { FiSettings } from "react-icons/fi"
|
||||
import { IoCloudyOutline, IoLibrary } from "react-icons/io5"
|
||||
import { PluginSidebarTray } from "../plugin/tray/plugin-sidebar-tray"
|
||||
|
||||
|
||||
export function OfflineSidebar() {
|
||||
const serverStatus = useServerStatus()
|
||||
const ctx = useAppSidebarContext()
|
||||
const ts = useThemeSettings()
|
||||
|
||||
const [expandedSidebar, setExpandSidebar] = React.useState(false)
|
||||
const isCollapsed = ts.expandSidebarOnHover ? (!ctx.isBelowBreakpoint && !expandedSidebar) : !ctx.isBelowBreakpoint
|
||||
|
||||
const { mutate: setOfflineMode, isPending: isSettingOfflineMode } = useSetOfflineMode()
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
const handleExpandSidebar = () => {
|
||||
if (!ctx.isBelowBreakpoint && ts.expandSidebarOnHover) {
|
||||
setExpandSidebar(true)
|
||||
}
|
||||
}
|
||||
const handleUnexpandedSidebar = () => {
|
||||
if (expandedSidebar && ts.expandSidebarOnHover) {
|
||||
setExpandSidebar(false)
|
||||
}
|
||||
}
|
||||
|
||||
const confirmDialog = useConfirmationDialog({
|
||||
title: "Disable offline mode",
|
||||
description: "Are you sure you want to disable offline mode?",
|
||||
actionText: "Yes",
|
||||
actionIntent: "primary",
|
||||
onConfirm: () => {
|
||||
setOfflineMode({ enabled: false })
|
||||
},
|
||||
})
|
||||
|
||||
return (
|
||||
<>
|
||||
<AppSidebar
|
||||
className={cn(
|
||||
"h-full flex flex-col justify-between transition-gpu w-full transition-[width]",
|
||||
(!ctx.isBelowBreakpoint && expandedSidebar) && "w-[260px]",
|
||||
(!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency) && "bg-transparent",
|
||||
(!ctx.isBelowBreakpoint && !ts.disableSidebarTransparency && ts.expandSidebarOnHover) && "hover:bg-[--background]",
|
||||
)}
|
||||
onMouseEnter={handleExpandSidebar}
|
||||
onMouseLeave={handleUnexpandedSidebar}
|
||||
>
|
||||
{(!ctx.isBelowBreakpoint && ts.expandSidebarOnHover && ts.disableSidebarTransparency) && <div
|
||||
className={cn(
|
||||
"fixed h-full translate-x-0 w-[50px] bg-gradient bg-gradient-to-r via-[--background] from-[--background] to-transparent",
|
||||
"group-hover/main-sidebar:translate-x-[250px] transition opacity-0 duration-300 group-hover/main-sidebar:opacity-100",
|
||||
)}
|
||||
></div>}
|
||||
|
||||
|
||||
<div>
|
||||
<div className="mb-4 p-4 pb-0 flex justify-center w-full">
|
||||
<img src="/logo.png" alt="logo" className="w-15 h-10" />
|
||||
</div>
|
||||
<VerticalMenu
|
||||
className="px-4"
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
items={[
|
||||
{
|
||||
iconType: IoLibrary,
|
||||
name: "Library",
|
||||
href: "/offline",
|
||||
isCurrent: pathname === "/offline",
|
||||
},
|
||||
...[serverStatus?.settings?.library?.enableManga && {
|
||||
iconType: FaBookReader,
|
||||
name: "Manga",
|
||||
href: "/offline/manga",
|
||||
isCurrent: pathname.startsWith("/offline/manga"),
|
||||
}].filter(Boolean) as any,
|
||||
].filter(Boolean)}
|
||||
onLinkItemClick={() => ctx.setOpen(false)}
|
||||
/>
|
||||
|
||||
<SidebarNavbar
|
||||
isCollapsed={isCollapsed}
|
||||
handleExpandSidebar={() => { }}
|
||||
handleUnexpandedSidebar={() => { }}
|
||||
/>
|
||||
|
||||
<PluginSidebarTray place="sidebar" />
|
||||
</div>
|
||||
<div className="flex w-full gap-2 flex-col px-4">
|
||||
<div>
|
||||
<VerticalMenu
|
||||
collapsed={isCollapsed}
|
||||
itemClass="relative"
|
||||
onLinkItemClick={() => ctx.setOpen(false)}
|
||||
items={[
|
||||
{
|
||||
iconType: IoCloudyOutline,
|
||||
name: "Disable offline mode",
|
||||
onClick: () => {
|
||||
confirmDialog.open()
|
||||
},
|
||||
},
|
||||
{
|
||||
iconType: FiSettings,
|
||||
name: "Settings",
|
||||
href: "/settings",
|
||||
isCurrent: pathname === ("/settings"),
|
||||
},
|
||||
]}
|
||||
/>
|
||||
</div>
|
||||
<div className="flex w-full gap-2 flex-col">
|
||||
<div
|
||||
className={cn(
|
||||
"w-full flex p-2.5 pt-1 items-center space-x-2",
|
||||
{ "hidden": ctx.isBelowBreakpoint },
|
||||
)}
|
||||
>
|
||||
<Avatar
|
||||
size="sm"
|
||||
className="cursor-pointer"
|
||||
/>
|
||||
{expandedSidebar && <p className="truncate">Offline</p>}
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</AppSidebar>
|
||||
<ConfirmationDialog
|
||||
{...confirmDialog}
|
||||
/>
|
||||
</>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,72 @@
|
||||
"use client"
|
||||
import { useMissingEpisodeCount } from "@/app/(main)/_hooks/missing-episodes-loader"
|
||||
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
|
||||
import { Badge } from "@/components/ui/badge"
|
||||
import { NavigationMenu, NavigationMenuProps } from "@/components/ui/navigation-menu"
|
||||
import { usePathname } from "next/navigation"
|
||||
import React, { useMemo } from "react"
|
||||
|
||||
interface TopMenuProps {
|
||||
children?: React.ReactNode
|
||||
}
|
||||
|
||||
export const TopMenu: React.FC<TopMenuProps> = (props) => {
|
||||
|
||||
const { children, ...rest } = props
|
||||
|
||||
const serverStatus = useServerStatus()
|
||||
|
||||
const pathname = usePathname()
|
||||
|
||||
const missingEpisodeCount = useMissingEpisodeCount()
|
||||
|
||||
const navigationItems = useMemo<NavigationMenuProps["items"]>(() => {
|
||||
|
||||
return [
|
||||
{
|
||||
href: "/",
|
||||
// icon: IoLibrary,
|
||||
isCurrent: pathname === "/",
|
||||
name: "My library",
|
||||
},
|
||||
{
|
||||
href: "/schedule",
|
||||
icon: null,
|
||||
isCurrent: pathname.startsWith("/schedule"),
|
||||
name: "Schedule",
|
||||
addon: missingEpisodeCount > 0 ? <Badge
|
||||
className="absolute top-1 right-2 h-2 w-2 p-0" size="sm"
|
||||
intent="alert-solid"
|
||||
/> : undefined,
|
||||
},
|
||||
...[serverStatus?.settings?.library?.enableManga && {
|
||||
href: "/manga",
|
||||
icon: null,
|
||||
isCurrent: pathname.startsWith("/manga"),
|
||||
name: "Manga",
|
||||
}].filter(Boolean) as NavigationMenuProps["items"],
|
||||
{
|
||||
href: "/discover",
|
||||
icon: null,
|
||||
isCurrent: pathname.startsWith("/discover") || pathname.startsWith("/search"),
|
||||
name: "Discover",
|
||||
},
|
||||
{
|
||||
href: "/anilist",
|
||||
icon: null,
|
||||
isCurrent: pathname.startsWith("/anilist"),
|
||||
name: serverStatus?.user?.isSimulated ? "My lists" : "AniList",
|
||||
},
|
||||
].filter(Boolean)
|
||||
}, [pathname, missingEpisodeCount, serverStatus?.settings?.library?.enableManga])
|
||||
|
||||
return (
|
||||
<NavigationMenu
|
||||
className="p-0 hidden lg:inline-block"
|
||||
itemClass="text-xl"
|
||||
items={navigationItems}
|
||||
data-top-menu
|
||||
/>
|
||||
)
|
||||
|
||||
}
|
||||
@@ -0,0 +1,409 @@
|
||||
import { AL_BaseAnime, AL_BaseManga, Anime_Episode, Onlinestream_Episode } from "@/api/generated/types"
|
||||
import { Button, ButtonProps, IconButton } from "@/components/ui/button"
|
||||
import { ContextMenuItem, ContextMenuSeparator } from "@/components/ui/context-menu"
|
||||
import { DropdownMenu, DropdownMenuItem, DropdownMenuSeparator } from "@/components/ui/dropdown-menu"
|
||||
import React, { useEffect, useState } from "react"
|
||||
import { BiDotsHorizontal } from "react-icons/bi"
|
||||
import {
|
||||
usePluginListenActionRenderAnimeLibraryDropdownItemsEvent,
|
||||
usePluginListenActionRenderAnimePageButtonsEvent,
|
||||
usePluginListenActionRenderAnimePageDropdownItemsEvent,
|
||||
usePluginListenActionRenderEpisodeCardContextMenuItemsEvent,
|
||||
usePluginListenActionRenderEpisodeGridItemMenuItemsEvent,
|
||||
usePluginListenActionRenderMangaPageButtonsEvent,
|
||||
usePluginListenActionRenderMediaCardContextMenuItemsEvent,
|
||||
usePluginSendActionClickedEvent,
|
||||
usePluginSendActionRenderAnimeLibraryDropdownItemsEvent,
|
||||
usePluginSendActionRenderAnimePageButtonsEvent,
|
||||
usePluginSendActionRenderAnimePageDropdownItemsEvent,
|
||||
usePluginSendActionRenderEpisodeCardContextMenuItemsEvent,
|
||||
usePluginSendActionRenderEpisodeGridItemMenuItemsEvent,
|
||||
usePluginSendActionRenderMangaPageButtonsEvent,
|
||||
usePluginSendActionRenderMediaCardContextMenuItemsEvent,
|
||||
} from "../generated/plugin-events"
|
||||
|
||||
function sortItems<T extends { label: string }>(items: T[]) {
|
||||
return items.sort((a, b) => a.label.localeCompare(b.label, undefined, { numeric: true }))
|
||||
}
|
||||
|
||||
type PluginAnimePageButton = {
|
||||
extensionId: string
|
||||
intent: string
|
||||
onClick: string
|
||||
label: string
|
||||
style: React.CSSProperties
|
||||
id: string
|
||||
}
|
||||
|
||||
export function PluginAnimePageButtons(props: { media: AL_BaseAnime }) {
|
||||
const [buttons, setButtons] = useState<PluginAnimePageButton[]>([])
|
||||
|
||||
const { sendActionRenderAnimePageButtonsEvent } = usePluginSendActionRenderAnimePageButtonsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderAnimePageButtonsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the anime page buttons
|
||||
usePluginListenActionRenderAnimePageButtonsEvent((event, extensionId) => {
|
||||
setButtons(p => {
|
||||
const otherButtons = p.filter(b => b.extensionId !== extensionId)
|
||||
const extButtons = event.buttons.map((b: Record<string, any>) => ({ ...b, extensionId } as PluginAnimePageButton))
|
||||
return sortItems([...otherButtons, ...extButtons])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(button: PluginAnimePageButton) {
|
||||
sendActionClickedEvent({
|
||||
actionId: button.id,
|
||||
event: {
|
||||
media: props.media,
|
||||
},
|
||||
}, button.extensionId)
|
||||
}
|
||||
|
||||
if (buttons.length === 0) return null
|
||||
|
||||
return <>
|
||||
{buttons.map(b => (
|
||||
<Button
|
||||
key={b.id}
|
||||
intent={b.intent as ButtonProps["intent"] || "white-subtle"}
|
||||
onClick={() => handleClick(b)}
|
||||
style={b.style}
|
||||
>{b.label || "???"}</Button>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginMangaPageButton = {
|
||||
extensionId: string
|
||||
intent: string
|
||||
onClick: string
|
||||
label: string
|
||||
style: React.CSSProperties
|
||||
id: string
|
||||
}
|
||||
|
||||
export function PluginMangaPageButtons(props: { media: AL_BaseManga }) {
|
||||
const [buttons, setButtons] = useState<PluginMangaPageButton[]>([])
|
||||
|
||||
const { sendActionRenderMangaPageButtonsEvent } = usePluginSendActionRenderMangaPageButtonsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderMangaPageButtonsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the manga page buttons
|
||||
usePluginListenActionRenderMangaPageButtonsEvent((event, extensionId) => {
|
||||
setButtons(p => {
|
||||
const otherButtons = p.filter(b => b.extensionId !== extensionId)
|
||||
const extButtons = event.buttons.map((b: Record<string, any>) => ({ ...b, extensionId } as PluginMangaPageButton))
|
||||
return sortItems([...otherButtons, ...extButtons])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(button: PluginMangaPageButton) {
|
||||
sendActionClickedEvent({
|
||||
actionId: button.id,
|
||||
event: {
|
||||
media: props.media,
|
||||
},
|
||||
}, button.extensionId)
|
||||
}
|
||||
|
||||
if (buttons.length === 0) return null
|
||||
|
||||
return <>
|
||||
{buttons.map(b => (
|
||||
<Button
|
||||
key={b.id}
|
||||
intent={b.intent as ButtonProps["intent"] || "white-subtle"}
|
||||
onClick={() => handleClick(b)}
|
||||
style={b.style}
|
||||
>{b.label || "???"}</Button>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginMediaCardContextMenuItem = {
|
||||
extensionId: string
|
||||
onClick: string
|
||||
label: string
|
||||
style: React.CSSProperties
|
||||
id: string
|
||||
for: "anime" | "manga" | "both"
|
||||
}
|
||||
|
||||
type PluginMediaCardContextMenuItemsProps = {
|
||||
for: "anime" | "manga",
|
||||
media: AL_BaseAnime | AL_BaseManga
|
||||
}
|
||||
|
||||
export function PluginMediaCardContextMenuItems(props: PluginMediaCardContextMenuItemsProps) {
|
||||
const [items, setItems] = useState<PluginMediaCardContextMenuItem[]>([])
|
||||
|
||||
const { sendActionRenderMediaCardContextMenuItemsEvent } = usePluginSendActionRenderMediaCardContextMenuItemsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderMediaCardContextMenuItemsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the media card context menu items
|
||||
usePluginListenActionRenderMediaCardContextMenuItemsEvent((event, extensionId) => {
|
||||
setItems(p => {
|
||||
const otherItems = p.filter(b => b.extensionId !== extensionId)
|
||||
const extItems = event.items
|
||||
.filter((i: PluginMediaCardContextMenuItem) => i.for === props.for || i.for === "both")
|
||||
.map((b: Record<string, any>) => ({ ...b, extensionId } as PluginMangaPageButton))
|
||||
return sortItems([...otherItems, ...extItems])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(item: PluginMediaCardContextMenuItem) {
|
||||
sendActionClickedEvent({
|
||||
actionId: item.id,
|
||||
event: {
|
||||
media: props.media,
|
||||
},
|
||||
}, item.extensionId)
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return <>
|
||||
<ContextMenuSeparator className="my-2" />
|
||||
{items.map(i => (
|
||||
<ContextMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</ContextMenuItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginAnimeLibraryDropdownMenuItem = {
|
||||
extensionId: string
|
||||
onClick: string
|
||||
label: string
|
||||
id: string
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function PluginAnimeLibraryDropdownItems() {
|
||||
const [items, setItems] = useState<PluginAnimeLibraryDropdownMenuItem[]>([])
|
||||
|
||||
const { sendActionRenderAnimeLibraryDropdownItemsEvent } = usePluginSendActionRenderAnimeLibraryDropdownItemsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderAnimeLibraryDropdownItemsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
|
||||
// Listen for the action to render the anime library dropdown items
|
||||
usePluginListenActionRenderAnimeLibraryDropdownItemsEvent((event, extensionId) => {
|
||||
setItems(p => {
|
||||
const otherItems = p.filter(i => i.extensionId !== extensionId)
|
||||
const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginAnimeLibraryDropdownMenuItem))
|
||||
return sortItems([...otherItems, ...extItems])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(item: PluginAnimeLibraryDropdownMenuItem) {
|
||||
sendActionClickedEvent({
|
||||
actionId: item.id,
|
||||
event: {},
|
||||
}, item.extensionId)
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return <>
|
||||
<DropdownMenuSeparator />
|
||||
{items.map(i => (
|
||||
<DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginEpisodeCardContextMenuItem = {
|
||||
extensionId: string
|
||||
onClick: string
|
||||
label: string
|
||||
id: string
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function PluginEpisodeCardContextMenuItems(props: { episode: Anime_Episode | undefined }) {
|
||||
const [items, setItems] = useState<PluginEpisodeCardContextMenuItem[]>([])
|
||||
|
||||
const { sendActionRenderEpisodeCardContextMenuItemsEvent } = usePluginSendActionRenderEpisodeCardContextMenuItemsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderEpisodeCardContextMenuItemsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the episode card context menu items
|
||||
usePluginListenActionRenderEpisodeCardContextMenuItemsEvent((event, extensionId) => {
|
||||
setItems(p => {
|
||||
const otherItems = p.filter(i => i.extensionId !== extensionId)
|
||||
const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginEpisodeCardContextMenuItem))
|
||||
return sortItems([...otherItems, ...extItems])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(item: PluginEpisodeCardContextMenuItem) {
|
||||
sendActionClickedEvent({
|
||||
actionId: item.id,
|
||||
event: {
|
||||
episode: props.episode,
|
||||
},
|
||||
}, item.extensionId)
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return <>
|
||||
<ContextMenuSeparator />
|
||||
{items.map(i => (
|
||||
<ContextMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</ContextMenuItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginEpisodeGridItemMenuItem = {
|
||||
extensionId: string
|
||||
onClick: string
|
||||
label: string
|
||||
id: string
|
||||
type: "library" | "torrentstream" | "debridstream" | "onlinestream" | "undownloaded" | "medialinks" | "mediastream"
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function PluginEpisodeGridItemMenuItems(props: {
|
||||
isDropdownMenu: boolean,
|
||||
type: PluginEpisodeGridItemMenuItem["type"],
|
||||
episode: Anime_Episode | Onlinestream_Episode | undefined
|
||||
}) {
|
||||
const [items, setItems] = useState<PluginEpisodeGridItemMenuItem[]>([])
|
||||
|
||||
const { sendActionRenderEpisodeGridItemMenuItemsEvent } = usePluginSendActionRenderEpisodeGridItemMenuItemsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderEpisodeGridItemMenuItemsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the episode grid item context menu items
|
||||
usePluginListenActionRenderEpisodeGridItemMenuItemsEvent((event, extensionId) => {
|
||||
setItems(p => {
|
||||
const otherItems = p.filter(i => i.extensionId !== extensionId && i.type === props.type)
|
||||
const extItems = event.items.filter((i: PluginEpisodeGridItemMenuItem) => i.type === props.type)
|
||||
.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginEpisodeGridItemMenuItem))
|
||||
return sortItems([...otherItems, ...extItems])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(item: PluginEpisodeGridItemMenuItem) {
|
||||
sendActionClickedEvent({
|
||||
actionId: item.id,
|
||||
event: {
|
||||
episode: props.episode,
|
||||
},
|
||||
}, item.extensionId)
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
if (props.isDropdownMenu) {
|
||||
return <DropdownMenu
|
||||
trigger={
|
||||
<IconButton
|
||||
icon={<BiDotsHorizontal />}
|
||||
intent="gray-basic"
|
||||
size="xs"
|
||||
/>
|
||||
}
|
||||
>
|
||||
{items.map(i => (
|
||||
<DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem>
|
||||
))}
|
||||
</DropdownMenu>
|
||||
}
|
||||
|
||||
return <>
|
||||
<DropdownMenuSeparator />
|
||||
{items.map(i => (
|
||||
<DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
}
|
||||
|
||||
/////////////////////////////////////////////////////////////////////////////////////////////////////////////////////
|
||||
|
||||
type PluginAnimePageDropdownMenuItem = {
|
||||
extensionId: string
|
||||
onClick: string
|
||||
label: string
|
||||
id: string
|
||||
style: React.CSSProperties
|
||||
}
|
||||
|
||||
export function PluginAnimePageDropdownItems(props: { media: AL_BaseAnime }) {
|
||||
const [items, setItems] = useState<PluginAnimePageDropdownMenuItem[]>([])
|
||||
|
||||
const { sendActionRenderAnimePageDropdownItemsEvent } = usePluginSendActionRenderAnimePageDropdownItemsEvent()
|
||||
const { sendActionClickedEvent } = usePluginSendActionClickedEvent()
|
||||
|
||||
useEffect(() => {
|
||||
sendActionRenderAnimePageDropdownItemsEvent({}, "")
|
||||
}, [])
|
||||
|
||||
// Listen for the action to render the anime page dropdown items
|
||||
usePluginListenActionRenderAnimePageDropdownItemsEvent((event, extensionId) => {
|
||||
setItems(p => {
|
||||
const otherItems = p.filter(i => i.extensionId !== extensionId)
|
||||
const extItems = event.items.map((i: Record<string, any>) => ({ ...i, extensionId } as PluginAnimePageDropdownMenuItem))
|
||||
return sortItems([...otherItems, ...extItems])
|
||||
})
|
||||
}, "")
|
||||
|
||||
// Send
|
||||
function handleClick(item: PluginAnimePageDropdownMenuItem) {
|
||||
sendActionClickedEvent({
|
||||
actionId: item.id,
|
||||
event: {
|
||||
media: props.media,
|
||||
},
|
||||
}, item.extensionId)
|
||||
}
|
||||
|
||||
if (items.length === 0) return null
|
||||
|
||||
return <>
|
||||
<DropdownMenuSeparator />
|
||||
{items.map(i => (
|
||||
<DropdownMenuItem key={i.id} onClick={() => handleClick(i)} style={i.style}>{i.label || "???"}</DropdownMenuItem>
|
||||
))}
|
||||
</>
|
||||
|
||||
}
|
||||
@@ -0,0 +1,209 @@
|
||||
import { CommandDialog, CommandGroup, CommandInput, CommandItem, CommandList } from "@/components/ui/command"
|
||||
import { useUpdateEffect } from "@/components/ui/core/hooks"
|
||||
import mousetrap from "mousetrap"
|
||||
import { usePathname, useRouter } from "next/navigation"
|
||||
import React from "react"
|
||||
import { PluginProvider, registry, RenderPluginComponents } from "../components/registry"
|
||||
import {
|
||||
usePluginListenCommandPaletteCloseEvent,
|
||||
usePluginListenCommandPaletteGetInputEvent,
|
||||
usePluginListenCommandPaletteOpenEvent,
|
||||
usePluginListenCommandPaletteSetInputEvent,
|
||||
usePluginListenCommandPaletteUpdatedEvent,
|
||||
usePluginSendCommandPaletteClosedEvent,
|
||||
usePluginSendCommandPaletteInputEvent,
|
||||
usePluginSendCommandPaletteItemSelectedEvent,
|
||||
usePluginSendCommandPaletteOpenedEvent,
|
||||
usePluginSendRenderCommandPaletteEvent,
|
||||
} from "../generated/plugin-events"
|
||||
|
||||
export type PluginCommandPaletteInfo = {
|
||||
extensionId: string
|
||||
placeholder: string
|
||||
keyboardShortcut: string
|
||||
}
|
||||
|
||||
type CommandItem = {
|
||||
id: string
|
||||
value: string
|
||||
filterType: string
|
||||
heading: string
|
||||
|
||||
// Either the label or the components should be set
|
||||
label: string // empty string if components are set
|
||||
components?: any
|
||||
}
|
||||
|
||||
export function PluginCommandPalette(props: { extensionId: string, info: PluginCommandPaletteInfo }) {
|
||||
|
||||
const { extensionId, info } = props
|
||||
|
||||
const router = useRouter()
|
||||
const pathname = usePathname()
|
||||
|
||||
const [open, setOpen] = React.useState(false)
|
||||
const [input, setInput] = React.useState("")
|
||||
const [activeItemId, setActiveItemId] = React.useState("")
|
||||
const [items, setItems] = React.useState<CommandItem[]>([])
|
||||
const [placeholder, setPlaceholder] = React.useState(info.placeholder)
|
||||
|
||||
const { sendRenderCommandPaletteEvent } = usePluginSendRenderCommandPaletteEvent()
|
||||
const { sendCommandPaletteInputEvent } = usePluginSendCommandPaletteInputEvent()
|
||||
const { sendCommandPaletteOpenedEvent } = usePluginSendCommandPaletteOpenedEvent()
|
||||
const { sendCommandPaletteClosedEvent } = usePluginSendCommandPaletteClosedEvent()
|
||||
const { sendCommandPaletteItemSelectedEvent } = usePluginSendCommandPaletteItemSelectedEvent()
|
||||
|
||||
// const parsedCommandProps = useSeaCommand_ParseCommand(input)
|
||||
|
||||
// Register the keyboard shortcut
|
||||
React.useEffect(() => {
|
||||
if (!!info.keyboardShortcut) {
|
||||
mousetrap.bind(info.keyboardShortcut, () => {
|
||||
setInput("")
|
||||
React.startTransition(() => {
|
||||
setOpen(true)
|
||||
})
|
||||
})
|
||||
|
||||
return () => {
|
||||
mousetrap.unbind(info.keyboardShortcut)
|
||||
}
|
||||
}
|
||||
}, [info.keyboardShortcut])
|
||||
|
||||
// Render the command palette
|
||||
useUpdateEffect(() => {
|
||||
if (!open) {
|
||||
setInput("")
|
||||
sendCommandPaletteClosedEvent({}, extensionId)
|
||||
}
|
||||
|
||||
if (open) {
|
||||
sendCommandPaletteOpenedEvent({}, extensionId)
|
||||
sendRenderCommandPaletteEvent({}, extensionId)
|
||||
}
|
||||
}, [open, extensionId])
|
||||
|
||||
// Send the input when the server requests it
|
||||
usePluginListenCommandPaletteGetInputEvent((data) => {
|
||||
sendCommandPaletteInputEvent({ value: input }, extensionId)
|
||||
}, extensionId)
|
||||
|
||||
// Set the input when the server sends it
|
||||
usePluginListenCommandPaletteSetInputEvent((data) => {
|
||||
setInput(data.value)
|
||||
}, extensionId)
|
||||
|
||||
// Open the command palette when the server requests it
|
||||
usePluginListenCommandPaletteOpenEvent((data) => {
|
||||
setOpen(true)
|
||||
}, extensionId)
|
||||
|
||||
// Close the command palette when the server requests it
|
||||
usePluginListenCommandPaletteCloseEvent((data) => {
|
||||
setOpen(false)
|
||||
}, extensionId)
|
||||
|
||||
// Continuously listen to render the command palette
|
||||
usePluginListenCommandPaletteUpdatedEvent((data) => {
|
||||
setItems(data.items)
|
||||
setPlaceholder(data.placeholder)
|
||||
}, extensionId)
|
||||
|
||||
const commandListRef = React.useRef<HTMLDivElement>(null)
|
||||
|
||||
function scrollToTop() {
|
||||
const list = commandListRef.current
|
||||
if (!list) return () => { }
|
||||
|
||||
const t = setTimeout(() => {
|
||||
list.scrollTop = 0
|
||||
// Find and focus the first command item
|
||||
const firstItem = list.querySelector("[cmdk-item]") as HTMLElement
|
||||
if (firstItem) {
|
||||
const value = firstItem.getAttribute("data-value")
|
||||
if (value) {
|
||||
setActiveItemId(value)
|
||||
}
|
||||
}
|
||||
}, 100)
|
||||
|
||||
return () => clearTimeout(t)
|
||||
}
|
||||
|
||||
React.useEffect(() => {
|
||||
const cl = scrollToTop()
|
||||
return () => cl()
|
||||
}, [input, pathname])
|
||||
|
||||
// Group items by heading and sort by priority
|
||||
const groupedItems = React.useMemo(() => {
|
||||
const groups: Record<string, CommandItem[]> = {}
|
||||
|
||||
const _items = items.filter(item =>
|
||||
item.filterType === "includes" ?
|
||||
item.value.toLowerCase().includes(input.toLowerCase()) :
|
||||
item.filterType === "startsWith" ?
|
||||
item.value.toLowerCase().startsWith(input.toLowerCase()) :
|
||||
true)
|
||||
|
||||
_items.forEach(item => {
|
||||
const heading = item.heading || ""
|
||||
if (!groups[heading]) groups[heading] = []
|
||||
groups[heading].push(item)
|
||||
})
|
||||
|
||||
// Scroll to top when items are rendered
|
||||
scrollToTop()
|
||||
|
||||
return groups
|
||||
}, [items, input])
|
||||
|
||||
function handleSelect(item: CommandItem) {
|
||||
// setInput("")
|
||||
sendCommandPaletteItemSelectedEvent({ itemId: item.id }, extensionId)
|
||||
}
|
||||
|
||||
|
||||
return (
|
||||
<CommandDialog
|
||||
open={open}
|
||||
onOpenChange={setOpen}
|
||||
commandProps={{
|
||||
value: activeItemId,
|
||||
onValueChange: setActiveItemId,
|
||||
}}
|
||||
overlayClass="bg-black/30"
|
||||
contentClass="max-w-2xl"
|
||||
commandClass="h-[300px]"
|
||||
>
|
||||
<CommandInput
|
||||
placeholder={placeholder || ""}
|
||||
value={input}
|
||||
onValueChange={setInput}
|
||||
/>
|
||||
<CommandList className="mb-2" ref={commandListRef}>
|
||||
|
||||
<PluginProvider registry={registry}>
|
||||
{Object.entries(groupedItems).map(([heading, items]) => (
|
||||
<CommandGroup key={heading} heading={heading}>
|
||||
{items.map(item => (
|
||||
<CommandItem
|
||||
key={item.id}
|
||||
value={item.value}
|
||||
onSelect={() => {
|
||||
handleSelect(item)
|
||||
}}
|
||||
className="block"
|
||||
>
|
||||
{!!item.label ? item.label : <RenderPluginComponents data={item.components} />}
|
||||
</CommandItem>
|
||||
))}
|
||||
</CommandGroup>
|
||||
))}
|
||||
</PluginProvider>
|
||||
|
||||
</CommandList>
|
||||
</CommandDialog>
|
||||
)
|
||||
}
|
||||
Some files were not shown because too many files have changed in this diff Show More
Reference in New Issue
Block a user