22 changed files with 354 additions and 467 deletions
@ -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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
|
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 @@ |
|||||||
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 @@ |
|||||||
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 @@ |
|||||||
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