mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-27 09:51:51 +01:00
Lightbar
This commit is contained in:
parent
eb57f1958f
commit
1fde44076a
@ -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="text-search-text w-full flex-1 bg-transparent px-4 py-4 pl-12 placeholder-search-placeholder focus:outline-none sm:py-4 sm:pr-2"
|
||||||
|
placeholder={props.placeholder}
|
||||||
|
/>
|
||||||
|
</Flare.Child>
|
||||||
|
</Flare.Base>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -22,6 +22,15 @@ function FooterLink(props: {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
function Dmca() {
|
||||||
|
const { t } = useTranslation();
|
||||||
|
return (
|
||||||
|
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo">
|
||||||
|
{t("footer.links.dmca")}
|
||||||
|
</FooterLink>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function Footer() {
|
export function Footer() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
@ -33,29 +42,27 @@ export function Footer() {
|
|||||||
<BrandPill />
|
<BrandPill />
|
||||||
</div>
|
</div>
|
||||||
<p className="mt-4 lg:max-w-[400px]">{t("footer.tagline")}</p>
|
<p className="mt-4 lg:max-w-[400px]">{t("footer.tagline")}</p>
|
||||||
<div className="mt-8 space-x-[2rem]">
|
|
||||||
<FooterLink icon={Icons.GITHUB} href="https://github.com/movie-web">
|
|
||||||
{t("footer.links.github")}
|
|
||||||
</FooterLink>
|
|
||||||
<FooterLink
|
|
||||||
icon={Icons.DISCORD}
|
|
||||||
href="https://github.com/movie-web"
|
|
||||||
>
|
|
||||||
{t("footer.links.github")}
|
|
||||||
</FooterLink>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="md:text-right">
|
<div className="md:text-right">
|
||||||
<h3 className="font-semibold text-type-emphasis">
|
<h3 className="font-semibold text-type-emphasis">
|
||||||
{t("footer.legal.disclaimer")}
|
{t("footer.legal.disclaimer")}
|
||||||
</h3>
|
</h3>
|
||||||
<p className="mt-3">{t("footer.legal.disclaimerText")}</p>
|
<p className="mt-3">{t("footer.legal.disclaimerText")}</p>
|
||||||
<div className="mt-8">
|
</div>
|
||||||
<FooterLink icon={Icons.DRAGON} href="https://youtu.be/-WOonkg_ZCo">
|
<div className="space-x-[2rem]">
|
||||||
{t("footer.links.dmca")}
|
<FooterLink icon={Icons.GITHUB} href="https://github.com/movie-web">
|
||||||
</FooterLink>
|
{t("footer.links.github")}
|
||||||
|
</FooterLink>
|
||||||
|
<FooterLink icon={Icons.DISCORD} href="https://discord.movie-web.app">
|
||||||
|
{t("footer.links.discord")}
|
||||||
|
</FooterLink>
|
||||||
|
<div className="inline md:hidden">
|
||||||
|
<Dmca />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
<div className="hidden items-center justify-end md:flex">
|
||||||
|
<Dmca />
|
||||||
|
</div>
|
||||||
</WideContainer>
|
</WideContainer>
|
||||||
</footer>
|
</footer>
|
||||||
);
|
);
|
||||||
|
@ -3,6 +3,7 @@ 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 SettingsModal from "@/views/SettingsModal";
|
||||||
@ -18,60 +19,67 @@ export function Navigation(props: NavigationProps) {
|
|||||||
const bannerHeight = useBannerSize();
|
const bannerHeight = useBannerSize();
|
||||||
const [showModal, setShowModal] = useState(false);
|
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 flex items-center">
|
||||||
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 min-h-[150px] bg-gradient-to-b from-background-main via-background-main to-transparent sm:from-transparent"
|
||||||
|
style={{
|
||||||
|
top: `${bannerHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||||
|
<div
|
||||||
|
className={`${
|
||||||
|
props.bg ? "opacity-100" : "opacity-0"
|
||||||
|
} absolute inset-0 block bg-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`}
|
||||||
|
>
|
||||||
|
<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>
|
||||||
|
<SettingsModal show={showModal} onClose={() => setShowModal(false)} />
|
||||||
|
</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;
|
||||||
|
}
|
@ -1,5 +1,6 @@
|
|||||||
import c from "classnames";
|
import c from "classnames";
|
||||||
import { useEffect, useRef } from "react";
|
import { ReactNode, useEffect, useRef } from "react";
|
||||||
|
import "./Flare.css";
|
||||||
|
|
||||||
export interface FlareProps {
|
export interface FlareProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
@ -12,7 +13,15 @@ export interface FlareProps {
|
|||||||
const SIZE_DEFAULT = 200;
|
const SIZE_DEFAULT = 200;
|
||||||
const CSS_VAR_DEFAULT = "--colors-global-accentA";
|
const CSS_VAR_DEFAULT = "--colors-global-accentA";
|
||||||
|
|
||||||
export function Flare(props: FlareProps) {
|
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 outerRef = useRef<HTMLDivElement>(null);
|
||||||
const size = props.flareSize ?? SIZE_DEFAULT;
|
const size = props.flareSize ?? SIZE_DEFAULT;
|
||||||
const cssVar = props.cssColorVar ?? CSS_VAR_DEFAULT;
|
const cssVar = props.cssColorVar ?? CSS_VAR_DEFAULT;
|
||||||
@ -20,13 +29,14 @@ export function Flare(props: FlareProps) {
|
|||||||
useEffect(() => {
|
useEffect(() => {
|
||||||
function mouseMove(e: MouseEvent) {
|
function mouseMove(e: MouseEvent) {
|
||||||
if (!outerRef.current) return;
|
if (!outerRef.current) return;
|
||||||
|
const rect = outerRef.current.getBoundingClientRect();
|
||||||
outerRef.current.style.setProperty(
|
outerRef.current.style.setProperty(
|
||||||
"--bg-x",
|
"--bg-x",
|
||||||
`${(e.clientX - size / 2).toFixed(0)}px`
|
`${(e.clientX - rect.left - size / 2).toFixed(0)}px`
|
||||||
);
|
);
|
||||||
outerRef.current.style.setProperty(
|
outerRef.current.style.setProperty(
|
||||||
"--bg-y",
|
"--bg-y",
|
||||||
`${(e.clientY - size / 2).toFixed(0)}px`
|
`${(e.clientY - rect.top - size / 2).toFixed(0)}px`
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
document.addEventListener("mousemove", mouseMove);
|
document.addEventListener("mousemove", mouseMove);
|
||||||
@ -38,10 +48,10 @@ export function Flare(props: FlareProps) {
|
|||||||
<div
|
<div
|
||||||
ref={outerRef}
|
ref={outerRef}
|
||||||
className={c(
|
className={c(
|
||||||
"overflow-hidden, pointer-events-none absolute inset-0 hidden",
|
"flare-light pointer-events-none absolute inset-0 overflow-hidden opacity-0 transition-opacity duration-[400ms]",
|
||||||
props.className,
|
props.className,
|
||||||
{
|
{
|
||||||
"!block": props.enabled ?? false,
|
"!opacity-100": props.enabled ?? false,
|
||||||
}
|
}
|
||||||
)}
|
)}
|
||||||
style={{
|
style={{
|
||||||
@ -49,7 +59,7 @@ export function Flare(props: FlareProps) {
|
|||||||
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundAttachment: "fixed",
|
backgroundAttachment: "fixed",
|
||||||
backgroundSize: "200px 200px",
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -60,16 +70,22 @@ export function Flare(props: FlareProps) {
|
|||||||
)}
|
)}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className="absolute inset-0 opacity-5"
|
className="absolute inset-0 opacity-10"
|
||||||
style={{
|
style={{
|
||||||
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
|
background: `radial-gradient(circle at center, rgba(var(${cssVar}), 1), rgba(var(${cssVar}), 0) 70%)`,
|
||||||
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
backgroundPosition: `var(--bg-x) var(--bg-y)`,
|
||||||
backgroundRepeat: "no-repeat",
|
backgroundRepeat: "no-repeat",
|
||||||
backgroundAttachment: "fixed",
|
backgroundAttachment: "fixed",
|
||||||
backgroundSize: "200px 200px",
|
backgroundSize: `${size.toFixed(0)}px ${size.toFixed(0)}px`,
|
||||||
}}
|
}}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
export const Flare = {
|
||||||
|
Base,
|
||||||
|
Light,
|
||||||
|
Child,
|
||||||
|
};
|
||||||
|
22
src/components/utils/Lightbar.css
Normal file
22
src/components/utils/Lightbar.css
Normal file
@ -0,0 +1,22 @@
|
|||||||
|
.lightbar {
|
||||||
|
position: absolute;
|
||||||
|
left: -25vw;
|
||||||
|
top: 0;
|
||||||
|
width: 150vw;
|
||||||
|
height: 800px;
|
||||||
|
pointer-events: none;
|
||||||
|
user-select: none;
|
||||||
|
--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;
|
||||||
|
}
|
9
src/components/utils/Lightbar.tsx
Normal file
9
src/components/utils/Lightbar.tsx
Normal file
@ -0,0 +1,9 @@
|
|||||||
|
import "./Lightbar.css";
|
||||||
|
|
||||||
|
export function Lightbar(props: { className?: string }) {
|
||||||
|
return (
|
||||||
|
<div className={props.className}>
|
||||||
|
<div className="lightbar" />
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
@ -17,7 +17,6 @@ import { SettingsProvider } from "@/state/settings";
|
|||||||
import { WatchedContextProvider } from "@/state/watched";
|
import { WatchedContextProvider } from "@/state/watched";
|
||||||
import { MediaView } from "@/views/media/MediaView";
|
import { MediaView } from "@/views/media/MediaView";
|
||||||
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
import { NotFoundPage } from "@/views/notfound/NotFoundView";
|
||||||
import { V2MigrationView } from "@/views/other/v2Migration";
|
|
||||||
import { SearchView } from "@/views/search/SearchView";
|
import { SearchView } from "@/views/search/SearchView";
|
||||||
|
|
||||||
function LegacyUrlView({ children }: { children: ReactElement }) {
|
function LegacyUrlView({ children }: { children: ReactElement }) {
|
||||||
@ -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>
|
||||||
|
@ -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],
|
||||||
@ -198,4 +199,4 @@ input[type=range].styled-slider.slider-progress::-ms-fill-lower {
|
|||||||
::-webkit-scrollbar {
|
::-webkit-scrollbar {
|
||||||
/* For some reason the styles don't get applied without the width */
|
/* For some reason the styles don't get applied without the width */
|
||||||
width: 13px;
|
width: 13px;
|
||||||
}
|
}
|
||||||
|
@ -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": {
|
||||||
@ -136,7 +136,8 @@
|
|||||||
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
|
"tagline": "Watch your favorite shows and movies with this open source streaming app.",
|
||||||
"links": {
|
"links": {
|
||||||
"github": "GitHub",
|
"github": "GitHub",
|
||||||
"dmca": "DMCA"
|
"dmca": "DMCA",
|
||||||
|
"discord": "Discord"
|
||||||
},
|
},
|
||||||
"legal": {
|
"legal": {
|
||||||
"disclaimer": "Disclaimer",
|
"disclaimer": "Disclaimer",
|
||||||
|
0
src/views/HomePage.tsx
Normal file
0
src/views/HomePage.tsx
Normal file
0
src/views/SearchPart.tsx
Normal file
0
src/views/SearchPart.tsx
Normal file
@ -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;
|
|
||||||
}
|
|
58
src/views/parts/home/BookmarksPart.tsx
Normal file
58
src/views/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>
|
||||||
|
);
|
||||||
|
}
|
45
src/views/parts/home/WatchingPart.tsx
Normal file
45
src/views/parts/home/WatchingPart.tsx
Normal file
@ -0,0 +1,45 @@
|
|||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
|
import {
|
||||||
|
getIfBookmarkedFromPortable,
|
||||||
|
useBookmarkContext,
|
||||||
|
} from "@/state/bookmark";
|
||||||
|
import { useWatchedContext } from "@/state/watched";
|
||||||
|
|
||||||
|
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>
|
||||||
|
);
|
||||||
|
}
|
@ -1,12 +1,9 @@
|
|||||||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useCallback, useEffect, useMemo, useState } from "react";
|
import { useMemo, useState } from "react";
|
||||||
import { Trans, useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { useHistory } from "react-router-dom";
|
|
||||||
|
|
||||||
import { Button } from "@/components/Button";
|
|
||||||
import { EditButton } from "@/components/buttons/EditButton";
|
import { EditButton } from "@/components/buttons/EditButton";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { Modal, ModalCard } from "@/components/layout/Modal";
|
|
||||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
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";
|
||||||
@ -16,8 +13,6 @@ import {
|
|||||||
} from "@/state/bookmark";
|
} from "@/state/bookmark";
|
||||||
import { useWatchedContext } from "@/state/watched";
|
import { useWatchedContext } from "@/state/watched";
|
||||||
|
|
||||||
import { EmbedMigration } from "../other/v2Migration";
|
|
||||||
|
|
||||||
function Bookmarks() {
|
function Bookmarks() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
|
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
|
||||||
@ -101,106 +96,9 @@ function Watched() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
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() {
|
export function HomeView() {
|
||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
<EmbedMigration />
|
|
||||||
<NewDomainModal />
|
|
||||||
<Bookmarks />
|
<Bookmarks />
|
||||||
<Watched />
|
<Watched />
|
||||||
</div>
|
</div>
|
||||||
|
@ -27,16 +27,13 @@ export function SearchView() {
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<FooterView>
|
<FooterView>
|
||||||
|
<Navigation bg={showBg} />
|
||||||
<div className="relative z-10 mb-16 sm:mb-24">
|
<div className="relative z-10 mb-16 sm:mb-24">
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<title>{t("global.name")}</title>
|
<title>{t("global.name")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
<Navigation bg={showBg} />
|
|
||||||
<ThinContainer>
|
<ThinContainer>
|
||||||
<div className="mt-44 space-y-16 text-center">
|
<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">
|
<div className="relative z-10 mb-16">
|
||||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
||||||
</div>
|
</div>
|
||||||
|
@ -50,20 +50,39 @@ module.exports = {
|
|||||||
defaultTheme: {
|
defaultTheme: {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
|
// meta data for the theme itself
|
||||||
|
global: {
|
||||||
|
accentA: "#505DBD",
|
||||||
|
accentB: "#3440A1"
|
||||||
|
},
|
||||||
|
|
||||||
|
// light bar
|
||||||
|
lightBar: {
|
||||||
|
light: "#2A2A71"
|
||||||
|
},
|
||||||
|
|
||||||
|
// only used for body colors/textures
|
||||||
background: {
|
background: {
|
||||||
main: "#0A0A10",
|
main: "#0A0A10",
|
||||||
accentA: "#6E3B80",
|
accentA: "#6E3B80",
|
||||||
accentB: "#1F1F50"
|
accentB: "#1F1F50"
|
||||||
},
|
},
|
||||||
global: {
|
|
||||||
accentA: "#505DBD",
|
// typography
|
||||||
accentB: "#3440A1"
|
|
||||||
},
|
|
||||||
type: {
|
type: {
|
||||||
emphasis: "#FFFFFF",
|
emphasis: "#FFFFFF",
|
||||||
text: "#73739D",
|
text: "#73739D",
|
||||||
dimmed: "#926CAD",
|
dimmed: "#926CAD",
|
||||||
divider: "#353549"
|
divider: "#262632"
|
||||||
|
},
|
||||||
|
|
||||||
|
// search bar
|
||||||
|
search: {
|
||||||
|
background: "#1E1E33",
|
||||||
|
focused: "#24243C",
|
||||||
|
placeholder: "#4A4A71",
|
||||||
|
icon: "#545476",
|
||||||
|
text: "#FFFFFF"
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
Loading…
Reference in New Issue
Block a user