Merge pull request #309 from storytold/bt-bflat-fy-media-page

media file pages (BFlat's work)
This commit is contained in:
Brandon Thomas authored and GitHub committed 2023-10-18 04:20:27 -04:00
commit b11652e800
36 files changed
+1170 -185

No files matched your search

+6
View File
@@ -410,6 +410,12 @@ class ApiConfig {
return `${this.getApiOrigin()}/voice_clone_requests/check`;
}
// =============== Media Files ===============
getMediaFile(mediaFileToken: string): string {
return `${this.getApiOrigin()}/v1/media_files/file/${mediaFileToken}`;
}
// =============== File Uploads ===============
uploadAudio(): string {
@@ -0,0 +1,17 @@
/// A common type returned by several endpoints.
/// Basic information to display a user and their avatar.
export interface UserDetailsLight {
user_token: string,
/// Username (lowercase)
username: string,
/// Username with user-specified capitalization
display_name: string,
gravatar_hash: string,
default_avatar: DefaultAvatarInfo,
}
export interface DefaultAvatarInfo {
image_index: number,
color_index: number,
}
@@ -0,0 +1,6 @@
export enum MediaFileType {
Audio = "audio",
Video = "video",
Image = "image",
}
@@ -0,0 +1,52 @@
import { ApiConfig } from "../ApiConfig";
import { UserDetailsLight } from "../_common/UserDetailsLight";
import { MediaFileType } from "../_common/enums/MediaFileType";
export interface MediaFile {
token: string,
media_type: MediaFileType,
public_bucket_path: string,
maybe_creator_user: UserDetailsLight | null,
creator_set_visibility: string,
created_at: Date,
updated_at: Date,
}
export enum MediaFileLookupError {
NotFound,
ServerError,
FrontendError,
}
export interface GetMediaFileResponse {
success: boolean,
media_file?: MediaFile,
}
export async function GetMediaFile(mediaFileToken: string) : Promise<GetMediaFileResponse> {
const endpoint = new ApiConfig().getMediaFile(mediaFileToken);
return await fetch(endpoint, {
method: 'GET',
headers: {
'Accept': 'application/json',
},
credentials: 'include',
})
.then(res => res.json())
.then(res => {
const response : GetMediaFileResponse = res;
if (response && response.success && response.media_file) {
// NB: Timestamps aren't converted to Date objects on their own!
response.media_file.created_at = new Date(response.media_file.created_at);
response.media_file.updated_at = new Date(response.media_file.updated_at);
return response;
} else {
return { success : false };
}
})
.catch(e => {
return { success : false };
});
}
+2 -1
View File
@@ -31,9 +31,10 @@
"@types/react": "^16.9.36",
"@types/react-dom": "^16.9.8",
"axios": "^0.21.0",
"bootstrap": "5.1.3",
"bulma": "^0.9.0",
"craco": "^0.0.3",
"date-fns": "^2.22.1",
"date-fns": "2.22.1",
"framer-motion": "^6.5.1",
"howler": "^2.2.0",
"i18next": "^21.6.11",
+10 -6
View File
@@ -1,4 +1,5 @@
import "./AppNew.scss";
import "scss/custom-bootstrap.scss";
import React from "react";
import { ApiConfig } from "@storyteller/components";
@@ -184,12 +185,15 @@ function newVocodes() {
return discord || twitter || alphaCookie;
}
function initInferenceJobsByCategoryMap() : Map<FrontendInferenceJobType, InferenceJob[]> {
let inferenceJobsByCategory = new Map();
inferenceJobsByCategory.set(FrontendInferenceJobType.FaceAnimation, []);
inferenceJobsByCategory.set(FrontendInferenceJobType.TextToSpeech, []);
inferenceJobsByCategory.set(FrontendInferenceJobType.VoiceConversion, []);
return inferenceJobsByCategory;
function initInferenceJobsByCategoryMap(): Map<
FrontendInferenceJobType,
InferenceJob[]
> {
let inferenceJobsByCategory = new Map();
inferenceJobsByCategory.set(FrontendInferenceJobType.FaceAnimation, []);
inferenceJobsByCategory.set(FrontendInferenceJobType.TextToSpeech, []);
inferenceJobsByCategory.set(FrontendInferenceJobType.VoiceConversion, []);
return inferenceJobsByCategory;
}
function isMacOs() {
@@ -0,0 +1,45 @@
@import "scss/colours.scss";
.fy-accordion-item {
border: 1px solid rgba($white, 0.12);
border-radius: 0.5rem;
overflow: hidden;
}
.fy-accordion-header {
display: flex;
justify-content: space-between;
align-items: center;
cursor: pointer;
background-color: $secondary;
transition: all 0.2s ease;
font-weight: 500;
&.open {
background-color: lighten($secondary, 5%);
}
}
.fy-accordion-content {
overflow: hidden;
background-color: lighten($panel, 4%);
&.open {
opacity: 1;
max-height: 100%;
visibility: visible;
}
}
.fy-accordion-content table {
width: 100%;
}
.caret-icon {
transition: transform 0.2s ease;
transform-origin: center;
}
.caret-rotated {
transform: rotate(180deg);
}
@@ -0,0 +1,119 @@
import React, {
useState,
useRef,
useEffect,
createContext,
useContext,
} from "react";
import "./Accordion.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faChevronDown } from "@fortawesome/pro-solid-svg-icons";
import { animated, useSpring } from "@react-spring/web";
interface AccordionProps {
children: React.ReactNode;
}
interface AccordionItemProps {
title: string;
defaultOpen?: boolean;
children: React.ReactNode;
}
interface AccordionContextType {
openItems: string[];
toggleItem: (title: string) => void;
}
const AccordionContext = createContext<AccordionContextType | undefined>(
undefined
);
function AccordionItem({
title,
defaultOpen = false,
children,
}: AccordionItemProps) {
const { openItems, toggleItem } = useContext(
AccordionContext
) as AccordionContextType;
const isOpen = openItems.includes(title);
const [contentHeight, setContentHeight] = useState<number | undefined>(
isOpen ? undefined : 0
);
const contentRef = useRef<HTMLDivElement>(null);
useEffect(() => {
if (contentRef.current) {
setContentHeight(isOpen ? contentRef.current.scrollHeight : 0);
}
}, [isOpen]);
const heightProps = useSpring({
height: isOpen ? `${contentHeight}px` : "0px",
config: { tension: 300, friction: 25, clamp: true },
});
const contentOpacityProps = useSpring({
opacity: isOpen ? 1 : 0,
config: { tension: 300, friction: 25, clamp: true },
});
const handleClick = () => {
toggleItem(title);
};
return (
<div className="fy-accordion-item">
<div
className={`fy-accordion-header p-3 ${isOpen ? "open" : ""}`}
onClick={handleClick}
>
{title}
<span className={`caret-icon ${isOpen ? "caret-rotated" : ""}`}>
<FontAwesomeIcon icon={faChevronDown} />
</span>
</div>
<animated.div style={{ ...heightProps, overflow: "hidden" }}>
<animated.div
ref={contentRef}
style={contentOpacityProps}
className="fy-accordion-content"
>
{children}
</animated.div>
</animated.div>
</div>
);
}
function Accordion({ children }: AccordionProps) {
const [openItems, setOpenItems] = useState<string[]>([]);
const toggleItem = (title: string) => {
if (openItems.includes(title)) {
setOpenItems(openItems.filter((item) => item !== title));
} else {
setOpenItems([...openItems, title]);
}
};
// Initialize openItems based on defaultOpen props of AccordionItem children
useEffect(() => {
const defaultOpenItems = React.Children.toArray(children)
.filter((child: any) => child.props.defaultOpen)
.map((child: any) => child.props.title);
setOpenItems(defaultOpenItems);
}, [children]);
return (
<AccordionContext.Provider value={{ openItems, toggleItem }}>
<div className="d-flex flex-column gap-3">{children}</div>
</AccordionContext.Provider>
);
}
Accordion.Item = AccordionItem;
export default Accordion;
@@ -0,0 +1 @@
export { default } from "./Accordion";
@@ -11,6 +11,16 @@
align-items: center;
justify-content: center;
color: $white !important;
&-square {
width: 46.5px;
height: 46.5px;
}
&-square-small {
width: 37.5px;
height: 37.5px;
}
}
.button-primary,
@@ -45,9 +55,9 @@
border-color: lighten($secondary, 5%);
}
.button-destructive,
.button-destructive:focus,
.button-destructive:active {
.button-danger,
.button-danger:focus,
.button-danger:active {
background-color: $danger;
color: $white !important;
border: 2.5px solid;
@@ -55,7 +65,7 @@
transition: 0.2s;
}
.button-destructive:hover {
.button-danger:hover {
background-color: lighten($danger, 5%);
color: $white !important;
border: 2.5px solid;
@@ -3,15 +3,20 @@ import { Link } from "react-router-dom";
import "./Button.scss";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { IconDefinition } from "@fortawesome/fontawesome-svg-core";
import Tippy from "@tippyjs/react";
import "tippy.js/dist/tippy.css";
interface ButtonProps extends ButtonHTMLAttributes<HTMLButtonElement> {
label?: string;
label?: string | null;
icon?: IconDefinition;
small?: boolean;
variant?: "primary" | "secondary" | "danger";
to?: string; // for internal links
href?: string; // for external links
to?: string;
href?: string;
target?: "_blank" | "_self";
square?: boolean;
tooltip?: string;
full?: boolean;
}
export default function Button({
@@ -22,56 +27,52 @@ export default function Button({
to,
href,
target = "_self",
square,
tooltip,
full = false,
...rest
}: ButtonProps) {
let buttonClass = `button`;
let IconComponent = icon ? (
<FontAwesomeIcon icon={icon} className={!square && label ? "me-2" : ""} />
) : null;
let LabelComponent = !square ? label : null;
if (small) {
buttonClass += ` button-small`;
}
const externalClass = rest.className || "";
switch (variant) {
case "primary":
buttonClass += " button-primary";
break;
case "secondary":
buttonClass += " button-secondary";
break;
case "danger":
buttonClass += " button-destructive";
break;
default:
buttonClass += " button-primary";
break;
}
const commonProps = {
className: `${externalClass} button ${small ? "button-small" : ""} ${
square ? (small ? "button-square-small" : "button-square") : ""
} button-${variant} ${full ? "w-100" : ""}`,
};
if (to) {
return (
<Link to={to} className={buttonClass}>
{icon && <FontAwesomeIcon icon={icon} className="me-2" />}
{label}
</Link>
);
}
if (href) {
return (
<a
href={href}
target={target}
rel="noopener noreferrer"
className={buttonClass}
>
{icon && <FontAwesomeIcon icon={icon} className="me-2" />}
{label}
</a>
);
}
delete rest.className;
// If neither 'to' nor 'href' are provided (regular button)
return (
<button className={buttonClass} type="button" {...rest}>
{icon && <FontAwesomeIcon icon={icon} className="me-2" />}
{label}
const ButtonContent = (
<>
{IconComponent}
{LabelComponent}
</>
);
const WrappedButton = to ? (
<Link to={to} {...commonProps}>
{ButtonContent}
</Link>
) : href ? (
<a href={href} target={target} rel="noopener noreferrer" {...commonProps}>
{ButtonContent}
</a>
) : (
<button type="button" {...commonProps} {...rest}>
{ButtonContent}
</button>
);
return tooltip ? (
<Tippy theme="fakeyou" content={tooltip}>
{WrappedButton}
</Tippy>
) : (
WrappedButton
);
}
@@ -3,11 +3,13 @@ import React from "react";
interface ContainerProps {
children: React.ReactNode;
type?: "full" | "padded" | "panel";
className?: string;
}
export default function PageContainer({
children,
type = "panel",
type = "full",
className = "",
}: ContainerProps) {
let containerClass = "";
@@ -19,12 +21,15 @@ export default function PageContainer({
containerClass = "container";
break;
case "panel":
containerClass = "";
containerClass = "container-panel";
break;
default:
containerClass = "container-fluid";
break;
}
return <div className={containerClass}>{children}</div>;
// Merge the classNames
const mergedClassNames = `${containerClass} ${className}`;
return <div className={mergedClassNames}>{children}</div>;
}
@@ -0,0 +1,34 @@
@import "scss/colours.scss";
table {
font-size: 14px;
margin-bottom: 0 !important;
}
td {
padding: 8px 16px !important;
min-width: 124px !important;
}
.no-outer-border {
border: none !important;
}
.no-outer-border > thead > tr > th:first-child,
.no-outer-border > tbody > tr > td:first-child {
border-left: none !important;
}
.no-outer-border > thead > tr > th:last-child,
.no-outer-border > tbody > tr > td:last-child {
border-right: none !important;
}
.no-outer-border > tbody > tr:last-child > td {
border-bottom: none !important;
}
.data-table-property {
font-weight: 500 !important;
background-color: lighten($panel, 7%) !important;
}
@@ -0,0 +1,34 @@
import React from "react";
import "./DataTable.scss";
import { Link } from "react-router-dom";
type Data = {
property: string;
value: string;
link?: string;
};
interface DataTableProps {
data: Data[];
}
export default function DataTable({ data }: DataTableProps) {
return (
<table className="table no-outer-border">
<tbody>
{data.map((row, index) => (
<tr key={index}>
<td className="data-table-property">{row.property}</td>
{row.link ? (
<td>
<Link to={row.link}>{row.value}</Link>
</td>
) : (
<td>{row.value}</td>
)}
</tr>
))}
</tbody>
</table>
);
}
@@ -0,0 +1 @@
export { default } from "./DataTable";
@@ -5,18 +5,15 @@ interface PanelProps {
padding?: boolean;
mb?: boolean;
mt?: boolean;
full?: boolean;
}
export default function Panel({ children, padding, mb, mt, full }: PanelProps) {
export default function Panel({ children, padding, mb, mt }: PanelProps) {
return (
<div className={full ? "container-panel mw-100" : "container-panel"}>
<div
className={`panel ${padding ? "p-3 py-4 p-md-4" : ""}
<div
className={`panel ${padding ? "p-3 py-4 p-md-4" : ""}
${mb ? "mb-4" : ""} ${mt ? "mt-4" : ""}`}
>
{children}
</div>
>
{children}
</div>
);
}
@@ -0,0 +1,37 @@
@import "scss/colours.scss";
.skeleton {
background: linear-gradient(
-90deg,
rgba($white, 10%) 25%,
rgba(darken($white, 20%), 10%) 50%,
rgba($white, 10%) 75%
);
background-size: 200% 100%;
animation: loading 1.5s infinite;
border-radius: 0.25rem;
width: 100%;
height: 1em;
display: inline-block;
}
@keyframes loading {
0% {
background-position: 200% 0;
}
100% {
background-position: -200% 0;
}
}
.skeleton.rounded {
border-radius: 50%;
}
.skeleton.medium {
width: 40%;
}
.skeleton.short {
width: 20%;
}
@@ -0,0 +1,13 @@
import React from "react";
import "./Skeleton.scss";
interface SkeletonProps {
type?: "full" | "medium" | "short";
rounded?: boolean;
}
export default function Skeleton({ type = "full", rounded }: SkeletonProps) {
const skeletonClass = `skeleton ${type} ${rounded && "rounded"}`;
return <div className={skeletonClass}></div>;
}
@@ -0,0 +1 @@
export { default } from "./Skeleton";
@@ -6,7 +6,7 @@ import Button from "components/common/Button";
interface PageHeaderProps {
titleIcon?: IconDefinition;
title: string;
title: string | React.ReactNode;
subText: string | React.ReactNode;
full?: boolean;
showButton?: boolean;
@@ -14,6 +14,8 @@ interface PageHeaderProps {
buttonLabel?: string;
buttonVariant?: "primary" | "secondary" | "danger";
buttonTo?: string;
buttonIcon?: IconDefinition;
buttonOnClick?: () => void;
}
export default function PageHeader({
@@ -26,6 +28,8 @@ export default function PageHeader({
buttonLabel,
buttonVariant = "primary",
buttonTo,
buttonIcon,
buttonOnClick,
}: PageHeaderProps) {
const icon = (
<>{titleIcon && <FontAwesomeIcon icon={titleIcon} className="me-3" />}</>
@@ -33,7 +37,7 @@ export default function PageHeader({
return (
<div className="pt-3 pb-4 pt-lg-4">
<Panel padding full={full}>
<Panel padding>
<div className="d-flex flex-column gap-4">
<div>
<div className="d-flex">
@@ -44,9 +48,11 @@ export default function PageHeader({
<div className="d-none d-md-block">
{showButton && (
<Button
icon={buttonIcon}
variant={buttonVariant}
label={buttonLabel}
to={buttonTo}
onClick={buttonOnClick}
/>
)}
</div>
@@ -35,7 +35,7 @@ export default function PageHeaderModelView({
return (
<div className="pt-3 pb-4 pt-lg-4">
{view === "regular" && (
<Panel padding full={full}>
<Panel padding>
<div className="row gy-3">
<div className="col-12 col-lg-8">
<h2 className="fw-bold">
@@ -0,0 +1 @@
export { default } from "./useTimeAgo";
@@ -0,0 +1,44 @@
import { useEffect, useState } from "react";
function useTimeAgo(timestamp: string) {
const [timeAgo, setTimeAgo] = useState<string>("");
useEffect(() => {
const calculateTimeAgo = () => {
const now = new Date();
const then = new Date(timestamp);
const seconds = Math.floor((now.getTime() - then.getTime()) / 1000);
const minutes = Math.floor(seconds / 60);
const hours = Math.floor(minutes / 60);
const days = Math.floor(hours / 24);
const months = Math.floor(days / 30);
const years = Math.floor(months / 12);
if (years > 0) {
setTimeAgo(`${years} ${years === 1 ? "year" : "years"} ago`);
} else if (months > 0) {
setTimeAgo(`${months} ${months === 1 ? "month" : "months"} ago`);
} else if (days > 0) {
setTimeAgo(`${days} ${days === 1 ? "day" : "days"} ago`);
} else if (hours > 0) {
setTimeAgo(`${hours} ${hours === 1 ? "hour" : "hours"} ago`);
} else if (minutes > 0) {
setTimeAgo(`${minutes} ${minutes === 1 ? "minute" : "minutes"} ago`);
} else {
setTimeAgo(`${seconds} ${seconds === 1 ? "second" : "seconds"} ago`);
}
};
calculateTimeAgo();
// Update the time difference every minute
const intervalId = setInterval(calculateTimeAgo, 60000);
return () => clearInterval(intervalId);
}, [timestamp]);
return timeAgo;
}
export default useTimeAgo;
+28 -20
View File
@@ -1,32 +1,40 @@
import React from 'react';
import ReactDOM from 'react-dom';
import * as serviceWorker from './serviceWorker';
import AppTranslated from './AppTranslated';
import React from "react";
import ReactDOM from "react-dom";
import * as serviceWorker from "./serviceWorker";
import AppTranslated from "./AppTranslated";
const IS_IOS : boolean = /iPad|iPhone|iPod/.test(navigator.platform || "");
const IS_IOS: boolean = /iPad|iPhone|iPod/.test(navigator.platform || "");
const enableSpectrograms = !IS_IOS;
const flashVocodesNotice = new URLSearchParams(window.location.search).has('vocodes');
const flashVocodesNotice = new URLSearchParams(window.location.search).has(
"vocodes"
);
const designSystemClass = 'fakeyou-refresh';
const designSystemClass = "fakeyou-refresh";
document.getElementsByTagName('html')[0].classList.add(designSystemClass);
document.getElementsByTagName("html")[0].classList.add(designSystemClass);
// We can't include Bootstrap CSS along with Bulma since some of the class names conflict.
// TODO(echelon): Once ported, statically move CSS to "index.html".
const bootstrapCss = document.createElement("link");
bootstrapCss.setAttribute("rel", "stylesheet");
bootstrapCss.setAttribute("crossorigin", "anonymous");
bootstrapCss.setAttribute("integrity", "sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3");
bootstrapCss.setAttribute("href", "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css");
document.getElementsByTagName("head")[0].appendChild(bootstrapCss);
// const bootstrapCss = document.createElement("link");
// bootstrapCss.setAttribute("rel", "stylesheet");
// bootstrapCss.setAttribute("crossorigin", "anonymous");
// bootstrapCss.setAttribute("integrity", "sha384-1BmE4kWBq78iYhFldvKuhfTAU6auU8tT94WrHftjDbrCEXSU1oBoqyl2QvZ6jIW3");
// bootstrapCss.setAttribute("href", "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/css/bootstrap.min.css");
// document.getElementsByTagName("head")[0].appendChild(bootstrapCss);
const bootstrapJs = document.createElement("script");
bootstrapJs.setAttribute("rel", "stylesheet");
bootstrapJs.setAttribute("crossorigin", "anonymous");
bootstrapJs.setAttribute("integrity", "sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p");
bootstrapJs.setAttribute("src", "https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js");
bootstrapJs.setAttribute(
"integrity",
"sha384-ka7Sk0Gln4gmtz2MlQnikT1wXgYsOg+OMhuP+IlRH9sENBO0LRn5q+8nbTov4+1p"
);
bootstrapJs.setAttribute(
"src",
"https://cdn.jsdelivr.net/npm/bootstrap@5.1.3/dist/js/bootstrap.bundle.min.js"
);
document.getElementsByTagName("body")[0].appendChild(bootstrapJs);
// NB: Posthog turned off for most users (anonymous users) due to cost.
@@ -37,11 +45,11 @@ document.getElementsByTagName("body")[0].appendChild(bootstrapJs);
ReactDOM.render(
<React.StrictMode>
<AppTranslated
enableSpectrograms={enableSpectrograms}
flashVocodesNotice={flashVocodesNotice}
/>
enableSpectrograms={enableSpectrograms}
flashVocodesNotice={flashVocodesNotice}
/>
</React.StrictMode>,
document.getElementById('root')
document.getElementById("root")
);
// If you want your app to work offline and load faster, you can change
@@ -0,0 +1,11 @@
// Override the $spacers variable
$spacers: (
0: 0,
1: 0.25rem,
2: 0.5rem,
3: 1rem,
4: 1.75rem,
5: 2rem,
);
@import "~bootstrap/scss/bootstrap";
+40 -19
View File
@@ -148,7 +148,7 @@ styles with important tags:
}
.container {
max-width: 1080px !important;
max-width: 1200px !important;
}
.container,
@@ -168,11 +168,6 @@ styles with important tags:
padding-bottom: 0.4rem !important;
}
.py-4 {
padding-top: 2rem !important;
padding-bottom: 2rem !important;
}
.lead {
color: rgba($white, 0.8);
font-size: 17px;
@@ -288,18 +283,33 @@ styles with important tags:
border-radius: 50px;
}
.bg-panel {
background-color: lighten($panel, 1%);
}
.panel {
position: relative;
width: 100%;
background-color: $panel;
border-color: #ffffff12 !important;
border: 1px solid;
border-left: none;
border-right: none;
border-radius: 0px;
box-shadow: 0 0 0 1px rgba(0 0 0 / 16%), 0 70px 65px rgb(0 0 0 / 8%),
0 30px 30px rgb(0 0 0 / 7%), 0 15px 15px rgb(0 0 0 / 5%),
0 10px 8px rgb(0 0 0 / 3%), 0 4px 4px rgb(0 0 0 / 2%),
0 2px 2px rgb(0 0 0 / 1%) !important;
&.panel-clear {
padding-left: 1rem;
padding-right: 1rem;
background-color: transparent;
border: none;
border-color: transparent !important;
box-shadow: none !important;
}
.panel-inner {
background-color: lighten($panel, 4%);
}
@@ -926,10 +936,6 @@ styles with important tags:
color: $white;
}
.tippy-box[data-theme~="fakeyou"] > .tippy-svg-arrow {
fill: lighten($secondary, 5%);
}
.tippy-box[data-theme~="range-slider"] {
margin-top: -5px;
background-color: lighten($panel, 10%);
@@ -937,6 +943,23 @@ styles with important tags:
font-size: 13px !important;
}
.tippy-box[data-theme~="fakeyou"][data-placement^="top"]
> .tippy-arrow::before {
border-top-color: lighten($secondary, 5%);
}
.tippy-box[data-theme~="fakeyou"][data-placement^="bottom"]
> .tippy-arrow::before {
border-bottom-color: lighten($secondary, 5%);
}
.tippy-box[data-theme~="fakeyou"][data-placement^="left"]
> .tippy-arrow::before {
border-left-color: lighten($secondary, 5%);
}
.tippy-box[data-theme~="fakeyou"][data-placement^="right"]
> .tippy-arrow::before {
border-right-color: lighten($secondary, 5%);
}
.sliding-content {
overflow: hidden;
opacity: 0;
@@ -1468,10 +1491,6 @@ textarea {
}
}
.p-lg-4 {
padding: 2rem !important;
}
.lead {
font-size: 18px;
}
@@ -1517,12 +1536,18 @@ textarea {
padding-left: var(--bs-gutter-x, 1rem);
margin-right: auto;
margin-left: auto;
max-width: 1080px;
max-width: 1200px;
border-radius: 0.5rem;
}
.panel {
border-radius: 0.5rem;
border: 1px solid;
&.panel-clear {
padding-left: 0rem;
padding-right: 0rem;
}
}
ul.patrons-list {
@@ -1538,9 +1563,5 @@ textarea {
height: 100%;
border-radius: 0.5rem;
}
.p-md-4 {
padding: 2rem !important;
}
}
}
@@ -6,7 +6,6 @@ import { LoginPage } from "./pages/login/LoginPage";
import { ModerationFc } from "./pages/moderation/moderation_main/ModerationFc";
import { ModerationIpBanListFc } from "./pages/moderation/moderation_ip_ban_list/ModerationIpBanListFc";
import { ModerationViewIpBanFc } from "./pages/moderation/moderation_view_ip_ban/ModerationViewIpBanFc";
import { Media } from "./pages/media";
import { LipsyncEditor } from "./pages/lipsync";
import { ProfileEditFc } from "./pages/profile/profile_edit/ProfileEditFc";
import { ProfilePage } from "./pages/profile/profile_view/ProfilePage";
@@ -90,6 +89,7 @@ import MobileMenu from "components/layout/MobileMenu/MobileMenu";
import { TopNav } from "./nav/TopNav";
import { TestingPage } from "./pages/testing/TestingPage";
import TtsModelSearchPage from "./pages/tts/tts_model_search/TtsModelSearchPage";
import MediaPage from "./pages/media/MediaPage";
interface Props {
sessionWrapper: SessionWrapper;
@@ -318,6 +318,10 @@ class PageContainer extends React.Component<
/>
</Route>
<Route path="/media/:token">
<MediaPage sessionWrapper={this.props.sessionWrapper} />
</Route>
<Route path="/dev-tts">
<TtsModelSearchPage sessionWrapper={this.props.sessionWrapper} />
</Route>
@@ -463,10 +467,6 @@ class PageContainer extends React.Component<
<CreateCategoryPage sessionWrapper={this.props.sessionWrapper} />
</Route>
<Route path="/media/:token">
<Media />
</Route>
<Route path="/moderation/user/list">
<ModerationUserListFc
sessionWrapper={this.props.sessionWrapper}
@@ -0,0 +1,17 @@
import React from "react";
import MediaAudioPlayer from "./MediaAudioPlayer";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaFile";
interface MediaAudioComponentProps {
mediaFile: MediaFile;
}
export default function MediaAudioComponent({
mediaFile,
}: MediaAudioComponentProps) {
return (
<div className="w-100">
<MediaAudioPlayer mediaFile={mediaFile} />
</div>
);
}
@@ -0,0 +1,150 @@
import React from "react";
import WaveSurfer from "wavesurfer.js";
import { useEffect, useState } from "react";
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import { faPlay, faPause } from "@fortawesome/pro-solid-svg-icons";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaFile";
enum PlaybackSpeed {
HALF,
NORMAL,
DOUBLE,
}
interface MediaAudioPlayerProps {
mediaFile: MediaFile;
}
export default function MediaAudioPlayer({ mediaFile }: MediaAudioPlayerProps) {
let [isPlaying, setIsPlaying] = useState(false);
let [isRepeating, setIsRepeating] = useState(false);
let [playbackSpeed, setPlaybackSpeed] = useState(PlaybackSpeed.NORMAL);
let [waveSurfer, setWaveSurfer] = useState<WaveSurfer | null>(null);
useEffect(() => {
const wavesurferInstance = WaveSurfer.create({
container: "#waveform", // Previousy I used 'this.ref.current' and React.createRef()
height: 200,
responsive: true,
waveColor: "#cbcbcb",
progressColor: "#fc8481",
cursorColor: "#fc6b68",
cursorWidth: 2,
normalize: false,
});
setWaveSurfer(wavesurferInstance);
}, []);
useEffect(() => {
const audioLink = new BucketConfig().getGcsUrl(
mediaFile.public_bucket_path
);
if (waveSurfer) {
waveSurfer.load(audioLink);
}
}, [waveSurfer, mediaFile]);
useEffect(() => {
if (waveSurfer) {
waveSurfer.unAll(); // NB: Otherwise we keep reinstalling the hooks and cause chaos
waveSurfer.on("pause", () => {
setIsPlaying(waveSurfer!.isPlaying());
});
waveSurfer.on("play", () => {
setIsPlaying(waveSurfer!.isPlaying());
});
waveSurfer.on("finish", () => {
if (waveSurfer && isRepeating) {
waveSurfer!.play();
}
});
}
}, [waveSurfer, isRepeating]);
const togglePlayPause = () => {
if (waveSurfer) {
waveSurfer.playPause();
}
};
const toggleIsRepeating = () => {
setIsRepeating(!isRepeating);
};
const togglePlaybackSpeed = () => {
let nextSpeed = PlaybackSpeed.NORMAL;
switch (playbackSpeed) {
case PlaybackSpeed.NORMAL:
nextSpeed = PlaybackSpeed.DOUBLE;
waveSurfer!.setPlaybackRate(1.5); // Okay, so a lie...
break;
case PlaybackSpeed.DOUBLE:
nextSpeed = PlaybackSpeed.HALF;
waveSurfer!.setPlaybackRate(0.5);
break;
case PlaybackSpeed.HALF:
nextSpeed = PlaybackSpeed.NORMAL;
waveSurfer!.setPlaybackRate(1.0);
break;
}
setPlaybackSpeed(nextSpeed);
};
let playButtonText = (
<>
<FontAwesomeIcon icon={faPlay} />
</>
);
if (isPlaying) {
playButtonText = (
<>
<FontAwesomeIcon icon={faPause} />
</>
);
}
let repeatButtonText = isRepeating ? "Repeat" : "NoRepeat";
let speedButtonText = "1x";
switch (playbackSpeed) {
case PlaybackSpeed.NORMAL:
speedButtonText = "1x";
break;
case PlaybackSpeed.DOUBLE:
speedButtonText = "2x";
break;
case PlaybackSpeed.HALF:
speedButtonText = "1/2x";
break;
}
return (
<div>
<div id="waveform"></div>
<div className="d-flex gap-4 flex-column justify-content-center align-items-center mt-4">
<div className="d-flex gap-2">
<button className="btn btn-primary" onClick={() => togglePlayPause()}>
{playButtonText}
</button>
<button
className="btn btn-secondary"
onClick={() => toggleIsRepeating()}
>
{repeatButtonText}
</button>
<button
className="btn btn-secondary"
onClick={() => togglePlaybackSpeed()}
>
{speedButtonText}
</button>
</div>
</div>
</div>
);
}
@@ -0,0 +1,21 @@
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaFile";
import React from "react";
interface MediaImageComponentProps {
mediaFile: MediaFile;
}
export default function MediaImageComponent({
mediaFile,
}: MediaImageComponentProps) {
return (
<div>
<img
src={mediaFile.public_bucket_path}
alt="test"
width="400"
height="300"
/>
</div>
);
}
@@ -0,0 +1,351 @@
import React, { useCallback, useEffect, useState } from "react";
import { Link, useParams } from "react-router-dom";
import MediaAudioComponent from "./MediaAudioComponent";
import MediaVideoComponent from "./MediaVideoComponent";
import MediaImageComponent from "./MediaImageComponent";
import { SessionWrapper } from "@storyteller/components/src/session/SessionWrapper";
import { GetMediaFile, 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 { FontAwesomeIcon } from "@fortawesome/react-fontawesome";
import {
faCircleExclamation,
faArrowDownToLine,
faCirclePlay,
faShare,
faSquareQuote,
} from "@fortawesome/pro-solid-svg-icons";
import Accordion from "components/common/Accordion";
import DataTable from "components/common/DataTable";
import { Gravatar } from "@storyteller/components/src/elements/Gravatar";
import useTimeAgo from "hooks/useTimeAgo";
import { CommentComponent } from "v2/view/_common/comments/CommentComponent";
import { MediaFileType } from "@storyteller/components/src/api/_common/enums/MediaFileType";
interface MediaPageProps {
sessionWrapper: SessionWrapper;
}
export default function MediaPage({ sessionWrapper }: MediaPageProps) {
const { token } = useParams<{ token: string }>();
const [mediaFile, setMediaFile] = useState<MediaFile | undefined | null>(null);
const [isLoading, setIsLoading] = useState<boolean>(true);
const [error, setError] = useState<boolean>(false);
const timeCreated = useTimeAgo(mediaFile?.created_at.toISOString() || "");
const getMediaFile = useCallback(async (mediaFileToken: string) => {
let result = await GetMediaFile(mediaFileToken);
if (result.media_file) {
setMediaFile(result.media_file);
setIsLoading(false);
} else {
setError(true);
setIsLoading(false);
}
}, []);
useEffect(() => {
getMediaFile(token);
}, [token, getMediaFile]);
function renderMediaComponent(mediaFile: MediaFile) {
switch (mediaFile.media_type) {
case MediaFileType.Audio:
return (
<div className="panel p-3 p-lg-4 d-flex flex-column gap-4 rounded">
<MediaAudioComponent mediaFile={mediaFile} />
<div>
<h5 className="fw-semibold">
<FontAwesomeIcon icon={faSquareQuote} className="me-2" />
Audio Text
</h5>
{/*<p>{mediaFile?.audio_text && mediaFile.audio_text}</p>*/}
</div>
</div>
);
case MediaFileType.Video:
return (
<div className="panel panel-clear">
<div className="ratio ratio-16x9">
<MediaVideoComponent mediaFile={mediaFile} />
</div>
</div>
);
case MediaFileType.Image:
return <MediaImageComponent mediaFile={mediaFile} />;
default:
return <div>Unsupported media type</div>;
}
}
if (isLoading)
return (
<>
<Container type="padded">
<div className="pt-4 pb-4">
<h2 className="fw-bold mb-1 pt-0 pt-lg-1">
<Skeleton type="short" />
</h2>
<p className="mb-0">
<Skeleton type="medium" />
</p>
</div>
</Container>
<Container type="padded">
<div className="row g-4">
<div className="col-12 col-xl-8">
<div className="panel p-3 py-4 p-md-4">
<h1 className="mb-0">
<Skeleton />
</h1>
</div>
<div className="panel p-3 py-4 p-md-4 mt-4 d-none d-xl-block">
<h4 className="fw-semibold mb-3">
<Skeleton type="short" />
</h4>
<h1>
<Skeleton />
</h1>
</div>
</div>
<div className="col-12 col-xl-4 d-flex flex-column gap-2">
<h1 className="mb-0">
<Skeleton type="medium" />
</h1>
<h1 className="mb-0">
<Skeleton />
</h1>
<h1 className="mb-0">
<Skeleton />
</h1>
</div>
</div>
</Container>
<div className="d-xl-none mt-4">
<Panel padding>
<h4 className="fw-semibold mb-3">
<Skeleton type="short" />
</h4>
<h1 className="mb-0">
<Skeleton />
</h1>
</Panel>
</div>
</>
);
if (error || !mediaFile)
return (
<Container type="panel">
<PageHeader
titleIcon={faCircleExclamation}
title="Media not found"
subText="This media does not exist or is private."
extension={
<div className="d-flex">
<Button label="Back to homepage" to="/" className="d-flex" />
</div>
}
/>
</Container>
);
const audioDetails = [
{ property: "Type", value: mediaFile.media_type },
{ property: "Created at", value: mediaFile.created_at.toString() },
{
property: "Visibility",
value: mediaFile.creator_set_visibility.toString(),
},
/*{
property: "Model",
value: mediaFile.model_used,
link: mediaFile.model_link,
},
{
property: "Vocoder",
value: "Test",
},
{
property: "Language",
value: "English",
},
{
property: "Model",
value: mediaFile.model_used,
},*/
];
const videoDetails = [
{ property: "Type", value: mediaFile.media_type },
{ property: "Created at", value: mediaFile.created_at.toString() },
{
property: "Visibility",
value: mediaFile.creator_set_visibility.toString(),
},
];
let pageTitle = "Result";
let pageSubText = "This is the result of your media.";
let mediaDetails = undefined;
switch (mediaFile.media_type) {
case MediaFileType.Audio:
pageTitle = "Audio Result";
pageSubText = mediaFile.token;
mediaDetails = (
<Accordion.Item title="Audio Details" defaultOpen={true}>
<DataTable data={audioDetails} />
</Accordion.Item>
);
break;
case MediaFileType.Video:
pageTitle = "Video Result";
pageSubText = mediaFile.token;
mediaDetails = (
<Accordion.Item title="Video Details" defaultOpen={true}>
<DataTable data={videoDetails} />
</Accordion.Item>
);
break;
case MediaFileType.Image:
pageTitle = "Image Model";
pageSubText = "Image Model SubText";
break;
default:
}
let modMediaDetails = undefined;
const modDetails = [
{ property: "Model creator is banned", value: "good standing" },
{
property: "Result creator is banned (if user)",
value: "good standing",
},
{
property: "Result creator IP address",
value: "0.0.0.0",
},
{
property: "Mod deleted at (UTC)",
value: "not deleted",
},
{
property: "Result creator deleted at (UTC)",
value: "not deleted",
},
];
if (sessionWrapper.canBanUsers()) {
modMediaDetails = (
<Accordion.Item title="Moderator Details" defaultOpen={false}>
<DataTable data={modDetails} />
</Accordion.Item>
);
}
return (
<>
<Container type="padded" className="py-5">
<h2 className="fw-bold mb-1">{pageTitle}</h2>
<p className="mb-0">{pageSubText}</p>
</Container>
<Container type="panel">
<div className="row g-4 mb-4">
<div className="col-12 col-xl-8">
<div className="media-wrapper">
{renderMediaComponent(mediaFile)}
</div>
<div className="panel p-3 py-4 p-md-4 mt-4 d-none d-xl-block">
<h4 className="fw-semibold mb-3">Comments</h4>
<CommentComponent
entityType="user"
entityToken={mediaFile.token}
sessionWrapper={sessionWrapper}
/>
</div>
</div>
<div className="col-12 col-xl-4">
<div className="panel panel-clear d-flex flex-column gap-4">
<div className="d-flex gap-2">
<Gravatar
size={48}
username={mediaFile.maybe_creator_user?.display_name}
avatarIndex={
mediaFile.maybe_creator_user?.default_avatar.image_index
}
backgroundIndex={
mediaFile.maybe_creator_user?.default_avatar.color_index
}
/>
<div className="d-flex flex-column">
<Link
className="fw-medium"
to={`/profile/${mediaFile.maybe_creator_user?.display_name}`}
>
{mediaFile.maybe_creator_user?.display_name}
</Link>
{timeCreated}
</div>
</div>
<div className="d-flex gap-2 flex-wrap">
<Button
icon={faArrowDownToLine}
label="Download"
onClick={() => {}}
className="flex-grow-1"
/>
<div className="d-flex gap-2">
<Button
square={true}
variant="secondary"
icon={faCirclePlay}
onClick={() => {}}
tooltip="Create"
/>
<Button
square={true}
variant="secondary"
icon={faShare}
onClick={() => {}}
tooltip="Share"
/>
</div>
</div>
<Accordion>
{mediaDetails}
{modMediaDetails}
</Accordion>
</div>
</div>
</div>
</Container>
<div className="d-xl-none my-4">
<Container type="panel">
<Panel padding={true}>
<h4 className="fw-semibold mb-3">Comments</h4>
<CommentComponent
entityType="user"
entityToken={mediaFile.token}
sessionWrapper={sessionWrapper}
/>
</Panel>
</Container>
</div>
</>
);
}
@@ -0,0 +1,22 @@
import { BucketConfig } from "@storyteller/components/src/api/BucketConfig";
import { MediaFile } from "@storyteller/components/src/api/media_files/GetMediaFile";
import React from "react";
interface MediaVideoComponentProps {
mediaFile: MediaFile;
}
export default function MediaVideoComponent({
mediaFile,
}: MediaVideoComponentProps) {
let mediaLink = new BucketConfig().getGcsUrl(
mediaFile.public_bucket_path
);
return (
<video className="rounded" controls width="100%" height="auto">
<source src={mediaLink} />
Your browser does not support the video element.
</video>
);
}
@@ -1,3 +0,0 @@
import media from './media';
export { media as Media };
@@ -1,11 +0,0 @@
import React from 'react';
interface Props {
value?: any;
}
export default function media({ value }: Props) {
return <div>
<h1>Media</h1>
</div>;
};
@@ -434,46 +434,6 @@ function TtsResultViewPage(props: Props) {
<div className="container py-5">
<div className="d-flex flex-column">
<h1 className=" fw-bold mb-2">{headingTitle}</h1>
{/* Rate Result Buttons */}
{/* Commented out because not ready yet. */}
{/*<div className="d-flex gap-3">
<div className="d-flex">
<Tippy
content="I like this audio"
hideOnClick
placement="bottom"
theme="fakeyou"
arrow={false}
>
<button className="btn-rate left rated">
<FontAwesomeIcon icon={faThumbsUp} />
</button>
</Tippy>
<div className="vr"></div>
<Tippy
content="I dislike this audio"
hideOnClick
placement="bottom"
theme="fakeyou"
arrow={false}
>
<button className="btn-rate right">
<FontAwesomeIcon icon={faThumbsDown} />
</button>
</Tippy>
</div>
<div className="d-flex align-items-center">
<FontAwesomeIcon
icon={faHeart}
className="me-2 rating-icon heart"
/>
<p>
<span className="fw-medium">1,518 likes</span>
</p>
</div>
</div>*/}
<p className="mb-3 result-text pt-2">
<TextExpander
@@ -494,14 +454,6 @@ function TtsResultViewPage(props: Props) {
</div>
</div>
{/* Without wavesurfer,
<audio
controls
src={audioLink}>
Your browser does not support the
<code>audio</code> element.
</audio>*/}
<div className="container-panel pt-3 pb-5">{socialSharing}</div>
<div className="container-panel pt-3 pb-5">
@@ -517,12 +469,6 @@ function TtsResultViewPage(props: Props) {
<div className="py-6">
<table className="table tts-result-table">
<tbody>
{/* <tr>
<th scope="row">Original text</th>
<td className="overflow-fix">
{ttsInferenceResult.raw_inference_text}
</td>
</tr> */}
<tr>
<th scope="row">Audio creator</th>
<td>{creatorDetails}</td>
+18 -1
View File
@@ -4440,9 +4440,10 @@ __metadata:
"@types/uuid": ^8.3.0
"@types/wavesurfer.js": ^5.2.0
axios: ^0.21.0
bootstrap: 5.1.3
bulma: ^0.9.0
craco: ^0.0.3
date-fns: ^2.22.1
date-fns: 2.22.1
framer-motion: ^6.5.1
howler: ^2.2.0
i18next: ^21.6.11
@@ -7280,6 +7281,15 @@ __metadata:
languageName: node
linkType: hard
"bootstrap@npm:5.1.3":
version: 5.1.3
resolution: "bootstrap@npm:5.1.3"
peerDependencies:
"@popperjs/core": ^2.10.2
checksum: 301b5ed872efba061104cf22ac93568e3837867fb5527ab9326a51510fb752bd4883e1d488225c8be72f86d9d3a55ef5b166aa7fa62c2fdd077c3f05b65752f8
languageName: node
linkType: hard
"brace-expansion@npm:^1.1.7":
version: 1.1.11
resolution: "brace-expansion@npm:1.1.11"
@@ -8966,6 +8976,13 @@ __metadata:
languageName: node
linkType: hard
"date-fns@npm:2.22.1":
version: 2.22.1
resolution: "date-fns@npm:2.22.1"
checksum: 7ff97cd605af50c02f341687c2cafd218839a1aace67965374989855a13f76dc4fe52e0e38c343c1ad1f8399787cb6839a0b14a669c44b30550c287300b1bb50
languageName: node
linkType: hard
"date-fns@npm:^2.22.1":
version: 2.28.0
resolution: "date-fns@npm:2.28.0"