diff --git a/package.json b/package.json index 9389abee..39029b7b 100644 --- a/package.json +++ b/package.json @@ -36,6 +36,7 @@ "@scure/bip39": "^1.2.2", "@sozialhelden/ietf-language-tags": "^5.4.2", "@types/node-forge": "^1.3.10", + "@vercel/analytics": "^1.2.2", "classnames": "^2.3.2", "core-js": "^3.34.0", "detect-browser": "^5.3.0", diff --git a/pnpm-lock.yaml b/pnpm-lock.yaml index 97b59603..35e43c3c 100644 --- a/pnpm-lock.yaml +++ b/pnpm-lock.yaml @@ -42,6 +42,9 @@ dependencies: '@types/node-forge': specifier: ^1.3.10 version: 1.3.10 + '@vercel/analytics': + specifier: ^1.2.2 + version: 1.2.2(react@18.2.0) classnames: specifier: ^2.3.2 version: 2.3.2 @@ -2488,6 +2491,21 @@ packages: resolution: {integrity: sha512-zuVdFrMJiuCDQUMCzQaD6KL28MjnqqN8XnAqiEq9PNm/hCPTSGfrXCOfwj1ow4LFb/tNymJPwsNbVePc1xFqrQ==} dev: true + /@vercel/analytics@1.2.2(react@18.2.0): + resolution: {integrity: sha512-X0rctVWkQV1e5Y300ehVNqpOfSOufo7ieA5PIdna8yX/U7Vjz0GFsGf4qvAhxV02uQ2CVt7GYcrFfddXXK2Y4A==} + peerDependencies: + next: '>= 13' + react: ^18 || ^19 + peerDependenciesMeta: + next: + optional: true + react: + optional: true + dependencies: + react: 18.2.0 + server-only: 0.0.1 + dev: false + /@vitejs/plugin-react@4.2.1(vite@5.0.12): resolution: {integrity: sha512-oojO9IDc4nCUUi8qIR11KoQm0XFFLIwsRBwHRR4d/88IWghn1y6ckz/bJ8GHDCsYEJee8mDzqtJxh15/cisJNQ==} engines: {node: ^14.18.0 || >=16.0.0} @@ -6172,6 +6190,10 @@ packages: randombytes: 2.1.0 dev: true + /server-only@0.0.1: + resolution: {integrity: sha512-qepMx2JxAa5jjfzxG79yPPq+8BuFToHd1hm7kI+Z4zAq1ftQiP7HcxMhDDItrbtwVeLg/cY2JnKnrcFkmiswNA==} + dev: false + /set-cookie-parser@2.6.0: resolution: {integrity: sha512-RVnVQxTXuerk653XfuliOxBP81Sf0+qfQE73LIYKcyMYHG94AuH0kgrQpRDuTZnSmjpysHmzxJXKNfa6PjFhyQ==} dev: false diff --git a/src/setup/App.tsx b/src/setup/App.tsx index 37c55e0f..5c1d01d7 100644 --- a/src/setup/App.tsx +++ b/src/setup/App.tsx @@ -1,3 +1,4 @@ +import { Analytics } from "@vercel/analytics/react"; import { ReactElement, Suspense, lazy, useEffect } from "react"; import { lazyWithPreload } from "react-lazy-with-preload"; import { @@ -155,6 +156,7 @@ function App() { ) : null} } /> + ); } diff --git a/src/utils/setup/App.tsx b/src/utils/setup/App.tsx new file mode 100644 index 00000000..37c55e0f --- /dev/null +++ b/src/utils/setup/App.tsx @@ -0,0 +1,162 @@ +import { ReactElement, Suspense, lazy, useEffect } from "react"; +import { lazyWithPreload } from "react-lazy-with-preload"; +import { + Navigate, + Route, + Routes, + useLocation, + useNavigate, + useParams, +} from "react-router-dom"; + +import { convertLegacyUrl, isLegacyUrl } from "@/backend/metadata/getmeta"; +import { generateQuickSearchMediaUrl } from "@/backend/metadata/tmdb"; +import { useOnlineListener } from "@/hooks/usePing"; +import { AboutPage } from "@/pages/About"; +import { AdminPage } from "@/pages/admin/AdminPage"; +import VideoTesterView from "@/pages/developer/VideoTesterView"; +import { DmcaPage, shouldHaveDmcaPage } from "@/pages/Dmca"; +import { NotFoundPage } from "@/pages/errors/NotFoundPage"; +import { HomePage } from "@/pages/HomePage"; +import { LoginPage } from "@/pages/Login"; +import { OnboardingPage } from "@/pages/onboarding/Onboarding"; +import { OnboardingExtensionPage } from "@/pages/onboarding/OnboardingExtension"; +import { OnboardingProxyPage } from "@/pages/onboarding/OnboardingProxy"; +import { RegisterPage } from "@/pages/Register"; +import { Layout } from "@/setup/Layout"; +import { useHistoryListener } from "@/stores/history"; +import { LanguageProvider } from "@/stores/language"; + +const DeveloperPage = lazy(() => import("@/pages/DeveloperPage")); +const TestView = lazy(() => import("@/pages/developer/TestView")); +const PlayerView = lazyWithPreload(() => import("@/pages/PlayerView")); +const SettingsPage = lazyWithPreload(() => import("@/pages/Settings")); + +PlayerView.preload(); +SettingsPage.preload(); + +function LegacyUrlView({ children }: { children: ReactElement }) { + const location = useLocation(); + const navigate = useNavigate(); + + useEffect(() => { + const url = location.pathname; + if (!isLegacyUrl(url)) return; + convertLegacyUrl(location.pathname).then((convertedUrl) => { + navigate(convertedUrl ?? "/", { replace: true }); + }); + }, [location.pathname, navigate]); + + if (isLegacyUrl(location.pathname)) return null; + return children; +} + +function QuickSearch() { + const { query } = useParams<{ query: string }>(); + const navigate = useNavigate(); + + useEffect(() => { + if (query) { + generateQuickSearchMediaUrl(query).then((url) => { + navigate(url ?? "/", { replace: true }); + }); + } else { + navigate("/", { replace: true }); + } + }, [query, navigate]); + + return null; +} + +function QueryView() { + const { query } = useParams<{ query: string }>(); + const navigate = useNavigate(); + + useEffect(() => { + if (query) { + navigate(`/browse/${query}`, { replace: true }); + } else { + navigate("/", { replace: true }); + } + }, [query, navigate]); + + return null; +} + +function App() { + useHistoryListener(); + useOnlineListener(); + + return ( + + + + {/* functional routes */} + } /> + } /> + } /> + + {/* pages */} + + + + + + } + /> + + + + + + } + /> + } /> + } /> + } /> + } /> + } /> + } /> + } + /> + } /> + + {shouldHaveDmcaPage() ? ( + } /> + ) : null} + + {/* Settings page */} + + + + } + /> + + {/* admin routes */} + } /> + + {/* other */} + } /> + } /> + {/* developer routes that can abuse workers are disabled in production */} + {process.env.NODE_ENV === "development" ? ( + } /> + ) : null} + } /> + + + ); +} + +export default App; diff --git a/src/utils/setup/Layout.tsx b/src/utils/setup/Layout.tsx new file mode 100644 index 00000000..3227ecbc --- /dev/null +++ b/src/utils/setup/Layout.tsx @@ -0,0 +1,25 @@ +import { ReactNode } from "react"; + +import { useBannerSize, useBannerStore } from "@/stores/banner"; +import { BannerLocation } from "@/stores/banner/BannerLocation"; + +export function Layout(props: { children: ReactNode }) { + const bannerSize = useBannerSize(); + const location = useBannerStore((s) => s.location); + + return ( +
+
+ +
+
+ {props.children} +
+
+ ); +} diff --git a/src/utils/setup/chromecast.ts b/src/utils/setup/chromecast.ts new file mode 100644 index 00000000..9c288629 --- /dev/null +++ b/src/utils/setup/chromecast.ts @@ -0,0 +1,30 @@ +const CHROMECAST_SENDER_SDK = + "https://www.gstatic.com/cv/js/sender/v1/cast_sender.js?loadCastFramework=1"; + +const callbacks: ((available: boolean) => void)[] = []; +let _available: boolean | null = null; + +function init(available: boolean) { + _available = available; + callbacks.forEach((cb) => cb(available)); +} + +export function isChromecastAvailable(cb: (available: boolean) => void) { + if (_available !== null) return cb(_available); + callbacks.push(cb); +} + +export function initializeChromecast() { + window.__onGCastApiAvailable = (isAvailable) => { + init(isAvailable); + }; + + // add script if doesnt exist yet + const exists = !!document.getElementById("chromecast-script"); + if (!exists) { + const script = document.createElement("script"); + script.setAttribute("src", CHROMECAST_SENDER_SDK); + script.setAttribute("id", "chromecast-script"); + document.body.appendChild(script); + } +} diff --git a/src/utils/setup/config.ts b/src/utils/setup/config.ts new file mode 100644 index 00000000..f081e673 --- /dev/null +++ b/src/utils/setup/config.ts @@ -0,0 +1,125 @@ +import { + APP_VERSION, + BACKEND_URL, + DISCORD_LINK, + DONATION_LINK, + GITHUB_LINK, +} from "./constants"; + +interface Config { + APP_VERSION: string; + GITHUB_LINK: string; + DONATION_LINK: string; + DISCORD_LINK: string; + DMCA_EMAIL: string; + TMDB_READ_API_KEY: string; + CORS_PROXY_URL: string; + NORMAL_ROUTER: boolean; + BACKEND_URL: string; + DISALLOWED_IDS: string; + TURNSTILE_KEY: string; + CDN_REPLACEMENTS: string; + HAS_ONBOARDING: string; + ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string; + ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: string; + ONBOARDING_PROXY_INSTALL_LINK: string; +} + +export interface RuntimeConfig { + APP_VERSION: string; + GITHUB_LINK: string; + DONATION_LINK: string; + DISCORD_LINK: string; + DMCA_EMAIL: string | null; + TMDB_READ_API_KEY: string; + NORMAL_ROUTER: boolean; + PROXY_URLS: string[]; + BACKEND_URL: string; + DISALLOWED_IDS: string[]; + TURNSTILE_KEY: string | null; + CDN_REPLACEMENTS: Array; + HAS_ONBOARDING: boolean; + ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: string | null; + ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: string | null; + ONBOARDING_PROXY_INSTALL_LINK: string | null; +} + +const env: Record = { + TMDB_READ_API_KEY: import.meta.env.VITE_TMDB_READ_API_KEY, + APP_VERSION: undefined, + GITHUB_LINK: undefined, + DONATION_LINK: undefined, + DISCORD_LINK: undefined, + ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: import.meta.env + .VITE_ONBOARDING_CHROME_EXTENSION_INSTALL_LINK, + ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: import.meta.env + .VITE_ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK, + ONBOARDING_PROXY_INSTALL_LINK: import.meta.env + .VITE_ONBOARDING_PROXY_INSTALL_LINK, + DMCA_EMAIL: import.meta.env.VITE_DMCA_EMAIL, + CORS_PROXY_URL: import.meta.env.VITE_CORS_PROXY_URL, + NORMAL_ROUTER: import.meta.env.VITE_NORMAL_ROUTER, + BACKEND_URL: import.meta.env.VITE_BACKEND_URL, + DISALLOWED_IDS: import.meta.env.VITE_DISALLOWED_IDS, + TURNSTILE_KEY: import.meta.env.VITE_TURNSTILE_KEY, + CDN_REPLACEMENTS: import.meta.env.VITE_CDN_REPLACEMENTS, + HAS_ONBOARDING: import.meta.env.VITE_HAS_ONBOARDING, +}; + +// loads from different locations, in order: environment (VITE_{KEY}), window (public/config.js) +function getKeyValue(key: keyof Config): string | undefined { + let windowValue = (window as any)?.__CONFIG__?.[`VITE_${key}`]; + if ( + windowValue !== null && + windowValue !== undefined && + windowValue.length === 0 + ) + windowValue = undefined; + return env[key] ?? windowValue ?? undefined; +} + +function getKey(key: keyof Config, defaultString?: string): string { + return getKeyValue(key)?.toString() ?? defaultString ?? ""; +} + +export function conf(): RuntimeConfig { + const dmcaEmail = getKey("DMCA_EMAIL"); + const chromeExtension = getKey("ONBOARDING_CHROME_EXTENSION_INSTALL_LINK"); + const firefoxExtension = getKey("ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK"); + const proxyInstallLink = getKey("ONBOARDING_PROXY_INSTALL_LINK"); + const turnstileKey = getKey("TURNSTILE_KEY"); + return { + APP_VERSION, + GITHUB_LINK, + DONATION_LINK, + DISCORD_LINK, + DMCA_EMAIL: dmcaEmail.length > 0 ? dmcaEmail : null, + ONBOARDING_CHROME_EXTENSION_INSTALL_LINK: + chromeExtension.length > 0 ? chromeExtension : null, + ONBOARDING_FIREFOX_EXTENSION_INSTALL_LINK: + firefoxExtension.length > 0 ? firefoxExtension : null, + ONBOARDING_PROXY_INSTALL_LINK: + proxyInstallLink.length > 0 ? proxyInstallLink : null, + BACKEND_URL: getKey("BACKEND_URL", BACKEND_URL), + TMDB_READ_API_KEY: getKey("TMDB_READ_API_KEY"), + PROXY_URLS: getKey("CORS_PROXY_URL") + .split(",") + .map((v) => v.trim()), + NORMAL_ROUTER: getKey("NORMAL_ROUTER", "false") === "true", + HAS_ONBOARDING: getKey("HAS_ONBOARDING", "false") === "true", + TURNSTILE_KEY: turnstileKey.length > 0 ? turnstileKey : null, + DISALLOWED_IDS: getKey("DISALLOWED_IDS", "") + .split(",") + .map((v) => v.trim()) + .filter((v) => v.length > 0), // Should be comma-seperated and contain the media type and ID, formatted like so: movie-753342,movie-753342,movie-753342 + CDN_REPLACEMENTS: getKey("CDN_REPLACEMENTS", "") + .split(",") + .map((v) => + v + .split(":") + .map((s) => s.trim()) + .filter((s) => s.length > 0), + ) + .filter((v) => v.length === 2), // The format is :,: + }; +} diff --git a/src/utils/setup/constants.ts b/src/utils/setup/constants.ts new file mode 100644 index 00000000..935987e9 --- /dev/null +++ b/src/utils/setup/constants.ts @@ -0,0 +1,6 @@ +export const APP_VERSION = import.meta.env.PACKAGE_VERSION; +export const DISCORD_LINK = "https://movie-web.github.io/links/discord"; +export const GITHUB_LINK = "https://github.com/movie-web/movie-web"; +export const DONATION_LINK = "https://ko-fi.com/movieweb"; +export const GA_ID = import.meta.env.VITE_GA_ID; +export const BACKEND_URL = import.meta.env.VITE_BACKEND_URL; diff --git a/src/utils/setup/ga.ts b/src/utils/setup/ga.ts new file mode 100644 index 00000000..9b900c0d --- /dev/null +++ b/src/utils/setup/ga.ts @@ -0,0 +1,11 @@ +import ReactGA from "react-ga4"; + +import { GA_ID } from "@/setup/constants"; + +if (GA_ID) { + ReactGA.initialize([ + { + trackingId: GA_ID, + }, + ]); +} diff --git a/src/utils/setup/i18n.ts b/src/utils/setup/i18n.ts new file mode 100644 index 00000000..4c99433e --- /dev/null +++ b/src/utils/setup/i18n.ts @@ -0,0 +1,27 @@ +import i18n from "i18next"; +import { initReactI18next } from "react-i18next"; + +import { locales } from "@/assets/languages"; +import { getLocaleInfo } from "@/utils/language"; + +// Languages +const langCodes = Object.keys(locales); +const resources = Object.fromEntries( + Object.entries(locales).map((entry) => [entry[0], { translation: entry[1] }]), +); +i18n.use(initReactI18next).init({ + fallbackLng: "en", + resources, + interpolation: { + escapeValue: false, // not needed for react as it escapes by default + }, +}); + +export const appLanguageOptions = langCodes.map((lang) => { + const langObj = getLocaleInfo(lang); + if (!langObj) + throw new Error(`Language with code ${lang} cannot be found in database`); + return langObj; +}); + +export default i18n; diff --git a/src/utils/setup/pwa.ts b/src/utils/setup/pwa.ts new file mode 100644 index 00000000..e7147ea9 --- /dev/null +++ b/src/utils/setup/pwa.ts @@ -0,0 +1,27 @@ +import { registerSW } from "virtual:pwa-register"; + +const intervalMS = 60 * 60 * 1000; + +registerSW({ + immediate: true, + onRegisteredSW(swUrl, r) { + if (!r) return; + setInterval(async () => { + if (!(!r.installing && navigator)) return; + + if ("connection" in navigator && !navigator.onLine) return; + + const resp = await fetch(swUrl, { + cache: "no-store", + headers: { + cache: "no-store", + "cache-control": "no-cache", + }, + }); + + if (resp?.status === 200) { + await r.update(); + } + }, intervalMS); + }, +});