node build fixed

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

View File

@@ -0,0 +1,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>
</>
)
}

View File

@@ -0,0 +1,136 @@
import { useLocalFileBulkAction, useRemoveEmptyDirectories } from "@/api/hooks/localfiles.hooks"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { ConfirmationDialog, useConfirmationDialog } from "@/components/shared/confirmation-dialog"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import { atom, useAtom } from "jotai"
import React from "react"
import { BiLockAlt, BiLockOpenAlt } from "react-icons/bi"
import { toast } from "sonner"
export const __bulkAction_modalAtomIsOpen = atom<boolean>(false)
export function BulkActionModal() {
const [isOpen, setIsOpen] = useAtom(__bulkAction_modalAtomIsOpen)
const { mutate: performBulkAction, isPending } = useLocalFileBulkAction()
function handleLockFiles() {
performBulkAction({
action: "lock",
}, {
onSuccess: () => {
setIsOpen(false)
toast.success("Files locked")
},
})
}
function handleUnlockFiles() {
performBulkAction({
action: "unlock",
}, {
onSuccess: () => {
setIsOpen(false)
toast.success("Files unlocked")
},
})
}
const { mutate: removeEmptyDirectories, isPending: isRemoving } = useRemoveEmptyDirectories()
function handleRemoveEmptyDirectories() {
removeEmptyDirectories(undefined, {
onSuccess: () => {
setIsOpen(false)
},
})
}
const confirmRemoveEmptyDirs = useConfirmationDialog({
title: "Remove empty directories",
description: "This action will remove all empty directories in the library. Are you sure you want to continue?",
onConfirm: () => {
handleRemoveEmptyDirectories()
},
})
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("anime-library-bulk-actions", {
priority: 1,
items: [
{
id: "lock-files", value: "lock", heading: "Library",
render: () => (
<p>Lock all files</p>
),
onSelect: ({ ctx }) => {
handleLockFiles()
},
},
{
id: "unlock-files", value: "unlock", heading: "Library",
render: () => (
<p>Unlock all files</p>
),
onSelect: ({ ctx }) => {
handleUnlockFiles()
},
},
],
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase())
},
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
showBasedOnInput: "startsWith",
})
return () => remove("anime-library-bulk-actions")
}, [])
return (
<Modal
open={isOpen} onOpenChange={() => setIsOpen(false)} title="Bulk actions"
contentClass="space-y-4"
>
<AppLayoutStack spacing="sm">
{/*<p>These actions do not affect ignored files.</p>*/}
<div className="flex gap-2 flex-col md:flex-row">
<Button
leftIcon={<BiLockAlt className="text-2xl" />}
intent="gray-outline"
className="w-full"
disabled={isPending || isRemoving}
onClick={handleLockFiles}
>
Lock all files
</Button>
<Button
leftIcon={<BiLockOpenAlt className="text-2xl" />}
intent="gray-outline"
className="w-full"
disabled={isPending || isRemoving}
onClick={handleUnlockFiles}
>
Unlock all files
</Button>
</div>
<Button
intent="gray-outline"
className="w-full"
disabled={isPending}
loading={isRemoving}
onClick={() => confirmRemoveEmptyDirs.open()}
>
Remove empty directories
</Button>
</AppLayoutStack>
<ConfirmationDialog {...confirmRemoveEmptyDirs} />
</Modal>
)
}

View File

