mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-28 08:36:06 +00:00
Merge branch 'v4' into refactor-player
This commit is contained in:
commit
984e75d82f
3
.github/CODEOWNERS
vendored
Normal file
3
.github/CODEOWNERS
vendored
Normal file
|
@ -0,0 +1,3 @@
|
|||
* @movie-web/core
|
||||
|
||||
.github @binaryoverload
|
|
@ -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
BIN
public/fishie.png
Normal file
Binary file not shown.
After Width: | Height: | Size: 314 B |
|
@ -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,
|
||||
|
|
|
@ -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),
|
||||
},
|
||||
};
|
||||
},
|
||||
});
|
1
src/backend/providers/flixhq/common.ts
Normal file
1
src/backend/providers/flixhq/common.ts
Normal file
|
@ -0,0 +1 @@
|
|||
export const flixHqBase = "https://flixhq.to";
|
36
src/backend/providers/flixhq/index.ts
Normal file
36
src/backend/providers/flixhq/index.ts
Normal 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),
|
||||
},
|
||||
],
|
||||
};
|
||||
},
|
||||
});
|
41
src/backend/providers/flixhq/scrape.ts
Normal file
41
src/backend/providers/flixhq/scrape.ts
Normal 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;
|
||||
}
|
43
src/backend/providers/flixhq/search.ts
Normal file
43
src/backend/providers/flixhq/search.ts
Normal 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;
|
||||
}
|
|
@ -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;
|
||||
|
|
|
@ -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} `;
|
||||
|
|
|
@ -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,
|
||||
},
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
|
86
src/components/layout/Footer.tsx
Normal file
86
src/components/layout/Footer.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
|
@ -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>
|
||||
);
|
||||
|
|
|
@ -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?.()}
|
||||
/>
|
||||
);
|
||||
|
||||
|
|
7
src/components/utils/Flare.css
Normal file
7
src/components/utils/Flare.css
Normal file
|
@ -0,0 +1,7 @@
|
|||
.flare-enabled .flare-light {
|
||||
opacity: 1 !important;
|
||||
}
|
||||
|
||||
.hover\:flare-enabled:hover .flare-light {
|
||||
opacity: 1 !important;
|
||||
}
|
90
src/components/utils/Flare.tsx
Normal file
90
src/components/utils/Flare.tsx
Normal 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,
|
||||
};
|
78
src/components/utils/Lightbar.css
Normal file
78
src/components/utils/Lightbar.css
Normal 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);
|
||||
}
|
||||
}
|
171
src/components/utils/Lightbar.tsx
Normal file
171
src/components/utils/Lightbar.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
64
src/pages/HomePage.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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;
|
||||
|
23
src/pages/errors/NotFoundPage.tsx
Normal file
23
src/pages/errors/NotFoundPage.tsx
Normal 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>
|
||||
);
|
||||
}
|
14
src/pages/layouts/HomeLayout.tsx
Normal file
14
src/pages/layouts/HomeLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
11
src/pages/layouts/PageLayout.tsx
Normal file
11
src/pages/layouts/PageLayout.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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
|
33
src/pages/parts/errors/ErrorWrapperPart.tsx
Normal file
33
src/pages/parts/errors/ErrorWrapperPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
22
src/pages/parts/errors/MediaNotFoundPart.tsx
Normal file
22
src/pages/parts/errors/MediaNotFoundPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
24
src/pages/parts/errors/ProviderNotFoundPart.tsx
Normal file
24
src/pages/parts/errors/ProviderNotFoundPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
58
src/pages/parts/home/BookmarksPart.tsx
Normal file
58
src/pages/parts/home/BookmarksPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
55
src/pages/parts/home/HeroPart.tsx
Normal file
55
src/pages/parts/home/HeroPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
50
src/pages/parts/home/WatchingPart.tsx
Normal file
50
src/pages/parts/home/WatchingPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
88
src/pages/parts/search/SearchListPart.tsx
Normal file
88
src/pages/parts/search/SearchListPart.tsx
Normal 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>
|
||||
);
|
||||
}
|
|
@ -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") || "..."} />
|
|
@ -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")
|
||||
)}
|
||||
/>
|
||||
</>
|
||||
|
|
|
@ -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;
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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."
|
||||
}
|
||||
}
|
||||
}
|
||||
|
|
|
@ -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>
|
||||
</>
|
||||
);
|
||||
}
|
|
@ -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"
|
||||
}
|
||||
}
|
||||
}
|
||||
}
|
||||
})
|
||||
]
|
||||
};
|
||||
|
|
Loading…
Reference in a new issue