56 changed files with 1256 additions and 6816 deletions
After Width: | Height: | Size: 314 B |
@ -1,128 +0,0 @@ |
|||||||
import { compareTitle } from "@/utils/titleMatch"; |
|
||||||
|
|
||||||
import { |
|
||||||
getMWCaptionTypeFromUrl, |
|
||||||
isSupportedSubtitle, |
|
||||||
} from "../helpers/captions"; |
|
||||||
import { mwFetch } from "../helpers/fetch"; |
|
||||||
import { registerProvider } from "../helpers/register"; |
|
||||||
import { MWCaption, MWStreamQuality, MWStreamType } from "../helpers/streams"; |
|
||||||
import { MWMediaType } from "../metadata/types/mw"; |
|
||||||
|
|
||||||
const flixHqBase = "https://consumet-api-clone.vercel.app/meta/tmdb"; // instance stolen from streaminal :)
|
|
||||||
|
|
||||||
type FlixHQMediaType = "Movie" | "TV Series"; |
|
||||||
interface FLIXMediaBase { |
|
||||||
id: number; |
|
||||||
title: string; |
|
||||||
url: string; |
|
||||||
image: string; |
|
||||||
type: FlixHQMediaType; |
|
||||||
releaseDate: string; |
|
||||||
} |
|
||||||
interface FLIXSubType { |
|
||||||
url: string; |
|
||||||
lang: string; |
|
||||||
} |
|
||||||
function convertSubtitles({ url, lang }: FLIXSubType): MWCaption | null { |
|
||||||
if (lang.includes("(maybe)")) return null; |
|
||||||
const supported = isSupportedSubtitle(url); |
|
||||||
if (!supported) return null; |
|
||||||
const type = getMWCaptionTypeFromUrl(url); |
|
||||||
return { |
|
||||||
url, |
|
||||||
langIso: lang, |
|
||||||
type, |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
const qualityMap: Record<string, MWStreamQuality> = { |
|
||||||
"360": MWStreamQuality.Q360P, |
|
||||||
"540": MWStreamQuality.Q540P, |
|
||||||
"480": MWStreamQuality.Q480P, |
|
||||||
"720": MWStreamQuality.Q720P, |
|
||||||
"1080": MWStreamQuality.Q1080P, |
|
||||||
}; |
|
||||||
|
|
||||||
function flixTypeToMWType(type: FlixHQMediaType) { |
|
||||||
if (type === "Movie") return MWMediaType.MOVIE; |
|
||||||
return MWMediaType.SERIES; |
|
||||||
} |
|
||||||
|
|
||||||
registerProvider({ |
|
||||||
id: "flixhq", |
|
||||||
displayName: "FlixHQ", |
|
||||||
rank: 100, |
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
|
||||||
async scrape({ media, episode, progress }) { |
|
||||||
if (!this.type.includes(media.meta.type)) { |
|
||||||
throw new Error("Unsupported type"); |
|
||||||
} |
|
||||||
// search for relevant item
|
|
||||||
const searchResults = await mwFetch<any>( |
|
||||||
`/${encodeURIComponent(media.meta.title)}`, |
|
||||||
{ |
|
||||||
baseURL: flixHqBase, |
|
||||||
} |
|
||||||
); |
|
||||||
|
|
||||||
const foundItem = searchResults.results.find((v: FLIXMediaBase) => { |
|
||||||
if (v.type !== "Movie" && v.type !== "TV Series") return false; |
|
||||||
return ( |
|
||||||
compareTitle(v.title, media.meta.title) && |
|
||||||
flixTypeToMWType(v.type) === media.meta.type && |
|
||||||
v.releaseDate === media.meta.year |
|
||||||
); |
|
||||||
}); |
|
||||||
|
|
||||||
if (!foundItem) throw new Error("No watchable item found"); |
|
||||||
|
|
||||||
// get media info
|
|
||||||
progress(25); |
|
||||||
const mediaInfo = await mwFetch<any>(`/info/${foundItem.id}`, { |
|
||||||
baseURL: flixHqBase, |
|
||||||
params: { |
|
||||||
type: flixTypeToMWType(foundItem.type), |
|
||||||
}, |
|
||||||
}); |
|
||||||
if (!mediaInfo.id) throw new Error("No watchable item found"); |
|
||||||
// get stream info from media
|
|
||||||
progress(50); |
|
||||||
|
|
||||||
let episodeId: string | undefined; |
|
||||||
if (media.meta.type === MWMediaType.MOVIE) { |
|
||||||
episodeId = mediaInfo.episodeId; |
|
||||||
} else if (media.meta.type === MWMediaType.SERIES) { |
|
||||||
const seasonNo = media.meta.seasonData.number; |
|
||||||
const episodeNo = media.meta.seasonData.episodes.find( |
|
||||||
(e) => e.id === episode |
|
||||||
)?.number; |
|
||||||
|
|
||||||
const season = mediaInfo.seasons.find((o: any) => o.season === seasonNo); |
|
||||||
episodeId = season.episodes.find((o: any) => o.episode === episodeNo).id; |
|
||||||
} |
|
||||||
if (!episodeId) throw new Error("No watchable item found"); |
|
||||||
progress(75); |
|
||||||
const watchInfo = await mwFetch<any>(`/watch/${episodeId}`, { |
|
||||||
baseURL: flixHqBase, |
|
||||||
params: { |
|
||||||
id: mediaInfo.id, |
|
||||||
}, |
|
||||||
}); |
|
||||||
|
|
||||||
if (!watchInfo.sources) throw new Error("No watchable item found"); |
|
||||||
|
|
||||||
// get best quality source
|
|
||||||
// comes sorted by quality in descending order
|
|
||||||
const source = watchInfo.sources[0]; |
|
||||||
return { |
|
||||||
embeds: [], |
|
||||||
stream: { |
|
||||||
streamUrl: source.url, |
|
||||||
quality: qualityMap[source.quality], |
|
||||||
type: source.isM3U8 ? MWStreamType.HLS : MWStreamType.MP4, |
|
||||||
captions: watchInfo.subtitles.map(convertSubtitles).filter(Boolean), |
|
||||||
}, |
|
||||||
}; |
|
||||||
}, |
|
||||||
}); |
|
@ -0,0 +1 @@ |
|||||||
|
export const flixHqBase = "https://flixhq.to"; |
@ -0,0 +1,36 @@ |
|||||||
|
import { MWEmbedType } from "@/backend/helpers/embed"; |
||||||
|
import { registerProvider } from "@/backend/helpers/register"; |
||||||
|
import { MWMediaType } from "@/backend/metadata/types/mw"; |
||||||
|
import { |
||||||
|
getFlixhqSourceDetails, |
||||||
|
getFlixhqSources, |
||||||
|
} from "@/backend/providers/flixhq/scrape"; |
||||||
|
import { getFlixhqId } from "@/backend/providers/flixhq/search"; |
||||||
|
|
||||||
|
registerProvider({ |
||||||
|
id: "flixhq", |
||||||
|
displayName: "FlixHQ", |
||||||
|
rank: 100, |
||||||
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES], |
||||||
|
async scrape({ media }) { |
||||||
|
const id = await getFlixhqId(media.meta); |
||||||
|
if (!id) throw new Error("flixhq no matching item found"); |
||||||
|
|
||||||
|
// TODO tv shows not supported. just need to scrape the specific episode sources
|
||||||
|
|
||||||
|
const sources = await getFlixhqSources(id); |
||||||
|
const upcloudStream = sources.find( |
||||||
|
(v) => v.embed.toLowerCase() === "upcloud" |
||||||
|
); |
||||||
|
if (!upcloudStream) throw new Error("upcloud stream not found for flixhq"); |
||||||
|
|
||||||
|
return { |
||||||
|
embeds: [ |
||||||
|
{ |
||||||
|
type: MWEmbedType.UPCLOUD, |
||||||
|
url: await getFlixhqSourceDetails(upcloudStream.episodeId), |
||||||
|
}, |
||||||
|
], |
||||||
|
}; |
||||||
|
}, |
||||||
|
}); |
@ -0,0 +1,41 @@ |
|||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||||
|
import { flixHqBase } from "@/backend/providers/flixhq/common"; |
||||||
|
|
||||||
|
export async function getFlixhqSources(id: string) { |
||||||
|
const type = id.split("/")[0]; |
||||||
|
const episodeParts = id.split("-"); |
||||||
|
const episodeId = episodeParts[episodeParts.length - 1]; |
||||||
|
|
||||||
|
const data = await proxiedFetch<string>( |
||||||
|
`/ajax/${type}/episodes/${episodeId}`, |
||||||
|
{ |
||||||
|
baseURL: flixHqBase, |
||||||
|
} |
||||||
|
); |
||||||
|
const doc = new DOMParser().parseFromString(data, "text/html"); |
||||||
|
|
||||||
|
const sourceLinks = [...doc.querySelectorAll(".nav-item > a")].map((el) => { |
||||||
|
const embedTitle = el.getAttribute("title"); |
||||||
|
const linkId = el.getAttribute("data-linkid"); |
||||||
|
if (!embedTitle || !linkId) throw new Error("invalid sources"); |
||||||
|
return { |
||||||
|
embed: embedTitle, |
||||||
|
episodeId: linkId, |
||||||
|
}; |
||||||
|
}); |
||||||
|
|
||||||
|
return sourceLinks; |
||||||
|
} |
||||||
|
|
||||||
|
export async function getFlixhqSourceDetails( |
||||||
|
sourceId: string |
||||||
|
): Promise<string> { |
||||||
|
const jsonData = await proxiedFetch<Record<string, any>>( |
||||||
|
`/ajax/sources/${sourceId}`, |
||||||
|
{ |
||||||
|
baseURL: flixHqBase, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
return jsonData.link; |
||||||
|
} |
@ -0,0 +1,43 @@ |
|||||||
|
import { proxiedFetch } from "@/backend/helpers/fetch"; |
||||||
|
import { MWMediaMeta } from "@/backend/metadata/types/mw"; |
||||||
|
import { flixHqBase } from "@/backend/providers/flixhq/common"; |
||||||
|
import { compareTitle } from "@/utils/titleMatch"; |
||||||
|
|
||||||
|
export async function getFlixhqId(meta: MWMediaMeta): Promise<string | null> { |
||||||
|
const searchResults = await proxiedFetch<string>( |
||||||
|
`/search/${meta.title.replaceAll(/[^a-z0-9A-Z]/g, "-")}`, |
||||||
|
{ |
||||||
|
baseURL: flixHqBase, |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
const doc = new DOMParser().parseFromString(searchResults, "text/html"); |
||||||
|
const items = [...doc.querySelectorAll(".film_list-wrap > div.flw-item")].map( |
||||||
|
(el) => { |
||||||
|
const id = el |
||||||
|
.querySelector("div.film-poster > a") |
||||||
|
?.getAttribute("href") |
||||||
|
?.slice(1); |
||||||
|
const title = el |
||||||
|
.querySelector("div.film-detail > h2 > a") |
||||||
|
?.getAttribute("title"); |
||||||
|
const year = el.querySelector( |
||||||
|
"div.film-detail > div.fd-infor > span:nth-child(1)" |
||||||
|
)?.textContent; |
||||||
|
|
||||||
|
if (!id || !title || !year) return null; |
||||||
|
return { |
||||||
|
id, |
||||||
|
title, |
||||||
|
year, |
||||||
|
}; |
||||||
|
} |
||||||
|
); |
||||||
|
|
||||||
|
const matchingItem = items.find( |
||||||
|
(v) => v && compareTitle(meta.title, v.title) && meta.year === v.year |
||||||
|
); |
||||||
|
|
||||||
|
if (!matchingItem) return null; |
||||||
|
return matchingItem.id; |
||||||
|
} |
@ -0,0 +1,86 @@ |
|||||||
|
import { useTranslation } from "react-i18next"; |
||||||
|
|
||||||
|
import { Icon, Icons } from "@/components/Icon"; |
||||||
|
import { BrandPill } from "@/components/layout/BrandPill"; |
||||||
|
import { WideContainer } from "@/components/layout/WideContainer"; |
||||||
|
import { conf } from "@/setup/config"; |
||||||
|
|
||||||
|
function FooterLink(props: { |
||||||
|
href: string; |
||||||
|
children: React.ReactNode; |
||||||
|
icon: Icons; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<a |
||||||
|
href={props.href} |
||||||
|
target="_blank" |
||||||
|
className="inline-flex items-center space-x-3 transition-colors duration-200 hover:text-type-emphasis" |
||||||
|
rel="noreferrer" |
||||||
|
> |
||||||
|
<Icon icon={props.icon} className="text-2xl" /> |
||||||
|
<span className="font-medium">{props.children}</span> |
||||||
|
</a> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
function Dmca() { |
||||||
|
const { t } = useTranslation(); |
||||||
|
return ( |
||||||
|
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo"> |
||||||
|
{t("footer.links.dmca")} |
||||||
|
</FooterLink> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function Footer() { |
||||||
|
const { t } = useTranslation(); |
||||||
|
|
||||||
|
return ( |
||||||
|
<footer className="mt-16 border-t border-type-divider py-16 md:py-8"> |
||||||
|
<WideContainer ultraWide classNames="grid md:grid-cols-2 gap-16 md:gap-8"> |
||||||
|
<div> |
||||||
|
<div className="inline-block"> |
||||||
|
<BrandPill /> |
||||||
|
</div> |
||||||
|
<p className="mt-4 lg:max-w-[400px]">{t("footer.tagline")}</p> |
||||||
|
</div> |
||||||
|
<div className="md:text-right"> |
||||||
|
<h3 className="font-semibold text-type-emphasis"> |
||||||
|
{t("footer.legal.disclaimer")} |
||||||
|
</h3> |
||||||
|
<p className="mt-3">{t("footer.legal.disclaimerText")}</p> |
||||||
|
</div> |
||||||
|
<div className="space-x-[2rem]"> |
||||||
|
<FooterLink icon={Icons.GITHUB} href={conf().GITHUB_LINK}> |
||||||
|
{t("footer.links.github")} |
||||||
|
</FooterLink> |
||||||
|
<FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}> |
||||||
|
{t("footer.links.discord")} |
||||||
|
</FooterLink> |
||||||
|
<div className="inline md:hidden"> |
||||||
|
<Dmca /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
<div className="hidden items-center justify-end md:flex"> |
||||||
|
<Dmca /> |
||||||
|
</div> |
||||||
|
</WideContainer> |
||||||
|
</footer> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export function FooterView(props: { |
||||||
|
children: React.ReactNode; |
||||||
|
className?: string; |
||||||
|
}) { |
||||||
|
return ( |
||||||
|
<div |
||||||
|
className={["flex min-h-screen flex-col", props.className || ""].join( |
||||||
|
" " |
||||||
|
)} |
||||||
|
> |
||||||
|
<div style={{ flex: "1 0 auto" }}>{props.children}</div> |
||||||
|
<Footer /> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -0,0 +1,7 @@ |
|||||||
|
.flare-enabled .flare-light { |
||||||
|
opacity: 1 !important; |
||||||
|
} |
||||||
|
|
||||||
|
.hover\:flare-enabled:hover .flare-light { |
||||||
|
opacity: 1 !important; |
||||||
|
} |
@ -0,0 +1,90 @@ |
|||||||
|
import c from "classnames"; |
||||||
|
import { ReactNode, useEffect, useRef } from "react"; |
||||||
|
import "./Flare.css"; |
||||||
|
|
||||||
|
export interface FlareProps { |
||||||
|
className?: string; |
||||||
|
backgroundClass: string; |
||||||
|
flareSize?: number; |
||||||
|
cssColorVar?: string; |
||||||
|
enabled?: boolean; |
||||||
|
} |
||||||
|
|
||||||
|
const SIZE_DEFAULT = 200; |
||||||
|
const CSS_VAR_DEFAULT = "--colors-global-accentA"; |
||||||
|
|
||||||
|
function Base(props: { className?: string; children?: ReactNode }) { |
||||||
|
return <div className={c(props.className, "relative")}>{props.children}</div>; |
||||||
|
} |
||||||
|
|
||||||
|
function Child(props: { className?: string; children?: ReactNode }) { |
||||||
|
return <div className={c(props.className, "relative")}>{props.children}</div>; |
||||||
|
} |
||||||
|
|
||||||
|
function Light(props: FlareProps) { |
||||||
|
const outerRef = useRef<HTMLDivElement>(null); |
||||||
|
const size = props.flareSize ?? SIZE_DEFAULT; |
||||||
|
const cssVar = props.cssColorVar ?? CSS_VAR_DEFAULT; |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
function mouseMove(e: MouseEvent) { |
||||||
|
if (!outerRef.current) return; |
||||||
|
const rect = outerRef.current.getBoundingClientRect(); |
||||||
|
const halfSize = size / 2; |
||||||
|
outerRef.current.style.setProperty( |
||||||
|
"--bg-x", |
||||||
|
`${(e.clientX - rect.left - halfSize).toFixed(0)}px` |
||||||
|
); |
||||||
|
outerRef.current.style.setProperty( |
||||||
|
"--bg-y", |
||||||
|
`${(e.clientY - rect.top - halfSize).toFixed(0)}px` |
||||||
|
); |
||||||
|
} |
||||||
|
document.addEventListener("mousemove", mouseMove); |
||||||
|
|
||||||
|
return () => document.removeEventListener("mousemove", mouseMove); |
||||||
|
}, [size]); |
||||||
|
|
||||||
|
return ( |
||||||
|
<div |
||||||
|
ref={outerRef} |
||||||
|
className={c( |
||||||
|
"flare-light pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-[400ms]", |
||||||
|
props.className, |
||||||
|
{ |
||||||
|
"!opacity-100": props.enabled ?? false, |
||||||
|
} |
||||||
|
)} |
||||||
|
style={{ |
||||||
|
backgroundImage: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`, |
||||||
|
backgroundPosition: `var(--bg-x) var(--bg-y)`, |
||||||
|
backgroundRepeat: "no-repeat", |
||||||
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`, |
||||||
|
}} |
||||||
|
> |
||||||
|
<div |
||||||
|
className={c( |
||||||
|
"absolute inset-[1px] overflow-hidden", |
||||||
|
props.className, |
||||||
|
props.backgroundClass |
||||||
|
)} |
||||||
|
> |
||||||
|
<div |
||||||
|
className="absolute inset-0 opacity-10" |
||||||
|
style={{ |
||||||
|
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`, |
||||||
|
backgroundPosition: `var(--bg-x) var(--bg-y)`, |
||||||
|
backgroundRepeat: "no-repeat", |
||||||
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`, |
||||||
|
}} |
||||||
|
/> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
||||||
|
|
||||||
|
export const Flare = { |
||||||
|
Base, |
||||||
|
Light, |
||||||
|
Child, |
||||||
|
}; |
@ -0,0 +1,78 @@ |
|||||||
|
.lightbar, .lightbar-visual { |
||||||
|
position: absolute; |
||||||
|
top: 0; |
||||||
|
width: 500vw; |
||||||
|
height: 800px; |
||||||
|
pointer-events: none; |
||||||
|
user-select: none; |
||||||
|
} |
||||||
|
|
||||||
|
.lightbar { |
||||||
|
left: 50vw; |
||||||
|
transform: translateX(-50%); |
||||||
|
} |
||||||
|
|
||||||
|
@screen sm { |
||||||
|
.lightbar, .lightbar-visual { |
||||||
|
width: 150vw; |
||||||
|
} |
||||||
|
|
||||||
|
.lightbar { |
||||||
|
left: -25vw; |
||||||
|
transform: initial; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
.lightbar { |
||||||
|
display: flex; |
||||||
|
justify-content: center; |
||||||
|
align-items: center; |
||||||
|
--d: 3s; |
||||||
|
--animation: cubic-bezier(.75, -0.00, .25, 1); |
||||||
|
animation: boot var(--d) var(--animation) forwards; |
||||||
|
} |
||||||
|
|
||||||
|
.lightbar-visual { |
||||||
|
left: 0; |
||||||
|
--top: theme('colors.background.main'); |
||||||
|
--bottom: theme('colors.lightBar.light'); |
||||||
|
--first: conic-gradient(from 90deg at 80% 50%, var(--top), var(--bottom)); |
||||||
|
--second: conic-gradient(from 270deg at 20% 50%, var(--bottom), var(--top)); |
||||||
|
mask-image: radial-gradient(100% 50% at center center, black, transparent); |
||||||
|
background-image: var(--first), var(--second); |
||||||
|
background-position-x: 1%, 99%; |
||||||
|
background-position-y: 0%, 0%; |
||||||
|
background-size: 50% 100%, 50% 100%; |
||||||
|
opacity: 1; |
||||||
|
transform: rotate(180deg) translateZ(0px) translateY(400px); |
||||||
|
transform-origin: center center; |
||||||
|
background-repeat: no-repeat; |
||||||
|
animation: lightbarBoot var(--d) var(--animation) forwards; |
||||||
|
} |
||||||
|
|
||||||
|
.lightbar canvas { |
||||||
|
width: 40%; |
||||||
|
height: 300px; |
||||||
|
transform: translateY(-250px); |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes boot { |
||||||
|
from { |
||||||
|
|
||||||
|
opacity: 0.25; |
||||||
|
} |
||||||
|
|
||||||
|
to { |
||||||
|
opacity: 1; |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
@keyframes lightbarBoot { |
||||||
|
0% { |
||||||
|
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(0.8); |
||||||
|
} |
||||||
|
|
||||||
|
100% { |
||||||
|
transform: rotate(180deg) translateZ(0px) translateY(400px) scaleX(1); |
||||||
|
} |
||||||
|
} |
@ -0,0 +1,171 @@ |
|||||||
|
import { useEffect, useRef } from "react"; |
||||||
|
import "./Lightbar.css"; |
||||||
|
|
||||||
|
class Particle { |
||||||
|
x = 0; |
||||||
|
|
||||||
|
y = 0; |
||||||
|
|
||||||
|
radius = 0; |
||||||
|
|
||||||
|
direction = 0; |
||||||
|
|
||||||
|
speed = 0; |
||||||
|
|
||||||
|
lifetime = 0; |
||||||
|
|
||||||
|
ran = 0; |
||||||
|
|
||||||
|
image: null | HTMLImageElement = null; |
||||||
|
|
||||||
|
constructor(canvas: HTMLCanvasElement, { doFish } = { doFish: false }) { |
||||||
|
if (doFish) { |
||||||
|
this.image = new Image(); |
||||||
|
if (this.image) this.image.src = "/fishie.png"; |
||||||
|
} |
||||||
|
|
||||||
|
this.reset(canvas); |
||||||
|
this.initialize(canvas); |
||||||
|
} |
||||||
|
|
||||||
|
reset(canvas: HTMLCanvasElement) { |
||||||
|
this.x = Math.round((Math.random() * canvas.width) / 2 + canvas.width / 4); |
||||||
|
this.y = Math.random() * 100 + 5; |
||||||
|
|
||||||
|
this.radius = 1 + Math.floor(Math.random() * 0.5); |
||||||
|
this.direction = (Math.random() * Math.PI) / 2 + Math.PI / 4; |
||||||
|
this.speed = 0.02 + Math.random() * 0.08; |
||||||
|
|
||||||
|
const second = 60; |
||||||
|
this.lifetime = second * 3 + Math.random() * (second * 30); |
||||||
|
|
||||||
|
if (this.image) { |
||||||
|
this.direction = Math.random() <= 0.5 ? 0 : Math.PI; |
||||||
|
this.lifetime = 30 * second; |
||||||
|
} |
||||||
|
|
||||||
|
this.ran = 0; |
||||||
|
} |
||||||
|
|
||||||
|
initialize(canvas: HTMLCanvasElement) { |
||||||
|
this.ran = Math.random() * this.lifetime; |
||||||
|
const baseSpeed = this.speed; |
||||||
|
this.speed = Math.random() * this.lifetime * baseSpeed; |
||||||
|
this.update(canvas); |
||||||
|
this.speed = baseSpeed; |
||||||
|
} |
||||||
|
|
||||||
|
update(canvas: HTMLCanvasElement) { |
||||||
|
this.ran += 1; |
||||||
|
|
||||||
|
const addX = this.speed * Math.cos(this.direction); |
||||||
|
const addY = this.speed * Math.sin(this.direction); |
||||||
|
this.x += addX; |
||||||
|
this.y += addY; |
||||||
|
|
||||||
|
if (this.ran > this.lifetime) { |
||||||
|
this.reset(canvas); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
render(canvas: HTMLCanvasElement) { |
||||||
|
const ctx = canvas.getContext("2d"); |
||||||
|
if (!ctx) return; |
||||||
|
|
||||||
|
ctx.save(); |
||||||
|
ctx.beginPath(); |
||||||
|
|
||||||
|
const x = this.ran / this.lifetime; |
||||||
|
const o = (x - x * x) * 4; |
||||||
|
ctx.globalAlpha = Math.max(0, o * 0.8); |
||||||
|
|
||||||
|
if (this.image) { |
||||||
|
ctx.translate(this.x, this.y); |
||||||
|
const w = 10; |
||||||
|
const h = (this.image.naturalWidth / this.image.naturalHeight) * w; |
||||||
|
ctx.rotate(this.direction - Math.PI); |
||||||
|
ctx.drawImage(this.image, -w / 2, h, h, w); |
||||||
|
} else { |
||||||
|
ctx.ellipse( |
||||||
|
this.x, |
||||||
|
this.y, |
||||||
|
this.radius, |
||||||
|
this.radius * 1.5, |
||||||
|
this.direction, |
||||||
|
0, |
||||||
|
Math.PI * 2 |
||||||
|
); |
||||||
|
ctx.fillStyle = "white"; |
||||||
|
ctx.fill(); |
||||||
|
} |
||||||
|
ctx.restore(); |
||||||
|
} |
||||||
|
} |
||||||
|
|
||||||
|
function ParticlesCanvas() { |
||||||
|
const canvasRef = useRef<HTMLCanvasElement>(null); |
||||||
|
|
||||||
|
useEffect(() => { |
||||||
|
if (!canvasRef.current) return; |
||||||
|
const canvas = canvasRef.current; |
||||||
|
const particles: Particle[] = []; |
||||||
|
|
||||||
|
canvas.width = canvas.scrollWidth; |
||||||
|
canvas.height = canvas.scrollHeight; |
||||||
|
|
||||||
|
const shouldShowFishie = Math.floor(Math.random() * 600) === 1; |
||||||
|
const particleCount = 20; |
||||||
|
|
||||||
|
for (let i = 0; i < particleCount; i += 1) { |
||||||
|
const particle = new Particle(canvas, { |
||||||
|
doFish: shouldShowFishie && i <= particleCount / 2, |
||||||
|
}); |
||||||
|
particles.push(particle); |
||||||
|
} |
||||||
|
|
||||||
|
let shouldTick = true; |
||||||
|
let handle: ReturnType<typeof requestAnimationFrame> | null = null; |
||||||
|
function particlesLoop() { |
||||||
|
const ctx = canvas.getContext("2d"); |
||||||
|
if (!ctx) return; |
||||||
|
|
||||||
|
if (shouldTick) { |
||||||
|
for (const particle of particles) { |
||||||
|
particle.update(canvas); |
||||||
|
} |
||||||
|
shouldTick = false; |
||||||
|
} |
||||||
|
|
||||||
|
canvas.width = canvas.scrollWidth; |
||||||
|
canvas.height = canvas.scrollHeight; |
||||||
|
for (const particle of particles) { |
||||||
|
particle.render(canvas); |
||||||
|
} |
||||||
|
|
||||||
|
handle = requestAnimationFrame(particlesLoop); |
||||||
|
} |
||||||
|
const interval = setInterval(() => { |
||||||
|
shouldTick = true; |
||||||
|
}, 1e3 / 120); // tick 120 times a sec
|
||||||
|
|
||||||
|
particlesLoop(); |
||||||
|
|
||||||
|
return () => { |
||||||
|
if (handle) cancelAnimationFrame(handle); |
||||||
|
clearInterval(interval); |
||||||
|
}; |
||||||
|
}, []); |
||||||
|
|
||||||
|
return <canvas className="particles" ref={canvasRef} />; |
||||||
|
} |
||||||
|
|
||||||
|
export function Lightbar(props: { className?: string }) { |
||||||
|
return ( |
||||||
|
<div className={props.className}> |
||||||
|
<div className="lightbar"> |
||||||
|
<ParticlesCanvas /> |
||||||
|
<div className="lightbar-visual" /> |
||||||
|
</div> |
||||||
|
</div> |
||||||
|
); |
||||||
|
} |
@ -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 "@/pages/layouts/HomeLayout"; |
||||||
|
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart"; |
||||||
|
import { HeroPart } from "@/pages/parts/home/HeroPart"; |
||||||
|
import { WatchingPart } from "@/pages/parts/home/WatchingPart"; |
||||||
|
import { SearchListPart } from "@/pages/parts/search/SearchListPart"; |
||||||
|
import { SearchLoadingPart } from "@/pages/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> |
||||||
|
); |
||||||
|
} |
@ -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 "@/pages/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> |
||||||
|
); |
||||||
|
} |
@ -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,58 @@ |
|||||||
|
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 { useBookmarkContext } from "@/state/bookmark"; |
||||||
|
import { useWatchedContext } from "@/state/watched"; |
||||||
|
|
||||||
|
export function BookmarksPart() { |
||||||
|
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> |
||||||
|
); |
||||||
|
} |
@ -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,50 @@ |
|||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
||||||
|
import { 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"; |
||||||
|
|
||||||
|
export function WatchingPart() { |
||||||
|
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> |
||||||
|
); |
||||||
|
} |
@ -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 "@/pages/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,148 +0,0 @@ |
|||||||
import { useMemo } from "react"; |
|
||||||
import { useTranslation } from "react-i18next"; |
|
||||||
|
|
||||||
import { CaptionCue } from "@/_oldvideo/components/actions/CaptionRendererAction"; |
|
||||||
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"; |
|
||||||
|
|
||||||
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> |
|
||||||
); |
|
||||||
} |
|
@ -1,87 +0,0 @@ |
|||||||
import { ReactNode } from "react"; |
|
||||||
import { Helmet } from "react-helmet"; |
|
||||||
import { useTranslation } from "react-i18next"; |
|
||||||
|
|
||||||
import { VideoPlayerHeader } from "@/_oldvideo/components/parts/VideoPlayerHeader"; |
|
||||||
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"; |
|
||||||
|
|
||||||
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> |
|
||||||
); |
|
||||||
} |
|
@ -1,107 +0,0 @@ |
|||||||
import pako from "pako"; |
|
||||||
import { useEffect, useState } from "react"; |
|
||||||
|
|
||||||
import { MWMediaType } from "@/backend/metadata/types/mw"; |
|
||||||
import { conf } from "@/setup/config"; |
|
||||||
|
|
||||||
function fromBinary(str: string): Uint8Array { |
|
||||||
const result = new Uint8Array(str.length); |
|
||||||
[...str].forEach((char, i) => { |
|
||||||
result[i] = char.charCodeAt(0); |
|
||||||
}); |
|
||||||
return result; |
|
||||||
} |
|
||||||
|
|
||||||
export function importV2Data({ data, time }: { data: any; time: Date }) { |
|
||||||
const savedTime = localStorage.getItem("mw-migration-date"); |
|
||||||
if (savedTime) { |
|
||||||
if (new Date(savedTime) >= time) { |
|
||||||
// has already migrated this or something newer, skip
|
|
||||||
return false; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
// restore migration data
|
|
||||||
if (data.bookmarks) |
|
||||||
localStorage.setItem("mw-bookmarks", JSON.stringify(data.bookmarks)); |
|
||||||
if (data.videoProgress) |
|
||||||
localStorage.setItem("video-progress", JSON.stringify(data.videoProgress)); |
|
||||||
|
|
||||||
localStorage.setItem("mw-migration-date", time.toISOString()); |
|
||||||
|
|
||||||
return true; |
|
||||||
} |
|
||||||
|
|
||||||
export function EmbedMigration() { |
|
||||||
let hasReceivedMigrationData = false; |
|
||||||
|
|
||||||
const onMessage = (e: any) => { |
|
||||||
const data = e.data; |
|
||||||
if (data && data.isMigrationData && !hasReceivedMigrationData) { |
|
||||||
hasReceivedMigrationData = true; |
|
||||||
const didImport = importV2Data({ |
|
||||||
data: data.data, |
|
||||||
time: data.date, |
|
||||||
}); |
|
||||||
if (didImport) window.location.reload(); |
|
||||||
} |
|
||||||
}; |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
window.addEventListener("message", onMessage); |
|
||||||
|
|
||||||
return () => { |
|
||||||
window.removeEventListener("message", onMessage); |
|
||||||
}; |
|
||||||
}); |
|
||||||
|
|
||||||
return <iframe src="https://movie.squeezebox.dev" hidden />; |
|
||||||
} |
|
||||||
|
|
||||||
export function V2MigrationView() { |
|
||||||
const [done, setDone] = useState(false); |
|
||||||
useEffect(() => { |
|
||||||
const params = new URLSearchParams(window.location.search ?? ""); |
|
||||||
if (!params.has("m-time") || !params.has("m-data")) { |
|
||||||
// migration params missing, just redirect
|
|
||||||
setDone(true); |
|
||||||
return; |
|
||||||
} |
|
||||||
|
|
||||||
const data = JSON.parse( |
|
||||||
pako.inflate(fromBinary(atob(params.get("m-data") as string)), { |
|
||||||
to: "string", |
|
||||||
}) |
|
||||||
); |
|
||||||
const timeOfMigration = new Date(params.get("m-time") as string); |
|
||||||
|
|
||||||
importV2Data({ |
|
||||||
data, |
|
||||||
time: timeOfMigration, |
|
||||||
}); |
|
||||||
|
|
||||||
// finished
|
|
||||||
setDone(true); |
|
||||||
}, []); |
|
||||||
|
|
||||||
// redirect when done
|
|
||||||
useEffect(() => { |
|
||||||
if (!done) return; |
|
||||||
const newUrl = new URL(window.location.href); |
|
||||||
|
|
||||||
const newParams = [] as string[]; |
|
||||||
newUrl.searchParams.forEach((_, key) => newParams.push(key)); |
|
||||||
newParams.forEach((v) => newUrl.searchParams.delete(v)); |
|
||||||
newUrl.searchParams.append("migrated", "1"); |
|
||||||
|
|
||||||
// hash router compatibility
|
|
||||||
newUrl.hash = conf().NORMAL_ROUTER ? "" : `/search/${MWMediaType.MOVIE}`; |
|
||||||
newUrl.pathname = conf().NORMAL_ROUTER |
|
||||||
? `/search/${MWMediaType.MOVIE}` |
|
||||||
: ""; |
|
||||||
|
|
||||||
window.location.href = newUrl.toString(); |
|
||||||
}, [done]); |
|
||||||
|
|
||||||
return null; |
|
||||||
} |
|
@ -1,208 +0,0 @@ |
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react"; |
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react"; |
|
||||||
import { Trans, useTranslation } from "react-i18next"; |
|
||||||
import { useHistory } from "react-router-dom"; |
|
||||||
|
|
||||||
import { Button } from "@/components/Button"; |
|
||||||
import { EditButton } from "@/components/buttons/EditButton"; |
|
||||||
import { Icons } from "@/components/Icon"; |
|
||||||
import { Modal, ModalCard } from "@/components/layout/Modal"; |
|
||||||
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"; |
|
||||||
|
|
||||||
import { EmbedMigration } from "../other/v2Migration"; |
|
||||||
|
|
||||||
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> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
function NewDomainModal() { |
|
||||||
const [show, setShow] = useState( |
|
||||||
new URLSearchParams(window.location.search).get("migrated") === "1" || |
|
||||||
localStorage.getItem("mw-show-domain-modal") === "true" |
|
||||||
); |
|
||||||
const [loaded, setLoaded] = useState(false); |
|
||||||
const history = useHistory(); |
|
||||||
const { t } = useTranslation(); |
|
||||||
|
|
||||||
const closeModal = useCallback(() => { |
|
||||||
localStorage.setItem("mw-show-domain-modal", "false"); |
|
||||||
setShow(false); |
|
||||||
}, []); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
const newParams = new URLSearchParams(history.location.search); |
|
||||||
newParams.delete("migrated"); |
|
||||||
if (newParams.get("migrated") === "1") |
|
||||||
localStorage.setItem("mw-show-domain-modal", "true"); |
|
||||||
history.replace({ |
|
||||||
search: newParams.toString(), |
|
||||||
}); |
|
||||||
}, [history]); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setTimeout(() => { |
|
||||||
setLoaded(true); |
|
||||||
}, 500); |
|
||||||
}, []); |
|
||||||
|
|
||||||
// If you see this bit of code, don't snitch!
|
|
||||||
// We need to urge users to update their bookmarks and usage,
|
|
||||||
// so we're putting a fake deadline that's only 2 weeks away.
|
|
||||||
const day = 1e3 * 60 * 60 * 24; |
|
||||||
const months = [ |
|
||||||
"January", |
|
||||||
"February", |
|
||||||
"March", |
|
||||||
"April", |
|
||||||
"May", |
|
||||||
"June", |
|
||||||
"July", |
|
||||||
"August", |
|
||||||
"September", |
|
||||||
"October", |
|
||||||
"November", |
|
||||||
"December", |
|
||||||
]; |
|
||||||
const firstVisitToSite = new Date( |
|
||||||
localStorage.getItem("firstVisitToSite") || Date.now() |
|
||||||
); |
|
||||||
localStorage.setItem("firstVisitToSite", firstVisitToSite.toISOString()); |
|
||||||
const fakeEndResult = new Date(firstVisitToSite.getTime() + 14 * day); |
|
||||||
const endDateString = `${fakeEndResult.getDate()} ${ |
|
||||||
months[fakeEndResult.getMonth()] |
|
||||||
} ${fakeEndResult.getFullYear()}`;
|
|
||||||
|
|
||||||
return ( |
|
||||||
<Modal show={show && loaded}> |
|
||||||
<ModalCard> |
|
||||||
<div className="mb-12"> |
|
||||||
<div |
|
||||||
className="absolute left-0 top-0 h-[300px] w-full -translate-y-1/2 opacity-50" |
|
||||||
style={{ |
|
||||||
backgroundImage: `radial-gradient(ellipse 70% 9rem, #7831C1 0%, transparent 100%)`, |
|
||||||
}} |
|
||||||
/> |
|
||||||
<div className="relative flex items-center justify-center"> |
|
||||||
<div className="rounded-full bg-bink-200 px-12 py-4 text-center text-sm font-bold text-white md:text-xl"> |
|
||||||
{t("v3.newDomain")} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
<div className="space-y-6"> |
|
||||||
<h2 className="text-2xl font-bold text-white"> |
|
||||||
{t("v3.newSiteTitle")} |
|
||||||
</h2> |
|
||||||
<p className="leading-7"> |
|
||||||
<Trans i18nKey="v3.newDomainText" values={{ date: endDateString }}> |
|
||||||
<span className="text-slate-300" /> |
|
||||||
<span className="font-bold text-white" /> |
|
||||||
</Trans> |
|
||||||
</p> |
|
||||||
<p>{t("v3.tireless")}</p> |
|
||||||
</div> |
|
||||||
<div className="mb-6 mt-16 flex items-center justify-center"> |
|
||||||
<Button icon={Icons.PLAY} onClick={() => closeModal()}> |
|
||||||
{t("v3.leaveAnnouncement")} |
|
||||||
</Button> |
|
||||||
</div> |
|
||||||
</ModalCard> |
|
||||||
</Modal> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function HomeView() { |
|
||||||
return ( |
|
||||||
<div className="mb-16"> |
|
||||||
<EmbedMigration /> |
|
||||||
<NewDomainModal /> |
|
||||||
<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,66 +0,0 @@ |
|||||||
import { useCallback, useState } from "react"; |
|
||||||
import { Helmet } from "react-helmet"; |
|
||||||
import { useTranslation } from "react-i18next"; |
|
||||||
import Sticky from "react-stickynode"; |
|
||||||
|
|
||||||
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 ( |
|
||||||
<> |
|
||||||
<div className="relative z-10 mb-16 sm:mb-24"> |
|
||||||
<Helmet> |
|
||||||
<title>{t("global.name")}</title> |
|
||||||
</Helmet> |
|
||||||
<Navigation bg={showBg} /> |
|
||||||
<ThinContainer> |
|
||||||
<div className="mt-44 space-y-16 text-center"> |
|
||||||
<div className="absolute bottom-0 left-0 right-0 flex h-0 justify-center"> |
|
||||||
<div className="absolute bottom-4 h-[100vh] w-[3000px] rounded-[100%] bg-denim-300 md:w-[200vw]" /> |
|
||||||
</div> |
|
||||||
<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> |
|
||||||
</> |
|
||||||
); |
|
||||||
} |
|
Loading…
Reference in new issue