mirror of
https://github.com/p-stream/p-stream.git
synced 2026-03-11 17:55:33 +00:00
add all movie lists page (letterboxd)
use backend urls
This commit is contained in:
parent
9b6fbf8aa8
commit
d182ccc680
5 changed files with 230 additions and 0 deletions
|
|
@ -949,6 +949,8 @@
|
|||
"loading": "Loading...",
|
||||
"back": "Go back"
|
||||
},
|
||||
"viewLists": "View All Movie Lists",
|
||||
"allLists": "All Movie Lists",
|
||||
"scrollToTop": "Back to top"
|
||||
},
|
||||
"fedapi": {
|
||||
|
|
|
|||
50
src/backend/metadata/letterboxd.ts
Normal file
50
src/backend/metadata/letterboxd.ts
Normal file
|
|
@ -0,0 +1,50 @@
|
|||
import { conf } from "@/utils/setup/config";
|
||||
|
||||
export interface TmdbMovie {
|
||||
adult: boolean;
|
||||
backdrop_path: string | null;
|
||||
genre_ids: number[];
|
||||
id: number;
|
||||
original_language: string;
|
||||
original_title: string;
|
||||
overview: string;
|
||||
popularity: number;
|
||||
poster_path: string | null;
|
||||
release_date: string;
|
||||
title: string;
|
||||
video: boolean;
|
||||
vote_average: number;
|
||||
vote_count: number;
|
||||
}
|
||||
|
||||
export interface ListMetadata {
|
||||
originalFilmCount: number;
|
||||
foundTmdbMovies: number;
|
||||
expectedItemCount: number | null;
|
||||
workingSelector: string;
|
||||
}
|
||||
|
||||
export interface LetterboxdList {
|
||||
listName: string;
|
||||
listUrl: string;
|
||||
tmdbMovies: TmdbMovie[];
|
||||
metadata: ListMetadata;
|
||||
}
|
||||
|
||||
export interface LetterboxdResponse {
|
||||
lists: LetterboxdList[];
|
||||
}
|
||||
|
||||
// Base function to fetch from Letterboxd API
|
||||
async function fetchFromLetterboxd<T = LetterboxdResponse>(
|
||||
endpoint: string,
|
||||
): Promise<T> {
|
||||
const response = await fetch(`${conf().BACKEND_URL}${endpoint}`);
|
||||
if (!response.ok) {
|
||||
throw new Error(`Failed to fetch from ${endpoint}: ${response.statusText}`);
|
||||
}
|
||||
return response.json();
|
||||
}
|
||||
|
||||
// Get Letterboxd lists with TMDB movie information
|
||||
export const getLetterboxdLists = () => fetchFromLetterboxd("/letterboxd");
|
||||
159
src/pages/discover/AllMovieLists.tsx
Normal file
159
src/pages/discover/AllMovieLists.tsx
Normal file
|
|
@ -0,0 +1,159 @@
|
|||
import { t } from "i18next";
|
||||
import { useEffect, useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { TmdbMovie, getLetterboxdLists } from "@/backend/metadata/letterboxd";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { MediaCard } from "@/components/media/MediaCard";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
import { Heading1 } from "@/components/utils/Text";
|
||||
import { SubPageLayout } from "@/pages/layouts/SubPageLayout";
|
||||
import { useDiscoverStore } from "@/stores/discover";
|
||||
import { MediaItem } from "@/utils/mediaTypes";
|
||||
|
||||
import { MediaCarousel } from "./components/MediaCarousel";
|
||||
|
||||
export function DiscoverMore() {
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const [letterboxdLists, setLetterboxdLists] = useState<any[]>([]);
|
||||
const detailsModal = useModal("discover-details");
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const navigate = useNavigate();
|
||||
const { lastView } = useDiscoverStore();
|
||||
|
||||
useEffect(() => {
|
||||
const fetchLetterboxdLists = async () => {
|
||||
try {
|
||||
const response = await getLetterboxdLists();
|
||||
setLetterboxdLists(response.lists);
|
||||
} catch (error) {
|
||||
console.error("Failed to fetch Letterboxd lists:", error);
|
||||
}
|
||||
};
|
||||
|
||||
fetchLetterboxdLists();
|
||||
}, []);
|
||||
|
||||
const handleShowDetails = async (media: MediaItem) => {
|
||||
setDetailsData({
|
||||
id: Number(media.id),
|
||||
type: media.type === "movie" ? "movie" : "show",
|
||||
});
|
||||
detailsModal.show();
|
||||
};
|
||||
|
||||
const handleBack = () => {
|
||||
if (lastView) {
|
||||
navigate(lastView.url);
|
||||
window.scrollTo(0, lastView.scrollPosition);
|
||||
} else {
|
||||
navigate(-1);
|
||||
}
|
||||
};
|
||||
|
||||
const handleWheel = (e: React.WheelEvent) => {
|
||||
if (Math.abs(e.deltaX) > Math.abs(e.deltaY)) {
|
||||
e.stopPropagation();
|
||||
e.preventDefault();
|
||||
}
|
||||
};
|
||||
|
||||
return (
|
||||
<SubPageLayout>
|
||||
<WideContainer>
|
||||
<div className="flex items-center justify-between gap-8">
|
||||
<Heading1 className="text-2xl font-bold text-white">
|
||||
{t("discover.allLists")}
|
||||
</Heading1>
|
||||
</div>
|
||||
<div className="flex items-center gap-4 pb-8">
|
||||
<button
|
||||
type="button"
|
||||
onClick={handleBack}
|
||||
className="flex items-center text-white hover:text-gray-300 transition-colors"
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.ARROW_LEFT} />
|
||||
<span className="ml-2">{t("discover.page.back")}</span>
|
||||
</button>
|
||||
</div>
|
||||
</WideContainer>
|
||||
<WideContainer ultraWide>
|
||||
{/* Latest Movies */}
|
||||
<MediaCarousel
|
||||
content={{ type: "latest", fallback: "nowPlaying" }}
|
||||
isTVShow={false}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
|
||||
{/* Latest TV Shows */}
|
||||
<MediaCarousel
|
||||
content={{ type: "latesttv", fallback: "onTheAir" }}
|
||||
isTVShow
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
|
||||
{/* Top Rated Movies */}
|
||||
<MediaCarousel
|
||||
content={{ type: "topRated" }}
|
||||
isTVShow={false}
|
||||
carouselRefs={carouselRefs}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
|
||||
{/* Letterboxd Lists */}
|
||||
{letterboxdLists.map((list) => (
|
||||
<div key={list.listUrl}>
|
||||
<div className="flex items-center justify-between ml-2 md:ml-8 mt-2">
|
||||
<div className="flex flex-col">
|
||||
<div className="flex items-center gap-4">
|
||||
<h2 className="text-2xl cursor-default font-bold text-white md:text-2xl pl-5 text-balance">
|
||||
{list.listName}
|
||||
</h2>
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
<div className="relative overflow-hidden carousel-container md:pb-4">
|
||||
<div
|
||||
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[list.listUrl] = el;
|
||||
}}
|
||||
onWheel={handleWheel}
|
||||
>
|
||||
<div className="md:w-12" />
|
||||
{list.tmdbMovies.map((movie: TmdbMovie) => (
|
||||
<div
|
||||
key={movie.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
|
||||
media={{
|
||||
id: movie.id.toString(),
|
||||
title: movie.title,
|
||||
poster: movie.poster_path
|
||||
? `https://image.tmdb.org/t/p/w342${movie.poster_path}`
|
||||
: "/placeholder.png",
|
||||
type: "movie",
|
||||
year: movie.release_date
|
||||
? parseInt(movie.release_date.split("-")[0], 10)
|
||||
: undefined,
|
||||
}}
|
||||
onShowDetails={handleShowDetails}
|
||||
/>
|
||||
</div>
|
||||
))}
|
||||
<div className="md:w-12" />
|
||||
</div>
|
||||
</div>
|
||||
</div>
|
||||
))}
|
||||
</WideContainer>
|
||||
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
|
||||
</SubPageLayout>
|
||||
);
|
||||
}
|
||||
|
|
@ -1,5 +1,9 @@
|
|||
import classNames from "classnames";
|
||||
import { t } from "i18next";
|
||||
import { useRef, useState } from "react";
|
||||
import { useNavigate } from "react-router-dom";
|
||||
|
||||
import { Button } from "@/components/buttons/Button";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { DetailsModal } from "@/components/overlays/details/DetailsModal";
|
||||
import { useModal } from "@/components/overlays/Modal";
|
||||
|
|
@ -16,6 +20,7 @@ import { ScrollToTopButton } from "./components/ScrollToTopButton";
|
|||
export function DiscoverContent() {
|
||||
const { selectedCategory, setSelectedCategory } = useDiscoverStore();
|
||||
const [detailsData, setDetailsData] = useState<any>();
|
||||
const navigate = useNavigate();
|
||||
const detailsModal = useModal("discover-details");
|
||||
const carouselRefs = useRef<{ [key: string]: HTMLDivElement | null }>({});
|
||||
const progressItems = useProgressStore((state) => state.items);
|
||||
|
|
@ -222,6 +227,18 @@ export function DiscoverContent() {
|
|||
</LazyTabContent>
|
||||
</WideContainer>
|
||||
|
||||
{/* View All Button */}
|
||||
<div
|
||||
className={classNames(
|
||||
"flex justify-center mt-8 mb-12",
|
||||
isMoviesTab ? "block" : "hidden",
|
||||
)}
|
||||
>
|
||||
<Button theme="purple" onClick={() => navigate("/discover/all")}>
|
||||
{t("discover.viewLists")}
|
||||
</Button>
|
||||
</div>
|
||||
|
||||
<ScrollToTopButton />
|
||||
|
||||
{detailsData && <DetailsModal id="discover-details" data={detailsData} />}
|
||||
|
|
|
|||
|
|
@ -15,6 +15,7 @@ import { useOnlineListener } from "@/hooks/usePing";
|
|||
import { AboutPage } from "@/pages/About";
|
||||
import { AdminPage } from "@/pages/admin/AdminPage";
|
||||
import VideoTesterView from "@/pages/developer/VideoTesterView";
|
||||
import { DiscoverMore } from "@/pages/discover/AllMovieLists";
|
||||
import { Discover } from "@/pages/discover/Discover";
|
||||
import { MoreContent } from "@/pages/discover/MoreContent";
|
||||
import { DmcaPage } from "@/pages/Dmca";
|
||||
|
|
@ -178,6 +179,7 @@ function App() {
|
|||
element={<MoreContent />}
|
||||
/>
|
||||
<Route path="/discover/more/:category" element={<MoreContent />} />
|
||||
<Route path="/discover/all" element={<DiscoverMore />} />
|
||||
{/* Settings page */}
|
||||
<Route
|
||||
path="/settings"
|
||||
|
|
|
|||
Loading…
Reference in a new issue