77 changed files with 233 additions and 1965 deletions
@ -0,0 +1,5 @@ |
|||||||
|
import en from "@/assets/locales/en.json"; |
||||||
|
|
||||||
|
export const locales = { |
||||||
|
en, |
||||||
|
}; |
@ -0,0 +1,59 @@ |
|||||||
|
{ |
||||||
|
"global": { |
||||||
|
"name": "movie-web" |
||||||
|
}, |
||||||
|
"media": { |
||||||
|
"types": { |
||||||
|
"movie": "Movie", |
||||||
|
"show": "Show" |
||||||
|
}, |
||||||
|
"episodeDisplay": "S{{season}} E{{episode}}" |
||||||
|
}, |
||||||
|
"home": { |
||||||
|
"mediaList": { |
||||||
|
"stopEditing": "Stop editing" |
||||||
|
} |
||||||
|
}, |
||||||
|
"overlays": { |
||||||
|
"close": "Close" |
||||||
|
}, |
||||||
|
"screens": { |
||||||
|
"loadingUser": "Loading your profile", |
||||||
|
"loadingApp": "Loading application", |
||||||
|
"loadingUserError": { |
||||||
|
"text": "", |
||||||
|
"textWithReset": "", |
||||||
|
"reset": "Reset custom server" |
||||||
|
}, |
||||||
|
"migration": { |
||||||
|
"failed": "Failed to migrate your data." |
||||||
|
} |
||||||
|
}, |
||||||
|
"navigation": { |
||||||
|
"banner": { |
||||||
|
"offline": "Check your internet connection" |
||||||
|
}, |
||||||
|
"menu": { |
||||||
|
"register": "Sync to cloud", |
||||||
|
"settings": "Settings", |
||||||
|
"about": "About us", |
||||||
|
"support": "Support", |
||||||
|
"logout": "Log out" |
||||||
|
} |
||||||
|
}, |
||||||
|
"actions": { |
||||||
|
"copy": "Copy" |
||||||
|
}, |
||||||
|
"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,21 +0,0 @@ |
|||||||
import { Helmet } from "react-helmet-async"; |
|
||||||
|
|
||||||
import { Transition } from "@/components/Transition"; |
|
||||||
|
|
||||||
export function Overlay(props: { children: React.ReactNode }) { |
|
||||||
return ( |
|
||||||
<> |
|
||||||
<Helmet> |
|
||||||
<body data-no-scroll /> |
|
||||||
</Helmet> |
|
||||||
<div className="fixed inset-0 z-[99999]"> |
|
||||||
<Transition |
|
||||||
animation="fade" |
|
||||||
className="absolute inset-0 bg-[rgba(8,6,18,0.85)]" |
|
||||||
isChild |
|
||||||
/> |
|
||||||
{props.children} |
|
||||||
</div> |
|
||||||
</> |
|
||||||
); |
|
||||||
} |
|
@ -1,47 +0,0 @@ |
|||||||
import { ChangeEventHandler, useEffect, useRef } from "react"; |
|
||||||
|
|
||||||
export type SliderProps = { |
|
||||||
label?: string; |
|
||||||
min: number; |
|
||||||
max: number; |
|
||||||
step: number; |
|
||||||
value?: number; |
|
||||||
valueDisplay?: string; |
|
||||||
onChange: ChangeEventHandler<HTMLInputElement>; |
|
||||||
}; |
|
||||||
|
|
||||||
export function Slider(props: SliderProps) { |
|
||||||
const ref = useRef<HTMLInputElement>(null); |
|
||||||
useEffect(() => { |
|
||||||
const e = ref.current as HTMLInputElement; |
|
||||||
e.style.setProperty("--value", e.value); |
|
||||||
e.style.setProperty("--min", e.min === "" ? "0" : e.min); |
|
||||||
e.style.setProperty("--max", e.max === "" ? "100" : e.max); |
|
||||||
e.addEventListener("input", () => e.style.setProperty("--value", e.value)); |
|
||||||
}, [ref]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div className="mb-6 flex flex-row gap-4"> |
|
||||||
<div className="flex w-full flex-col gap-2"> |
|
||||||
{props.label ? ( |
|
||||||
<label className="font-bold">{props.label}</label> |
|
||||||
) : null} |
|
||||||
<input |
|
||||||
type="range" |
|
||||||
ref={ref} |
|
||||||
className="styled-slider slider-progress mt-[20px]" |
|
||||||
onChange={props.onChange} |
|
||||||
value={props.value} |
|
||||||
max={props.max} |
|
||||||
min={props.min} |
|
||||||
step={props.step} |
|
||||||
/> |
|
||||||
</div> |
|
||||||
<div className="mt-1 aspect-[2/1] h-8 rounded-sm bg-[#1C161B] pt-1"> |
|
||||||
<div className="text-center font-bold text-white"> |
|
||||||
{props.valueDisplay ?? props.value} |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -1,17 +0,0 @@ |
|||||||
export interface ButtonControlProps { |
|
||||||
onClick?: () => void; |
|
||||||
children?: React.ReactNode; |
|
||||||
className?: string; |
|
||||||
} |
|
||||||
|
|
||||||
export function ButtonControl({ |
|
||||||
onClick, |
|
||||||
children, |
|
||||||
className, |
|
||||||
}: ButtonControlProps) { |
|
||||||
return ( |
|
||||||
<button onClick={onClick} className={className} type="button"> |
|
||||||
{children} |
|
||||||
</button> |
|
||||||
); |
|
||||||
} |
|
@ -1,114 +0,0 @@ |
|||||||
import React, { createRef, useEffect, useState } from "react"; |
|
||||||
import { createPortal } from "react-dom"; |
|
||||||
|
|
||||||
import { useFade } from "@/hooks/useFade"; |
|
||||||
|
|
||||||
interface BackdropProps { |
|
||||||
onClick?: (e: MouseEvent) => void; |
|
||||||
onBackdropHide?: () => void; |
|
||||||
active?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
export function useBackdrop(): [ |
|
||||||
(state: boolean) => void, |
|
||||||
BackdropProps, |
|
||||||
{ style: any } |
|
||||||
] { |
|
||||||
const [backdrop, setBackdropState] = useState(false); |
|
||||||
const [isHighlighted, setisHighlighted] = useState(false); |
|
||||||
|
|
||||||
const setBackdrop = (state: boolean) => { |
|
||||||
setBackdropState(state); |
|
||||||
if (state) setisHighlighted(true); |
|
||||||
}; |
|
||||||
|
|
||||||
const backdropProps: BackdropProps = { |
|
||||||
active: backdrop, |
|
||||||
onBackdropHide() { |
|
||||||
setisHighlighted(false); |
|
||||||
}, |
|
||||||
}; |
|
||||||
|
|
||||||
const highlightedProps = { |
|
||||||
style: isHighlighted |
|
||||||
? { |
|
||||||
zIndex: "1000", |
|
||||||
position: "relative", |
|
||||||
} |
|
||||||
: {}, |
|
||||||
}; |
|
||||||
|
|
||||||
return [setBackdrop, backdropProps, highlightedProps]; |
|
||||||
} |
|
||||||
|
|
||||||
function Backdrop(props: BackdropProps) { |
|
||||||
const clickEvent = props.onClick || (() => {}); |
|
||||||
const animationEvent = props.onBackdropHide || (() => {}); |
|
||||||
const [isVisible, setVisible, fadeProps] = useFade(); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
setVisible(!!props.active); |
|
||||||
/* eslint-disable-next-line */ |
|
||||||
}, [props.active, setVisible]); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
if (!isVisible) animationEvent(); |
|
||||||
/* eslint-disable-next-line */ |
|
||||||
}, [isVisible]); |
|
||||||
|
|
||||||
if (!isVisible) return null; |
|
||||||
|
|
||||||
return ( |
|
||||||
<div |
|
||||||
className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${ |
|
||||||
!isVisible ? "opacity-0" : "" |
|
||||||
}`}
|
|
||||||
{...fadeProps} |
|
||||||
onClick={(e) => clickEvent(e.nativeEvent)} |
|
||||||
/> |
|
||||||
); |
|
||||||
} |
|
||||||
|
|
||||||
export function BackdropContainer( |
|
||||||
props: { |
|
||||||
children: React.ReactNode; |
|
||||||
} & BackdropProps |
|
||||||
) { |
|
||||||
const root = createRef<HTMLDivElement>(); |
|
||||||
const copy = createRef<HTMLDivElement>(); |
|
||||||
|
|
||||||
useEffect(() => { |
|
||||||
let frame = -1; |
|
||||||
function poll() { |
|
||||||
if (root.current && copy.current) { |
|
||||||
const rect = root.current.getBoundingClientRect(); |
|
||||||
copy.current.style.top = `${rect.top}px`; |
|
||||||
copy.current.style.left = `${rect.left}px`; |
|
||||||
copy.current.style.width = `${rect.width}px`; |
|
||||||
copy.current.style.height = `${rect.height}px`; |
|
||||||
} |
|
||||||
frame = window.requestAnimationFrame(poll); |
|
||||||
} |
|
||||||
poll(); |
|
||||||
return () => { |
|
||||||
window.cancelAnimationFrame(frame); |
|
||||||
}; |
|
||||||
// we dont want this to run only on mount, dont care about ref updates
|
|
||||||
// eslint-disable-next-line react-hooks/exhaustive-deps
|
|
||||||
}, [root, copy]); |
|
||||||
|
|
||||||
return ( |
|
||||||
<div ref={root}> |
|
||||||
{createPortal( |
|
||||||
<div className="pointer-events-none fixed left-0 top-0 z-[999]"> |
|
||||||
<Backdrop active={props.active} {...props} /> |
|
||||||
<div ref={copy} className="pointer-events-auto absolute"> |
|
||||||
{props.children} |
|
||||||
</div> |
|
||||||
</div>, |
|
||||||
document.body |
|
||||||
)} |
|
||||||
<div className="invisible">{props.children}</div> |
|
||||||
</div> |
|
||||||
); |
|
||||||
} |
|
@ -1,47 +0,0 @@ |
|||||||
import { ReactNode } from "react"; |
|
||||||
import { Link as LinkRouter } from "react-router-dom"; |
|
||||||
|
|
||||||
interface ILinkPropsBase { |
|
||||||
children?: ReactNode; |
|
||||||
className?: string; |
|
||||||
onClick?: () => void; |
|
||||||
} |
|
||||||
|
|
||||||
interface ILinkPropsExternal extends ILinkPropsBase { |
|
||||||
url: string; |
|
||||||
newTab?: boolean; |
|
||||||
} |
|
||||||
|
|
||||||
interface ILinkPropsInternal extends ILinkPropsBase { |
|
||||||
to: string; |
|
||||||
} |
|
||||||
|
|
||||||
type LinkProps = ILinkPropsExternal | ILinkPropsInternal | ILinkPropsBase; |
|
||||||
|
|
||||||
export function Link(props: LinkProps) { |
|
||||||
const isExternal = !!(props as ILinkPropsExternal).url; |
|
||||||
const isInternal = !!(props as ILinkPropsInternal).to; |
|
||||||
const content = ( |
|
||||||
<span className="cursor-pointer font-bold text-type-link hover:text-type-linkHover"> |
|
||||||
{props.children} |
|
||||||
</span> |
|
||||||
); |
|
||||||
|
|
||||||
if (isExternal) |
|
||||||
return ( |
|
||||||
<a |
|
||||||
target={(props as ILinkPropsExternal).newTab ? "_blank" : undefined} |
|
||||||
rel="noreferrer" |
|
||||||
href={(props as ILinkPropsExternal).url} |
|
||||||
> |
|
||||||
{content} |
|
||||||
</a> |
|
||||||
); |
|
||||||
if (isInternal) |
|
||||||
return ( |
|
||||||
<LinkRouter to={(props as ILinkPropsInternal).to}>{content}</LinkRouter> |
|
||||||
); |
|
||||||
return ( |
|
||||||
<span onClick={() => props.onClick && props.onClick()}>{content}</span> |
|
||||||
); |
|
||||||
} |
|
@ -1,17 +0,0 @@ |
|||||||
@keyframes fadeIn { |
|
||||||
0% { |
|
||||||
opacity: 0; |
|
||||||
} |
|
||||||
100% { |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
} |
|
||||||
|
|
||||||
@keyframes fadeOut { |
|
||||||
0% { |
|
||||||
opacity: 1; |
|
||||||
} |
|
||||||
100% { |
|
||||||
opacity: 0; |
|
||||||
} |
|
||||||
} |
|
@ -1,29 +0,0 @@ |
|||||||
import React, { useEffect, useState } from "react"; |
|
||||||
import "./useFade.css"; |
|
||||||
|
|
||||||
export const useFade = ( |
|
||||||
initial = false |
|
||||||
): [boolean, React.Dispatch<React.SetStateAction<boolean>>, any] => { |
|
||||||
const [show, setShow] = useState<boolean>(initial); |
|
||||||
const [isVisible, setVisible] = useState<boolean>(show); |
|
||||||
|
|
||||||
// Update visibility when show changes
|
|
||||||
useEffect(() => { |
|
||||||
if (show) setVisible(true); |
|
||||||
}, [show]); |
|
||||||
|
|
||||||
// When the animation finishes, set visibility to false
|
|
||||||
const onAnimationEnd = () => { |
|
||||||
if (!show) setVisible(false); |
|
||||||
}; |
|
||||||
|
|
||||||
const style = { animation: `${show ? "fadeIn" : "fadeOut"} .3s` }; |
|
||||||
|
|
||||||
// These props go on the fading DOM element
|
|
||||||
const fadeProps = { |
|
||||||
style, |
|
||||||
onAnimationEnd, |
|
||||||
}; |
|
||||||
|
|
||||||
return [isVisible, setShow, fadeProps]; |
|
||||||
}; |
|
@ -1,60 +0,0 @@ |
|||||||
import { useLayoutEffect, useState } from "react"; |
|
||||||
|
|
||||||
export function useFloatingRouter(initial = "/") { |
|
||||||
const [route, setRoute] = useState<string[]>( |
|
||||||
initial.split("/").filter((v) => v.length > 0) |
|
||||||
); |
|
||||||
const [previousRoute, setPreviousRoute] = useState(route); |
|
||||||
const currentPage = route[route.length - 1] ?? "/"; |
|
||||||
|
|
||||||
useLayoutEffect(() => { |
|
||||||
if (previousRoute.length === route.length) return; |
|
||||||
// when navigating backwards, we delay the updating by a bit so transitions can be applied correctly
|
|
||||||
setTimeout(() => { |
|
||||||
setPreviousRoute(route); |
|
||||||
}, 20); |
|
||||||
}, [route, previousRoute]); |
|
||||||
|
|
||||||
function navigate(path: string) { |
|
||||||
const newRoute = path.split("/").filter((v) => v.length > 0); |
|
||||||
if (newRoute.length > previousRoute.length) setPreviousRoute(newRoute); |
|
||||||
setRoute(newRoute); |
|
||||||
} |
|
||||||
|
|
||||||
function isActive(page: string) { |
|
||||||
if (page === "/") return true; |
|
||||||
const index = previousRoute.indexOf(page); |
|
||||||
if (index === -1) return false; // not active
|
|
||||||
if (index === previousRoute.length - 1) return false; // active but latest route so shouldnt be counted as active
|
|
||||||
return true; |
|
||||||
} |
|
||||||
|
|
||||||
function isCurrentPage(page: string) { |
|
||||||
return page === currentPage; |
|
||||||
} |
|
||||||
|
|
||||||
function isLoaded(page: string) { |
|
||||||
if (page === "/") return true; |
|
||||||
return route.includes(page); |
|
||||||
} |
|
||||||
|
|
||||||
function pageProps(page: string) { |
|
||||||
return { |
|
||||||
show: isCurrentPage(page), |
|
||||||
active: isActive(page), |
|
||||||
}; |
|
||||||
} |
|
||||||
|
|
||||||
function reset() { |
|
||||||
navigate("/"); |
|
||||||
} |
|
||||||
|
|
||||||
return { |
|
||||||
navigate, |
|
||||||
reset, |
|
||||||
isLoaded, |
|
||||||
isCurrentPage, |
|
||||||
pageProps, |
|
||||||
isActive, |
|
||||||
}; |
|
||||||
} |
|
@ -1,70 +1,27 @@ |
|||||||
import i18n from "i18next"; |
import i18n from "i18next"; |
||||||
|
import ISO6391 from "iso-639-1"; |
||||||
import { initReactI18next } from "react-i18next"; |
import { initReactI18next } from "react-i18next"; |
||||||
|
|
||||||
// Languages
|
import { locales } from "@/assets/languages"; |
||||||
import { captionLanguages } from "./iso6391"; |
|
||||||
import cs from "./locales/cs/translation.json"; |
|
||||||
import de from "./locales/de/translation.json"; |
|
||||||
import en from "./locales/en/translation.json"; |
|
||||||
import fr from "./locales/fr/translation.json"; |
|
||||||
import it from "./locales/it/translation.json"; |
|
||||||
import nl from "./locales/nl/translation.json"; |
|
||||||
import pirate from "./locales/pirate/translation.json"; |
|
||||||
import pl from "./locales/pl/translation.json"; |
|
||||||
import tr from "./locales/tr/translation.json"; |
|
||||||
import vi from "./locales/vi/translation.json"; |
|
||||||
import zh from "./locales/zh/translation.json"; |
|
||||||
|
|
||||||
const locales = { |
// Languages
|
||||||
en: { |
const langCodes = Object.keys(locales); |
||||||
translation: en, |
const resources = Object.fromEntries( |
||||||
}, |
Object.entries(locales).map((entry) => [entry[0], { translation: entry[1] }]) |
||||||
it: { |
); |
||||||
translation: it, |
i18n.use(initReactI18next).init({ |
||||||
}, |
fallbackLng: "en", |
||||||
nl: { |
resources, |
||||||
translation: nl, |
interpolation: { |
||||||
}, |
escapeValue: false, // not needed for react as it escapes by default
|
||||||
tr: { |
|
||||||
translation: tr, |
|
||||||
}, |
|
||||||
fr: { |
|
||||||
translation: fr, |
|
||||||
}, |
|
||||||
de: { |
|
||||||
translation: de, |
|
||||||
}, |
|
||||||
zh: { |
|
||||||
translation: zh, |
|
||||||
}, |
|
||||||
cs: { |
|
||||||
translation: cs, |
|
||||||
}, |
|
||||||
pirate: { |
|
||||||
translation: pirate, |
|
||||||
}, |
|
||||||
vi: { |
|
||||||
translation: vi, |
|
||||||
}, |
|
||||||
pl: { |
|
||||||
translation: pl, |
|
||||||
}, |
}, |
||||||
}; |
}); |
||||||
i18n |
|
||||||
// pass the i18n instance to react-i18next.
|
|
||||||
.use(initReactI18next) |
|
||||||
// init i18next
|
|
||||||
// for all options read: https://www.i18next.com/overview/configuration-options
|
|
||||||
.init({ |
|
||||||
fallbackLng: "en", |
|
||||||
resources: locales, |
|
||||||
interpolation: { |
|
||||||
escapeValue: false, // not needed for react as it escapes by default
|
|
||||||
}, |
|
||||||
}); |
|
||||||
|
|
||||||
export const appLanguageOptions = captionLanguages.filter((x) => { |
export const appLanguageOptions = langCodes.map((lang) => { |
||||||
return Object.keys(locales).includes(x.id); |
const [langObj] = ISO6391.getLanguages([lang]); |
||||||
|
if (!langObj) |
||||||
|
throw new Error(`Language with code ${lang} cannot be found in database`); |
||||||
|
return langObj; |
||||||
}); |
}); |
||||||
|
|
||||||
export default i18n; |
export default i18n; |
||||||
|
@ -0,0 +1,11 @@ |
|||||||
|
function normalizeTitle(title: string): string { |
||||||
|
return title |
||||||
|
.trim() |
||||||
|
.toLowerCase() |
||||||
|
.replace(/['":]/g, "") |
||||||
|
.replace(/[^a-zA-Z0-9]+/g, "_"); |
||||||
|
} |
||||||
|
|
||||||
|
export function compareTitle(a: string, b: string): boolean { |
||||||
|
return normalizeTitle(a) === normalizeTitle(b); |
||||||
|
} |
@ -1,7 +0,0 @@ |
|||||||
export function normalizeTitle(title: string): string { |
|
||||||
return title |
|
||||||
.trim() |
|
||||||
.toLowerCase() |
|
||||||
.replace(/['":]/g, "") |
|
||||||
.replace(/[^a-zA-Z0-9]+/g, "_"); |
|
||||||
} |
|
Loading…
Reference in new issue