@@ -0,0 +1,272 @@
"use client"
import { Anime_Episode, Continuity_WatchHistory } from "@/api/generated/types"
import { getEpisodeMinutesRemaining, getEpisodePercentageComplete, useGetContinuityWatchHistory } from "@/api/hooks/continuity.hooks"
import { __libraryHeaderImageAtom } from "@/app/(main)/(library)/_components/library-header"
import { usePlayNext } from "@/app/(main)/_atoms/playback.atoms"
import { EpisodeCard } from "@/app/(main)/_features/anime/_components/episode-card"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { episodeCardCarouselItemClass } from "@/components/shared/classnames"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { TextGenerateEffect } from "@/components/shared/text-generate-effect"
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
import { useDebounce } from "@/hooks/use-debounce"
import { anilist_animeIsMovie } from "@/lib/helpers/media"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { useWindowSize } from "@uidotdev/usehooks"
import { atom } from "jotai/index"
import { useAtom, useSetAtom } from "jotai/react"
import Image from "next/image"
import { useRouter } from "next/navigation"
import React from "react"
import { seaCommand_compareMediaTitles } from "../../_features/sea-command/utils"
export const __libraryHeaderEpisodeAtom = atom<Anime_Episode | null>(null)
export function ContinueWatching({ episodes, isLoading, linkTemplate }: {
episodes: Anime_Episode[],
isLoading: boolean
linkTemplate?: string
}) {
const router = useRouter()
const ts = useThemeSettings()
const { data: watchHistory } = useGetContinuityWatchHistory()
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
const [headerEpisode, setHeaderEpisode] = useAtom(__libraryHeaderEpisodeAtom)
const [episodeRefs, setEpisodeRefs] = React.useState<React.RefObject<any>[]>([])
const [inViewEpisodes, setInViewEpisodes] = React.useState<number[]>([])
const debouncedInViewEpisodes = useDebounce(inViewEpisodes, 500)
const { width } = useWindowSize()
// Create refs for each episode
React.useEffect(() => {
setEpisodeRefs(episodes.map(() => React.createRef()))
}, [episodes])
// Observe each episode
React.useEffect(() => {
const observer = new IntersectionObserver((entries) => {
entries.forEach((entry) => {
const index = episodeRefs.findIndex(ref => ref.current === entry.target)
if (index !== -1) {
if (entry.isIntersecting) {
setInViewEpisodes(prev => prev.includes(index) ? prev : [...prev, index])
} else {
setInViewEpisodes(prev => prev.filter((idx) => idx !== index))
}
}
})
}, { threshold: 0.5 }) // Trigger callback when 50% of the element is visible
episodeRefs.forEach((ref) => {
if (ref.current) {
observer.observe(ref.current)
}
})
return () => {
episodeRefs.forEach((ref) => {
if (ref.current) {
observer.unobserve(ref.current)
}
})
}
}, [episodeRefs, width])
const prevSelectedEpisodeRef = React.useRef<Anime_Episode | null>(null)
// Set header image when new episode is in view
React.useEffect(() => {
if (debouncedInViewEpisodes.length === 0) return
let candidateIndices = debouncedInViewEpisodes
// Exclude previously selected episode if possible
if (prevSelectedEpisodeRef.current && debouncedInViewEpisodes.length > 1) {
candidateIndices = debouncedInViewEpisodes.filter(idx => episodes[idx]?.baseAnime?.id !== prevSelectedEpisodeRef.current?.baseAnime?.id)
}
if (candidateIndices.length === 0) candidateIndices = debouncedInViewEpisodes
const randomCandidateIdx = candidateIndices[Math.floor(Math.random() * candidateIndices.length)]
const selectedEpisode = episodes[randomCandidateIdx]
if (selectedEpisode) {
setHeaderImage({
bannerImage: selectedEpisode.baseAnime?.bannerImage || null,
episodeImage: selectedEpisode.baseAnime?.bannerImage || selectedEpisode.baseAnime?.coverImage?.extraLarge || null,
})
prevSelectedEpisodeRef.current = selectedEpisode
}
}, [debouncedInViewEpisodes, episodes, setHeaderImage])
const { setPlayNext } = usePlayNext()
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("continue-watching", {
items: episodes.map(episode => ({
data: episode,
id: `${episode.localFile?.path || episode.baseAnime?.title?.userPreferred || ""}-${episode.episodeNumber || 1}`,
value: `${episode.episodeNumber || 1}`,
heading: "Continue Watching",
priority: 100,
render: () => (
<>
<div className="w-12 aspect-[6/5] flex-none rounded-[--radius-md] relative overflow-hidden">
<Image
src={episode.episodeMetadata?.image || ""}
alt="episode image"
fill
className="object-center object-cover"
/>
</div>
<div className="flex gap-1 items-center w-full">
<p className="max-w-[70%] truncate">{episode.baseAnime?.title?.userPreferred || ""}</p>&nbsp;-&nbsp;
{!anilist_animeIsMovie(episode.baseAnime) ? <>
<p className="text-[--muted]">Ep</p><span>{episode.episodeNumber}</span>
</> : <>
<p className="text-[--muted]">Movie</p>
</>}
</div>
</>
),
onSelect: () => setPlayNext(episode.baseAnime?.id, () => {
router.push(`/entry?id=${episode.baseAnime?.id}`)
}),
})),
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase()) ||
seaCommand_compareMediaTitles(item.data.baseAnime?.title, input)
},
priority: 100,
})
return () => remove("continue-watching")
}, [episodes, inject, remove, router, setPlayNext])
if (episodes.length > 0) return (
<PageWrapper className="space-y-3 lg:space-y-6 p-4 relative z-[4]" data-continue-watching-container>
<h2 data-continue-watching-title>Continue watching</h2>
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && headerEpisode?.baseAnime) && <TextGenerateEffect
data-continue-watching-media-title
words={headerEpisode?.baseAnime?.title?.userPreferred || ""}
className="w-full text-xl lg:text-5xl lg:max-w-[50%] h-[3.2rem] !mt-1 line-clamp-1 truncate text-ellipsis hidden lg:block pb-1"
/>}
<Carousel
className="w-full max-w-full"
gap="md"
opts={{
align: "start",
}}
autoScroll
autoScrollDelay={8000}
>
<CarouselDotButtons />
<CarouselContent>
{episodes.map((episode, idx) => (
<CarouselItem
key={episode?.localFile?.path || idx}
className={episodeCardCarouselItemClass(ts.smallerEpisodeCarouselSize)}
>
<_EpisodeCard
key={episode.localFile?.path || ""}
episode={episode}
mRef={episodeRefs[idx]}
overrideLink={linkTemplate}
watchHistory={watchHistory}
/>
</CarouselItem>
))}
</CarouselContent>
</Carousel>
</PageWrapper>
)
return null
}
const _EpisodeCard = React.memo(({ episode, mRef, overrideLink, watchHistory }: {
episode: Anime_Episode,
mRef: React.RefObject<any>,
overrideLink?: string
watchHistory: Continuity_WatchHistory | undefined
}) => {
const serverStatus = useServerStatus()
const router = useRouter()
const setHeaderImage = useSetAtom(__libraryHeaderImageAtom)
const setHeaderEpisode = useSetAtom(__libraryHeaderEpisodeAtom)
React.useEffect(() => {
setHeaderImage(prev => {
if (prev?.episodeImage === null) {
return {
bannerImage: episode.baseAnime?.bannerImage || null,
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
}
}
return prev
})
setHeaderEpisode(prev => {
if (prev === null) {
return episode
}
return prev
})
}, [])
const { setPlayNext } = usePlayNext()
return (
<EpisodeCard
key={episode.localFile?.path || ""}
episode={episode}
image={episode.episodeMetadata?.image || episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge}
topTitle={episode.episodeTitle || episode?.baseAnime?.title?.userPreferred}
title={episode.displayTitle}
isInvalid={episode.isInvalid}
progressTotal={episode.baseAnime?.episodes}
progressNumber={episode.progressNumber}
episodeNumber={episode.episodeNumber}
length={episode.episodeMetadata?.length}
hasDiscrepancy={episode.episodeNumber !== episode.progressNumber}
percentageComplete={getEpisodePercentageComplete(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
minutesRemaining={getEpisodeMinutesRemaining(watchHistory, episode.baseAnime?.id || 0, episode.episodeNumber)}
anime={{
id: episode?.baseAnime?.id || 0,
image: episode?.baseAnime?.coverImage?.medium,
title: episode?.baseAnime?.title?.userPreferred,
}}
onMouseEnter={() => {
React.startTransition(() => {
setHeaderImage({
bannerImage: episode.baseAnime?.bannerImage || null,
episodeImage: episode.baseAnime?.bannerImage || episode.baseAnime?.coverImage?.extraLarge || null,
})
})
}}
mRef={mRef}
onClick={() => {
if (!overrideLink) {
setPlayNext(episode.baseAnime?.id, () => {
if (!serverStatus?.isOffline) {
router.push(`/entry?id=${episode.baseAnime?.id}`)
} else {
router.push(`/offline/entry/anime?id=${episode.baseAnime?.id}`)
}
})
} else {
setPlayNext(episode.baseAnime?.id, () => {
router.push(overrideLink.replace("{id}", episode.baseAnime?.id ? String(episode.baseAnime.id) : ""))
})
}
}}
/>
)
})

View File

@@ -0,0 +1,112 @@
"use client"
import { TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE } from "@/app/(main)/_features/custom-ui/styles"
import { cn } from "@/components/ui/core/styling"
import { getAssetUrl } from "@/lib/server/assets"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { motion } from "motion/react"
import React, { useEffect } from "react"
import { useWindowScroll } from "react-use"
type CustomLibraryBannerProps = {
discrete?: boolean
isLibraryScreen?: boolean // Anime library or manga library
}
export function CustomLibraryBanner(props: CustomLibraryBannerProps) {
/**
* Library screens: Shows the custom banner IF theme settings are set to use a custom banner
* Other pages: Shows the custom banner
*/
const { discrete, isLibraryScreen } = props
const ts = useThemeSettings()
const image = React.useMemo(() => ts.libraryScreenCustomBannerImage ? getAssetUrl(ts.libraryScreenCustomBannerImage) : "",
[ts.libraryScreenCustomBannerImage])
const [dimmed, setDimmed] = React.useState(false)
const { y } = useWindowScroll()
useEffect(() => {
if (y > 100)
setDimmed(true)
else
setDimmed(false)
}, [(y > 100)])
if (isLibraryScreen && ts.libraryScreenBannerType !== ThemeLibraryScreenBannerType.Custom) return null
if (discrete && !!ts.libraryScreenCustomBackgroundImage) return null
if (!image) return null
return (
<>
{!discrete && <div
data-custom-library-banner-top-spacer
className={cn(
"py-20",
ts.hideTopNavbar && "py-28",
)}
></div>}
<div
data-custom-library-banner-container
className={cn(
"__header h-[30rem] z-[1] top-0 w-full fixed group/library-header transition-opacity duration-1000",
discrete && "opacity-20",
!!ts.libraryScreenCustomBackgroundImage && "absolute", // If there's a background image, make the banner absolute
(!ts.libraryScreenCustomBackgroundImage && dimmed) && "opacity-5", // If the user has scrolled down, dim the banner
!ts.disableSidebarTransparency && TRANSPARENT_SIDEBAR_BANNER_IMG_STYLE,
"scroll-locked-offset",
)}
>
{(!ts.disableSidebarTransparency && !discrete) && <div
data-custom-library-banner-top-gradient
className="hidden lg:block h-full absolute z-[2] w-[20rem] opacity-70 left-0 top-0 bg-gradient bg-gradient-to-r from-[var(--background)] to-transparent"
/>}
<div
data-custom-library-banner-bottom-gradient
className="w-full z-[3] absolute bottom-[-5rem] h-[5rem] bg-gradient-to-b from-[--background] via-transparent via-100% to-transparent"
/>
<motion.div
initial={{ opacity: 0 }}
animate={{ opacity: 1, y: 0 }}
exit={{ opacity: 0 }}
transition={{ duration: 1, delay: 0.2 }}
className={cn(
"h-[30rem] z-[0] w-full flex-none absolute top-0 overflow-hidden",
"scroll-locked-offset",
)}
data-custom-library-banner-inner-container
>
<div
data-custom-library-banner-top-gradient
className={cn(
"CUSTOM_LIB_BANNER_TOP_FADE w-full absolute z-[2] top-0 h-[5rem] opacity-40 bg-gradient-to-b from-[--background] to-transparent via",
discrete && "opacity-70",
)}
/>
<div
data-custom-library-banner-image
className={cn(
"CUSTOM_LIB_BANNER_IMG z-[1] absolute inset-0 w-full h-full bg-cover bg-no-repeat transition-opacity duration-1000",
"scroll-locked-offset",
)}
style={{
backgroundImage: `url(${image})`,
backgroundPosition: ts.libraryScreenCustomBannerPosition || "50% 50%",
opacity: (ts.libraryScreenCustomBannerOpacity || 100) / 100,
backgroundRepeat: "no-repeat",
backgroundSize: "cover",
}}
/>
<div
data-custom-library-banner-bottom-gradient
className={cn(
"CUSTOM_LIB_BANNER_BOTTOM_FADE w-full z-[2] absolute bottom-0 h-[20rem] bg-gradient-to-t from-[--background] via-opacity-50 via-10% to-transparent via",
discrete && "via-50% via-opacity-100 h-[40rem]",
)}
/>
</motion.div>
</div>
</>
)
}

View File

@@ -0,0 +1,135 @@
import { Anime_LocalFile } from "@/api/generated/types"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
import { LuffyError } from "@/components/shared/luffy-error"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { Drawer } from "@/components/ui/drawer"
import { upath } from "@/lib/helpers/upath"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { TbFileSad } from "react-icons/tb"
import { toast } from "sonner"
export const __ignoredFileManagerIsOpen = atom(false)
type IgnoredFileManagerProps = {
files: Anime_LocalFile[]
}
export function IgnoredFileManager(props: IgnoredFileManagerProps) {
const { files } = props
const [isOpen, setIsOpen] = useAtom(__ignoredFileManagerIsOpen)
const { mutate: openInExplorer } = useOpenInExplorer()
const { mutate: updateLocalFiles, isPending: isUpdating } = useUpdateLocalFiles()
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
React.useLayoutEffect(() => {
setSelectedPaths(files?.map(lf => lf.path) ?? [])
}, [files])
function handleUnIgnoreSelected() {
if (selectedPaths.length > 0) {
updateLocalFiles({
paths: selectedPaths,
action: "unignore",
}, {
onSuccess: () => {
toast.success("Files un-ignored")
},
})
}
}
return (
<Drawer
open={isOpen}
onOpenChange={() => setIsOpen(false)}
// contentClass="max-w-5xl"
size="xl"
title="Ignored files"
>
<AppLayoutStack className="mt-4">
{files.length > 0 && <div className="flex flex-wrap items-center gap-2">
<div className="flex flex-1"></div>
<Button
leftIcon={<TbFileSad className="text-lg" />}
intent="white"
size="sm"
rounded
loading={isUpdating}
onClick={handleUnIgnoreSelected}
>
Un-ignore selection
</Button>
</div>}
{files.length === 0 && <LuffyError title={null}>
No ignored files
</LuffyError>}
{files.length > 0 &&
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[85vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
<div className="p-2">
<Checkbox
label={`Select all files`}
value={(selectedPaths.length === files?.length) ? true : (selectedPaths.length === 0
? false
: "indeterminate")}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (draft.length === files?.length) {
return []
} else {
return files?.map(lf => lf.path) ?? []
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
{files.map((lf, index) => (
<div
key={`${lf.path}-${index}`}
className="p-2 "
>
<div className="flex items-center">
<Checkbox
label={`${upath.basename(lf.path)}`}
value={selectedPaths.includes(lf.path)}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (checked) {
return [...draft, lf.path]
} else {
return draft.filter(p => p !== lf.path)
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
</div>
))}
</div>}
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -0,0 +1,138 @@
import { Anime_LibraryCollectionEntry, Anime_LibraryCollectionList } from "@/api/generated/types"
import { __mainLibrary_paramsAtom } from "@/app/(main)/(library)/_lib/handle-library-collection"
import { MediaCardLazyGrid } from "@/app/(main)/_features/media/_components/media-card-grid"
import { MediaEntryCard } from "@/app/(main)/_features/media/_components/media-entry-card"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { IconButton } from "@/components/ui/button"
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { getLibraryCollectionTitle } from "@/lib/server/utils"
import { useAtom } from "jotai/react"
import React from "react"
import { LuListFilter } from "react-icons/lu"
export function LibraryCollectionLists({ collectionList, isLoading, streamingMediaIds }: {
collectionList: Anime_LibraryCollectionList[],
isLoading: boolean,
streamingMediaIds: number[]
}) {
return (
<PageWrapper
key="library-collection-lists"
className="space-y-8"
data-library-collection-lists
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.25,
},
}}>
{collectionList.map(collection => {
if (!collection.entries?.length) return null
return <LibraryCollectionListItem key={collection.type} list={collection} streamingMediaIds={streamingMediaIds} />
})}
</PageWrapper>
)
}
export function LibraryCollectionFilteredLists({ collectionList, isLoading, streamingMediaIds }: {
collectionList: Anime_LibraryCollectionList[],
isLoading: boolean,
streamingMediaIds: number[]
}) {
// const params = useAtomValue(__mainLibrary_paramsAtom)
return (
<PageWrapper
key="library-filtered-lists"
className="space-y-8"
data-library-filtered-lists
{...{
initial: { opacity: 0, y: 60 },
animate: { opacity: 1, y: 0 },
exit: { opacity: 0, scale: 0.99 },
transition: {
duration: 0.25,
},
}}>
{/*<h3 className="text-center truncate">*/}
{/* {params.genre?.join(", ")}*/}
{/*</h3>*/}
<MediaCardLazyGrid itemCount={collectionList?.flatMap(n => n.entries)?.length ?? 0}>
{collectionList?.flatMap(n => n.entries)?.filter(Boolean)?.map(entry => {
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
})}
</MediaCardLazyGrid>
</PageWrapper>
)
}
export const LibraryCollectionListItem = React.memo(({ list, streamingMediaIds }: {
list: Anime_LibraryCollectionList,
streamingMediaIds: number[]
}) => {
const isCurrentlyWatching = list.type === "CURRENT"
const [params, setParams] = useAtom(__mainLibrary_paramsAtom)
return (
<React.Fragment key={list.type}>
<div className="flex gap-3 items-center" data-library-collection-list-item-header data-list-type={list.type}>
<h2 className="p-0 m-0">{getLibraryCollectionTitle(list.type)}</h2>
<div className="flex flex-1"></div>
{isCurrentlyWatching && <DropdownMenu
trigger={<IconButton
intent="white-basic"
size="xs"
className="mt-1"
icon={<LuListFilter />}
/>}
>
<DropdownMenuItem
onClick={() => {
setParams(draft => {
draft.continueWatchingOnly = !draft.continueWatchingOnly
return
})
}}
>
{params.continueWatchingOnly ? "Show all" : "Show unwatched only"}
</DropdownMenuItem>
</DropdownMenu>}
</div>
<MediaCardLazyGrid
itemCount={list?.entries?.length || 0}
data-library-collection-list-item-media-card-lazy-grid
data-list-type={list.type}
>
{list.entries?.map(entry => {
return <LibraryCollectionEntryItem key={entry.mediaId} entry={entry} streamingMediaIds={streamingMediaIds} />
})}
</MediaCardLazyGrid>
</React.Fragment>
)
})
export const LibraryCollectionEntryItem = React.memo(({ entry, streamingMediaIds }: {
entry: Anime_LibraryCollectionEntry,
streamingMediaIds: number[]
}) => {
return (
<MediaEntryCard
media={entry.media!}
listData={entry.listData}
libraryData={entry.libraryData}
nakamaLibraryData={entry.nakamaLibraryData}
showListDataButton
withAudienceScore={false}
type="anime"
showLibraryBadge={!!streamingMediaIds?.length && !streamingMediaIds.includes(entry.mediaId) && entry.listData?.status === "CURRENT"}
/>
)
})

View File

@@ -0,0 +1,191 @@
"use client"
import { Anime_LibraryCollectionList, Anime_LocalFile, Anime_UnknownGroup } from "@/api/generated/types"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { __bulkAction_modalAtomIsOpen } from "@/app/(main)/(library)/_containers/bulk-action-modal"
import { __ignoredFileManagerIsOpen } from "@/app/(main)/(library)/_containers/ignored-file-manager"
import { PlayRandomEpisodeButton } from "@/app/(main)/(library)/_containers/play-random-episode-button"
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
import { __scanner_modalIsOpen } from "@/app/(main)/(library)/_containers/scanner-modal"
import { __unknownMedia_drawerIsOpen } from "@/app/(main)/(library)/_containers/unknown-media-manager"
import { __unmatchedFileManagerIsOpen } from "@/app/(main)/(library)/_containers/unmatched-file-manager"
import { __library_viewAtom } from "@/app/(main)/(library)/_lib/library-view.atoms"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { SeaLink } from "@/components/shared/sea-link"
import { Button, IconButton } from "@/components/ui/button"
import { cn } from "@/components/ui/core/styling"
import { DropdownMenu, DropdownMenuItem } from "@/components/ui/dropdown-menu"
import { Tooltip } from "@/components/ui/tooltip"
import { ThemeLibraryScreenBannerType, useThemeSettings } from "@/lib/theme/hooks"
import { useAtom, useSetAtom } from "jotai/react"
import React from "react"
import { BiCollection, BiDotsVerticalRounded, BiFolder } from "react-icons/bi"
import { FiSearch } from "react-icons/fi"
import { IoLibrary, IoLibrarySharp } from "react-icons/io5"
import { MdOutlineVideoLibrary } from "react-icons/md"
import { PiClockCounterClockwiseFill } from "react-icons/pi"
import { TbFileSad, TbReload } from "react-icons/tb"
import { PluginAnimeLibraryDropdownItems } from "../../_features/plugin/actions/plugin-actions"
export type LibraryToolbarProps = {
collectionList: Anime_LibraryCollectionList[]
ignoredLocalFiles: Anime_LocalFile[]
unmatchedLocalFiles: Anime_LocalFile[]
unknownGroups: Anime_UnknownGroup[]
isLoading: boolean
hasEntries: boolean
isStreamingOnly: boolean
isNakamaLibrary: boolean
}
export function LibraryToolbar(props: LibraryToolbarProps) {
const {
collectionList,
ignoredLocalFiles,
unmatchedLocalFiles,
unknownGroups,
hasEntries,
isStreamingOnly,
isNakamaLibrary,
} = props
const ts = useThemeSettings()
const setBulkActionIsOpen = useSetAtom(__bulkAction_modalAtomIsOpen)
const status = useServerStatus()
const setScannerModalOpen = useSetAtom(__scanner_modalIsOpen)
const setUnmatchedFileManagerOpen = useSetAtom(__unmatchedFileManagerIsOpen)
const setIgnoredFileManagerOpen = useSetAtom(__ignoredFileManagerIsOpen)
const setUnknownMediaManagerOpen = useSetAtom(__unknownMedia_drawerIsOpen)
const setPlaylistsModalOpen = useSetAtom(__playlists_modalOpenAtom)
const [libraryView, setLibraryView] = useAtom(__library_viewAtom)
const { mutate: openInExplorer } = useOpenInExplorer()
const hasLibraryPath = !!status?.settings?.library?.libraryPath
return (
<>
{(ts.libraryScreenBannerType === ThemeLibraryScreenBannerType.Dynamic && hasEntries) && <div
className={cn(
"h-28",
ts.hideTopNavbar && "h-40",
)}
data-library-toolbar-top-padding
></div>}
<div className="flex flex-wrap w-full justify-end gap-2 p-4 relative z-[10]" data-library-toolbar-container>
<div className="flex flex-1" data-library-toolbar-spacer></div>
{(hasEntries) && (
<>
<Tooltip
trigger={<IconButton
data-library-toolbar-switch-view-button
intent={libraryView === "base" ? "white-subtle" : "white"}
icon={<IoLibrary className="text-2xl" />}
onClick={() => setLibraryView(p => p === "detailed" ? "base" : "detailed")}
/>}
>
Switch view
</Tooltip>
{!(isStreamingOnly || isNakamaLibrary) && <Tooltip
trigger={<IconButton
data-library-toolbar-playlists-button
intent={"white-subtle"}
icon={<MdOutlineVideoLibrary className="text-2xl" />}
onClick={() => setPlaylistsModalOpen(true)}
/>}
>Playlists</Tooltip>}
{!(isStreamingOnly || isNakamaLibrary) && <PlayRandomEpisodeButton />}
{/*Show up even when there's no local entries*/}
{!isNakamaLibrary && hasLibraryPath && <Button
data-library-toolbar-scan-button
intent={hasEntries ? "primary-subtle" : "primary"}
leftIcon={hasEntries ? <TbReload className="text-xl" /> : <FiSearch className="text-xl" />}
onClick={() => setScannerModalOpen(true)}
hideTextOnSmallScreen
>
{hasEntries ? "Refresh library" : "Scan your library"}
</Button>}
</>
)}
{(unmatchedLocalFiles.length > 0) && <Button
data-library-toolbar-unmatched-button
intent="alert"
leftIcon={<IoLibrarySharp />}
className="animate-bounce"
onClick={() => setUnmatchedFileManagerOpen(true)}
>
Resolve unmatched ({unmatchedLocalFiles.length})
</Button>}
{(unknownGroups.length > 0) && <Button
data-library-toolbar-unknown-button
intent="warning"
leftIcon={<IoLibrarySharp />}
className="animate-bounce"
onClick={() => setUnknownMediaManagerOpen(true)}
>
Resolve hidden media ({unknownGroups.length})
</Button>}
{(!isStreamingOnly && !isNakamaLibrary && hasLibraryPath) &&
<DropdownMenu
trigger={<IconButton
data-library-toolbar-dropdown-menu-trigger
icon={<BiDotsVerticalRounded />} intent="gray-basic"
/>}
>
<DropdownMenuItem
data-library-toolbar-open-directory-button
disabled={!hasLibraryPath}
className={cn("cursor-pointer", { "!text-[--muted]": !hasLibraryPath })}
onClick={() => {
openInExplorer({ path: status?.settings?.library?.libraryPath ?? "" })
}}
>
<BiFolder />
<span>Open directory</span>
</DropdownMenuItem>
<DropdownMenuItem
data-library-toolbar-bulk-actions-button
onClick={() => setBulkActionIsOpen(true)}
disabled={!hasEntries}
className={cn({ "!text-[--muted]": !hasEntries })}
>
<BiCollection />
<span>Bulk actions</span>
</DropdownMenuItem>
<DropdownMenuItem
data-library-toolbar-ignored-files-button
onClick={() => setIgnoredFileManagerOpen(true)}
// disabled={!hasEntries}
className={cn({ "!text-[--muted]": !hasEntries })}
>
<TbFileSad />
<span>Ignored files</span>
</DropdownMenuItem>
<SeaLink href="/scan-summaries">
<DropdownMenuItem
data-library-toolbar-scan-summaries-button
// className={cn({ "!text-[--muted]": !hasEntries })}
>
<PiClockCounterClockwiseFill />
<span>Scan summaries</span>
</DropdownMenuItem>
</SeaLink>
<PluginAnimeLibraryDropdownItems />
</DropdownMenu>}
</div>
</>
)
}

View File

@@ -0,0 +1,33 @@
import { usePlaybackPlayRandomVideo } from "@/api/hooks/playback_manager.hooks"
import { IconButton } from "@/components/ui/button"
import { Tooltip } from "@/components/ui/tooltip"
import React from "react"
import { LiaRandomSolid } from "react-icons/lia"
type PlayRandomEpisodeButtonProps = {
children?: React.ReactNode
}
export function PlayRandomEpisodeButton(props: PlayRandomEpisodeButtonProps) {
const {
children,
...rest
} = props
const { mutate: playRandom, isPending } = usePlaybackPlayRandomVideo()
return (
<>
<Tooltip
trigger={<IconButton
data-play-random-episode-button
intent={"white-subtle"}
icon={<LiaRandomSolid className="text-2xl" />}
loading={isPending}
onClick={() => playRandom()}
/>}
>Play random anime</Tooltip>
</>
)
}

View File

@@ -0,0 +1,358 @@
import { AL_BaseAnime, Anime_LibraryCollectionEntry, Anime_LocalFile } from "@/api/generated/types"
import { useGetLocalFiles } from "@/api/hooks/localfiles.hooks"
import { useGetPlaylistEpisodes } from "@/api/hooks/playlist.hooks"
import { animeLibraryCollectionAtom } from "@/app/(main)/_atoms/anime-library-collection.atoms"
import { imageShimmer } from "@/components/shared/image-helpers"
import { Button, IconButton } from "@/components/ui/button"
import { cn } from "@/components/ui/core/styling"
import { Modal } from "@/components/ui/modal"
import { Select } from "@/components/ui/select"
import { TextInput } from "@/components/ui/text-input"
import { useDebounce } from "@/hooks/use-debounce"
import { DndContext, DragEndEvent } from "@dnd-kit/core"
import { restrictToVerticalAxis } from "@dnd-kit/modifiers"
import { arrayMove, SortableContext, useSortable, verticalListSortingStrategy } from "@dnd-kit/sortable"
import { CSS } from "@dnd-kit/utilities"
import { useAtomValue } from "jotai/react"
import Image from "next/image"
import React from "react"
import { BiPlus, BiTrash } from "react-icons/bi"
import { toast } from "sonner"
type PlaylistManagerProps = {
paths: string[]
setPaths: (paths: string[]) => void
}
export function PlaylistManager(props: PlaylistManagerProps) {
const {
paths: controlledPaths,
setPaths: onChange,
...rest
} = props
const libraryCollection = useAtomValue(animeLibraryCollectionAtom)
const { data: localFiles } = useGetLocalFiles()
const firstRender = React.useRef(true)
const [paths, setPaths] = React.useState(controlledPaths)
React.useEffect(() => {
if (firstRender.current) {
firstRender.current = false
return
}
setPaths(controlledPaths)
}, [controlledPaths])
React.useEffect(() => {
onChange(paths)
}, [paths])
const handleDragEnd = React.useCallback((event: DragEndEvent) => {
const { active, over } = event
if (active.id !== over?.id) {
setPaths((items) => {
const oldIndex = items.indexOf(active.id as any)
const newIndex = items.indexOf(over?.id as any)
return arrayMove(items, oldIndex, newIndex)
})
}
}, [])
const [selectedCategory, setSelectedCategory] = React.useState("CURRENT")
const [searchInput, setSearchInput] = React.useState("")
const debouncedSearchInput = useDebounce(searchInput, 500)
const entries = React.useMemo(() => {
if (debouncedSearchInput.length !== 0) return (libraryCollection?.lists
?.filter(n => n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT")
?.flatMap(n => n.entries)
?.filter(Boolean) ?? []).filter(n => n?.media?.title?.english?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()) ||
n?.media?.title?.romaji?.toLowerCase()?.includes(debouncedSearchInput.toLowerCase()))
return libraryCollection?.lists?.filter(n => {
if (selectedCategory === "-") return n.type === "PLANNING" || n.type === "PAUSED" || n.type === "CURRENT"
return n.type === selectedCategory
})
?.flatMap(n => n.entries)
?.filter(n => !!n?.libraryData)
?.filter(Boolean) ?? []
}, [libraryCollection, debouncedSearchInput, selectedCategory])
return (
<div className="space-y-4">
<div className="space-y-2">
<Modal
title="Select an anime"
contentClass="max-w-4xl"
trigger={<Button
leftIcon={<BiPlus className="text-2xl" />}
intent="white"
className="rounded-full"
disabled={paths.length >= 10}
>Add an episode</Button>}
>
<div className="grid grid-cols-[150px,1fr] gap-2">
<Select
value={selectedCategory}
onValueChange={v => setSelectedCategory(v)}
options={[
{ label: "Current", value: "CURRENT" },
{ label: "Paused", value: "PAUSED" },
{ label: "Planning", value: "PLANNING" },
{ label: "All", value: "-" },
]}
disabled={searchInput.length !== 0}
/>
<TextInput
placeholder="Search"
value={searchInput}
onChange={e => setSearchInput(e.target.value)}
/>
</div>
<div className="grid grid-cols-2 md:grid-cols-5 gap-2">
{entries?.map(entry => {
return (
<PlaylistMediaEntry key={entry.mediaId} entry={entry} paths={paths} setPaths={setPaths} />
)
})}
</div>
</Modal>
</div>
<DndContext
modifiers={[restrictToVerticalAxis]}
onDragEnd={handleDragEnd}
>
<SortableContext
strategy={verticalListSortingStrategy}
items={paths}
>
<div className="space-y-2">
<ul className="space-y-2">
{paths.map(path => localFiles?.find(n => n.path === path))?.filter(Boolean).map((lf, index) => (
<SortableItem
key={lf.path}
id={lf.path}
localFile={lf}
media={libraryCollection?.lists?.flatMap(n => n.entries)
?.filter(Boolean)
?.find(n => lf?.mediaId === n.mediaId)?.media}
setPaths={setPaths}
/>
))}
</ul>
</div>
</SortableContext>
</DndContext>
</div>
)
}
type PlaylistMediaEntryProps = {
entry: Anime_LibraryCollectionEntry
paths: string[]
setPaths: React.Dispatch<React.SetStateAction<string[]>>
}
function PlaylistMediaEntry(props: PlaylistMediaEntryProps) {
const { entry, paths, setPaths } = props
return <Modal
title={entry.media?.title?.userPreferred || entry.media?.title?.romaji || ""}
trigger={(
<div
key={entry.mediaId}
className="col-span-1 aspect-[6/7] rounded-[--radius-md] border overflow-hidden relative transition cursor-pointer bg-[var(--background)] md:opacity-60 md:hover:opacity-100"
>
<Image
src={entry.media?.coverImage?.large || entry.media?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover"
/>
<p className="line-clamp-2 text-sm absolute m-2 bottom-0 font-semibold z-[10]">
{entry.media?.title?.userPreferred || entry.media?.title?.romaji}
</p>
<div
className="z-[5] absolute bottom-0 w-full h-[80%] bg-gradient-to-t from-[--background] to-transparent"
/>
</div>
)}
>
<EntryEpisodeList
selectedPaths={paths}
setSelectedPaths={setPaths}
entry={entry}
/>
</Modal>
}
function SortableItem({ localFile, id, media, setPaths }: {
id: string,
localFile: Anime_LocalFile | undefined,
media: AL_BaseAnime | undefined,
setPaths: any
}) {
const {
attributes,
listeners,
setNodeRef,
transform,
transition,
} = useSortable({ id: id })
const style = {
transform: CSS.Transform.toString(transform),
transition,
}
if (!localFile) return null
if (!media) return (
<li ref={setNodeRef} style={style}>
<div
className="px-2.5 py-2 bg-[var(--background)] border-[--red] rounded-[--radius-md] border flex gap-3 relative"
>
<IconButton
className="absolute top-2 right-2 rounded-full"
icon={<BiTrash />}
intent="alert-subtle"
size="sm"
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
/>
<div
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
{...attributes} {...listeners}
/>
<div>
<p className="text-lg text-white font-semibold">
<span>
???
</span>
<span className="text-gray-400 font-medium max-w-lg truncate">
</span>
</p>
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
</div>
</div>
</li>
)
return (
<li ref={setNodeRef} style={style}>
<div
className="px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border flex gap-3 relative"
>
<IconButton
className="absolute top-2 right-2 rounded-full"
icon={<BiTrash />}
intent="alert-subtle"
size="sm"
onClick={() => setPaths((prev: string[]) => prev.filter(n => n !== id))}
/>
<div
className="rounded-full w-4 h-auto bg-[--muted] md:bg-[--subtle] md:hover:bg-[--subtle-highlight] cursor-move"
{...attributes} {...listeners}
/>
<div
className="w-16 aspect-square rounded-[--radius-md] border overflow-hidden relative transition bg-[var(--background)]"
>
<Image
src={media?.coverImage?.large || media?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover"
/>
</div>
<div>
<div className="text-lg text-white font-semibold flex gap-1">
{localFile.metadata && <p>
{media?.format !== "MOVIE" ? `Episode ${localFile.metadata?.episode}` : "Movie"}
</p>}
<p className="max-w-full truncate text-gray-400 font-medium max-w-lg truncate">
{" - "}{media?.title?.userPreferred || media?.title?.romaji}
</p>
</div>
<p className="text-sm text-[--muted] font-normal italic line-clamp-1">{localFile.name}</p>
</div>
</div>
</li>
)
}
type EntryEpisodeListProps = {
entry: Anime_LibraryCollectionEntry
selectedPaths: string[]
setSelectedPaths: React.Dispatch<React.SetStateAction<string[]>>
}
function EntryEpisodeList(props: EntryEpisodeListProps) {
const {
entry,
selectedPaths,
setSelectedPaths,
...rest
} = props
const { data } = useGetPlaylistEpisodes(entry.mediaId, entry.listData?.progress || 0)
const handleSelect = (value: string) => {
if (selectedPaths.length <= 10) {
setSelectedPaths(prev => {
if (prev.includes(value)) {
return prev.filter(n => n !== value)
}
return [...prev, value]
})
} else {
toast.error("You can't add more than 10 episodes to a playlist")
}
}
return (
<div className="flex flex-col gap-2 overflow-auto p-1">
{data?.filter(n => !!n.metadata)?.sort((a, b) => a.metadata!.episode - b.metadata!.episode)?.map(lf => {
return (
<div
key={lf.path}
className={cn(
"px-2.5 py-2 bg-[var(--background)] rounded-[--radius-md] border cursor-pointer opacity-80 max-w-full",
selectedPaths.includes(lf.path) ? "bg-gray-800 opacity-100 text-white ring-1 ring-[--zinc]" : "hover:bg-[--subtle]",
"transition",
)}
onClick={() => handleSelect(lf.path)}
>
<p className="">{entry.media?.format !== "MOVIE" ? `Episode ${lf.metadata!.episode}` : "Movie"}</p>
<p className="text-sm text-[--muted] font-normal italic max-w-lg line-clamp-1">{lf.name}</p>
</div>
)
})}
</div>
)
}

View File

@@ -0,0 +1,109 @@
import { Anime_Playlist } from "@/api/generated/types"
import { useCreatePlaylist, useDeletePlaylist, useUpdatePlaylist } from "@/api/hooks/playlist.hooks"
import { PlaylistManager } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-manager"
import { Button } from "@/components/ui/button"
import { DangerZone } from "@/components/ui/form"
import { Modal } from "@/components/ui/modal"
import { Separator } from "@/components/ui/separator"
import { TextInput } from "@/components/ui/text-input"
import React from "react"
import { toast } from "sonner"
type PlaylistModalProps = {
playlist?: Anime_Playlist
trigger: React.ReactElement
}
export function PlaylistModal(props: PlaylistModalProps) {
const {
playlist,
trigger,
} = props
const [isOpen, setIsOpen] = React.useState(false)
const [name, setName] = React.useState(playlist?.name ?? "")
const [paths, setPaths] = React.useState<string[]>(playlist?.localFiles?.map(l => l.path) ?? [])
const isUpdate = !!playlist
const { mutate: createPlaylist, isPending: isCreating } = useCreatePlaylist()
const { mutate: deletePlaylist, isPending: isDeleting } = useDeletePlaylist()
const { mutate: updatePlaylist, isPending: isUpdating } = useUpdatePlaylist()
function reset() {
setName("")
setPaths([])
}
React.useEffect(() => {
if (isUpdate && !!playlist && !!playlist.localFiles) {
setName(playlist.name)
setPaths(playlist.localFiles.map(l => l.path))
}
}, [playlist, isOpen])
function handleSubmit() {
if (name.length === 0) {
toast.error("Please enter a name for the playlist")
return
}
if (isUpdate && !!playlist) {
updatePlaylist({ dbId: playlist.dbId, name, paths })
} else {
setIsOpen(false)
createPlaylist({ name, paths }, {
onSuccess: () => {
reset()
},
})
}
}
return (
<Modal
title={isUpdate ? "Edit playlist" : "Create a playlist"}
trigger={trigger}
open={isOpen}
onOpenChange={v => setIsOpen(v)}
contentClass="max-w-4xl"
>
<div className="space-y-4">
<div className="space-y-4">
<TextInput
label="Name"
value={name}
onChange={e => setName(e.target.value)}
/>
<Separator />
<PlaylistManager
paths={paths}
setPaths={setPaths}
/>
<div className="">
<Button disabled={paths.length === 0} onClick={handleSubmit} loading={isCreating || isDeleting || isUpdating}>
{isUpdate ? "Update" : "Create"}
</Button>
</div>
</div>
{isUpdate && <DangerZone
actionText="Delete playlist" onDelete={() => {
if (isUpdate && !!playlist) {
deletePlaylist({ dbId: playlist.dbId }, {
onSuccess: () => {
setIsOpen(false)
},
})
}
}}
/>}
</div>
</Modal>
)
}

View File

@@ -0,0 +1,123 @@
import { useGetPlaylists } from "@/api/hooks/playlist.hooks"
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
import { StartPlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/start-playlist-modal"
import { __playlists_modalOpenAtom } from "@/app/(main)/(library)/_containers/playlists/playlists-modal"
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
import { MediaCardBodyBottomGradient } from "@/app/(main)/_features/custom-ui/item-bottom-gradients"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { imageShimmer } from "@/components/shared/image-helpers"
import { Button } from "@/components/ui/button"
import { Carousel, CarouselContent, CarouselDotButtons, CarouselItem } from "@/components/ui/carousel"
import { cn } from "@/components/ui/core/styling"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { useAtomValue, useSetAtom } from "jotai/react"
import Image from "next/image"
import React from "react"
import { BiEditAlt } from "react-icons/bi"
import { FaCirclePlay } from "react-icons/fa6"
import { MdOutlineVideoLibrary } from "react-icons/md"
type PlaylistsListProps = {}
export function PlaylistsList(props: PlaylistsListProps) {
const {} = props
const { data: playlists, isLoading } = useGetPlaylists()
const userMedia = useAtomValue(__anilist_userAnimeMediaAtom)
const serverStatus = useServerStatus()
const setOpen = useSetAtom(__playlists_modalOpenAtom)
const handlePlaylistLoaded = React.useCallback(() => {
setOpen(false)
}, [])
if (isLoading) return <LoadingSpinner />
if (!playlists?.length) {
return (
<div className="text-center text-[--muted] space-y-1">
<MdOutlineVideoLibrary className="mx-auto text-5xl text-[--muted]" />
<div>
No playlists
</div>
</div>
)
}
return (
// <div className="grid grid-cols-2 md:grid-cols-4 lg:grid-cols-5 xl:grid-cols-6 gap-4">
<Carousel
className="w-full max-w-full"
gap="none"
opts={{
align: "start",
}}
>
<CarouselDotButtons />
<CarouselContent>
{playlists.map(p => {
const mainMedia = userMedia?.find(m => m.id === p.localFiles?.[0]?.mediaId)
return (
<CarouselItem
key={p.dbId}
className={cn(
"md:basis-1/3 lg:basis-1/4 2xl:basis-1/6 min-[2000px]:basis-1/6",
"aspect-[7/6] p-2",
)}
// onClick={() => handleSelect(lf.path)}
>
<div className="group/playlist-item flex gap-3 h-full justify-between items-center bg-gray-950 rounded-[--radius-md] transition relative overflow-hidden">
{(mainMedia?.coverImage?.large || mainMedia?.bannerImage) && <Image
src={mainMedia?.coverImage?.extraLarge || mainMedia?.bannerImage || ""}
placeholder={imageShimmer(700, 475)}
sizes="10rem"
fill
alt=""
className="object-center object-cover z-[1]"
/>}
<div className="absolute inset-0 z-[2] bg-gray-900 opacity-50 hover:opacity-70 transition-opacity flex items-center justify-center" />
<div className="absolute inset-0 z-[6] flex items-center justify-center">
<StartPlaylistModal
canStart={serverStatus?.settings?.library?.autoUpdateProgress}
playlist={p}
onPlaylistLoaded={handlePlaylistLoaded}
trigger={
<FaCirclePlay className="block text-5xl cursor-pointer opacity-50 hover:opacity-100 transition-opacity" />}
/>
</div>
<div className="absolute top-2 right-2 z-[6] flex items-center justify-center">
<PlaylistModal
trigger={<Button
className="w-full flex-none rounded-full"
leftIcon={<BiEditAlt />}
intent="white-subtle"
size="sm"
>Edit</Button>} playlist={p}
/>
</div>
<div className="absolute w-full bottom-0 h-fit z-[6]">
<div className="space-y-0 pb-3 items-center">
<p className="text-md font-bold text-white max-w-lg truncate text-center">{p.name}</p>
{p.localFiles &&
<p className="text-sm text-[--muted] font-normal line-clamp-1 text-center">{p.localFiles.length} episode{p.localFiles.length > 1
? `s`
: ""}</p>}
</div>
</div>
<MediaCardBodyBottomGradient />
</div>
</CarouselItem>
)
})}
</CarouselContent>
</Carousel>
)
}

View File

@@ -0,0 +1,60 @@
import { Anime_Playlist } from "@/api/generated/types"
import { usePlaybackStartPlaylist } from "@/api/hooks/playback_manager.hooks"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import React from "react"
import { FaPlay } from "react-icons/fa"
type StartPlaylistModalProps = {
trigger?: React.ReactElement
playlist: Anime_Playlist
canStart?: boolean
onPlaylistLoaded: () => void
}
export function StartPlaylistModal(props: StartPlaylistModalProps) {
const {
trigger,
playlist,
canStart,
onPlaylistLoaded,
...rest
} = props
const { mutate: startPlaylist, isPending } = usePlaybackStartPlaylist({
onSuccess: onPlaylistLoaded,
})
if (!playlist?.localFiles?.length) return null
return (
<Modal
title="Start playlist"
titleClass="text-center"
trigger={trigger}
>
<p className="text-center">
You are about to start the playlist <strong>"{playlist.name}"</strong>,
which contains {playlist.localFiles.length} episode{playlist.localFiles.length > 1 ? "s" : ""}.
</p>
<p className="text-[--muted] text-center">
Reminder: The playlist will be deleted once you start it, whether you finish it or not.
</p>
{!canStart && (
<p className="text-orange-300 text-center">
Please enable "Automatically update progress" to start
</p>
)}
<Button
className="w-full flex-none"
leftIcon={<FaPlay />}
intent="primary"
size="lg"
loading={isPending}
disabled={!canStart}
onClick={() => startPlaylist({ dbId: playlist.dbId })}
>Start</Button>
</Modal>
)
}

View File

@@ -0,0 +1,75 @@
import { PlaylistModal } from "@/app/(main)/(library)/_containers/playlists/_components/playlist-modal"
import { PlaylistsList } from "@/app/(main)/(library)/_containers/playlists/_components/playlists-list"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { Alert } from "@/components/ui/alert"
import { Button } from "@/components/ui/button"
import { Drawer } from "@/components/ui/drawer"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
type PlaylistsModalProps = {}
export const __playlists_modalOpenAtom = atom(false)
export function PlaylistsModal(props: PlaylistsModalProps) {
const {} = props
const serverStatus = useServerStatus()
const [isOpen, setIsOpen] = useAtom(__playlists_modalOpenAtom)
return (
<>
<Drawer
open={isOpen}
onOpenChange={v => setIsOpen(v)}
size="lg"
side="bottom"
contentClass=""
>
{/*<div*/}
{/* className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[5rem] absolute opacity-30 top-0 left-0 bg-no-repeat bg-right bg-cover"*/}
{/*>*/}
{/* <div*/}
{/* className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"*/}
{/* />*/}
{/*</div>*/}
<div className="space-y-6">
<div className="flex flex-col md:flex-row justify-between items-center gap-4">
<div>
<h4>Playlists</h4>
<p className="text-[--muted] text-sm">
Playlists only work with system media players and local files.
</p>
</div>
<div className="flex gap-2 items-center md:pr-8">
<PlaylistModal
trigger={
<Button intent="white" className="rounded-full">
Add a playlist
</Button>
}
/>
</div>
</div>
{!serverStatus?.settings?.library?.autoUpdateProgress && <Alert
className="max-w-2xl mx-auto"
intent="warning"
description={<>
<p>
You need to enable "Automatically update progress" to use playlists.
</p>
</>}
/>}
<div className="">
<PlaylistsList />
</div>
</div>
</Drawer>
</>
)
}

View File

@@ -0,0 +1,70 @@
"use client"
import { __scanner_isScanningAtom } from "@/app/(main)/(library)/_containers/scanner-modal"
import { useWebsocketMessageListener } from "@/app/(main)/_hooks/handle-websockets"
import { PageWrapper } from "@/components/shared/page-wrapper"
import { Card, CardDescription, CardHeader } from "@/components/ui/card"
import { Spinner } from "@/components/ui/loading-spinner"
import { ProgressBar } from "@/components/ui/progress-bar"
import { WSEvents } from "@/lib/server/ws-events"
import { useAtom } from "jotai/react"
import React, { useState } from "react"
export function ScanProgressBar() {
const [isScanning] = useAtom(__scanner_isScanningAtom)
const [progress, setProgress] = useState(0)
const [status, setStatus] = useState("Scanning...")
React.useEffect(() => {
if (!isScanning) {
setProgress(0)
setStatus("Scanning...")
}
}, [isScanning])
useWebsocketMessageListener<number>({
type: WSEvents.SCAN_PROGRESS,
onMessage: data => {
console.log("Scan progress", data)
setProgress(data)
},
})
useWebsocketMessageListener<string>({
type: WSEvents.SCAN_STATUS,
onMessage: data => {
console.log("Scan status", data)
setStatus(data)
},
})
if (!isScanning) return null
return (
<>
<div className="w-full bg-gray-950 fixed top-0 left-0 z-[100]" data-scan-progress-bar-container>
<ProgressBar size="xs" value={progress} />
</div>
{/*<div className="fixed left-0 top-8 w-full flex justify-center z-[100]">*/}
{/* <div className="bg-gray-900 rounded-full border h-14 px-6 flex gap-2 items-center">*/}
{/* <Spinner className="w-4 h-4" />*/}
{/* <p>{progress}% - {status}</p>*/}
{/* </div>*/}
{/*</div>*/}
<div className="z-50 fixed bottom-4 right-4" data-scan-progress-bar-card-container>
<PageWrapper>
<Card className="w-fit max-w-[400px] relative" data-scan-progress-bar-card>
<CardHeader>
<CardDescription className="flex items-center gap-2 text-base text-[--foregorund]">
<Spinner className="size-6" /> {progress}% - {status}
</CardDescription>
</CardHeader>
</Card>
</PageWrapper>
</div>
</>
)
}

View File

@@ -0,0 +1,181 @@
import { useScanLocalFiles } from "@/api/hooks/scan.hooks"
import { __anilist_userAnimeMediaAtom } from "@/app/(main)/_atoms/anilist.atoms"
import { useSeaCommandInject } from "@/app/(main)/_features/sea-command/use-inject"
import { useServerStatus } from "@/app/(main)/_hooks/use-server-status"
import { GlowingEffect } from "@/components/shared/glowing-effect"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Modal } from "@/components/ui/modal"
import { Separator } from "@/components/ui/separator"
import { Switch } from "@/components/ui/switch"
import { useBoolean } from "@/hooks/use-disclosure"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React from "react"
import { FiSearch } from "react-icons/fi"
export const __scanner_modalIsOpen = atom(false)
export const __scanner_isScanningAtom = atom(false)
export function ScannerModal() {
const serverStatus = useServerStatus()
const [isOpen, setOpen] = useAtom(__scanner_modalIsOpen)
const [, setScannerIsScanning] = useAtom(__scanner_isScanningAtom)
const [userMedia] = useAtom(__anilist_userAnimeMediaAtom)
const anilistDataOnly = useBoolean(true)
const skipLockedFiles = useBoolean(true)
const skipIgnoredFiles = useBoolean(true)
const { mutate: scanLibrary, isPending: isScanning } = useScanLocalFiles(() => {
setOpen(false)
})
React.useEffect(() => {
if (!userMedia?.length) anilistDataOnly.off()
else anilistDataOnly.on()
}, [userMedia])
React.useEffect(() => {
setScannerIsScanning(isScanning)
}, [isScanning])
function handleScan() {
scanLibrary({
enhanced: !anilistDataOnly.active,
skipLockedFiles: skipLockedFiles.active,
skipIgnoredFiles: skipIgnoredFiles.active,
})
setOpen(false)
}
const { inject, remove } = useSeaCommandInject()
React.useEffect(() => {
inject("scanner-controls", {
priority: 1,
items: [{
id: "refresh",
value: "refresh",
heading: "Library",
render: () => (
<p>Refresh library</p>
),
onSelect: ({ ctx }) => {
ctx.close()
setTimeout(() => {
handleScan()
}, 500)
},
showBasedOnInput: "startsWith",
}],
filter: ({ item, input }) => {
if (!input) return true
return item.value.toLowerCase().includes(input.toLowerCase())
},
shouldShow: ({ ctx }) => ctx.router.pathname === "/",
showBasedOnInput: "startsWith",
})
return () => remove("scanner-controls")
}, [])
return (
<>
<Modal
data-scanner-modal
open={isOpen}
onOpenChange={o => {
// if (!isScanning) {
// setOpen(o)
// }
setOpen(o)
}}
// title="Library scanner"
titleClass="text-center"
contentClass="space-y-4 max-w-2xl bg-gray-950 bg-opacity-70 backdrop-blur-sm firefox:bg-opacity-100 firefox:backdrop-blur-none rounded-xl"
overlayClass="bg-gray-950/70 backdrop-blur-sm"
>
<GlowingEffect
spread={50}
glow={true}
disabled={false}
proximity={100}
inactiveZone={0.01}
// movementDuration={4}
className="!mt-0 opacity-30"
/>
{/* <div
data-scanner-modal-top-pattern
className="!mt-0 bg-[url(/pattern-2.svg)] z-[-1] w-full h-[4rem] absolute opacity-40 top-0 left-0 bg-no-repeat bg-right bg-cover"
>
<div
className="w-full absolute top-0 h-full bg-gradient-to-t from-[--background] to-transparent z-[-2]"
/>
</div> */}
{serverStatus?.user?.isSimulated && <div className="border border-dashed rounded-md py-2 px-4 !mt-5">
Using this feature without an AniList account is not recommended if you have a large library, as it may lead to rate limits and
slower scanning. Please consider using an account for a better experience.
</div>}
<div className="space-y-4" data-scanner-modal-content>
<AppLayoutStack className="space-y-2">
<h5 className="text-[--muted]">Local files</h5>
<Switch
side="right"
label="Skip locked files"
value={skipLockedFiles.active}
onValueChange={v => skipLockedFiles.set(v as boolean)}
// size="lg"
/>
<Switch
side="right"
label="Skip ignored files"
value={skipIgnoredFiles.active}
onValueChange={v => skipIgnoredFiles.set(v as boolean)}
// size="lg"
/>
<Separator />
<AppLayoutStack className="space-y-2">
<h5 className="text-[--muted]">Matching data</h5>
<Switch
side="right"
label="Use my AniList lists only"
moreHelp="Disabling this will cause Seanime to send more API requests which may lead to rate limits and slower scanning"
// label="Enhanced scanning"
value={anilistDataOnly.active}
onValueChange={v => anilistDataOnly.set(v as boolean)}
// className="data-[state=checked]:bg-amber-700 dark:data-[state=checked]:bg-amber-700"
// size="lg"
help={!anilistDataOnly.active
? <span><span className="text-[--orange]">Slower for large libraries</span>. For faster scanning, add the anime
entries present in your library to your
lists and re-enable this before
scanning.</span>
: ""}
disabled={!userMedia?.length}
/>
</AppLayoutStack>
</AppLayoutStack>
</div>
<Button
onClick={handleScan}
intent="primary"
leftIcon={<FiSearch />}
loading={isScanning}
className="w-full"
disabled={!serverStatus?.settings?.library?.libraryPath}
>
Scan
</Button>
</Modal>
</>
)
}

View File

@@ -0,0 +1,148 @@
import { Anime_UnknownGroup } from "@/api/generated/types"
import { useAddUnknownMedia } from "@/api/hooks/anime_collection.hooks"
import { useAnimeEntryBulkAction } from "@/api/hooks/anime_entries.hooks"
import { SeaLink } from "@/components/shared/sea-link"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Drawer } from "@/components/ui/drawer"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import React, { useCallback } from "react"
import { BiLinkExternal, BiPlus } from "react-icons/bi"
import { TbDatabasePlus } from "react-icons/tb"
import { toast } from "sonner"
export const __unknownMedia_drawerIsOpen = atom(false)
type UnknownMediaManagerProps = {
unknownGroups: Anime_UnknownGroup[]
}
export function UnknownMediaManager(props: UnknownMediaManagerProps) {
const { unknownGroups } = props
const [isOpen, setIsOpen] = useAtom(__unknownMedia_drawerIsOpen)
const { mutate: addUnknownMedia, isPending: isAdding } = useAddUnknownMedia()
const { mutate: performBulkAction, isPending: isUnmatching } = useAnimeEntryBulkAction()
/**
* Add all unknown media to AniList
*/
const handleAddUnknownMedia = useCallback(() => {
addUnknownMedia({ mediaIds: unknownGroups.map(n => n.mediaId) })
}, [unknownGroups])
/**
* Close the drawer if there are no unknown groups
*/
React.useEffect(() => {
if (unknownGroups.length === 0) {
setIsOpen(false)
}
}, [unknownGroups])
/**
* Unmatch all files for a media
*/
const handleUnmatchMedia = useCallback((mediaId: number) => {
performBulkAction({
mediaId,
action: "unmatch",
}, {
onSuccess: () => {
toast.success("Media unmatched")
},
})
}, [])
if (unknownGroups.length === 0) return null
return (
<Drawer
data-unknown-media-manager-drawer
open={isOpen}
onOpenChange={o => {
if (!isAdding) {
setIsOpen(o)
}
}}
size="xl"
title="Resolve hidden media"
>
<AppLayoutStack className="mt-4">
<p className="">
Seanime matched {unknownGroups.length} group{unknownGroups.length === 1 ? "" : "s"} to media that {unknownGroups.length === 1
? "is"
: "are"} absent from your
AniList collection.<br />
Add the media to be able to see the entry in your library or unmatch them if incorrect.
</p>
<Button
leftIcon={<TbDatabasePlus />}
onClick={handleAddUnknownMedia}
loading={isAdding}
disabled={isUnmatching}
>
Add all to AniList
</Button>
<div className="divide divide-y divide-[--border] space-y-4">
{unknownGroups.map(group => {
return (
<div key={group.mediaId} className="pt-4 space-y-2">
<div className="flex items-center w-full justify-between">
<h4 className="font-semibold flex gap-2 items-center">
<span>Anilist ID:{" "}</span>
<SeaLink
href={`https://anilist.co/anime/${group.mediaId}`}
target="_blank"
className="underline text-brand-200 flex gap-1.5 items-center"
>
{group.mediaId} <BiLinkExternal />
</SeaLink>
</h4>
<div className="flex gap-2 items-center">
<Button
size="sm"
intent="primary-subtle"
disabled={isAdding}
onClick={() => addUnknownMedia({ mediaIds: [group.mediaId] })}
leftIcon={<BiPlus />}
>
Add to AniList
</Button>
<Button
size="sm"
intent="warning-subtle"
disabled={isUnmatching}
onClick={() => handleUnmatchMedia(group.mediaId)}
>
Unmatch
</Button>
</div>
</div>
<div className="bg-gray-900 border p-2 px-2 rounded-[--radius-md] space-y-1 max-h-40 max-w-full overflow-x-auto overflow-y-auto text-sm">
{group.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
.map(lf => {
return <p key={lf.path} className="text-[--muted] line-clamp-1 tracking-wide">
{lf.parsedInfo?.original || lf.path}
</p>
})}
</div>
</div>
)
})}
</div>
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -0,0 +1,374 @@
import { Anime_UnmatchedGroup } from "@/api/generated/types"
import { useAnimeEntryManualMatch, useFetchAnimeEntrySuggestions } from "@/api/hooks/anime_entries.hooks"
import { useOpenInExplorer } from "@/api/hooks/explorer.hooks"
import { useUpdateLocalFiles } from "@/api/hooks/localfiles.hooks"
import { SeaLink } from "@/components/shared/sea-link"
import { AppLayoutStack } from "@/components/ui/app-layout"
import { Button } from "@/components/ui/button"
import { Checkbox } from "@/components/ui/checkbox"
import { cn } from "@/components/ui/core/styling"
import { Drawer } from "@/components/ui/drawer"
import { LoadingSpinner } from "@/components/ui/loading-spinner"
import { NumberInput } from "@/components/ui/number-input"
import { RadioGroup } from "@/components/ui/radio-group"
import { upath } from "@/lib/helpers/upath"
import { atom } from "jotai"
import { useAtom } from "jotai/react"
import Image from "next/image"
import React from "react"
import { FaArrowLeft, FaArrowRight } from "react-icons/fa"
import { FcFolder } from "react-icons/fc"
import { FiSearch } from "react-icons/fi"
import { TbFileSad } from "react-icons/tb"
import { toast } from "sonner"
export const __unmatchedFileManagerIsOpen = atom(false)
type UnmatchedFileManagerProps = {
unmatchedGroups: Anime_UnmatchedGroup[]
}
export function UnmatchedFileManager(props: UnmatchedFileManagerProps) {
const { unmatchedGroups } = props
const [isOpen, setIsOpen] = useAtom(__unmatchedFileManagerIsOpen)
const [page, setPage] = React.useState(0)
const maxPage = unmatchedGroups.length - 1
const [currentGroup, setCurrentGroup] = React.useState(unmatchedGroups?.[0])
const [selectedPaths, setSelectedPaths] = React.useState<string[]>([])
const [anilistId, setAnilistId] = React.useState(0)
const { mutate: openInExplorer } = useOpenInExplorer()
const {
data: suggestions,
mutate: fetchSuggestions,
isPending: suggestionsLoading,
reset: resetSuggestions,
} = useFetchAnimeEntrySuggestions()
const { mutate: updateLocalFiles, isPending: isUpdatingFile } = useUpdateLocalFiles()
const { mutate: manualMatch, isPending: isMatching } = useAnimeEntryManualMatch()
const isUpdating = isUpdatingFile || isMatching
const [_r, setR] = React.useState(0)
const handleSelectAnime = React.useCallback((value: string | null) => {
if (value && !isNaN(Number(value))) {
setAnilistId(Number(value))
setR(r => r + 1)
}
}, [])
// Reset the selected paths when the current group changes
React.useLayoutEffect(() => {
setSelectedPaths(currentGroup?.localFiles?.map(lf => lf.path) ?? [])
}, [currentGroup])
// Reset the current group and page when the drawer is opened
React.useEffect(() => {
setPage(0)
setCurrentGroup(unmatchedGroups[0])
}, [isOpen, unmatchedGroups])
// Set the current group when the page changes
React.useEffect(() => {
setCurrentGroup(unmatchedGroups[page])
setAnilistId(0)
resetSuggestions()
}, [page, unmatchedGroups])
const AnilistIdInput = React.useCallback(() => {
return <NumberInput
value={anilistId}
onValueChange={v => setAnilistId(v)}
formatOptions={{
useGrouping: false,
}}
/>
}, [currentGroup?.dir, _r])
function onActionSuccess() {
if (page === 0 && unmatchedGroups.length === 1) {
setIsOpen(false)
}
setAnilistId(0)
resetSuggestions()
setPage(0)
setCurrentGroup(unmatchedGroups[0])
}
/**
* Manually match the current group with the specified Anilist ID.
* If the current group is the last group and there are no more unmatched groups, close the drawer.
*/
function handleMatchSelected() {
if (!!currentGroup && anilistId > 0 && selectedPaths.length > 0) {
manualMatch({
paths: selectedPaths,
mediaId: anilistId,
}, {
onSuccess: () => {
onActionSuccess()
},
})
}
}
const handleFetchSuggestions = React.useCallback(() => {
fetchSuggestions({
dir: currentGroup.dir,
})
}, [currentGroup?.dir, fetchSuggestions])
function handleIgnoreSelected() {
if (selectedPaths.length > 0) {
updateLocalFiles({
paths: selectedPaths,
action: "ignore",
}, {
onSuccess: () => {
onActionSuccess()
toast.success("Files ignored")
},
})
}
}
React.useEffect(() => {
if (!currentGroup) {
setIsOpen(false)
}
}, [currentGroup])
if (!currentGroup) return null
return (
<Drawer
data-unmatched-file-manager-drawer
open={isOpen}
onOpenChange={() => setIsOpen(false)}
// contentClass="max-w-5xl"
size="xl"
title="Unmatched files"
>
<AppLayoutStack className="mt-4">
<div className={cn("flex w-full justify-between", { "hidden": unmatchedGroups.length <= 1 })}>
<Button
intent="gray-subtle"
leftIcon={<FaArrowLeft />}
disabled={page === 0}
onClick={() => {
setPage(p => p - 1)
}}
className={cn("transition-opacity", { "opacity-0": page === 0 })}
>Previous</Button>
<p>
{page + 1} / {maxPage + 1}
</p>
<Button
intent="gray-subtle"
rightIcon={<FaArrowRight />}
disabled={page >= maxPage}
onClick={() => {
setPage(p => p + 1)
}}
className={cn("transition-opacity", { "opacity-0": page >= maxPage })}
>Next</Button>
</div>
<div
className="bg-gray-900 border p-2 px-4 rounded-[--radius-md] line-clamp-1 flex gap-2 items-center cursor-pointer transition hover:bg-opacity-80"
onClick={() => openInExplorer({
path: currentGroup.dir,
})}
>
<FcFolder className="text-2xl" />
{currentGroup.dir}
</div>
<div className="flex items-center flex-wrap gap-2">
<div className="flex gap-2 items-center w-full">
<p className="flex-none text-lg mr-2 font-semibold">Anilist ID</p>
<AnilistIdInput />
<Button
intent="white"
onClick={handleMatchSelected}
disabled={isUpdating}
>Match selection</Button>
</div>
{/*<div className="flex flex-1">*/}
{/*</div>*/}
</div>
<div className="bg-gray-950 border p-2 px-2 divide-y divide-[--border] rounded-[--radius-md] max-h-[50vh] max-w-full overflow-x-auto overflow-y-auto text-sm">
<div className="p-2">
<Checkbox
label={`Select all files`}
value={(selectedPaths.length === currentGroup?.localFiles?.length) ? true : (selectedPaths.length === 0
? false
: "indeterminate")}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (draft.length === currentGroup?.localFiles?.length) {
return []
} else {
return currentGroup?.localFiles?.map(lf => lf.path) ?? []
}
})
}
}}
fieldClass="w-[fit-content]"
/>
</div>
{currentGroup.localFiles?.sort((a, b) => ((Number(a.parsedInfo?.episode ?? 0)) - (Number(b.parsedInfo?.episode ?? 0))))
.map((lf, index) => (
<div
key={`${lf.path}-${index}`}
className="p-2 "
>
<div className="flex items-center">
<Checkbox
label={`${upath.basename(lf.path)}`}
value={selectedPaths.includes(lf.path)}
onValueChange={checked => {
if (typeof checked === "boolean") {
setSelectedPaths(draft => {
if (checked) {
return [...draft, lf.path]
} else {
return draft.filter(p => p !== lf.path)
}
})
}
}}
labelClass="text-sm tracking-wide data-[checked=false]:opacity-50"
fieldClass="w-[fit-content]"
/>
</div>
</div>
))}
</div>
{/*<Separator />*/}
{/*<Separator />*/}
<div className="flex flex-wrap items-center gap-1">
<Button
leftIcon={<FiSearch />}
intent="primary-subtle"
onClick={handleFetchSuggestions}
>
Fetch suggestions
</Button>
<SeaLink
target="_blank"
href={`https://anilist.co/search/anime?search=${encodeURIComponent(currentGroup?.localFiles?.[0]?.parsedInfo?.title || currentGroup?.localFiles?.[0]?.parsedFolderInfo?.[0]?.title || "")}`}
>
<Button
intent="white-link"
>
Search on AniList
</Button>
</SeaLink>
<div className="flex flex-1"></div>
<Button
leftIcon={<TbFileSad className="text-lg" />}
intent="warning-subtle"
size="sm"
rounded
disabled={isUpdating}
onClick={handleIgnoreSelected}
>
Ignore selection
</Button>
</div>
{suggestionsLoading && <LoadingSpinner />}
{(!suggestionsLoading && !!suggestions?.length) && <RadioGroup
defaultValue="1"
fieldClass="w-full"
fieldLabelClass="text-md"
label="Select Anime"
value={String(anilistId)}
onValueChange={handleSelectAnime}
options={suggestions?.map((media) => (
{
label: <div>
<p className="text-base md:text-md font-medium !-mt-1.5 line-clamp-1">{media.title?.userPreferred || media.title?.english || media.title?.romaji || "N/A"}</p>
<div className="mt-2 flex w-full gap-4">
{media.coverImage?.medium && <div
className="h-28 w-28 flex-none rounded-[--radius-md] object-cover object-center relative overflow-hidden"
>
<Image
src={media.coverImage.medium}
alt={""}
fill
quality={100}
priority
sizes="10rem"
className="object-cover object-center"
/>
</div>}
<div className="text-[--muted]">
<p>Type: <span
className="text-gray-200 font-semibold"
>{media.format}</span>
</p>
<p>Aired: {media.startDate?.year ? new Intl.DateTimeFormat("en-US", {
year: "numeric",
}).format(new Date(media.startDate?.year || 0, media.startDate?.month || 0)) : "-"}</p>
<p>Status: {media.status}</p>
<SeaLink href={`https://anilist.co/anime/${media.id}`} target="_blank">
<Button
intent="primary-link"
size="sm"
className="px-0"
>Open on AniList</Button>
</SeaLink>
</div>
</div>
</div>,
value: String(media.id) || "",
}
))}
stackClass="grid grid-cols-1 md:grid-cols-2 gap-2 space-y-0"
itemContainerClass={cn(
"items-start cursor-pointer transition border-transparent rounded-[--radius] p-4 w-full",
"bg-gray-50 hover:bg-[--subtle] dark:bg-gray-900",
"data-[state=checked]:bg-white dark:data-[state=checked]:bg-gray-950",
"focus:ring-2 ring-brand-100 dark:ring-brand-900 ring-offset-1 ring-offset-[--background] focus-within:ring-2 transition",
"border border-transparent data-[state=checked]:border-[--brand] data-[state=checked]:ring-offset-0",
)}
itemClass={cn(
"border-transparent absolute top-2 right-2 bg-transparent dark:bg-transparent dark:data-[state=unchecked]:bg-transparent",
"data-[state=unchecked]:bg-transparent data-[state=unchecked]:hover:bg-transparent dark:data-[state=unchecked]:hover:bg-transparent",
"focus-visible:ring-0 focus-visible:ring-offset-0 focus-visible:ring-offset-transparent",
)}
itemIndicatorClass="hidden"
itemLabelClass="font-medium flex flex-col items-center data-[state=checked]:text-[--brand] cursor-pointer"
/>}
</AppLayoutStack>
</Drawer>
)
}

View File

@@ -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 ?? [],
}
}

View File

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

View File

@@ -0,0 +1,3 @@
import { atom } from "jotai"
export const __library_viewAtom = atom<"base" | "detailed">("base")

View File

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

View File

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

View File

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

View File

@@ -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"

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

@@ -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"

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -0,0 +1,3 @@
import { atomWithStorage } from "jotai/utils"
export const __mediaplayer_discreteControlsAtom = atomWithStorage("sea-mediaplayer-discrete-controls", false)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

@@ -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]",
)

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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

View File

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