mirror of
https://github.com/sussy-code/smov.git
synced 2024-12-20 14:37:43 +01:00
media grids
This commit is contained in:
parent
42402eb5c7
commit
e7981539e6
16 changed files with 136 additions and 112 deletions
|
@ -42,6 +42,7 @@
|
|||
]
|
||||
},
|
||||
"devDependencies": {
|
||||
"@tailwindcss/line-clamp": "^0.4.2",
|
||||
"@types/crypto-js": "^4.1.1",
|
||||
"@types/node": "^17.0.15",
|
||||
"@types/react": "^17.0.39",
|
||||
|
|
|
@ -37,7 +37,7 @@ export function SearchBarInput(props: SearchBarProps) {
|
|||
}
|
||||
|
||||
return (
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-300 transition-colors focus-within:bg-denim-400 hover:bg-denim-400 sm:flex-row sm:items-center">
|
||||
<div className="relative flex flex-col rounded-[28px] bg-denim-400 transition-colors focus-within:bg-denim-400 hover:bg-denim-500 sm:flex-row sm:items-center">
|
||||
<div className="pointer-events-none absolute left-5 top-0 bottom-0 flex max-h-14 items-center">
|
||||
<Icon icon={Icons.SEARCH} />
|
||||
</div>
|
||||
|
|
|
@ -6,11 +6,7 @@ import React, {
|
|||
} from "react";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
|
||||
import {
|
||||
Backdrop,
|
||||
BackdropContainer,
|
||||
useBackdrop,
|
||||
} from "@/components/layout/Backdrop";
|
||||
import { BackdropContainer, useBackdrop } from "@/components/layout/Backdrop";
|
||||
import { ButtonControlProps, ButtonControl } from "./ButtonControl";
|
||||
|
||||
export interface OptionItem {
|
||||
|
|
|
@ -12,9 +12,9 @@ export function IconPatch(props: IconPatchProps) {
|
|||
return (
|
||||
<div className={props.className || undefined} onClick={props.onClick}>
|
||||
<div
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-300 transition-[color,transform,border-color] duration-75 ${
|
||||
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[color,transform,border-color] duration-75 ${
|
||||
props.clickable
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-400 hover:text-white active:scale-125"
|
||||
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||
: ""
|
||||
} ${props.active ? "border-bink-600 bg-bink-100 text-bink-600" : ""}`}
|
||||
>
|
||||
|
|
|
@ -40,7 +40,7 @@ export function useBackdrop(): [
|
|||
return [setBackdrop, backdropProps, highlightedProps];
|
||||
}
|
||||
|
||||
export function Backdrop(props: BackdropProps) {
|
||||
function Backdrop(props: BackdropProps) {
|
||||
const clickEvent = props.onClick || (() => {});
|
||||
const animationEvent = props.onBackdropHide || (() => {});
|
||||
const [isVisible, setVisible, fadeProps] = useFade();
|
||||
|
@ -59,7 +59,7 @@ export function Backdrop(props: BackdropProps) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
className={`pointer-events-auto fixed left-0 right-0 top-0 h-screen w-screen bg-black bg-opacity-50 opacity-100 transition-opacity ${
|
||||
!isVisible ? "opacity-0" : ""
|
||||
}`}
|
||||
{...fadeProps}
|
||||
|
@ -99,9 +99,9 @@ export function BackdropContainer(
|
|||
return (
|
||||
<div ref={root}>
|
||||
{createPortal(
|
||||
<div className="absolute top-0 left-0 z-[999]">
|
||||
<div className="pointer-events-none fixed top-0 left-0 z-[999]">
|
||||
<Backdrop active={props.active} {...props} />
|
||||
<div ref={copy} className="absolute">
|
||||
<div ref={copy} className="pointer-events-auto absolute">
|
||||
{props.children}
|
||||
</div>
|
||||
</div>,
|
||||
|
|
|
@ -6,13 +6,14 @@ export function BrandPill(props: { clickable?: boolean }) {
|
|||
|
||||
return (
|
||||
<div
|
||||
className={`flex items-center space-x-2 rounded-full bg-bink-100 bg-opacity-50 px-4 py-2 text-bink-600 ${props.clickable
|
||||
? "transition-[transform,background-color] hover:scale-105 hover:bg-bink-200 hover:text-bink-700 active:scale-95"
|
||||
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-bink-400 hover:text-bink-700 active:scale-95"
|
||||
: ""
|
||||
}`}
|
||||
}`}
|
||||
>
|
||||
<Icon className="text-xl" icon={Icons.MOVIE_WEB} />
|
||||
<span className="font-semibold text-white">{t('global.name')}</span>
|
||||
<span className="font-semibold text-white">{t("global.name")}</span>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
|
18
src/components/layout/WideContainer.tsx
Normal file
18
src/components/layout/WideContainer.tsx
Normal file
|
@ -0,0 +1,18 @@
|
|||
import { ReactNode } from "react";
|
||||
|
||||
interface WideContainerProps {
|
||||
classNames?: string;
|
||||
children?: ReactNode;
|
||||
}
|
||||
|
||||
export function WideContainer(props: WideContainerProps) {
|
||||
return (
|
||||
<div
|
||||
className={`mx-auto w-[700px] max-w-full px-8 sm:px-4 ${
|
||||
props.classNames || ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -5,23 +5,20 @@ import {
|
|||
MWMediaMeta,
|
||||
MWMediaType,
|
||||
} from "@/providers";
|
||||
import { Icon, Icons } from "@/components/Icon";
|
||||
import { serializePortableMedia } from "@/hooks/usePortableMedia";
|
||||
import { DotList } from "@/components/text/DotList";
|
||||
|
||||
export interface MediaCardProps {
|
||||
media: MWMediaMeta;
|
||||
// eslint-disable-next-line react/no-unused-prop-types
|
||||
watchedPercentage: number;
|
||||
linkable?: boolean;
|
||||
series?: boolean;
|
||||
}
|
||||
|
||||
function MediaCardContent({
|
||||
media,
|
||||
linkable,
|
||||
watchedPercentage,
|
||||
series,
|
||||
}: MediaCardProps) {
|
||||
// TODO add progress back
|
||||
|
||||
function MediaCardContent({ media, series, linkable }: MediaCardProps) {
|
||||
const provider = getProviderFromId(media.providerId);
|
||||
|
||||
if (!provider) {
|
||||
|
@ -29,52 +26,31 @@ function MediaCardContent({
|
|||
}
|
||||
|
||||
return (
|
||||
<article
|
||||
className={`group relative mb-4 flex overflow-hidden rounded bg-denim-300 py-4 px-5 ${
|
||||
linkable ? "hover:bg-denim-400" : ""
|
||||
<div
|
||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||
linkable ? "hover:bg-opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
{/* progress background */}
|
||||
{watchedPercentage > 0 ? (
|
||||
<div className="absolute top-0 left-0 right-0 bottom-0">
|
||||
<div
|
||||
className="relative h-full bg-bink-300 bg-opacity-30"
|
||||
style={{
|
||||
width: `${watchedPercentage}%`,
|
||||
}}
|
||||
>
|
||||
<div className="absolute right-0 top-0 bottom-0 ml-auto w-40 bg-gradient-to-l from-bink-400 to-transparent opacity-40" />
|
||||
</div>
|
||||
</div>
|
||||
) : null}
|
||||
|
||||
<div className="relative flex flex-1">
|
||||
{/* card content */}
|
||||
<div className="flex-1">
|
||||
<h1 className="mb-1 font-bold text-white">
|
||||
{media.title}
|
||||
{series && media.seasonId && media.episodeId ? (
|
||||
<span className="ml-2 text-xs text-denim-700">
|
||||
S{media.seasonId} E{media.episodeId}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
/>
|
||||
</div>
|
||||
|
||||
{/* hoverable chevron */}
|
||||
<div
|
||||
className={`flex translate-x-3 items-center justify-end text-xl text-white opacity-0 transition-[opacity,transform] ${
|
||||
linkable ? "group-hover:translate-x-0 group-hover:opacity-100" : ""
|
||||
}`}
|
||||
>
|
||||
<Icon icon={Icons.CHEVRON_RIGHT} />
|
||||
</div>
|
||||
</div>
|
||||
</article>
|
||||
<article
|
||||
className={`relative mb-2 p-3 transition-transform duration-100 ${
|
||||
linkable ? "group-hover:scale-95" : ""
|
||||
}`}
|
||||
>
|
||||
<div className="mb-4 aspect-[2/3] w-full rounded-xl bg-denim-500" />
|
||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||
<span>{media.title}</span>
|
||||
{series && media.seasonId && media.episodeId ? (
|
||||
<span className="ml-2 text-xs text-denim-700">
|
||||
S{media.seasonId} E{media.episodeId}
|
||||
</span>
|
||||
) : null}
|
||||
</h1>
|
||||
<DotList
|
||||
className="text-xs"
|
||||
content={[provider.displayName, media.mediaType, media.year]}
|
||||
/>
|
||||
</article>
|
||||
</div>
|
||||
);
|
||||
}
|
||||
|
||||
|
|
11
src/components/media/MediaGrid.tsx
Normal file
11
src/components/media/MediaGrid.tsx
Normal file
|
@ -0,0 +1,11 @@
|
|||
interface MediaGridProps {
|
||||
children?: React.ReactNode;
|
||||
}
|
||||
|
||||
export function MediaGrid(props: MediaGridProps) {
|
||||
return (
|
||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3">
|
||||
{props.children}
|
||||
</div>
|
||||
);
|
||||
}
|
|
@ -1,10 +1,15 @@
|
|||
export interface TitleProps {
|
||||
children?: React.ReactNode;
|
||||
className?: string;
|
||||
}
|
||||
|
||||
export function Title(props: TitleProps) {
|
||||
return (
|
||||
<h1 className="mx-auto max-w-xs text-2xl font-bold text-white sm:text-3xl md:text-4xl">
|
||||
<h1
|
||||
className={`text-2xl font-bold text-white sm:text-3xl md:text-4xl ${
|
||||
props.className ?? ""
|
||||
}`}
|
||||
>
|
||||
{props.children}
|
||||
</h1>
|
||||
);
|
||||
|
|
|
@ -1,5 +1,6 @@
|
|||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import {
|
||||
getIfBookmarkedFromPortable,
|
||||
|
@ -20,9 +21,14 @@ function Bookmarks() {
|
|||
title={t("search.bookmarks") || "Bookmarks"}
|
||||
icon={Icons.BOOKMARK}
|
||||
>
|
||||
{bookmarks.map((v) => (
|
||||
<WatchedMediaCard key={[v.mediaId, v.providerId].join("|")} media={v} />
|
||||
))}
|
||||
<MediaGrid>
|
||||
{bookmarks.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
);
|
||||
}
|
||||
|
@ -44,13 +50,15 @@ function Watched() {
|
|||
title={t("search.continueWatching") || "Continue Watching"}
|
||||
icon={Icons.CLOCK}
|
||||
>
|
||||
{watchedItems.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
series
|
||||
/>
|
||||
))}
|
||||
<MediaGrid>
|
||||
{watchedItems.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
series
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -5,7 +5,7 @@ export function SearchLoadingView() {
|
|||
const { t } = useTranslation();
|
||||
return (
|
||||
<Loading
|
||||
className="my-24"
|
||||
className="mt-40"
|
||||
text={t("search.loading") || "Fetching your favourite shows..."}
|
||||
/>
|
||||
);
|
||||
|
|
|
@ -1,6 +1,7 @@
|
|||
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||
import { Icons } from "@/components/Icon";
|
||||
import { SectionHeading } from "@/components/layout/SectionHeading";
|
||||
import { MediaGrid } from "@/components/media/MediaGrid";
|
||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||
import { useLoading } from "@/hooks/useLoading";
|
||||
import { MWMassProviderOutput, MWQuery, SearchProviders } from "@/providers";
|
||||
|
@ -19,7 +20,7 @@ function SearchSuffix(props: {
|
|||
const icon: Icons = allFailed ? Icons.WARNING : Icons.EYE_SLASH;
|
||||
|
||||
return (
|
||||
<div className="my-24 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<div className="mt-40 flex flex-col items-center justify-center space-y-3 text-center">
|
||||
<IconPatch
|
||||
icon={icon}
|
||||
className={`text-xl ${allFailed ? "text-red-400" : "text-bink-600"}`}
|
||||
|
@ -83,12 +84,14 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
|||
title={t("search.headingTitle") || "Search results"}
|
||||
icon={Icons.SEARCH}
|
||||
>
|
||||
{results.results.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
<MediaGrid>
|
||||
{results.results.map((v) => (
|
||||
<WatchedMediaCard
|
||||
key={[v.mediaId, v.providerId].join("|")}
|
||||
media={v}
|
||||
/>
|
||||
))}
|
||||
</MediaGrid>
|
||||
</SectionHeading>
|
||||
) : null}
|
||||
|
||||
|
|
|
@ -5,8 +5,8 @@ import { SearchBarInput } from "@/components/SearchBar";
|
|||
import Sticky from "react-stickynode";
|
||||
import { Title } from "@/components/text/Title";
|
||||
import { useSearchQuery } from "@/hooks/useSearchQuery";
|
||||
import { WideContainer } from "@/components/layout/WideContainer";
|
||||
import { useTranslation } from "react-i18next";
|
||||
|
||||
import { SearchResultsPartial } from "./SearchResultsPartial";
|
||||
|
||||
export function SearchView() {
|
||||
|
@ -21,16 +21,16 @@ export function SearchView() {
|
|||
|
||||
return (
|
||||
<>
|
||||
<div className="relative z-10">
|
||||
<div className="relative z-10 mb-24">
|
||||
<Navigation bg={showBg} />
|
||||
<ThinContainer>
|
||||
<div className="mt-44 space-y-16 text-center">
|
||||
<div className="absolute left-0 bottom-0 right-0 flex h-0 justify-center">
|
||||
<div className="absolute bottom-4 h-[100vh] w-[300vh] rounded-[100%] bg-[#211D30]" />
|
||||
<div className="absolute bottom-4 h-[100vh] w-[300vh] rounded-[100%] bg-denim-300" />
|
||||
</div>
|
||||
<div className="relative z-20">
|
||||
<div className="mb-16">
|
||||
<Title>{t("search.title")}</Title>
|
||||
<Title className="mx-auto max-w-xs">{t("search.title")}</Title>
|
||||
</div>
|
||||
<Sticky enabled top={16} onStateChange={stickStateChanged}>
|
||||
<SearchBarInput
|
||||
|
@ -46,9 +46,9 @@ export function SearchView() {
|
|||
</div>
|
||||
</ThinContainer>
|
||||
</div>
|
||||
<ThinContainer>
|
||||
<WideContainer>
|
||||
<SearchResultsPartial search={search} />
|
||||
</ThinContainer>
|
||||
</WideContainer>
|
||||
</>
|
||||
);
|
||||
}
|
||||
|
|
|
@ -12,29 +12,29 @@ module.exports = {
|
|||
"bink-500": "#8D66B5",
|
||||
"bink-600": "#A87FD1",
|
||||
"bink-700": "#CD97D6",
|
||||
"denim-100": "#131119",
|
||||
"denim-200": "#1E1A29",
|
||||
"denim-300": "#282336",
|
||||
"denim-400": "#322D43",
|
||||
"denim-500": "#433D55",
|
||||
"denim-600": "#5A5370",
|
||||
"denim-700": "#817998",
|
||||
"denim-100": "#120F1D",
|
||||
"denim-200": "#191526",
|
||||
"denim-300": "#211D30",
|
||||
"denim-400": "#2B263D",
|
||||
"denim-500": "#38334A",
|
||||
"denim-600": "#504B64",
|
||||
"denim-700": "#7A758F"
|
||||
},
|
||||
|
||||
/* fonts */
|
||||
fontFamily: {
|
||||
"open-sans": "'Open Sans'",
|
||||
"open-sans": "'Open Sans'"
|
||||
},
|
||||
|
||||
/* animations */
|
||||
keyframes: {
|
||||
"loading-pin": {
|
||||
"0%, 40%, 100%": { height: "0.5em", "background-color": "#282336" },
|
||||
"20%": { height: "1em", "background-color": "white" },
|
||||
},
|
||||
"20%": { height: "1em", "background-color": "white" }
|
||||
}
|
||||
},
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" },
|
||||
},
|
||||
animation: { "loading-pin": "loading-pin 1.8s ease-in-out infinite" }
|
||||
}
|
||||
},
|
||||
plugins: [require("tailwind-scrollbar")],
|
||||
plugins: [require("tailwind-scrollbar"), require("@tailwindcss/line-clamp")]
|
||||
};
|
||||
|
|
19
yarn.lock
19
yarn.lock
|
@ -254,6 +254,11 @@
|
|||
"@swc/core-win32-ia32-msvc" "1.3.22"
|
||||
"@swc/core-win32-x64-msvc" "1.3.22"
|
||||
|
||||
"@tailwindcss/line-clamp@^0.4.2":
|
||||
"integrity" "sha512-HFzAQuqYCjyy/SX9sLGB1lroPzmcnWv1FHkIpmypte10hptf4oPUfucryMKovZh2u0uiS9U5Ty3GghWfEJGwVw=="
|
||||
"resolved" "https://registry.npmjs.org/@tailwindcss/line-clamp/-/line-clamp-0.4.2.tgz"
|
||||
"version" "0.4.2"
|
||||
|
||||
"@tootallnate/once@2":
|
||||
"version" "2.0.0"
|
||||
|
||||
|
@ -1942,16 +1947,16 @@
|
|||
"version" "1.1.4"
|
||||
|
||||
"json5@^1.0.1":
|
||||
"integrity" "sha512-aKS4WQjPenRxiQsC93MNfjx+nbF4PAdYzmd/1JIj8HYzqfbu86beTuNgXDzPknWk0n0uARlyewZo4s++ES36Ow=="
|
||||
"resolved" "https://registry.npmjs.org/json5/-/json5-1.0.1.tgz"
|
||||
"version" "1.0.1"
|
||||
"integrity" "sha512-g1MWMLBiz8FKi1e4w0UyVL3w+iJceWAFBAaBnnGKOpNa5f8TLktkbre1+s6oICydWAm+HRUGTmI+//xv2hvXYA=="
|
||||
"resolved" "https://registry.npmjs.org/json5/-/json5-1.0.2.tgz"
|
||||
"version" "1.0.2"
|
||||
dependencies:
|
||||
"minimist" "^1.2.0"
|
||||
|
||||
"json5@^2.2.0":
|
||||
"integrity" "sha512-1hqLFMSrGHRHxav9q9gNjJ5EXznIxGVO09xQRrwplcS8qs28pZ8s8hupZAmqDwZUmVZ2Qb2jnyPOWcDH8m8dlA=="
|
||||
"resolved" "https://registry.npmjs.org/json5/-/json5-2.2.1.tgz"
|
||||
"version" "2.2.1"
|
||||
"integrity" "sha512-XmOWe7eyHYH14cLdVPoyg+GOH3rYX++KpzrylJwSW98t3Nk+U8XOl8FWKOgwtzdb8lXGf6zYwDUzeHMWfxasyg=="
|
||||
"resolved" "https://registry.npmjs.org/json5/-/json5-2.2.3.tgz"
|
||||
"version" "2.2.3"
|
||||
|
||||
"jsonparse@^1.3.1":
|
||||
"version" "1.3.1"
|
||||
|
@ -3225,7 +3230,7 @@
|
|||
"resolved" "https://registry.npmjs.org/tailwind-scrollbar/-/tailwind-scrollbar-2.0.1.tgz"
|
||||
"version" "2.0.1"
|
||||
|
||||
"tailwindcss@^3.2.4", "tailwindcss@3.x":
|
||||
"tailwindcss@^3.2.4", "tailwindcss@>=2.0.0 || >=3.0.0 || >=3.0.0-alpha.1", "tailwindcss@3.x":
|
||||
"integrity" "sha512-AhwtHCKMtR71JgeYDaswmZXhPcW9iuI9Sp2LvZPo9upDZ7231ZJ7eA9RaURbhpXGVlrjX4cFNlB4ieTetEb7hQ=="
|
||||
"resolved" "https://registry.npmjs.org/tailwindcss/-/tailwindcss-3.2.4.tgz"
|
||||
"version" "3.2.4"
|
||||
|
|
Loading…
Reference in a new issue