Migration A

This commit is contained in:
Ivan Evans 2024-09-18 14:51:51 -06:00
parent c383dc1da4
commit 6575cb30a6
13 changed files with 306 additions and 49 deletions

View file

@ -18,9 +18,11 @@
<link rel="icon" type="image/png" sizes="16x16" href="/favicon-16x16.png" />
<link rel="mask-icon" href="/safari-pinned-tab.svg" color="#120f1d" />
<meta name="msapplication-TileColor" content="#120f1d" />
<meta name="theme-color" content="#120f1d" />
<meta name="theme-color" content="#000000" />
<meta name="apple-mobile-web-app-capable" content="yes" />
<meta name="apple-mobile-web-app-status-bar-style" content="black-translucent">
<link rel="apple-touch-startup-image"
media="screen and (device-width: 430px) and (device-height: 932px) and (-webkit-device-pixel-ratio: 3) and (orientation: landscape)"
href="/splash_screens/iPhone_15_Pro_Max__iPhone_15_Plus__iPhone_14_Pro_Max_landscape.png">

View file

@ -1,9 +1,11 @@
@import url('https://fonts.googleapis.com/css2?family=Lato:ital,wght@0,100;0,300;0,400;0,700;0,900;1,100;1,300;1,400;1,700;1,900&family=PT+Serif:ital,wght@0,400;0,700;1,400;1,700&display=swap');
@tailwind base;
@tailwind components;
@tailwind utilities;
html,
body {
font-family: "Lato", sans-serif !important;
@apply bg-background-main font-main text-type-text;
min-height: 100vh;
min-height: 100dvh;
@ -79,6 +81,16 @@ html[data-no-scroll], html[data-no-scroll] body {
min-height: 100dvh;
}
.info-button {
display: inline-block;
padding: 0.75em;
margin: -0.75em;
position: absolute;
bottom: 0;
right: 0;
transform: translate(-15px, -10px)
}
/*generated with Input range slider CSS style generator (version 20211225)
https://toughengineer.github.io/demo/slider-styler*/
:root {

View file

@ -1,5 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useCallback } from "react";
import { useCallback, useRef } from "react";
import { useTranslation } from "react-i18next";
import { Icon, Icons } from "@/components/Icon";
@ -7,31 +7,48 @@ import { Icon, Icons } from "@/components/Icon";
export interface EditButtonProps {
editing: boolean;
onEdit?: (editing: boolean) => void;
id?: string;
}
export function EditButton(props: EditButtonProps) {
const { t } = useTranslation();
const [parent] = useAutoAnimate<HTMLSpanElement>();
const buttonRef = useRef<HTMLButtonElement>(null);
const onClick = useCallback(() => {
props.onEdit?.(!props.editing);
}, [props]);
return (
<button
type="button"
onClick={onClick}
className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105"
>
<span ref={parent}>
{props.editing ? (
<span className="mx-2 sm:mx-4 whitespace-nowrap">
{t("home.mediaList.stopEditing")}
</span>
) : (
<Icon icon={Icons.EDIT} />
)}
</span>
</button>
<>
<button
ref={buttonRef}
type="button"
onClick={onClick}
className="flex h-12 items-center overflow-hidden rounded-full bg-background-secondary px-4 py-2 text-white transition-[background-color,transform] hover:bg-background-secondaryHover active:scale-105"
id={props.id} // Assign id to the button
>
<span ref={parent}>
{props.editing ? (
<span className="mx-2 sm:mx-4 whitespace-nowrap">
{t("home.mediaList.stopEditing")}
</span>
) : (
<Icon icon={Icons.EDIT} />
)}
</span>
</button>
{props.editing && (
<button
type="button"
onClick={onClick}
className="fixed bottom-9 right-7 z-50 flex h-12 w-12 items-center justify-center rounded-full bg-background-secondary text-white border-2 border-green-500 transition-[background-color,transform,box-shadow] hover:bg-background-secondaryHover hover:scale-110 cursor-pointer"
id={props.id ? `${props.id}-check` : undefined} // Optionally use a different id for this button
>
<Icon icon={Icons.CHECKMARK} />
</button>
)}
</>
);
}

View file

@ -10,7 +10,9 @@ export function WideContainer(props: WideContainerProps) {
return (
<div
className={`mx-auto max-w-full px-8 ${
props.ultraWide ? "w-[1300px] sm:px-16" : "w-[900px] sm:px-8"
props.ultraWide
? "w-[1300px] 2xl:w-[2000px] 3xl:w-[2400px] 4xl:w-[2800px]"
: "w-[900px] 2xl:w-[1400px] 3xl:w-[1600px] 4xl:w-[1800px]"
} ${props.classNames || ""}`}
>
{props.children}

View file

@ -12,7 +12,7 @@ import { MediaItem } from "@/utils/mediaTypes";
import { MediaBookmarkButton } from "./MediaBookmark";
import { IconPatch } from "../buttons/IconPatch";
import { Icons } from "../Icon";
import { Icon, Icons } from "../Icon";
export interface MediaCardProps {
media: MediaItem;
@ -179,7 +179,29 @@ function MediaCardContent({
<h1 className="mb-1 line-clamp-3 max-h-[4.5rem] text-ellipsis break-words font-bold text-white">
<span>{media.title}</span>
</h1>
<DotList className="text-xs" content={dotListContent} />
<div className="media-info-container justify-content-center flex flex-wrap">
<DotList className="text-xs" content={dotListContent} />
<button
className="info-button"
type="button"
onClick={(e) => {
e.preventDefault();
const searchParam = encodeURIComponent(encodeURI(media.id));
const url =
media.type === "movie"
? `https://www.themoviedb.org/movie/${searchParam}`
: `https://www.themoviedb.org/tv/${searchParam}`;
window.open(url, "_blank");
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.CIRCLE_QUESTION}
/>
</button>
</div>
</Flare.Child>
</Flare.Base>
);

View file

@ -8,7 +8,7 @@ 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"
className="grid grid-cols-2 gap-6 sm:grid-cols-3 md:grid-cols-4 2xl:grid-cols-6 3xl:grid-cols-8 4xl:grid-cols-10"
ref={ref}
>
{props.children}

View file

@ -23,6 +23,25 @@ import { SubPageLayout } from "./layouts/SubPageLayout";
import { Icon, Icons } from "../components/Icon";
import { PageTitle } from "./parts/util/PageTitle";
const editorPicks = [
{ 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
];
export function Discover() {
const { t } = useTranslation();
const [genres, setGenres] = useState<Genre[]>([]);
@ -48,6 +67,45 @@ export function Discover() {
const [countdownTimeout, setCountdownTimeout] =
useState<NodeJS.Timeout | null>(null);
const [editorPicksData, setEditorPicksData] = useState<Media[]>([]);
useEffect(() => {
// Function to shuffle array
const shuffleArray = (array: any[]) => {
for (let i = array.length - 1; i > 0; i -= 1) {
const j = Math.floor(Math.random() * (i + 1));
[array[i], array[j]] = [array[j], array[i]];
}
return array;
};
const fetchEditorPicks = async () => {
try {
// Shuffle the editorPicks array
const shuffledPicks = shuffleArray([...editorPicks]);
const promises = shuffledPicks.map(async (pick) => {
const endpoint =
pick.type === "movie" ? `/movie/${pick.id}` : `/tv/${pick.id}`;
const data = await get<any>(endpoint, {
api_key: conf().TMDB_READ_API_KEY,
language: "en-US",
});
return {
...data,
type: pick.type,
};
});
const results = await Promise.all(promises);
setEditorPicksData(results);
} catch (error) {
console.error("Error fetching editor picks:", error);
}
};
fetchEditorPicks();
}, []);
useEffect(() => {
const fetchMoviesForCategory = async (category: Category) => {
try {
@ -315,11 +373,13 @@ export function Discover() {
const displayCategory =
category === "Now Playing"
? "In Cinemas"
: category.includes("Movie")
? `${category}s`
: isTVShow
? `${category} Shows`
: `${category} Movies`;
: category === "Editor Picks" // Check for "Editor Picks" specifically
? category
: category.includes("Movie")
? `${category}s`
: isTVShow
? `${category} Shows`
: `${category} Movies`;
// https://tailwindcss.com/docs/border-style
return (
@ -532,6 +592,16 @@ export function Discover() {
</p>
</div>
)}
{/* Editor Picks Section */}
<div className="mt-8">
{editorPicksData.length > 0 && (
<div className="mt-8">
{renderMovies(editorPicksData, "Editor Picks")}
</div>
)}
</div>
<div className="flex flex-col">
{categories.map((category) => (
<div

View file

@ -74,8 +74,8 @@ export function HomePage() {
) : (
<>
<div className="flex flex-col gap-8">
<BookmarksPart onItemsChange={setShowBookmarks} />
<WatchingPart onItemsChange={setShowWatching} />
<BookmarksPart onItemsChange={setShowBookmarks} />
</div>
{!(showBookmarks || showWatching) ? (
<div className="flex flex-col items-center justify-center">

View file

@ -1,5 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
@ -11,6 +11,8 @@ import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
import { MediaItem } from "@/utils/mediaTypes";
const LONG_PRESS_DURATION = 500; // 0.5 seconds
export function BookmarksPart({
onItemsChange,
}: {
@ -23,6 +25,8 @@ export function BookmarksPart({
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
const items = useMemo(() => {
let output: MediaItem[] = [];
Object.entries(bookmarks).forEach((entry) => {
@ -49,15 +53,60 @@ export function BookmarksPart({
onItemsChange(items.length > 0);
}, [items, onItemsChange]);
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;
}
};
if (items.length === 0) return null;
return (
<div>
<div
className="relative"
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
} // 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
>
<SectionHeading
title={t("home.bookmarks.sectionTitle") || "Bookmarks"}
icon={Icons.BOOKMARK}
>
<EditButton editing={editing} onEdit={setEditing} />
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-bookmark"
/>
</SectionHeading>
<MediaGrid ref={gridRef}>
{items.map((v) => (

View file

@ -1,5 +1,5 @@
import { useAutoAnimate } from "@formkit/auto-animate/react";
import { useEffect, useMemo, useState } from "react";
import { useEffect, useMemo, useRef, useState } from "react";
import { useTranslation } from "react-i18next";
import { EditButton } from "@/components/buttons/EditButton";
@ -7,25 +7,27 @@ import { Icons } from "@/components/Icon";
import { SectionHeading } from "@/components/layout/SectionHeading";
import { MediaGrid } from "@/components/media/MediaGrid";
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
import { useBookmarkStore } from "@/stores/bookmarks";
import { useProgressStore } from "@/stores/progress";
import { shouldShowProgress } from "@/stores/progress/utils";
import { MediaItem } from "@/utils/mediaTypes";
const LONG_PRESS_DURATION = 500; // 0.5 seconds
export function WatchingPart({
onItemsChange,
}: {
onItemsChange: (hasItems: boolean) => void;
}) {
const { t } = useTranslation();
const bookmarks = useBookmarkStore((s) => s.bookmarks);
const progressItems = useProgressStore((s) => s.items);
const removeItem = useProgressStore((s) => s.removeItem);
const [editing, setEditing] = useState(false);
const [gridRef] = useAutoAnimate<HTMLDivElement>();
const pressTimerRef = useRef<NodeJS.Timeout | null>(null);
const sortedProgressItems = useMemo(() => {
let output: MediaItem[] = [];
const output: MediaItem[] = [];
Object.entries(progressItems)
.filter((entry) => shouldShowProgress(entry[1]).show)
.sort((a, b) => b[1].updatedAt - a[1].updatedAt)
@ -36,35 +38,79 @@ export function WatchingPart({
});
});
output = output.filter((v) => {
const isBookMarked = !!bookmarks[v.id];
return !isBookMarked;
});
return output;
}, [progressItems, bookmarks]);
}, [progressItems]);
useEffect(() => {
onItemsChange(sortedProgressItems.length > 0);
}, [sortedProgressItems, onItemsChange]);
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 (sortedProgressItems.length === 0) return null;
return (
<div>
<div
className="relative"
onContextMenu={(e: React.MouseEvent<HTMLDivElement>) =>
e.preventDefault()
} // Prevent right-click context menu
>
<SectionHeading
title={t("home.continueWatching.sectionTitle")}
icon={Icons.CLOCK}
>
<EditButton editing={editing} onEdit={setEditing} />
<EditButton
editing={editing}
onEdit={setEditing}
id="edit-button-watching"
/>
</SectionHeading>
<MediaGrid ref={gridRef}>
{sortedProgressItems.map((v) => (
<WatchedMediaCard
key={v.id}
media={v}
closable={editing}
onClose={() => removeItem(v.id)}
/>
<div
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)}
/>
</div>
))}
</MediaGrid>
</div>

View file

@ -1,7 +1,10 @@
import { ReactNode } from "react";
import { useParams } from "react-router-dom";
import { Icon, Icons } from "@/components/Icon";
import { BrandPill } from "@/components/layout/BrandPill";
import { Player } from "@/components/player";
import { usePlayerMeta } from "@/components/player/hooks/usePlayerMeta";
import { useShouldShowControls } from "@/components/player/hooks/useShouldShowControls";
import { useIsMobile } from "@/hooks/useIsMobile";
import { PlayerMeta, playerStatus } from "@/stores/player/slices/source";
@ -15,10 +18,17 @@ export interface PlayerPartProps {
}
export function PlayerPart(props: PlayerPartProps) {
const params = useParams<{
media: string;
episode?: string;
season?: string;
}>();
const media = params.media;
const { showTargets, showTouchTargets } = useShouldShowControls();
const status = usePlayerStore((s) => s.status);
const { isMobile } = useIsMobile();
const isLoading = usePlayerStore((s) => s.mediaPlaying.isLoading);
const { playerMeta: meta } = usePlayerMeta();
return (
<Player.Container onLoad={props.onLoad} showingControls={showTargets}>
@ -60,6 +70,30 @@ export function PlayerPart(props: PlayerPartProps) {
<Player.BackLink url={props.backUrl} />
<span className="text mx-3 text-type-secondary">/</span>
<Player.Title />
<button
type="button"
onClick={(e) => {
e.preventDefault();
if (!media) return;
const id = media
.replace("tmdb-tv-", "")
.replace("tmdb-movie-", "");
let url;
if (meta?.type === "movie") {
url = `https://www.themoviedb.org/movie/${id}`;
} else {
url = `https://www.themoviedb.org/tv/${id}`;
}
window.open(url, "_blank");
}}
>
<Icon
className="text-xs font-semibold text-type-secondary"
icon={Icons.CIRCLE_QUESTION}
/>
</button>
<Player.BookmarkButton />
</div>
<div className="text-center hidden xl:flex justify-center items-center">

View file

@ -12,6 +12,9 @@ const config: Config = {
/* breakpoints */
screens: {
ssm: "400px",
'2xl': '1921px', // Custom breakpoint for screens at least 1920px wide
'3xl': '2650px', // Custom breakpoint for screens at least 2650px wide
'4xl': '3840px', // Custom breakpoint for screens at least 4096px wide
},
/* fonts */

View file

@ -70,8 +70,8 @@ export default defineConfig(({ mode }) => {
name: "sudo-flix",
short_name: "sudo-flix",
description: "Watch your favorite shows and movies for free with no ads ever! (っ'ヮ'c)",
theme_color: "#120f1d",
background_color: "#120f1d",
theme_color: "#000000",
background_color: "#000000",
display: "standalone",
start_url: "/",
icons: [