Feature/video media page (#570)

* video page initial progress

* added MediaPageSwitch

---------

Co-authored-by: Victor <victor@storyteller.ai>
This commit is contained in:
Victor OandVictor authored and GitHub committed 2024-05-25 11:00:25 -04:00
1 parent fd0164ea6f
commit 8713426256
32 files changed
+1633 -565

No files matched your search

@@ -0,0 +1,83 @@
@import "scss/colours.scss";
.fy-action-button {
appearance: none;
background-color: rgba(lighten($secondary, 5%), 0.9);
border: none;
border-radius: 0.5rem;
color: #fff;
display: flex;
gap: 0.125rem;
align-items: center;
// background: red;
line-height: 1;
font-size: 0.875rem;
text-align: right;
min-height: 2.25rem;
min-width: 1rem;
position: relative;
padding: 0 0 0 0.5rem;
// text-align: right;
&:hover {
background: rgba(lighten($secondary, 10%), 0.9);
}
svg {
width: 1em;
color: $primary;
margin-right: 1rem;
}
.fy-action-button-icon-toggled {
animation: pulse 0.3s ease;
}
}
.bookmark-action-button {
svg {
color: map-get($badge-colors, "yellow");
}
&.action-button-toggled {
background-color: darken(rgba(map-get($badge-colors, "yellow"), 0.9), 32%);
&:hover {
background-color: darken(
rgba(map-get($badge-colors, "yellow"), 0.9),
25%
);
}
}
.fy-workdots-label {
min-width: 2rem;
}
}
.feature-action-button {
svg {
color: map-get($badge-colors, "purple");
}
&.action-button-toggled {
background-color: darken(rgba(map-get($badge-colors, "purple"), 0.9), 32%);
&:hover {
background-color: darken(
rgba(map-get($badge-colors, "purple"), 0.9),
25%
);
}
}
}
.like-action-button {
svg {
color: $primary;
}
&.action-button-toggled {
background-color: rgba(#593a48, 0.9);
&:hover {
background-color: lighten(rgba(#593a48, 0.9), 10%);
}
}
}
@@ -0,0 +1,82 @@
import React from "react";
import Tippy from "@tippyjs/react";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import { AnimationStatus, useAnimationStatus } from "hooks";
import { WorkDots } from "components/svg";
import "./ActionButton.scss";
export interface ActionButtonProps {
actionType: "bookmark" | "feature" | "like";
busy?: boolean;
entityToken?: string;
entityType?: string;
iconOff: IconDefinition;
iconOn: IconDefinition;
isToggled: boolean;
labelOff: string | number;
labelOn: string | number;
toggle: (entityToken: string, entityType: string) => any;
toolTipOff?: string;
toolTipOn?: string;
}
export default function ActionButton({
actionType = "like",
busy,
entityToken = "",
entityType = "",
iconOff,
iconOn,
isToggled,
labelOff,
labelOn,
toggle,
toolTipOff,
toolTipOn,
}: ActionButtonProps) {
const { events, status } = useAnimationStatus();
const onClick = () => {
if (status === AnimationStatus.paused) {
toggle(entityToken, entityType);
}
};
return (
<Tippy
{...{
content: isToggled ? toolTipOn : toolTipOff,
delay: [500, 0],
hideOnClick: false,
offset: [0, 12],
placement: "bottom",
theme: "fakeyou",
trigger: "mouseenter",
}}
>
<button
{...{
className: `fy-action-button ${actionType}-action-button ${
isToggled ? "action-button-toggled" : ""
}`,
disabled: busy,
onClick,
}}
>
<Icon
{...{
className: `fy-action-button-icon${isToggled ? "-toggled" : ""}`,
icon: isToggled ? iconOn : iconOff,
}}
/>
<WorkDots
{...{
events,
labels: [labelOn, labelOff],
index: busy ? 0 : isToggled ? 1 : 2,
}}
/>
</button>
</Tippy>
);
}
@@ -0,0 +1,2 @@
export { default } from "./ActionButton";
export type { ActionButtonProps } from "./ActionButton";
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faBookmark } from "@fortawesome/pro-solid-svg-icons";
import { faBookmark as faBookmarkOutline } from "@fortawesome/pro-regular-svg-icons";
import { WorkDots } from "components/svg";
import { AnimationStatus, useAnimationStatus } from "hooks";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import "./BookmarkButton.scss";
@@ -29,24 +30,19 @@ export default function BookmarkButton({
overlay,
toggle,
}: BookmarkButtonProps) {
const { events, status } = useAnimationStatus();
const [isLoading, setIsLoading] = useState(false);
const handleClick = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// setIsLoading(true);
toggle(entityToken, entityType).then((isToggled: boolean) => {
// setIsToggled(isToggled);
setIsLoading(false);
});
// try {
// await onToggle(!isToggled);
// setIsToggled(!isToggled);
// } catch (error) {
// console.error("Error calling API", error);
// } finally {
// setIsLoading(false);
// }
if (status === AnimationStatus.paused) {
toggle(entityToken, entityType).then((isToggled: boolean) => {
// setIsToggled(isToggled);
setIsLoading(false);
});
}
};
const buttonClass = isToggled ? "favorite-button toggled" : "favorite-button";
@@ -79,7 +75,9 @@ export default function BookmarkButton({
/>
<div className="favorite-text">
<div {...{ className: "favorite-text-wrapper" }}>
<WorkDots {...{ labels: ["Saved", "Save"], index }} />
<WorkDots
{...{ events, labels: ["Saved", "Save"], noPad: true, index }}
/>
</div>
</div>
</button>
@@ -3,6 +3,7 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faHeart } from "@fortawesome/pro-solid-svg-icons";
import { faHeart as faHeartOutline } from "@fortawesome/pro-regular-svg-icons";
import { WorkDots } from "components/svg";
import { AnimationStatus, useAnimationStatus } from "hooks";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
import "./LikeButton.scss";
@@ -29,18 +30,19 @@ export default function LikeButton({
large,
toggle,
}: LikeButtonProps) {
const { events, status } = useAnimationStatus();
const [isLoading, setIsLoading] = useState(false);
// console.log("😎",busy);
const handleClick = async (event: React.MouseEvent) => {
event.preventDefault();
event.stopPropagation();
// setIsLoading(true);
toggle(entityToken, entityType).then((isToggled: boolean) => {
// setIsToggled(isToggled);
setIsLoading(false);
});
if (status === AnimationStatus.paused) {
toggle(entityToken, entityType).then((isToggled: boolean) => {
// setIsToggled(isToggled);
setIsLoading(false);
});
}
// try {
// await onToggle(!isToggled);
// setIsToggled(!isToggled);
@@ -80,7 +82,12 @@ export default function LikeButton({
<div className="like-number">
<div className="like-number-wrapper">
<WorkDots
{...{ labels: [likeCountShort], index: busy ? 0 : 1 }}
{...{
events,
labels: [likeCountShort],
noPad: true,
index: busy ? 0 : 1,
}}
/>
</div>
</div>
@@ -22,39 +22,7 @@
margin-bottom: 10px;
transition: 0.2s;
&:hover {
opacity: 0.8;
}
&.bg-facebook {
color: $white;
background-color: #4267b2;
}
&.bg-reddit {
color: $white;
background-color: #ff5700;
}
&.bg-reddit {
color: $white;
background-color: #ff5700;
}
&.bg-whatsapp {
color: $white;
background-color: #25d366;
}
&.bg-x {
color: $white;
background-color: #1da1f2;
}
&.bg-email {
color: $white;
background-color: #858585;
}
@include social-colors;
}
.social-button-text {
@@ -9,13 +9,17 @@ import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import React from "react";
import "./SocialButton.scss";
export type Socials = "x" | "whatsapp" | "facebook" | "reddit" | "email";
interface SocialButtonProps {
social: "x" | "whatsapp" | "facebook" | "reddit" | "email";
hideLabel?: boolean;
social: Socials;
shareUrl: string;
shareText: string;
}
export default function SocialButton({
hideLabel,
social,
shareUrl,
shareText,
@@ -64,13 +68,18 @@ export default function SocialButton({
return (
<button className="social-button" onClick={handleClick}>
<div className={`social-button-icon bg-${social}`}>
<div
className={`${
!hideLabel ? "social-button-icon" : "social-button-icon-no-style"
} bg-${social}`}
>
<FontAwesomeIcon icon={socialIcon} />
</div>
<p className="social-button-text">
{social.charAt(0).toUpperCase() + social.slice(1)}
</p>
{!hideLabel ? (
<p className="social-button-text">
{social.charAt(0).toUpperCase() + social.slice(1)}
</p>
) : null}
</button>
);
}
@@ -1 +1,2 @@
export { default } from "./SocialButton";
export type { Socials } from "./SocialButton";
@@ -1,8 +1,10 @@
import Accordion from "./Accordion";
import ActionButton, { ActionButtonProps } from "./ActionButton";
import AudioBlobPreview from "./AudioBlobPreview";
import AudioInput from "./AudioInput";
import Badge from "./Badge";
import BasicTabs from "./BasicTabs";
import BookmarkButton from "./BookmarkButton";
import Button from "./Button";
import ButtonRevertToDefault from "./ButtonRevertToDefault";
import Checkbox from "./Checkbox";
@@ -39,6 +41,7 @@ import SelectModalVideoTabs from "./SelectModalVideoTabs";
import SelectModalWeightsTabs from "./SelectModalWeightsTabs";
import Skeleton from "./Skeleton";
import Slider from "./Slider";
import SocialButton, { Socials } from "./SocialButton";
import Spinner from "./Spinner";
import SplitPanel from "./SplitPanel";
import Tabs, { NonRouteTabs } from "./Tabs";
@@ -56,10 +59,12 @@ import WeightCoverImage from "./WeightCoverImage";
export {
Accordion,
ActionButton,
AudioBlobPreview,
AudioInput,
Badge,
BasicTabs,
BookmarkButton,
Button,
//Cards
MediaCards,
@@ -101,6 +106,7 @@ export {
SelectModalWeightsTabs,
Skeleton,
Slider,
SocialButton,
Spinner,
SplitPanel,
Tabs,
@@ -117,7 +123,4 @@ export {
WeightCoverImage,
};
export type {
ModalUtilities,
SelectModalData
};
export type { ActionButtonProps, ModalUtilities, SelectModalData, Socials };
@@ -3,9 +3,11 @@ import { SlideProps } from "./EntityInput";
import {
// useMediaUploader,
useModal,
useSession
useSession,
} from "hooks";
import MediaBrowser, { MediaBrowserProps } from "components/modals/MediaBrowser";
import MediaBrowser, {
MediaBrowserProps,
} from "components/modals/MediaBrowser";
import { FileWrapper } from "components/common";
import {
AcceptTypes,
@@ -14,7 +16,7 @@ import {
getMediaTypesByCategory,
isSelectedType,
mediaCategoryfromString,
MediaFilters
MediaFilters,
} from "components/entities/EntityTypes";
import { FontAwesomeIcon as Icon } from "@fortawesome/react-fontawesome";
import {
@@ -24,66 +26,112 @@ import {
faGrid,
faImage,
faPersonWalking,
faWaveform
} from "@fortawesome/pro-solid-svg-icons";
faWaveform,
} from "@fortawesome/pro-solid-svg-icons";
interface EmptySlideProps extends SlideProps {
accept: AcceptTypes | AcceptTypes[],
inputProps?: any,
queryUser?: string,
type: EntityModeProp,
};
accept: AcceptTypes | AcceptTypes[];
inputProps?: any;
queryUser?: string;
type: EntityModeProp;
}
export default function EntityInputEmpty({ accept, inputProps, queryUser, type, ...rest }: EmptySlideProps) {
export default function EntityInputEmpty({
accept,
inputProps,
queryUser,
type,
...rest
}: EmptySlideProps) {
const { open } = useModal();
const { user } = useSession();
console.log("😎", user);
const accepted = Array.isArray(accept) ? accept : [accept];
const inputMode = EntityInputMode[type];
const isMedia = inputMode === EntityInputMode.media;
const fileTypes = isMedia ? accepted.map((mediaCategory,i) => {
return mediaCategory ? getMediaTypesByCategory(mediaCategoryfromString(mediaCategory)) : [];
}).flat() : [];
const fileTypes = isMedia
? accepted
.map((mediaCategory, i) => {
return mediaCategory
? getMediaTypesByCategory(mediaCategoryfromString(mediaCategory))
: [];
})
.flat()
: [];
const props: MediaBrowserProps = {
accept: accepted,
inputMode,
username: queryUser || user?.username || "",
...rest
...rest,
};
const browserClick = () => open({
component: MediaBrowser,
props
});
const browserClick = () =>
open({
component: MediaBrowser,
props,
});
const mediaIcons = () => {
if (isSelectedType(MediaFilters.audio,accepted[0])) return faWaveform;
if (isSelectedType(MediaFilters.engine_asset,accepted[0])) return faPersonWalking;
if (isSelectedType(MediaFilters.image,accepted[0])) return faImage;
if (isSelectedType(MediaFilters.video,accepted[0])) return faFile;
if (isSelectedType(MediaFilters.audio, accepted[0])) return faWaveform;
if (isSelectedType(MediaFilters.engine_asset, accepted[0]))
return faPersonWalking;
if (isSelectedType(MediaFilters.image, accepted[0])) return faImage;
if (isSelectedType(MediaFilters.video, accepted[0])) return faFile;
return faFile;
};
const supported = `${ fileTypes.length ? fileTypes.join(", ") : fileTypes[0] } files supported`;
const supported = `${
fileTypes.length ? fileTypes.join(", ") : fileTypes[0]
} files supported`;
return <>
<Icon {...{ className: "fy-entity-input-icon", icon: [faFile,mediaIcons(),faDiagramSankey,mediaIcons()][inputMode] }}/>
<div {...{ className: "fy-entity-input-empty-controls" }}>
{ isMedia &&
<FileWrapper {...{ containerClass: "fy-entity-input-row", fileTypes, panelClass: "fy-entity-input-button", noStyle: true, ...inputProps }}>
<>
<Icon {...{ className: "fy-entity-input-label-icon", icon: faFileArrowUp }}/>
<div {...{ className: "fy-entity-input-upload-detail" }}>
Upload, click or drag here
{ accept && <span>{ supported }</span> }
</div>
</>
</FileWrapper> }
<div {...{ className: "fy-entity-input-row fy-entity-input-button", onClick: browserClick }}>
<Icon {...{ className: "fy-entity-input-label-icon", icon: faGrid }}/>
Choose from your { ["bookmarks","media","weights"][inputMode] }
return (
<>
<Icon
{...{
className: "fy-entity-input-icon",
icon: [faFile, mediaIcons(), faDiagramSankey, mediaIcons()][
inputMode
],
}}
/>
<div {...{ className: "fy-entity-input-empty-controls" }}>
{isMedia && (
<FileWrapper
{...{
containerClass: "fy-entity-input-row",
fileTypes,
panelClass: "fy-entity-input-button",
noStyle: true,
...inputProps,
}}
>
<>
<Icon
{...{
className: "fy-entity-input-label-icon",
icon: faFileArrowUp,
}}
/>
<div {...{ className: "fy-entity-input-upload-detail" }}>
Upload, click or drag here
{accept && <span>{supported}</span>}
</div>
</>
</FileWrapper>
)}
<div
{...{
className: "fy-entity-input-row fy-entity-input-button",
onClick: browserClick,
}}
>
<Icon
{...{ className: "fy-entity-input-label-icon", icon: faGrid }}
/>
Choose from your {["bookmarks", "media", "weights"][inputMode]}
</div>
</div>
</div>
</>;
};
</>
);
}
@@ -1,35 +1,59 @@
import React, { useState } from 'react';
import React from "react";
import { a, useTransition } from "@react-spring/web";
import { basicTransition } from "resources";
import { AnimationEvents } from "hooks";
type Label = string | number;
interface Props {
debug?: boolean,
labels: string[],
index: number
debug?: boolean;
events: AnimationEvents;
labels: Label[];
noPad?: boolean;
index: number;
}
export default function WorkDots({ debug, labels = [], index }: Props) {
const [animating,animatingSet] = useState(false);
const transitions = useTransition(index, basicTransition({
onRest: () => animatingSet(false),
onStart: () => animatingSet(true)
}));
export default function WorkDots({
debug,
events,
labels = [],
noPad,
index,
}: Props) {
const transitions = useTransition(
index,
basicTransition({
...events,
})
);
if (debug) console.log("WorkDots ... Debug",index);
if (debug) console.log("WorkDots ... Debug", index);
return transitions((style: any, i: number, state: any) => {
let isLeaving = state.phase === "leave";
const content = (txt = "") =>
<a.div {...{ style: {
...style,
position: isLeaving && animating ? "absolute" : "relative"
} }}>{
txt ? txt : <svg {...{ className: "fy-workdots" }}>
<circle cx="2" cy="8" r="2" />
<circle cx="8" cy="8" r="2" />
<circle cx="14" cy="8" r="2" />
</svg>
}</a.div>;
return [ content(""), ...labels.map((label,i) => content(label))][i];
// let isLeaving = state.phase === "leave";
const content = (txt: Label) => {
return (
<a.div
{...{
className: "fy-workdots-label",
style: {
...style,
...(!noPad ? { right: ".5rem" } : {}),
},
}}
>
{txt !== undefined ? (
txt
) : (
<svg {...{ className: "fy-workdots" }}>
<circle cx="2" cy="8" r="2" />
<circle cx="8" cy="8" r="2" />
<circle cx="14" cy="8" r="2" />
</svg>
)}
</a.div>
);
};
return [content(""), ...labels.map((label: Label, i) => content(label))][i];
});
};
}
+37 -29
View File
@@ -1,34 +1,40 @@
import useBatchContent from './useBatchContent';
import useBookmarks from './useBookmarks';
import useChanger from './useChanger';
import useCoverImgUpload from './useCoverImgUpload';
import useDebounce from './useDebounce';
import useFile from './useFile';
import useHover from './useHover';
import useId from './useId';
import useIdempotency from './useIdempotency';
import useInferenceJobs from './useInferenceJobs';
import useInterval from './useInterval';
import useJobStatus from './useJobStatus';
import useLazyLists from './useLazyLists';
import useListContent from './useListContent';
import useLocalize from './useLocalize';
import useMedia from './useMedia';
import useMediaUploader from './useMediaUploader';
import useModal from './useModal';
import useNotifications from './useNotifications';
import useAnimationStatus, {
AnimationEvents,
AnimationStatus,
} from "./useAnimationStatus";
import useBatchContent from "./useBatchContent";
import useBookmarks, { MakeBookmarksProps } from "./useBookmarks";
import useChanger from "./useChanger";
import useCoverImgUpload from "./useCoverImgUpload";
import useDebounce from "./useDebounce";
import useFile from "./useFile";
import useHover from "./useHover";
import useId from "./useId";
import useIdempotency from "./useIdempotency";
import useInferenceJobs from "./useInferenceJobs";
import useInterval from "./useInterval";
import useJobStatus from "./useJobStatus";
import useLazyLists from "./useLazyLists";
import useListContent from "./useListContent";
import useLocalize from "./useLocalize";
import useMedia from "./useMedia";
import useMediaUploader from "./useMediaUploader";
import useModal from "./useModal";
import useNotifications from "./useNotifications";
import useOnScreen from "./useOnScreen";
import usePrevious from './usePrevious';
import useQueuePoll from './useQueuePoll';
import useRatings from './useRatings';
import useSdUpload from './useSdUpload';
import useSession from './useSession';
import useSlides from './useSlides';
import useStatusPoll from './useStatusPoll';
import useVideo from './useVideo';
import useWeightFetch from './useWeightFetch';
import usePrevious from "./usePrevious";
import useQueuePoll from "./useQueuePoll";
import useRatings, { MakeRatingsProps } from "./useRatings";
import useSdUpload from "./useSdUpload";
import useSession from "./useSession";
import useSlides from "./useSlides";
import useStatusPoll from "./useStatusPoll";
import useVideo from "./useVideo";
import useWeightFetch from "./useWeightFetch";
export {
AnimationStatus,
useAnimationStatus,
useBatchContent,
useBookmarks,
useChanger,
@@ -57,5 +63,7 @@ export {
useSlides,
useStatusPoll,
useVideo,
useWeightFetch
useWeightFetch,
};
export type { AnimationEvents, MakeBookmarksProps, MakeRatingsProps };
@@ -0,0 +1,23 @@
import { useState } from "react";
export enum AnimationStatus {
paused,
animating,
}
export interface AnimationEvents {
onStart: () => void;
onRest: () => void;
}
export default function useAnimationStatus() {
const [status, statusSet] = useState(AnimationStatus.paused);
return {
status,
events: {
onStart: () => statusSet(AnimationStatus.animating),
onRest: () => statusSet(AnimationStatus.paused),
},
};
}
@@ -3,37 +3,40 @@ import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetch
import { useSession } from "hooks";
interface Props {
checker: any,
debug?: string,
fetcher: any,
modLibrary?: any,
onPass?: any,
onFail?: any,
resultsKey: string,
toggleCheck: (x: any) => any
checker: any;
debug?: string;
fetcher: any;
modLibrary?: any;
onPass?: any;
onFail?: any;
resultsKey: string;
toggleCheck: (x: any) => any;
}
interface Library {
[key: string]: any
[key: string]: any;
}
interface Gather {
expand?: boolean,
modLibrary?: any,
key: string,
res: any
expand?: boolean;
modLibrary?: any;
key: string;
res: any;
}
type BatchToggle = (entity_token: string, entity_type: string) => boolean;
export interface MakePropsParams { entityToken: string, entityType: string }
export interface MakePropsParams {
entityToken: string;
entityType: string;
}
export interface BatchInputProps {
busy: boolean,
entityToken: string,
entityType: string,
isToggled: boolean,
toggle: BatchToggle
busy: boolean;
entityToken: string;
entityType: string;
isToggled: boolean;
toggle: BatchToggle;
}
export type MakeBatchProps = (x: MakePropsParams) => BatchInputProps;
@@ -42,57 +45,68 @@ export default function useBatchContent({
checker,
debug,
fetcher,
modLibrary = (current: any, res: any, entity_token: string ) => current,
modLibrary = (current: any, res: any, entity_token: string) => current,
onPass,
onFail = { fetch: () => new Promise(() => {}) },
resultsKey,
toggleCheck
toggleCheck,
}: Props) {
const session = useSession();
const [library, librarySet] = useState<Library>({});
const [busyList, busyListSet] = useState<Library>({});
const [status, statusSet] = useState(FetchStatus.ready);
const [tokenType,tokenTypeSet] = useState("");
const [tokenType, tokenTypeSet] = useState("");
const dlog = (...dbg: any) => debug ? console.log(...dbg) : {};
const dlog = (...dbg: any) => (debug ? console.log(...dbg) : {});
const gather = ({ expand, key, res }: Gather) => {
let tokens = res.results ? res.results.map((item: any) => (item.details || item)[key]) : [res[key]];
let loadingTokens = tokens.reduce((obj = {},token = "") => ({ ...obj, [token]: true }),{})
dlog("🪙",fetcher);
tokenTypeSet(key)
let tokens = res.results
? res.results.map((item: any) => (item.details || item)[key])
: [res[key]];
let loadingTokens = tokens.reduce(
(obj = {}, token = "") => ({ ...obj, [token]: true }),
{}
);
dlog("🪙", fetcher);
tokenTypeSet(key);
busyListSet(loadingTokens); // add current batch to busy list
fetcher("",{},{ tokens }).then((batchRes: any) => {
// console.log("🦄",resultsKey, res, modLibrary);
fetcher("", {}, { tokens }).then((batchRes: any) => {
// console.log("🦄", resultsKey, res, modLibrary);
if (batchRes.success && !!batchRes[resultsKey]) {
// console.log("🥏",resultsKey);
let newBatch = batchRes[resultsKey].reduce((obj = {}, { entity_token = "", ...current }) => {
// console.log("🧲", resultsKey, {current, res, entity_token, obj});
let newCurrent = {
...obj,
[entity_token]: { ...modLibrary(current, res, entity_token, key) }
};
let newBatch = batchRes[resultsKey].reduce(
(obj = {}, { entity_token = "", ...current }) => {
// console.log("🧲", resultsKey, {current, res, entity_token, obj});
let newCurrent = {
...obj,
[entity_token]: {
...modLibrary(current, res, entity_token, key),
},
};
// console.log("😡", resultsKey, newCurrent);
return newCurrent;
},{});
// console.log("😡", resultsKey, newCurrent);
return newCurrent;
},
{}
);
// console.log("🐸",busyList);
busyListSet({}); // this should be a for each key in tokens delete from busyList, but this is fine for now
librarySet((library: any) => expand ? { ...library, ...newBatch } : newBatch);
librarySet((library: any) =>
expand ? { ...library, ...newBatch } : newBatch
);
}
});
};
const busyAdd = (entity_token: string) =>
busyListSet(state => ({ ...state, [entity_token]: true }));
const busyAdd = (entity_token: string) => busyListSet(state => ({ ...state, [entity_token]: true }));
const busyRemove = (entity_token: string) => busyListSet(state => {
let newState = { ...state };
delete newState[entity_token];
return newState;
});
const busyRemove = (entity_token: string) =>
busyListSet(state => {
let newState = { ...state };
delete newState[entity_token];
return newState;
});
const toggle: BatchToggle = (entity_token, entity_type) => {
if (session.check()) {
@@ -100,43 +114,50 @@ export default function useBatchContent({
statusSet(FetchStatus.in_progress);
busyAdd(entity_token);
console.log(`⏳ toggling entity ${ entity_token }, in library?: ${ !!inLibrary }`);
console.log(
`⏳ toggling entity ${entity_token}, in library?: ${!!inLibrary}`
);
if (inLibrary && checker(inLibrary)) {
// console.log("⭕️");
return onPass.fetch(entity_token, entity_type, library)
.then((res: any) => {
console.log("⭕️",res);
busyRemove(entity_token);
librarySet(onPass.modLibrary(res, entity_token, entity_type, library));
statusSet(FetchStatus.ready);
return false;
});
return onPass
.fetch(entity_token, entity_type, library)
.then((res: any) => {
console.log("⭕️", res);
busyRemove(entity_token);
librarySet(
onPass.modLibrary(res, entity_token, entity_type, library)
);
statusSet(FetchStatus.ready);
return false;
});
} else {
// console.log("❌");
return onFail.fetch(entity_token, entity_type, library)
.then((res: any) => {
console.log("❌",res);
busyRemove(entity_token);
librarySet(onFail.modLibrary(res, entity_token, entity_type, library));
statusSet(FetchStatus.ready);
return true;
});
return onFail
.fetch(entity_token, entity_type, library)
.then((res: any) => {
console.log("❌", res);
busyRemove(entity_token);
librarySet(
onFail.modLibrary(res, entity_token, entity_type, library)
);
statusSet(FetchStatus.ready);
return true;
});
}
}
};
const toggled = ( entity_token = "" ) => toggleCheck(library[entity_token]);
const toggled = (entity_token = "") => toggleCheck(library[entity_token]);
const makeProps = ({ entityToken, entityType }: MakePropsParams) => ({
busy: busyList[entityToken],
entityToken,
entityType,
isToggled: toggled(entityToken),
toggle
toggle,
});
return {
busyAdd,
busyRemove,
@@ -150,6 +171,6 @@ export default function useBatchContent({
statusSet,
tokenType,
toggle,
toggled
toggled,
};
};
}
@@ -0,0 +1,78 @@
import { GetBookmarks } from "@storyteller/components/src/api/bookmarks/GetBookmarks";
import { CreateBookmark } from "@storyteller/components/src/api/bookmarks/CreateBookmark";
import { DeleteBookmark } from "@storyteller/components/src/api/bookmarks/DeleteBookmark";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faBookmark } from "@fortawesome/pro-solid-svg-icons";
import { faBookmark as faBookmarkOutline } from "@fortawesome/pro-regular-svg-icons";
import useBatchContent, {
BatchInputProps,
MakePropsParams,
} from "hooks/useBatchContent";
export interface BookmarksProps extends BatchInputProps {
actionType: "bookmark";
iconOff: IconDefinition;
iconOn: IconDefinition;
labelOff: string | number;
labelOn: string | number;
}
export type MakeBookmarksProps = (x: MakePropsParams) => BookmarksProps;
export default function useBookmarks() {
const toggleList =
(toBookmark: boolean) =>
(res: any, entity_token: string, entity_type: string, lib: any) => {
return {
...lib,
[entity_token]: {
entity_type,
is_bookmarked: toBookmark,
maybe_bookmark_token: toBookmark ? res.user_bookmark_token : null,
},
};
};
const bookmarks = useBatchContent({
debug: "bookmarks",
checker: ({ maybe_bookmark_token }: any) => !!maybe_bookmark_token,
fetcher: GetBookmarks,
onPass: {
fetch: (entity_token: string, entity_type: string, lib: any) => {
let bookmarkToken = lib[entity_token].maybe_bookmark_token;
return DeleteBookmark(bookmarkToken, { as_mod: false });
},
modLibrary: toggleList(false),
},
onFail: {
fetch: (entity_token: string, entity_type: string) =>
CreateBookmark("", {
entity_token,
entity_type,
}),
modLibrary: toggleList(true),
},
resultsKey: "bookmarks",
toggleCheck: (entity: any) => !!entity?.maybe_bookmark_token,
});
const makeProps: MakeBookmarksProps = ({
entityToken,
entityType,
}: MakePropsParams) => {
return {
...bookmarks.makeProps({ entityToken, entityType }),
actionType: "bookmark",
iconOff: faBookmarkOutline,
iconOn: faBookmark,
labelOff: "Save",
labelOn: "Saved",
};
};
return {
...bookmarks,
makeProps,
};
}
@@ -1 +0,0 @@
export { default } from "./useBookmarks";
@@ -1,47 +0,0 @@
import { GetBookmarks } from "@storyteller/components/src/api/bookmarks/GetBookmarks";
import { CreateBookmark } from "@storyteller/components/src/api/bookmarks/CreateBookmark";
import { DeleteBookmark } from "@storyteller/components/src/api/bookmarks/DeleteBookmark";
// import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetchTypes";
import { useBatchContent } from "hooks";
// import { useSession } from "hooks";
// interface BookmarkLibrary {
// [key: string]: any
// }
export default function useBookmarks() {
const toggleList = (toBookmark: boolean) => (res: any, entity_token: string, entity_type: string, lib: any) => {
return {
...lib,
[entity_token]: {
entity_type,
is_bookmarked: toBookmark,
maybe_bookmark_token: toBookmark ? res.user_bookmark_token : null
}
}
};
const bookmarks = useBatchContent({
checker: ({ maybe_bookmark_token }: any) => !!maybe_bookmark_token,
fetcher: GetBookmarks,
onPass: {
fetch: (entity_token: string, entity_type: string, lib: any) => {
let bookmarkToken = lib[entity_token].maybe_bookmark_token;
return DeleteBookmark(bookmarkToken,{ as_mod: false });
},
modLibrary: toggleList(false)
},
onFail: {
fetch: (entity_token: string, entity_type: string) => CreateBookmark("",{
entity_token,
entity_type
}),
modLibrary: toggleList(true)
},
resultsKey: "bookmarks",
toggleCheck: (entity: any) => !!entity?.maybe_bookmark_token
});
return bookmarks;
};
@@ -33,8 +33,17 @@ export default function useMedia({
const previousToken = usePrevious(mediaToken);
const reload = () => {
statusSet(FetchStatus.ready);
mediaSet(undefined);
};
useEffect(() => {
if (mediaToken && (!media || previousToken !== mediaToken)) {
if (
status === FetchStatus.ready &&
mediaToken &&
(!media || previousToken !== mediaToken)
) {
statusSet(FetchStatus.in_progress);
GetMedia(mediaToken, {})
.then(res => {
@@ -64,5 +73,14 @@ export default function useMedia({
}
}, [media, mediaToken, previousToken, prompt, onSuccess, status, statusSet]);
return { media, mediaSet, prompt, remove, status, writeStatus };
return {
media,
mediaFile: media,
mediaSet,
prompt,
remove,
reload,
status,
writeStatus,
};
}
@@ -0,0 +1,96 @@
import { GetRatings } from "@storyteller/components/src/api/user_ratings/GetRatings";
import { SetRating } from "@storyteller/components/src/api/user_ratings/SetRating";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import { faHeart } from "@fortawesome/pro-solid-svg-icons";
import { faHeart as faHeartOutline } from "@fortawesome/pro-regular-svg-icons";
import useBatchContent, {
BatchInputProps,
MakePropsParams,
} from "hooks/useBatchContent";
export interface RatingsProps extends BatchInputProps {
actionType: "like";
iconOff: IconDefinition;
iconOn: IconDefinition;
likeCount: 0;
labelOff: string | number;
labelOn: string | number;
}
export type MakeRatingsProps = (x: MakePropsParams) => RatingsProps;
export default function useRatings() {
const fetch = (entity_token: string, entity_type: string, lib: any) => {
const newRating = {
entity_token,
entity_type,
rating_value:
lib[entity_token]?.rating_value !== "positive" ? "positive" : "neutral",
};
return SetRating("", newRating);
};
const modLibrary = (
res: any,
entity_token: string,
entity_type: string,
lib: any
) => {
return {
...lib,
[entity_token]: {
entity_type,
rating_value:
lib[entity_token].rating_value === "neutral" ? "positive" : "neutral",
positive_rating_count: res.new_positive_rating_count_for_entity,
},
};
};
const ratings = useBatchContent({
fetcher: GetRatings,
checker: () => true,
// debug: "useRatings",
modLibrary: (
current: any,
res: any,
entity_token: string,
tokenType: string
) => {
let result = res.results
? res.results.find((item: any, i: number) => {
return (item.details || item)[tokenType] === entity_token;
})
: res;
let { positive_rating_count } = (result.details || result).stats;
return { ...current, positive_rating_count };
},
onFail: { fetch, modLibrary },
onPass: { fetch, modLibrary },
resultsKey: "ratings",
toggleCheck: (entity: any) => (entity?.rating_value || "") === "positive",
});
const makeProps: MakeRatingsProps = ({
entityToken,
entityType,
}: MakePropsParams) => {
const likeCount = ratings.library[entityToken]?.positive_rating_count || 0;
return {
...ratings.makeProps({ entityToken, entityType }),
actionType: "like",
iconOn: faHeart,
iconOff: faHeartOutline,
labelOff: likeCount,
labelOn: likeCount,
likeCount,
};
};
return {
...ratings,
makeProps,
};
}
@@ -1,2 +0,0 @@
export { default } from "./useRatings";
export type { MakeRatingsProps } from "./useRatings";
@@ -1,63 +0,0 @@
import { GetRatings } from "@storyteller/components/src/api/user_ratings/GetRatings";
import { SetRating } from "@storyteller/components/src/api/user_ratings/SetRating";
import useBatchContent, { BatchInputProps, MakePropsParams } from "hooks/useBatchContent";
export interface RatingsProps extends BatchInputProps {
likeCount: 0
}
export type MakeRatingsProps = (x: MakePropsParams) => RatingsProps;
export default function useRatings() {
const fetch = (entity_token: string, entity_type: string, lib: any) => {
const newRating = {
entity_token,
entity_type,
rating_value: lib[entity_token]?.rating_value !== "positive" ? "positive" : "neutral"
}
return SetRating("", newRating);
};
const modLibrary = (res: any, entity_token: string, entity_type: string, lib: any) => {
return {
...lib,
[entity_token]: {
entity_type,
rating_value: lib[entity_token].rating_value === "neutral" ? "positive" : "neutral",
positive_rating_count: res.new_positive_rating_count_for_entity
}
};
};
const ratings = useBatchContent({
fetcher: GetRatings,
checker: () => true,
// debug: "useRatings",
modLibrary: (current: any, res: any, entity_token: string, tokenType: string) => {
let result = res.results ? res.results.find((item: any, i: number) =>
{
return (item.details || item)[tokenType] === entity_token
}
) : res;
let { positive_rating_count } = (result.details || result).stats;
return { ...current, positive_rating_count };
},
onFail: { fetch, modLibrary },
onPass: { fetch, modLibrary },
resultsKey: "ratings",
toggleCheck: (entity: any) => (entity?.rating_value || "") === "positive"
});
const makeProps: MakeRatingsProps = ({ entityToken, entityType }: MakePropsParams) => ({
...ratings.makeProps({ entityToken, entityType }),
likeCount: ratings.library[entityToken]?.positive_rating_count || 0,
});
return {
...ratings,
makeProps
}
};
@@ -1,14 +1,15 @@
import { config } from "@react-spring/web";
const n = (x:any) => {};
const n = (x: any) => {};
const basicTransition = ({ ...overwrite }, onRest = n, onStart = n) => ({
config: config.gentle,
from: { opacity: 0, },
enter: { opacity: 1, },
leave: { opacity: 0, },
// config: { duration: 3000 },
from: { opacity: 0 },
enter: { opacity: 1.0, position: "relative" },
leave: { opacity: 0, position: "absolute" },
onRest,
onStart,
...overwrite
...overwrite,
});
export default basicTransition;
export default basicTransition;
@@ -1,2 +1,3 @@
$under1280: max-width 1280px;
$under992: max-width 992px;
$over992: min-width 992px;
$over992: min-width 992px;
@@ -81,3 +81,35 @@ $badge-colors: (
"purple": #8f49de,
"pink": #cc1d92,
);
@mixin social-colors {
color: $white;
&:hover {
opacity: 0.8;
}
&.bg-facebook {
background-color: #4267b2;
}
&.bg-reddit {
background-color: #ff5700;
}
&.bg-reddit {
background-color: #ff5700;
}
&.bg-whatsapp {
background-color: #25d366;
}
&.bg-x {
background-color: #1da1f2;
}
&.bg-email {
background-color: #858585;
}
}
@@ -193,6 +193,12 @@ styles with important tags:
color: rgba($white, 0.8);
font-weight: 400;
border-color: rgba($white, 0.12);
// this is to remove the lingering border on the comment component when there is only one comment -V
&:only-of-type {
border: none;
}
}
.table-mw {
@@ -98,7 +98,9 @@ import VcModelEditPage from "./pages/vc/vc_model_edit/VcModelEditPage";
import VcModelDeletePage from "./pages/vc/vc_model_delete/VcModelDeletePage";
// import { StorytellerStudioListPage } from "./pages/storyteller_studio/StorytellerStudioPage";
import TopNav from "components/layout/TopNav/TopNav";
import MediaPage from "./pages/media/MediaPage";
// import MediaPage from "./pages/media/MediaPage";
import MediaPageSwitch from "./pages/media/MediaPageSwitch";
// import DevMediaPage from "./pages/media/DevMediaPage";
import EditCoverImage from "./pages/media/EditCoverImage";
import MediaRenamePage from "./pages/media/MediaRenamePage";
import { VoiceDesignerFormPage } from "./pages/voice_designer/VoiceDesignerFormPage";
@@ -403,9 +405,17 @@ class PageContainer extends React.Component<
</Route>
<Route path="/media/:token">
<MediaPage />
<MediaPageSwitch />
</Route>
{/* <Route path="/media/:token">
<MediaPage />
</Route>*/}
{/* <Route path="/dev-media/:token">
<DevMediaPage />
</Route>*/}
<Route path="/edit-cover-image/:token">
<EditCoverImage />
</Route>
@@ -0,0 +1,217 @@
@import "scss/_breakpoint.scss";
@import "scss/breakpoints.scss";
@import "scss/colours.scss";
.media-page-container {
@include breakpoint($under992) {
display: flex;
flex-direction: column;
}
.video-page-main-content {
width: 75%;
&.video-page-theater-mode {
width: 100%;
}
header {
display: flex;
margin: 1rem 0;
justify-content: space-between;
.video-page-header-button {
height: 3rem;
display: inline;
}
.video-page-file-tools {
display: flex;
gap: 0.5rem;
}
}
@include breakpoint($under992) {
width: 100%;
}
.video-page-title {
line-height: 1;
span {
opacity: 0.66;
font-size: 0.75rem;
}
}
.fy-title-editor {
display: flex;
align-items: center;
gap: 0.5rem;
.fy-input {
margin-bottom: 0;
}
button {
width: 3rem;
display: inline-flex;
justify-content: center;
}
&.title-editor-spinner {
position: absolute;
height: 3rem;
// justify-content: center;
}
}
h2 {
// display: inline;
margin: 0 auto;
font-size: 1.75rem;
line-height: 1;
}
.video-page-media-content {
border-radius: 0.5rem;
flex-grow: 1;
}
.video-page-media-details {
display: flex;
justify-content: space-between;
flex-wrap: wrap;
.media-share-label {
font-size: 0.825rem;
line-height: 1.2;
margin-bottom: 0.5rem;
}
// padding: 1rem 0;
.video-page-creator {
// background-color: seagreen;
display: flex;
align-items: center;
// flex-wrap: wrap;
.gravatar-img {
// height: 56px;
margin-right: 0.5rem;
display: inline;
}
.video-page-creator-info {
display: flex;
flex-direction: column;
justify-content: center;
}
h3 {
color: $primary;
font-size: 1rem;
margin: 0;
}
span {
font-size: 0.75rem;
line-height: 1;
opacity: 0.6;
}
}
// @include breakpoint($under1280) { // replace when related videos returns
@include breakpoint($under992) {
.video-page-actions-container {
display: flex;
justify-content: flex-end;
width: 100%;
flex-wrap: wrap;
margin-top: 1rem;
}
.media-share-label {
width: 100%;
text-align: right;
}
}
.video-page-share-links {
display: flex;
gap: 0.5rem;
.social-button {
// margin: 0 0.5rem 0 0;
// height: 2.5rem;
// width: 2.5rem;
svg {
height: 1.5rem;
}
}
.social-button-icon-no-style {
background-color: red;
border-radius: 0.5rem;
display: flex;
justify-content: center;
align-items: center;
color: #fff;
height: 2.25rem;
width: 2.25rem;
@include social-colors;
}
.like-button,
.favorite-button {
line-height: 1;
align-items: center;
max-height: 2.25rem;
margin: 0 0.5rem 0 0;
height: 2.25rem;
.favorite-text {
display: flex;
align-items: center;
}
// width: 2.25rem;
}
}
}
.video-page-comments {
padding: 1rem;
margin-top: 1rem;
}
}
.video-page-related-videos {
// background-color: green;
// display: grid;
// grid-template-columns: 50% 50%;
padding: 0 1rem;
flex-grow: 1;
gap: 1rem;
@include breakpoint($under992) {
margin-top: 1rem;
padding: 0;
}
.video-page-related-item {
display: flex;
gap: 1rem;
margin-bottom: 1rem;
}
.video-page-related-details {
width: 40%;
h6 {
font-size: 0.75rem;
}
}
.video-page-related-cover-image {
background-color: purple;
border-radius: 0.5rem;
width: 60%;
aspect-ratio: 16/9;
}
}
}
@@ -1,13 +1,13 @@
import React, { useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import { Link } from "react-router-dom";
import MediaAudioPlayer from "./MediaAudioPlayer";
import MediaVideoComponent from "./MediaVideoComponent";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaFile";
import Container from "components/common/Container";
import Panel from "components/common/Panel";
import PageHeader from "components/layout/PageHeader";
import Skeleton from "components/common/Skeleton";
import Button from "components/common/Button";
import { MediaSubViewProps } from "./MediaPageSwitch";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleExclamation,
@@ -17,7 +17,6 @@ import {
faLink,
faFileCircleXmark,
faArrowRightArrowLeft,
faCopy,
faFilm,
} from "@fortawesome/pro-solid-svg-icons";
import Accordion from "components/common/Accordion";
@@ -29,10 +28,7 @@ import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import moment from "moment";
import WeightCoverImage from "components/common/WeightCoverImage";
import SocialButton from "components/common/SocialButton";
import { Badge, Input, Modal } from "components/common";
import BookmarkButton from "components/common/BookmarkButton";
import LikeButton from "components/common/LikeButton";
import { useBookmarks, useMedia, useRatings, useSession } from "hooks";
import { ActionButton, Badge, Input } from "components/common";
import { WeightCategory } from "@storyteller/components/src/api/_common/enums/WeightCategory";
import SdBatchMediaPanel from "./components/SdBatchMediaPanel/SdBatchMediaPanel";
import { GetMediaBatchImages } from "@storyteller/components/src/api/media_files/GetMediaBatchImages";
@@ -41,41 +37,28 @@ import { DeleteFeaturedItem } from "@storyteller/components/src/api/featured_ite
import { mediaTypeLabels } from "utils/mediaTypeLabels";
import { EngineMediaPanel } from "./components/EngineMediaPanel/EngineMediaPanel";
import { GetMediaFileTitle } from "common/GetMediaFileTitle";
import { STYLES_BY_KEY } from "common/StyleOptions";
import { faCube, faStarShooting } from "@fortawesome/pro-duotone-svg-icons";
import { usePrefixedDocumentTitle } from "common/UsePrefixedDocumentTitle";
import { GetWebsiteLink } from "@storyteller/components/src/env/GetWebsiteLink";
export default function MediaPage() {
const { canAccessStudio, canEditMediaFile, canBanUsers, user } = useSession();
const { token } = useParams<{ token: string }>();
const bookmarks = useBookmarks();
const ratings = useRatings();
const {
media: mediaFile,
prompt,
remove,
status,
} = useMedia({
mediaToken: token,
onSuccess: (res: any) => {
ratings.gather({ res, key: "token" });
},
});
export default function MediaPage({
bookmarkButtonProps,
canAccessStudio,
canBanUsers,
canEdit,
mediaFile,
openDeleteModal,
ratingButtonProps,
status,
urlToken,
}: MediaSubViewProps) {
const batchToken = mediaFile?.maybe_batch_token;
const [images, setImages] = useState<{ url: string; token: string }[]>([]);
const bucketConfig = new BucketConfig();
const timeCreated = moment(mediaFile?.created_at || "").fromNow();
const dateCreated = moment(mediaFile?.created_at || "").format("LLL");
const [buttonLabel, setButtonLabel] = useState("Copy");
const [copyPositiveButtonText, setCopyPositiveButtonText] = useState("Copy");
const [copyNegativeButtonText, setCopyNegativeButtonText] = useState("Copy");
const [activeSlide, setActiveSlide] = useState({ url: "", token: "" });
const viewerCanEdit = canEditMediaFile(
mediaFile?.maybe_creator_user?.user_token || ""
);
const isModerator = canBanUsers() || false;
const viewerCanMakeFeatured = canBanUsers() || false;
// Inside MediaPage.tsx
@@ -99,14 +82,6 @@ export default function MediaPage() {
// eslint-disable-next-line react-hooks/exhaustive-deps
}, [batchToken]);
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const closeDeleteModal = () => setIsDeleteModalOpen(false);
const openDeleteModal = () => setIsDeleteModalOpen(true);
const deleteMedia = () => remove(!!user?.can_ban_users);
const handleFeatureMedia = async () => {
setFeatureMedia(!mediaFile?.is_featured);
};
@@ -132,19 +107,6 @@ export default function MediaPage() {
}
};
const copyToClipboard = async (
text: string,
setButtonText: React.Dispatch<React.SetStateAction<string>>
) => {
try {
await navigator.clipboard.writeText(text);
setButtonText("Copied!");
setTimeout(() => setButtonText("Copy"), 2000); // Change back after 2 seconds
} catch (err) {
console.error("Failed to copy: ", err);
}
};
const handleActiveSlideChange = (image: any) => {
setActiveSlide(image);
};
@@ -168,113 +130,6 @@ export default function MediaPage() {
usePrefixedDocumentTitle(pageTitle);
const promptSection = (
<>
{prompt?.maybe_positive_prompt && (
<Panel padding={true} className="mt-3">
<div className="d-flex gap-3 align-items-center mb-2">
<h6 className="fw-semibold mb-0 flex-grow-1">Prompt</h6>
<Button
icon={faCopy}
onClick={() =>
copyToClipboard(
prompt.maybe_positive_prompt || "",
setCopyPositiveButtonText
)
}
label={copyPositiveButtonText}
variant="link"
className="fs-7"
/>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt.maybe_positive_prompt}</p>
</div>
{prompt?.maybe_negative_prompt && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">
Negative Prompt
</h6>
<Button
icon={faCopy}
onClick={() =>
copyToClipboard(
prompt.maybe_negative_prompt || "",
setCopyNegativeButtonText
)
}
label={copyNegativeButtonText}
variant="link"
className="fs-7"
/>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt.maybe_negative_prompt}</p>
</div>
</>
)}
{prompt?.maybe_style_name && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Style Name</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
{STYLES_BY_KEY.get(prompt.maybe_style_name)?.label}
</p>
</div>
</>
)}
{prompt?.used_face_detailer && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">
Used Face Detailer
</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
{prompt?.used_face_detailer ? "Yes" : "No"}
</p>
</div>
</>
)}
{prompt?.used_upscaler && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Used Upscaler</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt?.used_upscaler ? "Yes" : "No"}</p>
</div>
</>
)}
{isModerator &&
mediaFile?.maybe_moderator_fields
?.maybe_style_transfer_source_media_file_token && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Source Video</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
<Link
to={`/media/${mediaFile?.maybe_moderator_fields?.maybe_style_transfer_source_media_file_token}`}
>
Link to source (staff only)
</Link>
</p>
</div>
</>
)}
</Panel>
)}
</>
);
function renderMediaComponent(mediaFile: MediaFile) {
switch (mediaFile.media_type) {
case MediaFileType.Audio:
@@ -299,17 +154,6 @@ export default function MediaPage() {
)}
</div>
);
case MediaFileType.Video:
return (
<>
<div className="panel panel-clear">
<div className="ratio ratio-16x9 video-bg panel-border rounded">
<MediaVideoComponent mediaFile={mediaFile} />
</div>
</div>
{promptSection}
</>
);
case MediaFileType.Image:
let sdMediaImage = [
@@ -331,7 +175,6 @@ export default function MediaPage() {
images={mediaFile.maybe_batch_token ? images : sdMediaImage}
onActiveSlideChange={handleActiveSlideChange}
/>
{promptSection}
</>
);
case MediaFileType.Pmd:
@@ -563,7 +406,7 @@ export default function MediaPage() {
},
];
if (user?.canBanUsers) {
if (canBanUsers()) {
modMediaDetails = (
<Accordion.Item title="Moderator Details" defaultOpen={false}>
<DataTable data={modDetails} />
@@ -614,7 +457,7 @@ export default function MediaPage() {
...(coverMediaPath
? { src: bucketConfig.getGcsUrl(coverMediaPath) }
: {}),
...(viewerCanEdit ? { to: `/edit-cover-image/${token}` } : {}),
...(canEdit ? { to: `/edit-cover-image/${urlToken}` } : {}),
coverIndex: mediaFile.cover_image.default_cover.image_index,
}}
/>
@@ -626,7 +469,7 @@ export default function MediaPage() {
};
return (
<div>
<>
<Container type="panel" className="mb-5">
<Panel clear={true} className="py-4">
<div className="d-flex flex-column flex-lg-row gap-3 gap-lg-2">
@@ -657,24 +500,8 @@ export default function MediaPage() {
)}
<div className="d-flex align-items-center gap-2">
<LikeButton
{...{
large: true,
...ratings.makeProps({
entityToken: token,
entityType: "media_file",
}),
}}
/>
<BookmarkButton
{...{
large: true,
...bookmarks.makeProps({
entityToken: token,
entityType: "media_file",
}),
}}
/>
<ActionButton {...ratingButtonProps} />
<ActionButton {...bookmarkButtonProps} />
</div>
</div>
</div>
@@ -946,7 +773,7 @@ export default function MediaPage() {
</div>
</Panel>
{viewerCanEdit && (
{canEdit && (
<>
<div className="d-flex gap-2">
<Button
@@ -1011,16 +838,6 @@ export default function MediaPage() {
</Panel>
</Container>
</div>
<Modal
show={isDeleteModalOpen}
handleClose={closeDeleteModal}
title="Delete Media"
content={() => (
<>{`Are you sure you want to delete this media file? This action cannot be undone.`}</> // replace w/ dynamic later -V
// <>{`Are you sure you want to delete "${title}"? This action cannot be undone.`}</>
)}
onConfirm={deleteMedia}
/>
</div>
</>
);
}
@@ -0,0 +1,151 @@
import React, { useState } from "react";
import { useParams } from "react-router-dom";
import { useBookmarks, useMedia, useRatings, useSession } from "hooks";
import { MediaFileType } from "@storyteller/components/src/api/_common/enums/MediaFileType";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMedia";
import { Prompt } from "@storyteller/components/src/api/prompts/GetPrompts";
import { CreateFeaturedItem } from "@storyteller/components/src/api/featured_items/CreateFeaturedItem";
import { DeleteFeaturedItem } from "@storyteller/components/src/api/featured_items/DeleteFeaturedItem";
import { FetchStatus } from "@storyteller/components/src/api/_common/SharedFetchTypes";
import { ActionButtonProps, Container, Modal, Panel } from "components/common";
import MediaPage from "./MediaPage";
import VideoMediaPage from "./VideoMediaPage";
import { faStarShooting } from "@fortawesome/pro-duotone-svg-icons";
import { faStarShooting as faStarShootingOutline } from "@fortawesome/pro-regular-svg-icons";
import "./MediaPage.scss";
export interface MediaSubViewProps {
bookmarkButtonProps: ActionButtonProps;
canAccessStudio: () => boolean;
canBanUsers: () => boolean;
canEdit: boolean;
featureButtonProps: ActionButtonProps;
isFeatured: boolean;
isModerator: boolean;
mediaFile?: MediaFile;
openDeleteModal: () => void;
prompt?: Prompt;
ratingButtonProps: ActionButtonProps;
status: FetchStatus;
title: string;
titleSet: (title: string) => void;
urlToken: string;
}
export default function MediaPageSwitch() {
const { token: urlToken } = useParams<{ token: string }>();
const { canAccessStudio, canBanUsers, canEditMediaFile, user } = useSession();
const bookmarks = useBookmarks();
const ratings = useRatings();
const [isFeatured, isFeaturedSet] = useState(false);
const [title, titleSet] = useState("");
const { mediaFile, prompt, remove, status } = useMedia({
mediaToken: urlToken,
onSuccess: (res: any) => {
isFeaturedSet(res.is_featured);
titleSet(res.maybe_title || "Untitled Video");
ratings.gather({ res, key: "token" });
bookmarks.gather({ res, key: "token" });
},
});
const [isDeleteModalOpen, setIsDeleteModalOpen] = useState(false);
const canEdit = canEditMediaFile(urlToken);
const isModerator = canBanUsers() || false;
const closeDeleteModal = () => setIsDeleteModalOpen(false);
const openDeleteModal = () => setIsDeleteModalOpen(true);
const deleteMedia = () => remove(!!user?.can_ban_users);
const bookmarkButtonProps: ActionButtonProps = {
...bookmarks.makeProps({
entityToken: urlToken,
entityType: "media_file",
}),
toolTipOff: "Add to bookmarks",
toolTipOn: "Remove bookmark",
};
const ratingButtonProps: ActionButtonProps = {
...ratings.makeProps({
entityToken: urlToken,
entityType: "media_file",
}),
toolTipOff: "Like this",
toolTipOn: "Unlike this",
};
const featureButtonProps: ActionButtonProps = {
actionType: "feature",
isToggled: isFeatured,
labelOff: "Feature",
labelOn: "Featured",
iconOn: faStarShooting,
iconOff: faStarShootingOutline,
toggle: () => {
(isFeatured ? DeleteFeaturedItem : CreateFeaturedItem)("", {
entity_type: "media_file",
entity_token: mediaFile?.token || "",
}).then(() => {
isFeaturedSet((current: boolean) => {
return !current;
});
});
},
toolTipOff: "Feature this",
toolTipOn: "Remove from featured",
};
const subViewProps: MediaSubViewProps = {
bookmarkButtonProps,
canAccessStudio,
canBanUsers,
canEdit,
featureButtonProps,
isFeatured,
isModerator,
mediaFile,
openDeleteModal,
prompt,
ratingButtonProps,
status,
title,
titleSet,
urlToken,
};
const MediaSwitch = () => {
switch (mediaFile?.media_type) {
case MediaFileType.Video:
return <VideoMediaPage {...subViewProps} />;
default:
return <MediaPage {...subViewProps} />;
}
};
return (
<>
<Container type="panel" className="mb-5">
<Panel clear={true} className="py-4 media-page-container d-flex">
{MediaSwitch()}
</Panel>
</Container>
<Modal
{...{
content: () => (
<>{`Are you sure you want to delete this media file? This action cannot be undone.`}</> // replace w/ dynamic later -V
// <>{`Are you sure you want to delete "${title}"? This action cannot be undone.`}</>
),
handleClose: closeDeleteModal,
onConfirm: deleteMedia,
show: isDeleteModalOpen,
title: "Delete Media",
}}
/>
</>
);
}
@@ -3,20 +3,20 @@ import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaF
import React from "react";
interface MediaVideoComponentProps {
mediaFile: MediaFile;
mediaFile?: MediaFile;
}
export default function MediaVideoComponent({
mediaFile,
}: MediaVideoComponentProps) {
let mediaLink = new BucketConfig().getGcsUrl(
mediaFile.public_bucket_path
);
let mediaLink = mediaFile
? new BucketConfig().getGcsUrl(mediaFile.public_bucket_path)
: "";
return (
return mediaFile && mediaFile.public_bucket_path ? (
<video className="rounded" controls width="100%" height="auto">
<source src={mediaLink} />
Your browser does not support the video element.
</video>
);
) : null;
}
@@ -0,0 +1,142 @@
import React, { useState } from "react";
import { Link } from "react-router-dom";
import { Prompt } from "@storyteller/components/src/api/prompts/GetPrompts";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMedia";
import { Button, Panel } from "components/common";
import { STYLES_BY_KEY } from "common/StyleOptions";
import { faCopy } from "@fortawesome/pro-solid-svg-icons";
interface PromptViewerProps {
isModerator: boolean;
mediaFile?: MediaFile;
prompt: Prompt;
}
export default function PromptViewer({
isModerator,
mediaFile,
prompt,
}: PromptViewerProps) {
const [copyPositiveButtonText, setCopyPositiveButtonText] = useState("Copy");
const [copyNegativeButtonText, setCopyNegativeButtonText] = useState("Copy");
const copyToClipboard = async (
text: string,
copyTextSet: React.Dispatch<React.SetStateAction<string>>
) => {
try {
await navigator.clipboard.writeText(text);
copyTextSet("Copied!");
setTimeout(() => copyTextSet("Copy"), 2000); // Change back after 2 seconds
} catch (err) {
console.error("Failed to copy: ", err);
}
};
return (
<>
{prompt.maybe_positive_prompt && (
<Panel padding={true} className="mt-3">
<div className="d-flex gap-3 align-items-center mb-2">
<h6 className="fw-semibold mb-0 flex-grow-1">Prompt</h6>
<Button
icon={faCopy}
onClick={() =>
copyToClipboard(
prompt.maybe_positive_prompt || "",
setCopyPositiveButtonText
)
}
label={copyPositiveButtonText}
variant="link"
className="fs-7"
/>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt.maybe_positive_prompt}</p>
</div>
{prompt.maybe_negative_prompt && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">
Negative Prompt
</h6>
<Button
icon={faCopy}
onClick={() =>
copyToClipboard(
prompt.maybe_negative_prompt || "",
setCopyNegativeButtonText
)
}
label={copyNegativeButtonText}
variant="link"
className="fs-7"
/>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt.maybe_negative_prompt}</p>
</div>
</>
)}
{prompt.maybe_style_name && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Style Name</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
{STYLES_BY_KEY.get(prompt.maybe_style_name)?.label}
</p>
</div>
</>
)}
{prompt?.used_face_detailer && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">
Used Face Detailer
</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
{prompt?.used_face_detailer ? "Yes" : "No"}
</p>
</div>
</>
)}
{prompt?.used_upscaler && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Used Upscaler</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">{prompt?.used_upscaler ? "Yes" : "No"}</p>
</div>
</>
)}
{isModerator &&
mediaFile?.maybe_moderator_fields
?.maybe_style_transfer_source_media_file_token && (
<>
<div className="d-flex gap-3 align-items-center mb-2 mt-3">
<h6 className="fw-semibold mb-0 flex-grow-1">Source Video</h6>
</div>
<div className="panel-inner p-2 rounded">
<p className="fs-7">
<Link
to={`/media/${mediaFile?.maybe_moderator_fields?.maybe_style_transfer_source_media_file_token}`}
>
Link to source (staff only)
</Link>
</p>
</div>
</>
)}
</Panel>
)}
</>
);
}
@@ -0,0 +1,335 @@
import React, { useState } from "react";
import moment from "moment";
import { AnimationStatus, useAnimationStatus } from "hooks";
import {
ActionButton,
Button,
TempInput as Input,
SocialButton,
Socials,
Spinner,
} from "components/common";
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import { CommentComponent } from "v2/view/_common/comments/CommentComponent";
import MediaVideoComponent from "./MediaVideoComponent";
import {
RenameMedia,
RenameMediaResponse,
} from "@storyteller/components/src/api/media_files/RenameMedia";
import PromptViewer from "./PromptViewer";
import { MediaSubViewProps } from "./MediaPageSwitch";
import { a, useTransition } from "@react-spring/web";
import { basicTransition } from "resources";
import {
faArrowRightArrowLeft,
faCheck,
faPen,
faTrash,
faX,
} from "@fortawesome/pro-solid-svg-icons";
export enum EditingTitleState {
closed,
editing,
saving,
}
export default function DevMediaPage({
bookmarkButtonProps,
canAccessStudio,
canEdit,
featureButtonProps,
isModerator,
mediaFile,
openDeleteModal,
prompt,
ratingButtonProps,
title,
titleSet,
urlToken,
}: MediaSubViewProps) {
const [editingTitle, editingTitleSet] = useState(EditingTitleState.closed);
const { events, status: animationStatus } = useAnimationStatus();
const titlePaused = animationStatus === AnimationStatus.paused;
const transitions = useTransition(
editingTitle,
basicTransition({ ...events })
);
const saveTitle = () => {
editingTitleSet(EditingTitleState.saving);
RenameMedia(urlToken, {
name: title,
}).then((res: RenameMediaResponse) => {
if (res.success) {
editingTitleSet(EditingTitleState.closed);
}
});
};
// for later reference and eventual replacement
// const fakeVideos = [
// {
// coverImage: "",
// title: "Lorem ipsum",
// creator: "Alfredo Someguy",
// },
// {
// coverImage: "",
// title: "The Quick Brown Fox",
// creator: "Cheese McGee",
// },
// {
// coverImage: "",
// title: "An Anime Video",
// creator: "Georgia Jones",
// },
// {
// coverImage: "",
// title: "Some Other Video",
// creator: "Brain Problems",
// },
// {
// coverImage: "",
// title: "Kachow",
// creator: "Mr Smith",
// },
// {
// coverImage: "",
// title: "Lorem ipsum",
// creator: "Alfredo Someguy",
// },
// {
// coverImage: "",
// title: "Lorem ipsum",
// creator: "Alfredo Someguy",
// },
// ];
const shareLinks: Socials[] = [
"x",
"whatsapp",
"facebook",
"reddit",
"email",
];
return (
<>
<div
{...{
className: `video-page-main-content video-page-theater-mode`,
}}
>
<div
{...{
className: "video-page-media-content",
}}
>
<MediaVideoComponent mediaFile={mediaFile} />
</div>
<header>
{transitions(
(style: any, yah) =>
[
<a.div {...{ className: "fy-title-editor", style }}>
<div {...{ className: "video-page-title" }}>
<h2>{title}</h2>
<span>{`Created ${moment(
mediaFile?.created_at || ""
).fromNow()}`}</span>
</div>
{canEdit && (
<Button
{...{
icon: faPen,
onClick: () => {
if (titlePaused) {
editingTitleSet(EditingTitleState.editing);
}
},
square: true,
variant: "secondary",
}}
/>
)}
</a.div>,
<a.div {...{ className: "fy-title-editor", style }}>
<Input
{...{
onChange: (e: React.ChangeEvent<HTMLInputElement>) => {
titleSet(e.target.value);
},
value: title,
}}
/>{" "}
<Button
{...{
square: true,
icon: faCheck,
onClick: () => saveTitle(),
}}
/>
<Button
{...{
square: true,
icon: faX,
onClick: () => {
if (titlePaused) {
editingTitleSet(EditingTitleState.closed);
titleSet(mediaFile?.maybe_title || "Untitled video");
}
},
variant: "secondary",
}}
/>
</a.div>,
<a.div
{...{
className: "fy-title-editor title-editor-spinner",
style,
}}
>
<Spinner />
</a.div>,
][yah]
)}
<div {...{ className: "video-page-file-tools" }}>
{canEdit && (
<Button
{...{
icon: faTrash,
square: true,
onClick: openDeleteModal,
variant: "danger",
}}
/>
)}
{canAccessStudio() && (
<Button
{...{
icon: faArrowRightArrowLeft,
label: "Style Transfer",
to: `/style-video/${mediaFile?.token || ""}`,
}}
/>
)}
</div>
</header>
<div
{...{
className: "video-page-media-details",
}}
>
<div
{...{
className: "video-page-creator",
}}
>
<Gravatar
{...{
noHeight: true,
size: 56,
}}
username={mediaFile?.maybe_creator_user?.username || ""}
email_hash={mediaFile?.maybe_creator_user?.gravatar_hash || ""}
avatarIndex={
mediaFile?.maybe_creator_user?.default_avatar.image_index || 0
}
backgroundIndex={
mediaFile?.maybe_creator_user?.default_avatar.color_index || 0
}
/>
<div
{...{
className: "video-page-creator-info",
}}
>
<h3>{mediaFile?.maybe_creator_user?.username || ""}</h3>
</div>
</div>
<div
{...{
className: "video-page-share-container",
}}
>
<div {...{ className: "media-share-label" }}>Share</div>
<div
{...{
className: "video-page-share-links",
}}
>
{shareLinks.map((social, i) => (
<SocialButton
{...{
hideLabel: true,
social,
shareUrl: "abc",
shareText: "xyz",
}}
/>
))}
</div>
</div>
<div
{...{
className: "video-page-actions-container",
}}
>
<div {...{ className: "media-share-label" }}>Actions</div>
<div
{...{
className: "video-page-share-links",
}}
>
<ActionButton {...ratingButtonProps} />
<ActionButton {...bookmarkButtonProps} />
{isModerator && <ActionButton {...featureButtonProps} />}
</div>
</div>
</div>
{prompt && (
<PromptViewer
{...{
prompt,
isModerator,
mediaFile,
}}
/>
)}
<div {...{ className: "video-page-comments panel" }}>
<h5>Comments</h5>
<CommentComponent
entityType="media_file"
entityToken={mediaFile?.token || ""}
/>
</div>
</div>
{/* <div
{...{
className: "video-page-related-videos",
}}
>
{fakeVideos.map((relatedVideo, i) => (
<div {...{ className: "video-page-related-item" }}>
<div
{...{
className: "video-page-related-cover-image",
}}
></div>
<div
{...{
className: "video-page-related-details",
}}
>
<h6>{relatedVideo.title}</h6>
</div>
</div>
))}
</div>*/}
</>
);
}