mirror of
https://github.com/movie-web/movie-web.git
synced 2024-12-25 07:01:48 +01:00
bookmarks, progress and editing of those
This commit is contained in:
parent
fb96026195
commit
02cc4b7f1d
@ -4,6 +4,7 @@
|
|||||||
"private": true,
|
"private": true,
|
||||||
"homepage": "https://movie.squeezebox.dev",
|
"homepage": "https://movie.squeezebox.dev",
|
||||||
"dependencies": {
|
"dependencies": {
|
||||||
|
"@formkit/auto-animate": "^1.0.0-beta.5",
|
||||||
"@headlessui/react": "^1.5.0",
|
"@headlessui/react": "^1.5.0",
|
||||||
"crypto-js": "^4.1.1",
|
"crypto-js": "^4.1.1",
|
||||||
"fscreen": "^1.2.0",
|
"fscreen": "^1.2.0",
|
||||||
|
@ -3,6 +3,7 @@ import { memo } from "react";
|
|||||||
export enum Icons {
|
export enum Icons {
|
||||||
SEARCH = "search",
|
SEARCH = "search",
|
||||||
BOOKMARK = "bookmark",
|
BOOKMARK = "bookmark",
|
||||||
|
BOOKMARK_OUTLINE = "bookmark_outline",
|
||||||
CLOCK = "clock",
|
CLOCK = "clock",
|
||||||
EYE_SLASH = "eyeSlash",
|
EYE_SLASH = "eyeSlash",
|
||||||
ARROW_LEFT = "arrowLeft",
|
ARROW_LEFT = "arrowLeft",
|
||||||
@ -23,6 +24,7 @@ export enum Icons {
|
|||||||
VOLUME = "volume",
|
VOLUME = "volume",
|
||||||
VOLUME_X = "volume_x",
|
VOLUME_X = "volume_x",
|
||||||
X = "x",
|
X = "x",
|
||||||
|
EDIT = "edit",
|
||||||
}
|
}
|
||||||
|
|
||||||
export interface IconProps {
|
export interface IconProps {
|
||||||
@ -53,6 +55,8 @@ const iconList: Record<Icons, string> = {
|
|||||||
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
volume: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 640 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M533.6 32.5C598.5 85.3 640 165.8 640 256s-41.5 170.8-106.4 223.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C557.5 398.2 592 331.2 592 256s-34.5-142.2-88.7-186.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM473.1 107c43.2 35.2 70.9 88.9 70.9 149s-27.7 113.8-70.9 149c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C475.3 341.3 496 301.1 496 256s-20.7-85.3-53.2-111.8c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zm-60.5 74.5C434.1 199.1 448 225.9 448 256s-13.9 56.9-35.4 74.5c-10.3 8.4-25.4 6.8-33.8-3.5s-6.8-25.4 3.5-33.8C393.1 284.4 400 271 400 256s-6.9-28.4-17.7-37.3c-10.3-8.4-11.8-23.5-3.5-33.8s23.5-11.8 33.8-3.5zM301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3z"/></svg>`,
|
||||||
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
volume_x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 576 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M301.1 34.8C312.6 40 320 51.4 320 64V448c0 12.6-7.4 24-18.9 29.2s-25 3.1-34.4-5.3L131.8 352H64c-35.3 0-64-28.7-64-64V224c0-35.3 28.7-64 64-64h67.8L266.7 40.1c9.4-8.4 22.9-10.4 34.4-5.3zM425 167l55 55 55-55c9.4-9.4 24.6-9.4 33.9 0s9.4 24.6 0 33.9l-55 55 55 55c9.4 9.4 9.4 24.6 0 33.9s-24.6 9.4-33.9 0l-55-55-55 55c-9.4 9.4-24.6 9.4-33.9 0s-9.4-24.6 0-33.9l55-55-55-55c-9.4-9.4-9.4-24.6 0-33.9s24.6-9.4 33.9 0z"/></svg>`,
|
||||||
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
x: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 320 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M310.6 150.6c12.5-12.5 12.5-32.8 0-45.3s-32.8-12.5-45.3 0L160 210.7 54.6 105.4c-12.5-12.5-32.8-12.5-45.3 0s-12.5 32.8 0 45.3L114.7 256 9.4 361.4c-12.5 12.5-12.5 32.8 0 45.3s32.8 12.5 45.3 0L160 301.3 265.4 406.6c12.5 12.5 32.8 12.5 45.3 0s12.5-32.8 0-45.3L205.3 256 310.6 150.6z"/></svg>`,
|
||||||
|
edit: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 512 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M362.7 19.3L314.3 67.7 444.3 197.7l48.4-48.4c25-25 25-65.5 0-90.5L453.3 19.3c-25-25-65.5-25-90.5 0zm-71 71L58.6 323.5c-10.4 10.4-18 23.3-22.2 37.4L1 481.2C-1.5 489.7 .8 498.8 7 505s15.3 8.5 23.7 6.1l120.3-35.4c14.1-4.2 27-11.8 37.4-22.2L421.7 220.3 291.7 90.3z"/></svg>`,
|
||||||
|
bookmark_outline: `<svg xmlns="http://www.w3.org/2000/svg" width="1em" height="1em" viewBox="0 0 384 512"><!--! Font Awesome Pro 6.2.1 by @fontawesome - https://fontawesome.com License - https://fontawesome.com/license (Commercial License) Copyright 2022 Fonticons, Inc. --><path fill="currentColor" d="M336 0h-288C21.49 0 0 21.49 0 48v431.9c0 24.7 26.79 40.08 48.12 27.64L192 423.6l143.9 83.93C357.2 519.1 384 504.6 384 479.9V48C384 21.49 362.5 0 336 0zM336 452L192 368l-144 84V54C48 50.63 50.63 48 53.1 48h276C333.4 48 336 50.63 336 54V452z"/></svg>`,
|
||||||
};
|
};
|
||||||
|
|
||||||
export const Icon = memo((props: IconProps) => {
|
export const Icon = memo((props: IconProps) => {
|
||||||
|
32
src/components/buttons/EditButton.tsx
Normal file
32
src/components/buttons/EditButton.tsx
Normal file
@ -0,0 +1,32 @@
|
|||||||
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
import { useCallback } from "react";
|
||||||
|
import { ButtonControl } from "./ButtonControl";
|
||||||
|
|
||||||
|
export interface EditButtonProps {
|
||||||
|
editing: boolean;
|
||||||
|
onEdit?: (editing: boolean) => void;
|
||||||
|
}
|
||||||
|
|
||||||
|
export function EditButton(props: EditButtonProps) {
|
||||||
|
const [parent] = useAutoAnimate<HTMLSpanElement>();
|
||||||
|
|
||||||
|
const onClick = useCallback(() => {
|
||||||
|
props.onEdit?.(!props.editing);
|
||||||
|
}, [props]);
|
||||||
|
|
||||||
|
return (
|
||||||
|
<ButtonControl
|
||||||
|
onClick={onClick}
|
||||||
|
className="flex h-12 items-center overflow-hidden rounded-full bg-denim-400 px-4 py-2 text-white transition-[background-color,transform] hover:bg-denim-500 active:scale-105"
|
||||||
|
>
|
||||||
|
<span ref={parent}>
|
||||||
|
{props.editing ? (
|
||||||
|
<span className="mx-4">Stop editing</span>
|
||||||
|
) : (
|
||||||
|
<Icon icon={Icons.EDIT} />
|
||||||
|
)}
|
||||||
|
</span>
|
||||||
|
</ButtonControl>
|
||||||
|
);
|
||||||
|
}
|
@ -6,17 +6,24 @@ export interface IconPatchProps {
|
|||||||
clickable?: boolean;
|
clickable?: boolean;
|
||||||
className?: string;
|
className?: string;
|
||||||
icon: Icons;
|
icon: Icons;
|
||||||
|
transparent?: boolean;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function IconPatch(props: IconPatchProps) {
|
export function IconPatch(props: IconPatchProps) {
|
||||||
|
const clickableClasses = props.clickable
|
||||||
|
? "cursor-pointer hover:scale-110 hover:bg-denim-600 hover:text-white active:scale-125"
|
||||||
|
: "";
|
||||||
|
const transparentClasses = props.transparent
|
||||||
|
? "bg-opacity-0 hover:bg-opacity-50"
|
||||||
|
: "";
|
||||||
|
const activeClasses = props.active
|
||||||
|
? "border-bink-600 bg-bink-100 text-bink-600"
|
||||||
|
: "";
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className={props.className || undefined} onClick={props.onClick}>
|
<div className={props.className || undefined} onClick={props.onClick}>
|
||||||
<div
|
<div
|
||||||
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 ${
|
className={`flex h-12 w-12 items-center justify-center rounded-full border-2 border-transparent bg-denim-500 transition-[background-color,color,transform,border-color] duration-75 ${transparentClasses} ${clickableClasses} ${activeClasses}`}
|
||||||
props.clickable
|
|
||||||
? "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" : ""}`}
|
|
||||||
>
|
>
|
||||||
<Icon icon={props.icon} />
|
<Icon icon={props.icon} />
|
||||||
</div>
|
</div>
|
||||||
|
@ -20,8 +20,8 @@ export function SectionHeading(props: SectionHeadingProps) {
|
|||||||
) : null}
|
) : null}
|
||||||
{props.title}
|
{props.title}
|
||||||
</p>
|
</p>
|
||||||
|
{props.children}
|
||||||
</div>
|
</div>
|
||||||
{props.children}
|
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -2,6 +2,8 @@ import { Link } from "react-router-dom";
|
|||||||
import { DotList } from "@/components/text/DotList";
|
import { DotList } from "@/components/text/DotList";
|
||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { mediaTypeToJW } from "@/backend/metadata/justwatch";
|
import { mediaTypeToJW } from "@/backend/metadata/justwatch";
|
||||||
|
import { Icons } from "../Icon";
|
||||||
|
import { IconPatch } from "../buttons/IconPatch";
|
||||||
|
|
||||||
export interface MediaCardProps {
|
export interface MediaCardProps {
|
||||||
media: MWMediaMeta;
|
media: MWMediaMeta;
|
||||||
@ -11,6 +13,8 @@ export interface MediaCardProps {
|
|||||||
season: number;
|
season: number;
|
||||||
};
|
};
|
||||||
percentage?: number;
|
percentage?: number;
|
||||||
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
function MediaCardContent({
|
function MediaCardContent({
|
||||||
@ -18,18 +22,22 @@ function MediaCardContent({
|
|||||||
linkable,
|
linkable,
|
||||||
series,
|
series,
|
||||||
percentage,
|
percentage,
|
||||||
|
closable,
|
||||||
|
onClose,
|
||||||
}: MediaCardProps) {
|
}: MediaCardProps) {
|
||||||
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
const percentageString = `${Math.round(percentage ?? 0).toFixed(0)}%`;
|
||||||
|
|
||||||
|
const canLink = linkable && !closable;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div
|
<div
|
||||||
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
className={`group -m-3 mb-2 rounded-xl bg-denim-300 bg-opacity-0 transition-colors duration-100 ${
|
||||||
linkable ? "hover:bg-opacity-100" : ""
|
canLink ? "hover:bg-opacity-100" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<article
|
<article
|
||||||
className={`relative mb-2 p-3 transition-transform duration-100 ${
|
className={`pointer-events-auto relative mb-2 p-3 transition-transform duration-100 ${
|
||||||
linkable ? "group-hover:scale-95" : ""
|
canLink ? "group-hover:scale-95" : ""
|
||||||
}`}
|
}`}
|
||||||
>
|
>
|
||||||
<div
|
<div
|
||||||
@ -48,8 +56,16 @@ function MediaCardContent({
|
|||||||
|
|
||||||
{percentage !== undefined ? (
|
{percentage !== undefined ? (
|
||||||
<>
|
<>
|
||||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
|
<div
|
||||||
<div className="absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors group-hover:from-denim-100" />
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||||
|
canLink ? "group-hover:from-denim-100" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
|
<div
|
||||||
|
className={`absolute inset-x-0 bottom-0 h-12 bg-gradient-to-t from-denim-300 to-transparent transition-colors ${
|
||||||
|
canLink ? "group-hover:from-denim-100" : ""
|
||||||
|
}`}
|
||||||
|
/>
|
||||||
<div className="absolute inset-x-0 bottom-0 p-3">
|
<div className="absolute inset-x-0 bottom-0 p-3">
|
||||||
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
<div className="relative h-1 overflow-hidden rounded-full bg-denim-600">
|
||||||
<div
|
<div
|
||||||
@ -62,6 +78,19 @@ function MediaCardContent({
|
|||||||
</div>
|
</div>
|
||||||
</>
|
</>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
|
<div
|
||||||
|
className={`absolute inset-0 flex items-center justify-center bg-denim-200 bg-opacity-80 transition-opacity duration-200 ${
|
||||||
|
closable ? "opacity-100" : "pointer-events-none opacity-0"
|
||||||
|
}`}
|
||||||
|
>
|
||||||
|
<IconPatch
|
||||||
|
clickable
|
||||||
|
className="text-2xl text-slate-400"
|
||||||
|
onClick={() => closable && onClose?.()}
|
||||||
|
icon={Icons.X}
|
||||||
|
/>
|
||||||
|
</div>
|
||||||
</div>
|
</div>
|
||||||
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
<h1 className="mb-1 max-h-[4.5rem] text-ellipsis break-words font-bold text-white line-clamp-3">
|
||||||
<span>{media.title}</span>
|
<span>{media.title}</span>
|
||||||
@ -75,14 +104,14 @@ function MediaCardContent({
|
|||||||
export function MediaCard(props: MediaCardProps) {
|
export function MediaCard(props: MediaCardProps) {
|
||||||
const content = <MediaCardContent {...props} />;
|
const content = <MediaCardContent {...props} />;
|
||||||
|
|
||||||
if (!props.linkable) return <span>{content}</span>;
|
const canLink = props.linkable && !props.closable;
|
||||||
return (
|
|
||||||
<Link
|
const link = canLink
|
||||||
to={`/media/${encodeURIComponent(
|
? `/media/${encodeURIComponent(
|
||||||
mediaTypeToJW(props.media.type)
|
mediaTypeToJW(props.media.type)
|
||||||
)}-${encodeURIComponent(props.media.id)}`}
|
)}-${encodeURIComponent(props.media.id)}`
|
||||||
>
|
: "#";
|
||||||
{content}
|
|
||||||
</Link>
|
if (!props.linkable) return <span>{content}</span>;
|
||||||
);
|
return <Link to={link}>{content}</Link>;
|
||||||
}
|
}
|
||||||
|
@ -1,11 +1,15 @@
|
|||||||
|
import { forwardRef } from "react";
|
||||||
|
|
||||||
interface MediaGridProps {
|
interface MediaGridProps {
|
||||||
children?: React.ReactNode;
|
children?: React.ReactNode;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaGrid(props: MediaGridProps) {
|
export const MediaGrid = forwardRef<HTMLDivElement, MediaGridProps>(
|
||||||
return (
|
(props, ref) => {
|
||||||
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3">
|
return (
|
||||||
{props.children}
|
<div className="grid grid-cols-2 gap-6 sm:grid-cols-3" ref={ref}>
|
||||||
</div>
|
{props.children}
|
||||||
);
|
</div>
|
||||||
}
|
);
|
||||||
|
}
|
||||||
|
);
|
||||||
|
@ -1,24 +0,0 @@
|
|||||||
import { MWMediaMeta } from "@/backend/metadata/types";
|
|
||||||
import { useWatchedContext, getWatchedFromPortable } from "@/state/watched";
|
|
||||||
import { Episode } from "./EpisodeButton";
|
|
||||||
|
|
||||||
export interface WatchedEpisodeProps {
|
|
||||||
media: MWMediaMeta;
|
|
||||||
onClick?: () => void;
|
|
||||||
active?: boolean;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function WatchedEpisode(props: WatchedEpisodeProps) {
|
|
||||||
// const { watched } = useWatchedContext();
|
|
||||||
// const foundWatched = getWatchedFromPortable(watched.items, props.media);
|
|
||||||
// // const episode = getEpisodeFromMedia(props.media);
|
|
||||||
// const watchedPercentage = (foundWatched && foundWatched.percentage) || 0;
|
|
||||||
// return (
|
|
||||||
// <Episode
|
|
||||||
// progress={watchedPercentage}
|
|
||||||
// episodeNumber={episode?.episode?.sort ?? 1}
|
|
||||||
// active={props.active}
|
|
||||||
// onClick={props.onClick}
|
|
||||||
// />
|
|
||||||
// );
|
|
||||||
}
|
|
@ -5,6 +5,8 @@ import { MediaCard } from "./MediaCard";
|
|||||||
|
|
||||||
export interface WatchedMediaCardProps {
|
export interface WatchedMediaCardProps {
|
||||||
media: MWMediaMeta;
|
media: MWMediaMeta;
|
||||||
|
closable?: boolean;
|
||||||
|
onClose?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
||||||
@ -19,6 +21,8 @@ export function WatchedMediaCard(props: WatchedMediaCardProps) {
|
|||||||
series={watchedMedia?.item?.series}
|
series={watchedMedia?.item?.series}
|
||||||
linkable
|
linkable
|
||||||
percentage={watchedMedia?.percentage}
|
percentage={watchedMedia?.percentage}
|
||||||
|
onClose={props.onClose}
|
||||||
|
closable={props.closable}
|
||||||
/>
|
/>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
@ -1,7 +0,0 @@
|
|||||||
export interface TaglineProps {
|
|
||||||
children?: React.ReactNode;
|
|
||||||
}
|
|
||||||
|
|
||||||
export function Tagline(props: TaglineProps) {
|
|
||||||
return <p className="font-bold text-bink-600">{props.children}</p>;
|
|
||||||
}
|
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { useCallback, useRef, useState } from "react";
|
import { useCallback, useRef, useState } from "react";
|
||||||
import { CSSTransition } from "react-transition-group";
|
import { CSSTransition } from "react-transition-group";
|
||||||
import { BackdropControl } from "./controls/BackdropControl";
|
import { BackdropControl } from "./controls/BackdropControl";
|
||||||
@ -14,7 +15,7 @@ import { useVideoPlayerState } from "./VideoContext";
|
|||||||
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
import { VideoPlayer, VideoPlayerProps } from "./VideoPlayer";
|
||||||
|
|
||||||
interface DecoratedVideoPlayerProps {
|
interface DecoratedVideoPlayerProps {
|
||||||
title?: string;
|
media?: MWMediaMeta;
|
||||||
onGoBack?: () => void;
|
onGoBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -57,7 +58,7 @@ export function DecoratedVideoPlayer(
|
|||||||
|
|
||||||
return (
|
return (
|
||||||
<VideoPlayer autoPlay={props.autoPlay}>
|
<VideoPlayer autoPlay={props.autoPlay}>
|
||||||
<VideoPlayerError title={props.title} onGoBack={props.onGoBack}>
|
<VideoPlayerError media={props.media} onGoBack={props.onGoBack}>
|
||||||
<BackdropControl onBackdropChange={onBackdropChange}>
|
<BackdropControl onBackdropChange={onBackdropChange}>
|
||||||
<div className="absolute inset-0 flex items-center justify-center">
|
<div className="absolute inset-0 flex items-center justify-center">
|
||||||
<LoadingControl />
|
<LoadingControl />
|
||||||
@ -107,7 +108,7 @@ export function DecoratedVideoPlayer(
|
|||||||
ref={top}
|
ref={top}
|
||||||
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2"
|
||||||
>
|
>
|
||||||
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
|
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
</CSSTransition>
|
</CSSTransition>
|
||||||
</BackdropControl>
|
</BackdropControl>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||||
import { Link } from "@/components/text/Link";
|
import { Link } from "@/components/text/Link";
|
||||||
import { conf } from "@/setup/config";
|
import { conf } from "@/setup/config";
|
||||||
@ -15,7 +16,7 @@ interface ErrorBoundaryState {
|
|||||||
|
|
||||||
interface VideoErrorBoundaryProps {
|
interface VideoErrorBoundaryProps {
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
title?: string;
|
media?: MWMediaMeta;
|
||||||
onGoBack?: () => void;
|
onGoBack?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
@ -61,7 +62,7 @@ export class VideoErrorBoundary extends Component<
|
|||||||
<div className="absolute inset-0 bg-denim-100">
|
<div className="absolute inset-0 bg-denim-100">
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
||||||
<VideoPlayerHeader
|
<VideoPlayerHeader
|
||||||
title={this.props.title}
|
media={this.props.media}
|
||||||
onClick={this.props.onGoBack}
|
onClick={this.props.onGoBack}
|
||||||
/>
|
/>
|
||||||
</div>
|
</div>
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { IconPatch } from "@/components/buttons/IconPatch";
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icons } from "@/components/Icon";
|
import { Icons } from "@/components/Icon";
|
||||||
import { Title } from "@/components/text/Title";
|
import { Title } from "@/components/text/Title";
|
||||||
@ -6,7 +7,7 @@ import { useVideoPlayerState } from "../VideoContext";
|
|||||||
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
import { VideoPlayerHeader } from "./VideoPlayerHeader";
|
||||||
|
|
||||||
interface VideoPlayerErrorProps {
|
interface VideoPlayerErrorProps {
|
||||||
title?: string;
|
media?: MWMediaMeta;
|
||||||
onGoBack?: () => void;
|
onGoBack?: () => void;
|
||||||
children?: ReactNode;
|
children?: ReactNode;
|
||||||
}
|
}
|
||||||
@ -28,7 +29,7 @@ export function VideoPlayerError(props: VideoPlayerErrorProps) {
|
|||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
<div className="pointer-events-auto absolute inset-x-0 top-0 flex flex-col py-6 px-8 pb-2">
|
||||||
<VideoPlayerHeader title={props.title} onClick={props.onGoBack} />
|
<VideoPlayerHeader media={props.media} onClick={props.onGoBack} />
|
||||||
</div>
|
</div>
|
||||||
</div>
|
</div>
|
||||||
);
|
);
|
||||||
|
@ -1,13 +1,23 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
|
import { IconPatch } from "@/components/buttons/IconPatch";
|
||||||
import { Icon, Icons } from "@/components/Icon";
|
import { Icon, Icons } from "@/components/Icon";
|
||||||
import { BrandPill } from "@/components/layout/BrandPill";
|
import { BrandPill } from "@/components/layout/BrandPill";
|
||||||
|
import {
|
||||||
|
getIfBookmarkedFromPortable,
|
||||||
|
useBookmarkContext,
|
||||||
|
} from "@/state/bookmark";
|
||||||
|
|
||||||
interface VideoPlayerHeaderProps {
|
interface VideoPlayerHeaderProps {
|
||||||
title?: string;
|
media?: MWMediaMeta;
|
||||||
onClick?: () => void;
|
onClick?: () => void;
|
||||||
}
|
}
|
||||||
|
|
||||||
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
||||||
const showDivider = props.title && props.onClick;
|
const { bookmarkStore, setItemBookmark } = useBookmarkContext();
|
||||||
|
const isBookmarked = props.media
|
||||||
|
? getIfBookmarkedFromPortable(bookmarkStore.bookmarks, props.media)
|
||||||
|
: false;
|
||||||
|
const showDivider = props.media && props.onClick;
|
||||||
return (
|
return (
|
||||||
<div className="flex items-center">
|
<div className="flex items-center">
|
||||||
<div className="flex flex-1 items-center">
|
<div className="flex flex-1 items-center">
|
||||||
@ -24,8 +34,18 @@ export function VideoPlayerHeader(props: VideoPlayerHeaderProps) {
|
|||||||
{showDivider ? (
|
{showDivider ? (
|
||||||
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
<span className="mx-4 h-6 w-[1.5px] rotate-[30deg] bg-white opacity-50" />
|
||||||
) : null}
|
) : null}
|
||||||
{props.title ? (
|
{props.media ? (
|
||||||
<span className="text-white">{props.title}</span>
|
<span className="flex items-center space-x-2 text-white">
|
||||||
|
<span>{props.media.title}</span>
|
||||||
|
<IconPatch
|
||||||
|
clickable
|
||||||
|
transparent
|
||||||
|
icon={isBookmarked ? Icons.BOOKMARK : Icons.BOOKMARK_OUTLINE}
|
||||||
|
onClick={() =>
|
||||||
|
props.media && setItemBookmark(props.media, !isBookmarked)
|
||||||
|
}
|
||||||
|
/>
|
||||||
|
</span>
|
||||||
) : null}
|
) : null}
|
||||||
</p>
|
</p>
|
||||||
</div>
|
</div>
|
||||||
|
@ -59,24 +59,17 @@ export function BookmarkContextProvider(props: { children: ReactNode }) {
|
|||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
|
setItemBookmark(media: MWMediaMeta, bookmarked: boolean) {
|
||||||
setBookmarked((data: BookmarkStoreData) => {
|
setBookmarked((data: BookmarkStoreData): BookmarkStoreData => {
|
||||||
if (bookmarked) {
|
let bookmarks = [...data.bookmarks];
|
||||||
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
|
bookmarks = bookmarks.filter((v) => v.id !== media.id);
|
||||||
if (itemIndex === -1) {
|
if (bookmarked) bookmarks.push({ ...media });
|
||||||
const item: MWMediaMeta = { ...media };
|
return {
|
||||||
data.bookmarks.push(item);
|
bookmarks,
|
||||||
}
|
};
|
||||||
} else {
|
|
||||||
const itemIndex = getBookmarkIndexFromMedia(data.bookmarks, media);
|
|
||||||
if (itemIndex !== -1) {
|
|
||||||
data.bookmarks.splice(itemIndex);
|
|
||||||
}
|
|
||||||
}
|
|
||||||
return data;
|
|
||||||
});
|
});
|
||||||
},
|
},
|
||||||
getFilteredBookmarks() {
|
getFilteredBookmarks() {
|
||||||
return [];
|
return [...bookmarkStorage.bookmarks];
|
||||||
},
|
},
|
||||||
bookmarkStore: bookmarkStorage,
|
bookmarkStore: bookmarkStorage,
|
||||||
}),
|
}),
|
||||||
|
@ -50,12 +50,14 @@ export interface WatchedStoreData {
|
|||||||
interface WatchedStoreDataWrapper {
|
interface WatchedStoreDataWrapper {
|
||||||
updateProgress(media: MediaItem, progress: number, total: number): void;
|
updateProgress(media: MediaItem, progress: number, total: number): void;
|
||||||
getFilteredWatched(): WatchedStoreItem[];
|
getFilteredWatched(): WatchedStoreItem[];
|
||||||
|
removeProgress(id: string): void;
|
||||||
watched: WatchedStoreData;
|
watched: WatchedStoreData;
|
||||||
}
|
}
|
||||||
|
|
||||||
const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
const WatchedContext = createContext<WatchedStoreDataWrapper>({
|
||||||
updateProgress: () => {},
|
updateProgress: () => {},
|
||||||
getFilteredWatched: () => [],
|
getFilteredWatched: () => [],
|
||||||
|
removeProgress: () => {},
|
||||||
watched: {
|
watched: {
|
||||||
items: [],
|
items: [],
|
||||||
},
|
},
|
||||||
@ -84,6 +86,13 @@ export function WatchedContextProvider(props: { children: ReactNode }) {
|
|||||||
|
|
||||||
const contextValue = useMemo(
|
const contextValue = useMemo(
|
||||||
() => ({
|
() => ({
|
||||||
|
removeProgress(id: string) {
|
||||||
|
setWatched((data: WatchedStoreData) => {
|
||||||
|
const newData = { ...data };
|
||||||
|
newData.items = newData.items.filter((v) => v.item.meta.id !== id);
|
||||||
|
return newData;
|
||||||
|
});
|
||||||
|
},
|
||||||
updateProgress(media: MediaItem, progress: number, total: number): void {
|
updateProgress(media: MediaItem, progress: number, total: number): void {
|
||||||
// TODO series support
|
// TODO series support
|
||||||
setWatched((data: WatchedStoreData) => {
|
setWatched((data: WatchedStoreData) => {
|
||||||
|
@ -1,3 +1,4 @@
|
|||||||
|
import { MWMediaMeta } from "@/backend/metadata/types";
|
||||||
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
import { ErrorMessage } from "@/components/layout/ErrorBoundary";
|
||||||
import { Link } from "@/components/text/Link";
|
import { Link } from "@/components/text/Link";
|
||||||
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
import { VideoPlayerHeader } from "@/components/video/parts/VideoPlayerHeader";
|
||||||
@ -22,13 +23,13 @@ export function MediaFetchErrorView() {
|
|||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
export function MediaPlaybackErrorView(props: { title?: string }) {
|
export function MediaPlaybackErrorView(props: { media?: MWMediaMeta }) {
|
||||||
const goBack = useGoBack();
|
const goBack = useGoBack();
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<div className="h-screen flex-1">
|
<div className="h-screen flex-1">
|
||||||
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
<div className="fixed inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader onClick={goBack} title={props.title} />
|
<VideoPlayerHeader onClick={goBack} media={props.media} />
|
||||||
</div>
|
</div>
|
||||||
<ErrorMessage>
|
<ErrorMessage>
|
||||||
<p className="my-6 max-w-lg">
|
<p className="my-6 max-w-lg">
|
||||||
|
@ -51,10 +51,7 @@ function MediaViewScraping(props: MediaViewScrapingProps) {
|
|||||||
return (
|
return (
|
||||||
<div className="relative flex h-screen items-center justify-center">
|
<div className="relative flex h-screen items-center justify-center">
|
||||||
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
<div className="absolute inset-x-0 top-0 py-6 px-8">
|
||||||
<VideoPlayerHeader
|
<VideoPlayerHeader onClick={props.onGoBack} media={props.meta.meta} />
|
||||||
onClick={props.onGoBack}
|
|
||||||
title={props.meta.meta.title}
|
|
||||||
/>
|
|
||||||
</div>
|
</div>
|
||||||
<div className="flex flex-col items-center transition-opacity duration-200">
|
<div className="flex flex-col items-center transition-opacity duration-200">
|
||||||
{pending ? (
|
{pending ? (
|
||||||
@ -142,7 +139,7 @@ export function MediaView() {
|
|||||||
// show stream once we have a stream
|
// show stream once we have a stream
|
||||||
return (
|
return (
|
||||||
<div className="h-screen w-screen">
|
<div className="h-screen w-screen">
|
||||||
<DecoratedVideoPlayer title={meta.meta.title} onGoBack={goBack} autoPlay>
|
<DecoratedVideoPlayer media={meta.meta} onGoBack={goBack} autoPlay>
|
||||||
<SourceControl source={stream.streamUrl} type={stream.type} />
|
<SourceControl source={stream.streamUrl} type={stream.type} />
|
||||||
<ProgressListenerControl
|
<ProgressListenerControl
|
||||||
startAt={watchedItem?.progress}
|
startAt={watchedItem?.progress}
|
||||||
|
@ -8,32 +8,47 @@ import {
|
|||||||
} from "@/state/bookmark";
|
} from "@/state/bookmark";
|
||||||
import { useWatchedContext } from "@/state/watched";
|
import { useWatchedContext } from "@/state/watched";
|
||||||
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
import { WatchedMediaCard } from "@/components/media/WatchedMediaCard";
|
||||||
|
import { EditButton } from "@/components/buttons/EditButton";
|
||||||
|
import { useState } from "react";
|
||||||
|
import { useAutoAnimate } from "@formkit/auto-animate/react";
|
||||||
|
|
||||||
function Bookmarks() {
|
function Bookmarks() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getFilteredBookmarks } = useBookmarkContext();
|
const { getFilteredBookmarks, setItemBookmark } = useBookmarkContext();
|
||||||
const bookmarks = getFilteredBookmarks();
|
const bookmarks = getFilteredBookmarks();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
if (bookmarks.length === 0) return null;
|
if (bookmarks.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionHeading
|
<div>
|
||||||
title={t("search.bookmarks") || "Bookmarks"}
|
<SectionHeading
|
||||||
icon={Icons.BOOKMARK}
|
title={t("search.bookmarks") || "Bookmarks"}
|
||||||
>
|
icon={Icons.BOOKMARK}
|
||||||
<MediaGrid>
|
>
|
||||||
|
<EditButton editing={editing} onEdit={setEditing} />
|
||||||
|
</SectionHeading>
|
||||||
|
<MediaGrid ref={gridRef}>
|
||||||
{bookmarks.map((v) => (
|
{bookmarks.map((v) => (
|
||||||
<WatchedMediaCard key={v.id} media={v} />
|
<WatchedMediaCard
|
||||||
|
key={v.id}
|
||||||
|
media={v}
|
||||||
|
closable={editing}
|
||||||
|
onClose={() => setItemBookmark(v, false)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
</SectionHeading>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
function Watched() {
|
function Watched() {
|
||||||
const { t } = useTranslation();
|
const { t } = useTranslation();
|
||||||
const { getFilteredBookmarks } = useBookmarkContext();
|
const { getFilteredBookmarks } = useBookmarkContext();
|
||||||
const { getFilteredWatched } = useWatchedContext();
|
const { getFilteredWatched, removeProgress } = useWatchedContext();
|
||||||
|
const [editing, setEditing] = useState(false);
|
||||||
|
const [gridRef] = useAutoAnimate<HTMLDivElement>();
|
||||||
|
|
||||||
const bookmarks = getFilteredBookmarks();
|
const bookmarks = getFilteredBookmarks();
|
||||||
const watchedItems = getFilteredWatched().filter(
|
const watchedItems = getFilteredWatched().filter(
|
||||||
@ -43,16 +58,24 @@ function Watched() {
|
|||||||
if (watchedItems.length === 0) return null;
|
if (watchedItems.length === 0) return null;
|
||||||
|
|
||||||
return (
|
return (
|
||||||
<SectionHeading
|
<div>
|
||||||
title={t("search.continueWatching") || "Continue Watching"}
|
<SectionHeading
|
||||||
icon={Icons.CLOCK}
|
title={t("search.continueWatching") || "Continue Watching"}
|
||||||
>
|
icon={Icons.CLOCK}
|
||||||
<MediaGrid>
|
>
|
||||||
|
<EditButton editing={editing} onEdit={setEditing} />
|
||||||
|
</SectionHeading>
|
||||||
|
<MediaGrid ref={gridRef}>
|
||||||
{watchedItems.map((v) => (
|
{watchedItems.map((v) => (
|
||||||
<WatchedMediaCard key={v.item.meta.id} media={v.item.meta} />
|
<WatchedMediaCard
|
||||||
|
key={v.item.meta.id}
|
||||||
|
media={v.item.meta}
|
||||||
|
closable={editing}
|
||||||
|
onClose={() => removeProgress(v.item.meta.id)}
|
||||||
|
/>
|
||||||
))}
|
))}
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
</SectionHeading>
|
</div>
|
||||||
);
|
);
|
||||||
}
|
}
|
||||||
|
|
||||||
|
@ -68,16 +68,17 @@ export function SearchResultsView({ searchQuery }: { searchQuery: MWQuery }) {
|
|||||||
return (
|
return (
|
||||||
<div>
|
<div>
|
||||||
{results.length > 0 ? (
|
{results.length > 0 ? (
|
||||||
<SectionHeading
|
<div>
|
||||||
title={t("search.headingTitle") || "Search results"}
|
<SectionHeading
|
||||||
icon={Icons.SEARCH}
|
title={t("search.headingTitle") || "Search results"}
|
||||||
>
|
icon={Icons.SEARCH}
|
||||||
|
/>
|
||||||
<MediaGrid>
|
<MediaGrid>
|
||||||
{results.map((v) => (
|
{results.map((v) => (
|
||||||
<WatchedMediaCard key={v.id.toString()} media={v} />
|
<WatchedMediaCard key={v.id.toString()} media={v} />
|
||||||
))}
|
))}
|
||||||
</MediaGrid>
|
</MediaGrid>
|
||||||
</SectionHeading>
|
</div>
|
||||||
) : null}
|
) : null}
|
||||||
|
|
||||||
<SearchSuffix results={results.length} />
|
<SearchSuffix results={results.length} />
|
||||||
|
@ -40,6 +40,11 @@
|
|||||||
"minimatch" "^3.1.2"
|
"minimatch" "^3.1.2"
|
||||||
"strip-json-comments" "^3.1.1"
|
"strip-json-comments" "^3.1.1"
|
||||||
|
|
||||||
|
"@formkit/auto-animate@^1.0.0-beta.5":
|
||||||
|
"integrity" "sha512-WoSwyhAZPOe6RB/IgicOtCHtrWwEpfKIZ/H/nxpKfnZL9CB6hhhBGU5bCdMRw7YpAUF2CDlQa+WWh+gCqz5lDg=="
|
||||||
|
"resolved" "https://registry.npmjs.org/@formkit/auto-animate/-/auto-animate-1.0.0-beta.5.tgz"
|
||||||
|
"version" "1.0.0-beta.5"
|
||||||
|
|
||||||
"@gar/promisify@^1.1.3":
|
"@gar/promisify@^1.1.3":
|
||||||
"version" "1.1.3"
|
"version" "1.1.3"
|
||||||
|
|
||||||
|
Loading…
Reference in New Issue
Block a user