36 changed files with 1714 additions and 392 deletions
@ -1,3 +0,0 @@
@@ -1,3 +0,0 @@
|
||||
export const DISCORD_LINK = "https://discord.gg/Jhqt4Xzpfb"; |
||||
export const GITHUB_LINK = "https://github.com/JamesHawkinss/movie-web"; |
||||
export const APP_VERSION = "2.1.0"; |
@ -1,4 +1,4 @@
@@ -1,4 +1,4 @@
|
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "@/constants"; |
||||
import { APP_VERSION, GITHUB_LINK, DISCORD_LINK } from "./constants"; |
||||
|
||||
interface Config { |
||||
APP_VERSION: string; |
@ -1,224 +0,0 @@
@@ -1,224 +0,0 @@
|
||||
import { useEffect, useMemo, useState } from "react"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers"; |
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { Loading } from "@/components/layout/Loading"; |
||||
import { Tagline } from "@/components/text/Tagline"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
import { useWatchedContext } from "@/state/watched/context"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark/context"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
function SearchLoading() { |
||||
const { t } = useTranslation(); |
||||
return <Loading className="my-24" text={t('search.loading') || "Fetching your favourite shows..."} />; |
||||
} |
||||
|
||||
function SearchSuffix(props: { |
||||
fails: number; |
||||
total: number; |
||||
resultsSize: number; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const allFailed: boolean = props.fails === props.total; |
||||
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH; |
||||
|
||||
return ( |
||||
<div className="my-24 flex flex-col items-center justify-center space-y-3 text-center"> |
||||
<IconPatch |
||||
icon={icon} |
||||
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`} |
||||
/> |
||||
|
||||
{/* standard suffix */} |
||||
{!allFailed ? ( |
||||
<div> |
||||
{props.fails > 0 ? ( |
||||
<p className="text-red-400"> |
||||
{t('search.providersFailed', { fails: props.fails, total: props.total })} |
||||
</p> |
||||
) : null} |
||||
{props.resultsSize > 0 ? ( |
||||
<p>{t('search.allResults')}</p> |
||||
) : ( |
||||
<p>{t('search.noResults')}</p> |
||||
)} |
||||
</div> |
||||
) : null} |
||||
|
||||
{/* Error result */} |
||||
{allFailed ? ( |
||||
<div> |
||||
<p>{t('search.allFailed')}</p> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function SearchResultsView({ |
||||
searchQuery, |
||||
clear, |
||||
}: { |
||||
searchQuery: MWQuery; |
||||
clear: () => void; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [results, setResults] = useState<MWMassProviderOutput | undefined>(); |
||||
const [runSearchQuery, loading, error, success] = useLoading( |
||||
(query: MWQuery) => SearchProviders(query) |
||||
); |
||||
|
||||
useEffect(() => { |
||||
async function runSearch(query: MWQuery) { |
||||
const searchResults = await runSearchQuery(query); |
||||
if (!searchResults) return; |
||||
setResults(searchResults); |
||||
} |
||||
|
||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery); |
||||
}, [searchQuery, runSearchQuery]); |
||||
|
||||
return ( |
||||
<div> |
||||
{/* results */} |
||||
{success && results?.results.length ? ( |
||||
<SectionHeading |
||||
title={t('search.headingTitle') || "Search results"} |
||||
icon={Icons.SEARCH} |
||||
linkText={t('search.headingLink') || "Back to home"} |
||||
onClick={() => clear()} |
||||
> |
||||
{results.results.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={[v.mediaId, v.providerId].join("|")} |
||||
media={v} |
||||
/> |
||||
))} |
||||
</SectionHeading> |
||||
) : null} |
||||
|
||||
{/* search suffix */} |
||||
{success && results ? ( |
||||
<SearchSuffix |
||||
resultsSize={results.results.length} |
||||
fails={results.stats.failed} |
||||
total={results.stats.total} |
||||
/> |
||||
) : null} |
||||
|
||||
{/* error */} |
||||
{error ? <SearchSuffix resultsSize={0} fails={1} total={1} /> : null} |
||||
|
||||
{/* Loading icon */} |
||||
{loading ? <SearchLoading /> : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
function ExtraItems() { |
||||
const { t } = useTranslation(); |
||||
|
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const { getFilteredWatched } = useWatchedContext(); |
||||
|
||||
const bookmarks = getFilteredBookmarks(); |
||||
|
||||
const watchedItems = getFilteredWatched().filter( |
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v) |
||||
); |
||||
|
||||
if (watchedItems.length === 0 && bookmarks.length === 0) return null; |
||||
|
||||
return ( |
||||
<div className="mb-16 mt-32"> |
||||
{bookmarks.length > 0 ? ( |
||||
<SectionHeading title={t('search.bookmarks') || "Bookmarks"} icon={Icons.BOOKMARK}> |
||||
{bookmarks.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={[v.mediaId, v.providerId].join("|")} |
||||
media={v} |
||||
/> |
||||
))} |
||||
</SectionHeading> |
||||
) : null} |
||||
{watchedItems.length > 0 ? ( |
||||
<SectionHeading title={t('search.continueWatching') || "Continue Watching"} icon={Icons.CLOCK}> |
||||
{watchedItems.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={[v.mediaId, v.providerId].join("|")} |
||||
media={v} |
||||
series |
||||
/> |
||||
))} |
||||
</SectionHeading> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function SearchView() { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [searching, setSearching] = useState<boolean>(false); |
||||
const [loading, setLoading] = useState<boolean>(false); |
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery(); |
||||
|
||||
const debouncedSearch = useDebounce<MWQuery>(search, 2000); |
||||
useEffect(() => { |
||||
setSearching(search.searchQuery !== ""); |
||||
setLoading(search.searchQuery !== ""); |
||||
}, [search]); |
||||
useEffect(() => { |
||||
setLoading(false); |
||||
}, [debouncedSearch]); |
||||
|
||||
const resultView = useMemo(() => { |
||||
if (loading) return <SearchLoading />; |
||||
if (searching) |
||||
return ( |
||||
<SearchResultsView |
||||
searchQuery={debouncedSearch} |
||||
clear={() => setSearch({ searchQuery: "" }, true)} |
||||
/> |
||||
); |
||||
return <ExtraItems />; |
||||
}, [loading, searching, debouncedSearch, setSearch]); |
||||
|
||||
return ( |
||||
<> |
||||
<Navigation /> |
||||
<ThinContainer> |
||||
{/* input section */} |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="space-y-4"> |
||||
<Tagline>{t('search.tagline')}</Tagline> |
||||
<Title>{t('search.title')}</Title> |
||||
</div> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={t('search.placeholder') || "What do you want to watch?"} |
||||
/> |
||||
</div> |
||||
|
||||
{/* results view */} |
||||
{resultView} |
||||
</ThinContainer> |
||||
</> |
||||
); |
||||
} |
@ -0,0 +1,65 @@
@@ -0,0 +1,65 @@
|
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { |
||||
getIfBookmarkedFromPortable, |
||||
useBookmarkContext, |
||||
} from "@/state/bookmark"; |
||||
import { useWatchedContext } from "@/state/watched"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
function Bookmarks() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const bookmarks = getFilteredBookmarks(); |
||||
|
||||
if (bookmarks.length === 0) return null; |
||||
|
||||
return ( |
||||
<SectionHeading |
||||
title={t("search.bookmarks") || "Bookmarks"} |
||||
icon={Icons.BOOKMARK} |
||||
> |
||||
{bookmarks.map((v) => ( |
||||
<WatchedMediaCard key={[v.mediaId, v.providerId].join("|")} media={v} /> |
||||
))} |
||||
</SectionHeading> |
||||
); |
||||
} |
||||
|
||||
function Watched() { |
||||
const { t } = useTranslation(); |
||||
const { getFilteredBookmarks } = useBookmarkContext(); |
||||
const { getFilteredWatched } = useWatchedContext(); |
||||
|
||||
const bookmarks = getFilteredBookmarks(); |
||||
const watchedItems = getFilteredWatched().filter( |
||||
(v) => !getIfBookmarkedFromPortable(bookmarks, v) |
||||
); |
||||
|
||||
if (watchedItems.length === 0) return null; |
||||
|
||||
return ( |
||||
<SectionHeading |
||||
title={t("search.continueWatching") || "Continue Watching"} |
||||
icon={Icons.CLOCK} |
||||
> |
||||
{watchedItems.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={[v.mediaId, v.providerId].join("|")} |
||||
media={v} |
||||
series |
||||
/> |
||||
))} |
||||
</SectionHeading> |
||||
); |
||||
} |
||||
|
||||
export function HomeView() { |
||||
return ( |
||||
<div className="mb-16 mt-32"> |
||||
<Bookmarks /> |
||||
<Watched /> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,12 @@
@@ -0,0 +1,12 @@
|
||||
import { Loading } from "@/components/layout/Loading"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
export function SearchLoadingView() { |
||||
const { t } = useTranslation(); |
||||
return ( |
||||
<Loading |
||||
className="my-24" |
||||
text={t("search.loading") || "Fetching your favourite shows..."} |
||||
/> |
||||
); |
||||
} |
@ -0,0 +1,32 @@
@@ -0,0 +1,32 @@
|
||||
import { useDebounce } from "@/hooks/useDebounce"; |
||||
import { MWQuery } from "@/providers"; |
||||
import { useEffect, useMemo, useState } from "react"; |
||||
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, 2000); |
||||
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; |
||||
} |
@ -0,0 +1,102 @@
@@ -0,0 +1,102 @@
|
||||
import { IconPatch } from "@/components/buttons/IconPatch"; |
||||
import { Icons } from "@/components/Icon"; |
||||
import { SectionHeading } from "@/components/layout/SectionHeading"; |
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard"; |
||||
import { useLoading } from "@/hooks/useLoading"; |
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers"; |
||||
import { useEffect, useState } from "react"; |
||||
import { useTranslation } from "react-i18next"; |
||||
import { SearchLoadingView } from "./SearchLoadingView"; |
||||
|
||||
function SearchSuffix(props: { |
||||
fails: number; |
||||
total: number; |
||||
resultsSize: number; |
||||
}) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const allFailed: boolean = props.fails === props.total; |
||||
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH; |
||||
|
||||
return ( |
||||
<div className="my-24 flex flex-col items-center justify-center space-y-3 text-center"> |
||||
<IconPatch |
||||
icon={icon} |
||||
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`} |
||||
/> |
||||
|
||||
{/* standard suffix */} |
||||
{!allFailed ? ( |
||||
<div> |
||||
{props.fails > 0 ? ( |
||||
<p className="text-red-400"> |
||||
{t("search.providersFailed", { |
||||
fails: props.fails, |
||||
total: props.total, |
||||
})} |
||||
</p> |
||||
) : null} |
||||
{props.resultsSize > 0 ? ( |
||||
<p>{t("search.allResults")}</p> |
||||
) : ( |
||||
<p>{t("search.noResults")}</p> |
||||
)} |
||||
</div> |
||||
) : null} |
||||
|
||||
{/* Error result */} |
||||
{allFailed ? ( |
||||
<div> |
||||
<p>{t("search.allFailed")}</p> |
||||
</div> |
||||
) : null} |
||||
</div> |
||||
); |
||||
} |
||||
|
||||
export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) { |
||||
const { t } = useTranslation(); |
||||
|
||||
const [results, setResults] = useState<MWMassProviderOutput | undefined>(); |
||||
const [runSearchQuery, loading, error] = useLoading((query: MWQuery) => |
||||
SearchProviders(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 <SearchLoadingView />; |
||||
if (error) return <SearchSuffix resultsSize={0} fails={1} total={1} />; |
||||
if (!results) return null; |
||||
|
||||
return ( |
||||
<div> |
||||
{results?.results.length > 0 ? ( |
||||
<SectionHeading |
||||
title={t("search.headingTitle") || "Search results"} |
||||
icon={Icons.SEARCH} |
||||
> |
||||
{results.results.map((v) => ( |
||||
<WatchedMediaCard |
||||
key={[v.mediaId, v.providerId].join("|")} |
||||
media={v} |
||||
/> |
||||
))} |
||||
</SectionHeading> |
||||
) : null} |
||||
|
||||
<SearchSuffix |
||||
resultsSize={results.results.length} |
||||
fails={results.stats.failed} |
||||
total={results.stats.total} |
||||
/> |
||||
</div> |
||||
); |
||||
} |
@ -0,0 +1,54 @@
@@ -0,0 +1,54 @@
|
||||
import { useCallback, useState } from "react"; |
||||
import { Navigation } from "@/components/layout/Navigation"; |
||||
import { ThinContainer } from "@/components/layout/ThinContainer"; |
||||
import { SearchBarInput } from "@/components/SearchBar"; |
||||
import Sticky from "react-stickynode"; |
||||
import { Title } from "@/components/text/Title"; |
||||
import { useSearchQuery } from "@/hooks/useSearchQuery"; |
||||
import { useTranslation } from "react-i18next"; |
||||
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial"; |
||||
|
||||
export function SearchView() { |
||||
const { t } = useTranslation(); |
||||
const [search, setSearch, setSearchUnFocus] = useSearchQuery(); |
||||
const [showBg, setShowBg] = useState(false); |
||||
|
||||
const stickStateChanged = useCallback( |
||||
({ status }: Sticky.Status) => setShowBg(status === Sticky.STATUS_FIXED), |
||||
[setShowBg] |
||||
); |
||||
|
||||
return ( |
||||
<> |
||||
<div className="relative z-10"> |
||||
<Navigation bg={showBg} /> |
||||
<ThinContainer> |
||||
<div className="mt-44 space-y-16 text-center"> |
||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center"> |
||||
<div className="absolute bottom-4 h-[100vh] w-[300vh] rounded-[100%] bg-[#211D30]" /> |
||||
</div> |
||||
<div className="relative z-20"> |
||||
<div className="mb-16"> |
||||
<Title>{t("search.title")}</Title> |
||||
</div> |
||||
<Sticky enabled top={16} onStateChange={stickStateChanged}> |
||||
<SearchBarInput |
||||
onChange={setSearch} |
||||
value={search} |
||||
onUnFocus={setSearchUnFocus} |
||||
placeholder={ |
||||
t("search.placeholder") || "What do you want to watch?" |
||||
} |
||||
/> |
||||
</Sticky> |
||||
</div> |
||||
</div> |
||||
</ThinContainer> |
||||
</div> |
||||
<ThinContainer> |
||||
<SearchResultsPartial search={search} /> |
||||
</ThinContainer> |
||||
</> |
||||
); |
||||
} |
Loading…
Reference in new issue