Merge branch 'v4' into refactor-player

This commit is contained in:
mrjvs 2023-09-01 15:18:00 +02:00
commit 984e75d82f
56 changed files with 1260 additions and 6820 deletions

3
.github/CODEOWNERS vendored Normal file
View file

@ -0,0 +1,3 @@
* @movie-web/core
.github @binaryoverload

View file

@ -10,6 +10,7 @@
"@sentry/integrations": "^7.49.0",
"@sentry/react": "^7.49.0",
"@use-gesture/react": "^10.2.24",
"classnames": "^2.3.2",
"core-js": "^3.29.1",
"crypto-js": "^4.1.1",
"dompurify": "^3.0.1",
@ -97,6 +98,7 @@
"prettier-plugin-tailwindcss": "^0.1.7",
"tailwind-scrollbar": "^2.0.1",
"tailwindcss": "^3.2.4",
"tailwindcss-themer": "^3.1.0",
"typescript": "^4.6.4",
"vite": "^4.0.1",
"vite-plugin-checker": "^0.5.6",

BIN
public/fishie.png Normal file

Binary file not shown.

After

Width:  |  Height:  |  Size: 314 B

View file

@ -51,27 +51,35 @@ registerEmbedScraper({
}
);
let sources:
| {
file: string;
type: string;
let sources: { file: string; type: string } | null = null;
if (!isJSON(streamRes.sources)) {
const decryptionKey = JSON.parse(
await proxiedFetch<string>(
`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`
)
) as [number, number][];
let extractedKey = "";
const sourcesArray = streamRes.sources.split("");
for (const index of decryptionKey) {
for (let i: number = index[0]; i < index[1]; i += 1) {
extractedKey += streamRes.sources[i];
sourcesArray[i] = "";
}
| string = streamRes.sources;
if (!isJSON(sources) || typeof sources === "string") {
const decryptionKey = await proxiedFetch<string>(
`https://raw.githubusercontent.com/enimax-anime/key/e4/key.txt`
);
const decryptedStream = AES.decrypt(sources, decryptionKey).toString(
enc.Utf8
);
}
const decryptedStream = AES.decrypt(
sourcesArray.join(""),
extractedKey
).toString(enc.Utf8);
const parsedStream = JSON.parse(decryptedStream)[0];
if (!parsedStream) throw new Error("No stream found");
sources = parsedStream as { file: string; type: string };
sources = parsedStream;
}
if (!sources) throw new Error("upcloud source not found");
return {
embedId: MWEmbedType.UPCLOUD,
streamUrl: sources.file,

View file

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

View file

@ -0,0 +1 @@
export const flixHqBase = "https://flixhq.to";

View file

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

View file

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

View file

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

View file

@ -120,6 +120,7 @@ registerProvider({
id: "hdwatched",
displayName: "HDwatched",
rank: 150,
disabled: true, // very slow, haven't seen it work for a while
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape(options) {
const { media, progress } = options;

View file

@ -9,6 +9,7 @@ registerProvider({
id: "sflix",
displayName: "Sflix",
rank: 50,
disabled: true, // domain dead
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
async scrape({ media, episode, progress }) {
let searchQuery = `${media.meta.title} `;

View file

@ -18,6 +18,12 @@ import { compareTitle } from "@/utils/titleMatch";
const nanoid = customAlphabet("0123456789abcdef", 32);
function makeFasterUrl(url: string) {
const fasterUrl = new URL(url);
fasterUrl.host = "mp4.shegu.net"; // this domain is faster
return fasterUrl.toString();
}
const qualityMap = {
"360p": MWStreamQuality.Q360P,
"480p": MWStreamQuality.Q480P,
@ -199,7 +205,7 @@ registerProvider({
return {
embeds: [],
stream: {
streamUrl: hdQuality.path,
streamUrl: makeFasterUrl(hdQuality.path),
quality: qualityMap[hdQuality.quality as QualityInMap],
type: MWStreamType.MP4,
captions: mappedCaptions,
@ -248,13 +254,14 @@ registerProvider({
const mappedCaptions = subtitleRes.list
.map(convertSubtitles)
.filter(Boolean);
return {
embeds: [],
stream: {
quality: qualityMap[
hdQuality.quality as QualityInMap
] as MWStreamQuality,
streamUrl: hdQuality.path,
streamUrl: makeFasterUrl(hdQuality.path),
type: MWStreamType.MP4,
captions: mappedCaptions,
},

View file

@ -1,4 +1,8 @@
import c from "classnames";
import { useState } from "react";
import { MWQuery } from "@/backend/metadata/types/mw";
import { Flare } from "@/components/utils/Flare";
import { Icon, Icons } from "./Icon";
import { TextInputControl } from "./text-inputs/TextInputControl";
@ -11,6 +15,8 @@ export interface SearchBarProps {
}
export function SearchBarInput(props: SearchBarProps) {
const [focused, setFocused] = useState(false);
function setSearch(value: string) {
props.onChange(
{
@ -22,18 +28,42 @@ export function SearchBarInput(props: SearchBarProps) {
}
return (
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
<Icon icon={Icons.SEARCH} />
</div>
<TextInputControl
onUnFocus={props.onUnFocus}
onChange={(val) => setSearch(val)}
value={props.value.searchQuery}
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
placeholder={props.placeholder}
<Flare.Base
className={c({
"hover:flare-enabled group relative flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center":
true,
"bg-search-background": !focused,
"bg-search-focused": focused,
})}
>
<Flare.Light
flareSize={400}
enabled={focused}
className="rounded-[28px]"
backgroundClass={c({
"transition-colors": true,
"bg-search-background": !focused,
"bg-search-focused": focused,
})}
/>
</div>
<Flare.Child className="flex flex-1 flex-col">
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon">
<Icon icon={Icons.SEARCH} />
</div>
<TextInputControl
onUnFocus={() => {
setFocused(false);
props.onUnFocus();
}}
onFocus={() => setFocused(true)}
onChange={(val) => setSearch(val)}
value={props.value.searchQuery}
className="w-full flex-1 bg-transparent px-4 py-4 pl-12 text-search-text placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2"
placeholder={props.placeholder}
/>
</Flare.Child>
</Flare.Base>
);
}

View file

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

View file

@ -1,11 +1,11 @@
import { ReactNode, useState } from "react";
import { ReactNode } from "react";
import { Link } from "react-router-dom";
import { IconPatch } from "@/components/buttons/IconPatch";
import { Icons } from "@/components/Icon";
import { Lightbar } from "@/components/utils/Lightbar";
import { useBannerSize } from "@/hooks/useBanner";
import { conf } from "@/setup/config";
import SettingsModal from "@/views/SettingsModal";
import { BrandPill } from "./BrandPill";
@ -16,62 +16,59 @@ export interface NavigationProps {
export function Navigation(props: NavigationProps) {
const bannerHeight = useBannerSize();
const [showModal, setShowModal] = useState(false);
return (
<div
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
style={{
top: `${bannerHeight}px`,
}}
>
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
<div
className={`${
props.bg ? "opacity-100" : "opacity-0"
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
>
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
</div>
<div className="relative flex w-full items-center justify-center sm:w-fit">
<div className="mr-auto sm:mr-6">
<Link to="/">
<BrandPill clickable />
</Link>
</div>
{props.children}
</div>
<div
className={`${
props.children ? "hidden sm:flex" : "flex"
} relative flex-row gap-4`}
>
<IconPatch
className="text-2xl text-white"
icon={Icons.GEAR}
clickable
onClick={() => {
setShowModal(true);
}}
/>
<a
href={conf().DISCORD_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.DISCORD} clickable />
</a>
<a
href={conf().GITHUB_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.GITHUB} clickable />
</a>
<>
<div className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center">
<div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0">
<Lightbar />
</div>
</div>
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
</div>
<div
className="fixed left-0 right-0 top-0 z-10 min-h-[150px]"
style={{
top: `${bannerHeight}px`,
}}
>
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
<div
className={`${
props.bg ? "opacity-100" : "opacity-0"
} absolute inset-0 block bg-background-main transition-opacity duration-300`}
>
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
</div>
<div className="relative flex w-full items-center justify-center sm:w-fit">
<div className="mr-auto sm:mr-6">
<Link to="/">
<BrandPill clickable />
</Link>
</div>
{props.children}
</div>
<div
className={`${
props.children ? "hidden sm:flex" : "flex"
} relative flex-row gap-4`}
>
<a
href={conf().DISCORD_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.DISCORD} clickable />
</a>
<a
href={conf().GITHUB_LINK}
target="_blank"
rel="noreferrer"
className="text-2xl text-white"
>
<IconPatch icon={Icons.GITHUB} clickable />
</a>
</div>
</div>
</div>
</>
);
}

View file

@ -3,14 +3,15 @@ import { ReactNode } from "react";
interface WideContainerProps {
classNames?: string;
children?: ReactNode;
ultraWide?: boolean;
}
export function WideContainer(props: WideContainerProps) {
return (
<div
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
props.classNames || ""
}`}
className={`mx-auto max-w-full px-8 ${
props.ultraWide ? "w-[1300px] sm:px-16" : "w-[900px] sm:px-8"
} ${props.classNames || ""}`}
>
{props.children}
</div>

View file

@ -1,9 +1,11 @@
import c from "classnames";
import { useTranslation } from "react-i18next";
import { Link } from "react-router-dom";
import { TMDBMediaToId } from "@/backend/metadata/tmdb";
import { MWMediaMeta } from "@/backend/metadata/types/mw";
import { DotList } from "@/components/text/DotList";
import { Flare } from "@/components/utils/Flare";
import { IconPatch } from "../buttons/IconPatch";
import { Icons } from "../Icon";
@ -39,19 +41,27 @@ function MediaCardContent({
if (media.year) dotListContent.push(media.year);
return (
<div
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
canLink ? "hover:bg-opacity-100" : ""
<Flare.Base
className={`group -m-3 mb-2 rounded-xl bg-background-main transition-colors duration-100 ${
canLink ? "hover:bg-mediaCard-hoverBackground" : ""
}`}
>
<article
<Flare.Light
flareSize={300}
cssColorVar="--colors-mediaCard-hoverAccent"
backgroundClass="bg-mediaCard-hoverBackground duration-100"
className={c({
"rounded-xl bg-background-main group-hover:opacity-100": canLink,
})}
/>
<Flare.Child
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
canLink ? "group-hover:scale-95" : ""
}`}
>
<div
className={[
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-100",
closable ? "" : "group-hover:rounded-lg",
].join(" ")}
style={{
@ -61,13 +71,12 @@ function MediaCardContent({
{series ? (
<div
className={[
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
closable ? "" : "group-hover:bg-denim-500",
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
].join(" ")}
>
<p
className={[
"text-center text-xs font-bold text-slate-400 transition-colors",
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
closable ? "" : "group-hover:text-white",
].join(" ")}
>
@ -82,19 +91,19 @@ function MediaCardContent({
{percentage !== undefined ? (
<>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
canLink ? "group-hover:from-denim-100" : ""
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
canLink ? "group-hover:from-denim-100" : ""
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
}`}
/>
<div className="absolute inset-x-0 bottom-0 p-3">
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
<div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor">
<div
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor"
style={{
width: percentageString,
}}
@ -105,13 +114,13 @@ function MediaCardContent({
) : null}
<div
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-200 ${
closable ? "opacity-100" : "pointer-events-none opacity-0"
}`}
>
<IconPatch
clickable
className="text-2xl text-slate-400"
className="text-2xl text-mediaCard-badgeText"
onClick={() => closable && onClose?.()}
icon={Icons.X}
/>
@ -121,8 +130,8 @@ function MediaCardContent({
<span>{media.title}</span>
</h1>
<DotList className="text-xs" content={dotListContent} />
</article>
</div>
</Flare.Child>
</Flare.Base>
);
}

View file

@ -7,7 +7,10 @@ interface MediaGridProps {
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
(props, ref) => {
return (
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
<div
className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4"
ref={ref}
>
{props.children}
</div>
);

View file

@ -1,6 +1,7 @@
export interface TextInputControlPropsNoLabel {
onChange?: (data: string) => void;
onUnFocus?: () => void;
onFocus?: () => void;
value?: string;
placeholder?: string;
className?: string;
@ -17,6 +18,7 @@ export function TextInputControl({
label,
className,
placeholder,
onFocus,
}: TextInputControlProps) {
const input = (
<input
@ -26,6 +28,7 @@ export function TextInputControl({
onChange={(e) => onChange && onChange(e.target.value)}
value={value}
onBlur={() => onUnFocus && onUnFocus()}
onFocus={() => onFocus?.()}
/>
);

View file

@ -0,0 +1,7 @@
.flare-enabled .flare-light {
opacity: 1 !important;
}
.hover\:flare-enabled:hover .flare-light {
opacity: 1 !important;
}

View file

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

View file

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

View file

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

View file

@ -3,7 +3,7 @@ import { ThinContainer } from "@/components/layout/ThinContainer";
import { ArrowLink } from "@/components/text/ArrowLink";
import { Title } from "@/components/text/Title";
export default function DeveloperView() {
export default function DeveloperPage() {
return (
<div className="py-48">
<Navigation />

64
src/pages/HomePage.tsx Normal file
View file

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

View file

@ -9,8 +9,7 @@ import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useLoading } from "@/hooks/useLoading";
import { SearchLoadingView } from "./SearchLoadingView";
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
function SearchSuffix(props: { failed?: boolean; results?: number }) {
const { t } = useTranslation();
@ -63,7 +62,7 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
if (searchQuery.searchQuery !== "") runSearch(searchQuery);
}, [searchQuery, runSearchQuery]);
if (loading) return <SearchLoadingView />;
if (loading) return <SearchLoadingPart />;
if (error) return <SearchSuffix failed />;
if (!results) return null;

View file

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

View file

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

View file

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

View file

@ -23,11 +23,12 @@ import { Loading } from "@/components/layout/Loading";
import { useGoBack } from "@/hooks/useGoBack";
import { useLoading } from "@/hooks/useLoading";
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
import { ErrorWrapperPart } from "@/pages/parts/errors/ErrorWrapperPart";
import { MediaNotFoundPart } from "@/pages/parts/errors/MediaNotFoundPart";
import { useWatchedItem } from "@/state/watched";
import { MediaFetchErrorView } from "./MediaErrorView";
import { MediaScrapeLog } from "./MediaScrapeLog";
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
function MediaViewLoading(props: { onGoBack(): void }) {
const { t } = useTranslation();
@ -241,9 +242,9 @@ export function MediaView() {
if (error) return <MediaFetchErrorView />;
if (!meta || !selected)
return (
<NotFoundWrapper video>
<NotFoundMedia />
</NotFoundWrapper>
<ErrorWrapperPart video>
<MediaNotFoundPart />
</ErrorWrapperPart>
);
// scraping view will start scraping and return with onStream

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -2,7 +2,7 @@ import { useTranslation } from "react-i18next";
import { Loading } from "@/components/layout/Loading";
export function SearchLoadingView() {
export function SearchLoadingPart() {
const { t } = useTranslation();
return (
<Loading className="mb-24 mt-40 " text={t("search.loading") || "..."} />

View file

@ -11,14 +11,13 @@ import {
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
import { BannerContextProvider } from "@/hooks/useBanner";
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
import { HomePage } from "@/pages/HomePage";
import { MediaView } from "@/pages/media/MediaView";
import { Layout } from "@/setup/Layout";
import { BookmarkContextProvider } from "@/state/bookmark";
import { SettingsProvider } from "@/state/settings";
import { WatchedContextProvider } from "@/state/watched";
import { MediaView } from "@/views/media/MediaView";
import { NotFoundPage } from "@/views/notfound/NotFoundView";
import { V2MigrationView } from "@/views/other/v2Migration";
import { SearchView } from "@/views/search/SearchView";
function LegacyUrlView({ children }: { children: ReactElement }) {
const location = useLocation();
@ -62,7 +61,6 @@ function App() {
<Layout>
<Switch>
{/* functional routes */}
<Route exact path="/v2-migration" component={V2MigrationView} />
<Route exact path="/s/:query">
<QuickSearch />
</Route>
@ -87,22 +85,20 @@ function App() {
<Route
exact
path={["/browse/:query?", "/"]}
component={SearchView}
component={HomePage}
/>
{/* other */}
<Route
exact
path="/dev"
component={lazy(
() => import("@/views/developer/DeveloperView")
)}
component={lazy(() => import("@/pages/DeveloperPage"))}
/>
<Route
exact
path="/dev/video"
component={lazy(
() => import("@/views/developer/VideoTesterView")
() => import("@/pages/developer/VideoTesterView")
)}
/>
{/* developer routes that can abuse workers are disabled in production */}
@ -112,7 +108,7 @@ function App() {
exact
path="/dev/test"
component={lazy(
() => import("@/views/developer/TestView")
() => import("@/pages/developer/TestView")
)}
/>
@ -120,14 +116,14 @@ function App() {
exact
path="/dev/providers"
component={lazy(
() => import("@/views/developer/ProviderTesterView")
() => import("@/pages/developer/ProviderTesterView")
)}
/>
<Route
exact
path="/dev/embeds"
component={lazy(
() => import("@/views/developer/EmbedTesterView")
() => import("@/pages/developer/EmbedTesterView")
)}
/>
</>

View file

@ -4,9 +4,10 @@
html,
body {
@apply bg-denim-100 font-open-sans text-denim-700 overflow-x-hidden;
@apply bg-background-main font-open-sans text-denim-700 overflow-x-hidden;
min-height: 100vh;
min-height: 100dvh;
position: relative;
}
html[data-full],
@ -198,4 +199,4 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
::-webkit-scrollbar {
/* For some reason the styles don't get applied without the width */
width: 13px;
}
}

View file

@ -10,7 +10,7 @@
"headingTitle": "Search results",
"bookmarks": "Bookmarks",
"continueWatching": "Continue Watching",
"title": "What do you want to watch?",
"title": "What to watch tonight?",
"placeholder": "What do you want to watch?"
},
"media": {
@ -131,5 +131,17 @@
},
"errors": {
"offline": "Check your internet connection"
},
"footer": {
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
"links": {
"github": "GitHub",
"dmca": "DMCA",
"discord": "Discord"
},
"legal": {
"disclaimer": "Disclaimer",
"disclaimerText": "movie-web does not host any files, it merely links to 3rd party services. Legal issues should be taken up with the file hosts and providers. movie-web is not responsible for any media files shown by the video providers."
}
}
}

View file

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

View file

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

View file

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

View file

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

View file

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

View file

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

View file

@ -1,3 +1,5 @@
const themer = require("tailwindcss-themer");
/** @type {import('tailwindcss').Config} */
module.exports = {
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
@ -42,5 +44,61 @@ module.exports = {
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
}
},
plugins: [require("tailwind-scrollbar")]
plugins: [
require("tailwind-scrollbar"),
themer({
defaultTheme: {
extend: {
colors: {
// meta data for the theme itself
global: {
accentA: "#505DBD",
accentB: "#3440A1"
},
// light bar
lightBar: {
light: "#2A2A71"
},
// only used for body colors/textures
background: {
main: "#0A0A10",
accentA: "#6E3B80",
accentB: "#1F1F50"
},
// typography
type: {
emphasis: "#FFFFFF",
text: "#73739D",
dimmed: "#926CAD",
divider: "#262632"
},
// search bar
search: {
background: "#1E1E33",
focused: "#24243C",
placeholder: "#4A4A71",
icon: "#545476",
text: "#FFFFFF"
},
// media cards
mediaCard: {
hoverBackground: "#161622",
hoverAccent: "#4D79A8",
hoverShadow: "#0A0A10",
shadow: "#161622",
barColor: "#4B4B63",
barFillColor: "#BA7FD6",
badge: "#151522",
badgeText: "#5F5F7A"
}
}
}
}
})
]
};

5909
yarn.lock

File diff suppressed because it is too large Load diff