add carousel view for watching and bookmarks

This commit is contained in:
Pas 2025-06-01 15:21:00 -06:00
parent bcfed9b0b8
commit 3cc435332c
11 changed files with 566 additions and 64 deletions

View file

@ -782,7 +782,10 @@
"logos": "Image logos",
"logosDescription": "Show image logos instead of text titles in the details modal and featured section. Enabled by default.",
"logosNotice": "Most of the time, logos are English only. Other languages might want to disable this!",
"logosLabel": "Image logos"
"logosLabel": "Image logos",
"carouselView": "Carousel view",
"carouselViewDescription": "Display your currently watching and bookmark sections as carousels instead of a grid. Disabled by default.",
"carouselViewLabel": "Carousel view"
}
},
"connections": {

View file

@ -61,6 +61,7 @@ export function useSettingsState(
proxyTmdb: boolean,
enableSkipCredits: boolean,
enableImageLogos: boolean,
enableCarouselView: boolean,
) {
const [proxyUrlsState, setProxyUrls, resetProxyUrls, proxyUrlsChanged] =
useDerived(proxyUrls);
@ -150,6 +151,12 @@ export function useSettingsState(
] = useDerived(enableSourceOrder);
const [proxyTmdbState, setProxyTmdbState, resetProxyTmdb, proxyTmdbChanged] =
useDerived(proxyTmdb);
const [
enableCarouselViewState,
setEnableCarouselViewState,
resetEnableCarouselView,
enableCarouselViewChanged,
] = useDerived(enableCarouselView);
function reset() {
resetTheme();
@ -171,6 +178,7 @@ export function useSettingsState(
resetSourceOrder();
resetEnableSourceOrder();
resetProxyTmdb();
resetEnableCarouselView();
}
const changed =
@ -191,7 +199,8 @@ export function useSettingsState(
enableImageLogosChanged ||
sourceOrderChanged ||
enableSourceOrderChanged ||
proxyTmdbChanged;
proxyTmdbChanged ||
enableCarouselViewChanged;
return {
reset,
@ -286,5 +295,10 @@ export function useSettingsState(
set: setProxyTmdbState,
changed: proxyTmdbChanged,
},
enableCarouselView: {
state: enableCarouselViewState,
set: setEnableCarouselViewState,
changed: enableCarouselViewChanged,
},
};
}

View file

@ -1,4 +1,4 @@
import { useEffect, useState } from "react";
import { useEffect, useRef, useState } from "react";
import { Helmet } from "react-helmet-async";
import { useTranslation } from "react-i18next";
import { To, useNavigate } from "react-router-dom";
@ -13,8 +13,10 @@ import { FeaturedCarousel } from "@/pages/discover/components/FeaturedCarousel";
import type { FeaturedMedia } from "@/pages/discover/components/FeaturedCarousel";
import DiscoverContent from "@/pages/discover/discoverContent";
import { HomeLayout } from "@/pages/layouts/HomeLayout";
import { BookmarksCarousel } from "@/pages/parts/home/BookmarksCarousel";
import { BookmarksPart } from "@/pages/parts/home/BookmarksPart";
import { HeroPart } from "@/pages/parts/home/HeroPart";
import { WatchingCarousel } from "@/pages/parts/home/WatchingCarousel";
import { WatchingPart } from "@/pages/parts/home/WatchingPart";
import { SearchListPart } from "@/pages/parts/search/SearchListPart";
import { SearchLoadingPart } from "@/pages/parts/search/SearchLoadingPart";
@ -64,6 +66,11 @@ export function HomePage() {
const detailsModal = useModal("details");
const enableDiscover = usePreferencesStore((state) => state.enableDiscover);
const enableFeatured = usePreferencesStore((state) => state.enableFeatured);
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
const enableCarouselView = usePreferencesStore(
(state) => state.enableCarouselView,
);
const isMobile = window.innerWidth < 768;
const handleClick = (path: To) => {
window.scrollTo(0, 0);
@ -218,24 +225,50 @@ export function HomePage() {
)}
{conf().SHOW_AD ? <AdsPart /> : null}
</div>
<WideContainer>
<WideContainer ultraWide={enableCarouselView}>
{s.loading ? (
<SearchLoadingPart />
) : s.searching ? (
<SearchListPart
searchQuery={search}
onShowDetails={handleShowDetails}
/>
enableCarouselView ? (
<WideContainer>
<SearchListPart
searchQuery={search}
onShowDetails={handleShowDetails}
/>
</WideContainer>
) : (
<SearchListPart
searchQuery={search}
onShowDetails={handleShowDetails}
/>
)
) : (
<div className="flex flex-col gap-8">
<WatchingPart
onItemsChange={setShowWatching}
onShowDetails={handleShowDetails}
/>
<BookmarksPart
onItemsChange={setShowBookmarks}
onShowDetails={handleShowDetails}
/>
{enableCarouselView ? (
<>
<WatchingCarousel
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
<BookmarksCarousel
isMobile={isMobile}
carouselRefs={carouselRefs}
onShowDetails={handleShowDetails}
/>
</>
) : (
<>
<WatchingPart
onItemsChange={setShowWatching}
onShowDetails={handleShowDetails}
/>
<BookmarksPart
onItemsChange={setShowBookmarks}
onShowDetails={handleShowDetails}
/>
</>
)}
</div>
)}
{!(showBookmarks || showWatching) && !enableDiscover ? (
@ -252,25 +285,23 @@ export function HomePage() {
<div className="pb-20" />
))}
{/* there... perfect. */}
</WideContainer>
{enableDiscover && !search ? (
<div className="w-full max-w-[100dvw] justify-center items-center">
{enableDiscover && !search ? (
<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">
{!search && (
<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 className="flex flex-col justify-center items-center h-40 space-y-4">
<div className="flex flex-col items-center justify-center">
{!search && (
<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>
</div>
)}
)}
</WideContainer>
{detailsData && <DetailsModal id="details" data={detailsData} />}
</HomeLayout>

View file

@ -170,6 +170,11 @@ export function SettingsPage() {
const proxyTmdb = usePreferencesStore((s) => s.proxyTmdb);
const setProxyTmdb = usePreferencesStore((s) => s.setProxyTmdb);
const enableCarouselView = usePreferencesStore((s) => s.enableCarouselView);
const setEnableCarouselView = usePreferencesStore(
(s) => s.setEnableCarouselView,
);
const account = useAuthStore((s) => s.account);
const updateProfile = useAuthStore((s) => s.setAccountProfile);
const updateDeviceName = useAuthStore((s) => s.updateDeviceName);
@ -214,6 +219,7 @@ export function SettingsPage() {
proxyTmdb,
enableSkipCredits,
enableImageLogos,
enableCarouselView,
);
const availableSources = useMemo(() => {
@ -298,6 +304,7 @@ export function SettingsPage() {
setEnableSourceOrder(state.enableSourceOrder.state);
setFebboxToken(state.febboxToken.state);
setProxyTmdb(state.proxyTmdb.state);
setEnableCarouselView(state.enableCarouselView.state);
if (state.profile.state) {
updateProfile(state.profile.state);
@ -337,6 +344,7 @@ export function SettingsPage() {
setBackendUrl,
setEnableSourceOrder,
setProxyTmdb,
setEnableCarouselView,
]);
return (
<SubPageLayout>
@ -400,6 +408,8 @@ export function SettingsPage() {
setEnableDetailsModal={state.enableDetailsModal.set}
enableImageLogos={state.enableImageLogos.state}
setEnableImageLogos={state.enableImageLogos.set}
enableCarouselView={state.enableCarouselView.state}
setEnableCarouselView={state.enableCarouselView.set}
/>
</div>
<div id="settings-captions" className="mt-28">

View file

@ -49,7 +49,7 @@ export function Discover() {
</div>
{/* Main Content */}
<div className="relative z-20">
<div className="relative z-20 px-10">
<DiscoverContent />
</div>

View file

@ -120,11 +120,11 @@ export function FeaturedCarousel({
const { width: windowWidth, height: windowHeight } = useWindowSize();
const SLIDE_QUANTITY = 10;
const FETCH_QUANTITY = 20;
const SLIDE_QUANTITY_EDITOR_PICKS_MOVIES = 6;
const SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS = 4;
const SLIDE_DURATION = 8000;
// Fetch featured media
useEffect(() => {
const fetchFeaturedMedia = async () => {
setIsLoading(true);
@ -138,41 +138,65 @@ export function FeaturedCarousel({
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setMedia(
data.results.slice(0, SLIDE_QUANTITY).map((movie: any) => ({
// Fetch movie items and randomly select
const allMovies = data.results
.slice(0, FETCH_QUANTITY)
.map((movie: any) => ({
...movie,
type: "movie" as const,
})),
);
}));
// Shuffle
const shuffledMovies = [...allMovies].sort(() => 0.5 - Math.random());
setMedia(shuffledMovies.slice(0, SLIDE_QUANTITY));
} else if (effectiveCategory === "tvshows") {
const data = await get<any>("/tv/popular", {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
});
setMedia(
data.results.slice(0, SLIDE_QUANTITY).map((show: any) => ({
// Fetch show items
const allShows = data.results
.slice(0, FETCH_QUANTITY)
.map((show: any) => ({
...show,
type: "show" as const,
})),
);
}));
// Shuffle
const shuffledShows = [...allShows].sort(() => 0.5 - Math.random());
setMedia(shuffledShows.slice(0, SLIDE_QUANTITY));
} else if (effectiveCategory === "editorpicks") {
// Fetch editor picks movies
const moviePromises = EDITOR_PICKS_MOVIES.slice(
0,
SLIDE_QUANTITY_EDITOR_PICKS_MOVIES,
).map((item) =>
get<any>(`/movie/${item.id}`, {
// Shuffle editor picks Ids
const allMovieIds = EDITOR_PICKS_MOVIES.map((item) => ({
id: item.id,
type: "movie" as const,
}));
const allShowIds = EDITOR_PICKS_TV_SHOWS.map((item) => ({
id: item.id,
type: "show" as const,
}));
// Combine and shuffle
const combinedIds = [...allMovieIds, ...allShowIds].sort(
() => 0.5 - Math.random(),
);
// Select the quantity
const selectedMovieIds = combinedIds
.filter((item) => item.type === "movie")
.slice(0, SLIDE_QUANTITY_EDITOR_PICKS_MOVIES);
const selectedShowIds = combinedIds
.filter((item) => item.type === "show")
.slice(0, SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS);
// Fetch items
const moviePromises = selectedMovieIds.map(({ id }) =>
get<any>(`/movie/${id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
);
// Fetch editor picks TV shows
const showPromises = EDITOR_PICKS_TV_SHOWS.slice(
0,
SLIDE_QUANTITY_EDITOR_PICKS_TV_SHOWS,
).map((item) =>
get<any>(`/tv/${item.id}`, {
const showPromises = selectedShowIds.map(({ id }) =>
get<any>(`/tv/${id}`, {
api_key: conf().TMDB_READ_API_KEY,
language: formattedLanguage,
}),
@ -192,11 +216,7 @@ export function FeaturedCarousel({
type: "show" as const,
}));
// Combine and shuffle
const combined = [...movies, ...shows].sort(
() => 0.5 - Math.random(),
);
setMedia(combined);
setMedia([...movies, ...shows]);
}
} catch (error) {
console.error("Error fetching featured media:", error);
@ -260,7 +280,7 @@ export function FeaturedCarousel({
setTouchEnd(null);
};
// Fetch logo when current media changes
// Fetch clear logo when current media changes
useEffect(() => {
const fetchLogo = async () => {
// Cancel any in-progress logo fetch

View file

@ -2,6 +2,7 @@ import { useEffect, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { get } from "@/backend/metadata/tmdb";
import { WideContainer } from "@/components/layout/WideContainer";
import { DetailsModal } from "@/components/overlays/DetailsModal";
import { useModal } from "@/components/overlays/Modal";
import { useIsMobile } from "@/hooks/useIsMobile";
@ -704,8 +705,8 @@ export function DiscoverContent() {
selectedCategory={selectedCategory}
onCategoryChange={handleCategoryChange}
/>
{/* Content Section with Lazy Loading Tabs */}
<div className="w-full md:w-[90%] max-w-[2400px] mx-auto">
<WideContainer ultraWide classNames="!px-0">
{/* Movies Tab */}
<LazyTabContent isActive={isMoviesTab}>
{renderMoviesContent()}
@ -720,7 +721,7 @@ export function DiscoverContent() {
<LazyTabContent isActive={isEditorPicksTab}>
{renderEditorPicksContent()}
</LazyTabContent>
</div>
</WideContainer>
<ScrollToTopButton />

View file

@ -0,0 +1,198 @@
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaCard } from "@/components/media/MediaCard";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
interface BookmarksCarouselProps {
isMobile: boolean;
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
onShowDetails?: (media: MediaItem) => void;
}
const LONG_PRESS_DURATION = 500; // 0.5 seconds
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 BookmarksCarousel({
isMobile,
carouselRefs,
onShowDetails,
}: BookmarksCarouselProps) {
const { t } = useTranslation();
const browser = !!window.chrome;
let isScrolling = false;
const [editing, setEditing] = useState(false);
const removeBookmark = useBookmarkStore((s) => s.removeBookmark);
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
const bookmarksLength = useBookmarkStore(
(state) => Object.keys(state.bookmarks).length,
);
const progressItems = useProgressStore((state) => state.items);
const bookmarks = useBookmarkStore((state) => state.bookmarks);
const items = useMemo(() => {
let output: MediaItem[] = [];
Object.entries(bookmarks).forEach((entry) => {
output.push({
id: entry[0],
...entry[1],
});
});
output = output.sort((a, b) => {
const bookmarkA = bookmarks[a.id];
const bookmarkB = bookmarks[b.id];
const progressA = progressItems[a.id];
const progressB = progressItems[b.id];
const dateA = Math.max(bookmarkA.updatedAt, progressA?.updatedAt ?? 0);
const dateB = Math.max(bookmarkB.updatedAt, progressB?.updatedAt ?? 0);
return dateB - dateA;
});
return output;
}, [bookmarks, progressItems]);
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;
}
};
const handleLongPress = () => {
// Find the button by ID and simulate a click
const editButton = document.getElementById("edit-button-bookmark");
if (editButton) {
(editButton as HTMLButtonElement).click();
}
};
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
e.preventDefault(); // Prevent default touch action
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
};
const handleTouchEnd = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault(); // Prevent default mouse action
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
};
const handleMouseUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
};
const categorySlug = "bookmarks";
const SKELETON_COUNT = 10;
if (bookmarksLength === 0) return null;
return (
<>
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
icon={Icons.BOOKMARK}
className="ml-2 md:ml-8 mt-2 -mb-10"
>
<div className="mr-6">
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</div>
</SectionHeading>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
{items.length > 0
? items.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
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"
>
<WatchedMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeBookmark(media.id)}
/>
</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

@ -0,0 +1,190 @@
import React, { useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { CarouselNavButtons } from "@/pages/discover/components/CarouselNavButtons";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { MediaItem } from "@/utils/mediaTypes";
interface WatchingCarouselProps {
isMobile: boolean;
carouselRefs: React.MutableRefObject<{
[key: string]: HTMLDivElement | null;
}>;
onShowDetails?: (media: MediaItem) => void;
}
const LONG_PRESS_DURATION = 500; // 0.5 seconds
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 WatchingCarousel({
isMobile,
carouselRefs,
onShowDetails,
}: WatchingCarouselProps) {
const { t } = useTranslation();
const browser = !!window.chrome;
let isScrolling = false;
const [editing, setEditing] = useState(false);
const removeItem = useProgressStore((s) => s.removeItem);
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
const itemsLength = useProgressStore((state) => {
return Object.entries(state.items).filter(
(entry) => shouldShowProgress(entry[1]).show,
).length;
});
const progressItems = useProgressStore((state) => state.items);
const items = useMemo(() => {
const output: MediaItem[] = [];
Object.entries(progressItems)
.filter((entry) => shouldShowProgress(entry[1]).show)
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
.forEach((entry) => {
output.push({
id: entry[0],
...entry[1],
});
});
return output;
}, [progressItems]);
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;
}
};
const categorySlug = "continue-watching";
const SKELETON_COUNT = 10;
const handleLongPress = () => {
// Find the button by ID and simulate a click
const editButton = document.getElementById("edit-button-watching");
if (editButton) {
(editButton as HTMLButtonElement).click();
}
};
const handleTouchStart = (e: React.TouchEvent<HTMLDivElement>) => {
e.preventDefault(); // Prevent default touch action
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
};
const handleTouchEnd = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
};
const handleMouseDown = (e: React.MouseEvent<HTMLDivElement>) => {
e.preventDefault(); // Prevent default mouse action
pressTimerRef.current = setTimeout(handleLongPress, LONG_PRESS_DURATION);
};
const handleMouseUp = () => {
if (pressTimerRef.current) {
clearTimeout(pressTimerRef.current);
pressTimerRef.current = null;
}
};
if (itemsLength === 0) return null;
return (
<>
<SectionHeading
title={t("home.continueWatching.sectionTitle")}
icon={Icons.CLOCK}
className="ml-2 md:ml-8 mt-2 -mb-10"
>
<div className="mr-6">
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-watching"
/>
</div>
</SectionHeading>
<div className="relative overflow-hidden carousel-container md:pb-4">
<div
id={`carousel-${categorySlug}`}
className="grid grid-flow-col auto-cols-max gap-4 pt-0 overflow-x-scroll scrollbar-none rounded-xl overflow-y-hidden md:pl-8 md:pr-8"
ref={(el) => {
carouselRefs.current[categorySlug] = el;
}}
onWheel={handleWheel}
>
<div className="md:w-12" />
{items.length > 0
? items.map((media) => (
<div
key={media.id}
style={{ userSelect: "none" }}
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
}
onTouchStart={handleTouchStart}
onTouchEnd={handleTouchEnd}
onMouseDown={handleMouseDown}
onMouseUp={handleMouseUp}
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"
>
<WatchedMediaCard
key={media.id}
media={media}
onShowDetails={onShowDetails}
closable={editing}
onClose={() => removeItem(media.id)}
/>
</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

@ -213,6 +213,9 @@ export function AppearancePart(props: {
enableImageLogos: boolean;
setEnableImageLogos: (v: boolean) => void;
enableCarouselView: boolean;
setEnableCarouselView: (v: boolean) => void;
}) {
const { t } = useTranslation();
@ -362,6 +365,30 @@ export function AppearancePart(props: {
</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>
</div>
{/* Second Column - Themes */}

View file

@ -10,6 +10,7 @@ export interface PreferencesStore {
enableFeatured: boolean;
enableDetailsModal: boolean;
enableImageLogos: boolean;
enableCarouselView: boolean;
sourceOrder: string[];
enableSourceOrder: boolean;
proxyTmdb: boolean;
@ -21,6 +22,7 @@ export interface PreferencesStore {
setEnableFeatured(v: boolean): void;
setEnableDetailsModal(v: boolean): void;
setEnableImageLogos(v: boolean): void;
setEnableCarouselView(v: boolean): void;
setSourceOrder(v: string[]): void;
setEnableSourceOrder(v: boolean): void;
setProxyTmdb(v: boolean): void;
@ -36,6 +38,7 @@ export const usePreferencesStore = create(
enableFeatured: true, // enabled for testing
enableDetailsModal: false,
enableImageLogos: true,
enableCarouselView: true, // enabled for testing
sourceOrder: [],
enableSourceOrder: false,
proxyTmdb: false,
@ -74,6 +77,11 @@ export const usePreferencesStore = create(
s.enableImageLogos = v;
});
},
setEnableCarouselView(v) {
set((s) => {
s.enableCarouselView = v;
});
},
setSourceOrder(v) {
set((s) => {
s.sourceOrder = v;