mirror of
https://github.com/p-stream/p-stream.git
synced 2026-05-17 23:01:46 +00:00
OLD 3.2.1
remove discover and fix some stuff fix some color errors init old breaking shit but idc 1
This commit is contained in:
parent
faee906010
commit
88e03fd08d
45 changed files with 597 additions and 2831 deletions
|
|
@ -7,25 +7,23 @@ export interface IconPatchProps {
|
||||||
className?: string;
|
className?: string;
|
||||||
icon: Icons;
|
icon: Icons;
|
||||||
transparent?: boolean;
|
transparent?: boolean;
|
||||||
downsized?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconPatch(props: IconPatchProps) {
|
export function IconPatch(props: IconPatchProps) {
|
||||||
const clickableClasses = props.clickable
|
const clickableClasses = props.clickable
|
||||||
? "cursor-pointer hover:scale-110 hover:bg-pill-backgroundHover hover:text-white active:scale-125"
|
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||||
: "";
|
: "";
|
||||||
const transparentClasses = props.transparent
|
const transparentClasses = props.transparent
|
||||||
? "bg-opacity-0 hover:bg-opacity-50"
|
? "bg-opacity-0 hover:bg-opacity-50"
|
||||||
: "";
|
: "";
|
||||||
const activeClasses = props.active
|
const activeClasses = props.active
|
||||||
? "bg-pill-backgroundHover text-white"
|
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||||
: "";
|
: "";
|
||||||
const sizeClasses = props.downsized ? "h-10 w-10" : "h-12 w-12";
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className || undefined} onClick={props.onClick}>
|
<div className={props.className || undefined} onClick={props.onClick}>
|
||||||
<div
|
<div
|
||||||
className={`flex items-center justify-center rounded-full border-2 border-transparent bg-pill-background bg-opacity-100 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses} ${sizeClasses}`}
|
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||||
>
|
>
|
||||||
<Icon icon={props.icon} />
|
<Icon icon={props.icon} />
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -1,7 +1,5 @@
|
||||||
import c from "classnames";
|
import c from "classnames";
|
||||||
import { forwardRef, useRef, useState } from "react";
|
import { forwardRef, useState } from "react";
|
||||||
|
|
||||||
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";
|
||||||
|
|
@ -16,103 +14,50 @@ export interface SearchBarProps {
|
||||||
export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
const [focused, setFocused] = useState(false);
|
const [focused, setFocused] = useState(false);
|
||||||
const containerRef = useRef<HTMLDivElement>(null);
|
|
||||||
const [showTooltip, setShowTooltip] = useState(false);
|
|
||||||
|
|
||||||
function setSearch(value: string) {
|
function setSearch(value: string) {
|
||||||
props.onChange(value, true);
|
props.onChange(value, true);
|
||||||
}
|
}
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div ref={containerRef}>
|
<div
|
||||||
<Flare.Base
|
className={c(
|
||||||
className={c({
|
"relative flex flex-col rounded-[28px] transition-colors bg-denim-400",
|
||||||
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative":
|
{
|
||||||
true,
|
"hover:bg-denim-500": true,
|
||||||
"bg-search-background": !focused,
|
},
|
||||||
"bg-search-focused": focused,
|
)}
|
||||||
})}
|
>
|
||||||
>
|
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center denim-700">
|
||||||
<Flare.Light
|
<Icon icon={Icons.SEARCH} />
|
||||||
flareSize={400}
|
</div>
|
||||||
enabled={focused}
|
|
||||||
className="rounded-[28px]"
|
|
||||||
backgroundClass={c({
|
|
||||||
"transition-colors": true,
|
|
||||||
"bg-search-background": !focused,
|
|
||||||
"bg-search-focused": focused,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Flare.Child className="flex flex-1 flex-col">
|
|
||||||
<div
|
|
||||||
className="absolute bottom-0 left-5 top-0 flex max-h-14 items-center text-search-icon cursor-pointer z-10"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setShowTooltip(!showTooltip);
|
|
||||||
if (ref && typeof ref !== "function" && ref.current) {
|
|
||||||
ref.current.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon={Icons.SEARCH} />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<TextInputControl
|
<TextInputControl
|
||||||
ref={ref}
|
ref={ref}
|
||||||
onUnFocus={() => {
|
onUnFocus={() => {
|
||||||
setFocused(false);
|
setFocused(false);
|
||||||
props.onUnFocus();
|
props.onUnFocus();
|
||||||
}}
|
}}
|
||||||
onFocus={() => setFocused(true)}
|
onFocus={() => setFocused(true)}
|
||||||
onChange={(val) => setSearch(val)}
|
onChange={(val) => setSearch(val)}
|
||||||
value={props.value}
|
value={props.value}
|
||||||
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"
|
className="w-full flex-1 bg-transparent py-4 pl-12 text-white placeholder-denim-700 focus:outline-none sm:py-4 sm:pr-2"
|
||||||
placeholder={props.placeholder}
|
placeholder={props.placeholder}
|
||||||
/>
|
/>
|
||||||
|
|
||||||
{showTooltip && (
|
{props.value.length > 0 && (
|
||||||
<div className="py-4">
|
<div
|
||||||
<p className="font-bold text-sm mb-1 text-search-text">
|
onClick={() => {
|
||||||
Advanced Search:
|
props.onUnFocus("");
|
||||||
</p>
|
if (ref && typeof ref !== "function") {
|
||||||
<div className="space-y-1.5 text-xs text-search-text">
|
ref.current?.focus();
|
||||||
<div>
|
}
|
||||||
<p className="mb-0.5">Year search:</p>
|
}}
|
||||||
<p className="text-type-secondary italic pl-2">
|
className="cursor-pointer hover:text-white absolute bottom-0 right-2 top-0 flex justify-center my-auto h-10 w-10 items-center hover:bg-denim-600 active:scale-110 text-white rounded-full transition-[transform,background-color] duration-200"
|
||||||
Inception year:2010
|
>
|
||||||
</p>
|
<Icon icon={Icons.X} className="transition-colors duration-200" />
|
||||||
</div>
|
</div>
|
||||||
<div>
|
)}
|
||||||
<p className="mb-0.5">TMDB ID search:</p>
|
|
||||||
<p className="text-type-secondary italic pl-2">
|
|
||||||
tmdb:123456 - For movies
|
|
||||||
</p>
|
|
||||||
<p className="text-type-secondary italic pl-2">
|
|
||||||
tmdb:123456:tv - For TV shows
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{props.value.length > 0 && (
|
|
||||||
<div
|
|
||||||
onClick={() => {
|
|
||||||
props.onUnFocus("");
|
|
||||||
if (ref && typeof ref !== "function") {
|
|
||||||
ref.current?.focus();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
className="cursor-pointer hover:text-white absolute bottom-0 right-2 top-0 flex justify-center my-auto h-10 w-10 items-center hover:bg-search-hoverBackground active:scale-110 text-search-icon rounded-full transition-[transform,background-color] duration-200"
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={Icons.X}
|
|
||||||
className="transition-colors duration-200"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</Flare.Child>
|
|
||||||
</Flare.Base>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
},
|
},
|
||||||
|
|
|
||||||
|
|
@ -1,32 +1,26 @@
|
||||||
import classNames from "classnames";
|
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
|
|
||||||
export function BrandPill(props: {
|
export function BrandPill(props: {
|
||||||
clickable?: boolean;
|
clickable?: boolean;
|
||||||
header?: boolean;
|
hideTextOnMobile?: boolean;
|
||||||
backgroundClass?: string;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const isMobile = useIsMobile();
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
|
||||||
"flex items-center space-x-2 rounded-full px-4 py-2 text-type-logo",
|
|
||||||
props.backgroundClass ?? "bg-pill-background bg-opacity-50",
|
|
||||||
props.clickable
|
props.clickable
|
||||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-pill-backgroundHover backdrop-blur-lg hover:text-type-logo active:scale-95"
|
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-400 hover:text-bink-700 active:scale-95"
|
||||||
: "",
|
: ""
|
||||||
)}
|
}`}
|
||||||
>
|
>
|
||||||
<Icon className="text-2xl" icon={Icons.LOGO} />
|
<Icon className="text-xl" icon={Icons.LOGO} />
|
||||||
<span
|
<span
|
||||||
className={[
|
className={[
|
||||||
"font-semibold text-white",
|
"font-semibold text-white",
|
||||||
isMobile && props.header ? "hidden sm:block" : "",
|
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||||
].join(" ")}
|
].join(" ")}
|
||||||
>
|
>
|
||||||
{t("global.name")}
|
{t("global.name")}
|
||||||
|
|
|
||||||
|
|
@ -1,108 +0,0 @@
|
||||||
import { useCallback } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
import type { RequireExactlyOne } from "type-fest";
|
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
|
||||||
import { WideContainer } from "@/components/layout/WideContainer";
|
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
|
|
||||||
// to and href are mutually exclusive
|
|
||||||
type FooterLinkProps = RequireExactlyOne<
|
|
||||||
{
|
|
||||||
children: React.ReactNode;
|
|
||||||
icon: Icons;
|
|
||||||
to: string;
|
|
||||||
href: string;
|
|
||||||
},
|
|
||||||
"to" | "href"
|
|
||||||
>;
|
|
||||||
|
|
||||||
function FooterLink(props: FooterLinkProps) {
|
|
||||||
const navigate = useNavigate();
|
|
||||||
|
|
||||||
const navigateTo = useCallback(() => {
|
|
||||||
if (!props.to) return;
|
|
||||||
|
|
||||||
navigate(props.to);
|
|
||||||
}, [navigate, props.to]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<a
|
|
||||||
href={props.href}
|
|
||||||
target={props.href ? "_blank" : undefined}
|
|
||||||
rel="noreferrer"
|
|
||||||
className="tabbable rounded py-2 px-3 inline-flex cursor-pointer items-center space-x-3 transition-colors duration-200 hover:text-type-emphasis"
|
|
||||||
onClick={props.to ? navigateTo : undefined}
|
|
||||||
>
|
|
||||||
<Icon icon={props.icon} className="text-2xl" />
|
|
||||||
<span className="font-medium">{props.children}</span>
|
|
||||||
</a>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
function Dmca() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
if (window.location.hash === "#/dmca") return null;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<FooterLink to="/dmca" icon={Icons.DRAGON}>
|
|
||||||
{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="flex flex-wrap gap-[0.5rem] -ml-3">
|
|
||||||
<FooterLink icon={Icons.DISCORD} href={conf().DISCORD_LINK}>
|
|
||||||
{t("footer.links.discord")}
|
|
||||||
</FooterLink>
|
|
||||||
<FooterLink href="https://rentry.co/h5mypdfs" icon={Icons.TIP_JAR}>
|
|
||||||
Support us
|
|
||||||
</FooterLink>
|
|
||||||
<div className="inline md:hidden">
|
|
||||||
<Dmca />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
<div className="hidden items-center justify-end md:flex -mr-3">
|
|
||||||
<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>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -2,7 +2,7 @@ import { Icon, Icons } from "@/components/Icon";
|
||||||
|
|
||||||
export function IconPill(props: { icon: Icons; children?: React.ReactNode }) {
|
export function IconPill(props: { icon: Icons; children?: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<div className="bg-pill-background bg-opacity-50 px-4 py-2 rounded-full text-white flex justify-center items-center">
|
<div className="bg-denim-400 hover:bg-denim-500 px-4 py-2 rounded-full text-white flex justify-center items-center">
|
||||||
<Icon
|
<Icon
|
||||||
icon={props.icon ?? Icons.WAND}
|
icon={props.icon ?? Icons.WAND}
|
||||||
className="mr-3 text-xl text-type-link"
|
className="mr-3 text-xl text-type-link"
|
||||||
|
|
|
||||||
|
|
@ -1,28 +1,18 @@
|
||||||
import classNames from "classnames";
|
|
||||||
import { Link, To, useNavigate } from "react-router-dom";
|
import { Link, To, useNavigate } from "react-router-dom";
|
||||||
|
|
||||||
import { NoUserAvatar, UserAvatar } from "@/components/Avatar";
|
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { LinksDropdown } from "@/components/LinksDropdown";
|
|
||||||
import { Lightbar } from "@/components/utils/Lightbar";
|
|
||||||
import { useAuth } from "@/hooks/auth/useAuth";
|
|
||||||
import { BlurEllipsis } from "@/pages/layouts/SubPageLayout";
|
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
import { useBannerSize } from "@/stores/banner";
|
import { useBannerSize } from "@/stores/banner";
|
||||||
|
|
||||||
import { BrandPill } from "./BrandPill";
|
import { BrandPill } from "./BrandPill";
|
||||||
|
|
||||||
export interface NavigationProps {
|
export interface NavigationProps {
|
||||||
bg?: boolean;
|
bg?: boolean;
|
||||||
noLightbar?: boolean;
|
|
||||||
doBackground?: boolean;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function Navigation(props: NavigationProps) {
|
export function Navigation(props: NavigationProps) {
|
||||||
const bannerHeight = useBannerSize();
|
const bannerHeight = useBannerSize();
|
||||||
const navigate = useNavigate();
|
const navigate = useNavigate();
|
||||||
const { loggedIn } = useAuth();
|
|
||||||
|
|
||||||
const handleClick = (path: To) => {
|
const handleClick = (path: To) => {
|
||||||
window.scrollTo(0, 0);
|
window.scrollTo(0, 0);
|
||||||
|
|
@ -30,93 +20,53 @@ export function Navigation(props: NavigationProps) {
|
||||||
};
|
};
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<>
|
<div
|
||||||
{/* lightbar */}
|
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"
|
||||||
{!props.noLightbar ? (
|
style={{
|
||||||
|
top: `${bannerHeight}px`,
|
||||||
|
}}
|
||||||
|
>
|
||||||
|
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||||
<div
|
<div
|
||||||
className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center"
|
className={`${
|
||||||
style={{
|
props.bg ? "opacity-100" : "opacity-0"
|
||||||
top: `${bannerHeight}px`,
|
} absolute inset-0 block bg-denim-100 transition-opacity duration-300`}
|
||||||
}}
|
|
||||||
>
|
>
|
||||||
<div className="absolute inset-x-0 -mt-[22%] flex items-center sm:mt-0">
|
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||||
<Lightbar />
|
</div>
|
||||||
|
|
||||||
|
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||||
|
<div className="mr-auto sm:mr-6">
|
||||||
|
<Link to="/" onClick={() => window.scrollTo(0, 0)}>
|
||||||
|
<BrandPill clickable />
|
||||||
|
</Link>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
|
||||||
|
|
||||||
{/* backgrounds - these are seperate because of z-index issues */}
|
<div className="relative flex items-center gap-4">
|
||||||
<div
|
<a
|
||||||
className="top-content fixed z-[20] pointer-events-none left-0 right-0 top-0 min-h-[150px]"
|
onClick={() => handleClick("/settings")}
|
||||||
style={{
|
rel="noreferrer"
|
||||||
top: `${bannerHeight}px`,
|
className="text-xl text-white tabbable rounded-full"
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div
|
|
||||||
className={classNames(
|
|
||||||
"fixed left-0 right-0 top-0 flex items-center",
|
|
||||||
props.doBackground
|
|
||||||
? "bg-background-main border-b border-utils-divider border-opacity-50"
|
|
||||||
: null,
|
|
||||||
)}
|
|
||||||
>
|
|
||||||
{props.doBackground ? (
|
|
||||||
<div className="absolute w-full h-full inset-0 overflow-hidden">
|
|
||||||
<BlurEllipsis positionClass="absolute" />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div className="opacity-0 absolute inset-0 block h-20 pointer-events-auto" />
|
|
||||||
<div
|
|
||||||
className={`${
|
|
||||||
props.bg ? "opacity-100" : "opacity-0"
|
|
||||||
} absolute inset-0 block h-24 bg-background-main transition-opacity duration-300`}
|
|
||||||
>
|
>
|
||||||
<div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
|
<IconPatch icon={Icons.GEAR} clickable />
|
||||||
</div>
|
</a>
|
||||||
|
<a
|
||||||
|
href="https://discord.gg/7z6znYgrTG"
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xl text-white tabbable rounded-full"
|
||||||
|
>
|
||||||
|
<IconPatch icon={Icons.DISCORD} clickable />
|
||||||
|
</a>
|
||||||
|
<a
|
||||||
|
onClick={() => handleClick("/settings")}
|
||||||
|
rel="noreferrer"
|
||||||
|
className="text-xl text-white tabbable rounded-full"
|
||||||
|
>
|
||||||
|
<IconPatch icon={Icons.GITHUB} clickable />
|
||||||
|
</a>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
</div>
|
||||||
{/* content */}
|
|
||||||
<div
|
|
||||||
className="top-content fixed pointer-events-none left-0 right-0 z-[60] top-0 min-h-[150px]"
|
|
||||||
style={{
|
|
||||||
top: `${bannerHeight}px`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<div className={classNames("fixed left-0 right-0 flex items-center")}>
|
|
||||||
<div className="px-7 py-5 relative z-[60] flex flex-1 items-center justify-between">
|
|
||||||
<div className="flex items-center space-x-1.5 ssm:space-x-3 pointer-events-auto">
|
|
||||||
<Link
|
|
||||||
className="block tabbable rounded-full text-xs ssm:text-base"
|
|
||||||
to="/"
|
|
||||||
onClick={() => window.scrollTo(0, 0)}
|
|
||||||
>
|
|
||||||
<BrandPill clickable header />
|
|
||||||
</Link>
|
|
||||||
<a
|
|
||||||
href={conf().DISCORD_LINK}
|
|
||||||
target="_blank"
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-xl text-white tabbable rounded-full"
|
|
||||||
>
|
|
||||||
<IconPatch icon={Icons.DISCORD} clickable downsized />
|
|
||||||
</a>
|
|
||||||
<a
|
|
||||||
onClick={() => handleClick("/discover")}
|
|
||||||
rel="noreferrer"
|
|
||||||
className="text-xl text-white tabbable rounded-full"
|
|
||||||
>
|
|
||||||
<IconPatch icon={Icons.RISING_STAR} clickable downsized />
|
|
||||||
</a>
|
|
||||||
</div>
|
|
||||||
<div className="relative pointer-events-auto">
|
|
||||||
<LinksDropdown>
|
|
||||||
{loggedIn ? <UserAvatar withName /> : <NoUserAvatar />}
|
|
||||||
</LinksDropdown>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -14,7 +14,7 @@ export function ProgressRing(props: Props) {
|
||||||
viewBox="0 0 100 100"
|
viewBox="0 0 100 100"
|
||||||
>
|
>
|
||||||
<circle
|
<circle
|
||||||
className={`fill-transparent stroke-type-text stroke-[15] opacity-25 ${
|
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||||
props.backingRingClassname ?? ""
|
props.backingRingClassname ?? ""
|
||||||
}`}
|
}`}
|
||||||
r={radius}
|
r={radius}
|
||||||
|
|
|
||||||
|
|
@ -10,9 +10,7 @@ export function WideContainer(props: WideContainerProps) {
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`mx-auto max-w-full px-8 ${
|
className={`mx-auto max-w-full px-8 ${
|
||||||
props.ultraWide
|
props.ultraWide ? "w-[1300px] sm:px-16" : "w-[750px] sm:px-8"
|
||||||
? "w-[1300px] xl:w-[18000px] 3xl:w-[2400px] 4xl:w-[2800px]"
|
|
||||||
: "w-[900px] xl:w-[1200px] 3xl:w-[1600px] 4xl:w-[1800px]"
|
|
||||||
} ${props.classNames || ""}`}
|
} ${props.classNames || ""}`}
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
|
|
|
||||||
|
|
@ -1,24 +1,16 @@
|
||||||
// I'm sorry this is so confusing 😭
|
// I'm sorry this is so confusing 😭
|
||||||
|
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback } from "react";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { Link } from "react-router-dom";
|
import { Link } from "react-router-dom";
|
||||||
import { useCopyToClipboard } from "react-use";
|
|
||||||
|
|
||||||
import { mediaItemToId } from "@/backend/metadata/tmdb";
|
import { mediaItemToId } from "@/backend/metadata/tmdb";
|
||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
import { Flare } from "@/components/utils/Flare";
|
|
||||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
import { MediaItem } from "@/utils/mediaTypes";
|
||||||
|
|
||||||
import { MediaBookmarkButton } from "./MediaBookmark";
|
|
||||||
import { Button } from "../buttons/Button";
|
|
||||||
import { IconPatch } from "../buttons/IconPatch";
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
import { Icon, Icons } from "../Icon";
|
import { Icons } from "../Icon";
|
||||||
import { DetailsModal } from "../overlays/DetailsModal";
|
|
||||||
import { useModal } from "../overlays/Modal";
|
|
||||||
|
|
||||||
export interface MediaCardProps {
|
export interface MediaCardProps {
|
||||||
media: MediaItem;
|
media: MediaItem;
|
||||||
|
|
@ -32,7 +24,6 @@ export interface MediaCardProps {
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
function checkReleased(media: MediaItem): boolean {
|
function checkReleased(media: MediaItem): boolean {
|
||||||
|
|
@ -60,43 +51,14 @@ function MediaCardContent({
|
||||||
percentage,
|
percentage,
|
||||||
closable,
|
closable,
|
||||||
onClose,
|
onClose,
|
||||||
overlayVisible,
|
}: MediaCardProps) {
|
||||||
setOverlayVisible,
|
|
||||||
handleMouseEnter,
|
|
||||||
handleMouseLeave,
|
|
||||||
link,
|
|
||||||
isHoveringCard,
|
|
||||||
onShowDetails,
|
|
||||||
}: MediaCardProps & {
|
|
||||||
overlayVisible: boolean;
|
|
||||||
setOverlayVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
|
||||||
handleMouseEnter: () => void;
|
|
||||||
handleMouseLeave: () => void;
|
|
||||||
link: string;
|
|
||||||
isHoveringCard: boolean;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||||
|
|
||||||
const isReleased = useCallback(() => checkReleased(media), [media]);
|
const isReleased = useCallback(() => checkReleased(media), [media]);
|
||||||
|
|
||||||
const canLink = linkable && !closable && isReleased();
|
const canLink = linkable && !closable && isReleased();
|
||||||
|
|
||||||
const dotListContent = [t(`media.types.${media.type}`)];
|
const dotListContent = [t(`media.types.${media.type}`)];
|
||||||
|
|
||||||
const altDotListContent = [t(`ID: ${media.id}`)];
|
|
||||||
|
|
||||||
const [searchQuery] = useSearchQuery();
|
|
||||||
|
|
||||||
const [, copyToClipboard] = useCopyToClipboard();
|
|
||||||
const [hasCopied, setHasCopied] = useState(false);
|
|
||||||
|
|
||||||
const [hasCopiedID, setHasCopiedID] = useState(false);
|
|
||||||
|
|
||||||
if (closable) {
|
|
||||||
setOverlayVisible(false);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (isReleased() && media.year) {
|
if (isReleased() && media.year) {
|
||||||
dotListContent.push(media.year.toFixed());
|
dotListContent.push(media.year.toFixed());
|
||||||
}
|
}
|
||||||
|
|
@ -105,311 +67,100 @@ function MediaCardContent({
|
||||||
dotListContent.push(t("media.unreleased"));
|
dotListContent.push(t("media.unreleased"));
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleCopyClick = (
|
|
||||||
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
|
||||||
copyToClipboard(link);
|
|
||||||
setHasCopied(true);
|
|
||||||
setTimeout(() => setHasCopied(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCopyIDClick = (
|
|
||||||
e: React.MouseEvent<HTMLButtonElement | HTMLAnchorElement>,
|
|
||||||
) => {
|
|
||||||
e.preventDefault();
|
|
||||||
copyToClipboard(media.id);
|
|
||||||
setHasCopiedID(true);
|
|
||||||
setTimeout(() => setHasCopiedID(false), 2000);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className={classNames("media-card-content")}>
|
||||||
className={classNames("media-card-content", { jiggle: closable })}
|
<div
|
||||||
onMouseEnter={handleMouseEnter}
|
className={`group -m-3 mb-2 rounded-xl bg-background-main bg-opacity-0 transition-colors duration-100 ${
|
||||||
onMouseLeave={handleMouseLeave}
|
canLink ? "hover:bg-opacity-100" : ""
|
||||||
>
|
|
||||||
<Flare.Base
|
|
||||||
className={`group -m-[0.705em] rounded-xl bg-background-main transition-colors duration-300 focus:relative focus:z-10 ${
|
|
||||||
canLink ? "hover:bg-mediaCard-hoverBackground tabbable" : ""
|
|
||||||
}`}
|
}`}
|
||||||
tabIndex={canLink ? 0 : -1}
|
|
||||||
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
|
|
||||||
>
|
>
|
||||||
<Flare.Light
|
<article
|
||||||
flareSize={300}
|
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
canLink ? "group-hover:scale-95" : ""
|
||||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
|
||||||
className={classNames({
|
|
||||||
"rounded-xl bg-background-main group-hover:opacity-100": canLink,
|
|
||||||
})}
|
|
||||||
/>
|
|
||||||
<Flare.Child
|
|
||||||
className={`pointer-events-auto relative mb-2 p-[0.4em] transition-transform duration-300 ${
|
|
||||||
canLink ? (isHoveringCard ? "scale-95" : "") : "opacity-60"
|
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
className={classNames(
|
className={[
|
||||||
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
|
"relative mb-4 aspect-[2/3] w-full overflow-hidden rounded-xl bg-denim-500 bg-cover bg-center transition-[border-radius] duration-100",
|
||||||
{
|
closable ? "" : "group-hover:rounded-lg",
|
||||||
"group-hover:rounded-lg": canLink,
|
].join(" ")}
|
||||||
"blur-sm": overlayVisible,
|
|
||||||
},
|
|
||||||
)}
|
|
||||||
style={{
|
style={{
|
||||||
backgroundImage: media.poster
|
backgroundImage: media.poster
|
||||||
? overlayVisible
|
? `url(${media.poster})`
|
||||||
? `linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.8)), url(${media.poster})`
|
|
||||||
: `url(${media.poster})`
|
|
||||||
: undefined,
|
: undefined,
|
||||||
}}
|
}}
|
||||||
>
|
>
|
||||||
{!overlayVisible ? (
|
{series ? (
|
||||||
<div>
|
<div
|
||||||
{series ? (
|
className={[
|
||||||
<div
|
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
||||||
className={[
|
closable ? "" : "group-hover:bg-denim-500",
|
||||||
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
|
].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(" ")}
|
>
|
||||||
>
|
{t("media.episodeDisplay", {
|
||||||
{t("media.episodeDisplay", {
|
season: series.season || 1,
|
||||||
season: series.season || 1,
|
episode: series.episode,
|
||||||
episode: series.episode,
|
})}
|
||||||
})}
|
</p>
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
{percentage !== undefined ? (
|
|
||||||
<>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-x-0 -bottom-px pb-1 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
|
||||||
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div
|
|
||||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-mediaCard-shadow to-transparent transition-colors ${
|
|
||||||
canLink ? "group-hover:from-mediaCard-hoverShadow" : ""
|
|
||||||
}`}
|
|
||||||
/>
|
|
||||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
|
||||||
<div className="relative h-1 overflow-hidden rounded-full bg-mediaCard-barColor">
|
|
||||||
<div
|
|
||||||
className="absolute inset-y-0 left-0 rounded-full bg-mediaCard-barFillColor"
|
|
||||||
style={{
|
|
||||||
width: percentageString,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
) : null}
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
{!overlayVisible ? (
|
{percentage !== undefined ? (
|
||||||
<div>
|
<>
|
||||||
{!closable ? (
|
|
||||||
<div>
|
|
||||||
<div
|
|
||||||
className="absolute bookmark-button"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<MediaBookmarkButton media={media} />
|
|
||||||
</div>
|
|
||||||
{searchQuery.length > 0 ? (
|
|
||||||
<div
|
|
||||||
className="absolute"
|
|
||||||
onClick={(e) => e.preventDefault()}
|
|
||||||
>
|
|
||||||
<MediaBookmarkButton media={media} />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<div
|
<div
|
||||||
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
canLink ? "group-hover:from-denim-100" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
/>
|
||||||
<IconPatch
|
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||||
clickable
|
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||||
className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500"
|
<div
|
||||||
onClick={() => closable && onClose?.()}
|
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
|
||||||
icon={Icons.X}
|
style={{
|
||||||
/>
|
width: percentageString,
|
||||||
|
}}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
|
||||||
|
|
||||||
{overlayVisible ? (
|
<div
|
||||||
<div>
|
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||||
<div className="absolute inset-0 flex flex-col items-center justify-start gap-y-2 pt-8 md:pt-12">
|
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
<Button
|
}`}
|
||||||
theme="secondary"
|
>
|
||||||
className={classNames(
|
<IconPatch
|
||||||
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
|
clickable
|
||||||
"text-md text-white flex items-center justify-center",
|
className="text-2xl text-slate-400"
|
||||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
onClick={() => closable && onClose?.()}
|
||||||
"border-2 border-gray-400 border-opacity-20",
|
icon={Icons.X}
|
||||||
)}
|
/>
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
if (onShowDetails) onShowDetails(media);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
More Info
|
|
||||||
</Button>
|
|
||||||
|
|
||||||
{canLink ? (
|
|
||||||
<Button
|
|
||||||
theme="secondary"
|
|
||||||
className={classNames(
|
|
||||||
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
|
|
||||||
"text-md text-white flex items-center justify-center",
|
|
||||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
|
||||||
"border-2 border-gray-400 border-opacity-20",
|
|
||||||
)}
|
|
||||||
href={link}
|
|
||||||
onClick={handleCopyClick}
|
|
||||||
>
|
|
||||||
{hasCopied ? (
|
|
||||||
<Icon
|
|
||||||
className="text-md text-white mix-blend-screen"
|
|
||||||
icon={Icons.CHECKMARK}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
"Copy Link"
|
|
||||||
)}
|
|
||||||
</Button>
|
|
||||||
) : null}
|
|
||||||
|
|
||||||
<Button
|
|
||||||
theme="secondary"
|
|
||||||
className={classNames(
|
|
||||||
"w-[86%] md:w-[90%] h-12 rounded-lg px-4 py-2 my-1 transition-transform hover:scale-105 duration-100",
|
|
||||||
"text-md text-white flex items-center justify-center",
|
|
||||||
"bg-buttons-purple bg-opacity-15 hover:bg-buttons-purpleHover hover:bg-opacity-25 backdrop-blur-md",
|
|
||||||
"border-2 border-gray-400 border-opacity-20",
|
|
||||||
)}
|
|
||||||
onClick={() => setOverlayVisible(false)}
|
|
||||||
>
|
|
||||||
Close
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
</div>
|
||||||
|
|
||||||
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
||||||
<span>{media.title}</span>
|
<span>{media.title}</span>
|
||||||
</h1>
|
</h1>
|
||||||
<div className="media-info-container justify-content-center flex flex-wrap">
|
<DotList className="text-xs" content={dotListContent} />
|
||||||
{!overlayVisible ? (
|
</article>
|
||||||
<DotList className="text-xs" content={dotListContent} />
|
</div>
|
||||||
) : (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={handleCopyIDClick}
|
|
||||||
className="z-50"
|
|
||||||
>
|
|
||||||
{!hasCopiedID ? (
|
|
||||||
<DotList className="text-xs" content={altDotListContent} />
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center gap-1">
|
|
||||||
<DotList className="text-xs" content={altDotListContent} />
|
|
||||||
<Icon
|
|
||||||
className="text-xs text-type-secondary"
|
|
||||||
icon={Icons.CHECKMARK}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</button>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!overlayVisible && !closable ? (
|
|
||||||
<div className="absolute bottom-0 translate-y-1 right-1">
|
|
||||||
<button
|
|
||||||
className="media-more-button p-2"
|
|
||||||
type="button"
|
|
||||||
onClick={(e) => {
|
|
||||||
e.preventDefault();
|
|
||||||
e.stopPropagation();
|
|
||||||
setOverlayVisible(!overlayVisible);
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
className="text-xs font-semibold text-type-secondary"
|
|
||||||
icon={Icons.ELLIPSIS}
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</Flare.Child>
|
|
||||||
</Flare.Base>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaCard(props: MediaCardProps) {
|
export function MediaCard(props: MediaCardProps) {
|
||||||
const { media, onShowDetails } = props;
|
const content = <MediaCardContent {...props} />;
|
||||||
const [overlayVisible, setOverlayVisible] = useState(false);
|
|
||||||
const [timeoutId, setTimeoutId] = useState<NodeJS.Timeout | null>(null);
|
|
||||||
const hoverTimer = useRef<NodeJS.Timeout>();
|
|
||||||
const [isHoveringCard, setIsHoveringCard] = useState(false);
|
|
||||||
const [detailsData, setDetailsData] = useState<{
|
|
||||||
id: number;
|
|
||||||
type: "movie" | "show";
|
|
||||||
} | null>(null);
|
|
||||||
const detailsModal = useModal("details");
|
|
||||||
const enableDetailsModal = usePreferencesStore(
|
|
||||||
(state) => state.enableDetailsModal,
|
|
||||||
);
|
|
||||||
|
|
||||||
const handleMouseEnter = () => {
|
|
||||||
setIsHoveringCard(true);
|
|
||||||
|
|
||||||
if (timeoutId) {
|
|
||||||
clearTimeout(timeoutId);
|
|
||||||
setTimeoutId(null);
|
|
||||||
}
|
|
||||||
|
|
||||||
if (hoverTimer.current) {
|
|
||||||
clearTimeout(hoverTimer.current);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleMouseLeave = () => {
|
|
||||||
setIsHoveringCard(false);
|
|
||||||
if (hoverTimer.current) {
|
|
||||||
clearTimeout(hoverTimer.current);
|
|
||||||
}
|
|
||||||
|
|
||||||
const id = setTimeout(() => {
|
|
||||||
setOverlayVisible(false);
|
|
||||||
}, 2000); // 2 seconds
|
|
||||||
setTimeoutId(id);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleContextMenu = (e: React.MouseEvent) => {
|
|
||||||
e.preventDefault();
|
|
||||||
setOverlayVisible(true);
|
|
||||||
};
|
|
||||||
|
|
||||||
const isReleased = useCallback(
|
const isReleased = useCallback(
|
||||||
() => checkReleased(props.media),
|
() => checkReleased(props.media),
|
||||||
[props.media],
|
[props.media],
|
||||||
);
|
);
|
||||||
|
|
||||||
const canLink = props.linkable && !props.closable && isReleased();
|
const canLink = props.linkable && !props.closable && isReleased();
|
||||||
|
|
||||||
let link = canLink
|
let link = canLink
|
||||||
|
|
@ -425,109 +176,13 @@ export function MediaCard(props: MediaCardProps) {
|
||||||
}
|
}
|
||||||
}
|
}
|
||||||
|
|
||||||
const handleShowDetails = useCallback(async () => {
|
if (!canLink) return <span>{content}</span>;
|
||||||
if (onShowDetails) {
|
|
||||||
onShowDetails(media);
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
setDetailsData({
|
|
||||||
id: Number(media.id),
|
|
||||||
type: media.type === "movie" ? "movie" : "show",
|
|
||||||
});
|
|
||||||
detailsModal.show();
|
|
||||||
}, [media, detailsModal, onShowDetails]);
|
|
||||||
|
|
||||||
const handleCardClick = (e: React.MouseEvent) => {
|
|
||||||
if (enableDetailsModal && canLink) {
|
|
||||||
e.preventDefault();
|
|
||||||
handleShowDetails();
|
|
||||||
} else if (overlayVisible || e.defaultPrevented) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const content = (
|
|
||||||
<>
|
|
||||||
<MediaCardContent
|
|
||||||
{...props}
|
|
||||||
overlayVisible={overlayVisible}
|
|
||||||
setOverlayVisible={setOverlayVisible}
|
|
||||||
handleMouseEnter={handleMouseEnter}
|
|
||||||
handleMouseLeave={handleMouseLeave}
|
|
||||||
link={link}
|
|
||||||
isHoveringCard={isHoveringCard}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
{detailsData && <DetailsModal id="details" data={detailsData} />}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
|
|
||||||
if (!canLink)
|
|
||||||
return (
|
|
||||||
<span
|
|
||||||
className="relative"
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
onClick={(e) => {
|
|
||||||
if (overlayVisible || e.defaultPrevented) {
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{content}{" "}
|
|
||||||
</span>
|
|
||||||
);
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<Link
|
||||||
{!overlayVisible ? (
|
to={link}
|
||||||
<Link
|
className={classNames(props.closable ? "hover:cursor-default" : "")}
|
||||||
to={link}
|
>
|
||||||
tabIndex={-1}
|
{content}
|
||||||
className={classNames(
|
</Link>
|
||||||
"tabbable",
|
|
||||||
props.closable ? "hover:cursor-default" : "",
|
|
||||||
)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
onClick={handleCardClick}
|
|
||||||
>
|
|
||||||
<MediaCardContent
|
|
||||||
{...props}
|
|
||||||
overlayVisible={overlayVisible}
|
|
||||||
setOverlayVisible={setOverlayVisible}
|
|
||||||
handleMouseEnter={handleMouseEnter}
|
|
||||||
handleMouseLeave={handleMouseLeave}
|
|
||||||
link={link}
|
|
||||||
isHoveringCard={isHoveringCard}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
</Link>
|
|
||||||
) : (
|
|
||||||
<div
|
|
||||||
tabIndex={-1}
|
|
||||||
className={classNames(
|
|
||||||
"tabbable",
|
|
||||||
props.closable ? "hover:cursor-default" : "",
|
|
||||||
)}
|
|
||||||
onMouseEnter={handleMouseEnter}
|
|
||||||
onMouseLeave={handleMouseLeave}
|
|
||||||
onContextMenu={handleContextMenu}
|
|
||||||
>
|
|
||||||
<MediaCardContent
|
|
||||||
{...props}
|
|
||||||
overlayVisible={overlayVisible}
|
|
||||||
setOverlayVisible={setOverlayVisible}
|
|
||||||
handleMouseEnter={handleMouseEnter}
|
|
||||||
handleMouseLeave={handleMouseLeave}
|
|
||||||
link={link}
|
|
||||||
isHoveringCard={isHoveringCard}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -7,10 +7,7 @@ interface MediaGridProps {
|
||||||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||||
(props, ref) => {
|
(props, ref) => {
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||||
className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10"
|
|
||||||
ref={ref}
|
|
||||||
>
|
|
||||||
{props.children}
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ export interface WatchedMediaCardProps {
|
||||||
media: MediaItem;
|
media: MediaItem;
|
||||||
closable?: boolean;
|
closable?: boolean;
|
||||||
onClose?: () => void;
|
onClose?: () => void;
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||||
|
|
@ -47,7 +46,6 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||||
percentage={percentage}
|
percentage={percentage}
|
||||||
onClose={props.onClose}
|
onClose={props.onClose}
|
||||||
closable={props.closable}
|
closable={props.closable}
|
||||||
onShowDetails={props.onShowDetails}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
71
src/components/text/GlitchText.tsx
Normal file
71
src/components/text/GlitchText.tsx
Normal file
|
|
@ -0,0 +1,71 @@
|
||||||
|
import { useEffect, useState } from "react";
|
||||||
|
import { useInterval } from "react-use";
|
||||||
|
|
||||||
|
interface GlitchTextProps {
|
||||||
|
text: string;
|
||||||
|
glitchedText: string;
|
||||||
|
className?: string;
|
||||||
|
groupHover?: boolean;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function GlitchText({
|
||||||
|
text,
|
||||||
|
glitchedText,
|
||||||
|
className = "",
|
||||||
|
groupHover = false,
|
||||||
|
}: GlitchTextProps) {
|
||||||
|
const [isGlitching, setIsGlitching] = useState(false);
|
||||||
|
const [displayText, setDisplayText] = useState(text);
|
||||||
|
const [glitchCount, setGlitchCount] = useState(0);
|
||||||
|
const maxGlitches = 10; // Number of glitch iterations before showing final text
|
||||||
|
|
||||||
|
useEffect(() => {
|
||||||
|
if (groupHover) {
|
||||||
|
const parent = document.querySelector("[data-info-card]");
|
||||||
|
|
||||||
|
const handleMouseEnter = () => {
|
||||||
|
setIsGlitching(true);
|
||||||
|
setGlitchCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
const handleMouseLeave = () => {
|
||||||
|
setDisplayText(text);
|
||||||
|
setIsGlitching(false);
|
||||||
|
setGlitchCount(0);
|
||||||
|
};
|
||||||
|
|
||||||
|
parent?.addEventListener("mouseenter", handleMouseEnter);
|
||||||
|
parent?.addEventListener("mouseleave", handleMouseLeave);
|
||||||
|
|
||||||
|
return () => {
|
||||||
|
parent?.removeEventListener("mouseenter", handleMouseEnter);
|
||||||
|
parent?.removeEventListener("mouseleave", handleMouseLeave);
|
||||||
|
};
|
||||||
|
}
|
||||||
|
}, [groupHover, text]);
|
||||||
|
|
||||||
|
useInterval(
|
||||||
|
() => {
|
||||||
|
if (glitchCount >= maxGlitches) {
|
||||||
|
setDisplayText(glitchedText);
|
||||||
|
setIsGlitching(false);
|
||||||
|
setGlitchCount(0);
|
||||||
|
return;
|
||||||
|
}
|
||||||
|
|
||||||
|
const randomChars = glitchedText
|
||||||
|
.split("")
|
||||||
|
.map(() => String.fromCharCode(33 + Math.floor(Math.random() * 94)))
|
||||||
|
.join("");
|
||||||
|
setDisplayText(randomChars);
|
||||||
|
setGlitchCount((count) => count + 1);
|
||||||
|
},
|
||||||
|
isGlitching ? 50 : null,
|
||||||
|
);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<span className={`transition-all duration-200 ${className}`}>
|
||||||
|
{displayText}
|
||||||
|
</span>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
@ -6,9 +6,7 @@ export interface HeroTitleProps {
|
||||||
export function HeroTitle(props: HeroTitleProps) {
|
export function HeroTitle(props: HeroTitleProps) {
|
||||||
return (
|
return (
|
||||||
<h1
|
<h1
|
||||||
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
|
className={`text-4xl font-bold text-white max-w-[300px] ${props.className ?? ""}`}
|
||||||
props.className ?? ""
|
|
||||||
}`}
|
|
||||||
>
|
>
|
||||||
{props.children}
|
{props.children}
|
||||||
</h1>
|
</h1>
|
||||||
|
|
|
||||||
|
|
@ -1,25 +1,17 @@
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
import { useEffect, useState } from "react";
|
import { useEffect, useState } from "react";
|
||||||
import { Helmet } from "react-helmet-async";
|
import { Helmet } from "react-helmet-async";
|
||||||
import { useTranslation } from "react-i18next";
|
import { useTranslation } from "react-i18next";
|
||||||
import { To, useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { WideContainer } from "@/components/layout/WideContainer";
|
import { WideContainer } from "@/components/layout/WideContainer";
|
||||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
|
||||||
import { useModal } from "@/components/overlays/Modal";
|
|
||||||
import { useDebounce } from "@/hooks/useDebounce";
|
import { useDebounce } from "@/hooks/useDebounce";
|
||||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
|
||||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
|
||||||
import { HomeLayout } from "@/pages/layouts/HomeLayout";
|
import { HomeLayout } from "@/pages/layouts/HomeLayout";
|
||||||
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
|
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
|
||||||
import { HeroPart } from "@/pages/parts/home/HeroPart";
|
import { HeroPart } from "@/pages/parts/home/HeroPart";
|
||||||
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
||||||
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
||||||
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
|
||||||
import { usePreferencesStore } from "@/stores/preferences";
|
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
|
||||||
|
|
||||||
import { Button } from "./About";
|
|
||||||
|
|
||||||
function useSearch(search: string) {
|
function useSearch(search: string) {
|
||||||
const [searching, setSearching] = useState<boolean>(false);
|
const [searching, setSearching] = useState<boolean>(false);
|
||||||
|
|
@ -44,52 +36,17 @@ function useSearch(search: string) {
|
||||||
|
|
||||||
export function HomePage() {
|
export function HomePage() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { t: randomT } = useRandomTranslation();
|
|
||||||
const emptyText = randomT(`home.search.empty`);
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const [showBg, setShowBg] = useState<boolean>(false);
|
const [showBg, setShowBg] = useState<boolean>(false);
|
||||||
const searchParams = useSearchQuery();
|
const searchParams = useSearchQuery();
|
||||||
const [search] = searchParams;
|
const [search] = searchParams;
|
||||||
const s = useSearch(search);
|
const s = useSearch(search);
|
||||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||||
const [showWatching, setShowWatching] = useState(false);
|
const [showWatching, setShowWatching] = useState(false);
|
||||||
const [detailsData, setDetailsData] = useState<any>();
|
const [contentRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
// const [isLoadingDetails, setIsLoadingDetails] = useState(false);
|
|
||||||
const detailsModal = useModal("details");
|
|
||||||
|
|
||||||
const handleClick = (path: To) => {
|
|
||||||
window.scrollTo(0, 0);
|
|
||||||
navigate(path);
|
|
||||||
};
|
|
||||||
|
|
||||||
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
|
|
||||||
|
|
||||||
const handleShowDetails = async (media: MediaItem) => {
|
|
||||||
setDetailsData({
|
|
||||||
id: Number(media.id),
|
|
||||||
type: media.type === "movie" ? "movie" : "show",
|
|
||||||
});
|
|
||||||
detailsModal.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
// const { loggedIn } = useAuth(); // Adjust padding for popup show button based on logged in state
|
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<HomeLayout showBg={showBg}>
|
<HomeLayout showBg={showBg}>
|
||||||
{/* <a
|
<div className="relative mb-24">
|
||||||
onClick={() => modal.show()}
|
|
||||||
className={` text-white tabbable rounded-full z-50 fixed top-5 ${
|
|
||||||
loggedIn
|
|
||||||
? "right-[7.5rem] lg:right-[12.5rem] lg:text-2xl"
|
|
||||||
: "right-[7.5rem] text-xl lg:text-lg"
|
|
||||||
}`}
|
|
||||||
style={{ animation: "pulse 1s infinite" }}
|
|
||||||
>
|
|
||||||
<IconPill icon={Icons.WARNING}>
|
|
||||||
<span className="font-bold select-none">READ</span>
|
|
||||||
</IconPill>
|
|
||||||
</a> */}
|
|
||||||
<div className="mb-16 sm:mb-24">
|
|
||||||
<Helmet>
|
<Helmet>
|
||||||
<style type="text/css">{`
|
<style type="text/css">{`
|
||||||
html, body {
|
html, body {
|
||||||
|
|
@ -99,144 +56,41 @@ export function HomePage() {
|
||||||
<title>{t("global.name")}</title>
|
<title>{t("global.name")}</title>
|
||||||
</Helmet>
|
</Helmet>
|
||||||
|
|
||||||
{/* Popup
|
<div className="absolute left-0 top-0 h-[400px] w-full">
|
||||||
<FancyModal
|
<div
|
||||||
id="notice"
|
className="absolute -top-52 left-0 right-0 bottom-0"
|
||||||
title="We're changing our backend server!"
|
style={{
|
||||||
oneTime
|
backgroundImage: `radial-gradient(ellipse 80% 8rem, #211D30 100%, transparent 100%)`,
|
||||||
>
|
}}
|
||||||
<div>
|
/>
|
||||||
<p>
|
<div
|
||||||
On <strong>January 8th</strong>, the backend server will change
|
className="absolute -top-20 left-0 right-0 bottom-0"
|
||||||
from:
|
style={{
|
||||||
</p>
|
backgroundImage: `radial-gradient(ellipse 70% 12rem, #211D30 100%, transparent 100%)`,
|
||||||
<p>
|
}}
|
||||||
<strong>server.vidbinge.com</strong> →{" "}
|
/>
|
||||||
<strong>server.fifthwit.tech</strong>
|
</div>
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
You will need to <strong>migrate your account </strong> to the new
|
|
||||||
server or choose to continue using the old server by updating your
|
|
||||||
settings.
|
|
||||||
</p>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
<strong>What You Need to Know:</strong>
|
|
||||||
</p>
|
|
||||||
<ul>
|
|
||||||
<li>
|
|
||||||
1. <strong>Migrating Your Account:</strong> Your data (e.g.,
|
|
||||||
bookmarks) will not be automatically transferred. You'll
|
|
||||||
need to migrate your account from the settings page. Or from
|
|
||||||
below.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
2. <strong>Staying on the Old Server:</strong> If you don't
|
|
||||||
want to change to the new server, your data will remain safe on{" "}
|
|
||||||
<strong>server.vidbinge.com</strong>. You can change the Backend
|
|
||||||
URL in your settings to "https://server.vidbinge.com".
|
|
||||||
</li>
|
|
||||||
</ul>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
<strong>Steps to Move Your Data:</strong>
|
|
||||||
</p>
|
|
||||||
<ol>
|
|
||||||
<li>
|
|
||||||
1. Log into your account on <strong>server.vidbinge.com</strong>
|
|
||||||
.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
(If you already are logged in, press here:{" "}
|
|
||||||
<a href="/migration" className="text-type-link">
|
|
||||||
Migrate my data.
|
|
||||||
</a>
|
|
||||||
)
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
2. Go to the <strong>Settings</strong> page.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
3. Scroll down to{" "}
|
|
||||||
<strong>Connections > Custom Server</strong>.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
3. Press the "Migrate my data to a new server."
|
|
||||||
button.
|
|
||||||
</li>
|
|
||||||
<li>
|
|
||||||
4. Enter the new server url:{" "}
|
|
||||||
<strong>https://server.fifthwit.tech</strong> and press
|
|
||||||
"Migrate".
|
|
||||||
</li>
|
|
||||||
<li>5. Login to your account with the same passphrase!</li>
|
|
||||||
</ol>
|
|
||||||
<br />
|
|
||||||
<p>
|
|
||||||
Thank you for your understanding and support during this
|
|
||||||
transition! If you have questions or need help, feel free to reach
|
|
||||||
out on the{" "}
|
|
||||||
<a
|
|
||||||
href="https://discord.com/invite/7z6znYgrTG"
|
|
||||||
target="_blank"
|
|
||||||
rel="noopener noreferrer"
|
|
||||||
className="text-type-link"
|
|
||||||
>
|
|
||||||
P-Stream Discord
|
|
||||||
</a>
|
|
||||||
!
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
</FancyModal>
|
|
||||||
*/}
|
|
||||||
|
|
||||||
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} />
|
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} />
|
||||||
</div>
|
</div>
|
||||||
<WideContainer>
|
|
||||||
{s.loading ? (
|
|
||||||
<SearchLoadingPart />
|
|
||||||
) : s.searching ? (
|
|
||||||
<SearchListPart
|
|
||||||
searchQuery={search}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col gap-8">
|
|
||||||
<WatchingPart
|
|
||||||
onItemsChange={setShowWatching}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
<BookmarksPart
|
|
||||||
onItemsChange={setShowBookmarks}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
{!(showBookmarks || showWatching) && !enableDiscover ? (
|
|
||||||
<div className="flex flex-col translate-y-[-30px] items-center justify-center">
|
|
||||||
<p className="text-[18.5px] pb-3">{emptyText}</p>
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
</WideContainer>
|
|
||||||
{enableDiscover ? (
|
|
||||||
<div className="pt-12 w-full max-w-[100dvw] justify-center items-center">
|
|
||||||
<DiscoverContent />
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex flex-col justify-center items-center h-40 space-y-4">
|
|
||||||
<div className="flex flex-col items-center justify-center">
|
|
||||||
<Button
|
|
||||||
className="px-py p-[0.35em] mt-3 rounded-xl text-type-dimmed box-content text-[18px] bg-largeCard-background justify-center items-center"
|
|
||||||
onClick={() => handleClick("/discover")}
|
|
||||||
>
|
|
||||||
{t("home.search.discover")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{detailsData && <DetailsModal id="details" data={detailsData} />}
|
<WideContainer>
|
||||||
|
<div ref={contentRef}>
|
||||||
|
{s.loading ? (
|
||||||
|
<SearchLoadingPart />
|
||||||
|
) : s.searching ? (
|
||||||
|
<SearchListPart searchQuery={search} />
|
||||||
|
) : (
|
||||||
|
<div className="flex flex-col gap-8 -mt-8">
|
||||||
|
<BookmarksPart onItemsChange={setShowBookmarks} />
|
||||||
|
<WatchingPart
|
||||||
|
onItemsChange={setShowWatching}
|
||||||
|
className="space-y-6"
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
</WideContainer>
|
||||||
</HomeLayout>
|
</HomeLayout>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -23,7 +23,6 @@ import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { useSettingsState } from "@/hooks/useSettingsState";
|
import { useSettingsState } from "@/hooks/useSettingsState";
|
||||||
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
|
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
|
||||||
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
|
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
|
||||||
import { AppearancePart } from "@/pages/parts/settings/AppearancePart";
|
|
||||||
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
|
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
|
||||||
import { ConnectionsPart } from "@/pages/parts/settings/ConnectionsPart";
|
import { ConnectionsPart } from "@/pages/parts/settings/ConnectionsPart";
|
||||||
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
|
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
|
||||||
|
|
@ -155,7 +154,6 @@ export function SettingsPage() {
|
||||||
const setEnableDetailsModal = usePreferencesStore(
|
const setEnableDetailsModal = usePreferencesStore(
|
||||||
(s) => s.setEnableDetailsModal,
|
(s) => s.setEnableDetailsModal,
|
||||||
);
|
);
|
||||||
|
|
||||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||||
const setEnableSourceOrder = usePreferencesStore(
|
const setEnableSourceOrder = usePreferencesStore(
|
||||||
(s) => s.setEnableSourceOrder,
|
(s) => s.setEnableSourceOrder,
|
||||||
|
|
@ -235,13 +233,13 @@ export function SettingsPage() {
|
||||||
};
|
};
|
||||||
}, [setPreviewTheme]);
|
}, [setPreviewTheme]);
|
||||||
|
|
||||||
const setThemeWithPreview = useCallback(
|
// const setThemeWithPreview = useCallback(
|
||||||
(theme: string) => {
|
// (theme: string) => {
|
||||||
state.theme.set(theme === "default" ? null : theme);
|
// state.theme.set(theme === "default" ? null : theme);
|
||||||
setPreviewTheme(theme);
|
// setPreviewTheme(theme);
|
||||||
},
|
// },
|
||||||
[state.theme, setPreviewTheme],
|
// [state.theme, setPreviewTheme],
|
||||||
);
|
// );
|
||||||
|
|
||||||
const saveChanges = useCallback(async () => {
|
const saveChanges = useCallback(async () => {
|
||||||
if (account && backendUrl) {
|
if (account && backendUrl) {
|
||||||
|
|
@ -375,24 +373,13 @@ export function SettingsPage() {
|
||||||
setenableSourceOrder={state.enableSourceOrder.set}
|
setenableSourceOrder={state.enableSourceOrder.set}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-appearance" className="mt-28">
|
|
||||||
<AppearancePart
|
|
||||||
active={previewTheme ?? "default"}
|
|
||||||
inUse={activeTheme ?? "default"}
|
|
||||||
setTheme={setThemeWithPreview}
|
|
||||||
enableDiscover={state.enableDiscover.state}
|
|
||||||
setEnableDiscover={state.enableDiscover.set}
|
|
||||||
enableDetailsModal={state.enableDetailsModal.state}
|
|
||||||
setEnableDetailsModal={state.enableDetailsModal.set}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div id="settings-captions" className="mt-28">
|
<div id="settings-captions" className="mt-28">
|
||||||
<CaptionsPart
|
<CaptionsPart
|
||||||
styling={state.subtitleStyling.state}
|
styling={state.subtitleStyling.state}
|
||||||
setStyling={state.subtitleStyling.set}
|
setStyling={state.subtitleStyling.set}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
<div id="settings-connection" className="mt-28">
|
<div id="settings-connection" className="mt-28 mb-10">
|
||||||
<ConnectionsPart
|
<ConnectionsPart
|
||||||
backendUrl={state.backendUrl.state}
|
backendUrl={state.backendUrl.state}
|
||||||
setBackendUrl={state.backendUrl.set}
|
setBackendUrl={state.backendUrl.set}
|
||||||
|
|
|
||||||
|
|
@ -1,49 +0,0 @@
|
||||||
import { Helmet } from "react-helmet-async";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
|
||||||
|
|
||||||
import { SubPageLayout } from "../layouts/SubPageLayout";
|
|
||||||
import { PageTitle } from "../parts/util/PageTitle";
|
|
||||||
|
|
||||||
export function Discover() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<SubPageLayout>
|
|
||||||
<Helmet>
|
|
||||||
{/* Hide scrollbar */}
|
|
||||||
<style type="text/css">{`
|
|
||||||
html, body {
|
|
||||||
scrollbar-width: none;
|
|
||||||
-ms-overflow-style: none;
|
|
||||||
}
|
|
||||||
`}</style>
|
|
||||||
</Helmet>
|
|
||||||
|
|
||||||
<PageTitle subpage k="global.pages.discover" />
|
|
||||||
|
|
||||||
<div className="relative w-full max-w-screen-xl mx-auto px-4 text-center mt-12 mb-12">
|
|
||||||
<div
|
|
||||||
className="absolute inset-0 mx-auto h-[400px] max-w-[800px] rounded-full blur-[100px] opacity-20 transform -translate-y-[100px] pointer-events-none"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<h1
|
|
||||||
className="relative text-4xl md:text-5xl font-extrabold text-transparent bg-clip-text z-10"
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
{t("discover.page.title")}
|
|
||||||
</h1>
|
|
||||||
<p className="relative text-lg mt-4 text-gray-400 z-10">
|
|
||||||
{t("discover.page.subtitle")}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<DiscoverContent />
|
|
||||||
</SubPageLayout>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,59 +0,0 @@
|
||||||
/* Define shit here */
|
|
||||||
|
|
||||||
// Define the Media type
|
|
||||||
export interface Media {
|
|
||||||
id: number;
|
|
||||||
poster_path: string;
|
|
||||||
title?: string;
|
|
||||||
name?: string;
|
|
||||||
release_date?: string;
|
|
||||||
first_air_date?: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Update the Movie and TVShow interfaces to extend the Media interface
|
|
||||||
export interface Movie extends Media {
|
|
||||||
title: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
export interface TVShow extends Media {
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the Genre type
|
|
||||||
export interface Genre {
|
|
||||||
id: number;
|
|
||||||
name: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the Category type
|
|
||||||
export interface Category {
|
|
||||||
name: string;
|
|
||||||
endpoint: string;
|
|
||||||
}
|
|
||||||
|
|
||||||
// Define the categories
|
|
||||||
export const categories: Category[] = [
|
|
||||||
{
|
|
||||||
name: "Now Playing",
|
|
||||||
endpoint: "/movie/now_playing?language=en-US",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Top Rated",
|
|
||||||
endpoint: "/movie/top_rated?language=en-US",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Most Popular",
|
|
||||||
endpoint: "/movie/popular?language=en-US",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
||||||
export const tvCategories: Category[] = [
|
|
||||||
{
|
|
||||||
name: "Top Rated",
|
|
||||||
endpoint: "/tv/top_rated?language=en-US",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
name: "Most Popular",
|
|
||||||
endpoint: "/tv/popular?language=en-US",
|
|
||||||
},
|
|
||||||
];
|
|
||||||
|
|
@ -1,83 +0,0 @@
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
import { Flare } from "@/components/utils/Flare";
|
|
||||||
|
|
||||||
interface CarouselNavButtonsProps {
|
|
||||||
categorySlug: string;
|
|
||||||
carouselRefs: React.MutableRefObject<{
|
|
||||||
[key: string]: HTMLDivElement | null;
|
|
||||||
}>;
|
|
||||||
}
|
|
||||||
|
|
||||||
interface NavButtonProps {
|
|
||||||
direction: "left" | "right";
|
|
||||||
onClick: () => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function NavButton({ direction, onClick }: NavButtonProps) {
|
|
||||||
return (
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className={`absolute ${direction === "left" ? "left-12" : "right-12"} top-1/2 transform -translate-y-3/4 z-10`}
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<Flare.Base className="group -m-[0.705em] rounded-full bg-search-hoverBackground transition-transform duration-300 focus:relative focus:z-10 hover:bg-mediaCard-hoverBackground tabbable hover:scale-110">
|
|
||||||
<Flare.Light
|
|
||||||
flareSize={90}
|
|
||||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
|
||||||
backgroundClass="bg-mediaCard-hoverBackground duration-100"
|
|
||||||
className="rounded-full group-hover:opacity-100 z-20"
|
|
||||||
/>
|
|
||||||
<Flare.Child className="cursor-pointer text-white flex justify-center items-center h-10 w-10 rounded-full active:scale-110 transition-[transform,background-color] duration-200 z-30">
|
|
||||||
<Icon
|
|
||||||
icon={
|
|
||||||
direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT
|
|
||||||
}
|
|
||||||
/>
|
|
||||||
</Flare.Child>
|
|
||||||
</Flare.Base>
|
|
||||||
</button>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CarouselNavButtons({
|
|
||||||
categorySlug,
|
|
||||||
carouselRefs,
|
|
||||||
}: CarouselNavButtonsProps) {
|
|
||||||
const handleScroll = (direction: "left" | "right") => {
|
|
||||||
const carousel = carouselRefs.current[categorySlug];
|
|
||||||
if (!carousel) return;
|
|
||||||
|
|
||||||
const movieElements = carousel.getElementsByTagName("a");
|
|
||||||
if (movieElements.length === 0) return;
|
|
||||||
|
|
||||||
// Wait for next frame to ensure measurements are available
|
|
||||||
requestAnimationFrame(() => {
|
|
||||||
const movieWidth = movieElements[0].getBoundingClientRect().width;
|
|
||||||
|
|
||||||
const carouselWidth = carousel.getBoundingClientRect().width;
|
|
||||||
|
|
||||||
if (movieWidth === 0 || carouselWidth === 0) {
|
|
||||||
return;
|
|
||||||
}
|
|
||||||
|
|
||||||
const visibleMovies = Math.floor(carouselWidth / movieWidth);
|
|
||||||
const scrollAmount = movieWidth * (visibleMovies > 5 ? 4 : 2);
|
|
||||||
|
|
||||||
const newScrollPosition =
|
|
||||||
carousel.scrollLeft +
|
|
||||||
(direction === "left" ? -scrollAmount : scrollAmount);
|
|
||||||
|
|
||||||
carousel.scrollTo({
|
|
||||||
left: newScrollPosition,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
});
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<NavButton direction="left" onClick={() => handleScroll("left")} />
|
|
||||||
<NavButton direction="right" onClick={() => handleScroll("right")} />
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,68 +0,0 @@
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
interface CategoryButtonsProps {
|
|
||||||
categories: any[];
|
|
||||||
onCategoryClick: (id: string, name: string) => void;
|
|
||||||
categoryType: string;
|
|
||||||
isMobile: boolean;
|
|
||||||
showAlwaysScroll: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function CategoryButtons({
|
|
||||||
categories,
|
|
||||||
onCategoryClick,
|
|
||||||
categoryType,
|
|
||||||
isMobile,
|
|
||||||
showAlwaysScroll,
|
|
||||||
}: CategoryButtonsProps) {
|
|
||||||
const renderScrollButton = (direction: "left" | "right") => (
|
|
||||||
<div>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center rounded-full px-4 text-white py-3"
|
|
||||||
onClick={() => {
|
|
||||||
const element = document.getElementById(
|
|
||||||
`button-carousel-${categoryType}`,
|
|
||||||
);
|
|
||||||
if (element) {
|
|
||||||
element.scrollBy({
|
|
||||||
left: direction === "left" ? -200 : 200,
|
|
||||||
behavior: "smooth",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon
|
|
||||||
icon={direction === "left" ? Icons.CHEVRON_LEFT : Icons.CHEVRON_RIGHT}
|
|
||||||
className="text-2xl rtl:-scale-x-100"
|
|
||||||
/>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex overflow-x-auto">
|
|
||||||
{(showAlwaysScroll || isMobile) && renderScrollButton("left")}
|
|
||||||
|
|
||||||
<div
|
|
||||||
id={`button-carousel-${categoryType}`}
|
|
||||||
className="flex lg:px-4 mb-4 overflow-x-auto scroll-smooth"
|
|
||||||
>
|
|
||||||
<div className="flex space-x-2 py-1">
|
|
||||||
{categories.map((category) => (
|
|
||||||
<button
|
|
||||||
key={category.id || category.name}
|
|
||||||
type="button"
|
|
||||||
className="whitespace-nowrap flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
|
||||||
onClick={() => onCategoryClick(category.id, category.name)}
|
|
||||||
>
|
|
||||||
{category.name}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{(showAlwaysScroll || isMobile) && renderScrollButton("right")}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,95 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { Category, Genre, Media } from "@/pages/discover/common";
|
|
||||||
import { useIntersectionObserver } from "@/pages/discover/hooks/useIntersectionObserver";
|
|
||||||
import { useLazyTMDBData } from "@/pages/discover/hooks/useTMDBData";
|
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
|
||||||
|
|
||||||
import { MediaCarousel } from "./MediaCarousel";
|
|
||||||
|
|
||||||
interface LazyMediaCarouselProps {
|
|
||||||
category?: Category | null;
|
|
||||||
genre?: Genre | null;
|
|
||||||
mediaType: "movie" | "tv";
|
|
||||||
isMobile: boolean;
|
|
||||||
carouselRefs: React.MutableRefObject<{
|
|
||||||
[key: string]: HTMLDivElement | null;
|
|
||||||
}>;
|
|
||||||
preloadedMedia?: Media[];
|
|
||||||
title?: string;
|
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LazyMediaCarousel({
|
|
||||||
category,
|
|
||||||
genre,
|
|
||||||
mediaType,
|
|
||||||
isMobile,
|
|
||||||
carouselRefs,
|
|
||||||
preloadedMedia,
|
|
||||||
title,
|
|
||||||
onShowDetails,
|
|
||||||
}: LazyMediaCarouselProps) {
|
|
||||||
const [medias, setMedias] = useState<Media[]>([]);
|
|
||||||
|
|
||||||
// Use intersection observer to detect when this component is visible
|
|
||||||
const { targetRef, isIntersecting } = useIntersectionObserver(
|
|
||||||
{ rootMargin: "200px" }, // Load when within 200px of viewport
|
|
||||||
);
|
|
||||||
|
|
||||||
// Use the lazy loading hook only if we don't have preloaded media
|
|
||||||
const { media, isLoading } = useLazyTMDBData(
|
|
||||||
!preloadedMedia ? genre || null : null,
|
|
||||||
!preloadedMedia ? category || null : null,
|
|
||||||
mediaType,
|
|
||||||
isIntersecting,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Update medias when data is loaded or preloaded
|
|
||||||
useEffect(() => {
|
|
||||||
if (preloadedMedia) {
|
|
||||||
setMedias(preloadedMedia);
|
|
||||||
} else if (media.length > 0) {
|
|
||||||
setMedias(media);
|
|
||||||
}
|
|
||||||
}, [media, preloadedMedia]);
|
|
||||||
|
|
||||||
const categoryName = title || category?.name || genre?.name || "";
|
|
||||||
const categorySlug = `${categoryName.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${mediaType}`;
|
|
||||||
|
|
||||||
// Test intersection observer
|
|
||||||
// useEffect(() => {
|
|
||||||
// // eslint-disable-next-line no-console
|
|
||||||
// console.log(
|
|
||||||
// `Carousel ${categoryName}: ${isIntersecting ? "loaded ✅" : "unloaded ❌"}`,
|
|
||||||
// );
|
|
||||||
// }, [isIntersecting, categoryName]);
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div ref={targetRef as React.RefObject<HTMLDivElement>}>
|
|
||||||
{isIntersecting ? (
|
|
||||||
<MediaCarousel
|
|
||||||
medias={medias}
|
|
||||||
category={categoryName}
|
|
||||||
isTVShow={mediaType === "tv"}
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={onShowDetails}
|
|
||||||
/>
|
|
||||||
) : (
|
|
||||||
<div className="relative overflow-hidden carousel-container">
|
|
||||||
<div id={`carousel-${categorySlug}`}>
|
|
||||||
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
|
|
||||||
{categoryName} {mediaType === "tv" ? "Shows" : "Movies"}
|
|
||||||
</h2>
|
|
||||||
<div className="flex whitespace-nowrap pt-0 pb-4 overflow-auto scrollbar rounded-xl overflow-y-hidden h-[300px] animate-pulse bg-background-secondary/20">
|
|
||||||
<div className="w-full text-center flex items-center justify-center">
|
|
||||||
{isLoading ? "Loading..." : ""}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,29 +0,0 @@
|
||||||
import { ReactNode, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
interface LazyTabContentProps {
|
|
||||||
isActive: boolean;
|
|
||||||
children: ReactNode;
|
|
||||||
preloadWhenInactive?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function LazyTabContent({
|
|
||||||
isActive,
|
|
||||||
children,
|
|
||||||
preloadWhenInactive = false,
|
|
||||||
}: LazyTabContentProps) {
|
|
||||||
const [hasLoaded, setHasLoaded] = useState(false);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
// Load content when tab becomes active or if preload is enabled
|
|
||||||
if (isActive || preloadWhenInactive) {
|
|
||||||
setHasLoaded(true);
|
|
||||||
}
|
|
||||||
}, [isActive, preloadWhenInactive]);
|
|
||||||
|
|
||||||
// Only render children if the tab has been loaded
|
|
||||||
return (
|
|
||||||
<div style={{ display: isActive ? "block" : "none" }}>
|
|
||||||
{hasLoaded ? children : null}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,167 +0,0 @@
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { MediaCard } from "@/components/media/MediaCard";
|
|
||||||
import { Media } from "@/pages/discover/common";
|
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
|
||||||
|
|
||||||
import { CarouselNavButtons } from "./CarouselNavButtons";
|
|
||||||
|
|
||||||
interface MediaCarouselProps {
|
|
||||||
medias: Media[];
|
|
||||||
category: string;
|
|
||||||
isTVShow: boolean;
|
|
||||||
isMobile: boolean;
|
|
||||||
carouselRefs: React.MutableRefObject<{
|
|
||||||
[key: string]: HTMLDivElement | null;
|
|
||||||
}>;
|
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}
|
|
||||||
|
|
||||||
function MediaCardSkeleton() {
|
|
||||||
return (
|
|
||||||
<div className="relative mt-4 group cursor-default user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto">
|
|
||||||
<div className="animate-pulse">
|
|
||||||
<div className="w-full aspect-[2/3] bg-mediaCard-hoverBackground rounded-lg" />
|
|
||||||
<div className="mt-2 h-4 bg-mediaCard-hoverBackground rounded w-3/4" />
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function MediaCarousel({
|
|
||||||
medias,
|
|
||||||
category,
|
|
||||||
isTVShow,
|
|
||||||
isMobile,
|
|
||||||
carouselRefs,
|
|
||||||
onShowDetails,
|
|
||||||
}: MediaCarouselProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const categorySlug = `${category.toLowerCase().replace(/[^a-z0-9]+/g, "-")}-${isTVShow ? "tv" : "movie"}`;
|
|
||||||
const browser = !!window.chrome;
|
|
||||||
let isScrolling = false;
|
|
||||||
|
|
||||||
const handleWheel = (e: React.WheelEvent) => {
|
|
||||||
if (isScrolling) return;
|
|
||||||
isScrolling = true;
|
|
||||||
|
|
||||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
|
||||||
e.stopPropagation();
|
|
||||||
e.preventDefault();
|
|
||||||
}
|
|
||||||
|
|
||||||
if (browser) {
|
|
||||||
setTimeout(() => {
|
|
||||||
isScrolling = false;
|
|
||||||
}, 345);
|
|
||||||
} else {
|
|
||||||
isScrolling = false;
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
function getDisplayCategory(
|
|
||||||
categoryName: string,
|
|
||||||
isTVShowCondition: boolean,
|
|
||||||
): string {
|
|
||||||
const providerMatch = categoryName.match(
|
|
||||||
/^Popular (Movies|Shows) on (.+)$/,
|
|
||||||
);
|
|
||||||
if (providerMatch) {
|
|
||||||
const type = providerMatch[1].toLowerCase();
|
|
||||||
const provider = providerMatch[2];
|
|
||||||
return t("discover.carousel.title.popularOn", {
|
|
||||||
type:
|
|
||||||
type === "movies" ? t("media.types.movie") : t("media.types.show"),
|
|
||||||
provider,
|
|
||||||
});
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categoryName === "Now Playing") {
|
|
||||||
return t("discover.carousel.title.inCinemas");
|
|
||||||
}
|
|
||||||
|
|
||||||
if (categoryName === "Editor Picks") {
|
|
||||||
return t("discover.carousel.title.editorPicks");
|
|
||||||
}
|
|
||||||
|
|
||||||
return isTVShowCondition
|
|
||||||
? t("discover.carousel.title.tvshows", { category: categoryName })
|
|
||||||
: t("discover.carousel.title.movies", { category: categoryName });
|
|
||||||
}
|
|
||||||
|
|
||||||
const displayCategory = getDisplayCategory(category, isTVShow);
|
|
||||||
|
|
||||||
const filteredMedias = medias
|
|
||||||
.filter(
|
|
||||||
(media, index, self) =>
|
|
||||||
index ===
|
|
||||||
self.findIndex((m) => m.id === media.id && m.title === media.title),
|
|
||||||
)
|
|
||||||
.slice(0, 20);
|
|
||||||
|
|
||||||
const SKELETON_COUNT = 10;
|
|
||||||
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<h2 className="ml-2 md:ml-8 mt-2 text-2xl cursor-default font-bold text-white md:text-2xl mx-auto pl-5 text-balance">
|
|
||||||
{displayCategory}
|
|
||||||
</h2>
|
|
||||||
<div className="relative overflow-hidden carousel-container">
|
|
||||||
<div
|
|
||||||
id={`carousel-${categorySlug}`}
|
|
||||||
className="grid grid-flow-col auto-cols-max gap-4 pt-0 pb-4 overflow-x-scroll scrollbar rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
|
|
||||||
ref={(el) => {
|
|
||||||
carouselRefs.current[categorySlug] = el;
|
|
||||||
}}
|
|
||||||
onWheel={handleWheel}
|
|
||||||
>
|
|
||||||
<div className="md:w-12" />
|
|
||||||
|
|
||||||
{filteredMedias.length > 0
|
|
||||||
? filteredMedias.map((media) => (
|
|
||||||
<div
|
|
||||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
|
||||||
e.preventDefault()
|
|
||||||
}
|
|
||||||
key={media.id}
|
|
||||||
className="relative mt-4 group cursor-pointer user-select-none rounded-xl p-2 bg-transparent transition-colors duration-300 w-[10rem] md:w-[11.5rem] h-auto"
|
|
||||||
>
|
|
||||||
<MediaCard
|
|
||||||
linkable
|
|
||||||
key={media.id}
|
|
||||||
media={{
|
|
||||||
id: media.id.toString(),
|
|
||||||
title: media.title || media.name || "",
|
|
||||||
poster: `https://image.tmdb.org/t/p/w342${media.poster_path}`,
|
|
||||||
type: isTVShow ? "show" : "movie",
|
|
||||||
year: isTVShow
|
|
||||||
? media.first_air_date
|
|
||||||
? parseInt(media.first_air_date.split("-")[0], 10)
|
|
||||||
: undefined
|
|
||||||
: media.release_date
|
|
||||||
? parseInt(media.release_date.split("-")[0], 10)
|
|
||||||
: undefined,
|
|
||||||
}}
|
|
||||||
onShowDetails={onShowDetails}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
))
|
|
||||||
: Array.from({ length: SKELETON_COUNT }).map(() => (
|
|
||||||
<MediaCardSkeleton
|
|
||||||
key={`skeleton-${categorySlug}-${Math.random().toString(36).substring(7)}`}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
<div className="md:w-12" />
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{!isMobile && (
|
|
||||||
<CarouselNavButtons
|
|
||||||
categorySlug={categorySlug}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,63 +0,0 @@
|
||||||
import React from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
interface RandomMovieButtonProps {
|
|
||||||
countdown: number | null;
|
|
||||||
onClick: () => void;
|
|
||||||
randomMovieTitle: string | null;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function RandomMovieButton({
|
|
||||||
countdown,
|
|
||||||
onClick,
|
|
||||||
randomMovieTitle,
|
|
||||||
}: RandomMovieButtonProps) {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="w-full max-w-screen-xl mx-auto px-4">
|
|
||||||
<div className="flex items-center justify-center">
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
className="flex items-center space-x-2 rounded-full px-4 text-white py-2 bg-pill-background bg-opacity-50 hover:bg-pill-backgroundHover transition-[background,transform] duration-100 hover:scale-105"
|
|
||||||
onClick={onClick}
|
|
||||||
>
|
|
||||||
<span className="flex items-center">
|
|
||||||
{countdown !== null && countdown > 0 ? (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>{t("discover.randomMovie.cancel")}</span>
|
|
||||||
<Icon
|
|
||||||
icon={Icons.X}
|
|
||||||
className="text-2xl ml-[4.5px] mb-[-0.7px]"
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
) : (
|
|
||||||
<div className="flex items-center">
|
|
||||||
<span>{t("discover.randomMovie.button")}</span>
|
|
||||||
<img
|
|
||||||
src="/lightbar-images/dice.svg"
|
|
||||||
alt="Dice"
|
|
||||||
style={{ marginLeft: "8px" }}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Random Movie Countdown */}
|
|
||||||
{randomMovieTitle && countdown !== null && (
|
|
||||||
<div className="mt-4 mb-4 text-center">
|
|
||||||
<p>
|
|
||||||
{t("discover.randomMovie.nowPlaying")}{" "}
|
|
||||||
<span className="font-bold">{randomMovieTitle}</span>{" "}
|
|
||||||
{t("discover.randomMovie.in")}{" "}
|
|
||||||
{t("discover.randomMovie.countdown", { countdown })}
|
|
||||||
</p>
|
|
||||||
</div>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,55 +0,0 @@
|
||||||
import { useEffect, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
|
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
|
||||||
|
|
||||||
export function ScrollToTopButton() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
const [isVisible, setIsVisible] = useState(false);
|
|
||||||
|
|
||||||
const toggleVisibility = () => {
|
|
||||||
const scrolled = window.scrollY > 300;
|
|
||||||
setIsVisible(scrolled);
|
|
||||||
};
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const handleScroll = () => {
|
|
||||||
const timeout = setTimeout(toggleVisibility, 100);
|
|
||||||
return () => clearTimeout(timeout);
|
|
||||||
};
|
|
||||||
|
|
||||||
window.addEventListener("scroll", handleScroll);
|
|
||||||
return () => window.removeEventListener("scroll", handleScroll);
|
|
||||||
}, []);
|
|
||||||
|
|
||||||
const scrollToTop = () => {
|
|
||||||
window.scrollTo({ top: 0, behavior: "smooth" });
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="fixed bottom-4 left-1/2 transform -translate-x-1/2 z-50">
|
|
||||||
<div
|
|
||||||
className={`absolute inset-0 mx-auto h-[50px] w-[200px] rounded-full blur-[50px] opacity-50 pointer-events-none z-0 ${
|
|
||||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
backgroundImage: `linear-gradient(to right, rgba(var(--colors-buttons-purpleHover)), rgba(var(--colors-progress-filled)))`,
|
|
||||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
|
||||||
}}
|
|
||||||
/>
|
|
||||||
<button
|
|
||||||
type="button"
|
|
||||||
onClick={scrollToTop}
|
|
||||||
className={`relative flex items-center justify-center space-x-2 rounded-full px-4 py-3 text-lg font-semibold text-white bg-pill-background bg-opacity-80 hover:bg-pill-backgroundHover transition-opacity hover:scale-105 duration-500 ease-in-out ${
|
|
||||||
isVisible ? "opacity-100 visible" : "opacity-0 invisible"
|
|
||||||
}`}
|
|
||||||
style={{
|
|
||||||
transition: "opacity 0.4s ease-in-out, transform 0.4s ease-in-out",
|
|
||||||
}}
|
|
||||||
>
|
|
||||||
<Icon icon={Icons.CHEVRON_UP} className="text-2xl z-10" />
|
|
||||||
<span className="z-10">{t("discover.scrollToTop")}</span>
|
|
||||||
</button>
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
@ -1,56 +0,0 @@
|
||||||
.carousel-container {
|
|
||||||
position: relative;
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(0, 0, 0, 0), /* Left edge */
|
|
||||||
rgba(0, 0, 0, 1) 80px, /* visible after 80px */
|
|
||||||
rgba(0, 0, 0, 1) calc(100% - 80px), /* invisible 80px from right */
|
|
||||||
rgba(0, 0, 0, 0) 100% /* Right edge */
|
|
||||||
);
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(0, 0, 0, 0),
|
|
||||||
rgba(0, 0, 0, 1) 80px,
|
|
||||||
rgba(0, 0, 0, 1) calc(100% - 80px),
|
|
||||||
rgba(0, 0, 0, 0) 100%
|
|
||||||
);
|
|
||||||
z-index: 1;
|
|
||||||
}
|
|
||||||
|
|
||||||
@media (max-width: 768px) {
|
|
||||||
.carousel-container {
|
|
||||||
mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(0, 0, 0, 0), /* Left edge */
|
|
||||||
rgba(0, 0, 0, 1) 20px, /* visible after 80px */
|
|
||||||
rgba(0, 0, 0, 1) calc(100% - 20px), /* invisible 80px from right */
|
|
||||||
rgba(0, 0, 0, 0) 100% /* Right edge */
|
|
||||||
);
|
|
||||||
-webkit-mask-image: linear-gradient(
|
|
||||||
to right,
|
|
||||||
rgba(0, 0, 0, 0),
|
|
||||||
rgba(0, 0, 0, 1) 20px,
|
|
||||||
rgba(0, 0, 0, 1) calc(100% - 20px),
|
|
||||||
rgba(0, 0, 0, 0) 100%
|
|
||||||
);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
|
|
||||||
h2 {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
button {
|
|
||||||
position: relative;
|
|
||||||
z-index: 2;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar::-webkit-scrollbar {
|
|
||||||
display: none;
|
|
||||||
}
|
|
||||||
|
|
||||||
.scrollbar {
|
|
||||||
scrollbar-width: none !important;
|
|
||||||
-ms-overflow-style: -ms-autohiding-scrollbar !important;
|
|
||||||
}
|
|
||||||
|
|
@ -1,526 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
import { useTranslation } from "react-i18next";
|
|
||||||
import { useNavigate } from "react-router-dom";
|
|
||||||
|
|
||||||
import { get } from "@/backend/metadata/tmdb";
|
|
||||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
|
||||||
import { useModal } from "@/components/overlays/Modal";
|
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
|
||||||
import {
|
|
||||||
Genre,
|
|
||||||
Movie,
|
|
||||||
categories,
|
|
||||||
tvCategories,
|
|
||||||
} from "@/pages/discover/common";
|
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
import { MediaItem } from "@/utils/mediaTypes";
|
|
||||||
|
|
||||||
import "./discover.css";
|
|
||||||
import { CategoryButtons } from "./components/CategoryButtons";
|
|
||||||
import { LazyMediaCarousel } from "./components/LazyMediaCarousel";
|
|
||||||
import { LazyTabContent } from "./components/LazyTabContent";
|
|
||||||
import { MediaCarousel } from "./components/MediaCarousel";
|
|
||||||
import { RandomMovieButton } from "./components/RandomMovieButton";
|
|
||||||
import { ScrollToTopButton } from "./components/ScrollToTopButton";
|
|
||||||
import { useTMDBData } from "./hooks/useTMDBData";
|
|
||||||
|
|
||||||
const MOVIE_PROVIDERS = [
|
|
||||||
{ name: "Netflix", id: "8" },
|
|
||||||
{ name: "Apple TV+", id: "2" },
|
|
||||||
{ name: "Amazon Prime Video", id: "10" },
|
|
||||||
{ name: "Hulu", id: "15" },
|
|
||||||
{ name: "Max", id: "1899" },
|
|
||||||
{ name: "Paramount Plus", id: "531" },
|
|
||||||
{ name: "Disney Plus", id: "337" },
|
|
||||||
{ name: "Shudder", id: "99" },
|
|
||||||
];
|
|
||||||
|
|
||||||
const TV_PROVIDERS = [
|
|
||||||
{ name: "Netflix", id: "8" },
|
|
||||||
{ name: "Apple TV+", id: "350" },
|
|
||||||
{ name: "Paramount Plus", id: "531" },
|
|
||||||
{ name: "Hulu", id: "15" },
|
|
||||||
{ name: "Max", id: "1899" },
|
|
||||||
{ name: "Disney Plus", id: "337" },
|
|
||||||
{ name: "fubuTV", id: "257" },
|
|
||||||
];
|
|
||||||
|
|
||||||
// Editor Picks lists
|
|
||||||
const EDITOR_PICKS_MOVIES = [
|
|
||||||
{ id: 9342, type: "movie" }, // The Mask of Zorro
|
|
||||||
{ id: 293, type: "movie" }, // A River Runs Through It
|
|
||||||
{ id: 370172, type: "movie" }, // No Time To Die
|
|
||||||
{ id: 661374, type: "movie" }, // The Glass Onion
|
|
||||||
{ id: 207, type: "movie" }, // Dead Poets Society
|
|
||||||
{ id: 378785, type: "movie" }, // The Best of the Blues Brothers
|
|
||||||
{ id: 335984, type: "movie" }, // Blade Runner 2049
|
|
||||||
{ id: 13353, type: "movie" }, // It's the Great Pumpkin, Charlie Brown
|
|
||||||
{ id: 27205, type: "movie" }, // Inception
|
|
||||||
{ id: 106646, type: "movie" }, // The Wolf of Wall Street
|
|
||||||
{ id: 334533, type: "movie" }, // Captain Fantastic
|
|
||||||
{ id: 693134, type: "movie" }, // Dune: Part Two
|
|
||||||
{ id: 765245, type: "movie" }, // Swan Song
|
|
||||||
{ id: 264660, type: "movie" }, // Ex Machina
|
|
||||||
{ id: 92591, type: "movie" }, // Bernie
|
|
||||||
{ id: 976893, type: "movie" }, // Perfect Days
|
|
||||||
{ id: 13187, type: "movie" }, // A Charlie Brown Christmas
|
|
||||||
{ id: 11527, type: "movie" }, // Excalibur
|
|
||||||
{ id: 120, type: "movie" }, // LOTR: The Fellowship of the Ring
|
|
||||||
{ id: 157336, type: "movie" }, // Interstellar
|
|
||||||
{ id: 762, type: "movie" }, // Monty Python and the Holy Grail
|
|
||||||
{ id: 666243, type: "movie" }, // The Witcher: Nightmare of the Wolf
|
|
||||||
{ id: 545611, type: "movie" }, // Everything Everywhere All at Once
|
|
||||||
{ id: 329, type: "movie" }, // Jurrassic Park
|
|
||||||
{ id: 330459, type: "movie" }, // Rogue One: A Star Wars Story
|
|
||||||
{ id: 279, type: "movie" }, // Amadeus
|
|
||||||
{ id: 823219, type: "movie" }, // Flow
|
|
||||||
{ id: 22, type: "movie" }, // Pirates of the Caribbean: The Curse of the Black Pearl
|
|
||||||
{ id: 18971, type: "movie" }, // Rosencrantz and Guildenstern Are Dead
|
|
||||||
{ id: 26388, type: "movie" }, // Buried
|
|
||||||
{ id: 152601, type: "movie" }, // Her
|
|
||||||
];
|
|
||||||
|
|
||||||
const EDITOR_PICKS_TV_SHOWS = [
|
|
||||||
{ id: 456, type: "show" }, // The Simpsons
|
|
||||||
{ id: 73021, type: "show" }, // Disenchantment
|
|
||||||
{ id: 1434, type: "show" }, // Family Guy
|
|
||||||
{ id: 1695, type: "show" }, // Monk
|
|
||||||
{ id: 1408, type: "show" }, // House
|
|
||||||
{ id: 93740, type: "show" }, // Foundation
|
|
||||||
{ id: 60625, type: "show" }, // Rick and Morty
|
|
||||||
{ id: 1396, type: "show" }, // Breaking Bad
|
|
||||||
{ id: 44217, type: "show" }, // Vikings
|
|
||||||
{ id: 90228, type: "show" }, // Dune Prophecy
|
|
||||||
{ id: 13916, type: "show" }, // Death Note
|
|
||||||
{ id: 71912, type: "show" }, // The Witcher
|
|
||||||
{ id: 61222, type: "show" }, // Bojack Horseman
|
|
||||||
{ id: 93405, type: "show" }, // Squid Game
|
|
||||||
{ id: 87108, type: "show" }, // Chernobyl
|
|
||||||
{ id: 105248, type: "show" }, // Cyberpunk: Edgerunners
|
|
||||||
];
|
|
||||||
|
|
||||||
export function DiscoverContent() {
|
|
||||||
// State management
|
|
||||||
const [selectedCategory, setSelectedCategory] = useState("movies");
|
|
||||||
const [genres, setGenres] = useState<Genre[]>([]);
|
|
||||||
const [tvGenres, setTVGenres] = useState<Genre[]>([]);
|
|
||||||
const [randomMovie, setRandomMovie] = useState<Movie | null>(null);
|
|
||||||
const [countdown, setCountdown] = useState<number | null>(null);
|
|
||||||
const [countdownTimeout, setCountdownTimeout] =
|
|
||||||
useState<NodeJS.Timeout | null>(null);
|
|
||||||
const [selectedProvider, setSelectedProvider] = useState({
|
|
||||||
name: "",
|
|
||||||
id: "",
|
|
||||||
});
|
|
||||||
const [providerMovies, setProviderMovies] = useState<Movie[]>([]);
|
|
||||||
const [providerTVShows, setProviderTVShows] = useState<any[]>([]);
|
|
||||||
const [editorPicksMovies, setEditorPicksMovies] = useState<Movie[]>([]);
|
|
||||||
const [editorPicksTVShows, setEditorPicksTVShows] = useState<any[]>([]);
|
|
||||||
const [detailsData, setDetailsData] = useState<any>();
|
|
||||||
const detailsModal = useModal("discover-details");
|
|
||||||
|
|
||||||
// Refs
|
|
||||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
|
||||||
|
|
||||||
// Hooks
|
|
||||||
const navigate = useNavigate();
|
|
||||||
const { isMobile } = useIsMobile();
|
|
||||||
const { genreMedia: genreMovies } = useTMDBData(genres, categories, "movie");
|
|
||||||
// const { genreMedia: genreTVShows } = useTMDBData(
|
|
||||||
// tvGenres,
|
|
||||||
// tvCategories,
|
|
||||||
// "tv",
|
|
||||||
// );
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
// Only load data for the active tab
|
|
||||||
const isMoviesTab = selectedCategory === "movies";
|
|
||||||
const isTVShowsTab = selectedCategory === "tvshows";
|
|
||||||
const isEditorPicksTab = selectedCategory === "editorpicks";
|
|
||||||
|
|
||||||
// Fetch TV show genres
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isTVShowsTab) return;
|
|
||||||
|
|
||||||
const fetchTVGenres = async () => {
|
|
||||||
try {
|
|
||||||
const data = await get<any>("/genre/tv/list", {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
});
|
|
||||||
// Fetch only the first 10 TV show genres
|
|
||||||
setTVGenres(data.genres.slice(0, 10));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching TV show genres:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchTVGenres();
|
|
||||||
}, [isTVShowsTab]);
|
|
||||||
|
|
||||||
// Fetch Movie genres
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isMoviesTab) return;
|
|
||||||
|
|
||||||
const fetchGenres = async () => {
|
|
||||||
try {
|
|
||||||
const data = await get<any>("/genre/movie/list", {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
});
|
|
||||||
|
|
||||||
// Fetch only the first 12 genres
|
|
||||||
setGenres(data.genres.slice(0, 12));
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching genres:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchGenres();
|
|
||||||
}, [isMoviesTab]);
|
|
||||||
|
|
||||||
// Fetch Editor Picks Movies
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEditorPicksTab) return;
|
|
||||||
|
|
||||||
const fetchEditorPicksMovies = async () => {
|
|
||||||
try {
|
|
||||||
const moviePromises = EDITOR_PICKS_MOVIES.map((item) =>
|
|
||||||
get<any>(`/movie/${item.id}`, {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
append_to_response: "videos,images",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(moviePromises);
|
|
||||||
// Shuffle the results to display them randomly
|
|
||||||
const shuffled = [...results].sort(() => 0.5 - Math.random());
|
|
||||||
setEditorPicksMovies(shuffled);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching editor picks movies:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchEditorPicksMovies();
|
|
||||||
}, [isEditorPicksTab]);
|
|
||||||
|
|
||||||
// Fetch Editor Picks TV Shows
|
|
||||||
useEffect(() => {
|
|
||||||
if (!isEditorPicksTab) return;
|
|
||||||
|
|
||||||
const fetchEditorPicksTVShows = async () => {
|
|
||||||
try {
|
|
||||||
const tvShowPromises = EDITOR_PICKS_TV_SHOWS.map((item) =>
|
|
||||||
get<any>(`/tv/${item.id}`, {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
append_to_response: "videos,images",
|
|
||||||
}),
|
|
||||||
);
|
|
||||||
|
|
||||||
const results = await Promise.all(tvShowPromises);
|
|
||||||
// Shuffle the results to display them randomly
|
|
||||||
const shuffled = [...results].sort(() => 0.5 - Math.random());
|
|
||||||
setEditorPicksTVShows(shuffled);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching editor picks TV shows:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchEditorPicksTVShows();
|
|
||||||
}, [isEditorPicksTab]);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
let countdownInterval: NodeJS.Timeout;
|
|
||||||
if (countdown !== null && countdown > 0) {
|
|
||||||
countdownInterval = setInterval(() => {
|
|
||||||
setCountdown((prev) => (prev !== null ? prev - 1 : prev));
|
|
||||||
}, 1000);
|
|
||||||
}
|
|
||||||
return () => clearInterval(countdownInterval);
|
|
||||||
}, [countdown]);
|
|
||||||
|
|
||||||
// Handlers
|
|
||||||
const handleCategoryChange = (
|
|
||||||
eventOrValue: React.ChangeEvent<HTMLSelectElement> | string,
|
|
||||||
) => {
|
|
||||||
const value =
|
|
||||||
typeof eventOrValue === "string"
|
|
||||||
? eventOrValue
|
|
||||||
: eventOrValue.target.value;
|
|
||||||
setSelectedCategory(value);
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleRandomMovieClick = () => {
|
|
||||||
const allMovies = Object.values(genreMovies).flat();
|
|
||||||
const uniqueTitles = new Set(allMovies.map((movie) => movie.title));
|
|
||||||
const uniqueTitlesArray = Array.from(uniqueTitles);
|
|
||||||
const randomIndex = Math.floor(Math.random() * uniqueTitlesArray.length);
|
|
||||||
const selectedMovie = allMovies.find(
|
|
||||||
(movie) => movie.title === uniqueTitlesArray[randomIndex],
|
|
||||||
);
|
|
||||||
|
|
||||||
if (selectedMovie) {
|
|
||||||
if (countdown !== null && countdown > 0) {
|
|
||||||
setCountdown(null);
|
|
||||||
if (countdownTimeout) {
|
|
||||||
clearTimeout(countdownTimeout);
|
|
||||||
setCountdownTimeout(null);
|
|
||||||
setRandomMovie(null);
|
|
||||||
}
|
|
||||||
} else {
|
|
||||||
setRandomMovie(selectedMovie as Movie);
|
|
||||||
setCountdown(5);
|
|
||||||
const timeoutId = setTimeout(() => {
|
|
||||||
navigate(`/media/tmdb-movie-${selectedMovie.id}-discover-random`);
|
|
||||||
}, 5000);
|
|
||||||
setCountdownTimeout(timeoutId);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleProviderClick = async (id: string, name: string) => {
|
|
||||||
try {
|
|
||||||
setSelectedProvider({ name, id });
|
|
||||||
const endpoint =
|
|
||||||
selectedCategory === "movies" ? "/discover/movie" : "/discover/tv";
|
|
||||||
const setData =
|
|
||||||
selectedCategory === "movies" ? setProviderMovies : setProviderTVShows;
|
|
||||||
const data = await get<any>(endpoint, {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
with_watch_providers: id,
|
|
||||||
watch_region: "US",
|
|
||||||
language: "en-US",
|
|
||||||
});
|
|
||||||
setData(data.results);
|
|
||||||
} catch (error) {
|
|
||||||
console.error("Error fetching provider movies/shows:", error);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleCategoryClick = (id: string, name: string) => {
|
|
||||||
// Try both movie and tv versions of the category slug
|
|
||||||
const categorySlugBase = name.toLowerCase().replace(/[^a-z0-9]+/g, "-");
|
|
||||||
const movieElement = document.getElementById(
|
|
||||||
`carousel-${categorySlugBase}-movie`,
|
|
||||||
);
|
|
||||||
const tvElement = document.getElementById(
|
|
||||||
`carousel-${categorySlugBase}-tv`,
|
|
||||||
);
|
|
||||||
|
|
||||||
// Scroll to the first element that exists
|
|
||||||
const element = movieElement || tvElement;
|
|
||||||
if (element) {
|
|
||||||
element.scrollIntoView({
|
|
||||||
behavior: "smooth",
|
|
||||||
block: "center",
|
|
||||||
});
|
|
||||||
}
|
|
||||||
};
|
|
||||||
|
|
||||||
const handleShowDetails = async (media: MediaItem) => {
|
|
||||||
setDetailsData({
|
|
||||||
id: Number(media.id),
|
|
||||||
type: media.type === "movie" ? "movie" : "show",
|
|
||||||
});
|
|
||||||
detailsModal.show();
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render Editor Picks content
|
|
||||||
const renderEditorPicksContent = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
<LazyMediaCarousel
|
|
||||||
preloadedMedia={editorPicksMovies}
|
|
||||||
title="Editor Picks"
|
|
||||||
mediaType="movie"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
<LazyMediaCarousel
|
|
||||||
preloadedMedia={editorPicksTVShows}
|
|
||||||
title="Editor Picks"
|
|
||||||
mediaType="tv"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render Movies content with lazy loading
|
|
||||||
const renderMoviesContent = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Provider Movies */}
|
|
||||||
{providerMovies.length > 0 && (
|
|
||||||
<MediaCarousel
|
|
||||||
medias={providerMovies}
|
|
||||||
category={selectedProvider.name}
|
|
||||||
isTVShow={false}
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
{categories.map((category) => (
|
|
||||||
<LazyMediaCarousel
|
|
||||||
key={category.name}
|
|
||||||
category={category}
|
|
||||||
mediaType="movie"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{genres.map((genre) => (
|
|
||||||
<LazyMediaCarousel
|
|
||||||
key={genre.id}
|
|
||||||
genre={genre}
|
|
||||||
mediaType="movie"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
// Render TV Shows content with lazy loading
|
|
||||||
const renderTVShowsContent = () => {
|
|
||||||
return (
|
|
||||||
<>
|
|
||||||
{/* Provider TV Shows */}
|
|
||||||
{providerTVShows.length > 0 && (
|
|
||||||
<MediaCarousel
|
|
||||||
medias={providerTVShows}
|
|
||||||
category={selectedProvider.name}
|
|
||||||
isTVShow
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
)}
|
|
||||||
|
|
||||||
{/* Categories */}
|
|
||||||
{tvCategories.map((category) => (
|
|
||||||
<LazyMediaCarousel
|
|
||||||
key={category.name}
|
|
||||||
category={category}
|
|
||||||
mediaType="tv"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
|
|
||||||
{/* Genres */}
|
|
||||||
{tvGenres.map((genre) => (
|
|
||||||
<LazyMediaCarousel
|
|
||||||
key={genre.id}
|
|
||||||
genre={genre}
|
|
||||||
mediaType="tv"
|
|
||||||
isMobile={isMobile}
|
|
||||||
carouselRefs={carouselRefs}
|
|
||||||
onShowDetails={handleShowDetails}
|
|
||||||
/>
|
|
||||||
))}
|
|
||||||
</>
|
|
||||||
);
|
|
||||||
};
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="pt-6">
|
|
||||||
{/* Random Movie Button */}
|
|
||||||
<RandomMovieButton
|
|
||||||
countdown={countdown}
|
|
||||||
onClick={handleRandomMovieClick}
|
|
||||||
randomMovieTitle={randomMovie ? randomMovie.title : null}
|
|
||||||
/>
|
|
||||||
|
|
||||||
{/* Category Tabs */}
|
|
||||||
<div className="mt-8 pb-2 w-full max-w-screen-xl mx-auto">
|
|
||||||
<div className="relative flex justify-center mb-4">
|
|
||||||
<div className="flex space-x-4">
|
|
||||||
{["movies", "tvshows", "editorpicks"].map((category) => (
|
|
||||||
<button
|
|
||||||
key={category}
|
|
||||||
type="button"
|
|
||||||
className={`text-xl md:text-2xl font-bold p-2 bg-transparent text-center rounded-full cursor-pointer flex items-center transition-transform duration-200 ${
|
|
||||||
selectedCategory === category
|
|
||||||
? "transform scale-105 text-type-link"
|
|
||||||
: "text-type-secondary"
|
|
||||||
}`}
|
|
||||||
onClick={() => handleCategoryChange(category)}
|
|
||||||
>
|
|
||||||
{t(`discover.tabs.${category}`)}
|
|
||||||
</button>
|
|
||||||
))}
|
|
||||||
</div>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Only show provider and genre buttons for movies and tvshows categories */}
|
|
||||||
{selectedCategory !== "editorpicks" && (
|
|
||||||
<>
|
|
||||||
<div className="flex justify-center overflow-x-auto">
|
|
||||||
<CategoryButtons
|
|
||||||
categories={
|
|
||||||
selectedCategory === "movies" ? MOVIE_PROVIDERS : TV_PROVIDERS
|
|
||||||
}
|
|
||||||
onCategoryClick={handleProviderClick}
|
|
||||||
categoryType="providers"
|
|
||||||
isMobile={isMobile}
|
|
||||||
showAlwaysScroll={false}
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
<div className="flex overflow-x-auto">
|
|
||||||
<CategoryButtons
|
|
||||||
categories={
|
|
||||||
selectedCategory === "movies"
|
|
||||||
? [...categories, ...genres]
|
|
||||||
: [...tvCategories, ...tvGenres]
|
|
||||||
}
|
|
||||||
onCategoryClick={handleCategoryClick}
|
|
||||||
categoryType="movies"
|
|
||||||
isMobile={isMobile}
|
|
||||||
showAlwaysScroll
|
|
||||||
/>
|
|
||||||
</div>
|
|
||||||
</>
|
|
||||||
)}
|
|
||||||
</div>
|
|
||||||
|
|
||||||
{/* Content Section with Lazy Loading Tabs */}
|
|
||||||
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
|
|
||||||
{/* Movies Tab */}
|
|
||||||
<LazyTabContent isActive={isMoviesTab}>
|
|
||||||
{renderMoviesContent()}
|
|
||||||
</LazyTabContent>
|
|
||||||
|
|
||||||
{/* TV Shows Tab */}
|
|
||||||
<LazyTabContent isActive={isTVShowsTab}>
|
|
||||||
{renderTVShowsContent()}
|
|
||||||
</LazyTabContent>
|
|
||||||
|
|
||||||
{/* Editor Picks Tab */}
|
|
||||||
<LazyTabContent isActive={isEditorPicksTab}>
|
|
||||||
{renderEditorPicksContent()}
|
|
||||||
</LazyTabContent>
|
|
||||||
</div>
|
|
||||||
|
|
||||||
<ScrollToTopButton />
|
|
||||||
|
|
||||||
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
|
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export default DiscoverContent;
|
|
||||||
|
|
@ -1,43 +0,0 @@
|
||||||
import { useEffect, useRef, useState } from "react";
|
|
||||||
|
|
||||||
interface IntersectionObserverOptions {
|
|
||||||
root?: Element | null;
|
|
||||||
rootMargin?: string;
|
|
||||||
threshold?: number | number[];
|
|
||||||
}
|
|
||||||
|
|
||||||
export function useIntersectionObserver(
|
|
||||||
options: IntersectionObserverOptions = {},
|
|
||||||
) {
|
|
||||||
const [isIntersecting, setIsIntersecting] = useState(false);
|
|
||||||
const [hasIntersected, setHasIntersected] = useState(false);
|
|
||||||
const targetRef = useRef<Element | null>(null);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
const observer = new IntersectionObserver(
|
|
||||||
([entry]) => {
|
|
||||||
setIsIntersecting(entry.isIntersecting);
|
|
||||||
if (entry.isIntersecting) {
|
|
||||||
setHasIntersected(true);
|
|
||||||
}
|
|
||||||
},
|
|
||||||
{
|
|
||||||
...options,
|
|
||||||
rootMargin: options.rootMargin || "400px 0px",
|
|
||||||
},
|
|
||||||
);
|
|
||||||
|
|
||||||
const currentTarget = targetRef.current;
|
|
||||||
if (currentTarget) {
|
|
||||||
observer.observe(currentTarget);
|
|
||||||
}
|
|
||||||
|
|
||||||
return () => {
|
|
||||||
if (currentTarget) {
|
|
||||||
observer.unobserve(currentTarget);
|
|
||||||
}
|
|
||||||
};
|
|
||||||
}, [options]);
|
|
||||||
|
|
||||||
return { targetRef, isIntersecting, hasIntersected };
|
|
||||||
}
|
|
||||||
|
|
@ -1,147 +0,0 @@
|
||||||
import { useCallback, useEffect, useState } from "react";
|
|
||||||
|
|
||||||
import { get } from "@/backend/metadata/tmdb";
|
|
||||||
import { Category, Genre, Movie, TVShow } from "@/pages/discover/common";
|
|
||||||
import { conf } from "@/setup/config";
|
|
||||||
|
|
||||||
type MediaType = "movie" | "tv";
|
|
||||||
|
|
||||||
export function useTMDBData(
|
|
||||||
genres: Genre[],
|
|
||||||
categories: Category[],
|
|
||||||
mediaType: MediaType,
|
|
||||||
shouldLoad = true,
|
|
||||||
) {
|
|
||||||
const [genreMedia, setGenreMedia] = useState<{
|
|
||||||
[id: number]: Movie[] | TVShow[];
|
|
||||||
}>({});
|
|
||||||
const [categoryMedia, setCategoryMedia] = useState<{
|
|
||||||
[categoryName: string]: Movie[] | TVShow[];
|
|
||||||
}>({});
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
// Unified fetch function
|
|
||||||
const fetchMedia = useCallback(
|
|
||||||
async (endpoint: string, key: string, isGenre: boolean) => {
|
|
||||||
try {
|
|
||||||
const media: Movie[] | TVShow[] = [];
|
|
||||||
// Reduce the number of pages to improve performance
|
|
||||||
for (let page = 1; page <= 2; page += 1) {
|
|
||||||
const data = await get<any>(endpoint, {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
page: page.toString(),
|
|
||||||
...(isGenre ? { with_genres: key } : {}),
|
|
||||||
});
|
|
||||||
media.push(...data.results);
|
|
||||||
}
|
|
||||||
|
|
||||||
// Shuffle the media
|
|
||||||
for (let i = media.length - 1; i > 0; i -= 1) {
|
|
||||||
const j = Math.floor(Math.random() * (i + 1));
|
|
||||||
[media[i], media[j]] = [media[j], media[i]];
|
|
||||||
}
|
|
||||||
|
|
||||||
return media;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error fetching ${mediaType} for ${isGenre ? "genre" : "category"} ${key}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mediaType],
|
|
||||||
);
|
|
||||||
|
|
||||||
// Fetch media for each genre
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldLoad || genres.length === 0) return;
|
|
||||||
|
|
||||||
const fetchMediaForGenres = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
const genrePromises = genres.map(async (genre) => {
|
|
||||||
const media = await fetchMedia(
|
|
||||||
`/discover/${mediaType}`,
|
|
||||||
genre.id.toString(),
|
|
||||||
true,
|
|
||||||
);
|
|
||||||
setGenreMedia((prev) => ({ ...prev, [genre.id]: media }));
|
|
||||||
});
|
|
||||||
await Promise.all(genrePromises);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchMediaForGenres();
|
|
||||||
}, [genres, mediaType, fetchMedia, shouldLoad]);
|
|
||||||
|
|
||||||
// Fetch media for each category
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldLoad || categories.length === 0) return;
|
|
||||||
|
|
||||||
const fetchMediaForCategories = async () => {
|
|
||||||
setIsLoading(true);
|
|
||||||
const categoryPromises = categories.map(async (category) => {
|
|
||||||
const media = await fetchMedia(category.endpoint, category.name, false);
|
|
||||||
setCategoryMedia((prev) => ({ ...prev, [category.name]: media }));
|
|
||||||
});
|
|
||||||
await Promise.all(categoryPromises);
|
|
||||||
setIsLoading(false);
|
|
||||||
};
|
|
||||||
|
|
||||||
fetchMediaForCategories();
|
|
||||||
}, [categories, mediaType, fetchMedia, shouldLoad]);
|
|
||||||
|
|
||||||
return { genreMedia, categoryMedia, isLoading };
|
|
||||||
}
|
|
||||||
|
|
||||||
// Create a hook for lazy loading a specific genre or category
|
|
||||||
export function useLazyTMDBData(
|
|
||||||
genre: Genre | null,
|
|
||||||
category: Category | null,
|
|
||||||
mediaType: MediaType,
|
|
||||||
shouldLoad = false,
|
|
||||||
) {
|
|
||||||
const [media, setMedia] = useState<Movie[] | TVShow[]>([]);
|
|
||||||
const [isLoading, setIsLoading] = useState(false);
|
|
||||||
|
|
||||||
const fetchMedia = useCallback(
|
|
||||||
async (endpoint: string, key: string, isGenre: boolean) => {
|
|
||||||
try {
|
|
||||||
setIsLoading(true);
|
|
||||||
const mediaItems: Movie[] | TVShow[] = [];
|
|
||||||
// Only fetch one page for better performance
|
|
||||||
const data = await get<any>(endpoint, {
|
|
||||||
api_key: conf().TMDB_READ_API_KEY,
|
|
||||||
language: "en-US",
|
|
||||||
page: "1",
|
|
||||||
...(isGenre ? { with_genres: key } : {}),
|
|
||||||
});
|
|
||||||
mediaItems.push(...data.results);
|
|
||||||
setMedia(mediaItems);
|
|
||||||
setIsLoading(false);
|
|
||||||
return mediaItems;
|
|
||||||
} catch (error) {
|
|
||||||
console.error(
|
|
||||||
`Error fetching ${mediaType} for ${isGenre ? "genre" : "category"}:`,
|
|
||||||
error,
|
|
||||||
);
|
|
||||||
setIsLoading(false);
|
|
||||||
return [];
|
|
||||||
}
|
|
||||||
},
|
|
||||||
[mediaType],
|
|
||||||
);
|
|
||||||
|
|
||||||
useEffect(() => {
|
|
||||||
if (!shouldLoad) return;
|
|
||||||
|
|
||||||
if (genre) {
|
|
||||||
fetchMedia(`/discover/${mediaType}`, genre.id.toString(), true);
|
|
||||||
} else if (category) {
|
|
||||||
fetchMedia(category.endpoint, category.name, false);
|
|
||||||
}
|
|
||||||
}, [genre, category, mediaType, fetchMedia, shouldLoad]);
|
|
||||||
|
|
||||||
return { media, isLoading };
|
|
||||||
}
|
|
||||||
|
|
@ -1,4 +1,3 @@
|
||||||
import { FooterView } from "@/components/layout/Footer";
|
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
|
||||||
export function HomeLayout(props: {
|
export function HomeLayout(props: {
|
||||||
|
|
@ -6,9 +5,9 @@ export function HomeLayout(props: {
|
||||||
children: React.ReactNode;
|
children: React.ReactNode;
|
||||||
}) {
|
}) {
|
||||||
return (
|
return (
|
||||||
<FooterView>
|
<div className="flex flex-col min-h-screen pb-20">
|
||||||
<Navigation bg={props.showBg} />
|
<Navigation bg={props.showBg} />
|
||||||
{props.children}
|
{props.children}
|
||||||
</FooterView>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,11 +1,10 @@
|
||||||
import { FooterView } from "@/components/layout/Footer";
|
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
|
||||||
export function PageLayout(props: { children: React.ReactNode }) {
|
export function PageLayout(props: { children: React.ReactNode }) {
|
||||||
return (
|
return (
|
||||||
<FooterView>
|
<>
|
||||||
<Navigation />
|
<Navigation />
|
||||||
{props.children}
|
{props.children}
|
||||||
</FooterView>
|
</>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -1,6 +1,5 @@
|
||||||
import classNames from "classnames";
|
import classNames from "classnames";
|
||||||
|
|
||||||
import { FooterView } from "@/components/layout/Footer";
|
|
||||||
import { Navigation } from "@/components/layout/Navigation";
|
import { Navigation } from "@/components/layout/Navigation";
|
||||||
|
|
||||||
export function BlurEllipsis(props: { positionClass?: string }) {
|
export function BlurEllipsis(props: { positionClass?: string }) {
|
||||||
|
|
@ -34,10 +33,8 @@ export function SubPageLayout(props: { children: React.ReactNode }) {
|
||||||
>
|
>
|
||||||
<BlurEllipsis />
|
<BlurEllipsis />
|
||||||
{/* Main page */}
|
{/* Main page */}
|
||||||
<FooterView>
|
<Navigation />
|
||||||
<Navigation doBackground noLightbar />
|
<div className="mt-40 relative">{props.children}</div>
|
||||||
<div className="mt-40 relative">{props.children}</div>
|
|
||||||
</FooterView>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -65,7 +65,7 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
||||||
);
|
);
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<LargeCard top={<BrandPill backgroundClass="bg-[#161527]" />}>
|
<LargeCard top={<BrandPill />}>
|
||||||
<LargeCardText title={t("auth.login.title")}>
|
<LargeCardText title={t("auth.login.title")}>
|
||||||
{t("auth.login.description")}
|
{t("auth.login.description")}
|
||||||
</LargeCardText>
|
</LargeCardText>
|
||||||
|
|
|
||||||
|
|
@ -15,10 +15,8 @@ const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||||
|
|
||||||
export function BookmarksPart({
|
export function BookmarksPart({
|
||||||
onItemsChange,
|
onItemsChange,
|
||||||
onShowDetails,
|
|
||||||
}: {
|
}: {
|
||||||
onItemsChange: (hasItems: boolean) => void;
|
onItemsChange: (hasItems: boolean) => void;
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}) {
|
}) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const progressItems = useProgressStore((s) => s.items);
|
const progressItems = useProgressStore((s) => s.items);
|
||||||
|
|
@ -118,7 +116,6 @@ export function BookmarksPart({
|
||||||
media={v}
|
media={v}
|
||||||
closable={editing}
|
closable={editing}
|
||||||
onClose={() => removeBookmark(v.id)}
|
onClose={() => removeBookmark(v.id)}
|
||||||
onShowDetails={onShowDetails}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -7,7 +7,6 @@ import { Icon, Icons } from "@/components/Icon";
|
||||||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||||
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
|
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
|
||||||
import { HeroTitle } from "@/components/text/HeroTitle";
|
import { HeroTitle } from "@/components/text/HeroTitle";
|
||||||
import { useIsTV } from "@/hooks/useIsTv";
|
|
||||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
|
|
@ -65,8 +64,6 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
||||||
);
|
);
|
||||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||||
|
|
||||||
const { isTV } = useIsTV();
|
|
||||||
|
|
||||||
// Detect if running as a PWA on iOS
|
// Detect if running as a PWA on iOS
|
||||||
const isIOSPWA =
|
const isIOSPWA =
|
||||||
/iPad|iPhone|iPod/i.test(navigator.userAgent) &&
|
/iPad|iPhone|iPod/i.test(navigator.userAgent) &&
|
||||||
|
|
@ -105,9 +102,9 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
||||||
<ThinContainer>
|
<ThinContainer>
|
||||||
<div className="mt-44 space-y-16 text-center">
|
<div className="mt-44 space-y-16 text-center">
|
||||||
<div className="relative z-10 mb-16">
|
<div className="relative z-10 mb-16">
|
||||||
{isTV && search.length > 0 ? null : (
|
<HeroTitle className="mx-auto max-w-md">
|
||||||
<HeroTitle className="mx-auto max-w-md">{title}</HeroTitle>
|
What do you want to watch?
|
||||||
)}
|
</HeroTitle>
|
||||||
</div>
|
</div>
|
||||||
<div className="relative h-20 z-30">
|
<div className="relative h-20 z-30">
|
||||||
<Sticky
|
<Sticky
|
||||||
|
|
|
||||||
|
|
@ -13,13 +13,12 @@ import { MediaItem } from "@/utils/mediaTypes";
|
||||||
|
|
||||||
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||||
|
|
||||||
export function WatchingPart({
|
interface WatchingPartProps {
|
||||||
onItemsChange,
|
|
||||||
onShowDetails,
|
|
||||||
}: {
|
|
||||||
onItemsChange: (hasItems: boolean) => void;
|
onItemsChange: (hasItems: boolean) => void;
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
className?: string;
|
||||||
}) {
|
}
|
||||||
|
|
||||||
|
export function WatchingPart({ onItemsChange, className }: WatchingPartProps) {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const progressItems = useProgressStore((s) => s.items);
|
const progressItems = useProgressStore((s) => s.items);
|
||||||
const removeItem = useProgressStore((s) => s.removeItem);
|
const removeItem = useProgressStore((s) => s.removeItem);
|
||||||
|
|
@ -82,7 +81,7 @@ export function WatchingPart({
|
||||||
if (sortedProgressItems.length === 0) return null;
|
if (sortedProgressItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="relative">
|
<div className={`relative ${className}`}>
|
||||||
<SectionHeading
|
<SectionHeading
|
||||||
title={t("home.continueWatching.sectionTitle")}
|
title={t("home.continueWatching.sectionTitle")}
|
||||||
icon={Icons.CLOCK}
|
icon={Icons.CLOCK}
|
||||||
|
|
@ -96,21 +95,20 @@ export function WatchingPart({
|
||||||
<MediaGrid ref={gridRef}>
|
<MediaGrid ref={gridRef}>
|
||||||
{sortedProgressItems.map((v) => (
|
{sortedProgressItems.map((v) => (
|
||||||
<div
|
<div
|
||||||
key={v.id}
|
style={{ userSelect: "none" }} // Disable text selection
|
||||||
style={{ userSelect: "none" }}
|
|
||||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||||
e.preventDefault()
|
e.preventDefault()
|
||||||
}
|
} // Prevent right-click context menu
|
||||||
onTouchStart={handleTouchStart}
|
onTouchStart={handleTouchStart} // Handle touch start
|
||||||
onTouchEnd={handleTouchEnd}
|
onTouchEnd={handleTouchEnd} // Handle touch end
|
||||||
onMouseDown={handleMouseDown}
|
onMouseDown={handleMouseDown} // Handle mouse down
|
||||||
onMouseUp={handleMouseUp}
|
onMouseUp={handleMouseUp} // Handle mouse up
|
||||||
>
|
>
|
||||||
<WatchedMediaCard
|
<WatchedMediaCard
|
||||||
|
key={v.id}
|
||||||
media={v}
|
media={v}
|
||||||
closable={editing}
|
closable={editing}
|
||||||
onClose={() => removeItem(v.id)}
|
onClose={() => removeItem(v.id)}
|
||||||
onShowDetails={onShowDetails}
|
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
))}
|
))}
|
||||||
|
|
|
||||||
|
|
@ -4,7 +4,6 @@ import IosPwaLimitations from "@/components/buttons/IosPwaLimitations";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
import { Player } from "@/components/player";
|
import { Player } from "@/components/player";
|
||||||
import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton";
|
import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton";
|
||||||
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
|
|
||||||
import { Widescreen } from "@/components/player/atoms/Widescreen";
|
import { Widescreen } from "@/components/player/atoms/Widescreen";
|
||||||
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
||||||
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
|
import { useSkipTime } from "@/components/player/hooks/useSkipTime";
|
||||||
|
|
@ -12,7 +11,7 @@ import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
|
||||||
import { usePlayerStore } from "@/stores/player/store";
|
import { usePlayerStore } from "@/stores/player/store";
|
||||||
|
|
||||||
import { ScrapingPartInterruptButton, Tips } from "./ScrapingPart";
|
import { Tips } from "./ScrapingPart";
|
||||||
|
|
||||||
export interface PlayerPartProps {
|
export interface PlayerPartProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
|
|
@ -89,8 +88,6 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
<span className="text mx-3 text-type-secondary">/</span>
|
<span className="text mx-3 text-type-secondary">/</span>
|
||||||
<Player.Title />
|
<Player.Title />
|
||||||
|
|
||||||
<Player.InfoButton />
|
|
||||||
|
|
||||||
<Player.BookmarkButton />
|
<Player.BookmarkButton />
|
||||||
</div>
|
</div>
|
||||||
<div className="text-center hidden xl:flex justify-center items-center">
|
<div className="text-center hidden xl:flex justify-center items-center">
|
||||||
|
|
@ -112,10 +109,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
|
|
||||||
<Player.BottomControls show={showTargets}>
|
<Player.BottomControls show={showTargets}>
|
||||||
{status === playerStatus.PLAYING ? null : <Tips />}
|
{status === playerStatus.PLAYING ? null : <Tips />}
|
||||||
<div className="flex items-center justify-center space-x-3 h-full">
|
<div className="flex items-center space-x-3 w-full">
|
||||||
{status === playerStatus.SCRAPING ? (
|
|
||||||
<ScrapingPartInterruptButton />
|
|
||||||
) : null}
|
|
||||||
{status === playerStatus.PLAYING ? (
|
{status === playerStatus.PLAYING ? (
|
||||||
<>
|
<>
|
||||||
{isMobile ? <Player.Time short /> : null}
|
{isMobile ? <Player.Time short /> : null}
|
||||||
|
|
@ -123,18 +117,19 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
</div>
|
</div>
|
||||||
<div className="hidden lg:flex justify-between" dir="ltr">
|
<div className="hidden lg:flex items-center w-full">
|
||||||
<Player.LeftSideControls>
|
<Player.LeftSideControls>
|
||||||
{status === playerStatus.PLAYING ? (
|
{status === playerStatus.PLAYING ? (
|
||||||
<>
|
<div className="flex items-center px-2">
|
||||||
<Player.Pause />
|
<Player.Pause />
|
||||||
<Player.SkipBackward />
|
<Player.SkipBackward />
|
||||||
<Player.SkipForward />
|
<Player.SkipForward />
|
||||||
<Player.Volume />
|
<Player.Volume className="mr-2" />
|
||||||
<Player.Time />
|
<Player.Time />
|
||||||
</>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
</Player.LeftSideControls>
|
</Player.LeftSideControls>
|
||||||
|
<div className="flex-1" />
|
||||||
<div className="flex items-center space-x-3">
|
<div className="flex items-center space-x-3">
|
||||||
<Player.Episodes />
|
<Player.Episodes />
|
||||||
{status === playerStatus.PLAYING ? (
|
{status === playerStatus.PLAYING ? (
|
||||||
|
|
@ -144,15 +139,10 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
<Player.Chromecast />
|
<Player.Chromecast />
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
{status === playerStatus.PLAYBACK_ERROR ||
|
{(status === playerStatus.PLAYBACK_ERROR ||
|
||||||
status === playerStatus.PLAYING ? (
|
status === playerStatus.PLAYING) && <Player.Captions />}
|
||||||
<Player.Captions />
|
|
||||||
) : null}
|
|
||||||
<Player.Settings />
|
<Player.Settings />
|
||||||
{/* Fullscreen on when not shifting */}
|
|
||||||
{!isShifting && <Player.Fullscreen />}
|
{!isShifting && <Player.Fullscreen />}
|
||||||
|
|
||||||
{/* Expand button visible when shifting */}
|
|
||||||
{isShifting && (
|
{isShifting && (
|
||||||
<div>
|
<div>
|
||||||
<Widescreen />
|
<Widescreen />
|
||||||
|
|
@ -160,31 +150,31 @@ export function PlayerPart(props: PlayerPartProps) {
|
||||||
)}
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<div className="grid grid-cols-[2.5rem,1fr,2.5rem] gap-3 lg:hidden">
|
<div className="grid grid-cols-[56px,1fr,56px] items-center gap-3 lg:hidden">
|
||||||
<div />
|
<div />
|
||||||
<div className="flex justify-center space-x-3">
|
<div className="flex justify-center space-x-3">
|
||||||
{/* Disable PiP for iOS PWA */}
|
|
||||||
{!isIOSPWA && status === playerStatus.PLAYING && <Player.Pip />}
|
{!isIOSPWA && status === playerStatus.PLAYING && <Player.Pip />}
|
||||||
<Player.Episodes />
|
<Player.Episodes />
|
||||||
{status === playerStatus.PLAYING ? (
|
|
||||||
<div className="hidden ssm:block">
|
|
||||||
<Player.Captions />
|
|
||||||
</div>
|
|
||||||
) : null}
|
|
||||||
<Player.Settings />
|
<Player.Settings />
|
||||||
{isIOSPWA && <IosPwaLimitations />}
|
{isIOSPWA && status === playerStatus.PLAYING && <Widescreen />}
|
||||||
</div>
|
</div>
|
||||||
<div>
|
<div>
|
||||||
{/* iOS PWA */}
|
{!isIOSPWA && (
|
||||||
{!isIOSPWA && <Player.Fullscreen />}
|
<div>
|
||||||
{isIOSPWA && status === playerStatus.PLAYING && <Widescreen />}
|
<Player.Fullscreen />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
|
{isIOSPWA && (
|
||||||
|
<div>
|
||||||
|
<IosPwaLimitations />
|
||||||
|
</div>
|
||||||
|
)}
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
</Player.BottomControls>
|
</Player.BottomControls>
|
||||||
|
|
||||||
<Player.VolumeChangedPopout />
|
<Player.VolumeChangedPopout />
|
||||||
<Player.SubtitleDelayPopout />
|
<Player.SubtitleDelayPopout />
|
||||||
<UnreleasedEpisodeOverlay />
|
|
||||||
|
|
||||||
<Player.NextEpisodeButton
|
<Player.NextEpisodeButton
|
||||||
controlsShowing={showTargets}
|
controlsShowing={showTargets}
|
||||||
|
|
|
||||||
|
|
@ -9,16 +9,12 @@ import {
|
||||||
scrapePartsToProviderMetric,
|
scrapePartsToProviderMetric,
|
||||||
useReportProviders,
|
useReportProviders,
|
||||||
} from "@/backend/helpers/report";
|
} from "@/backend/helpers/report";
|
||||||
import { Button } from "@/components/buttons/Button";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { Loading } from "@/components/layout/Loading";
|
import { Loading } from "@/components/layout/Loading";
|
||||||
import {
|
import { ProgressRing } from "@/components/layout/ProgressRing";
|
||||||
ScrapeCard,
|
|
||||||
ScrapeItem,
|
|
||||||
} from "@/components/player/internals/ScrapeCard";
|
|
||||||
import {
|
import {
|
||||||
ScrapingItems,
|
ScrapingItems,
|
||||||
ScrapingSegment,
|
ScrapingSegment,
|
||||||
useListCenter,
|
|
||||||
useScrape,
|
useScrape,
|
||||||
} from "@/hooks/useProviderScrape";
|
} from "@/hooks/useProviderScrape";
|
||||||
|
|
||||||
|
|
@ -33,21 +29,57 @@ export interface ScrapingProps {
|
||||||
) => void;
|
) => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
|
interface ScrapePillProps {
|
||||||
|
name: string;
|
||||||
|
status: string;
|
||||||
|
percentage: number;
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrapePillSkeleton() {
|
||||||
|
return (
|
||||||
|
<div className="h-9 w-[220px] rounded-full bg-background-secondary opacity-50" />
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
|
function ScrapePill({ name, status, percentage }: ScrapePillProps) {
|
||||||
|
const isError = status === "failure";
|
||||||
|
|
||||||
|
return (
|
||||||
|
<div className="flex h-9 w-[220px] items-center rounded-full bg-background-secondary p-3">
|
||||||
|
<div className="mr-2 flex w-[18px] items-center justify-center">
|
||||||
|
{!isError ? (
|
||||||
|
<ProgressRing
|
||||||
|
className="h-[18px] w-[18px] text-primary"
|
||||||
|
percentage={percentage}
|
||||||
|
radius={40}
|
||||||
|
/>
|
||||||
|
) : (
|
||||||
|
<Icon icon={Icons.X} className="text-[0.85em] text-status-error" />
|
||||||
|
)}
|
||||||
|
</div>
|
||||||
|
<div className="flex-1 overflow-hidden">
|
||||||
|
<p
|
||||||
|
className={classNames(
|
||||||
|
"overflow-hidden text-ellipsis whitespace-nowrap",
|
||||||
|
{
|
||||||
|
"text-status-error": isError,
|
||||||
|
},
|
||||||
|
)}
|
||||||
|
>
|
||||||
|
{name}
|
||||||
|
</p>
|
||||||
|
</div>
|
||||||
|
</div>
|
||||||
|
);
|
||||||
|
}
|
||||||
|
|
||||||
export function ScrapingPart(props: ScrapingProps) {
|
export function ScrapingPart(props: ScrapingProps) {
|
||||||
const { report } = useReportProviders();
|
const { report } = useReportProviders();
|
||||||
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
||||||
const isMounted = useMountedState();
|
const isMounted = useMountedState();
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const listRef = useRef<HTMLDivElement | null>(null);
|
|
||||||
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
|
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
|
||||||
const renderedOnce = useListCenter(
|
|
||||||
containerRef,
|
|
||||||
listRef,
|
|
||||||
sourceOrder,
|
|
||||||
currentSource,
|
|
||||||
);
|
|
||||||
|
|
||||||
const resultRef = useRef({
|
const resultRef = useRef({
|
||||||
sourceOrder,
|
sourceOrder,
|
||||||
|
|
@ -92,91 +124,56 @@ export function ScrapingPart(props: ScrapingProps) {
|
||||||
return <WarningPart>{t("player.turnstile.error")}</WarningPart>;
|
return <WarningPart>{t("player.turnstile.error")}</WarningPart>;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div className="h-full w-full relative flex flex-col items-center justify-center">
|
||||||
className="h-full w-full relative dir-neutral:origin-top-left flex"
|
|
||||||
ref={containerRef}
|
|
||||||
>
|
|
||||||
{!sourceOrder || sourceOrder.length === 0 ? (
|
{!sourceOrder || sourceOrder.length === 0 ? (
|
||||||
<div className="absolute left-1/2 top-1/2 -translate-x-1/2 -translate-y-1/2 text-center flex flex-col justify-center z-0">
|
<div className="text-center flex flex-col justify-center z-0">
|
||||||
<Loading className="mb-8" />
|
<Loading className="mb-8" />
|
||||||
<p>{t("player.turnstile.verifyingHumanity")}</p>
|
<p>{t("player.turnstile.verifyingHumanity")}</p>
|
||||||
</div>
|
</div>
|
||||||
) : null}
|
) : (
|
||||||
<div
|
<div className="flex flex-col items-center gap-6">
|
||||||
className={classNames({
|
<div className="text-center flex flex-col items-center gap-3">
|
||||||
"absolute transition-[transform,opacity] opacity-0 dir-neutral:left-0":
|
<Loading className="mb-0" />
|
||||||
true,
|
<p className="text-type-secondary">
|
||||||
"!opacity-100": renderedOnce,
|
Finding the best video for you
|
||||||
})}
|
</p>
|
||||||
ref={listRef}
|
</div>
|
||||||
>
|
<div className="relative h-16 w-[400px] overflow-hidden">
|
||||||
{sourceOrder.map((order) => {
|
|
||||||
const source = sources[order.id];
|
|
||||||
const distance = Math.abs(
|
|
||||||
sourceOrder.findIndex((o) => o.id === order.id) -
|
|
||||||
currentProviderIndex,
|
|
||||||
);
|
|
||||||
return (
|
|
||||||
<div
|
<div
|
||||||
className="transition-opacity duration-100"
|
className="absolute inset-0 flex items-center justify-center"
|
||||||
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
|
style={{
|
||||||
key={order.id}
|
maskImage: `linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 80px, rgba(0, 0, 0, 1) calc(100% - 80px), rgba(0, 0, 0, 0) 100%)`,
|
||||||
|
WebkitMaskImage: `linear-gradient(to right, rgba(0, 0, 0, 0), rgba(0, 0, 0, 1) 80px, rgba(0, 0, 0, 1) calc(100% - 80px), rgba(0, 0, 0, 0) 100%)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
<ScrapeCard
|
<div className="relative flex h-full w-[220px] items-center">
|
||||||
id={order.id}
|
|
||||||
name={source.name}
|
|
||||||
status={source.status}
|
|
||||||
hasChildren={order.children.length > 0}
|
|
||||||
percentage={source.percentage}
|
|
||||||
>
|
|
||||||
<div
|
<div
|
||||||
className={classNames({
|
className="absolute inset-y-0 left-0 flex items-center gap-[16px] transition-transform duration-200"
|
||||||
"space-y-6 mt-8": order.children.length > 0,
|
style={{
|
||||||
})}
|
transform: `translateX(${
|
||||||
|
-1 * (220 + 16) * (currentProviderIndex + 1)
|
||||||
|
}px)`,
|
||||||
|
}}
|
||||||
>
|
>
|
||||||
{order.children.map((embedId) => {
|
<ScrapePillSkeleton />
|
||||||
const embed = sources[embedId];
|
{sourceOrder.map((order) => {
|
||||||
|
const source = sources[order.id];
|
||||||
return (
|
return (
|
||||||
<ScrapeItem
|
<ScrapePill
|
||||||
id={embedId}
|
key={order.id}
|
||||||
name={embed.name}
|
name={source.name}
|
||||||
status={embed.status}
|
status={source.status}
|
||||||
percentage={embed.percentage}
|
percentage={source.percentage}
|
||||||
key={embedId}
|
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
})}
|
})}
|
||||||
|
<ScrapePillSkeleton />
|
||||||
</div>
|
</div>
|
||||||
</ScrapeCard>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
</div>
|
||||||
})}
|
</div>
|
||||||
</div>
|
)}
|
||||||
</div>
|
|
||||||
);
|
|
||||||
}
|
|
||||||
|
|
||||||
export function ScrapingPartInterruptButton() {
|
|
||||||
const { t } = useTranslation();
|
|
||||||
|
|
||||||
return (
|
|
||||||
<div className="flex gap-3 pb-3">
|
|
||||||
<Button
|
|
||||||
href="/"
|
|
||||||
theme="secondary"
|
|
||||||
padding="md:px-17 p-3"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
{t("notFound.goHome")}
|
|
||||||
</Button>
|
|
||||||
<Button
|
|
||||||
onClick={() => window.location.reload()}
|
|
||||||
theme="purple"
|
|
||||||
padding="md:px-17 p-3"
|
|
||||||
className="mt-6"
|
|
||||||
>
|
|
||||||
{t("notFound.reloadButton")}
|
|
||||||
</Button>
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
|
||||||
|
|
@ -57,13 +57,7 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function SearchListPart({
|
export function SearchListPart({ searchQuery }: { searchQuery: string }) {
|
||||||
searchQuery,
|
|
||||||
onShowDetails,
|
|
||||||
}: {
|
|
||||||
searchQuery: string;
|
|
||||||
onShowDetails?: (media: MediaItem) => void;
|
|
||||||
}) {
|
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
|
|
||||||
const [results, setResults] = useState<MediaItem[]>([]);
|
const [results, setResults] = useState<MediaItem[]>([]);
|
||||||
|
|
@ -93,11 +87,7 @@ export function SearchListPart({
|
||||||
/>
|
/>
|
||||||
<MediaGrid>
|
<MediaGrid>
|
||||||
{results.map((v) => (
|
{results.map((v) => (
|
||||||
<WatchedMediaCard
|
<WatchedMediaCard key={v.id.toString()} media={v} />
|
||||||
key={v.id.toString()}
|
|
||||||
media={v}
|
|
||||||
onShowDetails={onShowDetails}
|
|
||||||
/>
|
|
||||||
))}
|
))}
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
</div>
|
</div>
|
||||||
|
|
|
||||||
|
|
@ -11,46 +11,6 @@ const availableThemes = [
|
||||||
selector: "theme-default",
|
selector: "theme-default",
|
||||||
key: "settings.appearance.themes.default",
|
key: "settings.appearance.themes.default",
|
||||||
},
|
},
|
||||||
{
|
|
||||||
id: "classic",
|
|
||||||
selector: "theme-classic",
|
|
||||||
key: "settings.appearance.themes.classic",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "blue",
|
|
||||||
selector: "theme-blue",
|
|
||||||
key: "settings.appearance.themes.blue",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "teal",
|
|
||||||
selector: "theme-teal",
|
|
||||||
key: "settings.appearance.themes.teal",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "red",
|
|
||||||
selector: "theme-red",
|
|
||||||
key: "settings.appearance.themes.red",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "gray",
|
|
||||||
selector: "theme-gray",
|
|
||||||
key: "settings.appearance.themes.gray",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "green",
|
|
||||||
selector: "theme-green",
|
|
||||||
key: "settings.appearance.themes.green",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "mocha",
|
|
||||||
selector: "theme-mocha",
|
|
||||||
key: "settings.appearance.themes.mocha",
|
|
||||||
},
|
|
||||||
{
|
|
||||||
id: "pink",
|
|
||||||
selector: "theme-pink",
|
|
||||||
key: "settings.appearance.themes.pink",
|
|
||||||
},
|
|
||||||
];
|
];
|
||||||
|
|
||||||
function ThemePreview(props: {
|
function ThemePreview(props: {
|
||||||
|
|
|
||||||
|
|
@ -6,6 +6,7 @@ import { useAsync } from "react-use";
|
||||||
import { getBackendMeta } from "@/backend/accounts/meta";
|
import { getBackendMeta } from "@/backend/accounts/meta";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { SidebarLink, SidebarSection } from "@/components/layout/Sidebar";
|
import { SidebarLink, SidebarSection } from "@/components/layout/Sidebar";
|
||||||
|
import { GlitchText } from "@/components/text/GlitchText";
|
||||||
import { Divider } from "@/components/utils/Divider";
|
import { Divider } from "@/components/utils/Divider";
|
||||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||||
|
|
@ -48,11 +49,6 @@ export function SidebarPart() {
|
||||||
id: "settings-preferences",
|
id: "settings-preferences",
|
||||||
icon: Icons.SETTINGS,
|
icon: Icons.SETTINGS,
|
||||||
},
|
},
|
||||||
{
|
|
||||||
textKey: "settings.appearance.title",
|
|
||||||
id: "settings-appearance",
|
|
||||||
icon: Icons.BRUSH,
|
|
||||||
},
|
|
||||||
{
|
{
|
||||||
textKey: "settings.subtitles.title",
|
textKey: "settings.subtitles.title",
|
||||||
id: "settings-captions",
|
id: "settings-captions",
|
||||||
|
|
@ -120,7 +116,7 @@ export function SidebarPart() {
|
||||||
<div className="text-settings-sidebar-type-inactive sidebar-boundary">
|
<div className="text-settings-sidebar-type-inactive sidebar-boundary">
|
||||||
<Sticky
|
<Sticky
|
||||||
topOffset={-6 * rem}
|
topOffset={-6 * rem}
|
||||||
stickyClassName="pt-[6rem]"
|
stickyClassName="pt-[8rem]"
|
||||||
disabled={isMobile}
|
disabled={isMobile}
|
||||||
hideOnBoundaryHit={false}
|
hideOnBoundaryHit={false}
|
||||||
boundaryElement=".sidebar-boundary"
|
boundaryElement=".sidebar-boundary"
|
||||||
|
|
@ -144,7 +140,10 @@ export function SidebarPart() {
|
||||||
className="text-sm"
|
className="text-sm"
|
||||||
title={t("settings.sidebar.info.title")}
|
title={t("settings.sidebar.info.title")}
|
||||||
>
|
>
|
||||||
<div className="px-3 py-3.5 rounded-lg bg-largeCard-background bg-opacity-50 grid grid-cols-2 gap-4">
|
<div
|
||||||
|
className="px-3 py-3.5 rounded-lg bg-largeCard-background bg-opacity-50 grid grid-cols-2 gap-4"
|
||||||
|
data-info-card
|
||||||
|
>
|
||||||
{/* Hostname */}
|
{/* Hostname */}
|
||||||
<div className="col-span-2 space-y-1">
|
<div className="col-span-2 space-y-1">
|
||||||
<p className="text-type-dimmed font-medium">
|
<p className="text-type-dimmed font-medium">
|
||||||
|
|
@ -179,9 +178,14 @@ export function SidebarPart() {
|
||||||
<p className="text-type-dimmed font-medium">
|
<p className="text-type-dimmed font-medium">
|
||||||
{t("settings.sidebar.info.appVersion")}
|
{t("settings.sidebar.info.appVersion")}
|
||||||
</p>
|
</p>
|
||||||
<p className="text-type-dimmed px-2 py-1 rounded bg-settings-sidebar-badge inline-block">
|
<div>
|
||||||
{conf().APP_VERSION}
|
<GlitchText
|
||||||
</p>
|
text={conf().APP_VERSION}
|
||||||
|
glitchedText="3.2.1"
|
||||||
|
className="text-type-dimmed px-2 py-1 rounded bg-settings-sidebar-badge inline-block"
|
||||||
|
groupHover
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
|
|
||||||
{/* Backend version */}
|
{/* Backend version */}
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { useOnlineListener } from "@/hooks/usePing";
|
||||||
import { AboutPage } from "@/pages/About";
|
import { AboutPage } from "@/pages/About";
|
||||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||||
import { Discover } from "@/pages/discover/Discover";
|
|
||||||
import { DmcaPage } from "@/pages/Dmca";
|
import { DmcaPage } from "@/pages/Dmca";
|
||||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
|
|
@ -166,8 +165,6 @@ function App() {
|
||||||
{/* Support page */}
|
{/* Support page */}
|
||||||
<Route path="/support" element={<SupportPage />} />
|
<Route path="/support" element={<SupportPage />} />
|
||||||
<Route path="/jip" element={<JipPage />} />
|
<Route path="/jip" element={<JipPage />} />
|
||||||
{/* Discover page */}
|
|
||||||
<Route path="/discover" element={<Discover />} />
|
|
||||||
{/* Settings page */}
|
{/* Settings page */}
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
|
|
|
||||||
|
|
@ -15,7 +15,6 @@ import { useOnlineListener } from "@/hooks/usePing";
|
||||||
import { AboutPage } from "@/pages/About";
|
import { AboutPage } from "@/pages/About";
|
||||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||||
import { Discover } from "@/pages/discover/Discover";
|
|
||||||
import { DmcaPage } from "@/pages/Dmca";
|
import { DmcaPage } from "@/pages/Dmca";
|
||||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||||
|
|
@ -148,8 +147,6 @@ function App() {
|
||||||
<Route path="/dmca" element={<DmcaPage />} />
|
<Route path="/dmca" element={<DmcaPage />} />
|
||||||
{/* Support page */}
|
{/* Support page */}
|
||||||
<Route path="/support" element={<SupportPage />} />
|
<Route path="/support" element={<SupportPage />} />
|
||||||
{/* Discover page */}
|
|
||||||
<Route path="/discover" element={<Discover />} />
|
|
||||||
{/* Settings page */}
|
{/* Settings page */}
|
||||||
<Route
|
<Route
|
||||||
path="/settings"
|
path="/settings"
|
||||||
|
|
|
||||||
|
|
@ -9,6 +9,30 @@ const config: Config = {
|
||||||
safelist: safeThemeList,
|
safelist: safeThemeList,
|
||||||
theme: {
|
theme: {
|
||||||
extend: {
|
extend: {
|
||||||
|
/* colors */
|
||||||
|
colors: {
|
||||||
|
"bink-100": "#432449",
|
||||||
|
"bink-200": "#412B57",
|
||||||
|
"bink-300": "#533670",
|
||||||
|
"bink-400": "#714C97",
|
||||||
|
"bink-500": "#8D66B5",
|
||||||
|
"bink-600": "#A87FD1",
|
||||||
|
"bink-700": "#CD97D6",
|
||||||
|
"denim-100": "#120F1D",
|
||||||
|
"denim-200": "#191526",
|
||||||
|
"denim-300": "#211D30",
|
||||||
|
"denim-400": "#2B263D",
|
||||||
|
"denim-500": "#38334A",
|
||||||
|
"denim-600": "#504B64",
|
||||||
|
"denim-700": "#7A758F",
|
||||||
|
"ash-600": "#817998",
|
||||||
|
"ash-500": "#9C93B5",
|
||||||
|
"ash-400": "#3D394D",
|
||||||
|
"ash-300": "#2C293A",
|
||||||
|
"ash-200": "#2B2836",
|
||||||
|
"ash-100": "#1E1C26"
|
||||||
|
},
|
||||||
|
|
||||||
/* breakpoints */
|
/* breakpoints */
|
||||||
screens: {
|
screens: {
|
||||||
xs: "350px",
|
xs: "350px",
|
||||||
|
|
@ -21,6 +45,7 @@ const config: Config = {
|
||||||
/* fonts */
|
/* fonts */
|
||||||
fontFamily: {
|
fontFamily: {
|
||||||
"main": "'DM Sans'", // "main": "'Open Sans'",
|
"main": "'DM Sans'", // "main": "'Open Sans'",
|
||||||
|
"open-sans": "'Open Sans'"
|
||||||
},
|
},
|
||||||
|
|
||||||
/* animations */
|
/* animations */
|
||||||
|
|
@ -37,14 +62,6 @@ const config: Config = {
|
||||||
require("tailwind-scrollbar"),
|
require("tailwind-scrollbar"),
|
||||||
themer({
|
themer({
|
||||||
defaultTheme: defaultTheme,
|
defaultTheme: defaultTheme,
|
||||||
themes: [
|
|
||||||
{
|
|
||||||
name: "default",
|
|
||||||
selectors: [".theme-default"],
|
|
||||||
...defaultTheme,
|
|
||||||
},
|
|
||||||
...allThemes,
|
|
||||||
],
|
|
||||||
}),
|
}),
|
||||||
plugin(({ addVariant }) => {
|
plugin(({ addVariant }) => {
|
||||||
addVariant("dir-neutral", "[dir] &");
|
addVariant("dir-neutral", "[dir] &");
|
||||||
|
|
|
||||||
|
|
@ -1,19 +1,5 @@
|
||||||
import teal from "./list/teal";
|
|
||||||
import blue from "./list/blue";
|
|
||||||
import red from "./list/red";
|
|
||||||
import gray from "./list/gray";
|
|
||||||
import classic from "./list/classic";
|
import classic from "./list/classic";
|
||||||
import green from "./list/green";
|
|
||||||
import mocha from "./list/mocha";
|
|
||||||
import pink from "./list/pink";
|
|
||||||
|
|
||||||
export const allThemes = [
|
export const allThemes = [
|
||||||
teal,
|
classic
|
||||||
blue,
|
|
||||||
gray,
|
|
||||||
red,
|
|
||||||
classic,
|
|
||||||
green,
|
|
||||||
mocha,
|
|
||||||
pink
|
|
||||||
]
|
]
|
||||||
|
|
|
||||||
|
|
@ -1,348 +1,292 @@
|
||||||
const tokens = {
|
const tokens = {
|
||||||
black: {
|
black: "#000000",
|
||||||
c50: "#000000",
|
white: "#FFFFFF",
|
||||||
c75: "#030303",
|
|
||||||
c80: "#080808",
|
|
||||||
c100: "#0d0d0d",
|
|
||||||
c125: "#141414",
|
|
||||||
c150: "#1a1a1a",
|
|
||||||
c200: "#262626",
|
|
||||||
c250: "#333333"
|
|
||||||
},
|
|
||||||
white: "#FFFFFF", // General white color
|
|
||||||
semantic: {
|
semantic: {
|
||||||
red: {
|
red: {
|
||||||
c100: "#F46E6E", // Error text
|
c100: "#CD97D6", // Using bink-700 for errors since we don't have red
|
||||||
c200: "#E44F4F", // Video player scraping error
|
c200: "#A87FD1", // Using bink-600 for error states
|
||||||
c300: "#D74747", // Danger button
|
c300: "#8D66B5", // Using bink-500 for danger buttons
|
||||||
c400: "#B43434", // Not currently used
|
|
||||||
},
|
},
|
||||||
green: {
|
green: {
|
||||||
c100: "#60D26A", // Success text
|
c100: "#A87FD1", // Using bink-600 for success states
|
||||||
c200: "#40B44B", // Video player scraping success
|
c200: "#8D66B5", // Using bink-500 for success indicators
|
||||||
c300: "#31A33C", // Not currently used
|
c300: "#714C97", // Using bink-400 for success buttons
|
||||||
c400: "#237A2B", // Not currently used
|
|
||||||
},
|
},
|
||||||
silver: {
|
silver: {
|
||||||
c100: "#DEDEDE", // Primary button hover
|
c100: "#7A758F", // Using denim-700 for hover states
|
||||||
c200: "#B6CAD7", // Not currently used
|
c300: "#504B64", // Using denim-600 for secondary text
|
||||||
c300: "#8EA3B0", // Secondary button text
|
c400: "#38334A", // Using denim-500 for dimmed text
|
||||||
c400: "#617A8A", // Main text in video player context
|
}
|
||||||
},
|
|
||||||
yellow: {
|
|
||||||
c100: "#FFF599", // Best onboarding highlight
|
|
||||||
c200: "#FCEC61", // Dropdown highlight hover
|
|
||||||
c300: "#D8C947", // Not currently used
|
|
||||||
c400: "#AFA349", // Dropdown highlight
|
|
||||||
},
|
|
||||||
rose: {
|
|
||||||
c100: "#DB3D61", // Authentication error text
|
|
||||||
c200: "#8A293B", // Danger button hover
|
|
||||||
c300: "#812435", // Danger button
|
|
||||||
c400: "#701B2B", // Not currently used
|
|
||||||
},
|
|
||||||
},
|
},
|
||||||
blue: {
|
// Simplified color palette using new theme colors
|
||||||
c50: "#ccccd6",
|
primary: {
|
||||||
c100: "#a2a2a2",
|
c100: "#CD97D6", // bink-700
|
||||||
c200: "#868686",
|
c200: "#A87FD1", // bink-600
|
||||||
c300: "#646464",
|
c300: "#8D66B5", // bink-500
|
||||||
c400: "#4e4e4e",
|
c400: "#714C97", // bink-400
|
||||||
c500: "#383838",
|
c500: "#533670", // bink-300
|
||||||
c600: "#2e2e2e",
|
c600: "#412B57", // bink-200
|
||||||
c700: "#272727",
|
c700: "#432449", // bink-100
|
||||||
c800: "#181818",
|
|
||||||
c900: "#0f0f0f"
|
|
||||||
},
|
},
|
||||||
purple: {
|
background: {
|
||||||
c50: "#aaafff",
|
c100: "#7A758F", // denim-700
|
||||||
c100: "#8288fe",
|
c200: "#504B64", // denim-600
|
||||||
c200: "#5a62eb",
|
c300: "#38334A", // denim-500
|
||||||
c300: "#454cd4",
|
c400: "#2B263D", // denim-400
|
||||||
c400: "#333abe",
|
c500: "#211D30", // denim-300
|
||||||
c500: "#292d86",
|
c600: "#191526", // denim-200
|
||||||
c600: "#1f2363",
|
c700: "#120F1D", // denim-100
|
||||||
c700: "#191b4a",
|
|
||||||
c800: "#111334", // Lightbar
|
|
||||||
c900: "#0b0d22"
|
|
||||||
},
|
},
|
||||||
ash: {
|
ash: {
|
||||||
c50: "#8d8d8d",
|
c100: "#1E1C26", // ash-100
|
||||||
c100: "#6b6b6b",
|
c200: "#2B2836", // ash-200
|
||||||
c200: "#545454",
|
c300: "#2C293A", // ash-300
|
||||||
c300: "#3c3c3c",
|
c400: "#3D394D", // ash-400
|
||||||
c400: "#313131",
|
c500: "#9C93B5", // ash-500
|
||||||
c500: "#2c2c2c",
|
c600: "#817998", // ash-600
|
||||||
c600: "#252525",
|
}
|
||||||
c700: "#1e1e1e",
|
}
|
||||||
c800: "#181818",
|
|
||||||
c900: "#111111"
|
|
||||||
},
|
|
||||||
shade: {
|
|
||||||
c25: "#939393", // Media card hover accent
|
|
||||||
c50: "#7c7c7c",
|
|
||||||
c100: "#666666",
|
|
||||||
c200: "#4f4f4f",
|
|
||||||
c300: "#404040",
|
|
||||||
c400: "#343434",
|
|
||||||
c500: "#282828",
|
|
||||||
c600: "#202020",
|
|
||||||
c700: "#1a1a1a",
|
|
||||||
c800: "#151515",
|
|
||||||
c900: "#0e0e0e"
|
|
||||||
},
|
|
||||||
};
|
|
||||||
|
|
||||||
export const defaultTheme = {
|
export const defaultTheme = {
|
||||||
extend: {
|
extend: {
|
||||||
colors: {
|
colors: {
|
||||||
themePreview: {
|
themePreview: {
|
||||||
primary: tokens.black.c80,
|
primary: tokens.primary.c300,
|
||||||
secondary: tokens.black.c100,
|
secondary: tokens.background.c200,
|
||||||
ghost: tokens.white,
|
ghost: tokens.white,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Branding
|
// Branding
|
||||||
pill: {
|
pill: {
|
||||||
background: tokens.black.c100,
|
background: tokens.background.c400,
|
||||||
backgroundHover: tokens.black.c125,
|
backgroundHover: tokens.background.c300,
|
||||||
highlight: tokens.blue.c200,
|
highlight: tokens.primary.c300,
|
||||||
activeBackground: tokens.shade.c700,
|
activeBackground: tokens.background.c400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// meta data for the theme itself
|
|
||||||
global: {
|
global: {
|
||||||
accentA: tokens.blue.c200,
|
accentA: tokens.primary.c300,
|
||||||
accentB: tokens.blue.c300,
|
accentB: tokens.primary.c400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// light bar
|
|
||||||
lightBar: {
|
lightBar: {
|
||||||
light: tokens.purple.c800,
|
light: tokens.primary.c400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Buttons
|
|
||||||
buttons: {
|
buttons: {
|
||||||
toggle: tokens.purple.c300,
|
toggle: tokens.primary.c300,
|
||||||
toggleDisabled: tokens.black.c200,
|
toggleDisabled: tokens.background.c400,
|
||||||
danger: tokens.semantic.rose.c300,
|
danger: tokens.semantic.red.c300,
|
||||||
dangerHover: tokens.semantic.rose.c200,
|
dangerHover: tokens.semantic.red.c200,
|
||||||
|
secondary: tokens.background.c500,
|
||||||
secondary: tokens.black.c100,
|
|
||||||
secondaryText: tokens.semantic.silver.c300,
|
secondaryText: tokens.semantic.silver.c300,
|
||||||
secondaryHover: tokens.black.c150,
|
secondaryHover: tokens.background.c400,
|
||||||
primary: tokens.white,
|
primary: tokens.white,
|
||||||
primaryText: tokens.black.c50,
|
primaryText: tokens.black,
|
||||||
primaryHover: tokens.semantic.silver.c100,
|
primaryHover: tokens.semantic.silver.c100,
|
||||||
purple: tokens.purple.c600,
|
cancel: tokens.background.c400,
|
||||||
purpleHover: tokens.purple.c400,
|
cancelHover: tokens.background.c300,
|
||||||
cancel: tokens.black.c100,
|
|
||||||
cancelHover: tokens.black.c150
|
|
||||||
},
|
},
|
||||||
|
|
||||||
// only used for body colors/textures
|
|
||||||
background: {
|
background: {
|
||||||
main: tokens.black.c75,
|
main: tokens.background.c700,
|
||||||
secondary: tokens.black.c75,
|
secondary: tokens.background.c600,
|
||||||
secondaryHover: tokens.black.c75,
|
secondaryHover: tokens.background.c500,
|
||||||
accentA: tokens.purple.c600,
|
accentA: tokens.primary.c600,
|
||||||
accentB: tokens.black.c100
|
accentB: tokens.primary.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Modals
|
|
||||||
modal: {
|
modal: {
|
||||||
background: tokens.shade.c800,
|
background: tokens.background.c600,
|
||||||
},
|
},
|
||||||
|
|
||||||
// typography
|
|
||||||
type: {
|
type: {
|
||||||
logo: tokens.purple.c100,
|
logo: tokens.primary.c200,
|
||||||
emphasis: tokens.white,
|
emphasis: tokens.white,
|
||||||
text: tokens.shade.c50,
|
text: tokens.background.c100,
|
||||||
dimmed: tokens.shade.c50,
|
dimmed: tokens.background.c200,
|
||||||
divider: tokens.ash.c500,
|
divider: tokens.background.c400,
|
||||||
secondary: tokens.ash.c100,
|
secondary: tokens.semantic.silver.c300,
|
||||||
danger: tokens.semantic.red.c100,
|
danger: tokens.semantic.red.c100,
|
||||||
success: tokens.semantic.green.c100,
|
success: tokens.semantic.green.c100,
|
||||||
link: tokens.purple.c100,
|
link: tokens.primary.c200,
|
||||||
linkHover: tokens.purple.c50
|
linkHover: tokens.primary.c100,
|
||||||
},
|
},
|
||||||
|
|
||||||
// search bar
|
// search bar
|
||||||
search: {
|
search: {
|
||||||
background: tokens.black.c100,
|
background: tokens.background.c500,
|
||||||
hoverBackground: tokens.shade.c900,
|
hoverBackground: tokens.background.c600,
|
||||||
focused: tokens.black.c125,
|
focused: tokens.background.c400,
|
||||||
placeholder: tokens.shade.c200,
|
placeholder: tokens.background.c100,
|
||||||
icon: tokens.shade.c500,
|
icon: tokens.background.c100,
|
||||||
text: tokens.white,
|
text: tokens.white,
|
||||||
},
|
},
|
||||||
|
|
||||||
// media cards
|
// media cards
|
||||||
mediaCard: {
|
mediaCard: {
|
||||||
hoverBackground: tokens.shade.c900,
|
hoverBackground: tokens.background.c600,
|
||||||
hoverAccent: tokens.black.c250,
|
hoverAccent: tokens.primary.c100,
|
||||||
hoverShadow: tokens.black.c50,
|
hoverShadow: tokens.background.c700,
|
||||||
shadow: tokens.shade.c800,
|
shadow: tokens.background.c500,
|
||||||
barColor: tokens.ash.c200,
|
barColor: tokens.ash.c200,
|
||||||
barFillColor: tokens.purple.c100,
|
barFillColor: tokens.primary.c200,
|
||||||
badge: tokens.shade.c700,
|
badge: tokens.background.c500,
|
||||||
badgeText: tokens.ash.c100
|
badgeText: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Large card
|
// Large card
|
||||||
largeCard: {
|
largeCard: {
|
||||||
background: tokens.black.c100,
|
background: tokens.background.c600,
|
||||||
icon: tokens.purple.c400,
|
icon: tokens.primary.c400,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Dropdown
|
// Dropdown
|
||||||
dropdown: {
|
dropdown: {
|
||||||
background: tokens.black.c100,
|
background: tokens.background.c600,
|
||||||
altBackground: tokens.black.c80,
|
altBackground: tokens.background.c500,
|
||||||
hoverBackground: tokens.black.c150,
|
hoverBackground: tokens.background.c400,
|
||||||
highlight: tokens.semantic.yellow.c400,
|
highlight: tokens.primary.c300,
|
||||||
highlightHover: tokens.semantic.yellow.c200,
|
highlightHover: tokens.primary.c200,
|
||||||
text: tokens.shade.c50,
|
text: tokens.background.c100,
|
||||||
secondary: tokens.shade.c100,
|
secondary: tokens.background.c200,
|
||||||
border: tokens.shade.c400,
|
border: tokens.background.c400,
|
||||||
contentBackground: tokens.black.c50
|
contentBackground: tokens.background.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Passphrase
|
// Passphrase
|
||||||
authentication: {
|
authentication: {
|
||||||
border: tokens.shade.c300,
|
border: tokens.background.c400,
|
||||||
inputBg: tokens.black.c100,
|
inputBg: tokens.background.c600,
|
||||||
inputBgHover: tokens.black.c150,
|
inputBgHover: tokens.background.c500,
|
||||||
wordBackground: tokens.shade.c500,
|
wordBackground: tokens.background.c500,
|
||||||
copyText: tokens.shade.c100,
|
copyText: tokens.background.c200,
|
||||||
copyTextHover: tokens.ash.c50,
|
copyTextHover: tokens.ash.c500,
|
||||||
errorText: tokens.semantic.rose.c100,
|
errorText: tokens.semantic.red.c100,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Settings page
|
// Settings page
|
||||||
settings: {
|
settings: {
|
||||||
sidebar: {
|
sidebar: {
|
||||||
activeLink: tokens.black.c100,
|
activeLink: tokens.background.c600,
|
||||||
badge: tokens.shade.c900,
|
badge: tokens.background.c700,
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
secondary: tokens.shade.c200,
|
secondary: tokens.background.c300,
|
||||||
inactive: tokens.shade.c50,
|
inactive: tokens.background.c100,
|
||||||
icon: tokens.black.c200,
|
icon: tokens.background.c100,
|
||||||
iconActivated: tokens.purple.c200,
|
iconActivated: tokens.primary.c200,
|
||||||
activated: tokens.purple.c100
|
activated: tokens.primary.c100,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
card: {
|
card: {
|
||||||
border: tokens.shade.c700,
|
border: tokens.background.c400,
|
||||||
background: tokens.black.c100,
|
background: tokens.background.c400,
|
||||||
altBackground: tokens.black.c100
|
altBackground: tokens.background.c400,
|
||||||
},
|
},
|
||||||
|
|
||||||
saveBar: {
|
saveBar: {
|
||||||
background: tokens.black.c50
|
background: tokens.background.c600,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// Utilities
|
// Utilities
|
||||||
utils: {
|
utils: {
|
||||||
divider: tokens.ash.c300
|
divider: tokens.ash.c300,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Onboarding
|
// Onboarding
|
||||||
onboarding: {
|
onboarding: {
|
||||||
bar: tokens.shade.c400,
|
bar: tokens.background.c400,
|
||||||
barFilled: tokens.purple.c300,
|
barFilled: tokens.primary.c300,
|
||||||
divider: tokens.shade.c200,
|
divider: tokens.background.c300,
|
||||||
card: tokens.shade.c800,
|
card: tokens.background.c600,
|
||||||
cardHover: tokens.shade.c700,
|
cardHover: tokens.background.c500,
|
||||||
border: tokens.shade.c600,
|
border: tokens.background.c600,
|
||||||
good: tokens.purple.c100,
|
good: tokens.primary.c200,
|
||||||
best: tokens.semantic.yellow.c100,
|
best: tokens.primary.c100,
|
||||||
link: tokens.purple.c100,
|
link: tokens.primary.c200,
|
||||||
},
|
},
|
||||||
|
|
||||||
// Error page
|
// Error page
|
||||||
errors: {
|
errors: {
|
||||||
card: tokens.black.c75,
|
card: tokens.background.c600,
|
||||||
border: tokens.ash.c500,
|
border: tokens.ash.c500,
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
secondary: tokens.ash.c100,
|
secondary: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
|
|
||||||
// About page
|
// About page
|
||||||
about: {
|
about: {
|
||||||
circle: tokens.black.c100,
|
circle: tokens.ash.c500,
|
||||||
circleText: tokens.ash.c50
|
circleText: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
// About page
|
|
||||||
editBadge: {
|
editBadge: {
|
||||||
bg: tokens.ash.c500,
|
bg: tokens.ash.c500,
|
||||||
bgHover: tokens.ash.c400,
|
bgHover: tokens.ash.c400,
|
||||||
text: tokens.ash.c50
|
text: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
progress: {
|
progress: {
|
||||||
background: tokens.ash.c50,
|
background: tokens.ash.c500,
|
||||||
preloaded: tokens.ash.c50,
|
preloaded: tokens.ash.c500,
|
||||||
filled: tokens.purple.c200,
|
filled: tokens.primary.c200,
|
||||||
},
|
},
|
||||||
|
|
||||||
// video player
|
// video player
|
||||||
video: {
|
video: {
|
||||||
buttonBackground: tokens.ash.c600,
|
buttonBackground: tokens.ash.c200,
|
||||||
|
|
||||||
autoPlay: {
|
autoPlay: {
|
||||||
background: tokens.ash.c800,
|
background: tokens.ash.c600,
|
||||||
hover: tokens.ash.c600,
|
hover: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
scraping: {
|
scraping: {
|
||||||
card: tokens.black.c50,
|
card: tokens.background.c500,
|
||||||
error: tokens.semantic.red.c200,
|
error: tokens.semantic.red.c200,
|
||||||
success: tokens.semantic.green.c200,
|
success: tokens.semantic.green.c200,
|
||||||
loading: tokens.purple.c200,
|
loading: tokens.primary.c200,
|
||||||
noresult: tokens.black.c200
|
noresult: tokens.ash.c500,
|
||||||
},
|
},
|
||||||
|
|
||||||
audio: {
|
audio: {
|
||||||
set: tokens.purple.c200,
|
set: tokens.primary.c200,
|
||||||
},
|
},
|
||||||
|
|
||||||
context: {
|
context: {
|
||||||
background: tokens.black.c50,
|
background: tokens.background.c600,
|
||||||
light: tokens.shade.c50,
|
light: tokens.background.c400,
|
||||||
border: tokens.ash.c600,
|
border: tokens.background.c500,
|
||||||
hoverColor: tokens.ash.c600,
|
hoverColor: tokens.background.c500,
|
||||||
buttonFocus: tokens.ash.c500,
|
buttonFocus: tokens.background.c400,
|
||||||
flagBg: tokens.ash.c500,
|
flagBg: tokens.background.c400,
|
||||||
inputBg: tokens.black.c100,
|
inputBg: tokens.background.c500,
|
||||||
buttonOverInputHover: tokens.ash.c500,
|
buttonOverInputHover: tokens.background.c400,
|
||||||
inputPlaceholder: tokens.ash.c200,
|
inputPlaceholder: tokens.background.c200,
|
||||||
cardBorder: tokens.ash.c700,
|
cardBorder: tokens.background.c600,
|
||||||
slider: tokens.black.c200,
|
slider: tokens.background.c100,
|
||||||
sliderFilled: tokens.purple.c200,
|
sliderFilled: tokens.primary.c300,
|
||||||
error: tokens.semantic.red.c200,
|
error: tokens.semantic.red.c200,
|
||||||
|
|
||||||
buttons: {
|
buttons: {
|
||||||
list: tokens.ash.c700,
|
list: tokens.background.c600,
|
||||||
active: tokens.ash.c900,
|
active: tokens.background.c600,
|
||||||
},
|
},
|
||||||
|
|
||||||
closeHover: tokens.ash.c800,
|
closeHover: tokens.background.c500,
|
||||||
|
|
||||||
type: {
|
type: {
|
||||||
main: tokens.semantic.silver.c300,
|
main: tokens.white,
|
||||||
secondary: tokens.ash.c200,
|
secondary: tokens.background.c200,
|
||||||
accent: tokens.purple.c200,
|
accent: tokens.primary.c300,
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
},
|
},
|
||||||
};
|
}
|
||||||
|
|
|
||||||
Loading…
Reference in a new issue