77 changed files with 233 additions and 1965 deletions
@ -0,0 +1,5 @@
@@ -0,0 +1,5 @@
|
||||
import en from "@/assets/locales/en.json"; |
||||
|
||||
export const locales = { |
||||
en, |
||||
}; |
@ -0,0 +1,59 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -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 @@
@@ -1,17 +0,0 @@
|
||||
@keyframes fadeIn { |
||||
0% { |
||||
opacity: 0; |
||||
} |
||||
100% { |
||||
opacity: 1; |
||||
} |
||||
} |
||||
|
||||
@keyframes fadeOut { |
||||
0% { |
||||
opacity: 1; |
||||
} |
||||
100% { |
||||
opacity: 0; |
||||
} |
||||
} |
@ -1,29 +0,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 @@
@@ -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 @@
@@ -1,70 +1,27 @@
|
||||
import i18n from "i18next"; |
||||
import ISO6391 from "iso-639-1"; |
||||
import { initReactI18next } from "react-i18next"; |
||||
|
||||
// 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"; |
||||
import { locales } from "@/assets/languages"; |
||||
|
||||
const locales = { |
||||
en: { |
||||
translation: en, |
||||
}, |
||||
it: { |
||||
translation: it, |
||||
}, |
||||
nl: { |
||||
translation: nl, |
||||
}, |
||||
tr: { |
||||
translation: tr, |
||||
}, |
||||
fr: { |
||||
translation: fr, |
||||
}, |
||||
de: { |
||||
translation: de, |
||||
}, |
||||
zh: { |
||||
translation: zh, |
||||
}, |
||||
cs: { |
||||
translation: cs, |
||||
}, |
||||
pirate: { |
||||
translation: pirate, |
||||
}, |
||||
vi: { |
||||
translation: vi, |
||||
}, |
||||
pl: { |
||||
translation: pl, |
||||
// 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
|
||||
}, |
||||
}; |
||||
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) => { |
||||
return Object.keys(locales).includes(x.id); |
||||
export const appLanguageOptions = langCodes.map((lang) => { |
||||
const [langObj] = ISO6391.getLanguages([lang]); |
||||
if (!langObj) |
||||
throw new Error(`Language with code ${lang} cannot be found in database`); |
||||
return langObj; |
||||
}); |
||||
|
||||
export default i18n; |
||||
|
@ -0,0 +1,11 @@
@@ -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 @@
@@ -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