mirror of
https://github.com/movie-web/movie-web.git
synced 2025-01-14 12:39:07 +01: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/integrations": "^7.49.0",
|
||||||
"@sentry/react": "^7.49.0",
|
"@sentry/react": "^7.49.0",
|
||||||
"@use-gesture/react": "^10.2.24",
|
"@use-gesture/react": "^10.2.24",
|
||||||
|
"classnames": "^2.3.2",
|
||||||
"core-js": "^3.29.1",
|
"core-js": "^3.29.1",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"dompurify": "^3.0.1",
|
"dompurify": "^3.0.1",
|
||||||
@ -97,6 +98,7 @@
|
|||||||
"prettier-plugin-tailwindcss": "^0.1.7",
|
"prettier-plugin-tailwindcss": "^0.1.7",
|
||||||
"tailwind-scrollbar": "^2.0.1",
|
"tailwind-scrollbar": "^2.0.1",
|
||||||
"tailwindcss": "^3.2.4",
|
"tailwindcss": "^3.2.4",
|
||||||
|
"tailwindcss-themer": "^3.1.0",
|
||||||
"typescript": "^4.6.4",
|
"typescript": "^4.6.4",
|
||||||
"vite": "^4.0.1",
|
"vite": "^4.0.1",
|
||||||
"vite-plugin-checker": "^0.5.6",
|
"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:
|
let sources: { file: string; type: string } | null = null;
|
||||||
| {
|
|
||||||
file: string;
|
if (!isJSON(streamRes.sources)) {
|
||||||
type: string;
|
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];
|
const parsedStream = JSON.parse(decryptedStream)[0];
|
||||||
if (!parsedStream) throw new Error("No stream found");
|
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 {
|
return {
|
||||||
embedId: MWEmbedType.UPCLOUD,
|
embedId: MWEmbedType.UPCLOUD,
|
||||||
streamUrl: sources.file,
|
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",
|
id: "hdwatched",
|
||||||
displayName: "HDwatched",
|
displayName: "HDwatched",
|
||||||
rank: 150,
|
rank: 150,
|
||||||
|
disabled: true, // very slow, haven't seen it work for a while
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
async scrape(options) {
|
async scrape(options) {
|
||||||
const { media, progress } = options;
|
const { media, progress } = options;
|
||||||
|
@ -9,6 +9,7 @@ registerProvider({
|
|||||||
id: "sflix",
|
id: "sflix",
|
||||||
displayName: "Sflix",
|
displayName: "Sflix",
|
||||||
rank: 50,
|
rank: 50,
|
||||||
|
disabled: true, // domain dead
|
||||||
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
type: [MWMediaType.MOVIE, MWMediaType.SERIES],
|
||||||
async scrape({ media, episode, progress }) {
|
async scrape({ media, episode, progress }) {
|
||||||
let searchQuery = `${media.meta.title} `;
|
let searchQuery = `${media.meta.title} `;
|
||||||
|
@ -18,6 +18,12 @@ import { compareTitle } from "@/utils/titleMatch";
|
|||||||
|
|
||||||
const nanoid = customAlphabet("0123456789abcdef", 32);
|
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 = {
|
const qualityMap = {
|
||||||
"360p": MWStreamQuality.Q360P,
|
"360p": MWStreamQuality.Q360P,
|
||||||
"480p": MWStreamQuality.Q480P,
|
"480p": MWStreamQuality.Q480P,
|
||||||
@ -199,7 +205,7 @@ registerProvider({
|
|||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
stream: {
|
stream: {
|
||||||
streamUrl: hdQuality.path,
|
streamUrl: makeFasterUrl(hdQuality.path),
|
||||||
quality: qualityMap[hdQuality.quality as QualityInMap],
|
quality: qualityMap[hdQuality.quality as QualityInMap],
|
||||||
type: MWStreamType.MP4,
|
type: MWStreamType.MP4,
|
||||||
captions: mappedCaptions,
|
captions: mappedCaptions,
|
||||||
@ -248,13 +254,14 @@ registerProvider({
|
|||||||
const mappedCaptions = subtitleRes.list
|
const mappedCaptions = subtitleRes.list
|
||||||
.map(convertSubtitles)
|
.map(convertSubtitles)
|
||||||
.filter(Boolean);
|
.filter(Boolean);
|
||||||
|
|
||||||
return {
|
return {
|
||||||
embeds: [],
|
embeds: [],
|
||||||
stream: {
|
stream: {
|
||||||
quality: qualityMap[
|
quality: qualityMap[
|
||||||
hdQuality.quality as QualityInMap
|
hdQuality.quality as QualityInMap
|
||||||
] as MWStreamQuality,
|
] as MWStreamQuality,
|
||||||
streamUrl: hdQuality.path,
|
streamUrl: makeFasterUrl(hdQuality.path),
|
||||||
type: MWStreamType.MP4,
|
type: MWStreamType.MP4,
|
||||||
captions: mappedCaptions,
|
captions: mappedCaptions,
|
||||||
},
|
},
|
||||||
|
@ -1,4 +1,8 @@
|
|||||||
|
import c from "classnames";
|
||||||
|
import { useState } from "react";
|
||||||
|
|
||||||
import { MWQuery } from "@/backend/metadata/types/mw";
|
import { MWQuery } from "@/backend/metadata/types/mw";
|
||||||
|
import { Flare } from "@/components/utils/Flare";
|
||||||
|
|
||||||
import { Icon, Icons } from "./Icon";
|
import { Icon, Icons } from "./Icon";
|
||||||
import { TextInputControl } from "./text-inputs/TextInputControl";
|
import { TextInputControl } from "./text-inputs/TextInputControl";
|
||||||
@ -11,6 +15,8 @@ export interface SearchBarProps {
|
|||||||
}
|
}
|
||||||
|
|
||||||
export function SearchBarInput(props: SearchBarProps) {
|
export function SearchBarInput(props: SearchBarProps) {
|
||||||
|
const [focused, setFocused] = useState(false);
|
||||||
|
|
||||||
function setSearch(value: string) {
|
function setSearch(value: string) {
|
||||||
props.onChange(
|
props.onChange(
|
||||||
{
|
{
|
||||||
@ -22,18 +28,42 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||||||
}
|
}
|
||||||
|
|
||||||
return (
|
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">
|
<Flare.Base
|
||||||
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center">
|
className={c({
|
||||||
<Icon icon={Icons.SEARCH} />
|
"hover:flare-enabled group relative flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center":
|
||||||
</div>
|
true,
|
||||||
|
"bg-search-background": !focused,
|
||||||
<TextInputControl
|
"bg-search-focused": focused,
|
||||||
onUnFocus={props.onUnFocus}
|
})}
|
||||||
onChange={(val) => setSearch(val)}
|
>
|
||||||
value={props.value.searchQuery}
|
<Flare.Light
|
||||||
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"
|
flareSize={400}
|
||||||
placeholder={props.placeholder}
|
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 { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
|
import { Lightbar } from "@/components/utils/Lightbar";
|
||||||
import { useBannerSize } from "@/hooks/useBanner";
|
import { useBannerSize } from "@/hooks/useBanner";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
import SettingsModal from "@/views/SettingsModal";
|
|
||||||
|
|
||||||
import { BrandPill } from "./BrandPill";
|
import { BrandPill } from "./BrandPill";
|
||||||
|
|
||||||
@ -16,62 +16,59 @@ export interface NavigationProps {
|
|||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
const bannerHeight = useBannerSize();
|
const bannerHeight = useBannerSize();
|
||||||
const [showModal, setShowModal] = useState(false);
|
|
||||||
return (
|
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"
|
<div className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center">
|
||||||
style={{
|
<div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0">
|
||||||
top: `${bannerHeight}px`,
|
<Lightbar />
|
||||||
}}
|
|
||||||
>
|
|
||||||
<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>
|
</div>
|
||||||
</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 {
|
interface WideContainerProps {
|
||||||
classNames?: string;
|
classNames?: string;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
ultraWide?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WideContainer(props: WideContainerProps) {
|
export function WideContainer(props: WideContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
|
className={`mx-auto max-w-full px-8 ${
|
||||||
props.classNames || ""
|
props.ultraWide ? "w-[1300px] sm:px-16" : "w-[900px] sm:px-8"
|
||||||
}`}
|
} ${props.classNames || ""}`}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,9 +1,11 @@
|
|||||||
|
import c from "classnames";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
|
|
||||||
import { TMDBMediaToId } from "@/backend/metadata/tmdb";
|
import { TMDBMediaToId } from "@/backend/metadata/tmdb";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
import { MWMediaMeta } from "@/backend/metadata/types/mw";
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
|
import { Flare } from "@/components/utils/Flare";
|
||||||
|
|
||||||
import { IconPatch } from "../buttons/IconPatch";
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
import { Icons } from "../Icon";
|
import { Icons } from "../Icon";
|
||||||
@ -39,19 +41,27 @@ function MediaCardContent({
|
|||||||
if (media.year) dotListContent.push(media.year);
|
if (media.year) dotListContent.push(media.year);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<Flare.Base
|
||||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
className={`group -m-3 mb-2 rounded-xl bg-background-main transition-colors duration-100 ${
|
||||||
canLink ? "hover:bg-opacity-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 ${
|
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||||
canLink ? "group-hover:scale-95" : ""
|
canLink ? "group-hover:scale-95" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={[
|
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",
|
closable ? "" : "group-hover:rounded-lg",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
style={{
|
style={{
|
||||||
@ -61,13 +71,12 @@ function MediaCardContent({
|
|||||||
{series ? (
|
{series ? (
|
||||||
<div
|
<div
|
||||||
className={[
|
className={[
|
||||||
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
|
||||||
closable ? "" : "group-hover:bg-denim-500",
|
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
<p
|
<p
|
||||||
className={[
|
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",
|
closable ? "" : "group-hover:text-white",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
@ -82,19 +91,19 @@ function MediaCardContent({
|
|||||||
{percentage !== undefined ? (
|
{percentage !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
||||||
canLink ? "group-hover:from-denim-100" : ""
|
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
||||||
canLink ? "group-hover:from-denim-100" : ""
|
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
||||||
}`}
|
}`}
|
||||||
/>
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
<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
|
<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={{
|
style={{
|
||||||
width: percentageString,
|
width: percentageString,
|
||||||
}}
|
}}
|
||||||
@ -105,13 +114,13 @@ function MediaCardContent({
|
|||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<div
|
<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"
|
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<IconPatch
|
<IconPatch
|
||||||
clickable
|
clickable
|
||||||
className="text-2xl text-slate-400"
|
className="text-2xl text-mediaCard-badgeText"
|
||||||
onClick={() => closable && onClose?.()}
|
onClick={() => closable && onClose?.()}
|
||||||
icon={Icons.X}
|
icon={Icons.X}
|
||||||
/>
|
/>
|
||||||
@ -121,8 +130,8 @@ function MediaCardContent({
|
|||||||
<span>{media.title}</span>
|
<span>{media.title}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<DotList className="text-xs" content={dotListContent} />
|
<DotList className="text-xs" content={dotListContent} />
|
||||||
</article>
|
</Flare.Child>
|
||||||
</div>
|
</Flare.Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -7,7 +7,10 @@ interface MediaGridProps {
|
|||||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
return (
|
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}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,6 +1,7 @@
|
|||||||
export interface TextInputControlPropsNoLabel {
|
export interface TextInputControlPropsNoLabel {
|
||||||
onChange?: (data: string) => void;
|
onChange?: (data: string) => void;
|
||||||
onUnFocus?: () => void;
|
onUnFocus?: () => void;
|
||||||
|
onFocus?: () => void;
|
||||||
value?: string;
|
value?: string;
|
||||||
placeholder?: string;
|
placeholder?: string;
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -17,6 +18,7 @@ export function TextInputControl({
|
|||||||
label,
|
label,
|
||||||
className,
|
className,
|
||||||
placeholder,
|
placeholder,
|
||||||
|
onFocus,
|
||||||
}: TextInputControlProps) {
|
}: TextInputControlProps) {
|
||||||
const input = (
|
const input = (
|
||||||
<input
|
<input
|
||||||
@ -26,6 +28,7 @@ export function TextInputControl({
|
|||||||
onChange={(e) => onChange && onChange(e.target.value)}
|
onChange={(e) => onChange && onChange(e.target.value)}
|
||||||
value={value}
|
value={value}
|
||||||
onBlur={() => onUnFocus && onUnFocus()}
|
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 { ArrowLink } from "@/components/text/ArrowLink";
|
||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
|
|
||||||
export default function DeveloperView() {
|
export default function DeveloperPage() {
|
||||||
return (
|
return (
|
||||||
<div className="py-48">
|
<div className="py-48">
|
||||||
<Navigation />
|
<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 { MediaGrid } from "@/components/media/MediaGrid";
|
||||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
|
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||||
import { SearchLoadingView } from "./SearchLoadingView";
|
|
||||||
|
|
||||||
function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -63,7 +62,7 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
|||||||
if (searchQuery.searchQuery !== "") runSearch(searchQuery);
|
if (searchQuery.searchQuery !== "") runSearch(searchQuery);
|
||||||
}, [searchQuery, runSearchQuery]);
|
}, [searchQuery, runSearchQuery]);
|
||||||
|
|
||||||
if (loading) return <SearchLoadingView />;
|
if (loading) return <SearchLoadingPart />;
|
||||||
if (error) return <SearchSuffix failed />;
|
if (error) return <SearchSuffix failed />;
|
||||||
if (!results) return null;
|
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 { useGoBack } from "@/hooks/useGoBack";
|
||||||
import { useLoading } from "@/hooks/useLoading";
|
import { useLoading } from "@/hooks/useLoading";
|
||||||
import { SelectedMediaData, useScrape } from "@/hooks/useScrape";
|
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 { useWatchedItem } from "@/state/watched";
|
||||||
|
|
||||||
import { MediaFetchErrorView } from "./MediaErrorView";
|
import { MediaFetchErrorView } from "./MediaErrorView";
|
||||||
import { MediaScrapeLog } from "./MediaScrapeLog";
|
import { MediaScrapeLog } from "./MediaScrapeLog";
|
||||||
import { NotFoundMedia, NotFoundWrapper } from "../notfound/NotFoundView";
|
|
||||||
|
|
||||||
function MediaViewLoading(props: { onGoBack(): void }) {
|
function MediaViewLoading(props: { onGoBack(): void }) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
@ -241,9 +242,9 @@ export function MediaView() {
|
|||||||
if (error) return <MediaFetchErrorView />;
|
if (error) return <MediaFetchErrorView />;
|
||||||
if (!meta || !selected)
|
if (!meta || !selected)
|
||||||
return (
|
return (
|
||||||
<NotFoundWrapper video>
|
<ErrorWrapperPart video>
|
||||||
<NotFoundMedia />
|
<MediaNotFoundPart />
|
||||||
</NotFoundWrapper>
|
</ErrorWrapperPart>
|
||||||
);
|
);
|
||||||
|
|
||||||
// scraping view will start scraping and return with onStream
|
// 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";
|
import { Loading } from "@/components/layout/Loading";
|
||||||
|
|
||||||
export function SearchLoadingView() {
|
export function SearchLoadingPart() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
return (
|
return (
|
||||||
<Loading className="mb-24 mt-40 " text={t("search.loading") || "..."} />
|
<Loading className="mb-24 mt-40 " text={t("search.loading") || "..."} />
|
@ -11,14 +11,13 @@ import {
|
|||||||
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta";
|
||||||
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb";
|
||||||
import { BannerContextProvider } from "@/hooks/useBanner";
|
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 { Layout } from "@/setup/Layout";
|
||||||
import { BookmarkContextProvider } from "@/state/bookmark";
|
import { BookmarkContextProvider } from "@/state/bookmark";
|
||||||
import { SettingsProvider } from "@/state/settings";
|
import { SettingsProvider } from "@/state/settings";
|
||||||
import { WatchedContextProvider } from "@/state/watched";
|
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 }) {
|
function LegacyUrlView({ children }: { children: ReactElement }) {
|
||||||
const location = useLocation();
|
const location = useLocation();
|
||||||
@ -62,7 +61,6 @@ function App() {
|
|||||||
<Layout>
|
<Layout>
|
||||||
<Switch>
|
<Switch>
|
||||||
{/* functional routes */}
|
{/* functional routes */}
|
||||||
<Route exact path="/v2-migration" component={V2MigrationView} />
|
|
||||||
<Route exact path="/s/:query">
|
<Route exact path="/s/:query">
|
||||||
<QuickSearch />
|
<QuickSearch />
|
||||||
</Route>
|
</Route>
|
||||||
@ -87,22 +85,20 @@ function App() {
|
|||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path={["/browse/:query?", "/"]}
|
path={["/browse/:query?", "/"]}
|
||||||
component={SearchView}
|
component={HomePage}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{/* other */}
|
{/* other */}
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/dev"
|
path="/dev"
|
||||||
component={lazy(
|
component={lazy(() => import("@/pages/DeveloperPage"))}
|
||||||
() => import("@/views/developer/DeveloperView")
|
|
||||||
)}
|
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/dev/video"
|
path="/dev/video"
|
||||||
component={lazy(
|
component={lazy(
|
||||||
() => import("@/views/developer/VideoTesterView")
|
() => import("@/pages/developer/VideoTesterView")
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
{/* developer routes that can abuse workers are disabled in production */}
|
{/* developer routes that can abuse workers are disabled in production */}
|
||||||
@ -112,7 +108,7 @@ function App() {
|
|||||||
exact
|
exact
|
||||||
path="/dev/test"
|
path="/dev/test"
|
||||||
component={lazy(
|
component={lazy(
|
||||||
() => import("@/views/developer/TestView")
|
() => import("@/pages/developer/TestView")
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
@ -120,14 +116,14 @@ function App() {
|
|||||||
exact
|
exact
|
||||||
path="/dev/providers"
|
path="/dev/providers"
|
||||||
component={lazy(
|
component={lazy(
|
||||||
() => import("@/views/developer/ProviderTesterView")
|
() => import("@/pages/developer/ProviderTesterView")
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
<Route
|
<Route
|
||||||
exact
|
exact
|
||||||
path="/dev/embeds"
|
path="/dev/embeds"
|
||||||
component={lazy(
|
component={lazy(
|
||||||
() => import("@/views/developer/EmbedTesterView")
|
() => import("@/pages/developer/EmbedTesterView")
|
||||||
)}
|
)}
|
||||||
/>
|
/>
|
||||||
</>
|
</>
|
||||||
|
@ -4,9 +4,10 @@
|
|||||||
|
|
||||||
html,
|
html,
|
||||||
body {
|
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: 100vh;
|
||||||
min-height: 100dvh;
|
min-height: 100dvh;
|
||||||
|
position: relative;
|
||||||
}
|
}
|
||||||
|
|
||||||
html[data-full],
|
html[data-full],
|
||||||
|
@ -10,7 +10,7 @@
|
|||||||
"headingTitle": "Search results",
|
"headingTitle": "Search results",
|
||||||
"bookmarks": "Bookmarks",
|
"bookmarks": "Bookmarks",
|
||||||
"continueWatching": "Continue Watching",
|
"continueWatching": "Continue Watching",
|
||||||
"title": "What do you want to watch?",
|
"title": "What to watch tonight?",
|
||||||
"placeholder": "What do you want to watch?"
|
"placeholder": "What do you want to watch?"
|
||||||
},
|
},
|
||||||
"media": {
|
"media": {
|
||||||
@ -131,5 +131,17 @@
|
|||||||
},
|
},
|
||||||
"errors": {
|
"errors": {
|
||||||
"offline": "Check your internet connection"
|
"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} */
|
/** @type {import('tailwindcss').Config} */
|
||||||
module.exports = {
|
module.exports = {
|
||||||
content: ["./index.html", "./src/**/*.{js,ts,jsx,tsx}"],
|
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" }
|
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…
x
Reference in New Issue
Block a user