22 changed files with 354 additions and 467 deletions
@ -0,0 +1,64 @@
@@ -0,0 +1,64 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { WideContainer } from "@/components/layout/WideContainer"; |
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
import { HomeLayout } from "@/views/layouts/HomeLayout"; |
||||
import { BookmarksPart } from "@/views/parts/home/BookmarksPart"; |
||||
import { HeroPart } from "@/views/parts/home/HeroPart"; |
||||
import { WatchingPart } from "@/views/parts/home/WatchingPart"; |
||||
import { SearchListPart } from "@/views/parts/search/SearchListPart"; |
||||
import { SearchLoadingPart } from "@/views/parts/search/SearchLoadingPart"; |
||||
|
||||
function useSearch(search: MWQuery) { |
||||
const [searching, setSearching] = useState<boolean>(false); |
||||
const [loading, setLoading] = useState<boolean>(false); |
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 500); |
||||
useEffect(() => { |
||||
setSearching(search.searchQuery !== ""); |
||||
setLoading(search.searchQuery !== ""); |
||||
}, [search]); |
||||
useEffect(() => { |
||||
setLoading(false); |
||||
}, [debouncedSearch]); |
||||
|
||||
return { |
||||
loading, |
||||
searching, |
||||
}; |
||||
} |
||||
|
||||
export function HomePage() { |
||||
const { t } = useTranslation(); |
||||
const [showBg, setShowBg] = useState<boolean>(false); |
||||
const searchParams = useSearchQuery(); |
||||
const [search] = searchParams; |
||||
const s = useSearch(search); |
||||
|
||||
return ( |
||||
<HomeLayout showBg={showBg}> |
||||
<div className="relative z-10 mb-16 sm:mb-24"> |
||||
<Helmet> |
||||
<title>{t("global.name")}</title> |
||||
</Helmet> |
||||
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} /> |
||||
</div> |
||||
<WideContainer> |
||||
{s.loading ? ( |
||||
<SearchLoadingPart /> |
||||
) : s.searching ? ( |
||||
<SearchListPart searchQuery={search} /> |
||||
) : ( |
||||
<> |
||||
<BookmarksPart /> |
||||
<WatchingPart /> |
||||
</> |
||||
)} |
||||
</WideContainer> |
||||
</HomeLayout> |
||||
); |
||||
} |
@ -1,148 +0,0 @@
@@ -1,148 +0,0 @@
|
||||
import { useMemo } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import CaptionColorSelector, { |
||||
colors, |
||||
} from "@/components/CaptionColorSelector"; |
||||
import { Dropdown } from "@/components/Dropdown"; |
||||
import { Icon, Icons } from "@/components/Icon"; |
||||
import { Modal, ModalCard } from "@/components/layout/Modal"; |
||||
import { Slider } from "@/components/Slider"; |
||||
import { conf } from "@/setup/config"; |
||||
import { appLanguageOptions } from "@/setup/i18n"; |
||||
import { |
||||
CaptionLanguageOption, |
||||
LangCode, |
||||
captionLanguages, |
||||
} from "@/setup/iso6391"; |
||||
import { useSettings } from "@/state/settings"; |
||||
import { CaptionCue } from "@/video/components/actions/CaptionRendererAction"; |
||||
|
||||
export default function SettingsModal(props: { |
||||
onClose: () => void; |
||||
show: boolean; |
||||
}) { |
||||
const { |
||||
captionSettings, |
||||
language, |
||||
setLanguage, |
||||
setCaptionLanguage, |
||||
setCaptionBackgroundColor, |
||||
setCaptionFontSize, |
||||
} = useSettings(); |
||||
const { t, i18n } = useTranslation(); |
||||
|
||||
const selectedCaptionLanguage = useMemo( |
||||
() => captionLanguages.find((l) => l.id === captionSettings.language), |
||||
[captionSettings.language] |
||||
) as CaptionLanguageOption; |
||||
const appLanguage = useMemo( |
||||
() => appLanguageOptions.find((l) => l.id === language), |
||||
[language] |
||||
) as CaptionLanguageOption; |
||||
const captionBackgroundOpacity = ( |
||||
(parseInt(captionSettings.style.backgroundColor.substring(7, 9), 16) / |
||||
255) * |
||||
100 |
||||
).toFixed(0); |
||||
return ( |
||||
<Modal show={props.show}> |
||||
<ModalCard className="text-white"> |
||||
<div className="flex flex-col gap-4"> |
||||
<div className="flex flex-row justify-between"> |
||||
<span className="text-xl font-bold">{t("settings.title")}</span> |
||||
<div |
||||
onClick={() => props.onClose()} |
||||
className="hover:cursor-pointer" |
||||
> |
||||
<Icon icon={Icons.X} /> |
||||
</div> |
||||
</div> |
||||
<div className="flex flex-col gap-10 lg:flex-row"> |
||||
<div className="lg:w-1/2"> |
||||
<div className="flex flex-col justify-between"> |
||||
<label className="text-md font-semibold"> |
||||
{t("settings.language")} |
||||
</label> |
||||
<Dropdown |
||||
selectedItem={appLanguage} |
||||
setSelectedItem={(val) => { |
||||
i18n.changeLanguage(val.id); |
||||
setLanguage(val.id as LangCode); |
||||
}} |
||||
options={appLanguageOptions} |
||||
/> |
||||
</div> |
||||
<div className="flex flex-col justify-between"> |
||||
<label className="text-md font-semibold"> |
||||
{t("settings.captionLanguage")} |
||||
</label> |
||||
<Dropdown |
||||
selectedItem={selectedCaptionLanguage} |
||||
setSelectedItem={(val) => { |
||||
setCaptionLanguage(val.id as LangCode); |
||||
}} |
||||
options={captionLanguages} |
||||
/> |
||||
</div> |
||||
<div className="flex flex-col justify-between"> |
||||
<Slider |
||||
label={ |
||||
t( |
||||
"videoPlayer.popouts.captionPreferences.fontSize" |
||||
) as string |
||||
} |
||||
min={14} |
||||
step={1} |
||||
max={60} |
||||
value={captionSettings.style.fontSize} |
||||
onChange={(e) => setCaptionFontSize(e.target.valueAsNumber)} |
||||
/> |
||||
<Slider |
||||
label={ |
||||
t( |
||||
"videoPlayer.popouts.captionPreferences.opacity" |
||||
) as string |
||||
} |
||||
step={1} |
||||
min={0} |
||||
max={255} |
||||
valueDisplay={`${captionBackgroundOpacity}%`} |
||||
value={parseInt( |
||||
captionSettings.style.backgroundColor.substring(7, 9), |
||||
16 |
||||
)} |
||||
onChange={(e) => |
||||
setCaptionBackgroundColor(e.target.valueAsNumber) |
||||
} |
||||
/> |
||||
<div className="flex flex-row justify-between"> |
||||
<label className="font-bold" htmlFor="color"> |
||||
{t("videoPlayer.popouts.captionPreferences.color")} |
||||
</label> |
||||
<div className="flex flex-row gap-2"> |
||||
{colors.map((color) => ( |
||||
<CaptionColorSelector key={color} color={color} /> |
||||
))} |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div /> |
||||
</div> |
||||
<div className="flex w-full flex-col justify-center"> |
||||
<div className="flex aspect-video flex-col justify-end rounded bg-zinc-800"> |
||||
<div className="pointer-events-none flex w-full flex-col items-center transition-[bottom]"> |
||||
<CaptionCue |
||||
scale={0.5} |
||||
text={selectedCaptionLanguage.nativeName} |
||||
/> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
</div> |
||||
<div className="float-right mt-1 text-sm">v{conf().APP_VERSION}</div> |
||||
</ModalCard> |
||||
</Modal> |
||||
); |
||||
} |
@ -0,0 +1,23 @@
@@ -0,0 +1,23 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { ErrorWrapperPart } from "@/views/parts/errors/ErrorWrapperPart"; |
||||
|
||||
export function NotFoundPage() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<ErrorWrapperPart> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.page.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.page.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</ErrorWrapperPart> |
||||
); |
||||
} |
@ -0,0 +1,14 @@
@@ -0,0 +1,14 @@
|
||||
import { FooterView } from "@/components/layout/Footer"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
|
||||
export function HomeLayout(props: { |
||||
showBg: boolean; |
||||
children: React.ReactNode; |
||||
}) { |
||||
return ( |
||||
<FooterView> |
||||
<Navigation bg={props.showBg} /> |
||||
{props.children} |
||||
</FooterView> |
||||
); |
||||
} |
@ -0,0 +1,11 @@
@@ -0,0 +1,11 @@
|
||||
import { FooterView } from "@/components/layout/Footer"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
|
||||
export function PageLayout(props: { children: React.ReactNode }) { |
||||
return ( |
||||
<FooterView> |
||||
<Navigation /> |
||||
{props.children} |
||||
</FooterView> |
||||
); |
||||
} |
@ -1,87 +0,0 @@
@@ -1,87 +0,0 @@
|
||||
import { ReactNode } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useGoBack } from "@/hooks/useGoBack"; |
||||
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; |
||||
|
||||
export function NotFoundWrapper(props: { |
||||
children?: ReactNode; |
||||
video?: boolean; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
const goBack = useGoBack(); |
||||
|
||||
return ( |
||||
<div className="relative flex flex-1 flex-col"> |
||||
<Helmet> |
||||
<title>{t("notFound.genericTitle")}</title> |
||||
</Helmet> |
||||
{props.video ? ( |
||||
<div className="absolute inset-x-0 top-0 px-8 py-6"> |
||||
<VideoPlayerHeader onClick={goBack} /> |
||||
</div> |
||||
) : ( |
||||
<Navigation /> |
||||
)} |
||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundMedia() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.media.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.media.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundProvider() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.provider.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm"> |
||||
{t("notFound.provider.description")} |
||||
</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function NotFoundPage() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<NotFoundWrapper> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.page.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.page.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</NotFoundWrapper> |
||||
); |
||||
} |
@ -0,0 +1,33 @@
@@ -0,0 +1,33 @@
|
||||
import { ReactNode } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { useGoBack } from "@/hooks/useGoBack"; |
||||
import { VideoPlayerHeader } from "@/video/components/parts/VideoPlayerHeader"; |
||||
|
||||
export function ErrorWrapperPart(props: { |
||||
children?: ReactNode; |
||||
video?: boolean; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
const goBack = useGoBack(); |
||||
|
||||
return ( |
||||
<div className="relative flex flex-1 flex-col"> |
||||
<Helmet> |
||||
<title>{t("notFound.genericTitle")}</title> |
||||
</Helmet> |
||||
{props.video ? ( |
||||
<div className="absolute inset-x-0 top-0 px-8 py-6"> |
||||
<VideoPlayerHeader onClick={goBack} /> |
||||
</div> |
||||
) : ( |
||||
<Navigation /> |
||||
)} |
||||
<div className="flex h-full flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
{props.children} |
||||
</div> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,22 @@
@@ -0,0 +1,22 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
|
||||
export function MediaNotFoundPart() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.media.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm">{t("notFound.media.description")}</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,24 @@
@@ -0,0 +1,24 @@
|
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { ArrowLink } from "@/components/text/ArrowLink"; |
||||
import { Title } from "@/components/text/Title"; |
||||
|
||||
export function ProviderNotFoundPart() { |
||||
const { t } = useTranslation(); |
||||
|
||||
return ( |
||||
<div className="flex flex-1 flex-col items-center justify-center p-5 text-center"> |
||||
<IconPatch |
||||
icon={Icons.EYE_SLASH} |
||||
className="mb-6 text-xl text-bink-600" |
||||
/> |
||||
<Title>{t("notFound.provider.title")}</Title> |
||||
<p className="mb-12 mt-5 max-w-sm"> |
||||
{t("notFound.provider.description")} |
||||
</p> |
||||
<ArrowLink to="/" linkText={t("notFound.backArrow")} /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,55 @@
@@ -0,0 +1,55 @@
|
||||
import { useCallback, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import Sticky from "react-stickynode"; |
||||
|
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useBannerSize } from "@/hooks/useBanner"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
|
||||
export interface HeroPartProps { |
||||
setIsSticky: (val: boolean) => void; |
||||
searchParams: ReturnType<typeof useSearchQuery>; |
||||
} |
||||
|
||||
export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) { |
||||
const { t } = useTranslation(); |
||||
const [search, setSearch, setSearchUnFocus] = searchParams; |
||||
const [, setShowBg] = useState(false); |
||||
const bannerSize = useBannerSize(); |
||||
const stickStateChanged = useCallback( |
||||
({ status }: Sticky.Status) => { |
||||
const val = status === Sticky.STATUS_FIXED; |
||||
setShowBg(val); |
||||
setIsSticky(val); |
||||
}, |
||||
[setShowBg, setIsSticky] |
||||
); |
||||
|
||||
return ( |
||||
<ThinContainer> |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="relative z-10 mb-16"> |
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title> |
||||
</div> |
||||
<div className="relative z-30"> |
||||
<Sticky |
||||
enabled |
||||
top={16 + bannerSize} |
||||
onStateChange={stickStateChanged} |
||||
> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={ |
||||
t("search.placeholder") || "What do you want to watch?" |
||||
} |
||||
/> |
||||
</Sticky> |
||||
</div> |
||||
</div> |
||||
</ThinContainer> |
||||
); |
||||
} |
@ -0,0 +1,88 @@
@@ -0,0 +1,88 @@
|
||||
import { useEffect, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { searchForMedia } from "@/backend/metadata/search"; |
||||
import { MWMediaMeta, MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { SearchLoadingPart } from "@/views/parts/search/SearchLoadingPart"; |
||||
|
||||
function SearchSuffix(props: { failed?: boolean; results?: number }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const icon: Icons = props.failed ? Icons.WARNING : Icons.EYE_SLASH; |
||||
|
||||
return ( |
||||
<div className="mb-24 mt-40 flex flex-col items-center justify-center space-y-3 text-center"> |
||||
<IconPatch |
||||
icon={icon} |
||||
className={`text-xl ${props.failed ? "text-red-400" : "text-bink-600"}`} |
||||
/> |
||||
|
||||
{/* standard suffix */} |
||||
{!props.failed ? ( |
||||
<div> |
||||
{(props.results ?? 0) > 0 ? ( |
||||
<p>{t("search.allResults")}</p> |
||||
) : ( |
||||
<p>{t("search.noResults")}</p> |
||||
)} |
||||
</div> |
||||
) : null} |
||||
|
||||
{/* Error result */} |
||||
{props.failed ? ( |
||||
<div> |
||||
<p>{t("search.allFailed")}</p> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function SearchListPart({ searchQuery }: { searchQuery: MWQuery }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [results, setResults] = useState<MWMediaMeta[]>([]); |
||||
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => |
||||
searchForMedia(query) |
||||
); |
||||
|
||||
useEffect(() => { |
||||
async function runSearch(query: MWQuery) { |
||||
const searchResults = await runSearchQuery(query); |
||||
if (!searchResults) return; |
||||
setResults(searchResults); |
||||
} |
||||
|
||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery); |
||||
}, [searchQuery, runSearchQuery]); |
||||
|
||||
if (loading) return <SearchLoadingPart />; |
||||
if (error) return <SearchSuffix failed />; |
||||
if (!results) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
{results.length > 0 ? ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.headingTitle") || "Search results"} |
||||
icon={Icons.SEARCH} |
||||
/> |
||||
<MediaGrid> |
||||
{results.map((v) => ( |
||||
<WatchedMediaCard key={v.id.toString()} media={v} /> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
) : null} |
||||
|
||||
<SearchSuffix results={results.length} /> |
||||
</div> |
||||
); |
||||
} |
@ -1,106 +0,0 @@
@@ -1,106 +0,0 @@
|
||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||
import { useMemo, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { EditButton } from "@/components/buttons/EditButton"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { MediaGrid } from "@/components/media/MediaGrid"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
|
||||
function Bookmarks() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext(); |
||||
const bookmarks = getFilteredBookmarks(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
const { watched } = useWatchedContext(); |
||||
|
||||
const bookmarksSorted = useMemo(() => { |
||||
return bookmarks |
||||
.map((v) => { |
||||
return { |
||||
...v, |
||||
watched: watched.items |
||||
.sort((a, b) => b.watchedAt - a.watchedAt) |
||||
.find((watchedItem) => watchedItem.item.meta.id === v.id), |
||||
}; |
||||
}) |
||||
.sort( |
||||
(a, b) => (b.watched?.watchedAt || 0) - (a.watched?.watchedAt || 0) |
||||
); |
||||
}, [watched.items, bookmarks]); |
||||
|
||||
if (bookmarks.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.bookmarks") || "Bookmarks"} |
||||
icon={Icons.BOOKMARK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{bookmarksSorted.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.id} |
||||
media={v} |
||||
closable={editing} |
||||
onClose={() => setItemBookmark(v, false)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function Watched() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const { getFilteredWatched, removeProgress } = useWatchedContext(); |
||||
const [editing, setEditing] = useState(false); |
||||
const [gridRef] = useAutoAnimate<HTMLDivElement>(); |
||||
|
||||
const bookmarks = getFilteredBookmarks(); |
||||
const watchedItems = getFilteredWatched().filter( |
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v.item.meta) |
||||
); |
||||
|
||||
if (watchedItems.length === 0) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
<SectionHeading |
||||
title={t("search.continueWatching") || "Continue Watching"} |
||||
icon={Icons.CLOCK} |
||||
> |
||||
<EditButton editing={editing} onEdit={setEditing} /> |
||||
</SectionHeading> |
||||
<MediaGrid ref={gridRef}> |
||||
{watchedItems.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={v.item.meta.id} |
||||
media={v.item.meta} |
||||
closable={editing} |
||||
onClose={() => removeProgress(v.item.meta.id)} |
||||
/> |
||||
))} |
||||
</MediaGrid> |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function HomeView() { |
||||
return ( |
||||
<div> |
||||
<Bookmarks /> |
||||
<Watched /> |
||||
</div> |
||||
); |
||||
} |
@ -1,34 +0,0 @@
@@ -1,34 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"; |
||||
|
||||
import { MWQuery } from "@/backend/metadata/types/mw"; |
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
|
||||
import { HomeView } from "./HomeView"; |
||||
import { SearchLoadingView } from "./SearchLoadingView"; |
||||
import { SearchResultsView } from "./SearchResultsView"; |
||||
|
||||
interface SearchResultsPartialProps { |
||||
search: MWQuery; |
||||
} |
||||
|
||||
export function SearchResultsPartial({ search }: SearchResultsPartialProps) { |
||||
const [searching, setSearching] = useState<boolean>(false); |
||||
const [loading, setLoading] = useState<boolean>(false); |
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 500); |
||||
useEffect(() => { |
||||
setSearching(search.searchQuery !== ""); |
||||
setLoading(search.searchQuery !== ""); |
||||
}, [search]); |
||||
useEffect(() => { |
||||
setLoading(false); |
||||
}, [debouncedSearch]); |
||||
|
||||
const resultView = useMemo(() => { |
||||
if (loading) return <SearchLoadingView />; |
||||
if (searching) return <SearchResultsView searchQuery={debouncedSearch} />; |
||||
return <HomeView />; |
||||
}, [loading, searching, debouncedSearch]); |
||||
|
||||
return resultView; |
||||
} |
@ -1,64 +0,0 @@
@@ -1,64 +0,0 @@
|
||||
import { useCallback, useState } from "react"; |
||||
import { Helmet } from "react-helmet"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import Sticky from "react-stickynode"; |
||||
|
||||
import { FooterView } from "@/components/layout/Footer"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { WideContainer } from "@/components/layout/WideContainer"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useBannerSize } from "@/hooks/useBanner"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial"; |
||||
|
||||
export function SearchView() { |
||||
const { t } = useTranslation(); |
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery(); |
||||
const [showBg, setShowBg] = useState(false); |
||||
const bannerSize = useBannerSize(); |
||||
|
||||
const stickStateChanged = useCallback( |
||||
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED), |
||||
[setShowBg] |
||||
); |
||||
|
||||
return ( |
||||
<FooterView> |
||||
<Navigation bg={showBg} /> |
||||
<div className="relative z-10 mb-16 sm:mb-24"> |
||||
<Helmet> |
||||
<title>{t("global.name")}</title> |
||||
</Helmet> |
||||
<ThinContainer> |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="relative z-10 mb-16"> |
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title> |
||||
</div> |
||||
<div className="relative z-30"> |
||||
<Sticky |
||||
enabled |
||||
top={16 + bannerSize} |
||||
onStateChange={stickStateChanged} |
||||
> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={ |
||||
t("search.placeholder") || "What do you want to watch?" |
||||
} |
||||
/> |
||||
</Sticky> |
||||
</div> |
||||
</div> |
||||
</ThinContainer> |
||||
</div> |
||||
<WideContainer> |
||||
<SearchResultsPartial search={search} /> |
||||
</WideContainer> |
||||
</FooterView> |
||||
); |
||||
} |
Loading…
Reference in new issue