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:
Pas 2025-03-10 17:05:02 -06:00
parent faee906010
commit 88e03fd08d
45 changed files with 597 additions and 2831 deletions

View file

@ -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>

View file

@ -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>
);
},

View file

@ -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")}

View file

@ -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>
);
}

View file

@ -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"

View file

@ -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>
);
}

View file

@ -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}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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>
);

View file

@ -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}
/>
);
}

View 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>
);
}

View file

@ -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>

View file

@ -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&apos;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&apos;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 &quot;https://server.vidbinge.com&quot;.
</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 &gt; Custom Server</strong>.
</li>
<li>
3. Press the &quot;Migrate my data to a new server.&quot;
button.
</li>
<li>
4. Enter the new server url:{" "}
<strong>https://server.fifthwit.tech</strong> and press
&quot;Migrate&quot;.
</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>
);
}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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",
},
];

View file

@ -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")} />
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>
);
}

View file

@ -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;
}

View file

@ -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;

View file

@ -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 };
}

View file

@ -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 };
}

View file

@ -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>
);
}

View file

@ -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>
</>
);
}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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>
))}

View file

@ -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

View file

@ -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>
))}

View file

@ -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}

View file

@ -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>
);
}

View file

@ -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>

View file

@ -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: {

View file

@ -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 */}

View file

@ -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"

View file

@ -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"

View file

@ -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] &");

View file

@ -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
]

View file

@ -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,
},
},
},
},
},
};
}