mirror of
https://github.com/p-stream/p-stream.git
synced 2026-01-11 20:10:32 +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;
|
||||
icon: Icons;
|
||||
transparent?: boolean;
|
||||
downsized?: boolean;
|
||||
}
|
||||
|
||||
export function IconPatch(props: IconPatchProps) {
|
||||
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
|
||||
? "bg-opacity-0 hover:bg-opacity-50"
|
||||
: "";
|
||||
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 (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<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} />
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -1,7 +1,5 @@
|
|||
import c from "classnames";
|
||||
import { forwardRef, useRef, useState } from "react";
|
||||
|
||||
import { Flare } from "@/components/utils/Flare";
|
||||
import { forwardRef, useState } from "react";
|
||||
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { TextInputControl } from "../text-inputs/TextInputControl";
|
||||
|
|
@ -16,103 +14,50 @@ export interface SearchBarProps {
|
|||
export const SearchBarInput = forwardRef<HTMLInputElement, SearchBarProps>(
|
||||
(props, ref) => {
|
||||
const [focused, setFocused] = useState(false);
|
||||
const containerRef = useRef<HTMLDivElement>(null);
|
||||
const [showTooltip, setShowTooltip] = useState(false);
|
||||
|
||||
function setSearch(value: string) {
|
||||
props.onChange(value, true);
|
||||
}
|
||||
|
||||
return (
|
||||
<div ref={containerRef}>
|
||||
<Flare.Base
|
||||
className={c({
|
||||
"hover:flare-enabled group flex flex-col rounded-[28px] transition-colors sm:flex-row sm:items-center relative":
|
||||
true,
|
||||
"bg-search-background": !focused,
|
||||
"bg-search-focused": focused,
|
||||
})}
|
||||
>
|
||||
<Flare.Light
|
||||
flareSize={400}
|
||||
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>
|
||||
<div
|
||||
className={c(
|
||||
"relative flex flex-col rounded-[28px] transition-colors bg-denim-400",
|
||||
{
|
||||
"hover:bg-denim-500": true,
|
||||
},
|
||||
)}
|
||||
>
|
||||
<div className="pointer-events-none absolute bottom-0 left-5 top-0 flex max-h-14 items-center denim-700">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
||||
<TextInputControl
|
||||
ref={ref}
|
||||
onUnFocus={() => {
|
||||
setFocused(false);
|
||||
props.onUnFocus();
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(val) => setSearch(val)}
|
||||
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"
|
||||
placeholder={props.placeholder}
|
||||
/>
|
||||
<TextInputControl
|
||||
ref={ref}
|
||||
onUnFocus={() => {
|
||||
setFocused(false);
|
||||
props.onUnFocus();
|
||||
}}
|
||||
onFocus={() => setFocused(true)}
|
||||
onChange={(val) => setSearch(val)}
|
||||
value={props.value}
|
||||
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}
|
||||
/>
|
||||
|
||||
{showTooltip && (
|
||||
<div className="py-4">
|
||||
<p className="font-bold text-sm mb-1 text-search-text">
|
||||
Advanced Search:
|
||||
</p>
|
||||
<div className="space-y-1.5 text-xs text-search-text">
|
||||
<div>
|
||||
<p className="mb-0.5">Year search:</p>
|
||||
<p className="text-type-secondary italic pl-2">
|
||||
Inception year:2010
|
||||
</p>
|
||||
</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>
|
||||
{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-denim-600 active:scale-110 text-white rounded-full transition-[transform,background-color] duration-200"
|
||||
>
|
||||
<Icon icon={Icons.X} className="transition-colors duration-200" />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
},
|
||||
|
|
|
|||
|
|
@ -1,32 +1,26 @@
|
|||
import classNames from "classnames";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
||||
export function BrandPill(props: {
|
||||
clickable?: boolean;
|
||||
header?: boolean;
|
||||
backgroundClass?: string;
|
||||
hideTextOnMobile?: boolean;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const isMobile = useIsMobile();
|
||||
|
||||
return (
|
||||
<div
|
||||
className={classNames(
|
||||
"flex items-center space-x-2 rounded-full px-4 py-2 text-type-logo",
|
||||
props.backgroundClass ?? "bg-pill-background bg-opacity-50",
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-300 bg-opacity-50 px-4 py-2 text-bink-600 ${
|
||||
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
|
||||
className={[
|
||||
"font-semibold text-white",
|
||||
isMobile && props.header ? "hidden sm:block" : "",
|
||||
props.hideTextOnMobile ? "hidden sm:block" : "",
|
||||
].join(" ")}
|
||||
>
|
||||
{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 }) {
|
||||
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={props.icon ?? Icons.WAND}
|
||||
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 { NoUserAvatar, UserAvatar } from "@/components/Avatar";
|
||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
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 { BrandPill } from "./BrandPill";
|
||||
|
||||
export interface NavigationProps {
|
||||
bg?: boolean;
|
||||
noLightbar?: boolean;
|
||||
doBackground?: boolean;
|
||||
}
|
||||
|
||||
export function Navigation(props: NavigationProps) {
|
||||
const bannerHeight = useBannerSize();
|
||||
const navigate = useNavigate();
|
||||
const { loggedIn } = useAuth();
|
||||
|
||||
const handleClick = (path: To) => {
|
||||
window.scrollTo(0, 0);
|
||||
|
|
@ -30,93 +20,53 @@ export function Navigation(props: NavigationProps) {
|
|||
};
|
||||
|
||||
return (
|
||||
<>
|
||||
{/* lightbar */}
|
||||
{!props.noLightbar ? (
|
||||
<div
|
||||
className="fixed left-0 right-0 top-0 z-20 min-h-[150px] bg-gradient-to-b from-denim-300 via-denim-300 to-transparent sm:from-transparent"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<div className="fixed left-0 right-0 flex items-center justify-between px-7 py-5">
|
||||
<div
|
||||
className="absolute inset-x-0 top-0 flex h-[88px] items-center justify-center"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
className={`${
|
||||
props.bg ? "opacity-100" : "opacity-0"
|
||||
} 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">
|
||||
<Lightbar />
|
||||
<div className="pointer-events-none absolute -bottom-24 h-24 w-full bg-gradient-to-b from-denim-100 to-transparent" />
|
||||
</div>
|
||||
|
||||
<div className="relative flex w-full items-center justify-center sm:w-fit">
|
||||
<div className="mr-auto sm:mr-6">
|
||||
<Link to="/" onClick={() => window.scrollTo(0, 0)}>
|
||||
<BrandPill clickable />
|
||||
</Link>
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{/* backgrounds - these are seperate because of z-index issues */}
|
||||
<div
|
||||
className="top-content fixed z-[20] pointer-events-none left-0 right-0 top-0 min-h-[150px]"
|
||||
style={{
|
||||
top: `${bannerHeight}px`,
|
||||
}}
|
||||
>
|
||||
<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="relative flex items-center gap-4">
|
||||
<a
|
||||
onClick={() => handleClick("/settings")}
|
||||
rel="noreferrer"
|
||||
className="text-xl text-white tabbable rounded-full"
|
||||
>
|
||||
<div className="absolute -bottom-24 h-24 w-full bg-gradient-to-b from-background-main to-transparent" />
|
||||
</div>
|
||||
<IconPatch icon={Icons.GEAR} clickable />
|
||||
</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>
|
||||
|
||||
{/* 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>
|
||||
</>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -14,7 +14,7 @@ export function ProgressRing(props: Props) {
|
|||
viewBox="0 0 100 100"
|
||||
>
|
||||
<circle
|
||||
className={`fill-transparent stroke-type-text stroke-[15] opacity-25 ${
|
||||
className={`fill-transparent stroke-denim-700 stroke-[15] opacity-25 ${
|
||||
props.backingRingClassname ?? ""
|
||||
}`}
|
||||
r={radius}
|
||||
|
|
|
|||
|
|
@ -10,9 +10,7 @@ export function WideContainer(props: WideContainerProps) {
|
|||
return (
|
||||
<div
|
||||
className={`mx-auto max-w-full px-8 ${
|
||||
props.ultraWide
|
||||
? "w-[1300px] xl:w-[18000px] 3xl:w-[2400px] 4xl:w-[2800px]"
|
||||
: "w-[900px] xl:w-[1200px] 3xl:w-[1600px] 4xl:w-[1800px]"
|
||||
props.ultraWide ? "w-[1300px] sm:px-16" : "w-[750px] sm:px-8"
|
||||
} ${props.classNames || ""}`}
|
||||
>
|
||||
{props.children}
|
||||
|
|
|
|||
|
|
@ -1,24 +1,16 @@
|
|||
// I'm sorry this is so confusing 😭
|
||||
|
||||
import classNames from "classnames";
|
||||
import { useCallback, useRef, useState } from "react";
|
||||
import { useCallback } from "react";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { Link } from "react-router-dom";
|
||||
import { useCopyToClipboard } from "react-use";
|
||||
|
||||
import { mediaItemToId } from "@/backend/metadata/tmdb";
|
||||
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 { MediaBookmarkButton } from "./MediaBookmark";
|
||||
import { Button } from "../buttons/Button";
|
||||
import { IconPatch } from "../buttons/IconPatch";
|
||||
import { Icon, Icons } from "../Icon";
|
||||
import { DetailsModal } from "../overlays/DetailsModal";
|
||||
import { useModal } from "../overlays/Modal";
|
||||
import { Icons } from "../Icon";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MediaItem;
|
||||
|
|
@ -32,7 +24,6 @@ export interface MediaCardProps {
|
|||
percentage?: number;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
function checkReleased(media: MediaItem): boolean {
|
||||
|
|
@ -60,43 +51,14 @@ function MediaCardContent({
|
|||
percentage,
|
||||
closable,
|
||||
onClose,
|
||||
overlayVisible,
|
||||
setOverlayVisible,
|
||||
handleMouseEnter,
|
||||
handleMouseLeave,
|
||||
link,
|
||||
isHoveringCard,
|
||||
onShowDetails,
|
||||
}: MediaCardProps & {
|
||||
overlayVisible: boolean;
|
||||
setOverlayVisible: React.Dispatch<React.SetStateAction<boolean>>;
|
||||
handleMouseEnter: () => void;
|
||||
handleMouseLeave: () => void;
|
||||
link: string;
|
||||
isHoveringCard: boolean;
|
||||
}) {
|
||||
}: MediaCardProps) {
|
||||
const { t } = useTranslation();
|
||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||
|
||||
const isReleased = useCallback(() => checkReleased(media), [media]);
|
||||
|
||||
const canLink = linkable && !closable && isReleased();
|
||||
|
||||
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) {
|
||||
dotListContent.push(media.year.toFixed());
|
||||
}
|
||||
|
|
@ -105,311 +67,100 @@ function MediaCardContent({
|
|||
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 (
|
||||
<div
|
||||
className={classNames("media-card-content", { jiggle: closable })}
|
||||
onMouseEnter={handleMouseEnter}
|
||||
onMouseLeave={handleMouseLeave}
|
||||
>
|
||||
<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" : ""
|
||||
<div className={classNames("media-card-content")}>
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-background-main bg-opacity-0 transition-colors duration-100 ${
|
||||
canLink ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
tabIndex={canLink ? 0 : -1}
|
||||
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
|
||||
>
|
||||
<Flare.Light
|
||||
flareSize={300}
|
||||
cssColorVar="--colors-mediaCard-hoverAccent"
|
||||
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"
|
||||
<article
|
||||
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||
canLink ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div
|
||||
className={classNames(
|
||||
"relative mb-4 pb-[150%] w-full overflow-hidden rounded-xl bg-mediaCard-hoverBackground bg-cover bg-center transition-[border-radius] duration-300",
|
||||
{
|
||||
"group-hover:rounded-lg": canLink,
|
||||
"blur-sm": overlayVisible,
|
||||
},
|
||||
)}
|
||||
className={[
|
||||
"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",
|
||||
].join(" ")}
|
||||
style={{
|
||||
backgroundImage: media.poster
|
||||
? overlayVisible
|
||||
? `linear-gradient(rgba(0, 0, 0, 0.2), rgba(0, 0, 0, 0.8)), url(${media.poster})`
|
||||
: `url(${media.poster})`
|
||||
? `url(${media.poster})`
|
||||
: undefined,
|
||||
}}
|
||||
>
|
||||
{!overlayVisible ? (
|
||||
<div>
|
||||
{series ? (
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-mediaCard-badge px-2 py-1 transition-colors",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"text-center text-xs font-bold text-mediaCard-badgeText transition-colors",
|
||||
closable ? "" : "group-hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("media.episodeDisplay", {
|
||||
season: series.season || 1,
|
||||
episode: series.episode,
|
||||
})}
|
||||
</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}
|
||||
{series ? (
|
||||
<div
|
||||
className={[
|
||||
"absolute right-2 top-2 rounded-md bg-denim-200 px-2 py-1 transition-colors",
|
||||
closable ? "" : "group-hover:bg-denim-500",
|
||||
].join(" ")}
|
||||
>
|
||||
<p
|
||||
className={[
|
||||
"text-center text-xs font-bold text-slate-400 transition-colors",
|
||||
closable ? "" : "group-hover:text-white",
|
||||
].join(" ")}
|
||||
>
|
||||
{t("media.episodeDisplay", {
|
||||
season: series.season || 1,
|
||||
episode: series.episode,
|
||||
})}
|
||||
</p>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
{!overlayVisible ? (
|
||||
<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}
|
||||
{percentage !== undefined ? (
|
||||
<>
|
||||
<div
|
||||
className={`absolute inset-0 flex items-center justify-center bg-mediaCard-badge bg-opacity-80 transition-opacity duration-500 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||
canLink ? "group-hover:from-denim-100" : ""
|
||||
}`}
|
||||
>
|
||||
<IconPatch
|
||||
clickable
|
||||
className="text-2xl text-mediaCard-badgeText transition-transform hover:scale-110 duration-500"
|
||||
onClick={() => closable && onClose?.()}
|
||||
icon={Icons.X}
|
||||
/>
|
||||
/>
|
||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||
<div
|
||||
className="absolute inset-y-0 left-0 rounded-full bg-bink-700"
|
||||
style={{
|
||||
width: percentageString,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
</>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
{overlayVisible ? (
|
||||
<div>
|
||||
<div className="absolute inset-0 flex flex-col items-center justify-start gap-y-2 pt-8 md:pt-12">
|
||||
<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={(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
|
||||
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||
}`}
|
||||
>
|
||||
<IconPatch
|
||||
clickable
|
||||
className="text-2xl text-slate-400"
|
||||
onClick={() => closable && onClose?.()}
|
||||
icon={Icons.X}
|
||||
/>
|
||||
</div>
|
||||
) : null}
|
||||
</div>
|
||||
|
||||
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
|
||||
<span>{media.title}</span>
|
||||
</h1>
|
||||
<div className="media-info-container justify-content-center flex flex-wrap">
|
||||
{!overlayVisible ? (
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
) : (
|
||||
<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>
|
||||
<DotList className="text-xs" content={dotListContent} />
|
||||
</article>
|
||||
</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
export function MediaCard(props: MediaCardProps) {
|
||||
const { media, onShowDetails } = 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 content = <MediaCardContent {...props} />;
|
||||
const isReleased = useCallback(
|
||||
() => checkReleased(props.media),
|
||||
[props.media],
|
||||
);
|
||||
|
||||
const canLink = props.linkable && !props.closable && isReleased();
|
||||
|
||||
let link = canLink
|
||||
|
|
@ -425,109 +176,13 @@ export function MediaCard(props: MediaCardProps) {
|
|||
}
|
||||
}
|
||||
|
||||
const handleShowDetails = useCallback(async () => {
|
||||
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>
|
||||
);
|
||||
if (!canLink) return <span>{content}</span>;
|
||||
return (
|
||||
<div className="relative">
|
||||
{!overlayVisible ? (
|
||||
<Link
|
||||
to={link}
|
||||
tabIndex={-1}
|
||||
className={classNames(
|
||||
"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>
|
||||
<Link
|
||||
to={link}
|
||||
className={classNames(props.closable ? "hover:cursor-default" : "")}
|
||||
>
|
||||
{content}
|
||||
</Link>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -7,10 +7,7 @@ interface MediaGridProps {
|
|||
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||
(props, ref) => {
|
||||
return (
|
||||
<div
|
||||
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}
|
||||
>
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ export interface WatchedMediaCardProps {
|
|||
media: MediaItem;
|
||||
closable?: boolean;
|
||||
onClose?: () => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}
|
||||
|
||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||
|
|
@ -47,7 +46,6 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
|||
percentage={percentage}
|
||||
onClose={props.onClose}
|
||||
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) {
|
||||
return (
|
||||
<h1
|
||||
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
className={`text-4xl font-bold text-white max-w-[300px] ${props.className ?? ""}`}
|
||||
>
|
||||
{props.children}
|
||||
</h1>
|
||||
|
|
|
|||
|
|
@ -1,25 +1,17 @@
|
|||
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||
import { useEffect, useState } from "react";
|
||||
import { Helmet } from "react-helmet-async";
|
||||
import { useTranslation } from "react-i18next";
|
||||
import { To, useNavigate } from "react-router-dom";
|
||||
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { DetailsModal } from "@/components/overlays/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { useDebounce } from "@/hooks/useDebounce";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import DiscoverContent from "@/pages/discover/discoverContent";
|
||||
import { HomeLayout } from "@/pages/layouts/HomeLayout";
|
||||
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
|
||||
import { HeroPart } from "@/pages/parts/home/HeroPart";
|
||||
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
|
||||
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
|
||||
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) {
|
||||
const [searching, setSearching] = useState<boolean>(false);
|
||||
|
|
@ -44,52 +36,17 @@ function useSearch(search: string) {
|
|||
|
||||
export function HomePage() {
|
||||
const { t } = useTranslation();
|
||||
const { t: randomT } = useRandomTranslation();
|
||||
const emptyText = randomT(`home.search.empty`);
|
||||
const navigate = useNavigate();
|
||||
const [showBg, setShowBg] = useState<boolean>(false);
|
||||
const searchParams = useSearchQuery();
|
||||
const [search] = searchParams;
|
||||
const s = useSearch(search);
|
||||
const [showBookmarks, setShowBookmarks] = useState(false);
|
||||
const [showWatching, setShowWatching] = useState(false);
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
// 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
|
||||
const [contentRef] = useAutoAnimate<HTMLDivElement>();
|
||||
|
||||
return (
|
||||
<HomeLayout showBg={showBg}>
|
||||
{/* <a
|
||||
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">
|
||||
<div className="relative mb-24">
|
||||
<Helmet>
|
||||
<style type="text/css">{`
|
||||
html, body {
|
||||
|
|
@ -99,144 +56,41 @@ export function HomePage() {
|
|||
<title>{t("global.name")}</title>
|
||||
</Helmet>
|
||||
|
||||
{/* Popup
|
||||
<FancyModal
|
||||
id="notice"
|
||||
title="We're changing our backend server!"
|
||||
oneTime
|
||||
>
|
||||
<div>
|
||||
<p>
|
||||
On <strong>January 8th</strong>, the backend server will change
|
||||
from:
|
||||
</p>
|
||||
<p>
|
||||
<strong>server.vidbinge.com</strong> →{" "}
|
||||
<strong>server.fifthwit.tech</strong>
|
||||
</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>
|
||||
*/}
|
||||
<div className="absolute left-0 top-0 h-[400px] w-full">
|
||||
<div
|
||||
className="absolute -top-52 left-0 right-0 bottom-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(ellipse 80% 8rem, #211D30 100%, transparent 100%)`,
|
||||
}}
|
||||
/>
|
||||
<div
|
||||
className="absolute -top-20 left-0 right-0 bottom-0"
|
||||
style={{
|
||||
backgroundImage: `radial-gradient(ellipse 70% 12rem, #211D30 100%, transparent 100%)`,
|
||||
}}
|
||||
/>
|
||||
</div>
|
||||
|
||||
<HeroPart searchParams={searchParams} setIsSticky={setShowBg} />
|
||||
</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>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -23,7 +23,6 @@ import { useIsMobile } from "@/hooks/useIsMobile";
|
|||
import { useSettingsState } from "@/hooks/useSettingsState";
|
||||
import { AccountActionsPart } from "@/pages/parts/settings/AccountActionsPart";
|
||||
import { AccountEditPart } from "@/pages/parts/settings/AccountEditPart";
|
||||
import { AppearancePart } from "@/pages/parts/settings/AppearancePart";
|
||||
import { CaptionsPart } from "@/pages/parts/settings/CaptionsPart";
|
||||
import { ConnectionsPart } from "@/pages/parts/settings/ConnectionsPart";
|
||||
import { DeviceListPart } from "@/pages/parts/settings/DeviceListPart";
|
||||
|
|
@ -155,7 +154,6 @@ export function SettingsPage() {
|
|||
const setEnableDetailsModal = usePreferencesStore(
|
||||
(s) => s.setEnableDetailsModal,
|
||||
);
|
||||
|
||||
const enableSourceOrder = usePreferencesStore((s) => s.enableSourceOrder);
|
||||
const setEnableSourceOrder = usePreferencesStore(
|
||||
(s) => s.setEnableSourceOrder,
|
||||
|
|
@ -235,13 +233,13 @@ export function SettingsPage() {
|
|||
};
|
||||
}, [setPreviewTheme]);
|
||||
|
||||
const setThemeWithPreview = useCallback(
|
||||
(theme: string) => {
|
||||
state.theme.set(theme === "default" ? null : theme);
|
||||
setPreviewTheme(theme);
|
||||
},
|
||||
[state.theme, setPreviewTheme],
|
||||
);
|
||||
// const setThemeWithPreview = useCallback(
|
||||
// (theme: string) => {
|
||||
// state.theme.set(theme === "default" ? null : theme);
|
||||
// setPreviewTheme(theme);
|
||||
// },
|
||||
// [state.theme, setPreviewTheme],
|
||||
// );
|
||||
|
||||
const saveChanges = useCallback(async () => {
|
||||
if (account && backendUrl) {
|
||||
|
|
@ -375,24 +373,13 @@ export function SettingsPage() {
|
|||
setenableSourceOrder={state.enableSourceOrder.set}
|
||||
/>
|
||||
</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">
|
||||
<CaptionsPart
|
||||
styling={state.subtitleStyling.state}
|
||||
setStyling={state.subtitleStyling.set}
|
||||
/>
|
||||
</div>
|
||||
<div id="settings-connection" className="mt-28">
|
||||
<div id="settings-connection" className="mt-28 mb-10">
|
||||
<ConnectionsPart
|
||||
backendUrl={state.backendUrl.state}
|
||||
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";
|
||||
|
||||
export function HomeLayout(props: {
|
||||
|
|
@ -6,9 +5,9 @@ export function HomeLayout(props: {
|
|||
children: React.ReactNode;
|
||||
}) {
|
||||
return (
|
||||
<FooterView>
|
||||
<div className="flex flex-col min-h-screen pb-20">
|
||||
<Navigation bg={props.showBg} />
|
||||
{props.children}
|
||||
</FooterView>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,11 +1,10 @@
|
|||
import { FooterView } from "@/components/layout/Footer";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
|
||||
export function PageLayout(props: { children: React.ReactNode }) {
|
||||
return (
|
||||
<FooterView>
|
||||
<>
|
||||
<Navigation />
|
||||
{props.children}
|
||||
</FooterView>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -1,6 +1,5 @@
|
|||
import classNames from "classnames";
|
||||
|
||||
import { FooterView } from "@/components/layout/Footer";
|
||||
import { Navigation } from "@/components/layout/Navigation";
|
||||
|
||||
export function BlurEllipsis(props: { positionClass?: string }) {
|
||||
|
|
@ -34,10 +33,8 @@ export function SubPageLayout(props: { children: React.ReactNode }) {
|
|||
>
|
||||
<BlurEllipsis />
|
||||
{/* Main page */}
|
||||
<FooterView>
|
||||
<Navigation doBackground noLightbar />
|
||||
<div className="mt-40 relative">{props.children}</div>
|
||||
</FooterView>
|
||||
<Navigation />
|
||||
<div className="mt-40 relative">{props.children}</div>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -65,7 +65,7 @@ export function LoginFormPart(props: LoginFormPartProps) {
|
|||
);
|
||||
|
||||
return (
|
||||
<LargeCard top={<BrandPill backgroundClass="bg-[#161527]" />}>
|
||||
<LargeCard top={<BrandPill />}>
|
||||
<LargeCardText title={t("auth.login.title")}>
|
||||
{t("auth.login.description")}
|
||||
</LargeCardText>
|
||||
|
|
|
|||
|
|
@ -15,10 +15,8 @@ const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
|||
|
||||
export function BookmarksPart({
|
||||
onItemsChange,
|
||||
onShowDetails,
|
||||
}: {
|
||||
onItemsChange: (hasItems: boolean) => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}) {
|
||||
const { t } = useTranslation();
|
||||
const progressItems = useProgressStore((s) => s.items);
|
||||
|
|
@ -118,7 +116,6 @@ export function BookmarksPart({
|
|||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeBookmark(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -7,7 +7,6 @@ import { Icon, Icons } from "@/components/Icon";
|
|||
import { ThinContainer } from "@/components/layout/ThinContainer";
|
||||
import { useSlashFocus } from "@/components/player/hooks/useSlashFocus";
|
||||
import { HeroTitle } from "@/components/text/HeroTitle";
|
||||
import { useIsTV } from "@/hooks/useIsTv";
|
||||
import { useRandomTranslation } from "@/hooks/useRandomTranslation";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { conf } from "@/setup/config";
|
||||
|
|
@ -65,8 +64,6 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
);
|
||||
const { width: windowWidth, height: windowHeight } = useWindowSize();
|
||||
|
||||
const { isTV } = useIsTV();
|
||||
|
||||
// Detect if running as a PWA on iOS
|
||||
const isIOSPWA =
|
||||
/iPad|iPhone|iPod/i.test(navigator.userAgent) &&
|
||||
|
|
@ -105,9 +102,9 @@ export function HeroPart({ setIsSticky, searchParams }: HeroPartProps) {
|
|||
<ThinContainer>
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="relative z-10 mb-16">
|
||||
{isTV && search.length > 0 ? null : (
|
||||
<HeroTitle className="mx-auto max-w-md">{title}</HeroTitle>
|
||||
)}
|
||||
<HeroTitle className="mx-auto max-w-md">
|
||||
What do you want to watch?
|
||||
</HeroTitle>
|
||||
</div>
|
||||
<div className="relative h-20 z-30">
|
||||
<Sticky
|
||||
|
|
|
|||
|
|
@ -13,13 +13,12 @@ import { MediaItem } from "@/utils/mediaTypes";
|
|||
|
||||
const LONG_PRESS_DURATION = 500; // 0.5 seconds
|
||||
|
||||
export function WatchingPart({
|
||||
onItemsChange,
|
||||
onShowDetails,
|
||||
}: {
|
||||
interface WatchingPartProps {
|
||||
onItemsChange: (hasItems: boolean) => void;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}) {
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function WatchingPart({ onItemsChange, className }: WatchingPartProps) {
|
||||
const { t } = useTranslation();
|
||||
const progressItems = useProgressStore((s) => s.items);
|
||||
const removeItem = useProgressStore((s) => s.removeItem);
|
||||
|
|
@ -82,7 +81,7 @@ export function WatchingPart({
|
|||
if (sortedProgressItems.length === 0) return null;
|
||||
|
||||
return (
|
||||
<div className="relative">
|
||||
<div className={`relative ${className}`}>
|
||||
<SectionHeading
|
||||
title={t("home.continueWatching.sectionTitle")}
|
||||
icon={Icons.CLOCK}
|
||||
|
|
@ -96,21 +95,20 @@ export function WatchingPart({
|
|||
<MediaGrid ref={gridRef}>
|
||||
{sortedProgressItems.map((v) => (
|
||||
<div
|
||||
key={v.id}
|
||||
style={{ userSelect: "none" }}
|
||||
style={{ userSelect: "none" }} // Disable text selection
|
||||
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
|
||||
e.preventDefault()
|
||||
}
|
||||
onTouchStart={handleTouchStart}
|
||||
onTouchEnd={handleTouchEnd}
|
||||
onMouseDown={handleMouseDown}
|
||||
onMouseUp={handleMouseUp}
|
||||
} // Prevent right-click context menu
|
||||
onTouchStart={handleTouchStart} // Handle touch start
|
||||
onTouchEnd={handleTouchEnd} // Handle touch end
|
||||
onMouseDown={handleMouseDown} // Handle mouse down
|
||||
onMouseUp={handleMouseUp} // Handle mouse up
|
||||
>
|
||||
<WatchedMediaCard
|
||||
key={v.id}
|
||||
media={v}
|
||||
closable={editing}
|
||||
onClose={() => removeItem(v.id)}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
|
|
|
|||
|
|
@ -4,7 +4,6 @@ import IosPwaLimitations from "@/components/buttons/IosPwaLimitations";
|
|||
import { BrandPill } from "@/components/layout/BrandPill";
|
||||
import { Player } from "@/components/player";
|
||||
import { SkipIntroButton } from "@/components/player/atoms/SkipIntroButton";
|
||||
import { UnreleasedEpisodeOverlay } from "@/components/player/atoms/UnreleasedEpisodeOverlay";
|
||||
import { Widescreen } from "@/components/player/atoms/Widescreen";
|
||||
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
|
||||
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 { usePlayerStore } from "@/stores/player/store";
|
||||
|
||||
import { ScrapingPartInterruptButton, Tips } from "./ScrapingPart";
|
||||
import { Tips } from "./ScrapingPart";
|
||||
|
||||
export interface PlayerPartProps {
|
||||
children?: ReactNode;
|
||||
|
|
@ -89,8 +88,6 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
<span className="text mx-3 text-type-secondary">/</span>
|
||||
<Player.Title />
|
||||
|
||||
<Player.InfoButton />
|
||||
|
||||
<Player.BookmarkButton />
|
||||
</div>
|
||||
<div className="text-center hidden xl:flex justify-center items-center">
|
||||
|
|
@ -112,10 +109,7 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
|
||||
<Player.BottomControls show={showTargets}>
|
||||
{status === playerStatus.PLAYING ? null : <Tips />}
|
||||
<div className="flex items-center justify-center space-x-3 h-full">
|
||||
{status === playerStatus.SCRAPING ? (
|
||||
<ScrapingPartInterruptButton />
|
||||
) : null}
|
||||
<div className="flex items-center space-x-3 w-full">
|
||||
{status === playerStatus.PLAYING ? (
|
||||
<>
|
||||
{isMobile ? <Player.Time short /> : null}
|
||||
|
|
@ -123,18 +117,19 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
</>
|
||||
) : null}
|
||||
</div>
|
||||
<div className="hidden lg:flex justify-between" dir="ltr">
|
||||
<div className="hidden lg:flex items-center w-full">
|
||||
<Player.LeftSideControls>
|
||||
{status === playerStatus.PLAYING ? (
|
||||
<>
|
||||
<div className="flex items-center px-2">
|
||||
<Player.Pause />
|
||||
<Player.SkipBackward />
|
||||
<Player.SkipForward />
|
||||
<Player.Volume />
|
||||
<Player.Volume className="mr-2" />
|
||||
<Player.Time />
|
||||
</>
|
||||
</div>
|
||||
) : null}
|
||||
</Player.LeftSideControls>
|
||||
<div className="flex-1" />
|
||||
<div className="flex items-center space-x-3">
|
||||
<Player.Episodes />
|
||||
{status === playerStatus.PLAYING ? (
|
||||
|
|
@ -144,15 +139,10 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
<Player.Chromecast />
|
||||
</>
|
||||
) : null}
|
||||
{status === playerStatus.PLAYBACK_ERROR ||
|
||||
status === playerStatus.PLAYING ? (
|
||||
<Player.Captions />
|
||||
) : null}
|
||||
{(status === playerStatus.PLAYBACK_ERROR ||
|
||||
status === playerStatus.PLAYING) && <Player.Captions />}
|
||||
<Player.Settings />
|
||||
{/* Fullscreen on when not shifting */}
|
||||
{!isShifting && <Player.Fullscreen />}
|
||||
|
||||
{/* Expand button visible when shifting */}
|
||||
{isShifting && (
|
||||
<div>
|
||||
<Widescreen />
|
||||
|
|
@ -160,31 +150,31 @@ export function PlayerPart(props: PlayerPartProps) {
|
|||
)}
|
||||
</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 className="flex justify-center space-x-3">
|
||||
{/* Disable PiP for iOS PWA */}
|
||||
{!isIOSPWA && status === playerStatus.PLAYING && <Player.Pip />}
|
||||
<Player.Episodes />
|
||||
{status === playerStatus.PLAYING ? (
|
||||
<div className="hidden ssm:block">
|
||||
<Player.Captions />
|
||||
</div>
|
||||
) : null}
|
||||
<Player.Settings />
|
||||
{isIOSPWA && <IosPwaLimitations />}
|
||||
{isIOSPWA && status === playerStatus.PLAYING && <Widescreen />}
|
||||
</div>
|
||||
<div>
|
||||
{/* iOS PWA */}
|
||||
{!isIOSPWA && <Player.Fullscreen />}
|
||||
{isIOSPWA && status === playerStatus.PLAYING && <Widescreen />}
|
||||
{!isIOSPWA && (
|
||||
<div>
|
||||
<Player.Fullscreen />
|
||||
</div>
|
||||
)}
|
||||
{isIOSPWA && (
|
||||
<div>
|
||||
<IosPwaLimitations />
|
||||
</div>
|
||||
)}
|
||||
</div>
|
||||
</div>
|
||||
</Player.BottomControls>
|
||||
|
||||
<Player.VolumeChangedPopout />
|
||||
<Player.SubtitleDelayPopout />
|
||||
<UnreleasedEpisodeOverlay />
|
||||
|
||||
<Player.NextEpisodeButton
|
||||
controlsShowing={showTargets}
|
||||
|
|
|
|||
|
|
@ -9,16 +9,12 @@ import {
|
|||
scrapePartsToProviderMetric,
|
||||
useReportProviders,
|
||||
} from "@/backend/helpers/report";
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { Loading } from "@/components/layout/Loading";
|
||||
import {
|
||||
ScrapeCard,
|
||||
ScrapeItem,
|
||||
} from "@/components/player/internals/ScrapeCard";
|
||||
import { ProgressRing } from "@/components/layout/ProgressRing";
|
||||
import {
|
||||
ScrapingItems,
|
||||
ScrapingSegment,
|
||||
useListCenter,
|
||||
useScrape,
|
||||
} from "@/hooks/useProviderScrape";
|
||||
|
||||
|
|
@ -33,21 +29,57 @@ export interface ScrapingProps {
|
|||
) => 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) {
|
||||
const { report } = useReportProviders();
|
||||
const { startScraping, sourceOrder, sources, currentSource } = useScrape();
|
||||
const isMounted = useMountedState();
|
||||
const { t } = useTranslation();
|
||||
|
||||
const containerRef = useRef<HTMLDivElement | null>(null);
|
||||
const listRef = useRef<HTMLDivElement | null>(null);
|
||||
const [failedStartScrape, setFailedStartScrape] = useState<boolean>(false);
|
||||
const renderedOnce = useListCenter(
|
||||
containerRef,
|
||||
listRef,
|
||||
sourceOrder,
|
||||
currentSource,
|
||||
);
|
||||
|
||||
const resultRef = useRef({
|
||||
sourceOrder,
|
||||
|
|
@ -92,91 +124,56 @@ export function ScrapingPart(props: ScrapingProps) {
|
|||
return <WarningPart>{t("player.turnstile.error")}</WarningPart>;
|
||||
|
||||
return (
|
||||
<div
|
||||
className="h-full w-full relative dir-neutral:origin-top-left flex"
|
||||
ref={containerRef}
|
||||
>
|
||||
<div className="h-full w-full relative flex flex-col items-center justify-center">
|
||||
{!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" />
|
||||
<p>{t("player.turnstile.verifyingHumanity")}</p>
|
||||
</div>
|
||||
) : null}
|
||||
<div
|
||||
className={classNames({
|
||||
"absolute transition-[transform,opacity] opacity-0 dir-neutral:left-0":
|
||||
true,
|
||||
"!opacity-100": renderedOnce,
|
||||
})}
|
||||
ref={listRef}
|
||||
>
|
||||
{sourceOrder.map((order) => {
|
||||
const source = sources[order.id];
|
||||
const distance = Math.abs(
|
||||
sourceOrder.findIndex((o) => o.id === order.id) -
|
||||
currentProviderIndex,
|
||||
);
|
||||
return (
|
||||
) : (
|
||||
<div className="flex flex-col items-center gap-6">
|
||||
<div className="text-center flex flex-col items-center gap-3">
|
||||
<Loading className="mb-0" />
|
||||
<p className="text-type-secondary">
|
||||
Finding the best video for you
|
||||
</p>
|
||||
</div>
|
||||
<div className="relative h-16 w-[400px] overflow-hidden">
|
||||
<div
|
||||
className="transition-opacity duration-100"
|
||||
style={{ opacity: Math.max(0, 1 - distance * 0.3) }}
|
||||
key={order.id}
|
||||
className="absolute inset-0 flex items-center justify-center"
|
||||
style={{
|
||||
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
|
||||
id={order.id}
|
||||
name={source.name}
|
||||
status={source.status}
|
||||
hasChildren={order.children.length > 0}
|
||||
percentage={source.percentage}
|
||||
>
|
||||
<div className="relative flex h-full w-[220px] items-center">
|
||||
<div
|
||||
className={classNames({
|
||||
"space-y-6 mt-8": order.children.length > 0,
|
||||
})}
|
||||
className="absolute inset-y-0 left-0 flex items-center gap-[16px] transition-transform duration-200"
|
||||
style={{
|
||||
transform: `translateX(${
|
||||
-1 * (220 + 16) * (currentProviderIndex + 1)
|
||||
}px)`,
|
||||
}}
|
||||
>
|
||||
{order.children.map((embedId) => {
|
||||
const embed = sources[embedId];
|
||||
<ScrapePillSkeleton />
|
||||
{sourceOrder.map((order) => {
|
||||
const source = sources[order.id];
|
||||
return (
|
||||
<ScrapeItem
|
||||
id={embedId}
|
||||
name={embed.name}
|
||||
status={embed.status}
|
||||
percentage={embed.percentage}
|
||||
key={embedId}
|
||||
<ScrapePill
|
||||
key={order.id}
|
||||
name={source.name}
|
||||
status={source.status}
|
||||
percentage={source.percentage}
|
||||
/>
|
||||
);
|
||||
})}
|
||||
<ScrapePillSkeleton />
|
||||
</div>
|
||||
</ScrapeCard>
|
||||
</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>
|
||||
)}
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
|
|||
|
|
@ -57,13 +57,7 @@ function SearchSuffix(props: { failed?: boolean; results?: number }) {
|
|||
);
|
||||
}
|
||||
|
||||
export function SearchListPart({
|
||||
searchQuery,
|
||||
onShowDetails,
|
||||
}: {
|
||||
searchQuery: string;
|
||||
onShowDetails?: (media: MediaItem) => void;
|
||||
}) {
|
||||
export function SearchListPart({ searchQuery }: { searchQuery: string }) {
|
||||
const { t } = useTranslation();
|
||||
|
||||
const [results, setResults] = useState<MediaItem[]>([]);
|
||||
|
|
@ -93,11 +87,7 @@ export function SearchListPart({
|
|||
/>
|
||||
<MediaGrid>
|
||||
{results.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={v.id.toString()}
|
||||
media={v}
|
||||
onShowDetails={onShowDetails}
|
||||
/>
|
||||
<WatchedMediaCard key={v.id.toString()} media={v} />
|
||||
))}
|
||||
</MediaGrid>
|
||||
</div>
|
||||
|
|
|
|||
|
|
@ -11,46 +11,6 @@ const availableThemes = [
|
|||
selector: "theme-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: {
|
||||
|
|
|
|||
|
|
@ -6,6 +6,7 @@ import { useAsync } from "react-use";
|
|||
import { getBackendMeta } from "@/backend/accounts/meta";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { SidebarLink, SidebarSection } from "@/components/layout/Sidebar";
|
||||
import { GlitchText } from "@/components/text/GlitchText";
|
||||
import { Divider } from "@/components/utils/Divider";
|
||||
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
|
||||
import { useIsMobile } from "@/hooks/useIsMobile";
|
||||
|
|
@ -48,11 +49,6 @@ export function SidebarPart() {
|
|||
id: "settings-preferences",
|
||||
icon: Icons.SETTINGS,
|
||||
},
|
||||
{
|
||||
textKey: "settings.appearance.title",
|
||||
id: "settings-appearance",
|
||||
icon: Icons.BRUSH,
|
||||
},
|
||||
{
|
||||
textKey: "settings.subtitles.title",
|
||||
id: "settings-captions",
|
||||
|
|
@ -120,7 +116,7 @@ export function SidebarPart() {
|
|||
<div className="text-settings-sidebar-type-inactive sidebar-boundary">
|
||||
<Sticky
|
||||
topOffset={-6 * rem}
|
||||
stickyClassName="pt-[6rem]"
|
||||
stickyClassName="pt-[8rem]"
|
||||
disabled={isMobile}
|
||||
hideOnBoundaryHit={false}
|
||||
boundaryElement=".sidebar-boundary"
|
||||
|
|
@ -144,7 +140,10 @@ export function SidebarPart() {
|
|||
className="text-sm"
|
||||
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 */}
|
||||
<div className="col-span-2 space-y-1">
|
||||
<p className="text-type-dimmed font-medium">
|
||||
|
|
@ -179,9 +178,14 @@ export function SidebarPart() {
|
|||
<p className="text-type-dimmed font-medium">
|
||||
{t("settings.sidebar.info.appVersion")}
|
||||
</p>
|
||||
<p className="text-type-dimmed px-2 py-1 rounded bg-settings-sidebar-badge inline-block">
|
||||
{conf().APP_VERSION}
|
||||
</p>
|
||||
<div>
|
||||
<GlitchText
|
||||
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>
|
||||
|
||||
{/* Backend version */}
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { useOnlineListener } from "@/hooks/usePing";
|
|||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { DmcaPage } from "@/pages/Dmca";
|
||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||
|
|
@ -166,8 +165,6 @@ function App() {
|
|||
{/* Support page */}
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
<Route path="/jip" element={<JipPage />} />
|
||||
{/* Discover page */}
|
||||
<Route path="/discover" element={<Discover />} />
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
|
|
@ -15,7 +15,6 @@ import { useOnlineListener } from "@/hooks/usePing";
|
|||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { DmcaPage } from "@/pages/Dmca";
|
||||
import MaintenancePage from "@/pages/errors/MaintenancePage";
|
||||
import { NotFoundPage } from "@/pages/errors/NotFoundPage";
|
||||
|
|
@ -148,8 +147,6 @@ function App() {
|
|||
<Route path="/dmca" element={<DmcaPage />} />
|
||||
{/* Support page */}
|
||||
<Route path="/support" element={<SupportPage />} />
|
||||
{/* Discover page */}
|
||||
<Route path="/discover" element={<Discover />} />
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
|
|
@ -9,6 +9,30 @@ const config: Config = {
|
|||
safelist: safeThemeList,
|
||||
theme: {
|
||||
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 */
|
||||
screens: {
|
||||
xs: "350px",
|
||||
|
|
@ -21,6 +45,7 @@ const config: Config = {
|
|||
/* fonts */
|
||||
fontFamily: {
|
||||
"main": "'DM Sans'", // "main": "'Open Sans'",
|
||||
"open-sans": "'Open Sans'"
|
||||
},
|
||||
|
||||
/* animations */
|
||||
|
|
@ -37,14 +62,6 @@ const config: Config = {
|
|||
require("tailwind-scrollbar"),
|
||||
themer({
|
||||
defaultTheme: defaultTheme,
|
||||
themes: [
|
||||
{
|
||||
name: "default",
|
||||
selectors: [".theme-default"],
|
||||
...defaultTheme,
|
||||
},
|
||||
...allThemes,
|
||||
],
|
||||
}),
|
||||
plugin(({ addVariant }) => {
|
||||
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 green from "./list/green";
|
||||
import mocha from "./list/mocha";
|
||||
import pink from "./list/pink";
|
||||
|
||||
export const allThemes = [
|
||||
teal,
|
||||
blue,
|
||||
gray,
|
||||
red,
|
||||
classic,
|
||||
green,
|
||||
mocha,
|
||||
pink
|
||||
classic
|
||||
]
|
||||
|
|
|
|||
|
|
@ -1,348 +1,292 @@
|
|||
const tokens = {
|
||||
black: {
|
||||
c50: "#000000",
|
||||
c75: "#030303",
|
||||
c80: "#080808",
|
||||
c100: "#0d0d0d",
|
||||
c125: "#141414",
|
||||
c150: "#1a1a1a",
|
||||
c200: "#262626",
|
||||
c250: "#333333"
|
||||
},
|
||||
white: "#FFFFFF", // General white color
|
||||
black: "#000000",
|
||||
white: "#FFFFFF",
|
||||
semantic: {
|
||||
red: {
|
||||
c100: "#F46E6E", // Error text
|
||||
c200: "#E44F4F", // Video player scraping error
|
||||
c300: "#D74747", // Danger button
|
||||
c400: "#B43434", // Not currently used
|
||||
c100: "#CD97D6", // Using bink-700 for errors since we don't have red
|
||||
c200: "#A87FD1", // Using bink-600 for error states
|
||||
c300: "#8D66B5", // Using bink-500 for danger buttons
|
||||
},
|
||||
green: {
|
||||
c100: "#60D26A", // Success text
|
||||
c200: "#40B44B", // Video player scraping success
|
||||
c300: "#31A33C", // Not currently used
|
||||
c400: "#237A2B", // Not currently used
|
||||
c100: "#A87FD1", // Using bink-600 for success states
|
||||
c200: "#8D66B5", // Using bink-500 for success indicators
|
||||
c300: "#714C97", // Using bink-400 for success buttons
|
||||
},
|
||||
silver: {
|
||||
c100: "#DEDEDE", // Primary button hover
|
||||
c200: "#B6CAD7", // Not currently used
|
||||
c300: "#8EA3B0", // Secondary button 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
|
||||
},
|
||||
c100: "#7A758F", // Using denim-700 for hover states
|
||||
c300: "#504B64", // Using denim-600 for secondary text
|
||||
c400: "#38334A", // Using denim-500 for dimmed text
|
||||
}
|
||||
},
|
||||
blue: {
|
||||
c50: "#ccccd6",
|
||||
c100: "#a2a2a2",
|
||||
c200: "#868686",
|
||||
c300: "#646464",
|
||||
c400: "#4e4e4e",
|
||||
c500: "#383838",
|
||||
c600: "#2e2e2e",
|
||||
c700: "#272727",
|
||||
c800: "#181818",
|
||||
c900: "#0f0f0f"
|
||||
// Simplified color palette using new theme colors
|
||||
primary: {
|
||||
c100: "#CD97D6", // bink-700
|
||||
c200: "#A87FD1", // bink-600
|
||||
c300: "#8D66B5", // bink-500
|
||||
c400: "#714C97", // bink-400
|
||||
c500: "#533670", // bink-300
|
||||
c600: "#412B57", // bink-200
|
||||
c700: "#432449", // bink-100
|
||||
},
|
||||
purple: {
|
||||
c50: "#aaafff",
|
||||
c100: "#8288fe",
|
||||
c200: "#5a62eb",
|
||||
c300: "#454cd4",
|
||||
c400: "#333abe",
|
||||
c500: "#292d86",
|
||||
c600: "#1f2363",
|
||||
c700: "#191b4a",
|
||||
c800: "#111334", // Lightbar
|
||||
c900: "#0b0d22"
|
||||
background: {
|
||||
c100: "#7A758F", // denim-700
|
||||
c200: "#504B64", // denim-600
|
||||
c300: "#38334A", // denim-500
|
||||
c400: "#2B263D", // denim-400
|
||||
c500: "#211D30", // denim-300
|
||||
c600: "#191526", // denim-200
|
||||
c700: "#120F1D", // denim-100
|
||||
},
|
||||
ash: {
|
||||
c50: "#8d8d8d",
|
||||
c100: "#6b6b6b",
|
||||
c200: "#545454",
|
||||
c300: "#3c3c3c",
|
||||
c400: "#313131",
|
||||
c500: "#2c2c2c",
|
||||
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"
|
||||
},
|
||||
};
|
||||
c100: "#1E1C26", // ash-100
|
||||
c200: "#2B2836", // ash-200
|
||||
c300: "#2C293A", // ash-300
|
||||
c400: "#3D394D", // ash-400
|
||||
c500: "#9C93B5", // ash-500
|
||||
c600: "#817998", // ash-600
|
||||
}
|
||||
}
|
||||
|
||||
export const defaultTheme = {
|
||||
extend: {
|
||||
colors: {
|
||||
themePreview: {
|
||||
primary: tokens.black.c80,
|
||||
secondary: tokens.black.c100,
|
||||
primary: tokens.primary.c300,
|
||||
secondary: tokens.background.c200,
|
||||
ghost: tokens.white,
|
||||
},
|
||||
|
||||
// Branding
|
||||
pill: {
|
||||
background: tokens.black.c100,
|
||||
backgroundHover: tokens.black.c125,
|
||||
highlight: tokens.blue.c200,
|
||||
activeBackground: tokens.shade.c700,
|
||||
background: tokens.background.c400,
|
||||
backgroundHover: tokens.background.c300,
|
||||
highlight: tokens.primary.c300,
|
||||
activeBackground: tokens.background.c400,
|
||||
},
|
||||
|
||||
// meta data for the theme itself
|
||||
global: {
|
||||
accentA: tokens.blue.c200,
|
||||
accentB: tokens.blue.c300,
|
||||
accentA: tokens.primary.c300,
|
||||
accentB: tokens.primary.c400,
|
||||
},
|
||||
|
||||
// light bar
|
||||
lightBar: {
|
||||
light: tokens.purple.c800,
|
||||
light: tokens.primary.c400,
|
||||
},
|
||||
|
||||
// Buttons
|
||||
buttons: {
|
||||
toggle: tokens.purple.c300,
|
||||
toggleDisabled: tokens.black.c200,
|
||||
danger: tokens.semantic.rose.c300,
|
||||
dangerHover: tokens.semantic.rose.c200,
|
||||
|
||||
secondary: tokens.black.c100,
|
||||
toggle: tokens.primary.c300,
|
||||
toggleDisabled: tokens.background.c400,
|
||||
danger: tokens.semantic.red.c300,
|
||||
dangerHover: tokens.semantic.red.c200,
|
||||
secondary: tokens.background.c500,
|
||||
secondaryText: tokens.semantic.silver.c300,
|
||||
secondaryHover: tokens.black.c150,
|
||||
secondaryHover: tokens.background.c400,
|
||||
primary: tokens.white,
|
||||
primaryText: tokens.black.c50,
|
||||
primaryText: tokens.black,
|
||||
primaryHover: tokens.semantic.silver.c100,
|
||||
purple: tokens.purple.c600,
|
||||
purpleHover: tokens.purple.c400,
|
||||
cancel: tokens.black.c100,
|
||||
cancelHover: tokens.black.c150
|
||||
cancel: tokens.background.c400,
|
||||
cancelHover: tokens.background.c300,
|
||||
},
|
||||
|
||||
// only used for body colors/textures
|
||||
background: {
|
||||
main: tokens.black.c75,
|
||||
secondary: tokens.black.c75,
|
||||
secondaryHover: tokens.black.c75,
|
||||
accentA: tokens.purple.c600,
|
||||
accentB: tokens.black.c100
|
||||
main: tokens.background.c700,
|
||||
secondary: tokens.background.c600,
|
||||
secondaryHover: tokens.background.c500,
|
||||
accentA: tokens.primary.c600,
|
||||
accentB: tokens.primary.c500,
|
||||
},
|
||||
|
||||
// Modals
|
||||
modal: {
|
||||
background: tokens.shade.c800,
|
||||
background: tokens.background.c600,
|
||||
},
|
||||
|
||||
// typography
|
||||
type: {
|
||||
logo: tokens.purple.c100,
|
||||
logo: tokens.primary.c200,
|
||||
emphasis: tokens.white,
|
||||
text: tokens.shade.c50,
|
||||
dimmed: tokens.shade.c50,
|
||||
divider: tokens.ash.c500,
|
||||
secondary: tokens.ash.c100,
|
||||
text: tokens.background.c100,
|
||||
dimmed: tokens.background.c200,
|
||||
divider: tokens.background.c400,
|
||||
secondary: tokens.semantic.silver.c300,
|
||||
danger: tokens.semantic.red.c100,
|
||||
success: tokens.semantic.green.c100,
|
||||
link: tokens.purple.c100,
|
||||
linkHover: tokens.purple.c50
|
||||
link: tokens.primary.c200,
|
||||
linkHover: tokens.primary.c100,
|
||||
},
|
||||
|
||||
// search bar
|
||||
search: {
|
||||
background: tokens.black.c100,
|
||||
hoverBackground: tokens.shade.c900,
|
||||
focused: tokens.black.c125,
|
||||
placeholder: tokens.shade.c200,
|
||||
icon: tokens.shade.c500,
|
||||
background: tokens.background.c500,
|
||||
hoverBackground: tokens.background.c600,
|
||||
focused: tokens.background.c400,
|
||||
placeholder: tokens.background.c100,
|
||||
icon: tokens.background.c100,
|
||||
text: tokens.white,
|
||||
},
|
||||
|
||||
// media cards
|
||||
mediaCard: {
|
||||
hoverBackground: tokens.shade.c900,
|
||||
hoverAccent: tokens.black.c250,
|
||||
hoverShadow: tokens.black.c50,
|
||||
shadow: tokens.shade.c800,
|
||||
hoverBackground: tokens.background.c600,
|
||||
hoverAccent: tokens.primary.c100,
|
||||
hoverShadow: tokens.background.c700,
|
||||
shadow: tokens.background.c500,
|
||||
barColor: tokens.ash.c200,
|
||||
barFillColor: tokens.purple.c100,
|
||||
badge: tokens.shade.c700,
|
||||
badgeText: tokens.ash.c100
|
||||
barFillColor: tokens.primary.c200,
|
||||
badge: tokens.background.c500,
|
||||
badgeText: tokens.ash.c500,
|
||||
},
|
||||
|
||||
// Large card
|
||||
largeCard: {
|
||||
background: tokens.black.c100,
|
||||
icon: tokens.purple.c400,
|
||||
background: tokens.background.c600,
|
||||
icon: tokens.primary.c400,
|
||||
},
|
||||
|
||||
// Dropdown
|
||||
dropdown: {
|
||||
background: tokens.black.c100,
|
||||
altBackground: tokens.black.c80,
|
||||
hoverBackground: tokens.black.c150,
|
||||
highlight: tokens.semantic.yellow.c400,
|
||||
highlightHover: tokens.semantic.yellow.c200,
|
||||
text: tokens.shade.c50,
|
||||
secondary: tokens.shade.c100,
|
||||
border: tokens.shade.c400,
|
||||
contentBackground: tokens.black.c50
|
||||
background: tokens.background.c600,
|
||||
altBackground: tokens.background.c500,
|
||||
hoverBackground: tokens.background.c400,
|
||||
highlight: tokens.primary.c300,
|
||||
highlightHover: tokens.primary.c200,
|
||||
text: tokens.background.c100,
|
||||
secondary: tokens.background.c200,
|
||||
border: tokens.background.c400,
|
||||
contentBackground: tokens.background.c500,
|
||||
},
|
||||
|
||||
// Passphrase
|
||||
authentication: {
|
||||
border: tokens.shade.c300,
|
||||
inputBg: tokens.black.c100,
|
||||
inputBgHover: tokens.black.c150,
|
||||
wordBackground: tokens.shade.c500,
|
||||
copyText: tokens.shade.c100,
|
||||
copyTextHover: tokens.ash.c50,
|
||||
errorText: tokens.semantic.rose.c100,
|
||||
border: tokens.background.c400,
|
||||
inputBg: tokens.background.c600,
|
||||
inputBgHover: tokens.background.c500,
|
||||
wordBackground: tokens.background.c500,
|
||||
copyText: tokens.background.c200,
|
||||
copyTextHover: tokens.ash.c500,
|
||||
errorText: tokens.semantic.red.c100,
|
||||
},
|
||||
|
||||
// Settings page
|
||||
settings: {
|
||||
sidebar: {
|
||||
activeLink: tokens.black.c100,
|
||||
badge: tokens.shade.c900,
|
||||
activeLink: tokens.background.c600,
|
||||
badge: tokens.background.c700,
|
||||
|
||||
type: {
|
||||
secondary: tokens.shade.c200,
|
||||
inactive: tokens.shade.c50,
|
||||
icon: tokens.black.c200,
|
||||
iconActivated: tokens.purple.c200,
|
||||
activated: tokens.purple.c100
|
||||
secondary: tokens.background.c300,
|
||||
inactive: tokens.background.c100,
|
||||
icon: tokens.background.c100,
|
||||
iconActivated: tokens.primary.c200,
|
||||
activated: tokens.primary.c100,
|
||||
},
|
||||
},
|
||||
|
||||
card: {
|
||||
border: tokens.shade.c700,
|
||||
background: tokens.black.c100,
|
||||
altBackground: tokens.black.c100
|
||||
border: tokens.background.c400,
|
||||
background: tokens.background.c400,
|
||||
altBackground: tokens.background.c400,
|
||||
},
|
||||
|
||||
saveBar: {
|
||||
background: tokens.black.c50
|
||||
background: tokens.background.c600,
|
||||
},
|
||||
},
|
||||
|
||||
// Utilities
|
||||
utils: {
|
||||
divider: tokens.ash.c300
|
||||
divider: tokens.ash.c300,
|
||||
},
|
||||
|
||||
// Onboarding
|
||||
onboarding: {
|
||||
bar: tokens.shade.c400,
|
||||
barFilled: tokens.purple.c300,
|
||||
divider: tokens.shade.c200,
|
||||
card: tokens.shade.c800,
|
||||
cardHover: tokens.shade.c700,
|
||||
border: tokens.shade.c600,
|
||||
good: tokens.purple.c100,
|
||||
best: tokens.semantic.yellow.c100,
|
||||
link: tokens.purple.c100,
|
||||
bar: tokens.background.c400,
|
||||
barFilled: tokens.primary.c300,
|
||||
divider: tokens.background.c300,
|
||||
card: tokens.background.c600,
|
||||
cardHover: tokens.background.c500,
|
||||
border: tokens.background.c600,
|
||||
good: tokens.primary.c200,
|
||||
best: tokens.primary.c100,
|
||||
link: tokens.primary.c200,
|
||||
},
|
||||
|
||||
// Error page
|
||||
errors: {
|
||||
card: tokens.black.c75,
|
||||
card: tokens.background.c600,
|
||||
border: tokens.ash.c500,
|
||||
|
||||
type: {
|
||||
secondary: tokens.ash.c100,
|
||||
secondary: tokens.ash.c500,
|
||||
},
|
||||
},
|
||||
|
||||
// About page
|
||||
about: {
|
||||
circle: tokens.black.c100,
|
||||
circleText: tokens.ash.c50
|
||||
circle: tokens.ash.c500,
|
||||
circleText: tokens.ash.c500,
|
||||
},
|
||||
|
||||
// About page
|
||||
editBadge: {
|
||||
bg: tokens.ash.c500,
|
||||
bgHover: tokens.ash.c400,
|
||||
text: tokens.ash.c50
|
||||
text: tokens.ash.c500,
|
||||
},
|
||||
|
||||
progress: {
|
||||
background: tokens.ash.c50,
|
||||
preloaded: tokens.ash.c50,
|
||||
filled: tokens.purple.c200,
|
||||
background: tokens.ash.c500,
|
||||
preloaded: tokens.ash.c500,
|
||||
filled: tokens.primary.c200,
|
||||
},
|
||||
|
||||
// video player
|
||||
video: {
|
||||
buttonBackground: tokens.ash.c600,
|
||||
buttonBackground: tokens.ash.c200,
|
||||
|
||||
autoPlay: {
|
||||
background: tokens.ash.c800,
|
||||
hover: tokens.ash.c600,
|
||||
background: tokens.ash.c600,
|
||||
hover: tokens.ash.c500,
|
||||
},
|
||||
|
||||
scraping: {
|
||||
card: tokens.black.c50,
|
||||
card: tokens.background.c500,
|
||||
error: tokens.semantic.red.c200,
|
||||
success: tokens.semantic.green.c200,
|
||||
loading: tokens.purple.c200,
|
||||
noresult: tokens.black.c200
|
||||
loading: tokens.primary.c200,
|
||||
noresult: tokens.ash.c500,
|
||||
},
|
||||
|
||||
audio: {
|
||||
set: tokens.purple.c200,
|
||||
set: tokens.primary.c200,
|
||||
},
|
||||
|
||||
context: {
|
||||
background: tokens.black.c50,
|
||||
light: tokens.shade.c50,
|
||||
border: tokens.ash.c600,
|
||||
hoverColor: tokens.ash.c600,
|
||||
buttonFocus: tokens.ash.c500,
|
||||
flagBg: tokens.ash.c500,
|
||||
inputBg: tokens.black.c100,
|
||||
buttonOverInputHover: tokens.ash.c500,
|
||||
inputPlaceholder: tokens.ash.c200,
|
||||
cardBorder: tokens.ash.c700,
|
||||
slider: tokens.black.c200,
|
||||
sliderFilled: tokens.purple.c200,
|
||||
background: tokens.background.c600,
|
||||
light: tokens.background.c400,
|
||||
border: tokens.background.c500,
|
||||
hoverColor: tokens.background.c500,
|
||||
buttonFocus: tokens.background.c400,
|
||||
flagBg: tokens.background.c400,
|
||||
inputBg: tokens.background.c500,
|
||||
buttonOverInputHover: tokens.background.c400,
|
||||
inputPlaceholder: tokens.background.c200,
|
||||
cardBorder: tokens.background.c600,
|
||||
slider: tokens.background.c100,
|
||||
sliderFilled: tokens.primary.c300,
|
||||
error: tokens.semantic.red.c200,
|
||||
|
||||
buttons: {
|
||||
list: tokens.ash.c700,
|
||||
active: tokens.ash.c900,
|
||||
list: tokens.background.c600,
|
||||
active: tokens.background.c600,
|
||||
},
|
||||
|
||||
closeHover: tokens.ash.c800,
|
||||
closeHover: tokens.background.c500,
|
||||
|
||||
type: {
|
||||
main: tokens.semantic.silver.c300,
|
||||
secondary: tokens.ash.c200,
|
||||
accent: tokens.purple.c200,
|
||||
main: tokens.white,
|
||||
secondary: tokens.background.c200,
|
||||
accent: tokens.primary.c300,
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
},
|
||||
};
|
||||
}
|
||||
|
|
|
|||
Loading…
Reference in a new issue