p-stream/src/pages/parts/settings/AppearancePart.tsx
Pas 647a4e8279 add cobalt theme
replace skyrealm
2025-12-17 15:21:00 -07:00

616 lines
21 KiB
TypeScript

import classNames from "classnames";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { Button } from "@/components/buttons/Button";
import { Toggle } from "@/components/buttons/Toggle";
import { SortableList } from "@/components/form/SortableList";
import { Icon, Icons } from "@/components/Icon";
import { EditGroupOrderModal } from "@/components/overlays/EditGroupOrderModal";
import { useModal } from "@/components/overlays/Modal";
import { Heading1 } from "@/components/utils/Text";
import { useBackendUrl } from "@/hooks/auth/useBackendUrl";
import { useAuthStore } from "@/stores/auth";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useGroupOrderStore } from "@/stores/groupOrder";
const availableThemes = [
{
id: "default",
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: "forest",
selector: "theme-forest",
key: "settings.appearance.themes.forest",
},
{
id: "autumn",
selector: "theme-autumn",
key: "settings.appearance.themes.autumn",
},
{
id: "frost",
selector: "theme-frost",
key: "settings.appearance.themes.frost",
},
{
id: "mocha",
selector: "theme-mocha",
key: "settings.appearance.themes.mocha",
},
{
id: "pink",
selector: "theme-pink",
key: "settings.appearance.themes.pink",
},
{
id: "noir",
selector: "theme-noir",
key: "settings.appearance.themes.noir",
},
{
id: "ember",
selector: "theme-ember",
key: "settings.appearance.themes.ember",
},
{
id: "acid",
selector: "theme-acid",
key: "settings.appearance.themes.acid",
},
{
id: "spark",
selector: "theme-spark",
key: "settings.appearance.themes.spark",
},
{
id: "cobalt",
selector: "theme-cobalt",
key: "settings.appearance.themes.cobalt",
},
{
id: "grape",
selector: "theme-grape",
key: "settings.appearance.themes.grape",
},
{
id: "spiderman",
selector: "theme-spiderman",
key: "settings.appearance.themes.spiderman",
},
{
id: "wolverine",
selector: "theme-wolverine",
key: "settings.appearance.themes.wolverine",
},
{
id: "hulk",
selector: "theme-hulk",
key: "settings.appearance.themes.hulk",
},
{
id: "popsicle",
selector: "theme-popsicle",
key: "settings.appearance.themes.popsicle",
},
{
id: "christmas",
selector: "theme-christmas",
key: "settings.appearance.themes.christmas",
},
];
function ThemePreview(props: {
selector?: string;
active?: boolean;
inUse?: boolean;
name: string;
onClick?: () => void;
}) {
const { t } = useTranslation();
return (
<div
className={classNames(props.selector, "cursor-pointer group tabbable")}
onClick={props.onClick}
>
{/* Little card thing */}
<div
tabIndex={0}
onKeyUp={(e) => e.key === "Enter" && e.currentTarget.click()}
className={classNames(
"tabbable scroll-mt-32 w-full h-32 relative rounded-lg border bg-gradient-to-br from-themePreview-primary/20 to-themePreview-secondary/10 bg-clip-content transition-colors duration-150",
props.active
? "border-themePreview-primary"
: "border-transparent group-hover:border-white/20",
)}
>
{/* Dots */}
<div className="absolute top-2 left-2">
<div className="h-5 w-5 bg-themePreview-primary rounded-full" />
<div className="h-5 w-5 bg-themePreview-secondary rounded-full -mt-2" />
</div>
{/* Active check */}
<Icon
icon={Icons.CHECKMARK}
className={classNames(
"absolute top-3 right-3 text-xs text-white transition-opacity duration-150",
props.active ? "opacity-100" : "opacity-0",
)}
/>
{/* Mini movie-web. So Kawaiiiii! */}
<div className="absolute bottom-0 left-1/2 transform -translate-x-1/2 w-3/5 h-4/5 rounded-t-lg -mb-px bg-background-main overflow-hidden">
<div className="relative w-full h-full">
{/* Background color */}
<div className="bg-themePreview-primary/50 w-[130%] h-10 absolute left-1/2 -top-5 blur-xl transform -translate-x-1/2 rounded-[100%]" />
{/* Navbar */}
<div className="p-2 flex justify-between items-center">
<div className="flex space-x-1">
<div className="bg-themePreview-ghost bg-opacity-10 w-4 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
</div>
<div className="bg-themePreview-ghost bg-opacity-10 w-2 h-2 rounded-full" />
</div>
{/* Hero */}
<div className="mt-1 flex items-center flex-col gap-1">
{/* Title and subtitle */}
<div className="bg-themePreview-ghost bg-opacity-20 w-8 h-0.5 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-20 w-6 h-0.5 rounded-full" />
{/* Search bar */}
<div className="bg-themePreview-ghost bg-opacity-10 w-16 h-2 mt-1 rounded-full" />
</div>
{/* Media grid */}
<div className="mt-5 px-3">
{/* Title */}
<div className="flex gap-1 items-center">
<div className="bg-themePreview-ghost bg-opacity-20 w-2 h-2 rounded-full" />
<div className="bg-themePreview-ghost bg-opacity-20 w-8 h-0.5 rounded-full" />
</div>
{/* Blocks */}
<div className="flex w-full gap-1 mt-1">
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
<div className="bg-themePreview-ghost bg-opacity-10 w-full h-20 rounded" />
</div>
</div>
</div>
</div>
</div>
<div className="mt-2 flex justify-between items-center">
<span className="font-medium text-white">{props.name}</span>
<span
className={classNames(
"inline-block px-3 py-1 leading-tight text-sm transition-opacity duration-150 rounded-full bg-pill-activeBackground text-white/85",
props.inUse ? "opacity-100" : "opacity-0 pointer-events-none",
)}
>
{t("settings.appearance.activeTheme")}
</span>
</div>
</div>
);
}
export function AppearancePart(props: {
active: string;
inUse: string;
setTheme: (theme: string) => void;
enableDiscover: boolean;
setEnableDiscover: (v: boolean) => void;
enableFeatured: boolean;
setEnableFeatured: (v: boolean) => void;
enableDetailsModal: boolean;
setEnableDetailsModal: (v: boolean) => void;
enableImageLogos: boolean;
setEnableImageLogos: (v: boolean) => void;
enableCarouselView: boolean;
setEnableCarouselView: (v: boolean) => void;
forceCompactEpisodeView: boolean;
setForceCompactEpisodeView: (v: boolean) => void;
homeSectionOrder: string[];
setHomeSectionOrder: (v: string[]) => void;
enableLowPerformanceMode: boolean;
}) {
const { t } = useTranslation();
const carouselRef = useRef<HTMLDivElement>(null);
const activeThemeRef = useRef<HTMLDivElement>(null);
const [isAtTop, setIsAtTop] = useState(true);
const [isAtBottom, setIsAtBottom] = useState(false);
// Group order modal
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const setGroupOrder = useGroupOrderStore((s) => s.setGroupOrder);
const editGroupOrderModal = useModal("bookmark-edit-order-settings");
const backendUrl = useBackendUrl();
const account = useAuthStore((s) => s.account);
// Check if there are groups
const hasGroups = useMemo(() => {
const groups = new Set<string>();
Object.values(bookmarks).forEach((bookmark) => {
if (Array.isArray(bookmark.group)) {
bookmark.group.forEach((group) => groups.add(group));
}
});
groups.add("bookmarks");
return groups.size > 1;
}, [bookmarks]);
const {
enableLowPerformanceMode,
setEnableDiscover,
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setForceCompactEpisodeView,
} = props;
// Apply low performance mode restrictions
useEffect(() => {
if (enableLowPerformanceMode) {
setEnableDiscover(false);
setEnableFeatured(false);
setEnableDetailsModal(false);
setEnableImageLogos(false);
setForceCompactEpisodeView(true);
}
}, [
enableLowPerformanceMode,
setEnableDiscover,
setEnableFeatured,
setEnableDetailsModal,
setEnableImageLogos,
setForceCompactEpisodeView,
]);
const checkScrollPosition = () => {
const container = carouselRef.current;
if (!container) return;
setIsAtTop(container.scrollTop <= 0);
setIsAtBottom(
Math.abs(
container.scrollHeight - container.scrollTop - container.clientHeight,
) < 2,
);
};
useEffect(() => {
const container = carouselRef.current;
if (!container) return;
container.addEventListener("scroll", checkScrollPosition);
checkScrollPosition(); // Check initial position
return () => container.removeEventListener("scroll", checkScrollPosition);
}, []);
useEffect(() => {
if (activeThemeRef.current && carouselRef.current) {
const element = activeThemeRef.current;
const container = carouselRef.current;
const elementRect = element.getBoundingClientRect();
const containerRect = container.getBoundingClientRect();
// Center the element in the container
container.scrollTop =
elementRect.top +
container.scrollTop -
containerRect.top -
(containerRect.height - elementRect.height) / 2;
checkScrollPosition(); // Update masks after scrolling
}
}, [props.active]);
const handleEditGroupOrder = () => {
editGroupOrderModal.show();
};
const handleCancelGroupOrder = () => {
editGroupOrderModal.hide();
};
const handleSaveGroupOrder = (newOrder: string[]) => {
setGroupOrder(newOrder);
editGroupOrderModal.hide();
// Save to backend
if (backendUrl && account) {
useGroupOrderStore
.getState()
.saveGroupOrderToBackend(backendUrl, account);
}
};
return (
<div className="space-y-12">
<Heading1 border>{t("settings.appearance.title")}</Heading1>
<div className="grid grid-cols-1 lg:grid-cols-2 gap-8">
{/* First Column - Preferences */}
<div className="space-y-8">
{/* Discover */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.discover")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.discoverDescription")}
</p>
<div
onClick={() => {
if (!props.enableLowPerformanceMode) {
const newDiscoverValue = !props.enableDiscover;
props.setEnableDiscover(newDiscoverValue);
if (!newDiscoverValue) {
props.setEnableFeatured(false);
}
}
}}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
props.enableLowPerformanceMode
? "cursor-not-allowed opacity-50 pointer-events-none"
: "cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enableDiscover} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.discoverLabel")}
</p>
</div>
</div>
{/* Featured Carousel */}
{props.enableDiscover && !props.enableLowPerformanceMode && (
<div className="pt-4 pl-4 border-l-8 border-dropdown-background">
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.featured")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.featuredDescription")}
</p>
<div
onClick={() => props.setEnableFeatured(!props.enableFeatured)}
className="bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg"
>
<Toggle enabled={props.enableFeatured} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.featuredLabel")}
</p>
</div>
</div>
)}
{/* Detials Modal */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.modal")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.modalDescription")}
</p>
<div
onClick={() =>
!props.enableLowPerformanceMode &&
props.setEnableDetailsModal(!props.enableDetailsModal)
}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
props.enableLowPerformanceMode
? "cursor-not-allowed opacity-50 pointer-events-none"
: "cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enableDetailsModal} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.modalLabel")}
</p>
</div>
</div>
{/* Image Logos */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.logos")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.logosDescription")}
</p>
<p className="max-w-[25rem] font-medium pt-2 items-center flex gap-4">
<Icon icon={Icons.CIRCLE_EXCLAMATION} className="" />
{t("settings.appearance.options.logosNotice")}
</p>
<div
onClick={() =>
!props.enableLowPerformanceMode &&
props.setEnableImageLogos(!props.enableImageLogos)
}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
props.enableLowPerformanceMode
? "cursor-not-allowed opacity-50 pointer-events-none"
: "cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enableImageLogos} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.logosLabel")}
</p>
</div>
</div>
{/* Carousel View */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.carouselView")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.carouselViewDescription")}
</p>
<div
onClick={() =>
props.setEnableCarouselView(!props.enableCarouselView)
}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
"cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.enableCarouselView} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.carouselViewLabel")}
</p>
</div>
</div>
{/* Force Compact Episode View */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.forceCompactEpisodeView")}
</p>
<p className="max-w-[25rem] font-medium">
{t(
"settings.appearance.options.forceCompactEpisodeViewDescription",
)}
</p>
<div
onClick={() =>
!props.enableLowPerformanceMode &&
props.setForceCompactEpisodeView(!props.forceCompactEpisodeView)
}
className={classNames(
"bg-dropdown-background hover:bg-dropdown-hoverBackground select-none my-4 cursor-pointer space-x-3 flex items-center max-w-[25rem] py-3 px-4 rounded-lg",
props.enableLowPerformanceMode
? "cursor-not-allowed opacity-50 pointer-events-none"
: "cursor-pointer opacity-100 pointer-events-auto",
)}
>
<Toggle enabled={props.forceCompactEpisodeView} />
<p className="flex-1 text-white font-bold">
{t("settings.appearance.options.forceCompactEpisodeViewLabel")}
</p>
</div>
</div>
{/* Home Section Order */}
<div>
<p className="text-white font-bold mb-3">
{t("settings.appearance.options.homeSectionOrder")}
</p>
<p className="max-w-[25rem] font-medium">
{t("settings.appearance.options.homeSectionOrderDescription")}
</p>
<div className="my-4 max-w-[25rem]">
<SortableList
items={props.homeSectionOrder.map((section) => ({
id: section,
name: t(`settings.appearance.sections.${section}`),
}))}
setItems={(items) => {
const newOrder = items.map((item) => item.id);
props.setHomeSectionOrder(newOrder);
}}
/>
</div>
{hasGroups && (
<div className="mt-4 max-w-[25rem]">
<Button
theme="secondary"
onClick={handleEditGroupOrder}
className="w-full"
>
{t("settings.appearance.options.homeSectionOrderGroups")}
</Button>
</div>
)}
</div>
</div>
{/* Second Column - Themes */}
<div className="space-y-8">
<div
ref={carouselRef}
className={classNames(
"grid grid-cols-2 gap-4 max-w-[600px] max-h-[36rem] md:max-h-[64rem] overflow-y-auto",
"vertical-carousel-container",
{
"hide-top-gradient": isAtTop,
"hide-bottom-gradient": isAtBottom,
},
)}
>
{availableThemes.map((v) => (
<div
key={v.id}
ref={props.active === v.id ? activeThemeRef : null}
>
<ThemePreview
selector={v.selector}
active={props.active === v.id}
inUse={props.inUse === v.id}
name={t(v.key)}
onClick={() => props.setTheme(v.id)}
/>
</div>
))}
</div>
</div>
</div>
{/* Edit Group Order Modal */}
<EditGroupOrderModal
id={editGroupOrderModal.id}
isShown={editGroupOrderModal.isShown}
onCancel={handleCancelGroupOrder}
onSave={handleSaveGroupOrder}
/>
</div>
);
}